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 @@ + + + + + + AI Developer Hub - MCP Server Implementation Plan + + + +
+
+

AI Developer Hub — MCP Server

+
Implementation plan, design rationale, and self-critique · generated for branch claude/mcp-capabilities-overview-D6RMz
+
+ Read-only v1 + Streamable HTTP + Shared-secret auth + 7 tools +
+
+
+ +
+ +

1. Goal

+

+ 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. +

+ +

2. SDK research (verified June 2026)

+ + + + + + + + + +
QuestionFinding
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 pinnedmcp-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.
TransportStreamable 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 APIserver.registerTool(name, { title, description, inputSchema }, handler)inputSchema is a Zod raw shape; handler returns { content: [{ type: "text", text }] }.
AuthwithMcpAuth(handler, verifyToken, { required: true })verifyToken(req, bearerToken) returns an AuthInfo or undefined (→ 401). Lets us plug in the Hub's shared-secret model.
+ +

3. Architecture

+

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.

+ +

Auth & middleware

+ + +

4. Tools to expose (the worthwhile set)

+

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.

+ + + + + + + + + +
ToolInputBacked byReturns
list_ai_toolsaiTools + accessTiers (direct)Active tool catalog with tiers & monthly cost
get_user_cost_profileemail, month?fetchProfileDataInternalUser, active licenses, Claude cost breakdown + last sync
get_claude_spend_summarymonth?loadDashboardKpisOrg Claude KPIs: MTD total, MoM delta, projection, workspaces over cap, today estimate
list_claude_workspacesloadWorkspaceListPer-workspace cost, cap, utilization %, today estimate
get_budget_statusfiscalYear?getActiveBudget + getBudgetWithCosts + buildBudgetForecastAnnual budget, per-period planned/billed/actual, forecast & on-track/at-risk
get_copilot_usage_summarysince?, until?copilotUsageMetrics + copilotBillingSnapshots (direct)Seats, latest billing cost, usage & acceptance rate over range
list_recent_sync_eventssourceType?, limit?syncEvents + loadSyncStatusRecent 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.

+ +

Deliberately out of scope for v1

+ + +

5. Testing & QA

+ + +

6. Self-critique — challenge & improvements

+ +
+

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.

+
+ +

7. Client configuration (for the morning)

