@exposify/express 0.3.8 → 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 +173 -7
- package/dist/cli.js +295 -0
- package/dist/index.cjs +350 -55
- package/dist/index.d.cts +30 -2
- package/dist/index.d.ts +30 -2
- package/dist/index.js +348 -40
- package/dist/scan-preload.cjs +7120 -0
- package/dist/scan-preload.js +7124 -0
- package/package.json +7 -4
package/dist/index.cjs
CHANGED
|
@@ -20,21 +20,27 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
20
20
|
// src/index.ts
|
|
21
21
|
var index_exports = {};
|
|
22
22
|
__export(index_exports, {
|
|
23
|
+
AIREADY_OPTIONS_TAG: () => AIREADY_OPTIONS_TAG,
|
|
23
24
|
aiReady: () => aiReady,
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
completeMcpLogin: () => import_core7.completeMcpLogin,
|
|
26
|
+
createHostedAuthResolver: () => import_core7.createHostedAuthResolver,
|
|
27
|
+
describe: () => import_core7.describe,
|
|
28
|
+
exposed: () => import_core7.exposed,
|
|
29
|
+
hidden: () => import_core7.hidden,
|
|
30
|
+
hostedBroker: () => import_core7.hostedBroker,
|
|
27
31
|
routesFromExpress: () => routesFromExpress
|
|
28
32
|
});
|
|
29
33
|
module.exports = __toCommonJS(index_exports);
|
|
30
|
-
var
|
|
31
|
-
var
|
|
34
|
+
var import_node_path3 = require("path");
|
|
35
|
+
var import_core6 = require("@exposify/core");
|
|
32
36
|
var import_node2 = require("@exposify/core/node");
|
|
33
37
|
|
|
34
38
|
// src/surface.ts
|
|
35
|
-
var
|
|
36
|
-
var
|
|
37
|
-
var
|
|
39
|
+
var import_promises2 = require("fs/promises");
|
|
40
|
+
var import_node_path2 = require("path");
|
|
41
|
+
var import_node_async_hooks = require("async_hooks");
|
|
42
|
+
var import_node_crypto = require("crypto");
|
|
43
|
+
var import_node_http = require("http");
|
|
38
44
|
var import_node = require("@exposify/core/node");
|
|
39
45
|
|
|
40
46
|
// src/introspect.ts
|
|
@@ -148,6 +154,7 @@ function flattenHandlers(items) {
|
|
|
148
154
|
}
|
|
149
155
|
|
|
150
156
|
// src/introspect.ts
|
|
157
|
+
var routeHandlers = /* @__PURE__ */ new WeakMap();
|
|
151
158
|
function routesFromExpress(app, opts) {
|
|
152
159
|
const router = routerOf(app);
|
|
153
160
|
const out = [];
|
|
@@ -200,13 +207,15 @@ function walk(stack, prefix, owner, out, walked, unresolved) {
|
|
|
200
207
|
const marker = handlers.map(import_core.getMarker).find(Boolean);
|
|
201
208
|
for (const [method, enabled] of Object.entries(layer.route.methods)) {
|
|
202
209
|
if (!enabled || method === "_all") continue;
|
|
203
|
-
|
|
210
|
+
const route = {
|
|
204
211
|
method: method.toUpperCase(),
|
|
205
212
|
path,
|
|
206
213
|
params: extractParams(path),
|
|
207
214
|
marker,
|
|
208
215
|
description: marker?.kind === "described" ? marker.description : void 0
|
|
209
|
-
}
|
|
216
|
+
};
|
|
217
|
+
routeHandlers.set(route, handlers);
|
|
218
|
+
out.push(route);
|
|
210
219
|
}
|
|
211
220
|
continue;
|
|
212
221
|
}
|
|
@@ -291,61 +300,244 @@ function ownerAndStackOf(handle) {
|
|
|
291
300
|
return void 0;
|
|
292
301
|
}
|
|
293
302
|
|
|
303
|
+
// src/descriptions.ts
|
|
304
|
+
var import_promises = require("fs/promises");
|
|
305
|
+
var import_node_path = require("path");
|
|
306
|
+
var import_core2 = require("@exposify/core");
|
|
307
|
+
var import_core3 = require("@exposify/core");
|
|
308
|
+
async function loadDescriptions(cwd, option, warn) {
|
|
309
|
+
if (option === false) return /* @__PURE__ */ new Map();
|
|
310
|
+
const path = (0, import_node_path.join)(cwd, option ?? import_core2.DESCRIPTIONS_FILE);
|
|
311
|
+
let text;
|
|
312
|
+
try {
|
|
313
|
+
text = await (0, import_promises.readFile)(path, "utf8");
|
|
314
|
+
} catch {
|
|
315
|
+
if (option !== void 0) warn(`descriptions file not found: ${path}`);
|
|
316
|
+
return /* @__PURE__ */ new Map();
|
|
317
|
+
}
|
|
318
|
+
const parsed = (0, import_core2.parseDescriptions)(text);
|
|
319
|
+
if (typeof parsed === "string") {
|
|
320
|
+
warn(`ignoring ${path}: ${parsed}`);
|
|
321
|
+
return /* @__PURE__ */ new Map();
|
|
322
|
+
}
|
|
323
|
+
return new Map(Object.entries(parsed.descriptions).map(([key, entry]) => [key, entry.text.trim()]));
|
|
324
|
+
}
|
|
325
|
+
async function loadOpenApi(cwd, option, warn) {
|
|
326
|
+
if (option === void 0) return void 0;
|
|
327
|
+
if (typeof option === "object") return option;
|
|
328
|
+
const path = (0, import_node_path.join)(cwd, option);
|
|
329
|
+
if (/\.ya?ml$/i.test(path)) {
|
|
330
|
+
warn(`openapi: YAML isn't supported (${path}); pass the parsed document object or a .json file`);
|
|
331
|
+
return void 0;
|
|
332
|
+
}
|
|
333
|
+
try {
|
|
334
|
+
return JSON.parse(await (0, import_promises.readFile)(path, "utf8"));
|
|
335
|
+
} catch (err) {
|
|
336
|
+
warn(`openapi: couldn't read ${path}: ${err.message}`);
|
|
337
|
+
return void 0;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
294
341
|
// src/surface.ts
|
|
342
|
+
var import_core4 = require("@exposify/core");
|
|
343
|
+
var warnOnce = (m) => console.warn(`[agentready] warning: ${m}`);
|
|
344
|
+
var NULL_BODY_STATUSES = /* @__PURE__ */ new Set([101, 103, 204, 205, 304]);
|
|
345
|
+
var IDENTITY_HEADERS = ["host", "forwarded", "x-forwarded-for", "x-forwarded-proto", "x-forwarded-host", "x-forwarded-port"];
|
|
346
|
+
var DISPATCH_HEADER = "x-exposify-dispatch";
|
|
347
|
+
var callerStore = new import_node_async_hooks.AsyncLocalStorage();
|
|
348
|
+
function callerOf(req) {
|
|
349
|
+
const headers = {};
|
|
350
|
+
for (const name of IDENTITY_HEADERS) {
|
|
351
|
+
const value = req.headers[name];
|
|
352
|
+
if (typeof value === "string") headers[name] = value;
|
|
353
|
+
else if (Array.isArray(value)) headers[name] = value.join(", ");
|
|
354
|
+
}
|
|
355
|
+
const socket = req.socket;
|
|
356
|
+
return {
|
|
357
|
+
remoteAddress: socket.remoteAddress,
|
|
358
|
+
remoteFamily: socket.remoteFamily,
|
|
359
|
+
encrypted: socket.encrypted === true,
|
|
360
|
+
headers
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
function runAsCaller(caller, fn) {
|
|
364
|
+
return callerStore.run(caller, fn);
|
|
365
|
+
}
|
|
295
366
|
async function buildExpressSurface(app, cfg, cwd, opts) {
|
|
296
367
|
const info = await readPackageInfo(cwd);
|
|
297
|
-
const appish = app;
|
|
298
|
-
if (!appish.request || !appish.response) {
|
|
299
|
-
console.warn(
|
|
300
|
-
"[agentready] express request/response prototypes not found; light-my-request corruption guard inactive"
|
|
301
|
-
);
|
|
302
|
-
}
|
|
303
|
-
const reqProto = appish.request ? Object.getPrototypeOf(appish.request) : void 0;
|
|
304
|
-
const resProto = appish.response ? Object.getPrototypeOf(appish.response) : void 0;
|
|
305
|
-
const pristineReqProto = reqProto ? Object.getPrototypeOf(reqProto) : void 0;
|
|
306
|
-
const pristineResProto = resProto ? Object.getPrototypeOf(resProto) : void 0;
|
|
307
368
|
return {
|
|
308
369
|
info,
|
|
309
|
-
|
|
370
|
+
// Text precedence: describe() in code > exposify.descriptions.json (reviewed) > OpenAPI > derived.
|
|
371
|
+
routes: (0, import_core3.applyDescriptions)(
|
|
372
|
+
(0, import_core4.applyOpenApi)(routesFromExpress(app, opts), await loadOpenApi(cwd, opts?.openapi, warnOnce)),
|
|
373
|
+
await loadDescriptions(cwd, opts?.descriptions, warnOnce)
|
|
374
|
+
),
|
|
310
375
|
docs: await (0, import_node.loadDocsFromFs)(cfg.docs, cwd),
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
376
|
+
dispatch: appDispatcher(app)
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
var dispatchers = /* @__PURE__ */ new WeakMap();
|
|
380
|
+
function appDispatcher(app) {
|
|
381
|
+
const known = dispatchers.get(app);
|
|
382
|
+
if (known) return known;
|
|
383
|
+
const loopback = lazyLoopback(app);
|
|
384
|
+
const dispatch = async (req) => {
|
|
385
|
+
const url = new URL(req.url);
|
|
386
|
+
const payload = req.body ? Buffer.from(await req.arrayBuffer()) : void 0;
|
|
387
|
+
const caller = callerStore.getStore();
|
|
388
|
+
const headers = {
|
|
389
|
+
...caller?.headers ?? {},
|
|
390
|
+
...Object.fromEntries(req.headers.entries())
|
|
391
|
+
};
|
|
392
|
+
if (payload) headers["content-length"] = String(payload.length);
|
|
393
|
+
const { port, tag, release } = await loopback(caller);
|
|
394
|
+
headers[DISPATCH_HEADER] = tag;
|
|
395
|
+
const res = await new Promise((resolve, reject) => {
|
|
396
|
+
const outgoing = (0, import_node_http.request)(
|
|
397
|
+
{ host: "127.0.0.1", port, method: req.method, path: url.pathname + url.search, headers, agent: false },
|
|
398
|
+
resolve
|
|
399
|
+
);
|
|
400
|
+
outgoing.on("error", reject);
|
|
401
|
+
outgoing.end(payload);
|
|
402
|
+
}).catch((err) => {
|
|
403
|
+
release();
|
|
404
|
+
throw err;
|
|
405
|
+
});
|
|
406
|
+
const chunks = [];
|
|
407
|
+
for await (const chunk of res) chunks.push(chunk);
|
|
408
|
+
const responseHeaders = new Headers();
|
|
409
|
+
for (const [name, value] of Object.entries(res.headers)) {
|
|
410
|
+
if (Array.isArray(value)) for (const v of value) responseHeaders.append(name, v);
|
|
411
|
+
else if (value !== void 0) responseHeaders.set(name, value);
|
|
412
|
+
}
|
|
413
|
+
const status = res.statusCode ?? 502;
|
|
414
|
+
return new Response(NULL_BODY_STATUSES.has(status) ? null : Buffer.concat(chunks), {
|
|
415
|
+
status,
|
|
416
|
+
headers: responseHeaders
|
|
417
|
+
});
|
|
418
|
+
};
|
|
419
|
+
dispatchers.set(app, dispatch);
|
|
420
|
+
return dispatch;
|
|
421
|
+
}
|
|
422
|
+
function lazyLoopback(app) {
|
|
423
|
+
const secret = (0, import_node_crypto.randomBytes)(32);
|
|
424
|
+
const pending = /* @__PURE__ */ new Map();
|
|
425
|
+
let nextId = 0;
|
|
426
|
+
let port = null;
|
|
427
|
+
const listener = (req, res) => {
|
|
428
|
+
const raw = req.headers[DISPATCH_HEADER];
|
|
429
|
+
delete req.headers[DISPATCH_HEADER];
|
|
430
|
+
for (let i = req.rawHeaders.length - 2; i >= 0; i -= 2) {
|
|
431
|
+
if (req.rawHeaders[i].toLowerCase() === DISPATCH_HEADER) req.rawHeaders.splice(i, 2);
|
|
432
|
+
}
|
|
433
|
+
const [key, id] = typeof raw === "string" ? raw.split(".") : [];
|
|
434
|
+
const presented = Buffer.from(key ?? "", "hex");
|
|
435
|
+
if (!id || presented.length !== secret.length || !(0, import_node_crypto.timingSafeEqual)(presented, secret) || !pending.has(id)) {
|
|
436
|
+
res.writeHead(403).end();
|
|
437
|
+
return;
|
|
328
438
|
}
|
|
439
|
+
const caller = pending.get(id);
|
|
440
|
+
pending.delete(id);
|
|
441
|
+
if (caller) {
|
|
442
|
+
const socket = req.socket;
|
|
443
|
+
Object.defineProperty(socket, "remoteAddress", { value: caller.remoteAddress, configurable: true });
|
|
444
|
+
Object.defineProperty(socket, "remoteFamily", { value: caller.remoteFamily, configurable: true });
|
|
445
|
+
if (caller.encrypted) Object.defineProperty(socket, "encrypted", { value: true, configurable: true });
|
|
446
|
+
}
|
|
447
|
+
app(req, res);
|
|
448
|
+
};
|
|
449
|
+
return async (caller) => {
|
|
450
|
+
port ??= new Promise((resolve, reject) => {
|
|
451
|
+
const server = (0, import_node_http.createServer)(listener);
|
|
452
|
+
server.once("error", reject);
|
|
453
|
+
server.listen(0, "127.0.0.1", () => {
|
|
454
|
+
server.unref();
|
|
455
|
+
resolve(server.address().port);
|
|
456
|
+
});
|
|
457
|
+
}).catch((err) => {
|
|
458
|
+
port = null;
|
|
459
|
+
throw err;
|
|
460
|
+
});
|
|
461
|
+
const resolved = await port;
|
|
462
|
+
const id = String(nextId++);
|
|
463
|
+
pending.set(id, caller);
|
|
464
|
+
return { port: resolved, tag: `${secret.toString("hex")}.${id}`, release: () => pending.delete(id) };
|
|
329
465
|
};
|
|
330
466
|
}
|
|
331
467
|
async function readPackageInfo(cwd) {
|
|
332
468
|
try {
|
|
333
|
-
const pkg = JSON.parse(await (0,
|
|
469
|
+
const pkg = JSON.parse(await (0, import_promises2.readFile)((0, import_node_path2.join)(cwd, "package.json"), "utf8"));
|
|
334
470
|
return { name: pkg.name ?? "app", version: pkg.version ?? "0.0.0" };
|
|
335
471
|
} catch {
|
|
336
472
|
return { name: "app", version: "0.0.0" };
|
|
337
473
|
}
|
|
338
474
|
}
|
|
339
475
|
|
|
476
|
+
// src/login.ts
|
|
477
|
+
var import_core5 = require("@exposify/core");
|
|
478
|
+
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;
|
|
479
|
+
function bodyFieldsOf(source) {
|
|
480
|
+
const names = /* @__PURE__ */ new Set();
|
|
481
|
+
for (const m of source.matchAll(/\b\w+\.body\.([A-Za-z_$][\w$]*)/g)) names.add(m[1]);
|
|
482
|
+
for (const m of source.matchAll(/\b\w+\.body\[\s*["'`]([^"'`]+)["'`]\s*\]/g)) names.add(m[1]);
|
|
483
|
+
for (const m of source.matchAll(/(?:const|let|var)\s*\{([^}]*)\}\s*=\s*\w+\.body\b/g)) {
|
|
484
|
+
for (const part of m[1].split(",")) {
|
|
485
|
+
const name = part.split(/[:=]/)[0].trim().replace(/^\.\.\./, "");
|
|
486
|
+
if (/^[A-Za-z_$][\w$]*$/.test(name)) names.add(name);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
return [...names];
|
|
490
|
+
}
|
|
491
|
+
function sourceOf(fn) {
|
|
492
|
+
try {
|
|
493
|
+
return typeof fn === "function" ? Function.prototype.toString.call(fn) : "";
|
|
494
|
+
} catch {
|
|
495
|
+
return "";
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
function parsesJson(app) {
|
|
499
|
+
const stack = (app?._router ?? app?.router)?.stack;
|
|
500
|
+
if (!Array.isArray(stack)) return null;
|
|
501
|
+
const names = stack.map((l) => l.name ?? "");
|
|
502
|
+
if (names.includes("jsonParser")) return true;
|
|
503
|
+
if (names.includes("urlencodedParser")) return false;
|
|
504
|
+
return null;
|
|
505
|
+
}
|
|
506
|
+
function findExpressLogin(app, mounts) {
|
|
507
|
+
let routes;
|
|
508
|
+
try {
|
|
509
|
+
routes = routesFromExpress(app, { mounts });
|
|
510
|
+
} catch {
|
|
511
|
+
return null;
|
|
512
|
+
}
|
|
513
|
+
const candidates = [];
|
|
514
|
+
for (const route of routes) {
|
|
515
|
+
if (route.method !== "POST" || route.params.length > 0 || import_core5.NOT_A_LOGIN.test(route.path)) continue;
|
|
516
|
+
const handlers = routeHandlers.get(route) ?? [];
|
|
517
|
+
const own = sourceOf(handlers.at(-1));
|
|
518
|
+
const all = handlers.map(sourceOf).join("\n");
|
|
519
|
+
const fields = (0, import_core5.loginFields)(bodyFieldsOf(own));
|
|
520
|
+
if (!fields) continue;
|
|
521
|
+
const named = import_core5.LOGIN_NAME.test(route.path);
|
|
522
|
+
if (!named && !SESSION_CODE.test(all)) continue;
|
|
523
|
+
candidates.push({ login: { path: route.path, fields, body: parsesJson(app) === false ? "form" : "json" }, named });
|
|
524
|
+
}
|
|
525
|
+
if (candidates.length === 0) return null;
|
|
526
|
+
candidates.sort((a, b) => Number(b.named) - Number(a.named) || a.login.fields.length - b.login.fields.length);
|
|
527
|
+
return { login: candidates[0].login, others: candidates.slice(1).map((c) => `POST ${c.login.path}`) };
|
|
528
|
+
}
|
|
529
|
+
|
|
340
530
|
// src/index.ts
|
|
341
|
-
var
|
|
531
|
+
var import_core7 = require("@exposify/core");
|
|
532
|
+
var AIREADY_OPTIONS_TAG = /* @__PURE__ */ Symbol.for("exposify.express.aiReadyOptions");
|
|
342
533
|
var MCP_PATH = "/mcp";
|
|
343
534
|
var PRM_ROOT = "/.well-known/oauth-protected-resource";
|
|
344
535
|
var LOG_PREFIX = "[agentready]";
|
|
345
536
|
function toolsSummary(routes, docs, cfg) {
|
|
346
|
-
const counts = (0,
|
|
537
|
+
const counts = (0, import_core6.countTools)(routes, cfg);
|
|
347
538
|
const details = counts.hiddenWrites > 0 ? [`${counts.hiddenWrites} write route${counts.hiddenWrites === 1 ? "" : "s"} hidden (opt in with exposed())`] : [];
|
|
348
|
-
|
|
539
|
+
if (cfg.expose === "*") details.unshift('all write routes exposed (expose: "*")');
|
|
540
|
+
return { tools: (0, import_core6.routeToolsPhrase)(counts, docs), details, empty: counts.reads + counts.writes === 0 };
|
|
349
541
|
}
|
|
350
542
|
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";
|
|
351
543
|
function prmPaths(resource) {
|
|
@@ -357,11 +549,49 @@ async function sendWebResponse(res, response) {
|
|
|
357
549
|
res.writeHead(response.status, Object.fromEntries(response.headers)).end(body);
|
|
358
550
|
}
|
|
359
551
|
function aiReady(options = {}) {
|
|
360
|
-
const cfg = (0,
|
|
361
|
-
const gate = cfg.oauth ? (0,
|
|
552
|
+
const cfg = (0, import_core6.resolveOptions)(options);
|
|
553
|
+
const gate = cfg.oauth ? (0, import_core6.createOAuthGate)(cfg.oauth) : null;
|
|
362
554
|
const metadataPaths = cfg.oauth ? prmPaths(cfg.oauth.resource) : /* @__PURE__ */ new Set();
|
|
555
|
+
const brokerGate = !gate && cfg.docsAuth === "required" ? (0, import_core6.createBrokerGate)(cfg.auth) : null;
|
|
556
|
+
const resourceOf = (req, path, mcpPath) => {
|
|
557
|
+
const original = String(req.originalUrl ?? req.url).split("?")[0];
|
|
558
|
+
const prefix = original.endsWith(path) ? original.slice(0, original.length - path.length) : "";
|
|
559
|
+
const local = `${req.protocol ?? "http"}://${req.headers.host ?? "localhost"}${prefix}${mcpPath}`;
|
|
560
|
+
return brokerGate.resourceFor((0, import_core6.publicRequestUrl)(local, headersOf(req)));
|
|
561
|
+
};
|
|
363
562
|
const cwd = options.cwd ?? process.cwd();
|
|
364
563
|
const mounts = options.mounts;
|
|
564
|
+
const brokerInfo = (0, import_core6.hostedResolverInfo)(cfg.auth);
|
|
565
|
+
let loginFound = null;
|
|
566
|
+
const loginOf = (app) => {
|
|
567
|
+
if (!brokerInfo || options.login === false) return null;
|
|
568
|
+
if (!loginFound) {
|
|
569
|
+
const given = options.login;
|
|
570
|
+
const value = given ? { path: given.path, fields: given.fields.map((name) => ({ name, kind: (0, import_core6.fieldKind)(name) })), body: given.body ?? "json" } : findExpressLogin(app, mounts)?.login ?? null;
|
|
571
|
+
loginFound = { value };
|
|
572
|
+
if (value) {
|
|
573
|
+
cfg.signedOutPaths = [value.path];
|
|
574
|
+
if (logReady) console.log(`${LOG_PREFIX} AI assistants sign in with your app's own login (POST ${value.path}) at ${MCP_PATH}/login`);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
return loginFound.value;
|
|
578
|
+
};
|
|
579
|
+
const appLoginFor = (req, login) => ({
|
|
580
|
+
label: `POST ${login.path}`,
|
|
581
|
+
fields: login.fields,
|
|
582
|
+
signedOutPaths: [login.path],
|
|
583
|
+
attempt: (values) => {
|
|
584
|
+
const body = login.body === "form" ? new URLSearchParams(values).toString() : JSON.stringify(values);
|
|
585
|
+
const request = new Request(`http://app.internal${login.path}`, {
|
|
586
|
+
method: "POST",
|
|
587
|
+
headers: { "content-type": login.body === "form" ? "application/x-www-form-urlencoded" : "application/json", ...req.headers["user-agent"] ? { "user-agent": String(req.headers["user-agent"]) } : {} },
|
|
588
|
+
body
|
|
589
|
+
});
|
|
590
|
+
return runAsCaller(callerOf(req), () => appDispatcher(req.app)(request)).then(import_core6.loginResultFromResponse);
|
|
591
|
+
}
|
|
592
|
+
});
|
|
593
|
+
const descriptions = options.descriptions;
|
|
594
|
+
const openapi = options.openapi;
|
|
365
595
|
try {
|
|
366
596
|
installMountRecorder();
|
|
367
597
|
} catch {
|
|
@@ -369,8 +599,8 @@ function aiReady(options = {}) {
|
|
|
369
599
|
let handlerPromise = null;
|
|
370
600
|
let disabled = false;
|
|
371
601
|
let warned = false;
|
|
372
|
-
const logReady = (0,
|
|
373
|
-
const auth = (0,
|
|
602
|
+
const logReady = (0, import_core6.startupLogEnabled)(options.startupLog);
|
|
603
|
+
const auth = (0, import_core6.describeAuth)(cfg.auth, cfg.oauth);
|
|
374
604
|
let reported = null;
|
|
375
605
|
const logSurface = (routes, docs, note) => {
|
|
376
606
|
const summary = toolsSummary(routes, docs, cfg);
|
|
@@ -379,19 +609,33 @@ function aiReady(options = {}) {
|
|
|
379
609
|
const first = reported === null;
|
|
380
610
|
reported = key;
|
|
381
611
|
if (summary.empty) {
|
|
382
|
-
console.warn((0,
|
|
612
|
+
console.warn((0, import_core6.formatWarning)(LOG_PREFIX, `MCP is up at ${MCP_PATH} but exposes no route tools`, NO_TOOLS_FIX));
|
|
383
613
|
return;
|
|
384
614
|
}
|
|
385
615
|
if (!logReady) return;
|
|
386
616
|
const details = [...summary.details, ...note && !first ? [note] : []];
|
|
387
|
-
console.log((0,
|
|
617
|
+
console.log((0, import_core6.formatReady)(LOG_PREFIX, { endpoint: MCP_PATH, tools: summary.tools, details, docs, auth }));
|
|
388
618
|
};
|
|
389
619
|
const middleware = (req, res, next) => {
|
|
390
620
|
const path = req.path ?? req.url.split("?")[0];
|
|
621
|
+
if (brokerInfo && (path === `${MCP_PATH}/login` || path === `${MCP_PATH}/login/`)) {
|
|
622
|
+
const login = loginOf(req.app);
|
|
623
|
+
if (!login) return next();
|
|
624
|
+
void serveLoginPage(req, res, login).catch((err) => {
|
|
625
|
+
console.warn("[agentready] sign-in form failed:", err);
|
|
626
|
+
if (!res.headersSent) res.writeHead(503).end("sign-in is unavailable");
|
|
627
|
+
});
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
391
630
|
if (gate && metadataPaths.has(path)) {
|
|
392
631
|
void sendWebResponse(res, gate.metadataResponse(req.method));
|
|
393
632
|
return;
|
|
394
633
|
}
|
|
634
|
+
if (brokerGate && (path === PRM_ROOT || path === `${PRM_ROOT}${MCP_PATH}`)) {
|
|
635
|
+
const resource = resourceOf(req, path, MCP_PATH);
|
|
636
|
+
void (0, import_core6.reportAppLogin)(cfg.auth, resource, loginOf(req.app)).then(() => sendWebResponse(res, brokerGate.metadataResponse(req.method, resource)));
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
395
639
|
if (path !== MCP_PATH && !path.startsWith(`${MCP_PATH}/`)) return next();
|
|
396
640
|
if (disabled) {
|
|
397
641
|
res.writeHead(503).end("agentready is disabled due to an internal error");
|
|
@@ -405,6 +649,14 @@ function aiReady(options = {}) {
|
|
|
405
649
|
console.warn("[agentready] oauth gate failed:", err);
|
|
406
650
|
if (!res.headersSent) res.writeHead(503).end("authorization check failed");
|
|
407
651
|
return null;
|
|
652
|
+
}) : brokerGate ? (0, import_core6.reportAppLogin)(cfg.auth, resourceOf(req, path, MCP_PATH), loginOf(req.app)).then(() => brokerGate.check(headersOf(req), resourceOf(req, path, MCP_PATH))).then(async (result) => {
|
|
653
|
+
if (result.ok) return {};
|
|
654
|
+
await sendWebResponse(res, result.response);
|
|
655
|
+
return null;
|
|
656
|
+
}, (err) => {
|
|
657
|
+
console.warn("[agentready] broker check failed:", err);
|
|
658
|
+
if (!res.headersSent) res.writeHead(503).end("authorization check failed");
|
|
659
|
+
return null;
|
|
408
660
|
}) : Promise.resolve({});
|
|
409
661
|
authorized.then((authContext) => {
|
|
410
662
|
if (!authContext) return;
|
|
@@ -413,16 +665,23 @@ function aiReady(options = {}) {
|
|
|
413
665
|
cfg,
|
|
414
666
|
cwd,
|
|
415
667
|
mounts,
|
|
668
|
+
{ descriptions, openapi },
|
|
416
669
|
(surface) => logSurface(surface.routes, surface.docs.length, "updated: routes registered after startup")
|
|
417
670
|
);
|
|
418
|
-
return handlerPromise.then(
|
|
671
|
+
return handlerPromise.then(
|
|
672
|
+
(handler) => (
|
|
673
|
+
// Tool calls made while handling this request reach the app with
|
|
674
|
+
// this caller's address and forwarding headers (see surface.ts).
|
|
675
|
+
runAsCaller(callerOf(req), () => handler(req, res, authContext))
|
|
676
|
+
)
|
|
677
|
+
);
|
|
419
678
|
}).catch((err) => {
|
|
420
679
|
disabled = true;
|
|
421
680
|
handlerPromise = null;
|
|
422
681
|
if (!warned) {
|
|
423
682
|
warned = true;
|
|
424
683
|
console.error(
|
|
425
|
-
(0,
|
|
684
|
+
(0, import_core6.formatFailed)(
|
|
426
685
|
LOG_PREFIX,
|
|
427
686
|
errorMessage(err),
|
|
428
687
|
`${MCP_PATH} now answers 503 until the process restarts (your own routes are unaffected); full error below`
|
|
@@ -433,7 +692,39 @@ function aiReady(options = {}) {
|
|
|
433
692
|
if (!res.headersSent) res.writeHead(503).end("agentready failed to initialize");
|
|
434
693
|
});
|
|
435
694
|
};
|
|
695
|
+
async function serveLoginPage(req, res, login) {
|
|
696
|
+
const original = String(req.originalUrl ?? req.url);
|
|
697
|
+
const local = `${req.protocol ?? "http"}://${req.headers.host ?? "localhost"}${original}`;
|
|
698
|
+
const url = new URL((0, import_core6.publicRequestUrl)(local, headersOf(req)));
|
|
699
|
+
url.search = new URL(local).search;
|
|
700
|
+
let body;
|
|
701
|
+
if (req.method === "POST") {
|
|
702
|
+
if (req.body && typeof req.body === "object" && Object.keys(req.body).length > 0) body = new URLSearchParams(req.body).toString();
|
|
703
|
+
else if (typeof req.body === "string") body = req.body;
|
|
704
|
+
else {
|
|
705
|
+
const chunks = [];
|
|
706
|
+
let size = 0;
|
|
707
|
+
for await (const chunk of req) {
|
|
708
|
+
size += chunk.length;
|
|
709
|
+
if (size > 16 * 1024) break;
|
|
710
|
+
chunks.push(chunk);
|
|
711
|
+
}
|
|
712
|
+
body = Buffer.concat(chunks).toString("utf8");
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
const request = new Request(url, { method: req.method, headers: headersOf(req), ...body !== void 0 ? { body } : {} });
|
|
716
|
+
const forwarded = String(req.headers["x-forwarded-for"] ?? "").split(",")[0]?.trim();
|
|
717
|
+
const ip = forwarded || req.socket?.remoteAddress;
|
|
718
|
+
const response = await (0, import_core6.handleLoginPage)(request, appLoginFor(req, login), brokerInfo, {
|
|
719
|
+
path: url.pathname,
|
|
720
|
+
secure: url.protocol === "https:",
|
|
721
|
+
origin: url.origin,
|
|
722
|
+
...ip ? { ip } : {}
|
|
723
|
+
});
|
|
724
|
+
await sendWebResponse(res, response);
|
|
725
|
+
}
|
|
436
726
|
middleware[MOUNT_OWNER_TAG] = true;
|
|
727
|
+
middleware[AIREADY_OPTIONS_TAG] = options;
|
|
437
728
|
setImmediate(() => {
|
|
438
729
|
void startupCheck().catch(() => {
|
|
439
730
|
});
|
|
@@ -450,7 +741,7 @@ function aiReady(options = {}) {
|
|
|
450
741
|
logSurface(routes, docs.length);
|
|
451
742
|
} catch (err) {
|
|
452
743
|
console.error(
|
|
453
|
-
(0,
|
|
744
|
+
(0, import_core6.formatFailed)(
|
|
454
745
|
LOG_PREFIX,
|
|
455
746
|
errorMessage(err),
|
|
456
747
|
`your app keeps serving its own routes; ${MCP_PATH} will retry on its first request and answer 503 if it fails again`
|
|
@@ -461,7 +752,7 @@ function aiReady(options = {}) {
|
|
|
461
752
|
const problem = await probe;
|
|
462
753
|
if (problem && cfg.oauth) {
|
|
463
754
|
console.warn(
|
|
464
|
-
(0,
|
|
755
|
+
(0, import_core6.formatWarning)(
|
|
465
756
|
LOG_PREFIX,
|
|
466
757
|
`can't verify access tokens from oauth.issuer ${cfg.oauth.issuer}: ${problem}`,
|
|
467
758
|
"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"
|
|
@@ -482,16 +773,16 @@ function headersOf(req) {
|
|
|
482
773
|
}
|
|
483
774
|
return headers;
|
|
484
775
|
}
|
|
485
|
-
async function initialize(app, cfg, cwd, mounts, onReady) {
|
|
776
|
+
async function initialize(app, cfg, cwd, mounts, extras, onReady) {
|
|
486
777
|
let surface = null;
|
|
487
778
|
let kbPromise = null;
|
|
488
779
|
const getSurface = async () => {
|
|
489
|
-
surface ??= await buildExpressSurface(app, cfg, cwd, { mounts });
|
|
780
|
+
surface ??= await buildExpressSurface(app, cfg, cwd, { mounts, ...extras });
|
|
490
781
|
return surface;
|
|
491
782
|
};
|
|
492
783
|
const getKb = async () => {
|
|
493
784
|
const s = await getSurface();
|
|
494
|
-
kbPromise ??= (0, import_node2.loadOrBuildKb)(s.docs, (0,
|
|
785
|
+
kbPromise ??= (0, import_node2.loadOrBuildKb)(s.docs, (0, import_node_path3.join)(cwd, ".exposify"));
|
|
495
786
|
return kbPromise;
|
|
496
787
|
};
|
|
497
788
|
const built = await getSurface();
|
|
@@ -504,9 +795,13 @@ async function initialize(app, cfg, cwd, mounts, onReady) {
|
|
|
504
795
|
}
|
|
505
796
|
// Annotate the CommonJS export names for ESM import in node:
|
|
506
797
|
0 && (module.exports = {
|
|
798
|
+
AIREADY_OPTIONS_TAG,
|
|
507
799
|
aiReady,
|
|
800
|
+
completeMcpLogin,
|
|
801
|
+
createHostedAuthResolver,
|
|
508
802
|
describe,
|
|
509
803
|
exposed,
|
|
510
804
|
hidden,
|
|
805
|
+
hostedBroker,
|
|
511
806
|
routesFromExpress
|
|
512
807
|
});
|
package/dist/index.d.cts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { RouteInfo, AgentReadyOptions } from '@exposify/core';
|
|
2
|
-
export { describe, exposed, hidden } from '@exposify/core';
|
|
2
|
+
export { HostedAuthResolverConfig, completeMcpLogin, createHostedAuthResolver, describe, exposed, hidden, hostedBroker } from '@exposify/core';
|
|
3
3
|
|
|
4
4
|
interface RoutesFromExpressOptions {
|
|
5
5
|
/**
|
|
@@ -15,7 +15,22 @@ interface RoutesFromExpressOptions {
|
|
|
15
15
|
}
|
|
16
16
|
declare function routesFromExpress(app: unknown, opts?: RoutesFromExpressOptions): RouteInfo[];
|
|
17
17
|
|
|
18
|
+
/** Symbol under which `aiReady()`'s middleware carries the options it was given. */
|
|
19
|
+
declare const AIREADY_OPTIONS_TAG: unique symbol;
|
|
20
|
+
|
|
18
21
|
interface ExpressAiReadyOptions extends AgentReadyOptions {
|
|
22
|
+
/**
|
|
23
|
+
* The app's own login, for Connect with the hosted broker: AI assistants
|
|
24
|
+
* sign in on a form served at /mcp/login, which makes this login request
|
|
25
|
+
* through your app. Found by itself (a POST route that reads a password
|
|
26
|
+
* from the body and sets a session or signs a token); name another route
|
|
27
|
+
* (`{ path, fields }`), or `false` to not offer it.
|
|
28
|
+
*/
|
|
29
|
+
login?: false | {
|
|
30
|
+
path: string;
|
|
31
|
+
fields: string[];
|
|
32
|
+
body?: "json" | "form";
|
|
33
|
+
};
|
|
19
34
|
/** Where docs globs and the .exposify cache resolve. Default: process.cwd() */
|
|
20
35
|
cwd?: string;
|
|
21
36
|
/**
|
|
@@ -29,7 +44,20 @@ interface ExpressAiReadyOptions extends AgentReadyOptions {
|
|
|
29
44
|
* router/app object registered at that prefix.
|
|
30
45
|
*/
|
|
31
46
|
mounts?: Record<string, unknown>;
|
|
47
|
+
/**
|
|
48
|
+
* Tool descriptions file written by `exposify-express setup`/`describe`.
|
|
49
|
+
* Default: exposify.descriptions.json in `cwd`, if present. `false` ignores it.
|
|
50
|
+
* describe() text in code still wins over it.
|
|
51
|
+
*/
|
|
52
|
+
descriptions?: string | false;
|
|
53
|
+
/**
|
|
54
|
+
* The app's OpenAPI document: an object (e.g. `SwaggerModule.createDocument(app, …)`
|
|
55
|
+
* in NestJS) or a path to a JSON file. Matching operations give tools real
|
|
56
|
+
* input schemas, and their summary/description becomes the tool description
|
|
57
|
+
* (below describe() and exposify.descriptions.json).
|
|
58
|
+
*/
|
|
59
|
+
openapi?: string | object;
|
|
32
60
|
}
|
|
33
61
|
declare function aiReady(options?: ExpressAiReadyOptions): (req: any, res: any, next: any) => void;
|
|
34
62
|
|
|
35
|
-
export { type ExpressAiReadyOptions, aiReady, routesFromExpress };
|
|
63
|
+
export { AIREADY_OPTIONS_TAG, type ExpressAiReadyOptions, aiReady, routesFromExpress };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { RouteInfo, AgentReadyOptions } from '@exposify/core';
|
|
2
|
-
export { describe, exposed, hidden } from '@exposify/core';
|
|
2
|
+
export { HostedAuthResolverConfig, completeMcpLogin, createHostedAuthResolver, describe, exposed, hidden, hostedBroker } from '@exposify/core';
|
|
3
3
|
|
|
4
4
|
interface RoutesFromExpressOptions {
|
|
5
5
|
/**
|
|
@@ -15,7 +15,22 @@ interface RoutesFromExpressOptions {
|
|
|
15
15
|
}
|
|
16
16
|
declare function routesFromExpress(app: unknown, opts?: RoutesFromExpressOptions): RouteInfo[];
|
|
17
17
|
|
|
18
|
+
/** Symbol under which `aiReady()`'s middleware carries the options it was given. */
|
|
19
|
+
declare const AIREADY_OPTIONS_TAG: unique symbol;
|
|
20
|
+
|
|
18
21
|
interface ExpressAiReadyOptions extends AgentReadyOptions {
|
|
22
|
+
/**
|
|
23
|
+
* The app's own login, for Connect with the hosted broker: AI assistants
|
|
24
|
+
* sign in on a form served at /mcp/login, which makes this login request
|
|
25
|
+
* through your app. Found by itself (a POST route that reads a password
|
|
26
|
+
* from the body and sets a session or signs a token); name another route
|
|
27
|
+
* (`{ path, fields }`), or `false` to not offer it.
|
|
28
|
+
*/
|
|
29
|
+
login?: false | {
|
|
30
|
+
path: string;
|
|
31
|
+
fields: string[];
|
|
32
|
+
body?: "json" | "form";
|
|
33
|
+
};
|
|
19
34
|
/** Where docs globs and the .exposify cache resolve. Default: process.cwd() */
|
|
20
35
|
cwd?: string;
|
|
21
36
|
/**
|
|
@@ -29,7 +44,20 @@ interface ExpressAiReadyOptions extends AgentReadyOptions {
|
|
|
29
44
|
* router/app object registered at that prefix.
|
|
30
45
|
*/
|
|
31
46
|
mounts?: Record<string, unknown>;
|
|
47
|
+
/**
|
|
48
|
+
* Tool descriptions file written by `exposify-express setup`/`describe`.
|
|
49
|
+
* Default: exposify.descriptions.json in `cwd`, if present. `false` ignores it.
|
|
50
|
+
* describe() text in code still wins over it.
|
|
51
|
+
*/
|
|
52
|
+
descriptions?: string | false;
|
|
53
|
+
/**
|
|
54
|
+
* The app's OpenAPI document: an object (e.g. `SwaggerModule.createDocument(app, …)`
|
|
55
|
+
* in NestJS) or a path to a JSON file. Matching operations give tools real
|
|
56
|
+
* input schemas, and their summary/description becomes the tool description
|
|
57
|
+
* (below describe() and exposify.descriptions.json).
|
|
58
|
+
*/
|
|
59
|
+
openapi?: string | object;
|
|
32
60
|
}
|
|
33
61
|
declare function aiReady(options?: ExpressAiReadyOptions): (req: any, res: any, next: any) => void;
|
|
34
62
|
|
|
35
|
-
export { type ExpressAiReadyOptions, aiReady, routesFromExpress };
|
|
63
|
+
export { AIREADY_OPTIONS_TAG, type ExpressAiReadyOptions, aiReady, routesFromExpress };
|