Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76ed91c468 | ||
|
|
b621923868 |
No files matched your search
@@ -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.
|
||||
@@ -122,6 +122,38 @@ bun run api # Hono+tRPC API on :4020 (added /hooks/* webhooks)
|
||||
- [ ] Object storage for assets
|
||||
- [ ] Advanced ranking, distributed workers, observability, multi-tenant
|
||||
|
||||
## Phase 6 — Network deployment + review hardening ✅ DONE
|
||||
|
||||
> Closed the real gaps found during review: the MCP server was stdio-only (unreachable
|
||||
> over the network) and the tRPC API was not routed on the domain (swallowed by web →
|
||||
> `/trpc/*` returned Next.js 404). Also found + fixed a security hole.
|
||||
|
||||
- [x] **MCP over Streamable HTTP** — `apps/mcp/src/http.ts` serves the 6 tools + 4
|
||||
resources via MCP 2025-03-26 Streamable HTTP on `:4021`, stateless mode
|
||||
(`sessionIdGenerator: undefined`, one server+transport per request, CORS on `/mcp`).
|
||||
Deployed as `mcpedia-mcp.service`; reachable at `https://mcp.asepharyana.my.id/mcp`.
|
||||
Stdio entry (`bun run mcp`) retained for local subprocess use.
|
||||
- [x] **tRPC API routed on the domain** — Caddy `wiki.asepharyana.my.id` now forwards
|
||||
`/trpc/*` (+ `/hooks/*`, `/health`) to the API on `:4020`; web stays on `:4016`.
|
||||
Read-only procedures (search, list, revisions, job status) are public; the
|
||||
`restoreRevision` mutation is gated by `x-webhook-secret` (see security fix below).
|
||||
- [x] **Security: lock down `restoreRevision`** — the state-changing tRPC mutation was
|
||||
anonymously callable over the network. Now requires `x-webhook-secret` (consistent
|
||||
with `/hooks` auth). The Web UI calls `@mcpedia/core` directly in a server component,
|
||||
so the gate does not affect the UI's restore button. Verified: no-secret → 401-class
|
||||
rejection, with-secret → reaches handler.
|
||||
- [x] **All four services live + supervised** — `mcpedia-web` (:4016), `mcpedia-api`
|
||||
(:4020), `mcpedia-worker` (BullMQ), `mcpedia-mcp` (:4021) all `active`, reboot-safe.
|
||||
GitHub push webhook → `https://wiki.asepharyana.my.id/hooks/reindex` (verified 200,
|
||||
worker drains, 0 failed).
|
||||
|
||||
### Verification done (real, against live services)
|
||||
- `https://mcp.asepharyana.my.id/mcp` initialize → 200 + serverInfo; tools/list → 6;
|
||||
resources/list → 4 (Streamable HTTP SSE framing).
|
||||
- `https://wiki.asepharyana.my.id/` → 200; `/health` → 200; `/trpc/listDocuments` → 200.
|
||||
- GitHub push webhook delivers 200; `queueStatus` shows completed:N, failed:0.
|
||||
- `turbo run typecheck` green across all 4 apps.
|
||||
|
||||
## Decisions locked (from initial planning)
|
||||
|
||||
- **Tooling:** bun workspaces + Turborepo (repo already used bun; pnpm rejected to minimize churn).
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
"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": {
|
||||
|
||||
Reference in new issue
Block a user