+
// 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>" }
+    }
+  }
+}
+ + +
+ + diff --git a/docs/mcp-server.md b/docs/mcp-server.md new file mode 100644 index 00000000..34c5e198 --- /dev/null +++ b/docs/mcp-server.md @@ -0,0 +1,80 @@ +# MCP Server + +The AI Developer Hub exposes a read-only [Model Context Protocol](https://modelcontextprotocol.io) +server so MCP clients (Claude Desktop / Code, Cursor, etc.) can query live +AI-spend data in natural language. + +- **Endpoint:** `POST /api/mcp/mcp` (Streamable HTTP transport) +- **Auth:** `Authorization: Bearer ` +- **Access:** read-only. No mutations, no decrypted API keys, no password hashes. + +## Setup + +1. Generate a secret (min 16 chars) and set it in the environment: + + ```bash + MCP_SERVER_SECRET="$(openssl rand -base64 32)" + ``` + +2. Deploy. The server is **dormant by default**: when `MCP_SERVER_SECRET` is + unset, every request is rejected with `401` and a one-time warning is logged, + so the feature stays off until a secret is provisioned. + +## Client configuration + +```jsonc +{ + "mcpServers": { + "ai-developer-hub": { + "type": "http", + "url": "https:///api/mcp/mcp", + "headers": { "Authorization": "Bearer " } + } + } +} +``` + +Test locally with the MCP Inspector: + +```bash +npx @modelcontextprotocol/inspector +# then point it at http://localhost:3000/api/mcp/mcp with the bearer header +``` + +## Tools + +All monetary fields are returned as both integer cents (`*Cents`) and a derived +USD number (`*Usd`). + +| Tool | Input | Description | +| --- | --- | --- | +| `list_ai_tools` | – | Active AI tools and their access tiers with monthly cost. | +| `get_user_cost_profile` | `email`, `month?` (YYYY-MM) | A user's active licenses and Claude API cost breakdown for a month. Looked up by exact email. | +| `get_claude_spend_summary` | `month?` (YYYY-MM) | Org-wide Claude spend KPIs: MTD total, MoM delta, month-end projection, workspaces over 80% of cap, today's estimate. Defaults to current month. | +| `list_claude_workspaces` | – | Anthropic workspaces with current-month spend, cap, utilization %, and today's estimate. | +| `get_budget_status` | `fiscalYear?` | Annual budget: per-period planned/billed/expected/actual and an OLS forecast with on-track / at-risk verdict. Defaults to the active budget. | +| `get_copilot_usage_summary` | `since?`, `until?` (YYYY-MM-DD) | GitHub Copilot seat/billing snapshot and aggregated usage over a range. Defaults to the last 28 days. | +| `list_recent_sync_events` | `sourceType?`, `limit?` | Recent data-pipeline sync events plus Claude-spend data freshness. | + +## Architecture + +- `src/app/api/mcp/[transport]/route.ts` — mounts the server via `mcp-handler` + (`createMcpHandler` + `withMcpAuth`). Thin by design. +- `src/lib/mcp/auth.ts` — shared-secret `verifyMcpToken` (constant-time compare). +- `src/lib/mcp/tools.ts` — registers the tools (Zod input schemas). +- `src/lib/mcp/data.ts` — data assembly; delegates to the existing tested read + layer (`profile-data`, `anthropic/queries`, `actions/budget`, ...). +- `src/lib/mcp/format.ts` — pure helpers (`centsToUsd`, `usd`, result wrappers). + +The route is excluded from the NextAuth middleware matcher (so unauthenticated +clients get a clean `401` instead of a redirect to `/login`) and added to the +nighthawk agent deny-list as defense-in-depth, mirroring `/api/sync`. + +## Security notes + +- Read-only: there are no write/mutation tools. +- `get_user_cost_profile` returns only the same surface as the existing + `/api/profile` route — never decrypted API keys. +- The shared-secret model matches the Hub's other machine-to-machine endpoints + (`PROFILE_API_SECRET`, `CRON_SECRET`). OAuth 2.1 is the documented upgrade + path; `withMcpAuth` keeps that door open without reworking the tools. diff --git a/package.json b/package.json index de6902f5..97d3c90f 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "@aws-sdk/client-s3": "^3.1002.0", "@aws-sdk/s3-request-presigner": "^3.1002.0", "@hookform/resolvers": "^5.2.2", + "@modelcontextprotocol/sdk": "1.26.0", "@neondatabase/serverless": "^1.0.2", "@react-email/components": "^1.0.9", "@tanstack/react-table": "^8.21.3", @@ -45,6 +46,7 @@ "dotenv": "^17.3.1", "drizzle-orm": "^0.45.1", "lucide-react": "^0.576.0", + "mcp-handler": "1.1.0", "next": "^15.5.12", "next-auth": "5.0.0-beta.30", "next-themes": "^0.4.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2a7d8a74..2afc83ef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: '@hookform/resolvers': specifier: ^5.2.2 version: 5.2.2(react-hook-form@7.71.2(react@19.2.4)) + '@modelcontextprotocol/sdk': + specifier: 1.26.0 + version: 1.26.0(zod@4.3.6) '@neondatabase/serverless': specifier: ^1.0.2 version: 1.0.2 @@ -56,6 +59,9 @@ importers: lucide-react: specifier: ^0.576.0 version: 0.576.0(react@19.2.4) + mcp-handler: + specifier: 1.1.0 + version: 1.1.0(@modelcontextprotocol/sdk@1.26.0(zod@4.3.6))(next@15.5.12(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) next: specifier: ^15.5.12 version: 15.5.12(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -1391,8 +1397,8 @@ packages: '@lhci/utils@0.15.1': resolution: {integrity: sha512-WclJnUQJeOMY271JSuaOjCv/aA0pgvuHZS29NFNdIeI14id8eiFsjith85EGKYhljgoQhJ2SiW4PsVfFiakNNw==} - '@modelcontextprotocol/sdk@1.27.1': - resolution: {integrity: sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==} + '@modelcontextprotocol/sdk@1.26.0': + resolution: {integrity: sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==} engines: {node: '>=18'} peerDependencies: '@cfworker/json-schema': ^4.1.1 @@ -2216,96 +2222,112 @@ packages: '@react-email/body@0.3.0': resolution: {integrity: sha512-uGo0BOOzjbMUo3lu+BIDWayvn5o6Xyfmnlla5VGf05n8gHMvO1ll7U4FtzWe3hxMLwt53pmc4iE0M+B5slG+Ug==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/button@0.2.1': resolution: {integrity: sha512-qXyj7RZLE7POy9BMKSoqQ00tOXThjOZSUnI2Yu9i29IHngPlmrNayIWBoVKtElES7OWwypUcpiajwi1mUWx6/A==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/code-block@0.2.1': resolution: {integrity: sha512-M3B7JpVH4ytgn83/ujRR1k1DQHvTeABiDM61OvAbjLRPhC/5KLHU5KkzIbbuGIrjWwxAbL1kSQzU8MhLEtSxyw==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/code-inline@0.0.6': resolution: {integrity: sha512-jfhebvv3dVsp3OdPgKXnk8+e2pBiDVZejDOBFzBa/IblrAJ9cQDkN6rBD5IyEg8hTOxwbw3iaI/yZFmDmIguIA==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/column@0.0.14': resolution: {integrity: sha512-f+W+Bk2AjNO77zynE33rHuQhyqVICx4RYtGX9NKsGUg0wWjdGP0qAuIkhx9Rnmk4/hFMo1fUrtYNqca9fwJdHg==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/components@1.0.9': resolution: {integrity: sha512-2vi1w423KdjGa9rLUJAq8daTq5xVvB5VHDuI8fRu3/JfqqihzUu5r0bET3qWDw9QpKOIXcZzWO3jN2+yMVtzUw==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/container@0.0.16': resolution: {integrity: sha512-QWBB56RkkU0AJ9h+qy33gfT5iuZknPC7Un/IjZv9B0QmMIK+WWacc0cH6y2SV5Cv/b99hU94fjEMOOO4enpkbQ==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/font@0.0.10': resolution: {integrity: sha512-0urVSgCmQIfx5r7Xc586miBnQUVnGp3OTYUm8m5pwtQRdTRO5XrTtEfNJ3JhYhSOruV0nD8fd+dXtKXobum6tA==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/head@0.0.13': resolution: {integrity: sha512-AJg6le/08Gz4tm+6MtKXqtNNyKHzmooOCdmtqmWxD7FxoAdU1eVcizhtQ0gcnVaY6ethEyE/hnEzQxt1zu5Kog==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/heading@0.0.16': resolution: {integrity: sha512-jmsKnQm1ykpBzw4hCYHwBkt5pW2jScXffPeEH5ZRF5tZeF5b1pvlFTO9han7C0pCkZYo1kEvWiRtx69yfCIwuw==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/hr@0.0.12': resolution: {integrity: sha512-TwmOmBDibavUQpXBxpmZYi2Iks/yeZOzFYh+di9EltMSnEabH8dMZXrl+pxNXzCgZ2XE8HY7VmUL65Lenfu5PA==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/html@0.0.12': resolution: {integrity: sha512-KTShZesan+UsreU7PDUV90afrZwU5TLwYlALuCSU0OT+/U8lULNNbAUekg+tGwCnOfIKYtpDPKkAMRdYlqUznw==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/img@0.0.12': resolution: {integrity: sha512-sRCpEARNVTf3FQhZOC+JTvu5r6ubiYWkT0ucYXg8ctkyi4G8QG+jgYPiNUqVeTLA2STOfmPM/nrk1nb84y6CPQ==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/link@0.0.13': resolution: {integrity: sha512-lkWc/NjOcefRZMkQoSDDbuKBEBDES9aXnFEOuPH845wD3TxPwh+QTf0fStuzjoRLUZWpHnio4z7qGGRYusn/sw==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/markdown@0.0.18': resolution: {integrity: sha512-gSuYK5fsMbGk87jDebqQ6fa2fKcWlkf2Dkva8kMONqLgGCq8/0d+ZQYMEJsdidIeBo3kmsnHZPrwdFB4HgjUXg==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/preview@0.0.14': resolution: {integrity: sha512-aYK8q0IPkBXyMsbpMXgxazwHxYJxTrXrV95GFuu2HbEiIToMwSyUgb8HDFYwPqqfV03/jbwqlsXmFxsOd+VNaw==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc @@ -2319,18 +2341,21 @@ packages: '@react-email/row@0.0.13': resolution: {integrity: sha512-bYnOac40vIKCId7IkwuLAAsa3fKfSfqCvv6epJKmPE0JBuu5qI4FHFCl9o9dVpIIS08s/ub+Y/txoMt0dYziGw==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/section@0.0.17': resolution: {integrity: sha512-qNl65ye3W0Rd5udhdORzTV9ezjb+GFqQQSae03NDzXtmJq6sqVXNWNiVolAjvJNypim+zGXmv6J9TcV5aNtE/w==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/tailwind@2.0.5': resolution: {integrity: sha512-7Ey+kiWliJdxPMCLYsdDts8ffp4idlP//w4Ui3q/A5kokVaLSNKG8DOg/8qAuzWmRiGwNQVOKBk7PXNlK5W+sg==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: '@react-email/body': 0.2.1 '@react-email/button': 0.2.1 @@ -2369,9 +2394,39 @@ packages: '@react-email/text@0.1.6': resolution: {integrity: sha512-TYqkioRS45wTR5il3dYk/SbUjjEdhSwh9BtRNB99qNH1pXAwA45H7rAuxehiu8iJQJH0IyIr+6n62gBz9ezmsw==} engines: {node: '>=20.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc + '@redis/bloom@1.2.0': + resolution: {integrity: sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==} + peerDependencies: + '@redis/client': ^1.0.0 + + '@redis/client@1.6.1': + resolution: {integrity: sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==} + engines: {node: '>=14'} + + '@redis/graph@1.1.1': + resolution: {integrity: sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==} + peerDependencies: + '@redis/client': ^1.0.0 + + '@redis/json@1.0.7': + resolution: {integrity: sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==} + peerDependencies: + '@redis/client': ^1.0.0 + + '@redis/search@1.2.0': + resolution: {integrity: sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==} + peerDependencies: + '@redis/client': ^1.0.0 + + '@redis/time-series@1.1.0': + resolution: {integrity: sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==} + peerDependencies: + '@redis/client': ^1.0.0 + '@rolldown/pluginutils@1.0.0-rc.3': resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} @@ -3423,6 +3478,7 @@ packages: basic-ftp@5.2.0: resolution: {integrity: sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==} engines: {node: '>=10.0.0'} + deprecated: Security vulnerability fixed in 5.2.1, please upgrade bcryptjs@3.0.3: resolution: {integrity: sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==} @@ -3566,6 +3622,10 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + cluster-key-slot@1.1.2: + resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} + engines: {node: '>=0.10.0'} + cmdk@1.1.1: resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==} peerDependencies: @@ -4459,6 +4519,10 @@ packages: resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} engines: {node: '>= 0.4'} + generic-pool@3.9.0: + resolution: {integrity: sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==} + engines: {node: '>= 4'} + gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -5159,6 +5223,16 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mcp-handler@1.1.0: + resolution: {integrity: sha512-MVCES7g18gcoZy+R/3v5nadkUMzMAWdos8jRl6DyljOKvd2/ZKDmwlCjL6zp4vo+7FeCXOYL1uWinHWlkKAAUg==} + hasBin: true + peerDependencies: + '@modelcontextprotocol/sdk': 1.26.0 + next: '>=13.0.0' + peerDependenciesMeta: + next: + optional: true + mdn-data@2.12.2: resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==} @@ -5829,10 +5903,14 @@ packages: recharts@2.15.4: resolution: {integrity: sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==} engines: {node: '>=14'} + deprecated: 1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide peerDependencies: react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + redis@4.7.1: + resolution: {integrity: sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==} + reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -6257,8 +6335,8 @@ packages: third-party-web@0.26.7: resolution: {integrity: sha512-buUzX4sXC4efFX6xg2bw6/eZsCUh8qQwSavC4D9HpONMFlRbcHhD8Je5qwYdCpViR6q0qla2wPP+t91a2vgolg==} - third-party-web@0.29.0: - resolution: {integrity: sha512-nBDSJw5B7Sl1YfsATG2XkW5qgUPODbJhXw++BKygi9w6O/NKS98/uY/nR/DxDq2axEjL6halHW1v+jhm/j1DBQ==} + third-party-web@0.29.2: + resolution: {integrity: sha512-fegtha91tq2DHphyoiBXVHjVi2YG9zFaRnboT9C28tO1en9Y3wJsfspuy40F+u5wl3hHVbw7cnd1b67kEGHb8g==} through@2.3.8: resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} @@ -6501,10 +6579,12 @@ packages: uuid@10.0.0: resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true validate-npm-package-name@7.0.2: @@ -6734,6 +6814,9 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + yargs-parser@13.1.2: resolution: {integrity: sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg==} @@ -8096,7 +8179,7 @@ snapshots: - supports-color - utf-8-validate - '@modelcontextprotocol/sdk@1.27.1(zod@3.25.76)': + '@modelcontextprotocol/sdk@1.26.0(zod@3.25.76)': dependencies: '@hono/node-server': 1.19.9(hono@4.12.3) ajv: 8.18.0 @@ -8118,6 +8201,28 @@ snapshots: transitivePeerDependencies: - supports-color + '@modelcontextprotocol/sdk@1.26.0(zod@4.3.6)': + dependencies: + '@hono/node-server': 1.19.9(hono@4.12.3) + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.6 + express: 5.2.1 + express-rate-limit: 8.2.1(express@5.2.1) + hono: 4.12.3 + jose: 6.1.3 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.3.6 + zod-to-json-schema: 3.25.1(zod@4.3.6) + transitivePeerDependencies: + - supports-color + '@mswjs/interceptors@0.41.3': dependencies: '@open-draft/deferred-promise': 2.2.0 @@ -8205,7 +8310,7 @@ snapshots: '@paulirish/trace_engine@0.0.53': dependencies: legacy-javascript: 0.0.1 - third-party-web: 0.29.0 + third-party-web: 0.29.2 '@playwright/test@1.58.2': dependencies: @@ -9097,6 +9202,32 @@ snapshots: dependencies: react: 19.2.4 + '@redis/bloom@1.2.0(@redis/client@1.6.1)': + dependencies: + '@redis/client': 1.6.1 + + '@redis/client@1.6.1': + dependencies: + cluster-key-slot: 1.1.2 + generic-pool: 3.9.0 + yallist: 4.0.0 + + '@redis/graph@1.1.1(@redis/client@1.6.1)': + dependencies: + '@redis/client': 1.6.1 + + '@redis/json@1.0.7(@redis/client@1.6.1)': + dependencies: + '@redis/client': 1.6.1 + + '@redis/search@1.2.0(@redis/client@1.6.1)': + dependencies: + '@redis/client': 1.6.1 + + '@redis/time-series@1.1.0(@redis/client@1.6.1)': + dependencies: + '@redis/client': 1.6.1 + '@rolldown/pluginutils@1.0.0-rc.3': {} '@rollup/rollup-android-arm-eabi@4.59.0': @@ -10363,6 +10494,8 @@ snapshots: clsx@2.1.1: {} + cluster-key-slot@1.1.2: {} + cmdk@1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) @@ -11429,6 +11562,8 @@ snapshots: generator-function@2.0.1: {} + generic-pool@3.9.0: {} + gensync@1.0.0-beta.2: {} get-caller-file@2.0.5: {} @@ -12121,6 +12256,15 @@ snapshots: math-intrinsics@1.1.0: {} + mcp-handler@1.1.0(@modelcontextprotocol/sdk@1.26.0(zod@4.3.6))(next@15.5.12(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)): + dependencies: + '@modelcontextprotocol/sdk': 1.26.0(zod@4.3.6) + chalk: 5.6.2 + commander: 11.1.0 + redis: 4.7.1 + optionalDependencies: + next: 15.5.12(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + mdn-data@2.12.2: {} media-typer@0.3.0: {} @@ -12836,6 +12980,15 @@ snapshots: tiny-invariant: 1.3.3 victory-vendor: 36.9.2 + redis@4.7.1: + dependencies: + '@redis/bloom': 1.2.0(@redis/client@1.6.1) + '@redis/client': 1.6.1 + '@redis/graph': 1.1.1(@redis/client@1.6.1) + '@redis/json': 1.0.7(@redis/client@1.6.1) + '@redis/search': 1.2.0(@redis/client@1.6.1) + '@redis/time-series': 1.1.0(@redis/client@1.6.1) + reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.8 @@ -13092,7 +13245,7 @@ snapshots: '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0) '@babel/preset-typescript': 7.28.5(@babel/core@7.29.0) '@dotenvx/dotenvx': 1.52.0 - '@modelcontextprotocol/sdk': 1.27.1(zod@3.25.76) + '@modelcontextprotocol/sdk': 1.26.0(zod@3.25.76) '@types/validate-npm-package-name': 4.0.2 browserslist: 4.28.1 commander: 14.0.3 @@ -13443,7 +13596,7 @@ snapshots: third-party-web@0.26.7: {} - third-party-web@0.29.0: {} + third-party-web@0.29.2: {} through@2.3.8: {} @@ -13910,6 +14063,8 @@ snapshots: yallist@3.1.1: {} + yallist@4.0.0: {} + yargs-parser@13.1.2: dependencies: camelcase: 5.3.1 @@ -13961,6 +14116,10 @@ snapshots: dependencies: zod: 3.25.76 + zod-to-json-schema@3.25.1(zod@4.3.6): + dependencies: + zod: 4.3.6 + zod@3.25.76: {} zod@4.3.6: {} diff --git a/src/app/api/mcp/[transport]/route.ts b/src/app/api/mcp/[transport]/route.ts new file mode 100644 index 00000000..049d5546 --- /dev/null +++ b/src/app/api/mcp/[transport]/route.ts @@ -0,0 +1,30 @@ +/** + * MCP server endpoint (Model Context Protocol over Streamable HTTP). + * + * Mounts the Hub's read-only tools at `/api/mcp/mcp`. Auth is the shared + * `MCP_SERVER_SECRET` bearer token enforced by `withMcpAuth`; when the secret + * is unset the server rejects every request (see src/lib/mcp/auth.ts). + * + * This route is excluded from the NextAuth middleware matcher so unauthenticated + * clients receive a clean 401 instead of a redirect to /login. + */ + +import { createMcpHandler, withMcpAuth } from "mcp-handler"; + +import { registerHubTools } from "@/lib/mcp/tools"; +import { verifyMcpToken } from "@/lib/mcp/auth"; + +export const dynamic = "force-dynamic"; +export const maxDuration = 60; + +const handler = createMcpHandler( + (server) => { + registerHubTools(server); + }, + {}, + { basePath: "/api/mcp" }, +); + +const authHandler = withMcpAuth(handler, verifyMcpToken, { required: true }); + +export { authHandler as GET, authHandler as POST, authHandler as DELETE }; diff --git a/src/lib/agent-auth.ts b/src/lib/agent-auth.ts index dc51d62a..a169d914 100644 --- a/src/lib/agent-auth.ts +++ b/src/lib/agent-auth.ts @@ -21,12 +21,16 @@ export const BUILT_IN_DENY_PATHS: readonly string[] = [ "POST /api/users/reset-password", "/api/invoices/ingest", "/api/sync", + "/api/mcp", "/setup-password", "POST /api/anthropic-config", "POST /api/github-config", ]; -function parseDenyEntry(entry: string): { method: string | null; path: string } { +function parseDenyEntry(entry: string): { + method: string | null; + path: string; +} { const trimmed = entry.trim(); if (!trimmed) return { method: null, path: "" }; const space = trimmed.indexOf(" "); @@ -51,7 +55,10 @@ export function isAgentDenied(pathname: string, method: string): boolean { const { method: entryMethod, path } = parseDenyEntry(raw); if (!path) continue; if (entryMethod && entryMethod !== upper) continue; - if (pathname === path || pathname.startsWith(path.endsWith("/") ? path : path + "/")) { + if ( + pathname === path || + pathname.startsWith(path.endsWith("/") ? path : path + "/") + ) { return true; } } @@ -89,7 +96,7 @@ export interface AgentJwtPayload { */ export async function mintAgentJwt( payload: AgentJwtPayload, - options: { maxAgeSeconds?: number } = {} + options: { maxAgeSeconds?: number } = {}, ): Promise<{ cookieName: string; token: string; maxAgeSeconds: number }> { const secret = env.AUTH_SECRET; if (!secret) { diff --git a/src/lib/env.ts b/src/lib/env.ts index 91a32f15..637d9b67 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -7,7 +7,12 @@ const envSchema = z.object({ // Auth (NextAuth v5) — min 32 chars matches `openssl rand -base64 32` output // and Auth.js's recommended minimum entropy for HMAC-derived session keys. - AUTH_SECRET: z.string().min(32, "AUTH_SECRET must be at least 32 characters (generate with `openssl rand -base64 32`)"), + AUTH_SECRET: z + .string() + .min( + 32, + "AUTH_SECRET must be at least 32 characters (generate with `openssl rand -base64 32`)", + ), AUTH_URL: z.string().optional(), NEXTAUTH_URL: z.string().optional(), @@ -16,13 +21,23 @@ const envSchema = z.object({ VERCEL_URL: z.string().optional(), // API key encryption - API_KEY_ENCRYPTION_SECRET: z.string().min(1, "API_KEY_ENCRYPTION_SECRET is required"), + API_KEY_ENCRYPTION_SECRET: z + .string() + .min(1, "API_KEY_ENCRYPTION_SECRET is required"), // Cloudflare R2 (invoice PDF storage) - CLOUDFLARE_R2_ACCOUNT_ID: z.string().min(1, "CLOUDFLARE_R2_ACCOUNT_ID is required"), - CLOUDFLARE_R2_ACCESS_KEY_ID: z.string().min(1, "CLOUDFLARE_R2_ACCESS_KEY_ID is required"), - CLOUDFLARE_R2_SECRET_ACCESS_KEY: z.string().min(1, "CLOUDFLARE_R2_SECRET_ACCESS_KEY is required"), - CLOUDFLARE_R2_BUCKET_NAME: z.string().min(1, "CLOUDFLARE_R2_BUCKET_NAME is required"), + CLOUDFLARE_R2_ACCOUNT_ID: z + .string() + .min(1, "CLOUDFLARE_R2_ACCOUNT_ID is required"), + CLOUDFLARE_R2_ACCESS_KEY_ID: z + .string() + .min(1, "CLOUDFLARE_R2_ACCESS_KEY_ID is required"), + CLOUDFLARE_R2_SECRET_ACCESS_KEY: z + .string() + .min(1, "CLOUDFLARE_R2_SECRET_ACCESS_KEY is required"), + CLOUDFLARE_R2_BUCKET_NAME: z + .string() + .min(1, "CLOUDFLARE_R2_BUCKET_NAME is required"), // Anthropic (invoice extraction) ANTHROPIC_API_KEY: z.string().min(1, "ANTHROPIC_API_KEY is required"), @@ -33,7 +48,9 @@ const envSchema = z.object({ // Cron and bearer authentication secrets — min 16 chars rejects accidental // weak values (e.g. "test", "changeme") at startup. CRON_SECRET: z.string().min(16, "CRON_SECRET must be at least 16 characters"), - INVOICE_INGEST_SECRET: z.string().min(16, "INVOICE_INGEST_SECRET must be at least 16 characters"), + INVOICE_INGEST_SECRET: z + .string() + .min(16, "INVOICE_INGEST_SECRET must be at least 16 characters"), // Agent session secret — only required on non-production preview/local AGENT_SESSION_SECRET: z.string().optional(), @@ -45,7 +62,9 @@ const envSchema = z.object({ // Required at startup so /api/invoices/ingest doesn't hard-fail in production. // Must reference an active admin user; the actual DB lookup is performed at // runtime by getSystemAdminUserId() since the schema can't reach the DB. - SYSTEM_ADMIN_USER_ID: z.string().regex(/^[1-9]\d*$/, "SYSTEM_ADMIN_USER_ID must be a positive integer"), + SYSTEM_ADMIN_USER_ID: z + .string() + .regex(/^[1-9]\d*$/, "SYSTEM_ADMIN_USER_ID must be a positive integer"), // Nighthawk agent AGENT_USER_EMAIL: z.string().optional(), @@ -55,6 +74,14 @@ const envSchema = z.object({ PROFILE_API_SECRET: z.string().optional(), VERCEL_AUTOMATION_BYPASS_SECRET: z.string().optional(), + // MCP server shared-secret bearer token. Optional so existing deployments + // boot without it; when unset the MCP server is dormant and rejects all + // requests. min 16 chars rejects accidental weak values, matching CRON_SECRET. + MCP_SERVER_SECRET: z + .string() + .min(16, "MCP_SERVER_SECRET must be at least 16 characters") + .optional(), + // Microsoft Teams — Workflows incoming webhook for Claude spend alerts. // If unset, the post-sync evaluator is a no-op. This is the feature kill switch. // The URL itself is the secret (HMAC-signed Logic Apps trigger) — never log it. @@ -74,7 +101,9 @@ export type Env = z.infer; * * @param input Defaults to process.env. Pass a custom object in unit tests. */ -export function validateEnv(input: Record = process.env): void { +export function validateEnv( + input: Record = process.env, +): void { // Read SKIP from the explicit input only — never fall back to process.env so // tests passing a custom env object are fully in control of validation. if (input.SKIP_ENV_VALIDATION === "1") return; diff --git a/src/lib/mcp/auth.ts b/src/lib/mcp/auth.ts new file mode 100644 index 00000000..f5c3d067 --- /dev/null +++ b/src/lib/mcp/auth.ts @@ -0,0 +1,62 @@ +/** + * Authentication for the MCP server. + * + * The Hub uses shared bearer secrets for its machine-to-machine endpoints + * (PROFILE_API_SECRET, CRON_SECRET, ...). The MCP server follows the same + * convention via `MCP_SERVER_SECRET`, plugged into mcp-handler's + * `withMcpAuth(handler, verifyMcpToken, { required: true })`. + * + * Dormant-by-default: when MCP_SERVER_SECRET is unset, every request is + * rejected (verifyMcpToken returns undefined → 401) and a one-time warning is + * logged, so the feature stays off until an operator provisions a secret. + */ + +import { timingSafeEqual } from "node:crypto"; +import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js"; + +/** Constant-time string comparison that is safe for unequal lengths. */ +export function safeEqual(a: string, b: string): boolean { + const bufA = Buffer.from(a, "utf8"); + const bufB = Buffer.from(b, "utf8"); + if (bufA.length !== bufB.length) { + // Still run a comparison to avoid leaking length via early return timing. + timingSafeEqual(bufA, bufA); + return false; + } + return timingSafeEqual(bufA, bufB); +} + +let warnedMissingSecret = false; + +/** + * Validate the bearer token presented by an MCP client against + * MCP_SERVER_SECRET. Returns an AuthInfo on success or undefined on any + * failure (missing secret, missing/invalid token), which mcp-handler maps to a + * 401 response. + */ +export function verifyMcpToken( + _req: Request, + bearerToken?: string, +): AuthInfo | undefined { + const secret = process.env.MCP_SERVER_SECRET; + + if (!secret) { + if (!warnedMissingSecret) { + warnedMissingSecret = true; + console.warn( + "[mcp] MCP_SERVER_SECRET is not set — the MCP server is disabled and will reject all requests.", + ); + } + return undefined; + } + + if (!bearerToken || !safeEqual(bearerToken, secret)) { + return undefined; + } + + return { + token: bearerToken, + clientId: "mcp-shared-secret", + scopes: [], + }; +} diff --git a/src/lib/mcp/data.ts b/src/lib/mcp/data.ts new file mode 100644 index 00000000..00bc6be8 --- /dev/null +++ b/src/lib/mcp/data.ts @@ -0,0 +1,398 @@ +/** + * Data-assembly functions for the MCP server. Each function maps one MCP tool + * to the Hub's existing read layer and returns a plain, JSON-serializable + * object with both `*Cents` (integer) and `*Usd` (number) monetary fields. + * + * No auth is performed here — the route enforces the shared secret via + * `withMcpAuth`. These functions never return decrypted API keys, password + * hashes, or invite tokens. + */ + +import { and, desc, eq, gte, lte } from "drizzle-orm"; + +import { db } from "@/lib/db"; +import { + accessTiers, + aiTools, + anthropicSyncStatus, + copilotBillingSnapshots, + copilotUsageMetrics, + githubConnections, + syncEvents, + users, +} from "@/lib/db/schema"; +import { fetchProfileDataInternal } from "@/lib/profile-data"; +import { + loadDashboardKpis, + loadSyncStatus, + loadWorkspaceList, +} from "@/lib/anthropic/queries"; +import { + getActiveBudget, + getBudgetWithCosts, + fetchActualByPeriod, +} from "@/actions/budget"; +import { buildBudgetForecast } from "@/lib/forecast"; +import { getCurrentMonth, formatUtcDateOnly } from "@/lib/utils"; +import type { SyncSourceType } from "@/lib/sync/framework"; +import { usd } from "@/lib/mcp/format"; + +/** Shape a calibrated "today" spend estimate (or null) for an MCP response. */ +function formatTodayEstimate( + estimate: { cents: number; confident: boolean; asOfIso: string } | null, +) { + if (!estimate) return null; + return { + ...usd("estimated", estimate.cents), + confident: estimate.confident, + asOf: estimate.asOfIso, + }; +} + +// --------------------------------------------------------------------------- +// list_ai_tools +// --------------------------------------------------------------------------- + +export async function listAiToolsData() { + const [tools, tiers] = await Promise.all([ + db + .select({ + id: aiTools.id, + name: aiTools.name, + vendor: aiTools.vendor, + status: aiTools.status, + }) + .from(aiTools) + .where(eq(aiTools.status, "active")), + db + .select({ + id: accessTiers.id, + toolId: accessTiers.toolId, + name: accessTiers.name, + monthlyCostCents: accessTiers.monthlyCostCents, + isActive: accessTiers.isActive, + }) + .from(accessTiers) + .where(eq(accessTiers.isActive, true)), + ]); + + const tiersByTool = new Map(); + for (const tier of tiers) { + const list = tiersByTool.get(tier.toolId) ?? []; + list.push(tier); + tiersByTool.set(tier.toolId, list); + } + + return { + tools: tools.map((tool) => ({ + ...tool, + tiers: (tiersByTool.get(tool.id) ?? []).map((tier) => ({ + id: tier.id, + name: tier.name, + ...usd("monthlyCost", tier.monthlyCostCents), + })), + })), + }; +} + +// --------------------------------------------------------------------------- +// get_user_cost_profile +// --------------------------------------------------------------------------- + +export async function getUserCostProfileData(email: string, month?: string) { + const user = await db.query.users.findFirst({ + where: eq(users.email, email), + columns: { id: true }, + }); + if (!user) { + throw new Error(`No user found with email: ${email}`); + } + + const [profile, syncRows] = await Promise.all([ + fetchProfileDataInternal(user.id, month), + db + .select({ lastSyncCompletedAt: anthropicSyncStatus.lastSyncCompletedAt }) + .from(anthropicSyncStatus) + .where(eq(anthropicSyncStatus.userId, user.id)) + .limit(1), + ]); + + const cost = profile.costData; + return { + user: { + name: profile.user.name, + email: profile.user.email, + role: profile.user.role, + circle: profile.user.circle, + profile: profile.user.profile, + discipline: profile.user.discipline, + }, + assignments: profile.assignments.map((a) => ({ + id: a.id, + toolName: a.toolName, + tierName: a.tierName, + status: a.status, + assignedAt: a.assignedAt?.toISOString() ?? null, + })), + costData: { + month: month ?? getCurrentMonth(), + available: cost.available, + error: cost.error ?? null, + ...usd("monthlyTotal", cost.monthlyTotalCents), + latestDataDate: cost.latestDataDate, + hasUnresolvedPricing: cost.hasUnresolvedPricing, + lastSyncAt: syncRows[0]?.lastSyncCompletedAt?.toISOString() ?? null, + dailyBreakdown: cost.dailyBreakdown.map((day) => ({ + date: day.date, + ...usd("total", day.totalCents), + models: day.models.map((m) => ({ + model: m.model, + ...usd("cost", m.costCents), + inputTokens: m.inputTokens, + outputTokens: m.outputTokens, + })), + })), + }, + }; +} + +// --------------------------------------------------------------------------- +// get_claude_spend_summary +// --------------------------------------------------------------------------- + +export async function getClaudeSpendSummaryData(month?: string) { + const targetMonth = month ?? getCurrentMonth(); + const kpis = await loadDashboardKpis(targetMonth); + + return { + month: targetMonth, + ...usd("total", kpis.totalCents), + ...usd("priorMonth", kpis.priorMonthCents), + ...usd("momDelta", kpis.momDeltaCents), + momDeltaPct: kpis.momDeltaPct, + ...usd("projectedMonthEnd", kpis.projectedMonthEndCents), + workspacesOverEightyCount: kpis.workspacesOverEightyCount, + workspacesWithLimitCount: kpis.workspacesWithLimitCount, + topOverWorkspaceName: kpis.topOverWorkspaceName, + topOverWorkspaceUtilizationPct: kpis.topOverWorkspaceUtilizationPct, + todayEstimate: formatTodayEstimate(kpis.todayEstimate), + }; +} + +// --------------------------------------------------------------------------- +// list_claude_workspaces +// --------------------------------------------------------------------------- + +export async function listClaudeWorkspacesData() { + const list = await loadWorkspaceList(); + return { + workspaces: list.map((w) => ({ + workspaceId: w.workspaceId, + name: w.name, + isDefault: w.isDefault, + ...usd("currentMonth", w.currentMonthCents), + ...usd("limit", w.limitCents), + utilizationPct: w.utilizationPct, + displayColor: w.displayColor, + todayEstimate: formatTodayEstimate(w.todayEstimate), + })), + }; +} + +// --------------------------------------------------------------------------- +// get_budget_status +// --------------------------------------------------------------------------- + +export async function getBudgetStatusData(fiscalYear?: number) { + let budgetId: number; + if (fiscalYear !== undefined) { + const row = await db.query.annualBudgets.findFirst({ + where: (b, { eq: eqOp }) => eqOp(b.fiscalYear, fiscalYear), + columns: { id: true }, + }); + if (!row) throw new Error(`No budget found for fiscal year ${fiscalYear}`); + budgetId = row.id; + } else { + const active = await getActiveBudget(); + if (!active) throw new Error("No active budget configured"); + budgetId = active.id; + } + + const budget = await getBudgetWithCosts(budgetId); + if (!budget) throw new Error("Budget not found"); + + const today = new Date(); + const actualByPeriod = await fetchActualByPeriod(budget, today); + const forecast = buildBudgetForecast(budget, actualByPeriod, today); + + return { + fiscalYear: budget.fiscalYear, + status: budget.status, + periodType: budget.periodType, + ...usd("totalAmount", budget.totalAmountCents), + periods: budget.periods.map((p) => ({ + label: p.periodLabel, + startDate: p.startDate, + endDate: p.endDate, + ...usd("planned", p.plannedAmountCents), + ...usd("billed", p.billedTotalCents), + ...usd("expected", p.expectedSpendCents), + ...usd("actual", actualByPeriod.get(p.id) ?? p.billedTotalCents), + })), + forecast: { + status: forecast.status, + ...usd("actualSpendToDate", forecast.actualSpendToDateCents), + ...usd("projectedAnnualTotal", forecast.projectedAnnualTotalCents), + ...usd("budgetCeiling", forecast.budgetCeilingCents), + insufficientData: forecast.insufficientData ?? null, + }, + }; +} + +// --------------------------------------------------------------------------- +// get_copilot_usage_summary +// --------------------------------------------------------------------------- + +export async function getCopilotUsageSummaryData( + since?: string, + until?: string, +) { + // Match getActiveConnection() in copilot-data.ts: a connection only counts as + // active for Copilot when syncing is enabled — otherwise its metrics are + // absent or stale and "connected: true" would be misleading. + const connection = await db.query.githubConnections.findFirst({ + where: and( + eq(githubConnections.status, "active"), + eq(githubConnections.copilotSyncEnabled, true), + ), + columns: { id: true, orgLogin: true }, + }); + if (!connection) { + return { + connected: false as const, + message: "No active GitHub connection with Copilot sync enabled", + }; + } + + // Default the range in UTC so the YYYY-MM-DD day boundary is timezone-stable + // (matches the rest of the codebase, which keys daily metrics on UTC dates). + const today = new Date(); + const untilDate = until ?? formatUtcDateOnly(today); + const sinceDate = + since ?? formatUtcDateOnly(new Date(today.getTime() - 27 * 86_400_000)); + + const [rows, billing] = await Promise.all([ + db + .select({ + totalActiveUsers: copilotUsageMetrics.totalActiveUsers, + totalEngagedUsers: copilotUsageMetrics.totalEngagedUsers, + totalSuggestions: copilotUsageMetrics.totalSuggestions, + totalAcceptances: copilotUsageMetrics.totalAcceptances, + totalLinesSuggested: copilotUsageMetrics.totalLinesSuggested, + totalLinesAccepted: copilotUsageMetrics.totalLinesAccepted, + totalChatTurns: copilotUsageMetrics.totalChatTurns, + }) + .from(copilotUsageMetrics) + .where( + and( + eq(copilotUsageMetrics.connectionId, connection.id), + gte(copilotUsageMetrics.date, sinceDate), + lte(copilotUsageMetrics.date, untilDate), + ), + ), + db.query.copilotBillingSnapshots.findFirst({ + where: eq(copilotBillingSnapshots.connectionId, connection.id), + orderBy: (b, { desc: descOp }) => [descOp(b.billingMonth)], + }), + ]); + + const sum = (key: keyof (typeof rows)[number]) => + rows.reduce((acc, r) => acc + (r[key] ?? 0), 0); + const peak = (key: keyof (typeof rows)[number]) => + rows.reduce((acc, r) => Math.max(acc, r[key] ?? 0), 0); + + const totalSuggestions = sum("totalSuggestions"); + const totalAcceptances = sum("totalAcceptances"); + + return { + connected: true as const, + org: connection.orgLogin, + dateRange: { since: sinceDate, until: untilDate }, + latestBilling: billing + ? { + billingMonth: billing.billingMonth, + planType: billing.planType, + totalSeats: billing.totalSeats, + activeSeats: billing.activeSeats, + ...usd("seatCost", billing.seatCostCents), + ...usd("totalCost", billing.totalCostCents), + } + : null, + usage: { + daysWithData: rows.length, + totalSuggestions, + totalAcceptances, + acceptanceRatePct: + totalSuggestions > 0 + ? Math.round((totalAcceptances / totalSuggestions) * 100) + : null, + totalLinesSuggested: sum("totalLinesSuggested"), + totalLinesAccepted: sum("totalLinesAccepted"), + totalChatTurns: sum("totalChatTurns"), + peakActiveUsers: peak("totalActiveUsers"), + peakEngagedUsers: peak("totalEngagedUsers"), + }, + }; +} + +// --------------------------------------------------------------------------- +// list_recent_sync_events +// --------------------------------------------------------------------------- + +export async function listRecentSyncEventsData( + sourceType?: SyncSourceType, + limit?: number, +) { + const cappedLimit = Math.min(Math.max(limit ?? 10, 1), 50); + + const [rows, freshness] = await Promise.all([ + db + .select({ + id: syncEvents.id, + sourceType: syncEvents.sourceType, + outcome: syncEvents.outcome, + startedAt: syncEvents.startedAt, + completedAt: syncEvents.completedAt, + createdCount: syncEvents.createdCount, + updatedCount: syncEvents.updatedCount, + skippedCount: syncEvents.skippedCount, + errorCount: syncEvents.errorCount, + errorMessage: syncEvents.errorMessage, + }) + .from(syncEvents) + .where(sourceType ? eq(syncEvents.sourceType, sourceType) : undefined) + .orderBy(desc(syncEvents.startedAt)) + .limit(cappedLimit), + loadSyncStatus(), + ]); + + return { + claudeSpendFreshness: { + lastSyncedAt: freshness.lastSyncedAt?.toISOString() ?? null, + ageMinutes: freshness.ageMinutes, + isStale: freshness.isStale, + }, + events: rows.map((e) => ({ + id: e.id, + sourceType: e.sourceType, + outcome: e.outcome, + startedAt: e.startedAt?.toISOString() ?? null, + completedAt: e.completedAt?.toISOString() ?? null, + createdCount: e.createdCount, + updatedCount: e.updatedCount, + skippedCount: e.skippedCount, + errorCount: e.errorCount, + errorMessage: e.errorMessage, + })), + }; +} diff --git a/src/lib/mcp/format.ts b/src/lib/mcp/format.ts new file mode 100644 index 00000000..d7998ee1 --- /dev/null +++ b/src/lib/mcp/format.ts @@ -0,0 +1,70 @@ +/** + * Pure formatting helpers for the MCP server. No I/O, no DB — fully unit + * testable. Monetary values in the Hub are stored as integer cents; MCP + * responses surface both the raw cents and a derived USD number so clients can + * choose precision vs. readability. + */ + +import { centsToUsd } from "@/lib/utils"; + +// Re-exported so the MCP layer has a single import surface for formatting. +export { centsToUsd }; + +/** + * MCP tool result shape (subset of the SDK's CallToolResult we use). The index + * signature mirrors the SDK type (which allows `_meta` and other extras), so + * this stays structurally assignable to a tool handler's expected return. + */ +export interface McpToolResult { + content: Array<{ type: "text"; text: string }>; + isError?: boolean; + [key: string]: unknown; +} + +/** + * Attach a `Usd` sibling for a `Cents` value. Returns an object + * spreadable into a response, e.g. `{ ...usd("total", 1234) }` → + * `{ totalCents: 1234, totalUsd: 12.34 }`. A null amount yields nulls. The + * generic field name flows through to the key names so consumers get precise + * typed fields (`totalCents`/`totalUsd`) rather than a loose index signature. + */ +export function usd( + field: Field, + cents: number | null | undefined, +): Record<`${Field}Cents` | `${Field}Usd`, number | null> { + const value = cents ?? null; + return { + [`${field}Cents`]: value, + [`${field}Usd`]: value === null ? null : centsToUsd(value), + } as Record<`${Field}Cents` | `${Field}Usd`, number | null>; +} + +/** Wrap a JSON-serializable value as a successful MCP text result. */ +export function jsonResult(data: unknown): McpToolResult { + return { + content: [{ type: "text", text: JSON.stringify(data, null, 2) }], + }; +} + +/** Wrap a message as an MCP error result (handler-level failure, not protocol). */ +export function errorResult(message: string): McpToolResult { + return { + content: [{ type: "text", text: `Error: ${message}` }], + isError: true, + }; +} + +/** + * Run a data-assembly function and serialize the outcome, converting thrown + * errors into a graceful `isError` result instead of a raw protocol error. + */ +export async function safeJsonResult( + fn: () => Promise, +): Promise { + try { + return jsonResult(await fn()); + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error"; + return errorResult(message); + } +} diff --git a/src/lib/mcp/tools.ts b/src/lib/mcp/tools.ts new file mode 100644 index 00000000..80201eae --- /dev/null +++ b/src/lib/mcp/tools.ts @@ -0,0 +1,132 @@ +/** + * Registers the AI Developer Hub's read-only MCP tools on a given server. + * + * Kept decoupled from `mcp-handler`: `registerHubTools` accepts any object with + * a `registerTool` method, so the wiring can be unit-tested against a fake + * server without spinning up the transport. All handlers route through + * `safeJsonResult` so a thrown error degrades to an `isError` result rather + * than a raw protocol error. + */ + +import { z } from "zod"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; + +import { syncSourceTypeEnum } from "@/lib/db/schema"; +import { safeJsonResult } from "@/lib/mcp/format"; +import { + getBudgetStatusData, + getClaudeSpendSummaryData, + getCopilotUsageSummaryData, + getUserCostProfileData, + listAiToolsData, + listClaudeWorkspacesData, + listRecentSyncEventsData, +} from "@/lib/mcp/data"; + +const monthSchema = z + .string() + .regex(/^\d{4}-(0[1-9]|1[0-2])$/, "Expected month as YYYY-MM"); +const dateSchema = z + .string() + .regex( + /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/, + "Expected YYYY-MM-DD", + ); + +/** Minimal surface of McpServer.registerTool used here — eases fake-server tests. */ +export type ToolRegistrar = Pick; + +export function registerHubTools(server: ToolRegistrar): void { + server.registerTool( + "list_ai_tools", + { + title: "List AI tools", + description: + "List the active AI tools tracked by the Hub with their access tiers and monthly cost (cents and USD).", + inputSchema: {}, + }, + () => safeJsonResult(() => listAiToolsData()), + ); + + server.registerTool( + "get_user_cost_profile", + { + title: "Get user cost profile", + description: + "Get a user's active license assignments and their Claude API cost breakdown for a month. Looked up by exact email. Never returns API keys.", + inputSchema: { + email: z.string().email("Expected a valid email address"), + month: monthSchema.optional(), + }, + }, + ({ email, month }) => + safeJsonResult(() => getUserCostProfileData(email, month)), + ); + + server.registerTool( + "get_claude_spend_summary", + { + title: "Get Claude spend summary", + description: + "Org-wide Claude (Anthropic) spend KPIs for a month: month-to-date total, month-over-month delta, month-end projection, workspaces over 80% of cap, and today's estimate. Defaults to the current month.", + inputSchema: { + month: monthSchema.optional(), + }, + }, + ({ month }) => safeJsonResult(() => getClaudeSpendSummaryData(month)), + ); + + server.registerTool( + "list_claude_workspaces", + { + title: "List Claude workspaces", + description: + "List Anthropic workspaces with current-month spend, monthly cap, utilization %, and today's estimate, ordered by cap-utilization severity.", + inputSchema: {}, + }, + () => safeJsonResult(() => listClaudeWorkspacesData()), + ); + + server.registerTool( + "get_budget_status", + { + title: "Get budget status", + description: + "Annual budget status: per-period planned/billed/expected/actual spend plus an OLS forecast of the annual total and on-track / at-risk verdict. Defaults to the active budget; pass a fiscal year to target a specific one.", + inputSchema: { + fiscalYear: z.number().int().min(2000).max(2100).optional(), + }, + }, + ({ fiscalYear }) => safeJsonResult(() => getBudgetStatusData(fiscalYear)), + ); + + server.registerTool( + "get_copilot_usage_summary", + { + title: "Get GitHub Copilot usage summary", + description: + "GitHub Copilot seat/billing snapshot and aggregated usage (suggestions, acceptance rate, lines, chat turns, peak users) over a date range. Defaults to the last 28 days.", + inputSchema: { + since: dateSchema.optional(), + until: dateSchema.optional(), + }, + }, + ({ since, until }) => + safeJsonResult(() => getCopilotUsageSummaryData(since, until)), + ); + + server.registerTool( + "list_recent_sync_events", + { + title: "List recent sync events", + description: + "Recent data-pipeline sync events (with outcome and change counts) plus Claude-spend data freshness. Optionally filter by source type.", + inputSchema: { + sourceType: z.enum(syncSourceTypeEnum.enumValues).optional(), + limit: z.number().int().min(1).max(50).optional(), + }, + }, + ({ sourceType, limit }) => + safeJsonResult(() => listRecentSyncEventsData(sourceType, limit)), + ); +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 3f3d7e16..71d8ade7 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -5,11 +5,21 @@ export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } +/** + * Convert integer cents to a USD number rounded to 2 decimals (`1234` → `12.34`). + * Guards against non-finite input by returning 0. Shared by `formatCurrency` + * (display) and the MCP layer (machine-readable numbers). + */ +export function centsToUsd(cents: number): number { + if (!Number.isFinite(cents)) return 0; + return Math.round(cents) / 100; +} + export function formatCurrency(cents: number): string { return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", - }).format(cents / 100); + }).format(centsToUsd(cents)); } export function formatVariance(variance: number): string { @@ -25,7 +35,9 @@ export function varianceClassName(variance: number): string { } /** Normalize an optional string field: empty/undefined/null → null */ -export function normalizeField(value: string | undefined | null): string | null { +export function normalizeField( + value: string | undefined | null, +): string | null { if (value === undefined || value === null || value === "") return null; return value; } @@ -49,20 +61,33 @@ export function getChangedUserFields( discipline: string; githubUsername: string | null; profile: string | null; - } + }, ): string[] { const changed: string[] = []; if (row.name !== existing.name) changed.push("name"); - if (row.circle !== undefined && normalizeField(row.circle) !== existing.circle) changed.push("circle"); - if (row.role !== undefined && row.role !== existing.role) changed.push("role"); + if ( + row.circle !== undefined && + normalizeField(row.circle) !== existing.circle + ) + changed.push("circle"); + if (row.role !== undefined && row.role !== existing.role) + changed.push("role"); if ( row.disciplineProvided && row.discipline !== undefined && row.discipline !== existing.discipline ) changed.push("discipline"); - if (row.githubUsername !== undefined && normalizeField(row.githubUsername) !== existing.githubUsername) changed.push("githubUsername"); - if (row.profile !== undefined && normalizeField(row.profile) !== existing.profile) changed.push("profile"); + if ( + row.githubUsername !== undefined && + normalizeField(row.githubUsername) !== existing.githubUsername + ) + changed.push("githubUsername"); + if ( + row.profile !== undefined && + normalizeField(row.profile) !== existing.profile + ) + changed.push("profile"); return changed; } @@ -74,7 +99,7 @@ export function getCurrentMonth(): string { /** End of the previous calendar month, 23:59:59.999 UTC. */ export function getLastMonthEnd(now: Date = new Date()): Date { const firstOfThisMonth = new Date( - Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1) + Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1), ); return new Date(firstOfThisMonth.getTime() - 1); } @@ -86,7 +111,7 @@ export function getLastMonthEnd(now: Date = new Date()): Date { export function projectMonthEnd( mtdCents: number, daysElapsed: number, - daysInMonth: number + daysInMonth: number, ): number { if (daysElapsed === 0) return 0; return Math.round((mtdCents / daysElapsed) * daysInMonth); @@ -139,7 +164,9 @@ export function formatUtcDateOnly(d: Date): string { * keyed to UTC, so the projection denominator can't skew at a UTC month boundary. */ export function getUtcDaysInMonth(d: Date): number { - return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0)).getUTCDate(); + return new Date( + Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0), + ).getUTCDate(); } // Sentinel values for faceted filters on nullable columns. @@ -153,7 +180,9 @@ export const NO_WORKSPACE_SENTINEL = "__no_workspace__"; * `/reports` so legacy "n/a"/"none" sentinels collapse with real nulls. Do not reuse for persistence * paths without coordinating, since user import/update flows currently rely on `normalizeField`. */ -export function normalizeCircle(circle: string | null | undefined): string | null { +export function normalizeCircle( + circle: string | null | undefined, +): string | null { if (circle === null || circle === undefined) return null; const trimmed = circle.trim(); if (trimmed === "") return null; diff --git a/src/middleware.ts b/src/middleware.ts index 4b05316c..73858b2c 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -10,7 +10,7 @@ export default auth((req) => { if (!req.auth && !isPublicPath(pathname)) { const callbackUrl = encodeURIComponent(pathname + search); return NextResponse.redirect( - new URL(`/login?callbackUrl=${callbackUrl}`, req.url) + new URL(`/login?callbackUrl=${callbackUrl}`, req.url), ); } @@ -41,6 +41,6 @@ export default auth((req) => { export const config = { matcher: [ - "/((?!_next/static|_next/image|favicon\\.ico|api/auth|api/sync|api/invoices/ingest|api/license-requests/ingest|api/profile|api/agent/session).*)", + "/((?!_next/static|_next/image|favicon\\.ico|api/auth|api/sync|api/invoices/ingest|api/license-requests/ingest|api/profile|api/agent/session|api/mcp).*)", ], }; diff --git a/tests/unit/mcp/auth.test.ts b/tests/unit/mcp/auth.test.ts new file mode 100644 index 00000000..78747ea6 --- /dev/null +++ b/tests/unit/mcp/auth.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { safeEqual, verifyMcpToken } from "@/lib/mcp/auth"; + +const SECRET = "super-secret-mcp-token-123456"; + +function req(): Request { + return new Request("http://localhost:3000/api/mcp/mcp", { method: "POST" }); +} + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("safeEqual", () => { + it("returns true for identical strings", () => { + expect(safeEqual("abc", "abc")).toBe(true); + }); + + it("returns false for differing strings of equal length", () => { + expect(safeEqual("abc", "abd")).toBe(false); + }); + + it("returns false for differing lengths without throwing", () => { + expect(safeEqual("abc", "abcd")).toBe(false); + expect(safeEqual("", "x")).toBe(false); + }); +}); + +describe("verifyMcpToken", () => { + it("rejects when MCP_SERVER_SECRET is not set", () => { + vi.stubEnv("MCP_SERVER_SECRET", ""); + expect(verifyMcpToken(req(), "anything")).toBeUndefined(); + }); + + it("rejects when no bearer token is provided", () => { + vi.stubEnv("MCP_SERVER_SECRET", SECRET); + expect(verifyMcpToken(req(), undefined)).toBeUndefined(); + }); + + it("rejects an incorrect token", () => { + vi.stubEnv("MCP_SERVER_SECRET", SECRET); + expect(verifyMcpToken(req(), "wrong-token")).toBeUndefined(); + }); + + it("returns AuthInfo for the correct token", () => { + vi.stubEnv("MCP_SERVER_SECRET", SECRET); + const info = verifyMcpToken(req(), SECRET); + expect(info).toEqual({ + token: SECRET, + clientId: "mcp-shared-secret", + scopes: [], + }); + }); +}); diff --git a/tests/unit/mcp/data.test.ts b/tests/unit/mcp/data.test.ts new file mode 100644 index 00000000..12c91355 --- /dev/null +++ b/tests/unit/mcp/data.test.ts @@ -0,0 +1,462 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { ProfileData } from "@/types"; + +// ── Chainable db.select() mock ─────────────────────────────────────────────── +// Each db.select() call consumes the next queued result; the chain is awaitable +// at any terminal (.where(), .limit(), ...) since every method returns itself. + +interface SelectChain { + from: () => SelectChain; + where: () => SelectChain; + innerJoin: () => SelectChain; + orderBy: () => SelectChain; + limit: () => SelectChain; + then: ( + resolve: (rows: unknown) => unknown, + reject?: (e: unknown) => unknown, + ) => Promise; +} + +const { + selectQueue, + mockUsersFindFirst, + mockAnnualFindFirst, + mockGithubFindFirst, + mockBillingFindFirst, +} = vi.hoisted(() => ({ + selectQueue: [] as unknown[], + mockUsersFindFirst: vi.fn(), + mockAnnualFindFirst: vi.fn(), + mockGithubFindFirst: vi.fn(), + mockBillingFindFirst: vi.fn(), +})); + +vi.mock("@/lib/db", () => { + const selectChain = (rows: unknown): SelectChain => { + const chain: SelectChain = { + from: () => chain, + where: () => chain, + innerJoin: () => chain, + orderBy: () => chain, + limit: () => chain, + then: (resolve, reject) => Promise.resolve(rows).then(resolve, reject), + }; + return chain; + }; + return { + db: { + select: () => selectChain(selectQueue.shift()), + query: { + users: { findFirst: mockUsersFindFirst }, + annualBudgets: { findFirst: mockAnnualFindFirst }, + githubConnections: { findFirst: mockGithubFindFirst }, + copilotBillingSnapshots: { findFirst: mockBillingFindFirst }, + }, + }, + }; +}); + +vi.mock("@/lib/profile-data", () => ({ + fetchProfileDataInternal: vi.fn(), +})); +vi.mock("@/lib/anthropic/queries", () => ({ + loadDashboardKpis: vi.fn(), + loadWorkspaceList: vi.fn(), + loadSyncStatus: vi.fn(), +})); +vi.mock("@/actions/budget", () => ({ + getActiveBudget: vi.fn(), + getBudgetWithCosts: vi.fn(), + fetchActualByPeriod: vi.fn(), +})); + +// ── Import after mocks ─────────────────────────────────────────────────────── + +import { + listAiToolsData, + getUserCostProfileData, + getClaudeSpendSummaryData, + listClaudeWorkspacesData, + getBudgetStatusData, + getCopilotUsageSummaryData, + listRecentSyncEventsData, +} from "@/lib/mcp/data"; +import { fetchProfileDataInternal } from "@/lib/profile-data"; +import { + loadDashboardKpis, + loadWorkspaceList, + loadSyncStatus, +} from "@/lib/anthropic/queries"; +import { + getActiveBudget, + getBudgetWithCosts, + fetchActualByPeriod, +} from "@/actions/budget"; + +beforeEach(() => { + vi.clearAllMocks(); + selectQueue.length = 0; +}); + +// ───────────────────────────────────────────────────────────────────────────── + +describe("listAiToolsData", () => { + it("groups active tiers under their tool and converts cost to USD", async () => { + selectQueue.push( + [{ id: 1, name: "Claude API", vendor: "Anthropic", status: "active" }], + [ + { + id: 10, + toolId: 1, + name: "Team", + monthlyCostCents: 2500, + isActive: true, + }, + ], + ); + + const result = await listAiToolsData(); + expect(result.tools).toHaveLength(1); + expect(result.tools[0]).toMatchObject({ + id: 1, + name: "Claude API", + vendor: "Anthropic", + }); + expect(result.tools[0].tiers[0]).toEqual({ + id: 10, + name: "Team", + monthlyCostCents: 2500, + monthlyCostUsd: 25, + }); + }); + + it("returns a tool with no tiers when none are active", async () => { + selectQueue.push( + [{ id: 2, name: "Cursor", vendor: "Anysphere", status: "active" }], + [], + ); + const result = await listAiToolsData(); + expect(result.tools[0].tiers).toEqual([]); + }); +}); + +describe("getUserCostProfileData", () => { + const profile: ProfileData = { + user: { + id: 1, + name: "Jane", + email: "jane@example.com", + role: "viewer", + circle: "Platform", + profile: "boost", + discipline: "developer", + }, + assignments: [ + { + id: 7, + toolName: "Claude API", + tierName: "Team", + assignedAt: new Date("2026-01-15T00:00:00Z"), + status: "active", + }, + ], + costData: { + available: true, + monthlyTotalCents: 4200, + latestDataDate: "2026-05-20", + hasUnresolvedPricing: false, + dailyBreakdown: [ + { + date: "2026-05-20", + totalCents: 4200, + models: [ + { + model: "claude-opus-4", + costCents: 4200, + inputTokens: 1000, + outputTokens: 500, + }, + ], + }, + ], + }, + }; + + it("throws when the user is not found", async () => { + mockUsersFindFirst.mockResolvedValue(undefined); + await expect(getUserCostProfileData("missing@example.com")).rejects.toThrow( + /No user found/, + ); + }); + + it("shapes profile + cost data with USD and ISO sync timestamp", async () => { + mockUsersFindFirst.mockResolvedValue({ id: 1 }); + vi.mocked(fetchProfileDataInternal).mockResolvedValue(profile); + selectQueue.push([ + { lastSyncCompletedAt: new Date("2026-05-21T08:00:00Z") }, + ]); + + const result = await getUserCostProfileData("jane@example.com", "2026-05"); + expect(result.user.email).toBe("jane@example.com"); + expect(result.assignments[0].assignedAt).toBe("2026-01-15T00:00:00.000Z"); + expect(result.costData.monthlyTotalUsd).toBe(42); + expect(result.costData.month).toBe("2026-05"); + expect(result.costData.lastSyncAt).toBe("2026-05-21T08:00:00.000Z"); + expect(result.costData.dailyBreakdown[0].models[0]).toEqual({ + model: "claude-opus-4", + costCents: 4200, + costUsd: 42, + inputTokens: 1000, + outputTokens: 500, + }); + }); + + it("tolerates a missing sync row", async () => { + mockUsersFindFirst.mockResolvedValue({ id: 1 }); + vi.mocked(fetchProfileDataInternal).mockResolvedValue(profile); + selectQueue.push([]); + const result = await getUserCostProfileData("jane@example.com"); + expect(result.costData.lastSyncAt).toBeNull(); + }); +}); + +describe("getClaudeSpendSummaryData", () => { + it("converts KPI cents to USD and maps today estimate", async () => { + vi.mocked(loadDashboardKpis).mockResolvedValue({ + totalCents: 100000, + priorMonthCents: 80000, + momDeltaCents: 20000, + momDeltaPct: 25, + projectedMonthEndCents: 150000, + workspacesOverEightyCount: 1, + workspacesWithLimitCount: 3, + topOverWorkspaceName: "Engineering", + topOverWorkspaceUtilizationPct: 92, + todayEstimate: { + cents: 5000, + rawUserCents: 4800, + calibration: 1.04, + confident: true, + asOfIso: "2026-05-21T08:00:00.000Z", + }, + }); + + const result = await getClaudeSpendSummaryData("2026-05"); + expect(result.month).toBe("2026-05"); + expect(result.totalUsd).toBe(1000); + expect(result.momDeltaUsd).toBe(200); + expect(result.todayEstimate).toEqual({ + estimatedCents: 5000, + estimatedUsd: 50, + confident: true, + asOf: "2026-05-21T08:00:00.000Z", + }); + expect(loadDashboardKpis).toHaveBeenCalledWith("2026-05"); + }); + + it("passes null today estimate through", async () => { + vi.mocked(loadDashboardKpis).mockResolvedValue({ + totalCents: 0, + priorMonthCents: 0, + momDeltaCents: 0, + momDeltaPct: null, + projectedMonthEndCents: 0, + workspacesOverEightyCount: 0, + workspacesWithLimitCount: 0, + topOverWorkspaceName: null, + topOverWorkspaceUtilizationPct: null, + todayEstimate: null, + }); + const result = await getClaudeSpendSummaryData("2026-05"); + expect(result.todayEstimate).toBeNull(); + }); +}); + +describe("listClaudeWorkspacesData", () => { + it("maps workspaces with USD cost, cap, and utilization", async () => { + vi.mocked(loadWorkspaceList).mockResolvedValue([ + { + workspaceId: "ws_1", + name: "Engineering", + isDefault: false, + isArchived: false, + currentMonthCents: 90000, + limitCents: 100000, + utilizationPct: 90, + displayColor: "#abc", + todayEstimate: null, + }, + ]); + const result = await listClaudeWorkspacesData(); + expect(result.workspaces[0]).toMatchObject({ + workspaceId: "ws_1", + name: "Engineering", + currentMonthCents: 90000, + currentMonthUsd: 900, + limitUsd: 1000, + utilizationPct: 90, + }); + }); +}); + +describe("getBudgetStatusData", () => { + const budget = { + id: 5, + fiscalYear: 2026, + status: "active", + periodType: "monthly", + totalAmountCents: 1200000, + periods: [ + { + id: 50, + periodLabel: "Jan 2026", + periodIndex: 0, + startDate: "2026-01-01", + endDate: "2026-01-31", + plannedAmountCents: 100000, + billedTotalCents: 95000, + expectedSpendCents: 98000, + }, + ], + }; + + it("throws when there is no active budget", async () => { + vi.mocked(getActiveBudget).mockResolvedValue(undefined); + await expect(getBudgetStatusData()).rejects.toThrow(/No active budget/); + }); + + it("assembles per-period actuals and a forecast verdict", async () => { + vi.mocked(getActiveBudget).mockResolvedValue( + budget as unknown as Awaited>, + ); + vi.mocked(getBudgetWithCosts).mockResolvedValue( + budget as unknown as Awaited>, + ); + vi.mocked(fetchActualByPeriod).mockResolvedValue(new Map([[50, 96000]])); + + const result = await getBudgetStatusData(); + expect(result.fiscalYear).toBe(2026); + expect(result.totalAmountUsd).toBe(12000); + expect(result.periods[0]).toMatchObject({ + label: "Jan 2026", + plannedUsd: 1000, + billedUsd: 950, + actualCents: 96000, + actualUsd: 960, + }); + expect(["on_track", "at_risk"]).toContain(result.forecast.status); + }); + + it("looks up by fiscal year when provided", async () => { + mockAnnualFindFirst.mockResolvedValue({ id: 5 }); + vi.mocked(getBudgetWithCosts).mockResolvedValue( + budget as unknown as Awaited>, + ); + vi.mocked(fetchActualByPeriod).mockResolvedValue(new Map([[50, 96000]])); + const result = await getBudgetStatusData(2026); + expect(result.fiscalYear).toBe(2026); + expect(getActiveBudget).not.toHaveBeenCalled(); + }); +}); + +describe("getCopilotUsageSummaryData", () => { + it("returns connected:false when no active GitHub connection", async () => { + mockGithubFindFirst.mockResolvedValue(undefined); + const result = await getCopilotUsageSummaryData(); + expect(result).toMatchObject({ connected: false }); + }); + + it("aggregates usage rows and surfaces latest billing in USD", async () => { + mockGithubFindFirst.mockResolvedValue({ id: 3, orgLogin: "acme" }); + selectQueue.push([ + { + totalActiveUsers: 10, + totalEngagedUsers: 8, + totalSuggestions: 100, + totalAcceptances: 40, + totalLinesSuggested: 500, + totalLinesAccepted: 200, + totalChatTurns: 30, + }, + { + totalActiveUsers: 14, + totalEngagedUsers: 12, + totalSuggestions: 100, + totalAcceptances: 60, + totalLinesSuggested: 500, + totalLinesAccepted: 300, + totalChatTurns: null, + }, + ]); + mockBillingFindFirst.mockResolvedValue({ + billingMonth: "2026-05-01", + planType: "business", + totalSeats: 50, + activeSeats: 44, + seatCostCents: 1900, + totalCostCents: 83600, + }); + + const result = await getCopilotUsageSummaryData("2026-05-01", "2026-05-31"); + expect(result).toMatchObject({ connected: true, org: "acme" }); + if (!result.connected) throw new Error("expected connected"); + expect(result.usage.daysWithData).toBe(2); + expect(result.usage.totalSuggestions).toBe(200); + expect(result.usage.totalAcceptances).toBe(100); + expect(result.usage.acceptanceRatePct).toBe(50); + expect(result.usage.totalChatTurns).toBe(30); + expect(result.usage.peakActiveUsers).toBe(14); + expect(result.latestBilling).toMatchObject({ + seatCostUsd: 19, + totalCostUsd: 836, + }); + }); + + it("reports null acceptance rate when there are no suggestions", async () => { + mockGithubFindFirst.mockResolvedValue({ id: 3, orgLogin: "acme" }); + selectQueue.push([]); + mockBillingFindFirst.mockResolvedValue(undefined); + const result = await getCopilotUsageSummaryData(); + if (!result.connected) throw new Error("expected connected"); + expect(result.usage.acceptanceRatePct).toBeNull(); + expect(result.latestBilling).toBeNull(); + }); +}); + +describe("listRecentSyncEventsData", () => { + it("maps events to ISO timestamps and includes freshness", async () => { + selectQueue.push([ + { + id: 1, + sourceType: "anthropic_api_costs", + outcome: "success", + startedAt: new Date("2026-05-21T08:00:00Z"), + completedAt: new Date("2026-05-21T08:01:00Z"), + createdCount: 5, + updatedCount: 2, + skippedCount: 0, + errorCount: 0, + errorMessage: null, + }, + ]); + vi.mocked(loadSyncStatus).mockResolvedValue({ + lastSyncedAt: new Date("2026-05-21T08:01:00Z"), + ageMinutes: 10, + isStale: false, + }); + + const result = await listRecentSyncEventsData(); + expect(result.claudeSpendFreshness).toEqual({ + lastSyncedAt: "2026-05-21T08:01:00.000Z", + ageMinutes: 10, + isStale: false, + }); + expect(result.events[0]).toMatchObject({ + id: 1, + sourceType: "anthropic_api_costs", + outcome: "success", + startedAt: "2026-05-21T08:00:00.000Z", + completedAt: "2026-05-21T08:01:00.000Z", + }); + }); +}); diff --git a/tests/unit/mcp/format.test.ts b/tests/unit/mcp/format.test.ts new file mode 100644 index 00000000..a07f48f8 --- /dev/null +++ b/tests/unit/mcp/format.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect } from "vitest"; +import { + centsToUsd, + usd, + jsonResult, + errorResult, + safeJsonResult, +} from "@/lib/mcp/format"; + +describe("centsToUsd", () => { + it("converts cents to a 2-decimal USD number", () => { + expect(centsToUsd(1234)).toBe(12.34); + expect(centsToUsd(0)).toBe(0); + expect(centsToUsd(5)).toBe(0.05); + expect(centsToUsd(100)).toBe(1); + }); + + it("rounds fractional cents before dividing", () => { + expect(centsToUsd(1234.6)).toBe(12.35); + }); + + it("returns 0 for non-finite input", () => { + expect(centsToUsd(NaN)).toBe(0); + expect(centsToUsd(Infinity)).toBe(0); + }); +}); + +describe("usd", () => { + it("emits a Cents and Usd sibling pair", () => { + expect(usd("total", 1234)).toEqual({ totalCents: 1234, totalUsd: 12.34 }); + }); + + it("emits nulls for null/undefined", () => { + expect(usd("total", null)).toEqual({ totalCents: null, totalUsd: null }); + expect(usd("total", undefined)).toEqual({ + totalCents: null, + totalUsd: null, + }); + }); + + it("treats 0 as a real value, not missing", () => { + expect(usd("total", 0)).toEqual({ totalCents: 0, totalUsd: 0 }); + }); +}); + +describe("jsonResult / errorResult", () => { + it("wraps data as pretty JSON text content", () => { + const result = jsonResult({ a: 1 }); + expect(result.isError).toBeUndefined(); + expect(result.content[0].type).toBe("text"); + expect(JSON.parse(result.content[0].text)).toEqual({ a: 1 }); + }); + + it("marks error results with isError and an Error: prefix", () => { + const result = errorResult("boom"); + expect(result.isError).toBe(true); + expect(result.content[0].text).toBe("Error: boom"); + }); +}); + +describe("safeJsonResult", () => { + it("serializes a successful result", async () => { + const result = await safeJsonResult(async () => ({ ok: true })); + expect(result.isError).toBeUndefined(); + expect(JSON.parse(result.content[0].text)).toEqual({ ok: true }); + }); + + it("converts a thrown Error into an isError result", async () => { + const result = await safeJsonResult(async () => { + throw new Error("nope"); + }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toBe("Error: nope"); + }); + + it("handles non-Error throws", async () => { + const result = await safeJsonResult(async () => { + throw "string failure"; + }); + expect(result.isError).toBe(true); + expect(result.content[0].text).toBe("Error: Unknown error"); + }); +}); diff --git a/tests/unit/mcp/tools.test.ts b/tests/unit/mcp/tools.test.ts new file mode 100644 index 00000000..5fa62ec4 --- /dev/null +++ b/tests/unit/mcp/tools.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { McpToolResult } from "@/lib/mcp/format"; + +vi.mock("@/lib/mcp/data", () => ({ + listAiToolsData: vi.fn(), + getUserCostProfileData: vi.fn(), + getClaudeSpendSummaryData: vi.fn(), + listClaudeWorkspacesData: vi.fn(), + getBudgetStatusData: vi.fn(), + getCopilotUsageSummaryData: vi.fn(), + listRecentSyncEventsData: vi.fn(), +})); + +import { registerHubTools, type ToolRegistrar } from "@/lib/mcp/tools"; +import { + listAiToolsData, + getUserCostProfileData, + getClaudeSpendSummaryData, +} from "@/lib/mcp/data"; + +type Handler = (args: Record) => Promise; + +function collectHandlers(): Map { + const handlers = new Map(); + const fakeServer = { + registerTool: (name: string, _meta: unknown, handler: Handler) => { + handlers.set(name, handler); + return undefined; + }, + }; + registerHubTools(fakeServer as unknown as ToolRegistrar); + return handlers; +} + +const EXPECTED_TOOLS = [ + "list_ai_tools", + "get_user_cost_profile", + "get_claude_spend_summary", + "list_claude_workspaces", + "get_budget_status", + "get_copilot_usage_summary", + "list_recent_sync_events", +]; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("registerHubTools", () => { + it("registers exactly the expected read-only tools", () => { + const handlers = collectHandlers(); + expect([...handlers.keys()].sort()).toEqual([...EXPECTED_TOOLS].sort()); + }); + + it("routes a successful call through jsonResult", async () => { + vi.mocked(listAiToolsData).mockResolvedValue({ tools: [] }); + const handlers = collectHandlers(); + const result = await handlers.get("list_ai_tools")!({}); + expect(result.isError).toBeUndefined(); + expect(JSON.parse(result.content[0].text)).toEqual({ tools: [] }); + }); + + it("forwards validated args to the data layer", async () => { + vi.mocked(getUserCostProfileData).mockResolvedValue({ + user: {}, + assignments: [], + costData: {}, + } as unknown as Awaited>); + const handlers = collectHandlers(); + await handlers.get("get_user_cost_profile")!({ + email: "a@b.com", + month: "2026-05", + }); + expect(getUserCostProfileData).toHaveBeenCalledWith("a@b.com", "2026-05"); + }); + + it("degrades a thrown error into an isError result", async () => { + vi.mocked(getClaudeSpendSummaryData).mockRejectedValue( + new Error("db down"), + ); + const handlers = collectHandlers(); + const result = await handlers.get("get_claude_spend_summary")!({}); + expect(result.isError).toBe(true); + expect(result.content[0].text).toBe("Error: db down"); + }); +});