@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/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
- describe: () => import_core3.describe,
25
- exposed: () => import_core3.exposed,
26
- hidden: () => import_core3.hidden,
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 import_node_path2 = require("path");
31
- var import_core2 = require("@exposify/core");
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 import_promises = require("fs/promises");
36
- var import_node_path = require("path");
37
- var import_light_my_request = require("light-my-request");
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
- out.push({
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
- routes: routesFromExpress(app, opts),
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
- async dispatch(req) {
312
- const url = new URL(req.url);
313
- try {
314
- const res = await (0, import_light_my_request.inject)(app, {
315
- method: req.method,
316
- url: url.pathname + url.search,
317
- headers: Object.fromEntries(req.headers.entries()),
318
- payload: req.body ? await req.text() : void 0
319
- });
320
- return new Response(res.body, {
321
- status: res.statusCode,
322
- headers: res.headers
323
- });
324
- } finally {
325
- if (pristineReqProto && reqProto) Object.setPrototypeOf(reqProto, pristineReqProto);
326
- if (pristineResProto && resProto) Object.setPrototypeOf(resProto, pristineResProto);
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, import_promises.readFile)((0, import_node_path.join)(cwd, "package.json"), "utf8"));
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 import_core3 = require("@exposify/core");
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, import_core2.countTools)(routes, cfg);
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
- return { tools: (0, import_core2.routeToolsPhrase)(counts, docs), details, empty: counts.reads + counts.writes === 0 };
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, import_core2.resolveOptions)(options);
361
- const gate = cfg.oauth ? (0, import_core2.createOAuthGate)(cfg.oauth) : null;
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, import_core2.startupLogEnabled)(options.startupLog);
373
- const auth = (0, import_core2.describeAuth)(cfg.auth, cfg.oauth);
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, import_core2.formatWarning)(LOG_PREFIX, `MCP is up at ${MCP_PATH} but exposes no route tools`, NO_TOOLS_FIX));
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, import_core2.formatReady)(LOG_PREFIX, { endpoint: MCP_PATH, tools: summary.tools, details, docs, auth }));
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((handler) => handler(req, res, authContext));
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, import_core2.formatFailed)(
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, import_core2.formatFailed)(
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, import_core2.formatWarning)(
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, import_node_path2.join)(cwd, ".exposify"));
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 };