feat(phase7): grow corpus, MCP write-tools+auth, /metrics observability
CI / typecheck + build (turbo) (push) Canceled after 0s

- content/: +5 real docs (caddy, bullmq, mcp-streamable-http, postgres-fts,
  cloudflare-525 writeup) across docs/writeups/notes. Reindexed: 9 docs, 17
  chunks, 5 revisions (was 4 docs).
- apps/mcp: add write-tools index_document/reindex_all/restore_revision (require
  x-webhook-secret) + queue_status (public). createMcpServer(authSecret?) threads
  the HTTP header; stdio keeps writes open (trusted local).
- apps/api: GET /metrics (Prometheus text: uptime + queue job gauges).
- Caddy: expose /metrics on wiki. domain -> :4020.
Verified live: /metrics 200; MCP tools/list -> 10; index_document unauth -> error,
auth -> enqueues + worker drains; typecheck green.
This commit is contained in:
asepharyana
2026-08-20 10:04:16 +07:00
parent 76ed91c468
commit 53d636af0e
12 changed files with 524 additions and 4 deletions
+29 -1
View File
@@ -6,7 +6,7 @@ import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { db } from "@mcpedia/db";
import { appRouter } from "./router";
import type { Context } from "./trpc";
import { enqueueIndexDoc, enqueueFullIndex } from "@mcpedia/queue";
import { enqueueIndexDoc, enqueueFullIndex, getQueue, INDEX_QUEUE } from "@mcpedia/queue";
import { WEBHOOK_SECRET } from "@mcpedia/config";
// Fail fast: never expose an open git-sync endpoint. If the operator hasn't
@@ -22,6 +22,34 @@ const app = new Hono();
// Health check (no auth — safe to expose).
app.get("/health", (c) => c.json({ ok: true }));
// --- Phase 7: Prometheus metrics (public, safe to scrape) ---
const startedAt = Date.now();
app.get("/metrics", async (c) => {
const queue = getQueue();
const [waiting, active, completed, failed, delayed] = await Promise.all([
queue.getWaitingCount(),
queue.getActiveCount(),
queue.getCompletedCount(),
queue.getFailedCount(),
queue.getDelayedCount(),
]);
const lines = [
"# HELP mcpedia_uptime_seconds seconds since process start",
"# TYPE mcpedia_uptime_seconds gauge",
`mcpedia_uptime_seconds ${((Date.now() - startedAt) / 1000).toFixed(1)}`,
`# HELP mcpedia_queue_jobs queue job counts for "${INDEX_QUEUE}"`,
"# TYPE mcpedia_queue_jobs gauge",
`mcpedia_queue_jobs{state="waiting"} ${waiting}`,
`mcpedia_queue_jobs{state="active"} ${active}`,
`mcpedia_queue_jobs{state="completed"} ${completed}`,
`mcpedia_queue_jobs{state="failed"} ${failed}`,
`mcpedia_queue_jobs{state="delayed"} ${delayed}`,
];
return c.text(lines.join("\n") + "\n", 200, {
"Content-Type": "text/plain; version=0.0.4; charset=utf-8",
});
});
// Shared guard for the git-sync webhooks: require `x-webhook-secret` header to
// match the configured secret. Reject anything else with 401.
// Verify a git-provider webhook. Supports GitHub's native HMAC signature
+1
View File
@@ -16,6 +16,7 @@
"dependencies": {
"@mcpedia/config": "workspace:*",
"@mcpedia/core": "workspace:*",
"@mcpedia/queue": "workspace:*",
"@mcpedia/search": "workspace:*",
"@modelcontextprotocol/sdk": "^1.29.0",
"zod": "^4.0.0"
+5 -2
View File
@@ -32,9 +32,12 @@ const httpServer = createServer(async (req: IncomingMessage, res: ServerResponse
return;
}
// Stateless: fresh server + transport per request.
// Stateless: fresh server + transport per request. The x-webhook-secret header
// (if present) is threaded into the server so write tools can require it.
const rawSecret = req.headers["x-webhook-secret"];
const authSecret = Array.isArray(rawSecret) ? rawSecret[0] : rawSecret;
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
const server = createMcpServer();
const server = createMcpServer(authSecret);
await server.connect(transport);
await transport.handleRequest(req, res);
});
+95 -1
View File
@@ -10,12 +10,24 @@ import {
hybridSearch,
keywordSearch,
listRevisions,
getRevision,
restoreRevision,
readContentFile,
} from "@mcpedia/core";
import { enqueueIndexDoc, enqueueFullIndex, getQueue, INDEX_QUEUE } from "@mcpedia/queue";
import { CONTENT_ROOT } from "@mcpedia/config";
import { join } from "node:path";
export function createMcpServer(): McpServer {
// Write tools require the caller to supply `x-webhook-secret` matching the
// configured WEBHOOK_SECRET. Read tools are open. `authSecret` is threaded from
// the HTTP transport (the request header); for stdio it is undefined (local use).
function requireMcpAuth(authSecret?: string) {
if (!authSecret) {
throw new Error("unauthorized: this tool requires the x-webhook-secret header");
}
}
export function createMcpServer(authSecret?: string): McpServer {
const server = new McpServer({
name: "mcpedia",
version: "0.1.0",
@@ -131,6 +143,88 @@ export function createMcpServer(): McpServer {
},
);
// --- Phase 7: mutating + admin tools (require x-webhook-secret) ---
server.registerTool(
"index_document",
{
description:
"Enqueue a single-document reindex job (parses, upserts, re-embeds). Requires the x-webhook-secret header. slug is the content path without extension, e.g. 'docs/caddy/reverse-proxy'.",
inputSchema: z.object({
slug: z.string().describe("Document slug, e.g. 'docs/caddy/reverse-proxy'"),
}),
},
async ({ slug }) => {
requireMcpAuth(authSecret);
const relPath = slug.endsWith(".md") || slug.endsWith(".mdx") ? slug : `${slug}.md`;
const job = await enqueueIndexDoc(relPath, "mcp");
return {
content: [{ type: "text", text: JSON.stringify({ ok: true, jobId: job.id, slug }) }],
};
},
);
server.registerTool(
"reindex_all",
{
description:
"Enqueue a full-corpus reindex (walks every content file). Requires the x-webhook-secret header.",
inputSchema: z.object({}),
},
async () => {
requireMcpAuth(authSecret);
const job = await enqueueFullIndex("mcp");
return {
content: [{ type: "text", text: JSON.stringify({ ok: true, jobId: job.id, kind: "full" }) }],
};
},
);
server.registerTool(
"restore_revision",
{
description:
"Restore a document revision by id (writes its body back into the live row + rebuilds chunks). Requires the x-webhook-secret header.",
inputSchema: z.object({ id: z.string().describe("Revision UUID") }),
},
async ({ id }) => {
requireMcpAuth(authSecret);
const result = await restoreRevision(id);
return {
content: [{ type: "text", text: JSON.stringify(result ?? { ok: false, error: "not found" }) }],
};
},
);
server.registerTool(
"queue_status",
{
description: "Current BullMQ index-queue counts (waiting/active/completed/failed/delayed).",
inputSchema: z.object({}),
},
async () => {
const queue = getQueue();
const [waiting, active, completed, failed, delayed] = await Promise.all([
queue.getWaitingCount(),
queue.getActiveCount(),
queue.getCompletedCount(),
queue.getFailedCount(),
queue.getDelayedCount(),
]);
return {
content: [
{
type: "text",
text: JSON.stringify(
{ queue: INDEX_QUEUE, counts: { waiting, active, completed, failed, delayed } },
null,
2,
),
},
],
};
},
);
// --- Phase 3: MCP Resources (read-only knowledge base surfaced via URIs) ---
// mcpedia://docs -> list all published documents
// mcpedia://docs/{slug} -> full markdown body (from disk)