@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.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 };
package/dist/index.js CHANGED
@@ -6,14 +6,24 @@ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require
6
6
  });
7
7
 
8
8
  // src/index.ts
9
- import { join as join2 } from "path";
9
+ import { readFileSync } from "fs";
10
+ import { join as join3 } from "path";
10
11
  import {
12
+ authStacksOf,
11
13
  countTools,
14
+ createBrokerGate,
12
15
  createOAuthGate,
16
+ dependencyNames,
13
17
  describeAuth,
14
18
  formatFailed,
15
19
  formatReady,
16
20
  formatWarning,
21
+ fieldKind,
22
+ handleLoginPage,
23
+ hostedResolverInfo,
24
+ loginResultFromResponse,
25
+ publicRequestUrl,
26
+ reportAppLogin,
17
27
  resolveOptions,
18
28
  routeToolsPhrase,
19
29
  startupLogEnabled
@@ -25,9 +35,14 @@ import {
25
35
  } from "@exposify/core/node";
26
36
 
27
37
  // src/surface.ts
28
- import { readFile } from "fs/promises";
29
- import { join } from "path";
30
- import { inject } from "light-my-request";
38
+ import { readFile as readFile2 } from "fs/promises";
39
+ import { join as join2 } from "path";
40
+ import { AsyncLocalStorage } from "async_hooks";
41
+ import { randomBytes, timingSafeEqual } from "crypto";
42
+ import {
43
+ createServer,
44
+ request as httpRequest
45
+ } from "http";
31
46
  import { loadDocsFromFs } from "@exposify/core/node";
32
47
 
33
48
  // src/introspect.ts
@@ -140,6 +155,7 @@ function flattenHandlers(items) {
140
155
  }
141
156
 
142
157
  // src/introspect.ts
158
+ var routeHandlers = /* @__PURE__ */ new WeakMap();
143
159
  function routesFromExpress(app, opts) {
144
160
  const router = routerOf(app);
145
161
  const out = [];
@@ -192,13 +208,15 @@ function walk(stack, prefix, owner, out, walked, unresolved) {
192
208
  const marker = handlers.map(getMarker).find(Boolean);
193
209
  for (const [method, enabled] of Object.entries(layer.route.methods)) {
194
210
  if (!enabled || method === "_all") continue;
195
- out.push({
211
+ const route = {
196
212
  method: method.toUpperCase(),
197
213
  path,
198
214
  params: extractParams(path),
199
215
  marker,
200
216
  description: marker?.kind === "described" ? marker.description : void 0
201
- });
217
+ };
218
+ routeHandlers.set(route, handlers);
219
+ out.push(route);
202
220
  }
203
221
  continue;
204
222
  }
@@ -283,60 +301,250 @@ function ownerAndStackOf(handle) {
283
301
  return void 0;
284
302
  }
285
303
 
304
+ // src/descriptions.ts
305
+ import { readFile } from "fs/promises";
306
+ import { join } from "path";
307
+ import { DESCRIPTIONS_FILE, parseDescriptions } from "@exposify/core";
308
+ import { applyDescriptions } from "@exposify/core";
309
+ async function loadDescriptions(cwd, option, warn) {
310
+ if (option === false) return /* @__PURE__ */ new Map();
311
+ const path = join(cwd, option ?? DESCRIPTIONS_FILE);
312
+ let text;
313
+ try {
314
+ text = await 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 = 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 = 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 readFile(path, "utf8"));
336
+ } catch (err) {
337
+ warn(`openapi: couldn't read ${path}: ${err.message}`);
338
+ return void 0;
339
+ }
340
+ }
341
+
286
342
  // src/surface.ts
343
+ import { applyOpenApi } from "@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 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
+ }
287
367
  async function buildExpressSurface(app, cfg, cwd, opts) {
288
368
  const info = await readPackageInfo(cwd);
289
- const appish = app;
290
- if (!appish.request || !appish.response) {
291
- console.warn(
292
- "[agentready] express request/response prototypes not found; light-my-request corruption guard inactive"
293
- );
294
- }
295
- const reqProto = appish.request ? Object.getPrototypeOf(appish.request) : void 0;
296
- const resProto = appish.response ? Object.getPrototypeOf(appish.response) : void 0;
297
- const pristineReqProto = reqProto ? Object.getPrototypeOf(reqProto) : void 0;
298
- const pristineResProto = resProto ? Object.getPrototypeOf(resProto) : void 0;
299
369
  return {
300
370
  info,
301
- routes: routesFromExpress(app, opts),
371
+ // Text precedence: describe() in code > exposify.descriptions.json (reviewed) > OpenAPI > derived.
372
+ routes: applyDescriptions(
373
+ applyOpenApi(routesFromExpress(app, opts), await loadOpenApi(cwd, opts?.openapi, warnOnce)),
374
+ await loadDescriptions(cwd, opts?.descriptions, warnOnce)
375
+ ),
302
376
  docs: await loadDocsFromFs(cfg.docs, cwd),
303
- async dispatch(req) {
304
- const url = new URL(req.url);
305
- try {
306
- const res = await inject(app, {
307
- method: req.method,
308
- url: url.pathname + url.search,
309
- headers: Object.fromEntries(req.headers.entries()),
310
- payload: req.body ? await req.text() : void 0
311
- });
312
- return new Response(res.body, {
313
- status: res.statusCode,
314
- headers: res.headers
315
- });
316
- } finally {
317
- if (pristineReqProto && reqProto) Object.setPrototypeOf(reqProto, pristineReqProto);
318
- if (pristineResProto && resProto) Object.setPrototypeOf(resProto, pristineResProto);
319
- }
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 = httpRequest(
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);
320
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 = 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);
433
+ }
434
+ const [key, id] = typeof raw === "string" ? raw.split(".") : [];
435
+ const presented = Buffer.from(key ?? "", "hex");
436
+ if (!id || presented.length !== secret.length || !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 = 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) };
321
466
  };
