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:
@@ -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.
|
||||||
@@ -72,7 +72,10 @@ app.all("/trpc/*", (c) =>
|
|||||||
endpoint: "/trpc",
|
endpoint: "/trpc",
|
||||||
req: c.req.raw,
|
req: c.req.raw,
|
||||||
router: appRouter,
|
router: appRouter,
|
||||||
createContext: (): Context => ({ db }),
|
createContext: (opts): Context => ({
|
||||||
|
db,
|
||||||
|
webhookSecret: opts.req.headers.get("x-webhook-secret") ?? undefined,
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
+17
-1
@@ -1,5 +1,5 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { publicProcedure, router } from "./trpc";
|
import { publicProcedure, router, t } from "./trpc";
|
||||||
import {
|
import {
|
||||||
getDocument,
|
getDocument,
|
||||||
getRelated,
|
getRelated,
|
||||||
@@ -13,6 +13,21 @@ import {
|
|||||||
} from "@mcpedia/core";
|
} from "@mcpedia/core";
|
||||||
import { getQueue, INDEX_QUEUE } from "@mcpedia/queue";
|
import { getQueue, INDEX_QUEUE } from "@mcpedia/queue";
|
||||||
import { getConnection, BULLMQ_PREFIX } from "@mcpedia/queue/client";
|
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({
|
export const appRouter = router({
|
||||||
search: publicProcedure
|
search: publicProcedure
|
||||||
@@ -49,6 +64,7 @@ export const appRouter = router({
|
|||||||
.query(async ({ input }) => getRevision(input.id)),
|
.query(async ({ input }) => getRevision(input.id)),
|
||||||
|
|
||||||
restoreRevision: publicProcedure
|
restoreRevision: publicProcedure
|
||||||
|
.use(requireWriteAuth)
|
||||||
.input(z.object({ id: z.string() }))
|
.input(z.object({ id: z.string() }))
|
||||||
.mutation(async ({ input }) => restoreRevision(input.id)),
|
.mutation(async ({ input }) => restoreRevision(input.id)),
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import { db } from "@mcpedia/db";
|
|||||||
|
|
||||||
export interface Context {
|
export interface Context {
|
||||||
db: typeof db;
|
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();
|
export const t = initTRPC.context<Context>().create();
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "bun run src/index.ts",
|
"start": "bun run src/index.ts",
|
||||||
|
"serve:http": "bun run src/http.ts",
|
||||||
"lint": "tsc --noEmit",
|
"lint": "tsc --noEmit",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"smoke": "bun run src/smoke.test.ts"
|
"smoke": "bun run src/smoke.test.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<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`);
|
||||||
|
});
|
||||||
@@ -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
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
"enqueue": "bun run scripts/enqueue.ts",
|
"enqueue": "bun run scripts/enqueue.ts",
|
||||||
"worker": "/home/code/.bun/bin/bun --cwd apps/worker src/index.ts",
|
"worker": "/home/code/.bun/bin/bun --cwd apps/worker src/index.ts",
|
||||||
"mcp": "bun --cwd apps/mcp run start",
|
"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"
|
"api": "/home/code/.bun/bin/bun --cwd apps/api src/index.ts"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
Reference in New Issue
Block a user