@boardwalk-labs/engine 0.1.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/LICENSE +202 -0
- package/README.md +69 -0
- package/bin/boardwalk-server.js +16 -0
- package/dist/agent/conversation.d.ts +42 -0
- package/dist/agent/conversation.js +4 -0
- package/dist/agent/leaf.d.ts +81 -0
- package/dist/agent/leaf.js +190 -0
- package/dist/agent/providers.d.ts +23 -0
- package/dist/agent/providers.js +347 -0
- package/dist/agent/rates.d.ts +13 -0
- package/dist/agent/rates.js +35 -0
- package/dist/agent/redact.d.ts +9 -0
- package/dist/agent/redact.js +27 -0
- package/dist/agent/resolve.d.ts +58 -0
- package/dist/agent/resolve.js +153 -0
- package/dist/agent/sse.d.ts +2 -0
- package/dist/agent/sse.js +30 -0
- package/dist/agent/tools.d.ts +57 -0
- package/dist/agent/tools.js +324 -0
- package/dist/clock.d.ts +8 -0
- package/dist/clock.js +32 -0
- package/dist/cron/cron.d.ts +34 -0
- package/dist/cron/cron.js +331 -0
- package/dist/engine.d.ts +106 -0
- package/dist/engine.js +183 -0
- package/dist/errors.d.ts +15 -0
- package/dist/errors.js +40 -0
- package/dist/ids.d.ts +7 -0
- package/dist/ids.js +42 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +8 -0
- package/dist/json_value.d.ts +7 -0
- package/dist/json_value.js +29 -0
- package/dist/mcp/client.d.ts +39 -0
- package/dist/mcp/client.js +112 -0
- package/dist/mcp/jsonrpc.d.ts +57 -0
- package/dist/mcp/jsonrpc.js +117 -0
- package/dist/mcp/oauth.d.ts +72 -0
- package/dist/mcp/oauth.js +337 -0
- package/dist/mcp/token_store.d.ts +30 -0
- package/dist/mcp/token_store.js +101 -0
- package/dist/mcp/transport_http.d.ts +38 -0
- package/dist/mcp/transport_http.js +143 -0
- package/dist/mcp/transport_stdio.d.ts +27 -0
- package/dist/mcp/transport_stdio.js +94 -0
- package/dist/run/child.d.ts +1 -0
- package/dist/run/child.js +139 -0
- package/dist/run/child_host.d.ts +26 -0
- package/dist/run/child_host.js +124 -0
- package/dist/run/idempotency.d.ts +5 -0
- package/dist/run/idempotency.js +31 -0
- package/dist/run/ipc.d.ts +159 -0
- package/dist/run/ipc.js +150 -0
- package/dist/run/run_dir.d.ts +31 -0
- package/dist/run/run_dir.js +106 -0
- package/dist/run/supervisor.d.ts +107 -0
- package/dist/run/supervisor.js +676 -0
- package/dist/scheduler/scheduler.d.ts +54 -0
- package/dist/scheduler/scheduler.js +215 -0
- package/dist/server/http.d.ts +42 -0
- package/dist/server/http.js +183 -0
- package/dist/server/routes/api.d.ts +17 -0
- package/dist/server/routes/api.js +107 -0
- package/dist/server/routes/hooks.d.ts +2 -0
- package/dist/server/routes/hooks.js +88 -0
- package/dist/server/routes/router.d.ts +15 -0
- package/dist/server/routes/router.js +75 -0
- package/dist/server/routes/stream.d.ts +2 -0
- package/dist/server/routes/stream.js +79 -0
- package/dist/server/routes/ui.d.ts +2 -0
- package/dist/server/routes/ui.js +120 -0
- package/dist/server/server.d.ts +25 -0
- package/dist/server/server.js +67 -0
- package/dist/server_main.d.ts +46 -0
- package/dist/server_main.js +203 -0
- package/dist/store/migrations.d.ts +21 -0
- package/dist/store/migrations.js +159 -0
- package/dist/store/store.d.ts +194 -0
- package/dist/store/store.js +567 -0
- package/package.json +57 -0
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
// PARENT-side OAuth 2.1 for MCP servers (MCP authorization spec, 2025-06-18): discovery
|
|
2
|
+
// (RFC 9728 protected-resource metadata → RFC 8414 AS metadata), RFC 7591 dynamic client
|
|
3
|
+
// registration, the authorization-code + PKCE (S256) grant with a loopback redirect, RFC 8707
|
|
4
|
+
// resource indicators, and the refresh grant. All of it runs in the ENGINE process: token
|
|
5
|
+
// state never belongs to the run process, and the one interactive step is an explicit public
|
|
6
|
+
// API (`Engine.authorizeMcpServer`) — a headless run that would need a human fails loudly
|
|
7
|
+
// instead of prompting. Every external response is Zod-validated (CODE_QUALITY §2.1).
|
|
8
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
9
|
+
import http from "node:http";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
import { EngineError } from "../errors.js";
|
|
12
|
+
import { canonicalServerUrl } from "./token_store.js";
|
|
13
|
+
/** A fresh high-entropy verifier and its S256 challenge. */
|
|
14
|
+
export function createPkcePair() {
|
|
15
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
16
|
+
return { verifier, challenge: s256Challenge(verifier) };
|
|
17
|
+
}
|
|
18
|
+
/** The S256 transform, exported so tests can verify the challenge against the verifier. */
|
|
19
|
+
export function s256Challenge(verifier) {
|
|
20
|
+
return createHash("sha256").update(verifier, "ascii").digest("base64url");
|
|
21
|
+
}
|
|
22
|
+
// ----------------------------------------------------------------------------
|
|
23
|
+
// WWW-Authenticate parsing (RFC 9110 §11.6.1, the Bearer challenge subset we need)
|
|
24
|
+
// ----------------------------------------------------------------------------
|
|
25
|
+
/**
|
|
26
|
+
* Extract the parameters of a Bearer challenge (`resource_metadata`, `error`, …). Returns an
|
|
27
|
+
* empty record for non-Bearer or malformed headers — discovery then falls back to the
|
|
28
|
+
* well-known path, so a sloppy header degrades gracefully instead of failing authorization.
|
|
29
|
+
*/
|
|
30
|
+
export function parseBearerChallenge(header) {
|
|
31
|
+
const match = /^\s*Bearer\b(.*)$/i.exec(header);
|
|
32
|
+
if (match === null)
|
|
33
|
+
return {};
|
|
34
|
+
const params = {};
|
|
35
|
+
// key="quoted value" or key=token, comma-separated.
|
|
36
|
+
const paramRe = /([A-Za-z0-9_-]+)\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^\s,"]+))/g;
|
|
37
|
+
for (const param of (match[1] ?? "").matchAll(paramRe)) {
|
|
38
|
+
const key = param[1];
|
|
39
|
+
const value = param[2] !== undefined ? param[2].replaceAll('\\"', '"') : param[3];
|
|
40
|
+
if (key !== undefined && value !== undefined)
|
|
41
|
+
params[key.toLowerCase()] = value;
|
|
42
|
+
}
|
|
43
|
+
return params;
|
|
44
|
+
}
|
|
45
|
+
// ----------------------------------------------------------------------------
|
|
46
|
+
// Discovery: server → protected-resource metadata → authorization-server metadata
|
|
47
|
+
// ----------------------------------------------------------------------------
|
|
48
|
+
const protectedResourceSchema = z.looseObject({
|
|
49
|
+
resource: z.string().optional(),
|
|
50
|
+
authorization_servers: z.array(z.string()).optional(),
|
|
51
|
+
});
|
|
52
|
+
const asMetadataSchema = z.looseObject({
|
|
53
|
+
issuer: z.string().min(1),
|
|
54
|
+
authorization_endpoint: z.string().min(1),
|
|
55
|
+
token_endpoint: z.string().min(1),
|
|
56
|
+
registration_endpoint: z.string().optional(),
|
|
57
|
+
});
|
|
58
|
+
/**
|
|
59
|
+
* Find the authorization server for an MCP server: probe it unauthenticated, follow the 401's
|
|
60
|
+
* `resource_metadata` hint (RFC 9728), fall back to the well-known protected-resource path,
|
|
61
|
+
* and finally — for pre-9728 servers — treat the server's own origin as the issuer (the
|
|
62
|
+
* 2025-03-26 MCP default). Failing any later step is loud: authorization can't be guessed.
|
|
63
|
+
*/
|
|
64
|
+
export async function discoverAuthorization(serverUrl, io = {}) {
|
|
65
|
+
const doFetch = io.fetchImpl ?? fetch;
|
|
66
|
+
const canonical = canonicalServerUrl(serverUrl);
|
|
67
|
+
const origin = new URL(serverUrl).origin;
|
|
68
|
+
const metadataUrls = [
|
|
69
|
+
...(await resourceMetadataHint(serverUrl, doFetch)),
|
|
70
|
+
wellKnownUrl(serverUrl, "oauth-protected-resource"),
|
|
71
|
+
`${origin}/.well-known/oauth-protected-resource`,
|
|
72
|
+
];
|
|
73
|
+
let issuer = origin; // pre-RFC-9728 fallback: the resource server is its own issuer
|
|
74
|
+
let resource = canonical;
|
|
75
|
+
for (const url of dedupe(metadataUrls)) {
|
|
76
|
+
const metadata = await fetchJson(url, doFetch, protectedResourceSchema);
|
|
77
|
+
if (metadata === null)
|
|
78
|
+
continue;
|
|
79
|
+
issuer = metadata.authorization_servers?.[0] ?? issuer;
|
|
80
|
+
resource = metadata.resource ?? resource;
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
const asMetadata = await fetchJson(wellKnownUrl(issuer, "oauth-authorization-server"), doFetch, asMetadataSchema);
|
|
84
|
+
if (asMetadata === null) {
|
|
85
|
+
throw new EngineError("PROVIDER_ERROR", `Could not discover OAuth authorization-server metadata for MCP server ${serverUrl} ` +
|
|
86
|
+
`(issuer ${issuer}).`, "The server's authorization server must publish RFC 8414 metadata at " +
|
|
87
|
+
"/.well-known/oauth-authorization-server.");
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
authorizationEndpoint: asMetadata.authorization_endpoint,
|
|
91
|
+
tokenEndpoint: asMetadata.token_endpoint,
|
|
92
|
+
registrationEndpoint: asMetadata.registration_endpoint,
|
|
93
|
+
resource,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
/** Probe the MCP server unauthenticated and harvest the 401's resource_metadata hint, if any. */
|
|
97
|
+
async function resourceMetadataHint(serverUrl, doFetch) {
|
|
98
|
+
try {
|
|
99
|
+
const response = await doFetch(serverUrl, {
|
|
100
|
+
method: "POST",
|
|
101
|
+
headers: {
|
|
102
|
+
"content-type": "application/json",
|
|
103
|
+
accept: "application/json, text/event-stream",
|
|
104
|
+
},
|
|
105
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 0, method: "ping" }),
|
|
106
|
+
});
|
|
107
|
+
if (response.status !== 401)
|
|
108
|
+
return [];
|
|
109
|
+
const challenge = parseBearerChallenge(response.headers.get("www-authenticate") ?? "");
|
|
110
|
+
const hint = challenge["resource_metadata"];
|
|
111
|
+
return hint !== undefined ? [hint] : [];
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return []; // unreachable server — the well-known fallbacks will fail loudly below
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/** RFC 8414 path handling: the well-known segment goes between the origin and the path. */
|
|
118
|
+
function wellKnownUrl(base, suffix) {
|
|
119
|
+
const url = new URL(base);
|
|
120
|
+
const path = url.pathname.replace(/\/+$/, "");
|
|
121
|
+
return `${url.origin}/.well-known/${suffix}${path}`;
|
|
122
|
+
}
|
|
123
|
+
async function fetchJson(url, doFetch, schema) {
|
|
124
|
+
try {
|
|
125
|
+
const response = await doFetch(url, { headers: { accept: "application/json" } });
|
|
126
|
+
if (!response.ok)
|
|
127
|
+
return null;
|
|
128
|
+
const parsed = schema.safeParse(await response.json());
|
|
129
|
+
return parsed.success ? parsed.data : null;
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
function dedupe(urls) {
|
|
136
|
+
return [...new Set(urls)];
|
|
137
|
+
}
|
|
138
|
+
// ----------------------------------------------------------------------------
|
|
139
|
+
// Dynamic client registration (RFC 7591)
|
|
140
|
+
// ----------------------------------------------------------------------------
|
|
141
|
+
const registrationResultSchema = z.looseObject({ client_id: z.string().min(1) });
|
|
142
|
+
/** Register a public client (no secret — token_endpoint_auth_method "none", per OAuth 2.1 CLI practice). */
|
|
143
|
+
export async function registerClient(registrationEndpoint, redirectUri, io = {}) {
|
|
144
|
+
const doFetch = io.fetchImpl ?? fetch;
|
|
145
|
+
const response = await doFetch(registrationEndpoint, {
|
|
146
|
+
method: "POST",
|
|
147
|
+
headers: { "content-type": "application/json", accept: "application/json" },
|
|
148
|
+
body: JSON.stringify({
|
|
149
|
+
client_name: "Boardwalk Engine",
|
|
150
|
+
redirect_uris: [redirectUri],
|
|
151
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
152
|
+
response_types: ["code"],
|
|
153
|
+
token_endpoint_auth_method: "none",
|
|
154
|
+
}),
|
|
155
|
+
});
|
|
156
|
+
if (!response.ok) {
|
|
157
|
+
throw new EngineError("PROVIDER_ERROR", `OAuth client registration failed: ${String(response.status)} from ${registrationEndpoint}.`);
|
|
158
|
+
}
|
|
159
|
+
const parsed = registrationResultSchema.safeParse(await response.json());
|
|
160
|
+
if (!parsed.success) {
|
|
161
|
+
throw new EngineError("PROVIDER_ERROR", `OAuth client registration returned a malformed response (no client_id).`);
|
|
162
|
+
}
|
|
163
|
+
return parsed.data.client_id;
|
|
164
|
+
}
|
|
165
|
+
// ----------------------------------------------------------------------------
|
|
166
|
+
// Token grants (authorization_code exchange + refresh_token)
|
|
167
|
+
// ----------------------------------------------------------------------------
|
|
168
|
+
const tokenResponseSchema = z.looseObject({
|
|
169
|
+
access_token: z.string().min(1),
|
|
170
|
+
refresh_token: z.string().min(1).optional(),
|
|
171
|
+
expires_in: z.number().positive().optional(),
|
|
172
|
+
});
|
|
173
|
+
export async function exchangeAuthorizationCode(args, io = {}) {
|
|
174
|
+
return await tokenGrant(args.tokenEndpoint, {
|
|
175
|
+
grant_type: "authorization_code",
|
|
176
|
+
client_id: args.clientId,
|
|
177
|
+
code: args.code,
|
|
178
|
+
redirect_uri: args.redirectUri,
|
|
179
|
+
code_verifier: args.codeVerifier,
|
|
180
|
+
resource: args.resource,
|
|
181
|
+
}, io);
|
|
182
|
+
}
|
|
183
|
+
export async function refreshAccessToken(args, io = {}) {
|
|
184
|
+
return await tokenGrant(args.tokenEndpoint, {
|
|
185
|
+
grant_type: "refresh_token",
|
|
186
|
+
client_id: args.clientId,
|
|
187
|
+
refresh_token: args.refreshToken,
|
|
188
|
+
...(args.resource !== undefined ? { resource: args.resource } : {}),
|
|
189
|
+
}, io);
|
|
190
|
+
}
|
|
191
|
+
async function tokenGrant(tokenEndpoint, params, io) {
|
|
192
|
+
const doFetch = io.fetchImpl ?? fetch;
|
|
193
|
+
const response = await doFetch(tokenEndpoint, {
|
|
194
|
+
method: "POST",
|
|
195
|
+
headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
|
|
196
|
+
body: new URLSearchParams(params).toString(),
|
|
197
|
+
});
|
|
198
|
+
if (!response.ok) {
|
|
199
|
+
// Status only — an AS error body can echo grant material; never put it in an error message.
|
|
200
|
+
throw new EngineError("PROVIDER_ERROR", `OAuth token request (${params["grant_type"] ?? "unknown grant"}) failed: ` +
|
|
201
|
+
`${String(response.status)} from ${tokenEndpoint}.`);
|
|
202
|
+
}
|
|
203
|
+
const parsed = tokenResponseSchema.safeParse(await response.json());
|
|
204
|
+
if (!parsed.success) {
|
|
205
|
+
throw new EngineError("PROVIDER_ERROR", "OAuth token response was malformed.");
|
|
206
|
+
}
|
|
207
|
+
return {
|
|
208
|
+
accessToken: parsed.data.access_token,
|
|
209
|
+
refreshToken: parsed.data.refresh_token,
|
|
210
|
+
expiresAt: parsed.data.expires_in !== undefined
|
|
211
|
+
? Date.now() + Math.floor(parsed.data.expires_in * 1000)
|
|
212
|
+
: undefined,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
const DEFAULT_AUTHORIZE_TIMEOUT_MS = 5 * 60_000;
|
|
216
|
+
/**
|
|
217
|
+
* The one-time interactive step: discovery → dynamic registration → PKCE authorization-code
|
|
218
|
+
* grant with a loopback redirect → tokens persisted to the store. Resolves when the grant is
|
|
219
|
+
* stored; rejects on state mismatch, an AS `error` redirect, or timeout. After this, runs use
|
|
220
|
+
* the server headlessly (silent refresh included) until the grant dies.
|
|
221
|
+
*/
|
|
222
|
+
export async function runAuthorizationFlow(args) {
|
|
223
|
+
const io = { fetchImpl: args.fetchImpl };
|
|
224
|
+
const discovery = await discoverAuthorization(args.serverUrl, io);
|
|
225
|
+
if (discovery.registrationEndpoint === undefined) {
|
|
226
|
+
throw new EngineError("UNSUPPORTED", `The authorization server for ${args.serverUrl} does not support dynamic client ` +
|
|
227
|
+
"registration (RFC 7591), which this engine requires.", "Pre-provisioned client credentials are not supported yet; supply a token via the " +
|
|
228
|
+
"McpServerRef headers instead.");
|
|
229
|
+
}
|
|
230
|
+
const state = randomBytes(16).toString("base64url");
|
|
231
|
+
const pkce = createPkcePair();
|
|
232
|
+
const loopback = await startLoopbackListener(state, args.timeoutMs ?? DEFAULT_AUTHORIZE_TIMEOUT_MS);
|
|
233
|
+
try {
|
|
234
|
+
const clientId = await registerClient(discovery.registrationEndpoint, loopback.redirectUri, io);
|
|
235
|
+
const authorizeUrl = new URL(discovery.authorizationEndpoint);
|
|
236
|
+
authorizeUrl.searchParams.set("response_type", "code");
|
|
237
|
+
authorizeUrl.searchParams.set("client_id", clientId);
|
|
238
|
+
authorizeUrl.searchParams.set("redirect_uri", loopback.redirectUri);
|
|
239
|
+
authorizeUrl.searchParams.set("state", state);
|
|
240
|
+
authorizeUrl.searchParams.set("code_challenge", pkce.challenge);
|
|
241
|
+
authorizeUrl.searchParams.set("code_challenge_method", "S256");
|
|
242
|
+
authorizeUrl.searchParams.set("resource", discovery.resource);
|
|
243
|
+
args.onAuthorizationUrl(authorizeUrl.toString());
|
|
244
|
+
const code = await loopback.code;
|
|
245
|
+
const grant = await exchangeAuthorizationCode({
|
|
246
|
+
tokenEndpoint: discovery.tokenEndpoint,
|
|
247
|
+
clientId,
|
|
248
|
+
code,
|
|
249
|
+
redirectUri: loopback.redirectUri,
|
|
250
|
+
codeVerifier: pkce.verifier,
|
|
251
|
+
resource: discovery.resource,
|
|
252
|
+
}, io);
|
|
253
|
+
const entry = {
|
|
254
|
+
clientId,
|
|
255
|
+
accessToken: grant.accessToken,
|
|
256
|
+
...(grant.refreshToken !== undefined ? { refreshToken: grant.refreshToken } : {}),
|
|
257
|
+
...(grant.expiresAt !== undefined ? { expiresAt: grant.expiresAt } : {}),
|
|
258
|
+
tokenEndpoint: discovery.tokenEndpoint,
|
|
259
|
+
resource: discovery.resource,
|
|
260
|
+
};
|
|
261
|
+
args.store.set(args.serverUrl, entry);
|
|
262
|
+
}
|
|
263
|
+
finally {
|
|
264
|
+
loopback.close();
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* The OAuth 2.1 native-app redirect target: an ephemeral HTTP listener on 127.0.0.1 (loopback
|
|
269
|
+
* redirect URIs are the one http:// form the spec allows). One-shot — first valid hit settles.
|
|
270
|
+
*/
|
|
271
|
+
function startLoopbackListener(state, timeoutMs) {
|
|
272
|
+
return new Promise((resolveListener, rejectListener) => {
|
|
273
|
+
let resolveCode = null;
|
|
274
|
+
let rejectCode = null;
|
|
275
|
+
const code = new Promise((resolve, reject) => {
|
|
276
|
+
resolveCode = resolve;
|
|
277
|
+
rejectCode = reject;
|
|
278
|
+
});
|
|
279
|
+
// Why the pre-attached catch: if the flow fails BEFORE awaiting `code` (e.g. registration
|
|
280
|
+
// throws), close() rejects this promise with nobody listening yet — without a standing
|
|
281
|
+
// handler that's an unhandled rejection. The real awaiter still receives the rejection.
|
|
282
|
+
void code.catch(() => undefined);
|
|
283
|
+
const server = http.createServer((req, res) => {
|
|
284
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
285
|
+
if (url.pathname !== "/callback") {
|
|
286
|
+
res.writeHead(404).end();
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
const fail = (message) => {
|
|
290
|
+
res.writeHead(400, { "content-type": "text/plain" }).end(message);
|
|
291
|
+
rejectCode?.(new EngineError("PROVIDER_ERROR", `MCP authorization failed: ${message}`));
|
|
292
|
+
};
|
|
293
|
+
const errorParam = url.searchParams.get("error");
|
|
294
|
+
if (errorParam !== null) {
|
|
295
|
+
fail(`the authorization server returned error "${errorParam}".`);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
if (url.searchParams.get("state") !== state) {
|
|
299
|
+
fail("state mismatch on the OAuth redirect (possible CSRF) — try authorizing again.");
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
const codeParam = url.searchParams.get("code");
|
|
303
|
+
if (codeParam === null || codeParam.length === 0) {
|
|
304
|
+
fail("the redirect carried no authorization code.");
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
res
|
|
308
|
+
.writeHead(200, { "content-type": "text/html" })
|
|
309
|
+
.end("<html><body>Authorized — you can close this tab and return to Boardwalk.</body></html>");
|
|
310
|
+
resolveCode?.(codeParam);
|
|
311
|
+
});
|
|
312
|
+
const timer = setTimeout(() => {
|
|
313
|
+
rejectCode?.(new EngineError("PROVIDER_ERROR", `MCP authorization timed out after ${String(Math.round(timeoutMs / 1000))}s — the ` +
|
|
314
|
+
"authorization URL was never completed."));
|
|
315
|
+
server.close();
|
|
316
|
+
}, timeoutMs);
|
|
317
|
+
server.listen(0, "127.0.0.1", () => {
|
|
318
|
+
const address = server.address();
|
|
319
|
+
if (address === null || typeof address !== "object") {
|
|
320
|
+
clearTimeout(timer);
|
|
321
|
+
server.close();
|
|
322
|
+
rejectListener(new EngineError("INTERNAL", "Loopback listener failed to bind a port."));
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
resolveListener({
|
|
326
|
+
redirectUri: `http://127.0.0.1:${String(address.port)}/callback`,
|
|
327
|
+
code,
|
|
328
|
+
close: () => {
|
|
329
|
+
clearTimeout(timer);
|
|
330
|
+
server.close();
|
|
331
|
+
// A close before settle (caller bailed early) must not leave the promise pending.
|
|
332
|
+
rejectCode?.(new EngineError("CANCELLED", "MCP authorization was abandoned."));
|
|
333
|
+
},
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/** Where the store lives under the engine data dir (engine + supervisor must agree). */
|
|
3
|
+
export declare const MCP_TOKENS_FILENAME = "mcp_tokens.json";
|
|
4
|
+
declare const entrySchema: z.ZodObject<{
|
|
5
|
+
clientId: z.ZodString;
|
|
6
|
+
accessToken: z.ZodString;
|
|
7
|
+
refreshToken: z.ZodOptional<z.ZodString>;
|
|
8
|
+
expiresAt: z.ZodOptional<z.ZodNumber>;
|
|
9
|
+
tokenEndpoint: z.ZodOptional<z.ZodString>;
|
|
10
|
+
resource: z.ZodOptional<z.ZodString>;
|
|
11
|
+
}, z.core.$strict>;
|
|
12
|
+
export type McpTokenEntry = z.infer<typeof entrySchema>;
|
|
13
|
+
/**
|
|
14
|
+
* Canonicalize an MCP server URL for keying token state: the same server written with a
|
|
15
|
+
* default port, trailing slash, or different case must hit the same entry — otherwise an
|
|
16
|
+
* authorize under one spelling is invisible to a run using another.
|
|
17
|
+
*/
|
|
18
|
+
export declare function canonicalServerUrl(serverUrl: string): string;
|
|
19
|
+
export declare class McpTokenStore {
|
|
20
|
+
private readonly path;
|
|
21
|
+
constructor(path: string);
|
|
22
|
+
/** The stored entry for a server, or null. `serverUrl` may be any spelling of the URL. */
|
|
23
|
+
get(serverUrl: string): McpTokenEntry | null;
|
|
24
|
+
set(serverUrl: string, entry: McpTokenEntry): void;
|
|
25
|
+
delete(serverUrl: string): void;
|
|
26
|
+
private readAll;
|
|
27
|
+
private writeAll;
|
|
28
|
+
private corruptError;
|
|
29
|
+
}
|
|
30
|
+
export {};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// PARENT-side MCP OAuth token persistence. Tokens live with the engine — never in the run
|
|
2
|
+
// process beyond the single brokered value a request needs — in one JSON file under the data
|
|
3
|
+
// dir, mode 0600 (they are credentials; CODE_QUALITY §7.4 treats them like secrets: values
|
|
4
|
+
// never logged). Zod-validated on every read because a disk file is a trust boundary even
|
|
5
|
+
// when we wrote it.
|
|
6
|
+
import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { dirname } from "node:path";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import { EngineError } from "../errors.js";
|
|
10
|
+
/** Where the store lives under the engine data dir (engine + supervisor must agree). */
|
|
11
|
+
export const MCP_TOKENS_FILENAME = "mcp_tokens.json";
|
|
12
|
+
const entrySchema = z.strictObject({
|
|
13
|
+
/** The dynamically-registered OAuth client id this server's grants belong to. */
|
|
14
|
+
clientId: z.string().min(1),
|
|
15
|
+
accessToken: z.string().min(1),
|
|
16
|
+
refreshToken: z.string().min(1).optional(),
|
|
17
|
+
/** Epoch ms. Absent ⇒ the AS declared no expiry; the token is used until rejected. */
|
|
18
|
+
expiresAt: z.number().int().positive().optional(),
|
|
19
|
+
// Why these two travel with the tokens: silent refresh runs headless in the supervisor; it
|
|
20
|
+
// must not depend on re-running discovery (a transient discovery failure would break runs
|
|
21
|
+
// that hold a perfectly good refresh token).
|
|
22
|
+
tokenEndpoint: z.string().min(1).optional(),
|
|
23
|
+
/** The RFC 8707 resource indicator the grant was issued for. */
|
|
24
|
+
resource: z.string().min(1).optional(),
|
|
25
|
+
});
|
|
26
|
+
const fileSchema = z.record(z.string(), entrySchema);
|
|
27
|
+
/**
|
|
28
|
+
* Canonicalize an MCP server URL for keying token state: the same server written with a
|
|
29
|
+
* default port, trailing slash, or different case must hit the same entry — otherwise an
|
|
30
|
+
* authorize under one spelling is invisible to a run using another.
|
|
31
|
+
*/
|
|
32
|
+
export function canonicalServerUrl(serverUrl) {
|
|
33
|
+
let url;
|
|
34
|
+
try {
|
|
35
|
+
url = new URL(serverUrl);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
throw new EngineError("VALIDATION", `"${serverUrl}" is not a valid MCP server URL.`);
|
|
39
|
+
}
|
|
40
|
+
const path = url.pathname.replace(/\/+$/, "");
|
|
41
|
+
// URL already lowercases the host and drops default ports from `host`.
|
|
42
|
+
return `${url.protocol}//${url.host}${path}`;
|
|
43
|
+
}
|
|
44
|
+
export class McpTokenStore {
|
|
45
|
+
path;
|
|
46
|
+
constructor(path) {
|
|
47
|
+
this.path = path;
|
|
48
|
+
}
|
|
49
|
+
/** The stored entry for a server, or null. `serverUrl` may be any spelling of the URL. */
|
|
50
|
+
get(serverUrl) {
|
|
51
|
+
return this.readAll()[canonicalServerUrl(serverUrl)] ?? null;
|
|
52
|
+
}
|
|
53
|
+
set(serverUrl, entry) {
|
|
54
|
+
const all = this.readAll();
|
|
55
|
+
all[canonicalServerUrl(serverUrl)] = entry;
|
|
56
|
+
this.writeAll(all);
|
|
57
|
+
}
|
|
58
|
+
delete(serverUrl) {
|
|
59
|
+
const all = this.readAll();
|
|
60
|
+
const key = canonicalServerUrl(serverUrl);
|
|
61
|
+
if (key in all) {
|
|
62
|
+
delete all[key];
|
|
63
|
+
this.writeAll(all);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
readAll() {
|
|
67
|
+
let raw;
|
|
68
|
+
try {
|
|
69
|
+
raw = readFileSync(this.path, "utf8");
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return {}; // no file yet — nothing authorized
|
|
73
|
+
}
|
|
74
|
+
let json;
|
|
75
|
+
try {
|
|
76
|
+
json = JSON.parse(raw);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
throw this.corruptError();
|
|
80
|
+
}
|
|
81
|
+
const parsed = fileSchema.safeParse(json);
|
|
82
|
+
if (!parsed.success)
|
|
83
|
+
throw this.corruptError();
|
|
84
|
+
return parsed.data;
|
|
85
|
+
}
|
|
86
|
+
writeAll(all) {
|
|
87
|
+
mkdirSync(dirname(this.path), { recursive: true });
|
|
88
|
+
// Write-tmp-then-rename so a crash mid-write can't corrupt the token file (which would
|
|
89
|
+
// force re-authorizing every MCP server). rename is atomic within a filesystem; the tmp
|
|
90
|
+
// sibling shares the data dir so it's never a cross-device move.
|
|
91
|
+
const tmp = `${this.path}.${String(process.pid)}.tmp`;
|
|
92
|
+
writeFileSync(tmp, `${JSON.stringify(all, null, 2)}\n`, { mode: 0o600 });
|
|
93
|
+
// writeFileSync only applies `mode` on create — re-assert so an existing tmp from a prior
|
|
94
|
+
// crash ends up locked down before it becomes the real file.
|
|
95
|
+
chmodSync(tmp, 0o600);
|
|
96
|
+
renameSync(tmp, this.path);
|
|
97
|
+
}
|
|
98
|
+
corruptError() {
|
|
99
|
+
return new EngineError("INTERNAL", `The MCP token store at ${this.path} is corrupt.`, "Delete the file and re-authorize the affected servers with engine.authorizeMcpServer.");
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { JsonRpcOutbound, McpTransport } from "./jsonrpc.js";
|
|
2
|
+
export interface HttpTransportOptions {
|
|
3
|
+
/** The MCP server's name from the agent() call — names the endpoint in every error. */
|
|
4
|
+
serverName: string;
|
|
5
|
+
url: string;
|
|
6
|
+
/** Program-supplied headers (the program is the trusted layer; credentials go here). */
|
|
7
|
+
headers?: Record<string, string> | undefined;
|
|
8
|
+
/**
|
|
9
|
+
* The OAuth hook: called when the server answers 401. `failedToken` is the bearer token the
|
|
10
|
+
* rejected request carried (null when none was sent). Returns a token to retry with; throws
|
|
11
|
+
* to fail the call (e.g. when the engine holds no token and a human must authorize).
|
|
12
|
+
* Omitted ⇒ a 401 is a plain provider error.
|
|
13
|
+
*/
|
|
14
|
+
acquireToken?: ((failedToken: string | null) => Promise<string>) | undefined;
|
|
15
|
+
fetchImpl?: typeof fetch | undefined;
|
|
16
|
+
}
|
|
17
|
+
export declare class HttpTransport implements McpTransport {
|
|
18
|
+
private readonly serverName;
|
|
19
|
+
private readonly url;
|
|
20
|
+
private readonly baseHeaders;
|
|
21
|
+
private readonly acquireToken;
|
|
22
|
+
private readonly fetchImpl;
|
|
23
|
+
private messageCb;
|
|
24
|
+
private sessionId;
|
|
25
|
+
private protocolVersion;
|
|
26
|
+
private bearerToken;
|
|
27
|
+
constructor(opts: HttpTransportOptions);
|
|
28
|
+
send(message: JsonRpcOutbound): Promise<void>;
|
|
29
|
+
onMessage(cb: (message: unknown) => void): void;
|
|
30
|
+
onClose(_cb: (err: Error) => void): void;
|
|
31
|
+
/** The MCP client calls this after the initialize handshake settles the version. */
|
|
32
|
+
setProtocolVersion(version: string): void;
|
|
33
|
+
/** Best-effort session teardown (spec: clients SHOULD DELETE the session when done). */
|
|
34
|
+
close(): Promise<void>;
|
|
35
|
+
private requestHeaders;
|
|
36
|
+
private consumeResponse;
|
|
37
|
+
private deliverJson;
|
|
38
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// MCP streamable-HTTP transport (spec rev 2025-06-18 §Transports): every client message is a
|
|
2
|
+
// POST to the server URL; the response is either a single JSON body or an SSE stream of
|
|
3
|
+
// JSON-RPC messages (one shared parser with the provider adapters — src/agent/sse.ts). The
|
|
4
|
+
// transport also owns the session header (`Mcp-Session-Id`, captured at initialize and
|
|
5
|
+
// replayed on every later request) and the OAuth retry dance: program-supplied headers are
|
|
6
|
+
// always applied first; a bearer token is only fetched — through the `acquireToken` hook the
|
|
7
|
+
// connection layer provides — after the server answers 401.
|
|
8
|
+
import { EngineError } from "../errors.js";
|
|
9
|
+
import { sseDataLines } from "../agent/sse.js";
|
|
10
|
+
/** 401-retry budget per message: original try + one retried token + one invalidate-and-retry. */
|
|
11
|
+
const MAX_AUTH_ATTEMPTS = 3;
|
|
12
|
+
export class HttpTransport {
|
|
13
|
+
serverName;
|
|
14
|
+
url;
|
|
15
|
+
baseHeaders;
|
|
16
|
+
acquireToken;
|
|
17
|
+
fetchImpl;
|
|
18
|
+
messageCb = null;
|
|
19
|
+
sessionId = null;
|
|
20
|
+
protocolVersion = null;
|
|
21
|
+
bearerToken = null;
|
|
22
|
+
constructor(opts) {
|
|
23
|
+
this.serverName = opts.serverName;
|
|
24
|
+
this.url = opts.url;
|
|
25
|
+
this.baseHeaders = { ...opts.headers };
|
|
26
|
+
this.acquireToken = opts.acquireToken;
|
|
27
|
+
this.fetchImpl = opts.fetchImpl ?? fetch;
|
|
28
|
+
}
|
|
29
|
+
async send(message) {
|
|
30
|
+
const body = JSON.stringify(message);
|
|
31
|
+
let response = null;
|
|
32
|
+
for (let attempt = 1; attempt <= MAX_AUTH_ATTEMPTS; attempt++) {
|
|
33
|
+
const sentToken = this.bearerToken;
|
|
34
|
+
try {
|
|
35
|
+
response = await this.fetchImpl(this.url, {
|
|
36
|
+
method: "POST",
|
|
37
|
+
headers: this.requestHeaders(sentToken),
|
|
38
|
+
body,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
// fetch network failures are bare TypeErrors ("fetch failed") — name the server.
|
|
43
|
+
throw new EngineError("PROVIDER_ERROR", `MCP server "${this.serverName}" (${this.url}) is unreachable: ` +
|
|
44
|
+
`${err instanceof Error ? err.message : String(err)}.`, "Check the URL and that the server is up — the agent() call named this server, so " +
|
|
45
|
+
"the run fails rather than silently dropping its tools.");
|
|
46
|
+
}
|
|
47
|
+
if (response.status !== 401)
|
|
48
|
+
break;
|
|
49
|
+
if (this.acquireToken === undefined || attempt === MAX_AUTH_ATTEMPTS) {
|
|
50
|
+
throw new EngineError("PROVIDER_ERROR", `MCP server "${this.serverName}" (${this.url}) rejected the request: 401 Unauthorized.`, this.acquireToken === undefined
|
|
51
|
+
? "The server demands credentials; supply them in the McpServerRef headers, or use " +
|
|
52
|
+
"an engine-authorized OAuth token."
|
|
53
|
+
: "A freshly issued token was still rejected — re-authorize the server with " +
|
|
54
|
+
"engine.authorizeMcpServer.");
|
|
55
|
+
}
|
|
56
|
+
// The hook decides between cached/refreshed/none; passing the failed token tells the
|
|
57
|
+
// engine to invalidate it rather than hand the same one back.
|
|
58
|
+
this.bearerToken = await this.acquireToken(sentToken);
|
|
59
|
+
}
|
|
60
|
+
if (response === null)
|
|
61
|
+
return; // unreachable: the loop always assigns — satisfies narrowing
|
|
62
|
+
await this.consumeResponse(response);
|
|
63
|
+
}
|
|
64
|
+
onMessage(cb) {
|
|
65
|
+
this.messageCb = cb;
|
|
66
|
+
}
|
|
67
|
+
onClose(_cb) {
|
|
68
|
+
// HTTP has no long-lived pipe to lose; per-request failures reject the send instead.
|
|
69
|
+
}
|
|
70
|
+
/** The MCP client calls this after the initialize handshake settles the version. */
|
|
71
|
+
setProtocolVersion(version) {
|
|
72
|
+
this.protocolVersion = version;
|
|
73
|
+
}
|
|
74
|
+
/** Best-effort session teardown (spec: clients SHOULD DELETE the session when done). */
|
|
75
|
+
async close() {
|
|
76
|
+
if (this.sessionId === null)
|
|
77
|
+
return;
|
|
78
|
+
try {
|
|
79
|
+
await this.fetchImpl(this.url, {
|
|
80
|
+
method: "DELETE",
|
|
81
|
+
headers: this.requestHeaders(this.bearerToken),
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
// The session will expire server-side; teardown must never fail a finished run.
|
|
86
|
+
}
|
|
87
|
+
this.sessionId = null;
|
|
88
|
+
}
|
|
89
|
+
requestHeaders(token) {
|
|
90
|
+
return {
|
|
91
|
+
"content-type": "application/json",
|
|
92
|
+
accept: "application/json, text/event-stream",
|
|
93
|
+
...this.baseHeaders,
|
|
94
|
+
...(this.sessionId !== null ? { "mcp-session-id": this.sessionId } : {}),
|
|
95
|
+
...(this.protocolVersion !== null ? { "mcp-protocol-version": this.protocolVersion } : {}),
|
|
96
|
+
// After base headers: OAuth only engages when the program's own headers got a 401, so
|
|
97
|
+
// overriding a program-supplied Authorization here is the correct escalation.
|
|
98
|
+
...(token !== null ? { authorization: `Bearer ${token}` } : {}),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
async consumeResponse(response) {
|
|
102
|
+
const session = response.headers.get("mcp-session-id");
|
|
103
|
+
if (session !== null && session.length > 0)
|
|
104
|
+
this.sessionId = session;
|
|
105
|
+
if (response.status === 202 || response.status === 204)
|
|
106
|
+
return; // accepted notification
|
|
107
|
+
if (!response.ok) {
|
|
108
|
+
const detail = (await response.text()).slice(0, 300);
|
|
109
|
+
throw new EngineError("PROVIDER_ERROR", `MCP server "${this.serverName}" (${this.url}) returned ${String(response.status)}: ${detail}`);
|
|
110
|
+
}
|
|
111
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
112
|
+
if (contentType.includes("text/event-stream")) {
|
|
113
|
+
// The server chose to stream: each SSE data line is one JSON-RPC message; the response
|
|
114
|
+
// to our request arrives through the same onMessage path the correlator already watches.
|
|
115
|
+
for await (const data of sseDataLines(response)) {
|
|
116
|
+
this.deliverJson(data);
|
|
117
|
+
}
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (contentType.includes("application/json")) {
|
|
121
|
+
this.deliverJson(await response.text());
|
|
122
|
+
}
|
|
123
|
+
// Any other content type carries no JSON-RPC messages — nothing to deliver.
|
|
124
|
+
}
|
|
125
|
+
deliverJson(text) {
|
|
126
|
+
let message;
|
|
127
|
+
try {
|
|
128
|
+
message = JSON.parse(text);
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return; // malformed server frame — the request will time out with a clear error
|
|
132
|
+
}
|
|
133
|
+
// 2025-03-26-era servers may still answer with a JSON-RPC batch array.
|
|
134
|
+
if (Array.isArray(message)) {
|
|
135
|
+
// Why the annotation: Array.isArray narrows unknown to any[]; widen back to unknown[].
|
|
136
|
+
const items = message;
|
|
137
|
+
for (const item of items)
|
|
138
|
+
this.messageCb?.(item);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
this.messageCb?.(message);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { JsonRpcOutbound, McpTransport } from "./jsonrpc.js";
|
|
2
|
+
export interface StdioTransportOptions {
|
|
3
|
+
/** The MCP server's name from the agent() call — names the process in every error. */
|
|
4
|
+
serverName: string;
|
|
5
|
+
command: string;
|
|
6
|
+
args?: readonly string[] | undefined;
|
|
7
|
+
/** Layered over process.env (the program supplies credentials here directly). */
|
|
8
|
+
env?: Record<string, string> | undefined;
|
|
9
|
+
}
|
|
10
|
+
export declare class StdioTransport implements McpTransport {
|
|
11
|
+
private readonly serverName;
|
|
12
|
+
private readonly child;
|
|
13
|
+
private messageCb;
|
|
14
|
+
private closeCb;
|
|
15
|
+
/** Set once the transport failed or was closed — later sends must fail fast, not hang. */
|
|
16
|
+
private dead;
|
|
17
|
+
private closedDeliberately;
|
|
18
|
+
private stdoutBuffer;
|
|
19
|
+
constructor(opts: StdioTransportOptions);
|
|
20
|
+
send(message: JsonRpcOutbound): Promise<void>;
|
|
21
|
+
onMessage(cb: (message: unknown) => void): void;
|
|
22
|
+
onClose(cb: (err: Error) => void): void;
|
|
23
|
+
/** Kill the server process. Deliberate teardown — no error is surfaced for the exit. */
|
|
24
|
+
close(): Promise<void>;
|
|
25
|
+
private deliverLine;
|
|
26
|
+
private die;
|
|
27
|
+
}
|