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:
@@ -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
@@ -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)),
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user