diff --git a/.changeset/mcp-http-docker.md b/.changeset/mcp-http-docker.md new file mode 100644 index 0000000..073f2bf --- /dev/null +++ b/.changeset/mcp-http-docker.md @@ -0,0 +1,5 @@ +--- +"@typesensekit/mcp": patch +--- + +Add a stateless Streamable HTTP MCP entrypoint plus Docker and end-to-end assistant search documentation. diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..820980d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +node_modules +dist +coverage +.git +.turbo +.vite +.env +.env.* +*.log +*.tgz diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9e837de --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +FROM node:24-slim + +WORKDIR /app + +RUN corepack enable +ENV CI=true + +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.base.json ./ +COPY packages ./packages + +RUN pnpm install --frozen-lockfile \ + && pnpm --filter @typesensekit/mcp build + +ENV TYPESENSEKIT_MCP_PORT=3000 +ENV TYPESENSEKIT_READ_ONLY=true +EXPOSE 3000 + +USER node + +CMD ["node", "packages/mcp/dist/http.js"] diff --git a/README.md b/README.md index 6e32d7c..56c014c 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,11 @@ tsk skills hermes For scoped key examples, production guidance, and the compatibility matrix, read [`docs/mcp-security.md`](./docs/mcp-security.md). +For Streamable HTTP, Docker, and deployment examples, read +[`docs/mcp-http-docker.md`](./docs/mcp-http-docker.md). For an end-to-end +assistant search flow with citations, read +[`examples/assistant-search-citations.md`](./examples/assistant-search-citations.md). + The MCP server also exposes resources: | Resource | Purpose | diff --git a/docs/mcp-http-docker.md b/docs/mcp-http-docker.md new file mode 100644 index 0000000..f3f32b5 --- /dev/null +++ b/docs/mcp-http-docker.md @@ -0,0 +1,71 @@ +# MCP HTTP and Docker + +TypesenseKit ships two MCP entrypoints: + +- `typesensekit-mcp` for local stdio clients. +- `typesensekit-mcp-http` for stateless Streamable HTTP deployments. + +## Run HTTP Locally + +```sh +TYPESENSE_URL=http://localhost:8108 \ +TYPESENSE_API_KEY=xyz \ +TYPESENSEKIT_MCP_PORT=3000 \ +pnpm --filter @typesensekit/mcp exec typesensekit-mcp-http +``` + +The HTTP endpoint defaults to `POST /mcp`. A lightweight health check is +available at `GET /healthz`. + +Optional environment: + +```sh +TYPESENSEKIT_MCP_PATH=/mcp +TYPESENSEKIT_READ_ONLY=true +TYPESENSE_CONNECTION_TIMEOUT_SECONDS=5 +``` + +## Docker + +Build the image: + +```sh +docker build -t typesensekit-mcp . +``` + +Run the image: + +```sh +docker run --rm -p 3000:3000 \ + -e TYPESENSE_URL=https://your-cluster.typesense.net \ + -e TYPESENSE_API_KEY=your-scoped-api-key \ + -e TYPESENSEKIT_READ_ONLY=true \ + typesensekit-mcp +``` + +## Minimal Deploy Example + +Deploy the container behind a platform or reverse proxy that provides TLS, +request logging, and rate limiting: + +```yaml +services: + - name: typesensekit-mcp + type: web + env: docker + dockerfilePath: ./Dockerfile + healthCheckPath: /healthz + envVars: + - key: TYPESENSE_URL + sync: false + - key: TYPESENSE_API_KEY + sync: false + - key: TYPESENSEKIT_READ_ONLY + value: "true" + - key: TYPESENSE_CONNECTION_TIMEOUT_SECONDS + value: "5" +``` + +Keep the service private unless the client performs authentication in front of +it. TypesenseKit's HTTP entrypoint does not add its own authentication layer; it +relies on the deployment boundary and the scoped Typesense API key. diff --git a/examples/assistant-search-citations.md b/examples/assistant-search-citations.md new file mode 100644 index 0000000..a7d3eb8 --- /dev/null +++ b/examples/assistant-search-citations.md @@ -0,0 +1,67 @@ +# Assistant Search With Citations + +This example shows the intended assistant flow: connect an MCP client, run a +search, then cite Typesense document fields in the final answer. + +## 1. Start TypesenseKit + +```sh +TYPESENSE_URL=https://your-cluster.typesense.net \ +TYPESENSE_API_KEY=your-scoped-api-key \ +npx -y @typesensekit/mcp +``` + +## 2. Configure The MCP Client + +```json +{ + "mcpServers": { + "typesensekit": { + "command": "npx", + "args": ["-y", "@typesensekit/mcp"], + "env": { + "TYPESENSE_URL": "https://your-cluster.typesense.net", + "TYPESENSE_API_KEY": "your-scoped-api-key", + "TYPESENSEKIT_READ_ONLY": "true" + } + } + } +} +``` + +## 3. Ask The Assistant + +Prompt: + +```text +Search the products collection for "lounge chair". Use documents.search with +query_by set to "title,description" and cite each recommendation with its +document id and URL. +``` + +Tool call: + +```json +{ + "collection": "products", + "params": { + "q": "lounge chair", + "query_by": "title,description", + "include_fields": "id,title,url,description", + "per_page": 3 + } +} +``` + +Answer shape: + +```text +Recommended matches: + +1. Eames-style lounge chair - cited from document prod_123, https://example.com/products/prod_123 +2. Walnut reading chair - cited from document prod_456, https://example.com/products/prod_456 +3. Low leather lounge chair - cited from document prod_789, https://example.com/products/prod_789 +``` + +For stricter citations, require the assistant to quote only fields returned by +Typesense and include each document `id` or canonical `url`. diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 333a0e3..9e55b9b 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -9,5 +9,11 @@ TYPESENSE_URL=http://localhost:8108 TYPESENSE_API_KEY=xyz pnpm dlx @typesensekit Set `TYPESENSEKIT_READ_ONLY=false` to expose write/delete/admin tools. +Run the stateless Streamable HTTP server: + +```sh +TYPESENSE_URL=http://localhost:8108 TYPESENSE_API_KEY=xyz pnpm dlx --package @typesensekit/mcp typesensekit-mcp-http +``` + See the root README for client configuration, MCP resources, security guidance, and operation coverage. diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 2e3f027..881e3ed 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -5,7 +5,8 @@ "main": "dist/server.js", "types": "dist/server.d.ts", "bin": { - "typesensekit-mcp": "dist/cli.js" + "typesensekit-mcp": "dist/cli.js", + "typesensekit-mcp-http": "dist/http.js" }, "exports": { ".": { diff --git a/packages/mcp/src/http.ts b/packages/mcp/src/http.ts new file mode 100644 index 0000000..bf6e540 --- /dev/null +++ b/packages/mcp/src/http.ts @@ -0,0 +1,130 @@ +import { + createServer, + type IncomingMessage, + type ServerResponse, +} from "node:http"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { createTypesenseMcpServer } from "./server.js"; + +const DEFAULT_PORT = 3000; +const DEFAULT_PATH = "/mcp"; + +function readPort(): number { + const value = process.env.TYPESENSEKIT_MCP_PORT ?? process.env.PORT; + if (!value) return DEFAULT_PORT; + + const port = Number(value); + if (!Number.isInteger(port) || port <= 0) { + throw new Error(`Invalid MCP HTTP port: ${value}`); + } + return port; +} + +function readPath(): string { + const path = process.env.TYPESENSEKIT_MCP_PATH ?? DEFAULT_PATH; + return path.startsWith("/") ? path : `/${path}`; +} + +function sendJson( + res: ServerResponse, + statusCode: number, + body: Record, +) { + res.writeHead(statusCode, { "content-type": "application/json" }); + res.end(JSON.stringify(body)); +} + +function readBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + let body = ""; + req.setEncoding("utf8"); + req.on("data", (chunk) => { + body += chunk; + }); + req.on("error", reject); + req.on("end", () => { + if (!body) { + resolve(undefined); + return; + } + + try { + resolve(JSON.parse(body)); + } catch (error) { + reject(error); + } + }); + }); +} + +function jsonRpcError(message: string) { + return { + jsonrpc: "2.0", + error: { code: -32603, message }, + id: null, + }; +} + +async function handleMcpRequest(req: IncomingMessage, res: ServerResponse) { + const server = createTypesenseMcpServer(); + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + }); + + try { + const body = await readBody(req); + await server.connect(transport); + await transport.handleRequest(req, res, body); + } finally { + res.on("close", () => { + void transport.close(); + void server.close(); + }); + } +} + +const port = readPort(); +const path = readPath(); + +const httpServer = createServer(async (req, res) => { + const url = new URL( + req.url ?? "/", + `http://${req.headers.host ?? "localhost"}`, + ); + + if (url.pathname === "/healthz") { + sendJson(res, 200, { ok: true }); + return; + } + + if (url.pathname !== path) { + sendJson(res, 404, jsonRpcError("Not found")); + return; + } + + if (req.method !== "POST") { + sendJson(res, 405, jsonRpcError("Method not allowed")); + return; + } + + try { + await handleMcpRequest(req, res); + } catch (error) { + console.error("MCP HTTP request failed", error); + if (!res.headersSent) { + sendJson(res, 500, jsonRpcError("Internal server error")); + } + } +}); + +httpServer.listen(port, () => { + console.error( + `TypesenseKit MCP HTTP server listening on ${path} port ${port}`, + ); +}); + +for (const signal of ["SIGINT", "SIGTERM"] as const) { + process.on(signal, () => { + httpServer.close(() => process.exit(0)); + }); +} diff --git a/packages/mcp/tsup.config.ts b/packages/mcp/tsup.config.ts index 62ed030..074b981 100644 --- a/packages/mcp/tsup.config.ts +++ b/packages/mcp/tsup.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from "tsup"; export default defineConfig([ { - entry: { cli: "src/cli.ts" }, + entry: { cli: "src/cli.ts", http: "src/http.ts" }, format: ["esm"], dts: true, clean: true,