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.
This commit is contained in:
asepharyana
2026-08-20 09:53:33 +07:00
parent ec0ab4dbb3
commit b621923868
8 changed files with 137 additions and 2 deletions
+4 -1
View File
@@ -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,
}),
}),
);
+17 -1
View File
@@ -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)),
+4
View File
@@ -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<Context>().create();
+1
View File
@@ -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"
+44
View File
@@ -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<string, string> = {
"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`);
});