contractor-license-mcp-server 0.6.4 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,12 +1,12 @@
1
1
  # contractor-license-mcp-server
2
2
 
3
- **Real-time contractor license verification across 45 US states.** An [MCP server](https://modelcontextprotocol.io) that lets Claude Desktop, Cursor, and any MCP-compatible AI agent verify a contractor's license, status, expiration, and disciplinary history directly against state licensing board portals.
3
+ **Real-time contractor license verification across all 50 US states + DC, plus 8 major-city contractor licensing portals (Chicago, NYC, Philadelphia, Detroit, Atlanta, Dallas, Las Vegas, Nashville).** An [MCP server](https://modelcontextprotocol.io) that lets Claude Desktop, Claude Code, Cursor, Windsurf, and any MCP-compatible AI agent verify a contractor's license, status, expiration, and disciplinary history directly against licensing board portals.
4
4
 
5
5
  Send `{state, license_number, trade}` — get back validity, licensee name, expiration date, status, and any disciplinary actions on file. Results are fetched live from official state portals (no stale nightly exports) and cached for 24 hours when active.
6
6
 
7
7
  ## Why this server
8
8
 
9
- - **45 states** covered via official state licensing board portals, not third-party data aggregators
9
+ - **All 50 US states + DC + 8 major cities** covered via official licensing board portals, not third-party data aggregators
10
10
  - **Live lookups** — each verification hits the authoritative portal, so expirations and disciplinary actions are as fresh as the board's own data
11
11
  - **Batch verification** — up to 25 licenses per call, run in parallel
12
12
  - **Disciplinary history** — returned when the portal exposes it
@@ -14,13 +14,33 @@ Send `{state, license_number, trade}` — get back validity, licensee name, expi
14
14
 
15
15
  ## Quick start
16
16
 
17
- ### Claude Desktop
17
+ ### Hosted (recommended)
18
18
 
19
- Add this to your Claude Desktop config:
19
+ No install required. Add this to your Claude Desktop config:
20
20
 
21
21
  - macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`
22
22
  - Windows: `%APPDATA%\Claude\claude_desktop_config.json`
23
23
 
24
+ ```json
25
+ {
26
+ "mcpServers": {
27
+ "tradesapi": {
28
+ "type": "streamable-http",
29
+ "url": "https://www.tradesapi.com/mcp",
30
+ "headers": {
31
+ "Authorization": "Bearer YOUR_API_KEY"
32
+ }
33
+ }
34
+ }
35
+ }
36
+ ```
37
+
38
+ Replace `YOUR_API_KEY` with the key from your dashboard and restart Claude Desktop.
39
+
40
+ ### Local install (alternative)
41
+
42
+ If you prefer to run the MCP server locally via stdio:
43
+
24
44
  ```json
25
45
  {
26
46
  "mcpServers": {
@@ -54,15 +74,16 @@ npm install -g contractor-license-mcp-server
54
74
 
55
75
  ## Tools
56
76
 
57
- ### `clv_verify_license`
77
+ ### `verify_license`
58
78
 
59
- Verify a single contractor license against a state licensing board portal.
79
+ Verify a single contractor license against the official state (or city) licensing portal.
60
80
 
61
81
  | Parameter | Required | Description |
62
82
  |---|---|---|
63
83
  | `state` | yes | Two-letter state code (`CA`, `TX`, `FL`, ...) |
84
+ | `city` | no | Optional city slug to target a municipal portal: `chicago`, `nyc`, `philadelphia`, `detroit`, `atlanta`, `dallas`, `lasvegas`, `nashville`. Lowercase, no spaces. |
64
85
  | `license_number` | yes | The license number to verify |
65
- | `trade` | no | `general`, `electrical`, `plumbing`, `hvac`, `mechanical`, `residential`, or `home_inspection` (defaults to `general`) |
86
+ | `trade` | no | `general`, `electrical`, `plumbing`, `hvac`, `mechanical`, `roofing`, `residential`, ... (defaults to `general`) |
66
87
  | `force_refresh` | no | Bypass the 24h cache and re-fetch from the portal |
67
88
  | `response_format` | no | `markdown` (default) or `json` |
68
89
 
@@ -81,74 +102,45 @@ Verify a single contractor license against a state licensing board portal.
81
102
  | Expiration | 05/12/2026 |
82
103
  ```
83
104
 
84
- ### `clv_batch_verify`
105
+ ### `batch_verify`
85
106
 
86
- Verify up to 25 licenses in a single call. Each verification runs independently — partial failures do not block the batch.
107
+ Verify up to 25 licenses in a single call. Each verification runs independently — partial failures do not block the batch. Per-item `city` is supported.
87
108
 
88
109
  | Parameter | Required | Description |
89
110
  |---|---|---|
90
- | `licenses` | yes | Array of `{ state, license_number, trade }` objects (1–25 items) |
111
+ | `licenses` | yes | Array of `{ state, city?, license_number, trade }` objects (1–25 items) |
91
112
  | `response_format` | no | `markdown` (default) or `json` |
92
113
 
93
- ### `clv_list_supported_states`
114
+ ### `search_by_name`
94
115
 
95
- List all supported states, including portal URLs and available trades. Use this to see which states and trades you can query.
116
+ Fuzzy-match contractors by business or individual name within a single state (or city) database. Costs 2 credits per call.
96
117
 
97
118
  | Parameter | Required | Description |
98
119
  |---|---|---|
120
+ | `state` | yes | Two-letter state code |
121
+ | `city` | no | Optional city slug for municipal databases |
122
+ | `name` | yes | Business or individual name (case-insensitive, partial-match tolerant) |
123
+ | `trade` | no | Trade filter |
124
+ | `limit` | no | Max results (1–50, default 20) |
99
125
  | `response_format` | no | `markdown` (default) or `json` |
100
126
 
101
- ## Supported states
127
+ Not every state portal supports name search — call `list_supported_states` and check `supports_name_search` per jurisdiction first.
128
+
129
+ ### `list_supported_states`
102
130
 
103
- | Code | State | Trades |
131
+ List every supported jurisdiction with portal URLs, current health, available trades, and registered municipal scrapers nested under each state. Use this to discover what's reachable before constructing other tool calls.
132
+
133
+ | Parameter | Required | Description |
104
134
  |---|---|---|
105
- | AK | Alaska | general, electrical, mechanical |
106
- | AL | Alabama | general, electrical, plumbing, hvac, residential |
107
- | AR | Arkansas | general |
108
- | AZ | Arizona | general, electrical, plumbing, hvac |
109
- | CA | California | general, electrical, plumbing, hvac |
110
- | CO | Colorado | electrical, plumbing |
111
- | CT | Connecticut | general, electrical, plumbing, hvac |
112
- | DC | District of Columbia | general |
113
- | DE | Delaware | electrical, plumbing, hvac |
114
- | FL | Florida | general, electrical, plumbing, hvac |
115
- | GA | Georgia | general |
116
- | HI | Hawaii | general |
117
- | IA | Iowa | electrical |
118
- | ID | Idaho | electrical, plumbing, hvac |
119
- | IL | Illinois | general, electrical, plumbing, hvac |
120
- | IN | Indiana | plumbing |
121
- | KY | Kentucky | general, electrical, hvac, plumbing |
122
- | LA | Louisiana | general |
123
- | MA | Massachusetts | general, mechanical |
124
- | MD | Maryland | general, hvac, electrical, plumbing |
125
- | ME | Maine | electrical, plumbing |
126
- | MI | Michigan | electrical, plumbing, hvac |
127
- | MN | Minnesota | general, electrical, plumbing |
128
- | MS | Mississippi | general |
129
- | NC | North Carolina | general |
130
- | ND | North Dakota | general, electrical |
131
- | NE | Nebraska | general, electrical |
132
- | NH | New Hampshire | electrical, plumbing |
133
- | NJ | New Jersey | general, electrical, hvac, plumbing |
134
- | NM | New Mexico | general, electrical, plumbing, hvac |
135
- | NV | Nevada | general, electrical, plumbing, hvac |
136
- | NY | New York | home_inspection |
137
- | OH | Ohio | general, electrical, plumbing, hvac |
138
- | OK | Oklahoma | electrical, plumbing, hvac |
139
- | OR | Oregon | general |
140
- | PA | Pennsylvania | general, electrical, hvac, plumbing |
141
- | RI | Rhode Island | general |
142
- | SC | South Carolina | general, electrical, plumbing, hvac |
143
- | TN | Tennessee | general, electrical, plumbing |
144
- | TX | Texas | hvac, electrical, plumbing |
145
- | UT | Utah | general, electrical, plumbing, hvac |
146
- | VA | Virginia | general, electrical, plumbing, hvac |
147
- | VT | Vermont | electrical, plumbing |
148
- | WA | Washington | general |
149
- | WV | West Virginia | general, electrical, hvac, plumbing |
150
-
151
- Coverage expands continuously. Run `clv_list_supported_states` from your agent for the current list, or see the live state grid at [www.tradesapi.com](https://www.tradesapi.com).
135
+ | `response_format` | no | `markdown` (default) or `json` |
136
+
137
+ ## Coverage
138
+
139
+ All 50 US states + DC at the state level, plus 8 major-city contractor licensing portals (Chicago, NYC, Philadelphia, Detroit, Atlanta, Dallas, Las Vegas, Nashville).
140
+
141
+ Run `list_supported_states` from your agent for the live, fetched-fresh-each-call list of supported jurisdictions, available trades per jurisdiction, current portal health, and which states support name search. The MCP package no longer bundles a static state table — what comes back from `list_supported_states` is always current with prod.
142
+
143
+ You can also see the live state grid at [www.tradesapi.com](https://www.tradesapi.com).
152
144
 
153
145
  ## Configuration
154
146
 
@@ -164,8 +156,8 @@ Each license verification consumes **1 credit**, whether the result is fresh or
164
156
  ## Development
165
157
 
166
158
  ```bash
167
- git clone https://github.com/Noquarter6/contractor-license-mcp-server.git
168
- cd contractor-license-mcp-server
159
+ git clone https://github.com/jackunderwood/Contractor-License-Verification.git
160
+ cd Contractor-License-Verification/mcp-server
169
161
  npm install
170
162
  npm run build
171
163
  npm test
package/dist/api.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { LicenseResult, SearchResponse } from "./types.js";
1
+ import type { BatchItem, BatchResponse, LicenseResult, SearchResponse, StatesApiResponse } from "./types.js";
2
2
  export declare class ApiError extends Error {
3
3
  statusCode: number;
4
4
  retryAfter?: number | undefined;
@@ -6,14 +6,16 @@ export declare class ApiError extends Error {
6
6
  }
7
7
  export declare class ApiClient {
8
8
  private http;
9
- constructor(baseURL: string, apiKey: string);
10
- verify(state: string, licenseNumber: string, trade: string): Promise<LicenseResult>;
11
- search(state: string, name: string, trade: string, limit: number): Promise<SearchResponse>;
9
+ constructor(baseURL: string, apiKey: string, extraHeaders?: Record<string, string>);
10
+ verify(state: string, licenseNumber: string, trade: string, city?: string, forceRefresh?: boolean): Promise<LicenseResult>;
11
+ batch(items: BatchItem[]): Promise<BatchResponse>;
12
+ search(state: string, name: string, trade: string, limit: number, city?: string): Promise<SearchResponse>;
12
13
  health(): Promise<{
13
14
  status: string;
14
15
  api: string;
15
16
  database: string;
16
17
  redis: string;
17
18
  }>;
19
+ states(): Promise<StatesApiResponse>;
18
20
  private wrapError;
19
21
  }
package/dist/api.js CHANGED
@@ -11,29 +11,56 @@ export class ApiError extends Error {
11
11
  }
12
12
  export class ApiClient {
13
13
  http;
14
- constructor(baseURL, apiKey) {
14
+ constructor(baseURL, apiKey, extraHeaders = {}) {
15
+ const headers = { ...extraHeaders };
16
+ if (apiKey)
17
+ headers["X-API-Key"] = apiKey;
15
18
  this.http = axios.create({
16
19
  baseURL,
17
- headers: { "X-API-Key": apiKey },
18
- timeout: 120_000, // Portal lookups can be slow
20
+ headers,
21
+ timeout: 120_000,
19
22
  });
20
23
  }
21
- async verify(state, licenseNumber, trade) {
24
+ async verify(state, licenseNumber, trade, city, forceRefresh) {
22
25
  try {
23
- const { data } = await this.http.get("/verify", {
24
- params: { state, license: licenseNumber, trade },
25
- });
26
+ const params = { state, license: licenseNumber, trade };
27
+ if (city)
28
+ params.city = city;
29
+ if (forceRefresh)
30
+ params.fresh = "true";
31
+ const { data } = await this.http.get("/verify", { params });
26
32
  return data;
27
33
  }
28
34
  catch (err) {
29
35
  throw this.wrapError(err);
30
36
  }
31
37
  }
32
- async search(state, name, trade, limit) {
38
+ async batch(items) {
33
39
  try {
34
- const { data } = await this.http.get("/search", {
35
- params: { state, name, trade, limit },
36
- });
40
+ const body = {
41
+ licenses: items.map((it) => ({
42
+ state: it.state,
43
+ ...(it.city ? { city: it.city } : {}),
44
+ license: it.license,
45
+ trade: it.trade,
46
+ })),
47
+ };
48
+ const { data } = await this.http.post("/batch", body);
49
+ return {
50
+ summary: { total: data.total, succeeded: data.succeeded, failed: data.failed },
51
+ results: data.results,
52
+ };
53
+ }
54
+ catch (err) {
55
+ throw this.wrapError(err);
56
+ }
57
+ }
58
+ async search(state, name, trade, limit, city) {
59
+ try {
60
+ const params = { state, name, trade, limit };
61
+ if (city)
62
+ params.city = city;
63
+ const { data } = await this.http.get("/search", { params });
37
64
  return data;
38
65
  }
39
66
  catch (err) {
@@ -49,6 +76,15 @@ export class ApiClient {
49
76
  throw this.wrapError(err);
50
77
  }
51
78
  }
79
+ async states() {
80
+ try {
81
+ const { data } = await this.http.get("/states");
82
+ return data;
83
+ }
84
+ catch (err) {
85
+ throw this.wrapError(err);
86
+ }
87
+ }
52
88
  wrapError(err) {
53
89
  if (err.isAxiosError && err.response) {
54
90
  const { status, data, headers } = err.response;
package/dist/format.js CHANGED
@@ -26,23 +26,48 @@ export function formatLicenseResult(result, format) {
26
26
  }
27
27
  return lines.join("\n");
28
28
  }
29
+ function statusIcon(status) {
30
+ switch (status) {
31
+ case "healthy":
32
+ return "OK";
33
+ case "degraded":
34
+ return "DEGRADED";
35
+ case "maintenance":
36
+ return "MAINTENANCE";
37
+ default:
38
+ return "DOWN";
39
+ }
40
+ }
29
41
  export function formatStatesList(states, format) {
42
+ const allMunis = states.flatMap((s) => s.municipalities ?? []);
30
43
  if (format === "json") {
31
- return JSON.stringify({ states, total: states.length }, null, 2);
44
+ return JSON.stringify({
45
+ total_states: states.length,
46
+ total_municipalities: allMunis.length,
47
+ states,
48
+ }, null, 2);
32
49
  }
50
+ const header = allMunis.length > 0
51
+ ? `## Supported Jurisdictions (${states.length} states + ${allMunis.length} cities)`
52
+ : `## Supported States (${states.length} states)`;
33
53
  const lines = [
34
- `## Supported States (${states.length} states)`,
54
+ header,
55
+ "",
56
+ "### States",
35
57
  "",
36
58
  "| State | Name | Status | Trades |",
37
59
  "|-------|------|--------|--------|",
38
60
  ];
39
61
  for (const s of states) {
40
- const statusIcon = s.status === "healthy"
41
- ? "OK"
42
- : s.status === "degraded"
43
- ? "DEGRADED"
44
- : "DOWN";
45
- lines.push(`| ${s.code} | ${s.name} | ${statusIcon} | ${s.trades.join(", ")} |`);
62
+ lines.push(`| ${s.code} | ${s.name} | ${statusIcon(s.status)} | ${s.trades.join(", ")} |`);
63
+ }
64
+ if (allMunis.length > 0) {
65
+ lines.push("", "### Cities (municipal scrapers)", "");
66
+ lines.push("| Code | City | Parent State | Status | Trades |");
67
+ lines.push("|------|------|--------------|--------|--------|");
68
+ for (const m of allMunis) {
69
+ lines.push(`| ${m.code} | ${m.city} | ${m.parent_state} | ${statusIcon(m.status)} | ${m.trades.join(", ")} |`);
70
+ }
46
71
  }
47
72
  return lines.join("\n");
48
73
  }
package/dist/http.d.ts ADDED
@@ -0,0 +1,9 @@
1
+ export interface AuthResult {
2
+ /** api_key_id when authenticated via JWT, or undefined when authenticated via raw API key */
3
+ apiKeyId?: string;
4
+ /** Raw token (either JWT or raw API key) — used to build ApiClient */
5
+ token: string;
6
+ /** True if this was a JWT (and therefore Node uses X-Internal-Secret + X-Api-Key-Id when calling FastAPI) */
7
+ isJwt: boolean;
8
+ }
9
+ export declare function sessionIdentity(auth: AuthResult): string;
package/dist/http.js ADDED
@@ -0,0 +1,274 @@
1
+ import express from "express";
2
+ import cookieParser from "cookie-parser";
3
+ import { createHash, randomUUID } from "node:crypto";
4
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
5
+ import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
6
+ import { mcpAuthRouter } from "@modelcontextprotocol/sdk/server/auth/router.js";
7
+ import { ApiClient } from "./api.js";
8
+ import { createServer } from "./server.js";
9
+ import { OAuthProvider } from "./oauth/provider.js";
10
+ import { oauthCallbackRouter } from "./oauth/callback.js";
11
+ import { verifyAccessToken, looksLikeJwt } from "./oauth/jwt.js";
12
+ const API_URL = process.env.CLV_API_URL ?? "http://127.0.0.1:8000";
13
+ const INTERNAL_SECRET = process.env.INTERNAL_SECRET ?? "";
14
+ const PORT = parseInt(process.env.MCP_PORT ?? "3001", 10);
15
+ const ISSUER = process.env.OAUTH_ISSUER ?? "https://www.tradesapi.com";
16
+ const RESOURCE = process.env.OAUTH_RESOURCE ?? "https://www.tradesapi.com/mcp";
17
+ const BASE_URL = process.env.OAUTH_BASE_URL ?? "https://www.tradesapi.com";
18
+ const SESSION_TTL_MS = 30 * 60 * 1000;
19
+ const REAP_INTERVAL_MS = 60 * 1000;
20
+ const sessions = new Map();
21
+ function touchSession(sessionId) {
22
+ const entry = sessions.get(sessionId);
23
+ if (entry)
24
+ entry.lastActivity = Date.now();
25
+ }
26
+ // Keep a handle so future graceful-shutdown logic can clearInterval(reaper).
27
+ const reaper = setInterval(() => {
28
+ const now = Date.now();
29
+ for (const [sid, entry] of sessions) {
30
+ if (now - entry.lastActivity > SESSION_TTL_MS) {
31
+ entry.transport.close?.();
32
+ sessions.delete(sid);
33
+ }
34
+ }
35
+ }, REAP_INTERVAL_MS);
36
+ void reaper;
37
+ // Fail-fast if critical OAuth env vars are missing/weak in production.
38
+ // FastAPI hard-fails on INTERNAL_SECRET < 32 chars and on every
39
+ // JWT_SECRET rotation key < 32 chars; mirror both checks here so a
40
+ // misconfigured deploy crashes at boot rather than minting weak tokens
41
+ // or 401ing silently.
42
+ if (process.env.NODE_ENV === "production") {
43
+ if (!INTERNAL_SECRET || INTERNAL_SECRET.length < 32) {
44
+ console.error("FATAL: INTERNAL_SECRET must be set and >= 32 characters in production");
45
+ process.exit(1);
46
+ }
47
+ const jwtKeys = (process.env.JWT_SECRET ?? "")
48
+ .split(",")
49
+ .map((s) => s.trim())
50
+ .filter(Boolean);
51
+ if (jwtKeys.length === 0) {
52
+ console.error("FATAL: JWT_SECRET must be set in production");
53
+ process.exit(1);
54
+ }
55
+ for (const k of jwtKeys) {
56
+ if (k.length < 32) {
57
+ console.error("FATAL: every JWT_SECRET rotation key must be >= 32 characters in production");
58
+ process.exit(1);
59
+ }
60
+ }
61
+ }
62
+ const app = express();
63
+ const oauthProvider = new OAuthProvider();
64
+ app.use(cookieParser());
65
+ // Expose Mcp-Session-Id header to browsers (existing behavior)
66
+ app.use((_req, res, next) => {
67
+ res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id");
68
+ next();
69
+ });
70
+ // ── OAuth endpoints (root-mounted) ─────────────────────────────────────────
71
+ // mcpAuthRouter handles /authorize, /token, /register, /revoke, /.well-known/*
72
+ app.use(mcpAuthRouter({
73
+ provider: oauthProvider,
74
+ issuerUrl: new URL(ISSUER),
75
+ baseUrl: new URL(BASE_URL),
76
+ resourceServerUrl: new URL(RESOURCE),
77
+ scopesSupported: ["mcp:tools"],
78
+ resourceName: "TradesAPI Contractor License Verification",
79
+ }));
80
+ // Custom routes not handled by the SDK
81
+ app.use(oauthCallbackRouter(oauthProvider));
82
+ // JSON body parser for /mcp endpoint (AFTER OAuth; token/register need urlencoded)
83
+ app.use(express.json());
84
+ function extractBearer(req) {
85
+ const auth = req.headers.authorization;
86
+ if (!auth?.startsWith("Bearer "))
87
+ return null;
88
+ return auth.slice(7);
89
+ }
90
+ function sendUnauthorized(res, reason) {
91
+ const resourceMeta = `${BASE_URL}/.well-known/oauth-protected-resource/mcp`;
92
+ res.setHeader("WWW-Authenticate", `Bearer resource_metadata="${resourceMeta}", error="invalid_token", error_description="${reason}"`);
93
+ res.status(401).json({
94
+ jsonrpc: "2.0",
95
+ error: { code: -32001, message: reason },
96
+ id: null,
97
+ });
98
+ }
99
+ async function requireAuth(req, res) {
100
+ const token = extractBearer(req);
101
+ if (!token) {
102
+ sendUnauthorized(res, "Missing Authorization: Bearer <token>");
103
+ return null;
104
+ }
105
+ if (looksLikeJwt(token)) {
106
+ try {
107
+ // ── DEVIATION: pass ISSUER as 3rd arg (design doc requires iss check;
108
+ // same fix as Task 9 where jwt.ts's expectedIssuer param was added).
109
+ const info = await verifyAccessToken(token, RESOURCE, ISSUER);
110
+ // `sub` is always set by _mintPair (Task 9). Fail loud if it isn't —
111
+ // falling back to info.clientId would silently promote the OAuth client
112
+ // (Claude Desktop etc.) into FastAPI's X-Api-Key-Id position.
113
+ const apiKeyId = info.extra?.sub;
114
+ if (!apiKeyId) {
115
+ sendUnauthorized(res, "Invalid or expired access token");
116
+ return null;
117
+ }
118
+ req.auth = info;
119
+ return { apiKeyId, token, isJwt: true };
120
+ }
121
+ catch {
122
+ // ── DEVIATION: drop `err: any` (tsconfig strict — same fix as Task 6
123
+ // review). We log nothing here; the WWW-Authenticate error_description
124
+ // is the user-visible signal. jwt.ts already sanitizes leaked claims.
125
+ sendUnauthorized(res, "Invalid or expired access token");
126
+ return null;
127
+ }
128
+ }
129
+ // Raw API key — pass through as before
130
+ return { token, isJwt: false };
131
+ }
132
+ function buildApiClient(auth) {
133
+ if (auth.isJwt && auth.apiKeyId) {
134
+ // JWT-authenticated: use internal headers
135
+ return new ApiClient(API_URL, "", {
136
+ "X-Internal-Secret": INTERNAL_SECRET,
137
+ "X-Api-Key-Id": auth.apiKeyId,
138
+ });
139
+ }
140
+ // Raw API key
141
+ return new ApiClient(API_URL, auth.token);
142
+ }
143
+ export function sessionIdentity(auth) {
144
+ if (auth.isJwt && auth.apiKeyId)
145
+ return auth.apiKeyId;
146
+ return createHash("sha256").update(auth.token).digest("hex");
147
+ }
148
+ // ── MCP endpoint (POST / GET / DELETE) ─────────────────────────────────────
149
+ app.post("/mcp", async (req, res) => {
150
+ const auth = await requireAuth(req, res);
151
+ if (!auth)
152
+ return;
153
+ const sessionId = req.headers["mcp-session-id"];
154
+ if (sessionId) {
155
+ const entry = sessions.get(sessionId);
156
+ if (!entry) {
157
+ res.status(404).json({
158
+ jsonrpc: "2.0",
159
+ error: { code: -32000, message: "Session not found. Send an initialize request to start a new session." },
160
+ id: null,
161
+ });
162
+ return;
163
+ }
164
+ // Reject cross-account session hijack: the bearer token's identity must
165
+ // match the identity the session was initialized with.
166
+ if (entry.identity !== sessionIdentity(auth)) {
167
+ res.status(403).json({
168
+ jsonrpc: "2.0",
169
+ error: { code: -32002, message: "Session does not match authenticated identity" },
170
+ id: null,
171
+ });
172
+ return;
173
+ }
174
+ touchSession(sessionId);
175
+ await entry.transport.handleRequest(req, res, req.body);
176
+ return;
177
+ }
178
+ if (!isInitializeRequest(req.body)) {
179
+ res.status(400).json({
180
+ jsonrpc: "2.0",
181
+ error: { code: -32600, message: "First request must be an initialize request (no Mcp-Session-Id header found)" },
182
+ id: null,
183
+ });
184
+ return;
185
+ }
186
+ const transport = new StreamableHTTPServerTransport({
187
+ sessionIdGenerator: () => randomUUID(),
188
+ onsessioninitialized: (sid) => {
189
+ sessions.set(sid, {
190
+ transport,
191
+ lastActivity: Date.now(),
192
+ identity: sessionIdentity(auth),
193
+ });
194
+ },
195
+ });
196
+ transport.onclose = () => {
197
+ const sid = transport.sessionId;
198
+ if (sid)
199
+ sessions.delete(sid);
200
+ };
201
+ const client = buildApiClient(auth);
202
+ const server = createServer(client);
203
+ await server.connect(transport);
204
+ await transport.handleRequest(req, res, req.body);
205
+ });
206
+ app.get("/mcp", async (req, res) => {
207
+ const auth = await requireAuth(req, res);
208
+ if (!auth)
209
+ return;
210
+ const sessionId = req.headers["mcp-session-id"];
211
+ if (!sessionId) {
212
+ res.status(400).json({
213
+ jsonrpc: "2.0",
214
+ error: { code: -32000, message: "Mcp-Session-Id header required for SSE stream" },
215
+ id: null,
216
+ });
217
+ return;
218
+ }
219
+ const entry = sessions.get(sessionId);
220
+ if (!entry) {
221
+ res.status(404).json({
222
+ jsonrpc: "2.0",
223
+ error: { code: -32000, message: "Session not found" },
224
+ id: null,
225
+ });
226
+ return;
227
+ }
228
+ // Reject cross-account session hijack: the bearer token's identity must
229
+ // match the identity the session was initialized with.
230
+ if (entry.identity !== sessionIdentity(auth)) {
231
+ res.status(403).json({
232
+ jsonrpc: "2.0",
233
+ error: { code: -32002, message: "Session does not match authenticated identity" },
234
+ id: null,
235
+ });
236
+ return;
237
+ }
238
+ touchSession(sessionId);
239
+ await entry.transport.handleRequest(req, res);
240
+ });
241
+ app.delete("/mcp", async (req, res) => {
242
+ const auth = await requireAuth(req, res);
243
+ if (!auth)
244
+ return;
245
+ const sessionId = req.headers["mcp-session-id"];
246
+ if (!sessionId) {
247
+ res.status(400).json({ jsonrpc: "2.0", error: { code: -32000, message: "Mcp-Session-Id header required" }, id: null });
248
+ return;
249
+ }
250
+ const entry = sessions.get(sessionId);
251
+ if (!entry) {
252
+ res.status(404).json({ jsonrpc: "2.0", error: { code: -32000, message: "Session not found" }, id: null });
253
+ return;
254
+ }
255
+ // Reject cross-account session hijack: the bearer token's identity must
256
+ // match the identity the session was initialized with.
257
+ if (entry.identity !== sessionIdentity(auth)) {
258
+ res.status(403).json({
259
+ jsonrpc: "2.0",
260
+ error: { code: -32002, message: "Session does not match authenticated identity" },
261
+ id: null,
262
+ });
263
+ return;
264
+ }
265
+ await entry.transport.handleRequest(req, res);
266
+ });
267
+ app.get("/mcp/health", (_req, res) => {
268
+ res.json({ status: "ok", sessions: sessions.size });
269
+ });
270
+ app.listen(PORT, () => {
271
+ console.log(`MCP HTTP server listening on port ${PORT}`);
272
+ console.log(`API backend: ${API_URL}`);
273
+ console.log(`OAuth issuer: ${ISSUER}`);
274
+ });