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 +55 -63
- package/dist/api.d.ts +6 -4
- package/dist/api.js +47 -11
- package/dist/format.js +33 -8
- package/dist/http.d.ts +9 -0
- package/dist/http.js +274 -0
- package/dist/index.js +3 -64
- package/dist/oauth/callback.d.ts +3 -0
- package/dist/oauth/callback.js +104 -0
- package/dist/oauth/consent.d.ts +17 -0
- package/dist/oauth/consent.js +77 -0
- package/dist/oauth/internal-api.d.ts +17 -0
- package/dist/oauth/internal-api.js +40 -0
- package/dist/oauth/jwt.d.ts +26 -0
- package/dist/oauth/jwt.js +90 -0
- package/dist/oauth/provider.d.ts +19 -0
- package/dist/oauth/provider.js +222 -0
- package/dist/oauth/store.d.ts +6 -0
- package/dist/oauth/store.js +28 -0
- package/dist/redis.d.ts +7 -0
- package/dist/redis.js +27 -0
- package/dist/schemas.d.ts +3 -0
- package/dist/schemas.js +30 -25
- package/dist/server.d.ts +3 -0
- package/dist/server.js +76 -0
- package/dist/tools/batch.js +14 -18
- package/dist/tools/search.js +2 -2
- package/dist/tools/states.d.ts +2 -1
- package/dist/tools/states.js +28 -49
- package/dist/tools/verify.js +2 -2
- package/dist/types.d.ts +43 -2
- package/package.json +8 -2
package/README.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# contractor-license-mcp-server
|
|
2
2
|
|
|
3
|
-
**Real-time contractor license verification across
|
|
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
|
-
- **
|
|
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
|
-
###
|
|
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
|
-
### `
|
|
77
|
+
### `verify_license`
|
|
58
78
|
|
|
59
|
-
Verify a single contractor license against
|
|
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`,
|
|
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
|
-
### `
|
|
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
|
-
### `
|
|
114
|
+
### `search_by_name`
|
|
94
115
|
|
|
95
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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/
|
|
168
|
-
cd
|
|
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
|
-
|
|
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
|
|
18
|
-
timeout: 120_000,
|
|
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
|
|
24
|
-
|
|
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
|
|
38
|
+
async batch(items) {
|
|
33
39
|
try {
|
|
34
|
-
const
|
|
35
|
-
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
lines.push(
|
|
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
|
+
});
|