diff --git a/docs/mcp-implementation-plan.html b/docs/mcp-implementation-plan.html new file mode 100644 index 00000000..bd22ca43 --- /dev/null +++ b/docs/mcp-implementation-plan.html @@ -0,0 +1,206 @@ + + +
+ + +claude/mcp-capabilities-overview-D6RMz+ Expose the Hub's read-only AI-spend data through a Model Context Protocol (MCP) server, so + MCP clients (Claude Desktop / Code, Cursor, etc.) can answer questions like + “What did Alice spend on Claude last month?” or + “Which Claude workspaces are over 80% of their cap?” + directly against live Hub data, reusing the existing tested data layer rather than duplicating logic. +
+ +| Question | Finding |
|---|---|
| Which library? | mcp-handler (Vercel-maintained), wraps the official @modelcontextprotocol/sdk as a Next.js App Router route handler. |
| Next.js 15 supported? | Yes — peer dep is next >=13.0.0. (An earlier search result claiming “Next 16 required” conflated this with Next's own built-in MCP guide; the package README confirms 13+.) |
| Versions pinned | mcp-handler@1.1.0 (peer requires exactly @modelcontextprotocol/sdk@1.26.0) — SDK pinned to 1.26.0 to satisfy the peer and avoid pnpm warnings. 1.26.0 also clears the pre-1.26 security advisory. |
| Zod v4 (app uses 4.3.6)? | Supported since SDK 1.22.0 via Standard Schema. Known quirks: z.discriminatedUnion() can be silently dropped, and per-field descriptions may not always propagate. Mitigation: keep tool inputs to flat strings/numbers/optionals, no unions. |
| Transport | Streamable HTTP (default since the 2025-03 spec). Stateless mode works on serverless with no Redis. redis is a dormant dependency only used for SSE resumability. |
| Tool API | server.registerTool(name, { title, description, inputSchema }, handler) — inputSchema is a Zod raw shape; handler returns { content: [{ type: "text", text }] }. |
| Auth | withMcpAuth(handler, verifyToken, { required: true }) — verifyToken(req, bearerToken) returns an AuthInfo or undefined (→ 401). Lets us plug in the Hub's shared-secret model. |
One dynamic route mounts the MCP server; all domain logic lives in a small, unit-testable src/lib/mcp/ module that delegates to the existing data layer. The route stays thin.
src/app/api/mcp/[transport]/route.ts # createMcpHandler + withMcpAuth (thin)
+src/lib/mcp/
+ ├─ auth.ts # verifyMcpToken: constant-time shared-secret check
+ ├─ format.ts # centsToUsd, jsonResult, errorResult (pure)
+ ├─ data.ts # per-tool data assembly (delegates to existing lib/actions)
+ └─ tools.ts # registerHubTools(server): wires names → handlers
+docs/mcp-server.md # operator + client setup guide
+ Endpoint resolves to POST /api/mcp/mcp (basePath /api/mcp). Namespacing under /api/mcp avoids a catch-all dynamic segment colliding with existing /api/* routes.
MCP_SERVER_SECRET (z.string().min(16).optional()) — mirrors PROFILE_API_SECRET. When unset, the server rejects every request (401) and logs a one-time warning, so the feature is dormant-by-default and safe to ship.crypto.timingSafeEqual with a length guard) to avoid timing leaks.api/mcp to the middleware.ts matcher exclusion, so unauthenticated MCP clients get a clean 401 instead of a 302 → /login redirect (same pattern as /api/profile, /api/sync)./api/mcp to the nighthawk agent BUILT_IN_DENY_PATHS, matching how /api/sync is treated.Selected for high value, clean mapping to already-tested read functions, and zero session dependency. All monetary fields are returned as both integer cents and a derived USD number.
+| Tool | Input | Backed by | Returns |
|---|---|---|---|
list_ai_tools | – | aiTools + accessTiers (direct) | Active tool catalog with tiers & monthly cost |
get_user_cost_profile | email, month? | fetchProfileDataInternal | User, active licenses, Claude cost breakdown + last sync |
get_claude_spend_summary | month? | loadDashboardKpis | Org Claude KPIs: MTD total, MoM delta, projection, workspaces over cap, today estimate |
list_claude_workspaces | – | loadWorkspaceList | Per-workspace cost, cap, utilization %, today estimate |
get_budget_status | fiscalYear? | getActiveBudget + getBudgetWithCosts + buildBudgetForecast | Annual budget, per-period planned/billed/actual, forecast & on-track/at-risk |
get_copilot_usage_summary | since?, until? | copilotUsageMetrics + copilotBillingSnapshots (direct) | Seats, latest billing cost, usage & acceptance rate over range |
list_recent_sync_events | sourceType?, limit? | syncEvents + loadSyncStatus | Recent sync activity & Claude-spend freshness (health view) |
Domain coverage: licenses, per-user Claude cost, org Claude spend, Claude workspaces, budgets, GitHub Copilot, and pipeline health.
+ +get_user_cost_profile returns costs and license metadata, never decrypted keys (unlike the admin CSV export).getCopilotOverview/getCopilotSeats) are skipped — they call requireAdmin() internally, so a bearer-auth caller would just get “Unauthorized”. We query the tables directly instead.tests/unit/mcp/format.test.ts — cents→USD rounding, result envelope shape.tests/unit/mcp/auth.test.ts — missing secret → reject, wrong token → reject, correct → AuthInfo, constant-time path.tests/unit/mcp/data.test.ts — each assembly function with mocked db/loaders; asserts shaping & USD conversion & not-found handling.tests/unit/mcp/tools.test.ts — registers tools against a fake server, invokes each handler, asserts content envelope & isError on failures.pnpm typecheck (strict, no any), pnpm lint (zero warnings), pnpm test, pnpm build. Then /simplify.Q: mcp-handler@1.1.0 drags in redis, commander, chalk. Is that acceptable weight for a serverless app?
A: Yes for v1 — it's the Vercel-blessed path and Redis stays dormant in stateless Streamable HTTP mode. Mitigation that survives this decision: all domain logic is decoupled into src/lib/mcp/* and unit-tested independently of mcp-handler, so swapping to the raw SDK transport later is a route-file change, not a rewrite.
Q: Zod v4 + SDK 1.26 has known schema quirks. Risk?
+A: Bounded by keeping inputs to flat z.string()/z.number()/.optional() — no discriminated unions, no nested objects. Month/fiscal-year validated with the same regexes the app already uses (/^\d{4}-(0[1-9]|1[0-2])$/).
Q: Shared secret instead of full OAuth 2.1 — is that good enough?
+A: For an internal, read-only, single-tenant tool it matches the app's existing bearer-secret convention (PROFILE_API_SECRET, CRON_SECRET) and operators already manage these. OAuth is the documented upgrade path; withMcpAuth keeps that door open without reworking the tools.
Q: Could this leak sensitive data to an AI client?
+A: Improvement applied: no decrypted API keys, no password hashes, no invite tokens. get_user_cost_profile returns only the same surface as the existing /api/profile route. Per-user lookup is by exact email, returning a clean “not found” rather than enumerable data.
Q: Server actions in budget.ts are "use server". Safe to call from a route?
A: Yes — the read actions used (getActiveBudget, getBudgetWithCosts, getBudgetForecast, fetchActualByPeriod) contain no requireAdmin() and no client boundary is crossed; they run as ordinary server functions.
Q: What if MCP_SERVER_SECRET is forgotten in production?
A: Improvement applied: dormant-by-default. Unset → every call 401s with a one-time server warning. Optional in the env schema so existing deployments keep booting; the feature simply stays off until a secret is provisioned.
+Q: Errors inside a tool handler?
+A: Every handler is wrapped to return { isError: true, content: [...] } with a safe message instead of throwing a raw protocol error, so a bad email or empty dataset degrades gracefully.
// Claude Desktop / Code MCP config
+{
+ "mcpServers": {
+ "ai-developer-hub": {
+ "type": "http",
+ "url": "https://<your-hub-host>/api/mcp/mcp",
+ "headers": { "Authorization": "Bearer <MCP_SERVER_SECRET>" }
+ }
+ }
+}
+
+
+