@exposify/express 0.3.7 → 0.3.9
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 +284 -7
- package/dist/cli.js +295 -0
- package/dist/index.cjs +476 -49
- package/dist/index.d.cts +30 -2
- package/dist/index.d.ts +30 -2
- package/dist/index.js +494 -45
- package/dist/scan-preload.cjs +7120 -0
- package/dist/scan-preload.js +7124 -0
- package/package.json +8 -4
package/dist/index.js
CHANGED
|
@@ -6,19 +6,40 @@ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require
|
|
|
6
6
|
});
|
|
7
7
|
|
|
8
8
|
// src/index.ts
|
|
9
|
-
import { join as
|
|
9
|
+
import { join as join3 } from "path";
|
|
10
10
|
import {
|
|
11
|
-
|
|
11
|
+
countTools,
|
|
12
|
+
createBrokerGate,
|
|
13
|
+
createOAuthGate,
|
|
14
|
+
describeAuth,
|
|
15
|
+
formatFailed,
|
|
16
|
+
formatReady,
|
|
17
|
+
formatWarning,
|
|
18
|
+
fieldKind,
|
|
19
|
+
handleLoginPage,
|
|
20
|
+
hostedResolverInfo,
|
|
21
|
+
loginResultFromResponse,
|
|
22
|
+
publicRequestUrl,
|
|
23
|
+
reportAppLogin,
|
|
24
|
+
resolveOptions,
|
|
25
|
+
routeToolsPhrase,
|
|
26
|
+
startupLogEnabled
|
|
12
27
|
} from "@exposify/core";
|
|
13
28
|
import {
|
|
14
29
|
createNodeMcpHandler,
|
|
30
|
+
loadDocsFromFs as loadDocsFromFs2,
|
|
15
31
|
loadOrBuildKb
|
|
16
32
|
} from "@exposify/core/node";
|
|
17
33
|
|
|
18
34
|
// src/surface.ts
|
|
19
|
-
import { readFile } from "fs/promises";
|
|
20
|
-
import { join } from "path";
|
|
21
|
-
import {
|
|
35
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
36
|
+
import { join as join2 } from "path";
|
|
37
|
+
import { AsyncLocalStorage } from "async_hooks";
|
|
38
|
+
import { randomBytes, timingSafeEqual } from "crypto";
|
|
39
|
+
import {
|
|
40
|
+
createServer,
|
|
41
|
+
request as httpRequest
|
|
42
|
+
} from "http";
|
|
22
43
|
import { loadDocsFromFs } from "@exposify/core/node";
|
|
23
44
|
|
|
24
45
|
// src/introspect.ts
|
|
@@ -42,6 +63,7 @@ function installMountRecorder(routerCtor) {
|
|
|
42
63
|
const wrapper = function(...args) {
|
|
43
64
|
try {
|
|
44
65
|
recordUse(this, args);
|
|
66
|
+
recordTaggedOwner(this, args);
|
|
45
67
|
} catch {
|
|
46
68
|
}
|
|
47
69
|
return originalUse.apply(this, args);
|
|
@@ -51,6 +73,21 @@ function installMountRecorder(routerCtor) {
|
|
|
51
73
|
} catch {
|
|
52
74
|
}
|
|
53
75
|
}
|
|
76
|
+
var MOUNT_OWNER_TAG = /* @__PURE__ */ Symbol.for("exposify.express.recordMountOwner");
|
|
77
|
+
var taggedOwners = /* @__PURE__ */ new WeakMap();
|
|
78
|
+
function recordTaggedOwner(parent, args) {
|
|
79
|
+
const stack = [...args];
|
|
80
|
+
while (stack.length > 0) {
|
|
81
|
+
const arg = stack.pop();
|
|
82
|
+
if (Array.isArray(arg)) stack.push(...arg);
|
|
83
|
+
else if (typeof arg === "function" && arg[MOUNT_OWNER_TAG]) {
|
|
84
|
+
if (!taggedOwners.has(arg)) taggedOwners.set(arg, parent);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
function getMountOwner(fn) {
|
|
89
|
+
return taggedOwners.get(fn);
|
|
90
|
+
}
|
|
54
91
|
function getRecordedMounts(handle) {
|
|
55
92
|
if (typeof handle !== "function") return void 0;
|
|
56
93
|
return recordedMounts.get(handle);
|
|
@@ -115,6 +152,7 @@ function flattenHandlers(items) {
|
|
|
115
152
|
}
|
|
116
153
|
|
|
117
154
|
// src/introspect.ts
|
|
155
|
+
var routeHandlers = /* @__PURE__ */ new WeakMap();
|
|
118
156
|
function routesFromExpress(app, opts) {
|
|
119
157
|
const router = routerOf(app);
|
|
120
158
|
const out = [];
|
|
@@ -167,13 +205,15 @@ function walk(stack, prefix, owner, out, walked, unresolved) {
|
|
|
167
205
|
const marker = handlers.map(getMarker).find(Boolean);
|
|
168
206
|
for (const [method, enabled] of Object.entries(layer.route.methods)) {
|
|
169
207
|
if (!enabled || method === "_all") continue;
|
|
170
|
-
|
|
208
|
+
const route = {
|
|
171
209
|
method: method.toUpperCase(),
|
|
172
210
|
path,
|
|
173
211
|
params: extractParams(path),
|
|
174
212
|
marker,
|
|
175
213
|
description: marker?.kind === "described" ? marker.description : void 0
|
|
176
|
-
}
|
|
214
|
+
};
|
|
215
|
+
routeHandlers.set(route, handlers);
|
|
216
|
+
out.push(route);
|
|
177
217
|
}
|
|
178
218
|
continue;
|
|
179
219
|
}
|
|
@@ -258,59 +298,305 @@ function ownerAndStackOf(handle) {
|
|
|
258
298
|
return void 0;
|
|
259
299
|
}
|
|
260
300
|
|
|
301
|
+
// src/descriptions.ts
|
|
302
|
+
import { readFile } from "fs/promises";
|
|
303
|
+
import { join } from "path";
|
|
304
|
+
import { DESCRIPTIONS_FILE, parseDescriptions } from "@exposify/core";
|
|
305
|
+
import { applyDescriptions } from "@exposify/core";
|
|
306
|
+
async function loadDescriptions(cwd, option, warn) {
|
|
307
|
+
if (option === false) return /* @__PURE__ */ new Map();
|
|
308
|
+
const path = join(cwd, option ?? DESCRIPTIONS_FILE);
|
|
309
|
+
let text;
|
|
310
|
+
try {
|
|
311
|
+
text = await readFile(path, "utf8");
|
|
312
|
+
} catch {
|
|
313
|
+
if (option !== void 0) warn(`descriptions file not found: ${path}`);
|
|
314
|
+
return /* @__PURE__ */ new Map();
|
|
315
|
+
}
|
|
316
|
+
const parsed = parseDescriptions(text);
|
|
317
|
+
if (typeof parsed === "string") {
|
|
318
|
+
warn(`ignoring ${path}: ${parsed}`);
|
|
319
|
+
return /* @__PURE__ */ new Map();
|
|
320
|
+
}
|
|
321
|
+
return new Map(Object.entries(parsed.descriptions).map(([key, entry]) => [key, entry.text.trim()]));
|
|
322
|
+
}
|
|
323
|
+
async function loadOpenApi(cwd, option, warn) {
|
|
324
|
+
if (option === void 0) return void 0;
|
|
325
|
+
if (typeof option === "object") return option;
|
|
326
|
+
const path = join(cwd, option);
|
|
327
|
+
if (/\.ya?ml$/i.test(path)) {
|
|
328
|
+
warn(`openapi: YAML isn't supported (${path}); pass the parsed document object or a .json file`);
|
|
329
|
+
return void 0;
|
|
330
|
+
}
|
|
331
|
+
try {
|
|
332
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
333
|
+
} catch (err) {
|
|
334
|
+
warn(`openapi: couldn't read ${path}: ${err.message}`);
|
|
335
|
+
return void 0;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
261
339
|
// src/surface.ts
|
|
340
|
+
import { applyOpenApi } from "@exposify/core";
|
|
341
|
+
var warnOnce = (m) => console.warn(`[agentready] warning: ${m}`);
|
|
342
|
+
var NULL_BODY_STATUSES = /* @__PURE__ */ new Set([101, 103, 204, 205, 304]);
|
|
343
|
+
var IDENTITY_HEADERS = ["host", "forwarded", "x-forwarded-for", "x-forwarded-proto", "x-forwarded-host", "x-forwarded-port"];
|
|
344
|
+
var DISPATCH_HEADER = "x-exposify-dispatch";
|
|
345
|
+
var callerStore = new AsyncLocalStorage();
|
|
346
|
+
function callerOf(req) {
|
|
347
|
+
const headers = {};
|
|
348
|
+
for (const name of IDENTITY_HEADERS) {
|
|
349
|
+
const value = req.headers[name];
|
|
350
|
+
if (typeof value === "string") headers[name] = value;
|
|
351
|
+
else if (Array.isArray(value)) headers[name] = value.join(", ");
|
|
352
|
+
}
|
|
353
|
+
const socket = req.socket;
|
|
354
|
+
return {
|
|
355
|
+
remoteAddress: socket.remoteAddress,
|
|
356
|
+
remoteFamily: socket.remoteFamily,
|
|
357
|
+
encrypted: socket.encrypted === true,
|
|
358
|
+
headers
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
function runAsCaller(caller, fn) {
|
|
362
|
+
return callerStore.run(caller, fn);
|
|
363
|
+
}
|
|
262
364
|
async function buildExpressSurface(app, cfg, cwd, opts) {
|
|
263
365
|
const info = await readPackageInfo(cwd);
|
|
264
|
-
const appish = app;
|
|
265
|
-
if (!appish.request || !appish.response) {
|
|
266
|
-
console.warn(
|
|
267
|
-
"[agentready] express request/response prototypes not found; light-my-request corruption guard inactive"
|
|
268
|
-
);
|
|
269
|
-
}
|
|
270
|
-
const reqProto = appish.request ? Object.getPrototypeOf(appish.request) : void 0;
|
|
271
|
-
const resProto = appish.response ? Object.getPrototypeOf(appish.response) : void 0;
|
|
272
|
-
const pristineReqProto = reqProto ? Object.getPrototypeOf(reqProto) : void 0;
|
|
273
|
-
const pristineResProto = resProto ? Object.getPrototypeOf(resProto) : void 0;
|
|
274
366
|
return {
|
|
275
367
|
info,
|
|
276
|
-
|
|
368
|
+
// Text precedence: describe() in code > exposify.descriptions.json (reviewed) > OpenAPI > derived.
|
|
369
|
+
routes: applyDescriptions(
|
|
370
|
+
applyOpenApi(routesFromExpress(app, opts), await loadOpenApi(cwd, opts?.openapi, warnOnce)),
|
|
371
|
+
await loadDescriptions(cwd, opts?.descriptions, warnOnce)
|
|
372
|
+
),
|
|
277
373
|
docs: await loadDocsFromFs(cfg.docs, cwd),
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
374
|
+
dispatch: appDispatcher(app)
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
var dispatchers = /* @__PURE__ */ new WeakMap();
|
|
378
|
+
function appDispatcher(app) {
|
|
379
|
+
const known = dispatchers.get(app);
|
|
380
|
+
if (known) return known;
|
|
381
|
+
const loopback = lazyLoopback(app);
|
|
382
|
+
const dispatch = async (req) => {
|
|
383
|
+
const url = new URL(req.url);
|
|
384
|
+
const payload = req.body ? Buffer.from(await req.arrayBuffer()) : void 0;
|
|
385
|
+
const caller = callerStore.getStore();
|
|
386
|
+
const headers = {
|
|
387
|
+
...caller?.headers ?? {},
|
|
388
|
+
...Object.fromEntries(req.headers.entries())
|
|
389
|
+
};
|
|
390
|
+
if (payload) headers["content-length"] = String(payload.length);
|
|
391
|
+
const { port, tag, release } = await loopback(caller);
|
|
392
|
+
headers[DISPATCH_HEADER] = tag;
|
|
393
|
+
const res = await new Promise((resolve, reject) => {
|
|
394
|
+
const outgoing = httpRequest(
|
|
395
|
+
{ host: "127.0.0.1", port, method: req.method, path: url.pathname + url.search, headers, agent: false },
|
|
396
|
+
resolve
|
|
397
|
+
);
|
|
398
|
+
outgoing.on("error", reject);
|
|
399
|
+
outgoing.end(payload);
|
|
400
|
+
}).catch((err) => {
|
|
401
|
+
release();
|
|
402
|
+
throw err;
|
|
403
|
+
});
|
|
404
|
+
const chunks = [];
|
|
405
|
+
for await (const chunk of res) chunks.push(chunk);
|
|
406
|
+
const responseHeaders = new Headers();
|
|
407
|
+
for (const [name, value] of Object.entries(res.headers)) {
|
|
408
|
+
if (Array.isArray(value)) for (const v of value) responseHeaders.append(name, v);
|
|
409
|
+
else if (value !== void 0) responseHeaders.set(name, value);
|
|
295
410
|
}
|
|
411
|
+
const status = res.statusCode ?? 502;
|
|
412
|
+
return new Response(NULL_BODY_STATUSES.has(status) ? null : Buffer.concat(chunks), {
|
|
413
|
+
status,
|
|
414
|
+
headers: responseHeaders
|
|
415
|
+
});
|
|
416
|
+
};
|
|
417
|
+
dispatchers.set(app, dispatch);
|
|
418
|
+
return dispatch;
|
|
419
|
+
}
|
|
420
|
+
function lazyLoopback(app) {
|
|
421
|
+
const secret = randomBytes(32);
|
|
422
|
+
const pending = /* @__PURE__ */ new Map();
|
|
423
|
+
let nextId = 0;
|
|
424
|
+
let port = null;
|
|
425
|
+
const listener = (req, res) => {
|
|
426
|
+
const raw = req.headers[DISPATCH_HEADER];
|
|
427
|
+
delete req.headers[DISPATCH_HEADER];
|
|
428
|
+
for (let i = req.rawHeaders.length - 2; i >= 0; i -= 2) {
|
|
429
|
+
if (req.rawHeaders[i].toLowerCase() === DISPATCH_HEADER) req.rawHeaders.splice(i, 2);
|
|
430
|
+
}
|
|
431
|
+
const [key, id] = typeof raw === "string" ? raw.split(".") : [];
|
|
432
|
+
const presented = Buffer.from(key ?? "", "hex");
|
|
433
|
+
if (!id || presented.length !== secret.length || !timingSafeEqual(presented, secret) || !pending.has(id)) {
|
|
434
|
+
res.writeHead(403).end();
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
const caller = pending.get(id);
|
|
438
|
+
pending.delete(id);
|
|
439
|
+
if (caller) {
|
|
440
|
+
const socket = req.socket;
|
|
441
|
+
Object.defineProperty(socket, "remoteAddress", { value: caller.remoteAddress, configurable: true });
|
|
442
|
+
Object.defineProperty(socket, "remoteFamily", { value: caller.remoteFamily, configurable: true });
|
|
443
|
+
if (caller.encrypted) Object.defineProperty(socket, "encrypted", { value: true, configurable: true });
|
|
444
|
+
}
|
|
445
|
+
app(req, res);
|
|
446
|
+
};
|
|
447
|
+
return async (caller) => {
|
|
448
|
+
port ??= new Promise((resolve, reject) => {
|
|
449
|
+
const server = createServer(listener);
|
|
450
|
+
server.once("error", reject);
|
|
451
|
+
server.listen(0, "127.0.0.1", () => {
|
|
452
|
+
server.unref();
|
|
453
|
+
resolve(server.address().port);
|
|
454
|
+
});
|
|
455
|
+
}).catch((err) => {
|
|
456
|
+
port = null;
|
|
457
|
+
throw err;
|
|
458
|
+
});
|
|
459
|
+
const resolved = await port;
|
|
460
|
+
const id = String(nextId++);
|
|
461
|
+
pending.set(id, caller);
|
|
462
|
+
return { port: resolved, tag: `${secret.toString("hex")}.${id}`, release: () => pending.delete(id) };
|
|
296
463
|
};
|
|
297
464
|
}
|
|
298
465
|
async function readPackageInfo(cwd) {
|
|
299
466
|
try {
|
|
300
|
-
const pkg = JSON.parse(await
|
|
467
|
+
const pkg = JSON.parse(await readFile2(join2(cwd, "package.json"), "utf8"));
|
|
301
468
|
return { name: pkg.name ?? "app", version: pkg.version ?? "0.0.0" };
|
|
302
469
|
} catch {
|
|
303
470
|
return { name: "app", version: "0.0.0" };
|
|
304
471
|
}
|
|
305
472
|
}
|
|
306
473
|
|
|
474
|
+
// src/login.ts
|
|
475
|
+
import { LOGIN_NAME, NOT_A_LOGIN, loginFields } from "@exposify/core";
|
|
476
|
+
var SESSION_CODE = /\bres\.cookie\(|\.session\b|\.login\(|\blogIn\(|set-cookie|setHeader\(\s*["']set-cookie|\bjwt\.sign\(|new\s+SignJWT|\bsign\(|createSession|issueToken|generateToken/i;
|
|
477
|
+
function bodyFieldsOf(source) {
|
|
478
|
+
const names = /* @__PURE__ */ new Set();
|
|
479
|
+
for (const m of source.matchAll(/\b\w+\.body\.([A-Za-z_$][\w$]*)/g)) names.add(m[1]);
|
|
480
|
+
for (const m of source.matchAll(/\b\w+\.body\[\s*["'`]([^"'`]+)["'`]\s*\]/g)) names.add(m[1]);
|
|
481
|
+
for (const m of source.matchAll(/(?:const|let|var)\s*\{([^}]*)\}\s*=\s*\w+\.body\b/g)) {
|
|
482
|
+
for (const part of m[1].split(",")) {
|
|
483
|
+
const name = part.split(/[:=]/)[0].trim().replace(/^\.\.\./, "");
|
|
484
|
+
if (/^[A-Za-z_$][\w$]*$/.test(name)) names.add(name);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
return [...names];
|
|
488
|
+
}
|
|
489
|
+
function sourceOf(fn) {
|
|
490
|
+
try {
|
|
491
|
+
return typeof fn === "function" ? Function.prototype.toString.call(fn) : "";
|
|
492
|
+
} catch {
|
|
493
|
+
return "";
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
function parsesJson(app) {
|
|
497
|
+
const stack = (app?._router ?? app?.router)?.stack;
|
|
498
|
+
if (!Array.isArray(stack)) return null;
|
|
499
|
+
const names = stack.map((l) => l.name ?? "");
|
|
500
|
+
if (names.includes("jsonParser")) return true;
|
|
501
|
+
if (names.includes("urlencodedParser")) return false;
|
|
502
|
+
return null;
|
|
503
|
+
}
|
|
504
|
+
function findExpressLogin(app, mounts) {
|
|
505
|
+
let routes;
|
|
506
|
+
try {
|
|
507
|
+
routes = routesFromExpress(app, { mounts });
|
|
508
|
+
} catch {
|
|
509
|
+
return null;
|
|
510
|
+
}
|
|
511
|
+
const candidates = [];
|
|
512
|
+
for (const route of routes) {
|
|
513
|
+
if (route.method !== "POST" || route.params.length > 0 || NOT_A_LOGIN.test(route.path)) continue;
|
|
514
|
+
const handlers = routeHandlers.get(route) ?? [];
|
|
515
|
+
const own = sourceOf(handlers.at(-1));
|
|
516
|
+
const all = handlers.map(sourceOf).join("\n");
|
|
517
|
+
const fields = loginFields(bodyFieldsOf(own));
|
|
518
|
+
if (!fields) continue;
|
|
519
|
+
const named = LOGIN_NAME.test(route.path);
|
|
520
|
+
if (!named && !SESSION_CODE.test(all)) continue;
|
|
521
|
+
candidates.push({ login: { path: route.path, fields, body: parsesJson(app) === false ? "form" : "json" }, named });
|
|
522
|
+
}
|
|
523
|
+
if (candidates.length === 0) return null;
|
|
524
|
+
candidates.sort((a, b) => Number(b.named) - Number(a.named) || a.login.fields.length - b.login.fields.length);
|
|
525
|
+
return { login: candidates[0].login, others: candidates.slice(1).map((c) => `POST ${c.login.path}`) };
|
|
526
|
+
}
|
|
527
|
+
|
|
307
528
|
// src/index.ts
|
|
308
|
-
import {
|
|
529
|
+
import {
|
|
530
|
+
exposed,
|
|
531
|
+
hidden,
|
|
532
|
+
describe,
|
|
533
|
+
completeMcpLogin,
|
|
534
|
+
createHostedAuthResolver,
|
|
535
|
+
hostedBroker
|
|
536
|
+
} from "@exposify/core";
|
|
537
|
+
var AIREADY_OPTIONS_TAG = /* @__PURE__ */ Symbol.for("exposify.express.aiReadyOptions");
|
|
309
538
|
var MCP_PATH = "/mcp";
|
|
539
|
+
var PRM_ROOT = "/.well-known/oauth-protected-resource";
|
|
540
|
+
var LOG_PREFIX = "[agentready]";
|
|
541
|
+
function toolsSummary(routes, docs, cfg) {
|
|
542
|
+
const counts = countTools(routes, cfg);
|
|
543
|
+
const details = counts.hiddenWrites > 0 ? [`${counts.hiddenWrites} write route${counts.hiddenWrites === 1 ? "" : "s"} hidden (opt in with exposed())`] : [];
|
|
544
|
+
if (cfg.expose === "*") details.unshift('all write routes exposed (expose: "*")');
|
|
545
|
+
return { tools: routeToolsPhrase(counts, docs), details, empty: counts.reads + counts.writes === 0 };
|
|
546
|
+
}
|
|
547
|
+
var NO_TOOLS_FIX = "register app.use(aiReady()) before your routers, list routers mounted earlier in the `mounts` option, or check whether routes are added later (e.g. after an await) -- those appear on the first /mcp request";
|
|
548
|
+
function prmPaths(resource) {
|
|
549
|
+
const resourcePath = new URL(resource).pathname;
|
|
550
|
+
return /* @__PURE__ */ new Set([PRM_ROOT, `${PRM_ROOT}${MCP_PATH}`, resourcePath === "/" ? PRM_ROOT : `${PRM_ROOT}${resourcePath}`]);
|
|
551
|
+
}
|
|
552
|
+
async function sendWebResponse(res, response) {
|
|
553
|
+
const body = response.body ? await response.text() : void 0;
|
|
554
|
+
res.writeHead(response.status, Object.fromEntries(response.headers)).end(body);
|
|
555
|
+
}
|
|
310
556
|
function aiReady(options = {}) {
|
|
311
557
|
const cfg = resolveOptions(options);
|
|
558
|
+
const gate = cfg.oauth ? createOAuthGate(cfg.oauth) : null;
|
|
559
|
+
const metadataPaths = cfg.oauth ? prmPaths(cfg.oauth.resource) : /* @__PURE__ */ new Set();
|
|
560
|
+
const brokerGate = !gate && cfg.docsAuth === "required" ? createBrokerGate(cfg.auth) : null;
|
|
561
|
+
const resourceOf = (req, path, mcpPath) => {
|
|
562
|
+
const original = String(req.originalUrl ?? req.url).split("?")[0];
|
|
563
|
+
const prefix = original.endsWith(path) ? original.slice(0, original.length - path.length) : "";
|
|
564
|
+
const local = `${req.protocol ?? "http"}://${req.headers.host ?? "localhost"}${prefix}${mcpPath}`;
|
|
565
|
+
return brokerGate.resourceFor(publicRequestUrl(local, headersOf(req)));
|
|
566
|
+
};
|
|
312
567
|
const cwd = options.cwd ?? process.cwd();
|
|
313
568
|
const mounts = options.mounts;
|
|
569
|
+
const brokerInfo = hostedResolverInfo(cfg.auth);
|
|
570
|
+
let loginFound = null;
|
|
571
|
+
const loginOf = (app) => {
|
|
572
|
+
if (!brokerInfo || options.login === false) return null;
|
|
573
|
+
if (!loginFound) {
|
|
574
|
+
const given = options.login;
|
|
575
|
+
const value = given ? { path: given.path, fields: given.fields.map((name) => ({ name, kind: fieldKind(name) })), body: given.body ?? "json" } : findExpressLogin(app, mounts)?.login ?? null;
|
|
576
|
+
loginFound = { value };
|
|
577
|
+
if (value) {
|
|
578
|
+
cfg.signedOutPaths = [value.path];
|
|
579
|
+
if (logReady) console.log(`${LOG_PREFIX} AI assistants sign in with your app's own login (POST ${value.path}) at ${MCP_PATH}/login`);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
return loginFound.value;
|
|
583
|
+
};
|
|
584
|
+
const appLoginFor = (req, login) => ({
|
|
585
|
+
label: `POST ${login.path}`,
|
|
586
|
+
fields: login.fields,
|
|
587
|
+
signedOutPaths: [login.path],
|
|
588
|
+
attempt: (values) => {
|
|
589
|
+
const body = login.body === "form" ? new URLSearchParams(values).toString() : JSON.stringify(values);
|
|
590
|
+
const request = new Request(`http://app.internal${login.path}`, {
|
|
591
|
+
method: "POST",
|
|
592
|
+
headers: { "content-type": login.body === "form" ? "application/x-www-form-urlencoded" : "application/json", ...req.headers["user-agent"] ? { "user-agent": String(req.headers["user-agent"]) } : {} },
|
|
593
|
+
body
|
|
594
|
+
});
|
|
595
|
+
return runAsCaller(callerOf(req), () => appDispatcher(req.app)(request)).then(loginResultFromResponse);
|
|
596
|
+
}
|
|
597
|
+
});
|
|
598
|
+
const descriptions = options.descriptions;
|
|
599
|
+
const openapi = options.openapi;
|
|
314
600
|
try {
|
|
315
601
|
installMountRecorder();
|
|
316
602
|
} catch {
|
|
@@ -318,45 +604,208 @@ function aiReady(options = {}) {
|
|
|
318
604
|
let handlerPromise = null;
|
|
319
605
|
let disabled = false;
|
|
320
606
|
let warned = false;
|
|
321
|
-
|
|
607
|
+
const logReady = startupLogEnabled(options.startupLog);
|
|
608
|
+
const auth = describeAuth(cfg.auth, cfg.oauth);
|
|
609
|
+
let reported = null;
|
|
610
|
+
const logSurface = (routes, docs, note) => {
|
|
611
|
+
const summary = toolsSummary(routes, docs, cfg);
|
|
612
|
+
const key = [summary.tools, ...summary.details, docs].join("|");
|
|
613
|
+
if (key === reported) return;
|
|
614
|
+
const first = reported === null;
|
|
615
|
+
reported = key;
|
|
616
|
+
if (summary.empty) {
|
|
617
|
+
console.warn(formatWarning(LOG_PREFIX, `MCP is up at ${MCP_PATH} but exposes no route tools`, NO_TOOLS_FIX));
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
if (!logReady) return;
|
|
621
|
+
const details = [...summary.details, ...note && !first ? [note] : []];
|
|
622
|
+
console.log(formatReady(LOG_PREFIX, { endpoint: MCP_PATH, tools: summary.tools, details, docs, auth }));
|
|
623
|
+
};
|
|
624
|
+
const middleware = (req, res, next) => {
|
|
322
625
|
const path = req.path ?? req.url.split("?")[0];
|
|
626
|
+
if (brokerInfo && (path === `${MCP_PATH}/login` || path === `${MCP_PATH}/login/`)) {
|
|
627
|
+
const login = loginOf(req.app);
|
|
628
|
+
if (!login) return next();
|
|
629
|
+
void serveLoginPage(req, res, login).catch((err) => {
|
|
630
|
+
console.warn("[agentready] sign-in form failed:", err);
|
|
631
|
+
if (!res.headersSent) res.writeHead(503).end("sign-in is unavailable");
|
|
632
|
+
});
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
if (gate && metadataPaths.has(path)) {
|
|
636
|
+
void sendWebResponse(res, gate.metadataResponse(req.method));
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
if (brokerGate && (path === PRM_ROOT || path === `${PRM_ROOT}${MCP_PATH}`)) {
|
|
640
|
+
const resource = resourceOf(req, path, MCP_PATH);
|
|
641
|
+
void reportAppLogin(cfg.auth, resource, loginOf(req.app)).then(() => sendWebResponse(res, brokerGate.metadataResponse(req.method, resource)));
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
323
644
|
if (path !== MCP_PATH && !path.startsWith(`${MCP_PATH}/`)) return next();
|
|
324
645
|
if (disabled) {
|
|
325
646
|
res.writeHead(503).end("agentready is disabled due to an internal error");
|
|
326
647
|
return;
|
|
327
648
|
}
|
|
328
|
-
|
|
329
|
-
|
|
649
|
+
const authorized = gate ? gate.check(headersOf(req)).then(async (result) => {
|
|
650
|
+
if (result.ok) return { claims: result.claims };
|
|
651
|
+
await sendWebResponse(res, result.response);
|
|
652
|
+
return null;
|
|
653
|
+
}, (err) => {
|
|
654
|
+
console.warn("[agentready] oauth gate failed:", err);
|
|
655
|
+
if (!res.headersSent) res.writeHead(503).end("authorization check failed");
|
|
656
|
+
return null;
|
|
657
|
+
}) : brokerGate ? reportAppLogin(cfg.auth, resourceOf(req, path, MCP_PATH), loginOf(req.app)).then(() => brokerGate.check(headersOf(req), resourceOf(req, path, MCP_PATH))).then(async (result) => {
|
|
658
|
+
if (result.ok) return {};
|
|
659
|
+
await sendWebResponse(res, result.response);
|
|
660
|
+
return null;
|
|
661
|
+
}, (err) => {
|
|
662
|
+
console.warn("[agentready] broker check failed:", err);
|
|
663
|
+
if (!res.headersSent) res.writeHead(503).end("authorization check failed");
|
|
664
|
+
return null;
|
|
665
|
+
}) : Promise.resolve({});
|
|
666
|
+
authorized.then((authContext) => {
|
|
667
|
+
if (!authContext) return;
|
|
668
|
+
handlerPromise ??= initialize(
|
|
669
|
+
req.app,
|
|
670
|
+
cfg,
|
|
671
|
+
cwd,
|
|
672
|
+
mounts,
|
|
673
|
+
{ descriptions, openapi },
|
|
674
|
+
(surface) => logSurface(surface.routes, surface.docs.length, "updated: routes registered after startup")
|
|
675
|
+
);
|
|
676
|
+
return handlerPromise.then(
|
|
677
|
+
(handler) => (
|
|
678
|
+
// Tool calls made while handling this request reach the app with
|
|
679
|
+
// this caller's address and forwarding headers (see surface.ts).
|
|
680
|
+
runAsCaller(callerOf(req), () => handler(req, res, authContext))
|
|
681
|
+
)
|
|
682
|
+
);
|
|
683
|
+
}).catch((err) => {
|
|
330
684
|
disabled = true;
|
|
331
685
|
handlerPromise = null;
|
|
332
686
|
if (!warned) {
|
|
333
687
|
warned = true;
|
|
334
|
-
console.
|
|
688
|
+
console.error(
|
|
689
|
+
formatFailed(
|
|
690
|
+
LOG_PREFIX,
|
|
691
|
+
errorMessage(err),
|
|
692
|
+
`${MCP_PATH} now answers 503 until the process restarts (your own routes are unaffected); full error below`
|
|
693
|
+
),
|
|
694
|
+
err
|
|
695
|
+
);
|
|
335
696
|
}
|
|
336
697
|
if (!res.headersSent) res.writeHead(503).end("agentready failed to initialize");
|
|
337
698
|
});
|
|
338
699
|
};
|
|
700
|
+
async function serveLoginPage(req, res, login) {
|
|
701
|
+
const original = String(req.originalUrl ?? req.url);
|
|
702
|
+
const local = `${req.protocol ?? "http"}://${req.headers.host ?? "localhost"}${original}`;
|
|
703
|
+
const url = new URL(publicRequestUrl(local, headersOf(req)));
|
|
704
|
+
url.search = new URL(local).search;
|
|
705
|
+
let body;
|
|
706
|
+
if (req.method === "POST") {
|
|
707
|
+
if (req.body && typeof req.body === "object" && Object.keys(req.body).length > 0) body = new URLSearchParams(req.body).toString();
|
|
708
|
+
else if (typeof req.body === "string") body = req.body;
|
|
709
|
+
else {
|
|
710
|
+
const chunks = [];
|
|
711
|
+
let size = 0;
|
|
712
|
+
for await (const chunk of req) {
|
|
713
|
+
size += chunk.length;
|
|
714
|
+
if (size > 16 * 1024) break;
|
|
715
|
+
chunks.push(chunk);
|
|
716
|
+
}
|
|
717
|
+
body = Buffer.concat(chunks).toString("utf8");
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
const request = new Request(url, { method: req.method, headers: headersOf(req), ...body !== void 0 ? { body } : {} });
|
|
721
|
+
const forwarded = String(req.headers["x-forwarded-for"] ?? "").split(",")[0]?.trim();
|
|
722
|
+
const ip = forwarded || req.socket?.remoteAddress;
|
|
723
|
+
const response = await handleLoginPage(request, appLoginFor(req, login), brokerInfo, {
|
|
724
|
+
path: url.pathname,
|
|
725
|
+
secure: url.protocol === "https:",
|
|
726
|
+
origin: url.origin,
|
|
727
|
+
...ip ? { ip } : {}
|
|
728
|
+
});
|
|
729
|
+
await sendWebResponse(res, response);
|
|
730
|
+
}
|
|
731
|
+
middleware[MOUNT_OWNER_TAG] = true;
|
|
732
|
+
middleware[AIREADY_OPTIONS_TAG] = options;
|
|
733
|
+
setImmediate(() => {
|
|
734
|
+
void startupCheck().catch(() => {
|
|
735
|
+
});
|
|
736
|
+
});
|
|
737
|
+
async function startupCheck() {
|
|
738
|
+
const owner = getMountOwner(middleware);
|
|
739
|
+
if (!owner) {
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
const probe = gate ? gate.probe() : Promise.resolve(null);
|
|
743
|
+
try {
|
|
744
|
+
const routes = routesFromExpress({ _router: owner }, { mounts });
|
|
745
|
+
const docs = await loadDocsFromFs2(cfg.docs, cwd);
|
|
746
|
+
logSurface(routes, docs.length);
|
|
747
|
+
} catch (err) {
|
|
748
|
+
console.error(
|
|
749
|
+
formatFailed(
|
|
750
|
+
LOG_PREFIX,
|
|
751
|
+
errorMessage(err),
|
|
752
|
+
`your app keeps serving its own routes; ${MCP_PATH} will retry on its first request and answer 503 if it fails again`
|
|
753
|
+
),
|
|
754
|
+
err
|
|
755
|
+
);
|
|
756
|
+
}
|
|
757
|
+
const problem = await probe;
|
|
758
|
+
if (problem && cfg.oauth) {
|
|
759
|
+
console.warn(
|
|
760
|
+
formatWarning(
|
|
761
|
+
LOG_PREFIX,
|
|
762
|
+
`can't verify access tokens from oauth.issuer ${cfg.oauth.issuer}: ${problem}`,
|
|
763
|
+
"make oauth.issuer match the tokens' `iss` exactly (trailing slash included) and check it's reachable from this server; until then MCP clients get 503"
|
|
764
|
+
)
|
|
765
|
+
);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
return middleware;
|
|
339
769
|
}
|
|
340
|
-
|
|
770
|
+
function errorMessage(err) {
|
|
771
|
+
return err instanceof Error ? err.message : String(err);
|
|
772
|
+
}
|
|
773
|
+
function headersOf(req) {
|
|
774
|
+
const headers = new Headers();
|
|
775
|
+
for (const [name, value] of Object.entries(req.headers)) {
|
|
776
|
+
if (typeof value === "string") headers.set(name, value);
|
|
777
|
+
else if (Array.isArray(value)) headers.set(name, value.join(", "));
|
|
778
|
+
}
|
|
779
|
+
return headers;
|
|
780
|
+
}
|
|
781
|
+
async function initialize(app, cfg, cwd, mounts, extras, onReady) {
|
|
341
782
|
let surface = null;
|
|
342
783
|
let kbPromise = null;
|
|
343
784
|
const getSurface = async () => {
|
|
344
|
-
surface ??= await buildExpressSurface(app, cfg, cwd, { mounts });
|
|
785
|
+
surface ??= await buildExpressSurface(app, cfg, cwd, { mounts, ...extras });
|
|
345
786
|
return surface;
|
|
346
787
|
};
|
|
347
788
|
const getKb = async () => {
|
|
348
789
|
const s = await getSurface();
|
|
349
|
-
kbPromise ??= loadOrBuildKb(s.docs,
|
|
790
|
+
kbPromise ??= loadOrBuildKb(s.docs, join3(cwd, ".exposify"));
|
|
350
791
|
return kbPromise;
|
|
351
792
|
};
|
|
352
|
-
await getSurface();
|
|
793
|
+
const built = await getSurface();
|
|
353
794
|
await getKb();
|
|
795
|
+
try {
|
|
796
|
+
onReady(built);
|
|
797
|
+
} catch {
|
|
798
|
+
}
|
|
354
799
|
return createNodeMcpHandler({ getSurface, getKb, cfg });
|
|
355
800
|
}
|
|
356
801
|
export {
|
|
802
|
+
AIREADY_OPTIONS_TAG,
|
|
357
803
|
aiReady,
|
|
804
|
+
completeMcpLogin,
|
|
805
|
+
createHostedAuthResolver,
|
|
358
806
|
describe,
|
|
359
807
|
exposed,
|
|
360
808
|
hidden,
|
|
809
|
+
hostedBroker,
|
|
361
810
|
routesFromExpress
|
|
362
811
|
};
|