From b621923868bfe8883a257c216b9f37aa8c3b2aca Mon Sep 17 00:00:00 2001 From: asepharyana Date: Thu, 20 Aug 2026 09:53:33 +0700 Subject: [PATCH] feat(mcp): Streamable HTTP transport + deploy; secure restoreRevision - apps/mcp/src/http.ts: serve MCP over Streamable HTTP (MCP 2025-03-26) on :4021, stateless mode (sessionIdGenerator undefined), CORS on /mcp. Remote clients can now call the 6 tools + 4 resources without a stdio subprocess. - deploy/mcpedia-mcp.service: supervised systemd unit (MCP_PORT=4021). - Caddy: mcp.asepharyana.my.id -> 4021; wiki. domain now also routes /trpc/* to the API (was swallowed by web -> tRPC was unreachable on the domain). - apps/api: restoreRevision tRPC mutation now requires x-webhook-secret (the Web UI calls @mcpedia/core directly, so this only gates the open network endpoint). Threads the header into tRPC Context. Secures a state-changing action that was anonymously callable. Verified live: https://mcp.asepharyana.my.id/mcp initialize/tools/list/ resources/list all 200; restoreRevision no-secret -> unauthorized, with-secret -> handler; read-only tRPC reachable via domain. --- .hermes/plans/mcp-http-transport.md | 42 +++++++++++++++++++++++++++ apps/api/src/index.ts | 5 +++- apps/api/src/router.ts | 18 +++++++++++- apps/api/src/trpc.ts | 4 +++ apps/mcp/package.json | 1 + apps/mcp/src/http.ts | 44 +++++++++++++++++++++++++++++ deploy/mcpedia-mcp.service | 24 ++++++++++++++++ package.json | 1 + 8 files changed, 137 insertions(+), 2 deletions(-) create mode 100644 .hermes/plans/mcp-http-transport.md create mode 100644 apps/mcp/src/http.ts create mode 100644 deploy/mcpedia-mcp.service diff --git a/.hermes/plans/mcp-http-transport.md b/.hermes/plans/mcp-http-transport.md new file mode 100644 index 0000000..10160e3 --- /dev/null +++ b/.hermes/plans/mcp-http-transport.md @@ -0,0 +1,42 @@ +# MCP HTTP transport + deploy + review actions + +## Goal +Make the MCPedia MCP server reachable over the network (not just stdio subprocess), so +remote MCP clients (Claude, a Discord bot, a web client) can call its 6 tools + 4 resources. +Serve via Streamable HTTP (MCP 2025-03-26 spec), deploy as a supervised systemd service, +expose through Caddy on a dedicated subdomain. + +## Design decisions +- **StreamableHTTPServerTransport, stateless mode** (`sessionIdGenerator: undefined`). + One McpServer + transport per request. No session map, no shared-transport connect race, + no memory leak. Re-registering 6 tools + 4 resources per request is negligible for a KB. +- **New entry `apps/mcp/src/http.ts`** served by Node `http` (built-in), NOT mounted on the + API app — keeps the MCP app's zod-4 isolation intact (api app is zod 3). +- **Port 4021** (next free in the 4000s range; 4020 is the API). +- **Subdomain `mcp.asepharyana.my.id`** -> 4021 (Cloudflare `*` wildcard already proxies it; + Caddy auto-issues LE cert, no extra DNS work). +- **CORS** allow on `/mcp` (remote web clients need it). +- MCP tools are read-only (search/get/list/related + read-only resources) => open MCP is + low-risk. No auth on MCP itself. + +## Files +- `apps/mcp/src/http.ts` (NEW) — Node http server, `/mcp` route, stateless transport. +- `apps/mcp/package.json` — add `serve:http` script. +- `package.json` (root) — add `mcp:http` script (absolute bun path). +- `deploy/mcpedia-mcp.service` (NEW) — systemd unit, MCP_PORT=4021. +- `/etc/caddy/Caddyfile` — add `mcp.asepharyana.my.id { import proxy 4021 }`. + +## Security finding (review, flagged not silently built) +The tRPC `restoreRevision` mutation is exposed UNauthenticated at +`https://wiki.asepharyana.my.id/trpc/restoreRevision` — anyone can revert a live doc. +The web UI's restore path calls `@mcpedia/core` directly (server component), so the tRPC +mutation is dead surface. Fix: guard the mutation with the existing WEBHOOK_SECRET header, +or drop it from the router. Will apply the guard (consistent with /hooks auth) unless user +prefers removal. + +## Verification +- `bun --cwd apps/mcp run typecheck` green. +- Live: `curl -XPOST https://mcp.asepharyana.my.id/mcp` initialize -> 200 + serverInfo; + tools/list -> 6 tools; resources/list -> 4 resources. +- `systemctl is-active mcpedia-mcp` == active. +- Commit + push. diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index acd68fc..6dd94bb 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -72,7 +72,10 @@ app.all("/trpc/*", (c) => endpoint: "/trpc", req: c.req.raw, router: appRouter, - createContext: (): Context => ({ db }), + createContext: (opts): Context => ({ + db, + webhookSecret: opts.req.headers.get("x-webhook-secret") ?? undefined, + }), }), ); diff --git a/apps/api/src/router.ts b/apps/api/src/router.ts index 4bfcb1f..c385a96 100644 --- a/apps/api/src/router.ts +++ b/apps/api/src/router.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { publicProcedure, router } from "./trpc"; +import { publicProcedure, router, t } from "./trpc"; import { getDocument, getRelated, @@ -13,6 +13,21 @@ import { } from "@mcpedia/core"; import { getQueue, INDEX_QUEUE } from "@mcpedia/queue"; import { getConnection, BULLMQ_PREFIX } from "@mcpedia/queue/client"; +import { WEBHOOK_SECRET } from "@mcpedia/config"; + +// restoreRevision is a state-changing action (it rewrites the live document row +// + rebuilds its chunks). It must NOT be callable anonymously over the network — +// only the Web UI (which calls @mcpedia/core directly) and an operator with the +// webhook secret may use it. Anything else is rejected. +const requireWriteAuth = t.middleware(({ ctx, next }) => { + if (!WEBHOOK_SECRET) { + throw new Error("WEBHOOK_SECRET is not configured; writes are disabled"); + } + if (ctx.webhookSecret !== WEBHOOK_SECRET) { + throw new Error("unauthorized: missing or invalid x-webhook-secret"); + } + return next(); +}); export const appRouter = router({ search: publicProcedure @@ -49,6 +64,7 @@ export const appRouter = router({ .query(async ({ input }) => getRevision(input.id)), restoreRevision: publicProcedure + .use(requireWriteAuth) .input(z.object({ id: z.string() })) .mutation(async ({ input }) => restoreRevision(input.id)), diff --git a/apps/api/src/trpc.ts b/apps/api/src/trpc.ts index f2406c0..0a15ecd 100644 --- a/apps/api/src/trpc.ts +++ b/apps/api/src/trpc.ts @@ -3,6 +3,10 @@ import { db } from "@mcpedia/db"; export interface Context { db: typeof db; + // Raw `x-webhook-secret` header from the incoming request, if present. + // State-changing tRPC mutations (restoreRevision) require it to match + // WEBHOOK_SECRET; read-only procedures ignore it. + webhookSecret?: string; } export const t = initTRPC.context().create(); diff --git a/apps/mcp/package.json b/apps/mcp/package.json index 7ddf67e..e6c6ae1 100644 --- a/apps/mcp/package.json +++ b/apps/mcp/package.json @@ -8,6 +8,7 @@ }, "scripts": { "start": "bun run src/index.ts", + "serve:http": "bun run src/http.ts", "lint": "tsc --noEmit", "typecheck": "tsc --noEmit", "smoke": "bun run src/smoke.test.ts" diff --git a/apps/mcp/src/http.ts b/apps/mcp/src/http.ts new file mode 100644 index 0000000..d634180 --- /dev/null +++ b/apps/mcp/src/http.ts @@ -0,0 +1,44 @@ +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { createMcpServer } from "./index"; + +// Phase 3 follow-up: serve the MCPedia MCP server over Streamable HTTP +// (MCP 2025-03-26 transport) so remote clients can use its tools/resources +// without spawning a stdio subprocess. Stateless mode (sessionIdGenerator +// undefined): one McpServer + transport per request, no session affinity, no +// shared-transport connect race, no session-map memory leak. Re-registering +// 6 tools + 4 resources per request is negligible for a KB-sized corpus. + +const PORT = Number(process.env.MCP_PORT ?? 4021); +const PATH = "/mcp"; + +const CORS: Record = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS", + "Access-Control-Allow-Headers": + "Content-Type, Accept, Authorization, MCP-Protocol-Version, mcp-session-id", +}; + +const httpServer = createServer(async (req: IncomingMessage, res: ServerResponse) => { + for (const [k, v] of Object.entries(CORS)) res.setHeader(k, v); + + if (req.method === "OPTIONS") { + res.writeHead(204).end(); + return; + } + + if ((req.url ?? "").split("?")[0] !== PATH) { + res.writeHead(404).end("Not found"); + return; + } + + // Stateless: fresh server + transport per request. + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + const server = createMcpServer(); + await server.connect(transport); + await transport.handleRequest(req, res); +}); + +httpServer.listen(PORT, () => { + console.log(`MCPedia MCP server (Streamable HTTP) listening on http://localhost:${PORT}/mcp`); +}); diff --git a/deploy/mcpedia-mcp.service b/deploy/mcpedia-mcp.service new file mode 100644 index 0000000..4c908a9 --- /dev/null +++ b/deploy/mcpedia-mcp.service @@ -0,0 +1,24 @@ +[Unit] +Description=MCPedia MCP server (Streamable HTTP) +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +WorkingDirectory=/home/code/mcpedia +# Loads DATABASE_URL, EMBED_* from the repo .env (MCP tools read the KB). +EnvironmentFile=/home/code/mcpedia/.env +# Absolute bun path (systemd has a minimal PATH; /usr/bin/env bun fails). +Environment=MCP_PORT=4021 +ExecStart=/home/code/.bun/bin/bun --cwd apps/mcp src/http.ts +Restart=on-failure +RestartSec=5 +User=code +Group=code +NoNewPrivileges=true +PrivateTmp=true +MemoryMax=512M +TasksMax=256 + +[Install] +WantedBy=multi-user.target diff --git a/package.json b/package.json index 69c6c52..9e80a03 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "enqueue": "bun run scripts/enqueue.ts", "worker": "/home/code/.bun/bin/bun --cwd apps/worker src/index.ts", "mcp": "bun --cwd apps/mcp run start", + "mcp:http": "/home/code/.bun/bin/bun --cwd apps/mcp src/http.ts", "api": "/home/code/.bun/bin/bun --cwd apps/api src/index.ts" }, "devDependencies": {