322
467
  }
323
468
  async function readPackageInfo(cwd) {
324
469
  try {
325
- const pkg = JSON.parse(await readFile(join(cwd, "package.json"), "utf8"));
470
+ const pkg = JSON.parse(await readFile2(join2(cwd, "package.json"), "utf8"));
326
471
  return { name: pkg.name ?? "app", version: pkg.version ?? "0.0.0" };
327
472
  } catch {
328
473
  return { name: "app", version: "0.0.0" };
329
474
  }
330
475
  }
331
476
 
477
+ // src/login.ts
478
+ import { LOGIN_NAME, NOT_A_LOGIN, loginFields } from "@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 || 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 = loginFields(bodyFieldsOf(own));
521
+ if (!fields) continue;
522
+ const named = 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
+
332
531
  // src/index.ts
333
- import { exposed, hidden, describe } from "@exposify/core";
532
+ import {
533
+ exposed,
534
+ hidden,
535
+ describe,
536
+ completeMcpLogin,
537
+ createHostedAuthResolver,
538
+ hostedBroker
539
+ } from "@exposify/core";
540
+ var AIREADY_OPTIONS_TAG = /* @__PURE__ */ Symbol.for("exposify.express.aiReadyOptions");
334
541
  var MCP_PATH = "/mcp";
335
542
  var PRM_ROOT = "/.well-known/oauth-protected-resource";
336
543
  var LOG_PREFIX = "[agentready]";
337
544
  function toolsSummary(routes, docs, cfg) {
338
545
  const counts = countTools(routes, cfg);
339
546
  const details = counts.hiddenWrites > 0 ? [`${counts.hiddenWrites} write route${counts.hiddenWrites === 1 ? "" : "s"} hidden (opt in with exposed())`] : [];
547
+ if (cfg.expose === "*") details.unshift('all write routes exposed (expose: "*")');
340
548
  return { tools: routeToolsPhrase(counts, docs), details, empty: counts.reads + counts.writes === 0 };
341
549
  }
342
550
  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";
