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