feat(phase7): grow corpus, MCP write-tools+auth, /metrics observability
CI / typecheck + build (turbo) (push) Canceled after 0s
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:
+29
-1
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user