@@ -352,8 +560,58 @@ function aiReady(options = {}) {
352
560
  const cfg = resolveOptions(options);
353
561
  const gate = cfg.oauth ? createOAuthGate(cfg.oauth) : null;
354
562
  const metadataPaths = cfg.oauth ? prmPaths(cfg.oauth.resource) : /* @__PURE__ */ new Set();
563
+ const brokerGate = !gate && cfg.docsAuth === "required" ? createBrokerGate(cfg.auth) : null;
564
+ const resourceOf = (req, path, mcpPath) => {
565
+ const original = String(req.originalUrl ?? req.url).split("?")[0];
566
+ const prefix = original.endsWith(path) ? original.slice(0, original.length - path.length) : "";
567
+ const local = `${req.protocol ?? "http"}://${req.headers.host ?? "localhost"}${prefix}${mcpPath}`;
568
+ return brokerGate.resourceFor(publicRequestUrl(local, headersOf(req)));
569
+ };
355
570
  const cwd = options.cwd ?? process.cwd();
356
571
  const mounts = options.mounts;
572
+ const brokerInfo = hostedResolverInfo(cfg.auth);
573
+ let loginFound = null;
574
+ const loginOf = (app) => {
575
+ if (!brokerInfo || options.login === false) return null;
576
+ if (!loginFound) {
577
+ const given = options.login;
578
+ const value = given ? { path: given.path, fields: given.fields.map((name) => ({ name, kind: fieldKind(name) })), body: given.body ?? "json" } : findExpressLogin(app, mounts)?.login ?? null;
579
+ loginFound = { value };
580
+ if (value) cfg.signedOutPaths = [value.path];
581
+ }
582
+ return loginFound.value;
583
+ };
584
+ let stacks;
585
+ const stacksOf = () => {
586
+ if (!stacks) {
587
+ try {
588
+ stacks = authStacksOf(dependencyNames(JSON.parse(readFileSync(join3(cwd, "package.json"), "utf8")))).map((s) => s.pkg);
589
+ } catch {
590
+ stacks = [];
591
+ }
592
+ }
593
+ return stacks;
594
+ };
595
+ const report = (req, resource) => {
596
+ const login = loginOf(req.app);
597
+ return reportAppLogin(cfg.auth, resource, login, { ...login ? { where: `POST ${login.path}` } : {}, stacks: stacksOf(), log: logReady });
598
+ };
599
+ const appLoginFor = (req, login) => ({
600
+ label: `POST ${login.path}`,
601
+ fields: login.fields,
602
+ signedOutPaths: [login.path],
603
+ attempt: (values) => {
604
+ const body = login.body === "form" ? new URLSearchParams(values).toString() : JSON.stringify(values);
605
+ const request = new Request(`http://app.internal${login.path}`, {
606
+ method: "POST",
607
+ headers: { "content-type": login.body === "form" ? "application/x-www-form-urlencoded" : "application/json", ...req.headers["user-agent"] ? { "user-agent": String(req.headers["user-agent"]) } : {} },
608
+ body
609
+ });
610
+ return runAsCaller(callerOf(req), () => appDispatcher(req.app)(request)).then(loginResultFromResponse);
611
+ }
612
+ });
613
+ const descriptions = options.descriptions;
614
+ const openapi = options.openapi;
357
615
  try {
358
616
  installMountRecorder();
359
617
  } catch {
@@ -380,10 +638,26 @@ function aiReady(options = {}) {
380
638
  };
381
639
  const middleware = (req, res, next) => {
382
640
  const path = req.path ?? req.url.split("?")[0];
641
+ if (brokerInfo && (path === `${MCP_PATH}/login` || path === `${MCP_PATH}/login/`)) {
642
+ const login = loginOf(req.app);
643
+ if (!login) return next();
644
+ void serveLoginPage(req, res, login).catch((err) => {
645
+ console.warn("[agentready] sign-in form failed:", err);
646
+ if (!res.headersSent) res.writeHead(503).end("sign-in is unavailable");
647
+ });
648
+ return;
649
+ }
383
650
  if (gate && metadataPaths.has(path)) {
384
651
  void sendWebResponse(res, gate.metadataResponse(req.method));
385
652
  return;
386
653
  }
654
+ if (brokerGate && (path === PRM_ROOT || path === `${PRM_ROOT}${MCP_PATH}`)) {
655
+ const resource = resourceOf(req, path, MCP_PATH);
656
+ void report(req, resource).then(
657
+ (verdict) => verdict === "open" ? void res.writeHead(404).end("not found") : sendWebResponse(res, brokerGate.metadataResponse(req.method, resource))
658
+ );
659
+ return;
660
+ }
387
661
  if (path !== MCP_PATH && !path.startsWith(`${MCP_PATH}/`)) return next();
388
662
  if (disabled) {
389
663
  res.writeHead(503).end("agentready is disabled due to an internal error");
@@ -397,6 +671,16 @@ function aiReady(options = {}) {
397
671
  console.warn("[agentready] oauth gate failed:", err);
398
672
  if (!res.headersSent) res.writeHead(503).end("authorization check failed");
399
673
  return null;
674
+ }) : brokerGate ? report(req, resourceOf(req, path, MCP_PATH)).then(async (verdict) => {
675
+ if (verdict === "open") return {};
676
+ const result = await brokerGate.check(headersOf(req), resourceOf(req, path, MCP_PATH));
677
+ if (result.ok) return {};
678
+ await sendWebResponse(res, result.response);
679
+ return null;
680
+ }).catch((err) => {
681
+ console.warn("[agentready] broker check failed:", err);
682
+ if (!res.headersSent) res.writeHead(503).end("authorization check failed");
683
+ return null;
400
684
  }) : Promise.resolve({});
401
685
  authorized.then((authContext) => {
402
686
  if (!authContext) return;
@@ -405,9 +689,16 @@ function aiReady(options = {}) {
405
689
  cfg,
406
690
  cwd,
407
691
  mounts,
692
+ { descriptions, openapi },
408
693
  (surface) => logSurface(surface.routes, surface.docs.length, "updated: routes registered after startup")
409
694
  );
410
- return handlerPromise.then((handler) => handler(req, res, authContext));
695
+ return handlerPromise.then(
696
+ (handler) => (
697
+ // Tool calls made while handling this request reach the app with
698
+ // this caller's address and forwarding headers (see surface.ts).
699
+ runAsCaller(callerOf(req), () => handler(req, res, authContext))
700
+ )
701
+ );
411
702
  }).catch((err) => {
412
703
  disabled = true;
413
704
  handlerPromise = null;
@@ -425,7 +716,39 @@ function aiReady(options = {}) {
425
716
  if (!res.headersSent) res.writeHead(503).end("agentready failed to initialize");
426
717
  });
427
718
  };
719
+ async function serveLoginPage(req, res, login) {
720
+ const original = String(req.originalUrl ?? req.url);
721
+ const local = `${req.protocol ?? "http"}://${req.headers.host ?? "localhost"}${original}`;
722
+ const url = new URL(publicRequestUrl(local, headersOf(req)));
723
+ url.search = new URL(local).search;
724
+ let body;
725
+ if (req.method === "POST") {
726
+ if (req.body && typeof req.body === "object" && Object.keys(req.body).length > 0) body = new URLSearchParams(req.body).toString();
727
+ else if (typeof req.body === "string") body = req.body;
728
+ else {
729
+ const chunks = [];
730
+ let size = 0;
731
+ for await (const chunk of req) {
732
+ size += chunk.length;
733
+ if (size > 16 * 1024) break;
734
+ chunks.push(chunk);
735
+ }
736
+ body = Buffer.concat(chunks).toString("utf8");
737
+ }
738
+ }
739
+ const request = new Request(url, { method: req.method, headers: headersOf(req), ...body !== void 0 ? { body } : {} });
740
+ const forwarded = String(req.headers["x-forwarded-for"] ?? "").split(",")[0]?.trim();
741
+ const ip = forwarded || req.socket?.remoteAddress;
742
+ const response = await handleLoginPage(request, appLoginFor(req, login), brokerInfo, {
743
+ path: url.pathname,
744
+ secure: url.protocol === "https:",
745
+ origin: url.origin,
746
+ ...ip ? { ip } : {}
747
+ });
748
+ await sendWebResponse(res, response);
749
+ }
428
750
  middleware[MOUNT_OWNER_TAG] = true;
751
+ middleware[AIREADY_OPTIONS_TAG] = options;
429
752
  setImmediate(() => {
430
753
  void startupCheck().catch(() => {
431
754
  });
@@ -474,16 +797,16 @@ function headersOf(req) {
474
797
  }
475
798
  return headers;
476
799
  }
477
- async function initialize(app, cfg, cwd, mounts, onReady) {
800
+ async function initialize(app, cfg, cwd, mounts, extras, onReady) {
478
801
  let surface = null;
479
802
  let kbPromise = null;
480
803
  const getSurface = async () => {
481
- surface ??= await buildExpressSurface(app, cfg, cwd, { mounts });
804
+ surface ??= await buildExpressSurface(app, cfg, cwd, { mounts, ...extras });
482
805
  return surface;
483
806
  };
484
807
  const getKb = async () => {
485
808
  const s = await getSurface();
486
- kbPromise ??= loadOrBuildKb(s.docs, join2(cwd, ".exposify"));
809
+ kbPromise ??= loadOrBuildKb(s.docs, join3(cwd, ".exposify"));
487
810
  return kbPromise;
488
811
  };
489
812
  const built = await getSurface();
@@ -495,9 +818,13 @@ async function initialize(app, cfg, cwd, mounts, onReady) {
495
818
  return createNodeMcpHandler({ getSurface, getKb, cfg });
496
819
  }
497
820
  export {
821
+ AIREADY_OPTIONS_TAG,
498
822
  aiReady,
823
+ completeMcpLogin,
824
+ createHostedAuthResolver,
499
825
  describe,
500
826
  exposed,
501
827
  hidden,
828
+ hostedBroker,
502
829
  routesFromExpress
503
830
  };