diff --git a/README.md b/README.md index 70bb011..73b2ba1 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,8 @@ _Coming soon._ ### Claude Desktop Setup +#### Local + 1. Open Claude Desktop settings 2. Add the following to your configuration: ```json @@ -126,6 +128,53 @@ _Coming soon._ > [!TIP] > You can refer to the [official documentation](https://modelcontextprotocol.io/quickstart/user) for Claude Desktop. +#### Remote +To run an HTTP server, as Claude Desktop doesn't natively support it yet, you'll have to use a gateway: +```json +{ + "mcpServers": { + "algolia-mcp": { + "command": "/npx", + "args": [ + "-y", + "mcp-remote", + "http://localhost:4243/mcp" + ] + } + } +} +``` +> [!INFO] +> Our HTTP server leverages the [Streamable HTTP transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http). +> It is also backward compatible with the [SSE transport](https://modelcontextprotocol.io/docs/concepts/transports#server-sent-events-sse). + +### OpenAI Playground (SSE server) +![alt text](assets/openai_playgroud_add_mcp.png) + +Run the Algolia MCP server in SSE mode +1. Set up the project +2. Build the server +3. Authenticate with Algolia +4. Launch the server in SSE mode (default: on port 4243) + +```sh +cd dist +./algolia-mcp start-server --transport http +``` +5. Make the SSE server accessible from the internet using ngrok (installation guide) +```sh +ngrok http 4243 +``` + +Add the SSE server to the Playground +1. Go to https://platform.openai.com/playground +2. Select Tools > “MCP Server” +3. Add the `https://[random].ngrok-free.app` obtained after running ngrok +4. Select “None” for authentication + +### n8n or any other MCP client using an SSE server +Follow the same instructions as for the OpenAI Playground. + ### CLI Options #### Available Commands @@ -134,14 +183,14 @@ _Coming soon._ Usage: algolia-mcp [options] [command] Options: - -h, --help display help for command + -h, --help Display help for command Commands: - start-server [options] Starts the Algolia MCP server - authenticate Authenticate with Algolia - logout Remove all stored credentials - list-tools List all available tools - help [command] display help for command + start-server [options] Starts the Algolia MCP server () + authenticate Authenticate with Algolia + logout Remove all stored credentials + list-tools List all available tools + help [command] Display help for command ``` #### Server Options @@ -154,7 +203,8 @@ Starts the Algolia MCP server Options: -t, --allow-tools Comma separated list of tool ids (default: getUserInfo,getApplications,...,listIndices) --credentials Application ID and associated API key to use. Optional: the MCP will authenticate you if unspecified, giving you access to all your applications. - -h, --help display help for command + --transport [stdio|http] Transport type (default:stdio) + -h, --help Display help for command ``` ## 🛠 Development diff --git a/assets/openai_playgroud_add_mcp.png b/assets/openai_playgroud_add_mcp.png new file mode 100644 index 0000000..c1b8cb8 Binary files /dev/null and b/assets/openai_playgroud_add_mcp.png differ diff --git a/package-lock.json b/package-lock.json index 70d9966..78f30ec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,12 +13,16 @@ "ajv": "^8.17.1", "algoliasearch": "^5.23.4", "commander": "^13.1.0", + "cors": "^2.8.5", + "express": "^5.1.0", "open": "^10.1.0", "zod": "^3.24.4" }, "devDependencies": { "@eslint/js": "^9.24.0", "@modelcontextprotocol/inspector": "^0.9.0", + "@types/cors": "^2.8.17", + "@types/express": "^5.0.1", "@types/node": "^22.14.0", "@vitest/coverage-v8": "^3.1.1", "bun": "^1.2.9", @@ -3114,6 +3118,27 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/body-parser": { + "version": "1.19.5", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", + "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/cookie": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", @@ -3121,6 +3146,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/cors": { + "version": "2.8.17", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.17.tgz", + "integrity": "sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", @@ -3128,6 +3163,38 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/express": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.1.tgz", + "integrity": "sha512-UZUw8vjpWFXuDnjFTh7/5c2TWDlQqeXHi6hcN7F2XSVT5P+WmUnnbFS3KA6Jnc6IsEqI2qCVu2bK0R0J4A8ZQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "*" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.6.tgz", + "integrity": "sha512-3xhRnjJPkULekpSzgtoNYYcTWgEZkp4myc+Saevii5JPnHNvHMRlBSHDbs7Bh1iPPoVTERHEZXyhyLbMEsExsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", + "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -3135,6 +3202,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.14.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.0.tgz", @@ -3145,6 +3219,43 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/qs": { + "version": "6.9.18", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.18.tgz", + "integrity": "sha512-kK7dgTYDyGqS+e2Q4aK9X3D7q234CIZ1Bv0q/7Z5IwRDoADNU81xXJK/YVyLbLTZCoIwUoDoffFeF+p/eIklAA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", + "integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.7", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz", + "integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "*" + } + }, "node_modules/@types/statuses": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.5.tgz", diff --git a/package.json b/package.json index 17d345c..ae3efee 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "type-check": "tsc --noEmit", "lint": "eslint --ext .ts src", "test": "vitest", - "build": "bun build ./src/app.ts --compile", + "build": "bun build ./src/app.ts --compile --outfile dist/app", "debug": "mcp-inspector npm start" }, "type": "module", @@ -20,12 +20,16 @@ "ajv": "^8.17.1", "algoliasearch": "^5.23.4", "commander": "^13.1.0", + "cors": "^2.8.5", + "express": "^5.1.0", "open": "^10.1.0", "zod": "^3.24.4" }, "devDependencies": { "@eslint/js": "^9.24.0", "@modelcontextprotocol/inspector": "^0.9.0", + "@types/cors": "^2.8.17", + "@types/express": "^5.0.1", "@types/node": "^22.14.0", "@vitest/coverage-v8": "^3.1.1", "bun": "^1.2.9", diff --git a/src/app.ts b/src/app.ts index 02394d4..533b5fd 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,6 +1,5 @@ import { Command } from "commander"; import { type ListToolsOptions } from "./commands/list-tools.ts"; -import { ZodError } from "zod"; const program = new Command("algolia-mcp"); @@ -63,20 +62,6 @@ const ALLOW_TOOLS_OPTIONS_TUPLE = [ DEFAULT_ALLOW_TOOLS, ] as const; -function formatErrorForCli(error: unknown): string { - if (error instanceof ZodError) { - return [...error.errors.map((e) => `- ${e.path.join(".") || ""}: ${e.message}`)].join( - "\n", - ); - } - - if (error instanceof Error) { - return error.message; - } - - return "Unknown error"; -} - program .command("start-server", { isDefault: true }) .description("Starts the Algolia MCP server") @@ -92,13 +77,24 @@ program return { applicationId, apiKey }; }, ) + + .option("--transport [stdio|http]", "Transport type, either `stdio` (default) or `http`", "stdio") .action(async (opts) => { - try { - const { startServer } = await import("./commands/start-server.ts"); - await startServer(opts); - } catch (error) { - console.error(formatErrorForCli(error)); - process.exit(1); + switch (opts.transport) { + case "stdio": { + const { startServer } = await import("./commands/start-server.ts"); + await startServer(opts); + break; + } + case "http": { + console.info('Starting server with HTTP transport support'); + const { startHttpServer } = await import("./commands/start-http-server.ts"); + await startHttpServer(opts); + break; + } + default: + console.error(`Unknown transport type: ${opts.transport}\nAllowed values: stdio, http`); + process.exit(1); } }); diff --git a/src/commands/start-http-server.ts b/src/commands/start-http-server.ts new file mode 100644 index 0000000..e756e7c --- /dev/null +++ b/src/commands/start-http-server.ts @@ -0,0 +1,256 @@ +import express, { type NextFunction, type Request, type Response } from "express"; +import cors from "cors"; +import { randomUUID } from "node:crypto"; +import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; +import { InMemoryEventStore } from "@modelcontextprotocol/sdk/examples/shared/inMemoryEventStore.js"; +import type { StartServerOptions } from "../server/types.ts"; +import { initMCPServer } from "../server/init-server.ts"; +import { CONFIG } from "../config.ts"; + +export async function startHttpServer(opts: StartServerOptions) { + try { + // Create Express application + const app = express(); + app.use(express.json()); + app.use( + cors({ + origin: process.env.ALLOWED_ORIGINS?.split(",") || "*", + methods: ["GET", "POST", "DELETE"], + allowedHeaders: ["Content-Type", "Authorization"], + }), + ); + + // Store transports by session ID + const transports: Map = new Map(); + + // Health check endpoint + app.get("/health", (_, res) => { + res.status(200).json({ + status: "ok", + version: CONFIG.version, + uptime: process.uptime(), + timestamp: new Date().toISOString(), + connections: transports.size, + }); + }); + + //============================================================================= + // STREAMABLE HTTP TRANSPORT (PROTOCOL VERSION 2025-03-26) + //============================================================================= + + // Handle all MCP Streamable HTTP requests (GET, POST, DELETE) on a single endpoint + app.all("/mcp", async (req: Request, res: Response) => { + console.log(`Received ${req.method} request to /mcp`); + + try { + // Check for existing session ID + const sessionId = req.headers["mcp-session-id"] as string | undefined; + let transport: StreamableHTTPServerTransport; + + if (sessionId != null && transports.has(sessionId)) { + // Check if the transport is of the correct type + const existingTransport = transports.get(sessionId); + if (existingTransport instanceof StreamableHTTPServerTransport) { + // Reuse existing transport + transport = existingTransport; + } else { + // Transport exists but is not a StreamableHTTPServerTransport (could be SSEServerTransport) + res.status(400).json({ + jsonrpc: "2.0", + error: { + code: -32000, + message: "Bad Request: Session exists but uses a different transport protocol", + }, + id: null, + }); + return; + } + } else if (sessionId == null && req.method === "POST" && isInitializeRequest(req.body)) { + const eventStore = new InMemoryEventStore(); + transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + eventStore, // Enable resumability + onsessioninitialized: (sessionId) => { + // Store the transport by session ID when session is initialized + console.log(`StreamableHTTP session initialized with ID: ${sessionId}`); + transports.set(sessionId, transport); + }, + }); + + // Set up onclose handler to clean up transport when closed + transport.onclose = () => { + if (transport.sessionId != null && transports.has(transport.sessionId)) { + console.log(`Transport closed for HTTP session ${transport.sessionId}, removing from transports map`); + transports.delete(transport.sessionId); + } + }; + + // Connect the transport to the MCP server + const server = await initMCPServer(opts); + await server.connect(transport); + } else { + // Invalid request - no session ID or not initialization request + res.status(400).json({ + jsonrpc: "2.0", + error: { + code: -32000, + message: "Bad Request: No valid session ID provided", + }, + id: null, + }); + return; + } + + // Handle the request with the transport + await transport.handleRequest(req, res, req.body); + } catch (error) { + console.error("Error handling MCP request:", error); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: "2.0", + error: { + code: -32603, + message: "Internal server error", + }, + id: null, + }); + } + } + }); + + //============================================================================= + // DEPRECATED HTTP+SSE TRANSPORT (PROTOCOL VERSION 2024-11-05) + //============================================================================= + + app.get("/sse", async (_: Request, res: Response) => { + console.log("Received GET request to /sse (deprecated SSE transport)"); + const transport = new SSEServerTransport("/messages", res); + transports.set(transport.sessionId, transport); + res.on("close", () => { + if (transport.sessionId != null && transports.has(transport.sessionId)) { + console.log(`Transport closed for SSE session ${transport.sessionId}, removing from transports map`); + transports.delete(transport.sessionId); + } + }); + const server = await initMCPServer(opts); + await server.connect(transport); + }); + + app.post("/messages", async (req: Request, res: Response) => { + const sessionId = req.query.sessionId as string; + let transport: SSEServerTransport; + const existingTransport = transports.get(sessionId); + if (existingTransport instanceof SSEServerTransport) { + // Reuse existing transport + transport = existingTransport; + } else { + // Transport exists but is not a SSEServerTransport (could be StreamableHTTPServerTransport) + res.status(400).json({ + jsonrpc: "2.0", + error: { + code: -32000, + message: "Bad Request: Session exists but uses a different transport protocol", + }, + id: null, + }); + return; + } + if (transport != null) { + await transport.handlePostMessage(req, res, req.body); + } else { + res.status(400).send({ + jsonrpc: "2.0", + error: { + code: -32000, + message: "Bad Request: No transport found for sessionId", + }, + id: null, + }); + } + }); + + //============================================================================= + // END OF SERVER SETUP + //============================================================================= + + // Error handling + app.use((err: Error, _: Request, res: Response, __: NextFunction) => { + console.error("Unhandled exception: ", err.stack); + res.status(500).json({ + jsonrpc: "2.0", + error: { + code: -32603, + message: "Internal Server Error", + }, + id: null, + }); + }); + + // Graceful shutdown of all connections + async function closeAllConnections() { + for (const [sessionId, transport] of transports.entries()) { + try { + console.log(`Closing transport for session ${sessionId}`); + await transport.send({ + jsonrpc: "2.0", + method: "notifications/shutdown", + }); + await transport.close(); + } catch (error) { + console.error(`Error closing transport for session ${sessionId}: `, error); + } + } + transports.clear(); + console.log("All connections closed"); + } + + // Graceful shutdown + process.on("SIGTERM", async () => { + console.log(`Graceful shutdown initiated at ${new Date().toISOString()}`); + await closeAllConnections(); + server.close(() => { + console.log(`Graceful shutdown complete at ${new Date().toISOString()}`); + process.exit(0); + }); + }); + + // Handle server shutdown + process.on("SIGINT", async () => { + console.log("Shutting down server..."); + await closeAllConnections(); + + process.exit(0); + }); + + // Start the server + const PORT = 4243; + const server = app.listen(PORT, () => { + console.log(`HTTP MCP server listening on port ${PORT}`); + console.log(` + ============================================== + SUPPORTED TRANSPORT OPTIONS: + + 1. Streamable Http(Protocol version: 2025-03-26) + Endpoint: /mcp + Methods: GET, POST, DELETE + Usage: + - Initialize with POST to /mcp + - Establish SSE stream with GET to /mcp + - Send requests with POST to /mcp + - Terminate session with DELETE to /mcp + + 2. Http + SSE (Protocol version: 2024-11-05) + Endpoints: /sse (GET) and /messages (POST) + Usage: + - Establish SSE stream with GET to /sse + - Send requests with POST to /messages?sessionId= + ============================================== + `); + }); + } catch (err) { + console.error("Error starting server:", err); + process.exit(1); + } +} diff --git a/src/commands/start-server.ts b/src/commands/start-server.ts index 5b9331d..4135edb 100644 --- a/src/commands/start-server.ts +++ b/src/commands/start-server.ts @@ -1,227 +1,31 @@ #!/usr/bin/env -S node --experimental-strip-types -import { authenticate } from "../authentication.ts"; -import { AppStateManager } from "../appState.ts"; -import { DashboardApi } from "../DashboardApi.ts"; -import { - operationId as GetUserInfoOperationId, - registerGetUserInfo, -} from "../tools/registerGetUserInfo.ts"; -import { - operationId as GetApplicationsOperationId, - registerGetApplications, -} from "../tools/registerGetApplications.ts"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; -import type { - ProcessCallbackArguments, - ProcessInputSchema, - RequestMiddleware, -} from "../tools/registerOpenApi.ts"; -import { registerOpenApiTools } from "../tools/registerOpenApi.ts"; -import { CONFIG } from "../config.ts"; -import { - ABTestingSpec, - AnalyticsSpec, - CollectionsSpec, - IngestionSpec, - MonitoringSpec, - QuerySuggestionsSpec, - RecommendSpec, - SearchSpec, - UsageSpec, -} from "../openApi.ts"; -import { CliFilteringOptionsSchema, getToolFilter, isToolAllowed } from "../toolFilters.ts"; -import { - operationId as SetAttributesForFacetingOperationId, - registerSetAttributesForFaceting, -} from "../tools/registerSetAttributesForFaceting.ts"; -import { - registerSetCustomRanking, - operationId as SetCustomRankingOperationId, -} from "../tools/registerSetCustomRanking.ts"; - -import { CustomMcpServer } from "../CustomMcpServer.ts"; -import { z } from "zod"; - -export const StartServerOptionsSchema = CliFilteringOptionsSchema.extend({ - credentials: z - .object({ - applicationId: z.string(), - apiKey: z.string(), - }) - .optional(), -}); - -type StartServerOptions = z.infer; - -function makeRegionRequestMiddleware(dashboardApi: DashboardApi): RequestMiddleware { - return async ({ request, params }) => { - const application = await dashboardApi.getApplication(params.applicationId); - const region = application.data.attributes.log_region === "de" ? "eu" : "us"; - - const url = new URL(request.url); - const regionFromUrl = url.hostname.match(/data\.(.+)\.algolia.com/)?.[0]; - - if (regionFromUrl !== region) { - console.error("Had to adjust region from", regionFromUrl, "to", region); - url.hostname = `data.${region}.algolia.com`; - return new Request(url, request.clone()); - } - - return request; - }; -} - -export async function startServer(options: StartServerOptions): Promise { - const { credentials, ...opts } = StartServerOptionsSchema.parse(options); - const toolFilter = getToolFilter(opts); - - const server = new CustomMcpServer({ - name: "algolia", - version: CONFIG.version, - capabilities: { - resources: {}, - tools: {}, - }, - }); - - const regionHotFixMiddlewares: RequestMiddleware[] = []; - let processCallbackArguments: ProcessCallbackArguments; - const processInputSchema: ProcessInputSchema = (inputSchema) => { - // If we got it from the options, we don't need it from the AI - if (credentials && inputSchema.properties?.applicationId) { - delete inputSchema.properties.applicationId; - - if (Array.isArray(inputSchema.required)) { - inputSchema.required = inputSchema.required.filter((item) => item !== "applicationId"); - } - } - - return inputSchema; - }; - - if (credentials) { - processCallbackArguments = async (params, securityKeys) => { - const result = { ...params }; - - if (securityKeys.has("applicationId")) { - result.applicationId = credentials.applicationId; - } - - if (securityKeys.has("apiKey")) { - result.apiKey = credentials.apiKey; - } - - return result; - }; - } else { - const appState = await AppStateManager.load(); - - if (!appState.get("accessToken")) { - const token = await authenticate(); - - await appState.update({ - accessToken: token.access_token, - refreshToken: token.refresh_token, - }); - } - - const dashboardApi = new DashboardApi({ baseUrl: CONFIG.dashboardApiBaseUrl, appState }); - - processCallbackArguments = async (params, securityKeys) => { - const result = { ...params }; - - if (securityKeys.has("apiKey")) { - result.apiKey = await dashboardApi.getApiKey(params.applicationId); - } - - return result; - }; - - regionHotFixMiddlewares.push(makeRegionRequestMiddleware(dashboardApi)); - - // Dashboard API Tools - if (isToolAllowed(GetUserInfoOperationId, toolFilter)) { - registerGetUserInfo(server, dashboardApi); - } - - if (isToolAllowed(GetApplicationsOperationId, toolFilter)) { - registerGetApplications(server, dashboardApi); - } - - // TODO: Make it available when with applicationId+apiKey mode too - if (isToolAllowed(SetAttributesForFacetingOperationId, toolFilter)) { - registerSetAttributesForFaceting(server, dashboardApi); - } - - if (isToolAllowed(SetCustomRankingOperationId, toolFilter)) { - registerSetCustomRanking(server, dashboardApi); - } +import { initMCPServer } from "../server/init-server.ts"; +import type { StartServerOptions } from "../server/types.ts"; +import { ZodError } from "zod"; + +function formatErrorForCli(error: unknown): string { + if (error instanceof ZodError) { + return [...error.errors.map((e) => `- ${e.path.join(".") || ""}: ${e.message}`)].join( + "\n", + ); } - for (const openApiSpec of [ - SearchSpec, - AnalyticsSpec, - RecommendSpec, - ABTestingSpec, - MonitoringSpec, - CollectionsSpec, - QuerySuggestionsSpec, - ]) { - registerOpenApiTools({ - server, - processInputSchema, - processCallbackArguments, - openApiSpec, - toolFilter, - }); + if (error instanceof Error) { + return error.message; } - // Usage - registerOpenApiTools({ - server, - processInputSchema, - processCallbackArguments, - openApiSpec: UsageSpec, - toolFilter, - requestMiddlewares: [ - // The Usage API expects `name` parameter as multiple values - // rather than comma-separated. - async ({ request }) => { - const url = new URL(request.url); - const nameParams = url.searchParams.get("name"); - - if (!nameParams) { - return new Request(url, request.clone()); - } - - const nameValues = nameParams.split(","); - - url.searchParams.delete("name"); - - nameValues.forEach((value) => { - url.searchParams.append("name", value); - }); - - return new Request(url, request.clone()); - }, - ], - }); - - // Ingestion API Tools - registerOpenApiTools({ - server, - processInputSchema, - processCallbackArguments, - openApiSpec: IngestionSpec, - toolFilter, - requestMiddlewares: [ - // Dirty fix for Claud hallucinating regions - ...regionHotFixMiddlewares, - ], - }); + return "Unknown error"; +} - const transport = new StdioServerTransport(); - await server.connect(transport); - return server; +export async function startServer(options: StartServerOptions) { + try { + const server = await initMCPServer(options); + const transport = new StdioServerTransport(); + await server.connect(transport); + } catch (error) { + console.error(formatErrorForCli(error)); + process.exit(1); + } } diff --git a/src/server/init-server.ts b/src/server/init-server.ts new file mode 100644 index 0000000..d85e83a --- /dev/null +++ b/src/server/init-server.ts @@ -0,0 +1,209 @@ +import { AppStateManager } from "../appState.ts"; +import { authenticate } from "../authentication.ts"; +import { DashboardApi } from "../DashboardApi.ts"; +import { CONFIG } from "../config.ts"; +import { getToolFilter, isToolAllowed } from "../toolFilters.ts"; +import { + operationId as GetUserInfoOperationId, + registerGetUserInfo, +} from "../tools/registerGetUserInfo.ts"; +import { + operationId as GetApplicationsOperationId, + registerGetApplications, +} from "../tools/registerGetApplications.ts"; +import { + type ProcessCallbackArguments, type ProcessInputSchema, + registerOpenApiTools, + type RequestMiddleware +} from "../tools/registerOpenApi.ts"; +import { + ABTestingSpec, + AnalyticsSpec, + CollectionsSpec, + IngestionSpec, + MonitoringSpec, + QuerySuggestionsSpec, + RecommendSpec, + SearchSpec, + UsageSpec, +} from "../openApi.ts"; +import { + operationId as SetAttributesForFacetingOperationId, + registerSetAttributesForFaceting, +} from "../tools/registerSetAttributesForFaceting.ts"; +import { + operationId as SetCustomRankingOperationId, + registerSetCustomRanking, +} from "../tools/registerSetCustomRanking.ts"; +import { CustomMcpServer } from "../CustomMcpServer.ts"; +import { type StartServerOptions, StartServerOptionsSchema } from "./types.ts"; + +function makeRegionRequestMiddleware(dashboardApi: DashboardApi): RequestMiddleware { + return async ({ request, params }) => { + const application = await dashboardApi.getApplication(params.applicationId); + const region = application.data.attributes.log_region === "de" ? "eu" : "us"; + + const url = new URL(request.url); + const regionFromUrl = url.hostname.match(/data\.(.+)\.algolia.com/)?.[0]; + + if (regionFromUrl !== region) { + console.error("Had to adjust region from", regionFromUrl, "to", region); + url.hostname = `data.${region}.algolia.com`; + return new Request(url, request.clone()); + } + + return request; + }; +} + +export async function initMCPServer(options: StartServerOptions): Promise { + const { credentials, ...opts } = StartServerOptionsSchema.parse(options); + const toolFilter = getToolFilter(opts); + + const server = new CustomMcpServer({ + name: "algolia", + version: CONFIG.version, + capabilities: { + resources: {}, + tools: {}, + }, + }); + + const regionHotFixMiddlewares: RequestMiddleware[] = []; + let processCallbackArguments: ProcessCallbackArguments; + const processInputSchema: ProcessInputSchema = (inputSchema) => { + // If we got it from the options, we don't need it from the AI + if (credentials && inputSchema.properties?.applicationId) { + delete inputSchema.properties.applicationId; + + if (Array.isArray(inputSchema.required)) { + inputSchema.required = inputSchema.required.filter((item) => item !== "applicationId"); + } + } + + return inputSchema; + }; + + if (credentials) { + processCallbackArguments = async (params, securityKeys) => { + const result = { ...params }; + + if (securityKeys.has("applicationId")) { + result.applicationId = credentials.applicationId; + } + + if (securityKeys.has("apiKey")) { + result.apiKey = credentials.apiKey; + } + + return result; + }; + } else { + const appState = await AppStateManager.load(); + + if (!appState.get("accessToken")) { + const token = await authenticate(); + + await appState.update({ + accessToken: token.access_token, + refreshToken: token.refresh_token, + }); + } + + const dashboardApi = new DashboardApi({ baseUrl: CONFIG.dashboardApiBaseUrl, appState }); + + processCallbackArguments = async (params, securityKeys) => { + const result = { ...params }; + + if (securityKeys.has("apiKey")) { + result.apiKey = await dashboardApi.getApiKey(params.applicationId); + } + + return result; + }; + + regionHotFixMiddlewares.push(makeRegionRequestMiddleware(dashboardApi)); + + // Dashboard API Tools + if (isToolAllowed(GetUserInfoOperationId, toolFilter)) { + registerGetUserInfo(server, dashboardApi); + } + + if (isToolAllowed(GetApplicationsOperationId, toolFilter)) { + registerGetApplications(server, dashboardApi); + } + + // TODO: Make it available when with applicationId+apiKey mode too + if (isToolAllowed(SetAttributesForFacetingOperationId, toolFilter)) { + registerSetAttributesForFaceting(server, dashboardApi); + } + + if (isToolAllowed(SetCustomRankingOperationId, toolFilter)) { + registerSetCustomRanking(server, dashboardApi); + } + } + + for (const openApiSpec of [ + SearchSpec, + AnalyticsSpec, + RecommendSpec, + ABTestingSpec, + MonitoringSpec, + CollectionsSpec, + QuerySuggestionsSpec, + ]) { + registerOpenApiTools({ + server, + processInputSchema, + processCallbackArguments, + openApiSpec, + toolFilter, + }); + } + + // Usage + registerOpenApiTools({ + server, + processInputSchema, + processCallbackArguments, + openApiSpec: UsageSpec, + toolFilter, + requestMiddlewares: [ + // The Usage API expects `name` parameter as multiple values + // rather than comma-separated. + async ({ request }) => { + const url = new URL(request.url); + const nameParams = url.searchParams.get("name"); + + if (!nameParams) { + return new Request(url, request.clone()); + } + + const nameValues = nameParams.split(","); + + url.searchParams.delete("name"); + + nameValues.forEach((value) => { + url.searchParams.append("name", value); + }); + + return new Request(url, request.clone()); + }, + ], + }); + + // Ingestion API Tools + registerOpenApiTools({ + server, + processInputSchema, + processCallbackArguments, + openApiSpec: IngestionSpec, + toolFilter, + requestMiddlewares: [ + // Dirty fix for Claud hallucinating regions + ...regionHotFixMiddlewares, + ], + }); + + return server; +} diff --git a/src/server/types.ts b/src/server/types.ts new file mode 100644 index 0000000..2d75b38 --- /dev/null +++ b/src/server/types.ts @@ -0,0 +1,13 @@ +import { z } from "zod"; +import { CliFilteringOptionsSchema } from "../toolFilters.ts"; + +export const StartServerOptionsSchema = CliFilteringOptionsSchema.extend({ + credentials: z + .object({ + applicationId: z.string(), + apiKey: z.string(), + }) + .optional(), +}); + +export type StartServerOptions = z.infer; diff --git a/src/toolFilters.ts b/src/toolFilters.ts index 131e7c5..5cc5dd2 100644 --- a/src/toolFilters.ts +++ b/src/toolFilters.ts @@ -3,6 +3,7 @@ import z from "zod"; export const CliFilteringOptionsSchema = z.object({ allowTools: z.array(z.string()).optional(), denyTools: z.array(z.string()).optional(), + transport: z.literal('stdio').or(z.literal('http')).default('stdio') }); export type CliFilteringOptions = z.infer;