@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/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
- 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_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 import_promises = require("fs/promises");
36
- var import_node_path = require("path");
37
- var import_light_my_request = require("light-my-request");
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
- out.push({
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
- routes: routesFromExpress(app, opts),
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
- 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
- }
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, import_promises.readFile)((0, import_node_path.join)(cwd, "package.json"), "utf8"));
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 import_core3 = require("@exposify/core");
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, import_core2.countTools)(routes, cfg);
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
- return { tools: (0, import_core2.routeToolsPhrase)(counts, docs), details, empty: counts.reads + counts.writes === 0 };
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, import_core2.resolveOptions)(options);
361
- const gate = cfg.oauth ? (0, import_core2.createOAuthGate)(cfg.oauth) : null;
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, import_core2.startupLogEnabled)(options.startupLog);
373
- const auth = (0, import_core2.describeAuth)(cfg.auth, cfg.oauth);
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, import_core2.formatWarning)(LOG_PREFIX, `MCP is up at ${MCP_PATH} but exposes no route tools`, NO_TOOLS_FIX));
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, import_core2.formatReady)(LOG_PREFIX, { endpoint: MCP_PATH, tools: summary.tools, details, docs, auth }));
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((handler) => handler(req, res, authContext));
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, import_core2.formatFailed)(
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, import_core2.formatFailed)(
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, import_core2.formatWarning)(
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, import_node_path2.join)(cwd, ".exposify"));
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 };