@exposify/express 0.3.7 → 0.3.8

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 CHANGED
@@ -17,6 +17,111 @@ credentials (`Authorization`/`Cookie`), forwarded to your routes unchanged.
17
17
  There is no separate authorization layer to configure or get wrong — an
18
18
  agent can do exactly what the person it's acting for could already do.
19
19
 
20
+ ## "Click Connect and log in" (OAuth)
21
+
22
+ By default the MCP client has to be handed a token by hand. If your app's
23
+ auth provider is an OAuth 2.1 authorization server (Auth0, WorkOS, Clerk,
24
+ Okta, Keycloak, Supabase Auth, ...), add `oauth` and MCP clients log in on
25
+ their own:
26
+
27
+ ```ts
28
+ app.use(aiReady({
29
+ oauth: {
30
+ issuer: "https://acme.us.auth0.com/", // exactly the tokens' `iss`
31
+ resource: "https://api.acme.com/mcp", // the public MCP URL = required token audience
32
+ scopes: ["read"], // optional
33
+ },
34
+ }));
35
+ ```
36
+
37
+ The whole `/mcp` endpoint then requires a valid access token. A request
38
+ without one gets `401` + `WWW-Authenticate: Bearer resource_metadata="…"`,
39
+ and Protected Resource Metadata (RFC 9728) is served at
40
+ `/.well-known/oauth-protected-resource[/mcp]`. That is enough for Claude,
41
+ Cursor, etc. to discover your issuer, register, open your real login page
42
+ and refresh tokens later, with nothing for the user to paste. Tokens are
43
+ verified as JWTs against the issuer's JWKS (discovered from its metadata):
44
+ signature, `iss`, `aud` = `resource`, expiry, and `scopes`. An expired
45
+ token is an HTTP `401` (so the client refreshes), and an unreachable
46
+ issuer is a `503`, never a pass.
47
+
48
+ Then the validated `Authorization` is forwarded to your routes in-process,
49
+ exactly like passthrough.
50
+
51
+ ### One change in your app: accept the MCP audience
52
+
53
+ MCP tokens are issued for the MCP URL (`aud: "https://api.acme.com/mcp"`),
54
+ and most JWT middleware rejects any audience it wasn't told about. So add
55
+ the MCP URL next to your existing audience. Your web app's tokens keep
56
+ working:
57
+
58
+ ```ts
59
+ const MCP_URL = "https://api.acme.com/mcp";
60
+ app.use(aiReady({ oauth: { issuer: ISSUER, resource: MCP_URL } }));
61
+
62
+ // express-oauth2-jwt-bearer (Auth0):
63
+ app.use(auth({ issuerBaseURL: ISSUER, audience: ["https://api.acme.com", MCP_URL] }));
64
+
65
+ // or jose directly:
66
+ await jwtVerify(token, JWKS, { issuer: ISSUER, audience: ["https://api.acme.com", MCP_URL] });
67
+ ```
68
+
69
+ Any other middleware works the same way: pass both values wherever it takes
70
+ the expected audience. On the provider side, the issuer must be willing to
71
+ mint tokens for the MCP URL. In Auth0, for example, add an API whose
72
+ identifier is the MCP URL, and leave your existing API alone.
73
+
74
+ If you miss this, sign-in still succeeds but every tool call fails. The
75
+ first time your app answers `401` to a token the MCP endpoint already
76
+ validated, `aiReady()` logs one warning naming the audience to add and
77
+ quoting your app's own `WWW-Authenticate` header.
78
+
79
+ - `resource` is required, never inferred from `Host` (proxies get it
80
+ wrong). Use your public URL; `http` is only accepted for `localhost`.
81
+ - `audience` overrides the expected `aud` for issuers that can't bind
82
+ tokens to `resource`. It's weaker: any token for that audience passes,
83
+ and a warning is logged at startup.
84
+ - `jwksUri` skips metadata discovery. `verify: async (token) => claims | null`
85
+ replaces JWT verification (opaque tokens, introspection).
86
+ - A custom `auth` resolver receives `(headers, { claims })`, so it can map
87
+ the validated user to whatever your app expects.
88
+ - `oauth` can't be combined with `docsAuth: "public"` (that throws): clients
89
+ only start login on a connect-time `401`, so the docs tools are behind
90
+ the token too.
91
+ - Apps without an OAuth authorization server (session cookies,
92
+ passport-local, homegrown JWTs) need one first; this package doesn't
93
+ include one.
94
+
95
+ ## Startup log
96
+
97
+ On boot (`npm run dev`, or in production), right after your routes
98
+ register, `aiReady()` prints one line saying whether MCP is up:
99
+
100
+ ```
101
+ [agentready] MCP ready at /mcp: 6 tools (4 read, 1 write, search_docs), 1 write route hidden (opt in with exposed()), 2 docs indexed, auth: passthrough
102
+ ```
103
+
104
+ When something's wrong, the line names the problem and a fix instead:
105
+
106
+ ```
107
+ [agentready] MCP failed to start: <reason>
108
+ fix: <what to do>
109
+ [agentready] warning: MCP is up at /mcp but exposes no route tools
110
+ fix: register app.use(aiReady()) before your routers, ...
111
+ [agentready] warning: can't verify access tokens from oauth.issuer https://...: <reason>
112
+ fix: make oauth.issuer match the tokens' `iss` exactly ...
113
+ ```
114
+
115
+ - With `oauth`, the issuer's metadata and keys are fetched once at boot, so
116
+ a wrong issuer shows up here instead of on the first login.
117
+ - Routes registered later (e.g. after an `await`) are picked up on the first
118
+ `/mcp` request, which prints an updated line only if the count changed.
119
+ - Failures and warnings always print. The ready line is on by default and
120
+ off under `NODE_ENV=test`. `startupLog: true | false` overrides that
121
+ (and also turns the issuer check on or off).
122
+ - The check is read-only and never throws into your app; your own routes
123
+ keep working either way.
124
+
20
125
  ## Exposure policy
21
126
 
22
127
  - **Reads (GET/HEAD) are exposed automatically.** Writes are hidden by
@@ -60,11 +165,13 @@ app.use(aiReady({ mounts: { "/legacy": legacyRouter } }));
60
165
 
61
166
  ```ts
62
167
  aiReady({
63
- auth?: "passthrough" | "none", // default "passthrough"
168
+ auth?: "passthrough" | "none" | AuthResolver, // default "passthrough"
169
+ oauth?: { issuer, resource, scopes?, audience?, jwksUri?, verify? }, // see above
170
+ startupLog?: boolean, // default on, off under NODE_ENV=test
64
171
  docs?: string[], // default ["README.md", "docs/**/*.md"]
65
172
  expose?: string[] | "*", // e.g. ["GET /trends"]; default unset
66
173
  exclude?: string[], // picomatch globs against the route path
67
- docsAuth?: "public" | "required", // default "public"
174
+ docsAuth?: "public" | "required", // default "public" ("required" with oauth)
68
175
  embedder?: (texts: string[]) => Promise<number[][]>,
69
176
  telemetry?: { enabled: boolean; sink?(event): void; capturePayloads?: boolean },
70
177
  cwd?: string, // default process.cwd()
@@ -84,6 +191,10 @@ default above.
84
191
  only works when `aiReady()` is registered *before* the mount. A router
85
192
  mounted both before and after `aiReady()` ("straddle" mount) resolves
86
193
  correctly on Express 4 but silently drops the earlier prefix on Express 5.
194
+ - With `oauth`, only a global `scopes` set is checked at the edge; per-tool
195
+ scopes aren't supported (your routes' own `403`s still come back as tool
196
+ errors). Combining `oauth` with a token-broker resolver isn't supported
197
+ yet.
87
198
  - In-process dispatch uses `light-my-request`, which splices Express's
88
199
  process-wide request/response prototypes for the duration of each
89
200
  dispatch — a narrow concurrency window under real concurrent traffic, a
package/dist/index.cjs CHANGED
@@ -59,6 +59,7 @@ function installMountRecorder(routerCtor) {
59
59
  const wrapper = function(...args) {
60
60
  try {
61
61
  recordUse(this, args);
62
+ recordTaggedOwner(this, args);
62
63
  } catch {
63
64
  }
64
65
  return originalUse.apply(this, args);
@@ -68,6 +69,21 @@ function installMountRecorder(routerCtor) {
68
69
  } catch {
69
70
  }
70
71
  }
72
+ var MOUNT_OWNER_TAG = /* @__PURE__ */ Symbol.for("exposify.express.recordMountOwner");
73
+ var taggedOwners = /* @__PURE__ */ new WeakMap();
74
+ function recordTaggedOwner(parent, args) {
75
+ const stack = [...args];
76
+ while (stack.length > 0) {
77
+ const arg = stack.pop();
78
+ if (Array.isArray(arg)) stack.push(...arg);
79
+ else if (typeof arg === "function" && arg[MOUNT_OWNER_TAG]) {
80
+ if (!taggedOwners.has(arg)) taggedOwners.set(arg, parent);
81
+ }
82
+ }
83
+ }
84
+ function getMountOwner(fn) {
85
+ return taggedOwners.get(fn);
86
+ }
71
87
  function getRecordedMounts(handle) {
72
88
  if (typeof handle !== "function") return void 0;
73
89
  return recordedMounts.get(handle);
@@ -324,8 +340,26 @@ async function readPackageInfo(cwd) {
324
340
  // src/index.ts
325
341
  var import_core3 = require("@exposify/core");
326
342
  var MCP_PATH = "/mcp";
343
+ var PRM_ROOT = "/.well-known/oauth-protected-resource";
344
+ var LOG_PREFIX = "[agentready]";
345
+ function toolsSummary(routes, docs, cfg) {
346
+ const counts = (0, import_core2.countTools)(routes, cfg);
347
+ 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 };
349
+ }
350
+ 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
+ function prmPaths(resource) {
352
+ const resourcePath = new URL(resource).pathname;
353
+ return /* @__PURE__ */ new Set([PRM_ROOT, `${PRM_ROOT}${MCP_PATH}`, resourcePath === "/" ? PRM_ROOT : `${PRM_ROOT}${resourcePath}`]);
354
+ }
355
+ async function sendWebResponse(res, response) {
356
+ const body = response.body ? await response.text() : void 0;
357
+ res.writeHead(response.status, Object.fromEntries(response.headers)).end(body);
358
+ }
327
359
  function aiReady(options = {}) {
328
360
  const cfg = (0, import_core2.resolveOptions)(options);
361
+ const gate = cfg.oauth ? (0, import_core2.createOAuthGate)(cfg.oauth) : null;
362
+ const metadataPaths = cfg.oauth ? prmPaths(cfg.oauth.resource) : /* @__PURE__ */ new Set();
329
363
  const cwd = options.cwd ?? process.cwd();
330
364
  const mounts = options.mounts;
331
365
  try {
@@ -335,26 +369,120 @@ function aiReady(options = {}) {
335
369
  let handlerPromise = null;
336
370
  let disabled = false;
337
371
  let warned = false;
338
- return (req, res, next) => {
372
+ const logReady = (0, import_core2.startupLogEnabled)(options.startupLog);
373
+ const auth = (0, import_core2.describeAuth)(cfg.auth, cfg.oauth);
374
+ let reported = null;
375
+ const logSurface = (routes, docs, note) => {
376
+ const summary = toolsSummary(routes, docs, cfg);
377
+ const key = [summary.tools, ...summary.details, docs].join("|");
378
+ if (key === reported) return;
379
+ const first = reported === null;
380
+ reported = key;
381
+ 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));
383
+ return;
384
+ }
385
+ if (!logReady) return;
386
+ 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 }));
388
+ };
389
+ const middleware = (req, res, next) => {
339
390
  const path = req.path ?? req.url.split("?")[0];
391
+ if (gate && metadataPaths.has(path)) {
392
+ void sendWebResponse(res, gate.metadataResponse(req.method));
393
+ return;
394
+ }
340
395
  if (path !== MCP_PATH && !path.startsWith(`${MCP_PATH}/`)) return next();
341
396
  if (disabled) {
342
397
  res.writeHead(503).end("agentready is disabled due to an internal error");
343
398
  return;
344
399
  }
345
- handlerPromise ??= initialize(req.app, cfg, cwd, mounts);
346
- handlerPromise.then((handler) => handler(req, res)).catch((err) => {
400
+ const authorized = gate ? gate.check(headersOf(req)).then(async (result) => {
401
+ if (result.ok) return { claims: result.claims };
402
+ await sendWebResponse(res, result.response);
403
+ return null;
404
+ }, (err) => {
405
+ console.warn("[agentready] oauth gate failed:", err);
406
+ if (!res.headersSent) res.writeHead(503).end("authorization check failed");
407
+ return null;
408
+ }) : Promise.resolve({});
409
+ authorized.then((authContext) => {
410
+ if (!authContext) return;
411
+ handlerPromise ??= initialize(
412
+ req.app,
413
+ cfg,
414
+ cwd,
415
+ mounts,
416
+ (surface) => logSurface(surface.routes, surface.docs.length, "updated: routes registered after startup")
417
+ );
418
+ return handlerPromise.then((handler) => handler(req, res, authContext));
419
+ }).catch((err) => {
347
420
  disabled = true;
348
421
  handlerPromise = null;
349
422
  if (!warned) {
350
423
  warned = true;
351
- console.warn("[agentready] disabled after internal error:", err);
424
+ console.error(
425
+ (0, import_core2.formatFailed)(
426
+ LOG_PREFIX,
427
+ errorMessage(err),
428
+ `${MCP_PATH} now answers 503 until the process restarts (your own routes are unaffected); full error below`
429
+ ),
430
+ err
431
+ );
352
432
  }
353
433
  if (!res.headersSent) res.writeHead(503).end("agentready failed to initialize");
354
434
  });
355
435
  };
436
+ middleware[MOUNT_OWNER_TAG] = true;
437
+ setImmediate(() => {
438
+ void startupCheck().catch(() => {
439
+ });
440
+ });
441
+ async function startupCheck() {
442
+ const owner = getMountOwner(middleware);
443
+ if (!owner) {
444
+ return;
445
+ }
446
+ const probe = gate ? gate.probe() : Promise.resolve(null);
447
+ try {
448
+ const routes = routesFromExpress({ _router: owner }, { mounts });
449
+ const docs = await (0, import_node2.loadDocsFromFs)(cfg.docs, cwd);
450
+ logSurface(routes, docs.length);
451
+ } catch (err) {
452
+ console.error(
453
+ (0, import_core2.formatFailed)(
454
+ LOG_PREFIX,
455
+ errorMessage(err),
456
+ `your app keeps serving its own routes; ${MCP_PATH} will retry on its first request and answer 503 if it fails again`
457
+ ),
458
+ err
459
+ );
460
+ }
461
+ const problem = await probe;
462
+ if (problem && cfg.oauth) {
463
+ console.warn(
464
+ (0, import_core2.formatWarning)(
465
+ LOG_PREFIX,
466
+ `can't verify access tokens from oauth.issuer ${cfg.oauth.issuer}: ${problem}`,
467
+ "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"
468
+ )
469
+ );
470
+ }
471
+ }
472
+ return middleware;
356
473
  }
357
- async function initialize(app, cfg, cwd, mounts) {
474
+ function errorMessage(err) {
475
+ return err instanceof Error ? err.message : String(err);
476
+ }
477
+ function headersOf(req) {
478
+ const headers = new Headers();
479
+ for (const [name, value] of Object.entries(req.headers)) {
480
+ if (typeof value === "string") headers.set(name, value);
481
+ else if (Array.isArray(value)) headers.set(name, value.join(", "));
482
+ }
483
+ return headers;
484
+ }
485
+ async function initialize(app, cfg, cwd, mounts, onReady) {
358
486
  let surface = null;
359
487
  let kbPromise = null;
360
488
  const getSurface = async () => {
@@ -366,8 +494,12 @@ async function initialize(app, cfg, cwd, mounts) {
366
494
  kbPromise ??= (0, import_node2.loadOrBuildKb)(s.docs, (0, import_node_path2.join)(cwd, ".exposify"));
367
495
  return kbPromise;
368
496
  };
369
- await getSurface();
497
+ const built = await getSurface();
370
498
  await getKb();
499
+ try {
500
+ onReady(built);
501
+ } catch {
502
+ }
371
503
  return (0, import_node2.createNodeMcpHandler)({ getSurface, getKb, cfg });
372
504
  }
373
505
  // Annotate the CommonJS export names for ESM import in node:
package/dist/index.js CHANGED
@@ -8,10 +8,19 @@ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require
8
8
  // src/index.ts
9
9
  import { join as join2 } from "path";
10
10
  import {
11
- resolveOptions
11
+ countTools,
12
+ createOAuthGate,
13
+ describeAuth,
14
+ formatFailed,
15
+ formatReady,
16
+ formatWarning,
17
+ resolveOptions,
18
+ routeToolsPhrase,
19
+ startupLogEnabled
12
20
  } from "@exposify/core";
13
21
  import {
14
22
  createNodeMcpHandler,
23
+ loadDocsFromFs as loadDocsFromFs2,
15
24
  loadOrBuildKb
16
25
  } from "@exposify/core/node";
17
26
 
@@ -42,6 +51,7 @@ function installMountRecorder(routerCtor) {
42
51
  const wrapper = function(...args) {
43
52
  try {
44
53
  recordUse(this, args);
54
+ recordTaggedOwner(this, args);
45
55
  } catch {
46
56
  }
47
57
  return originalUse.apply(this, args);
@@ -51,6 +61,21 @@ function installMountRecorder(routerCtor) {
51
61
  } catch {
52
62
  }
53
63
  }
64
+ var MOUNT_OWNER_TAG = /* @__PURE__ */ Symbol.for("exposify.express.recordMountOwner");
65
+ var taggedOwners = /* @__PURE__ */ new WeakMap();
66
+ function recordTaggedOwner(parent, args) {
67
+ const stack = [...args];
68
+ while (stack.length > 0) {
69
+ const arg = stack.pop();
70
+ if (Array.isArray(arg)) stack.push(...arg);
71
+ else if (typeof arg === "function" && arg[MOUNT_OWNER_TAG]) {
72
+ if (!taggedOwners.has(arg)) taggedOwners.set(arg, parent);
73
+ }
74
+ }
75
+ }
76
+ function getMountOwner(fn) {
77
+ return taggedOwners.get(fn);
78
+ }
54
79
  function getRecordedMounts(handle) {
55
80
  if (typeof handle !== "function") return void 0;
56
81
  return recordedMounts.get(handle);
@@ -307,8 +332,26 @@ async function readPackageInfo(cwd) {
307
332
  // src/index.ts
308
333
  import { exposed, hidden, describe } from "@exposify/core";
309
334
  var MCP_PATH = "/mcp";
335
+ var PRM_ROOT = "/.well-known/oauth-protected-resource";
336
+ var LOG_PREFIX = "[agentready]";
337
+ function toolsSummary(routes, docs, cfg) {
338
+ const counts = countTools(routes, cfg);
339
+ const details = counts.hiddenWrites > 0 ? [`${counts.hiddenWrites} write route${counts.hiddenWrites === 1 ? "" : "s"} hidden (opt in with exposed())`] : [];
340
+ return { tools: routeToolsPhrase(counts, docs), details, empty: counts.reads + counts.writes === 0 };
341
+ }
342
+ 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";
343
+ function prmPaths(resource) {
344
+ const resourcePath = new URL(resource).pathname;
345
+ return /* @__PURE__ */ new Set([PRM_ROOT, `${PRM_ROOT}${MCP_PATH}`, resourcePath === "/" ? PRM_ROOT : `${PRM_ROOT}${resourcePath}`]);
346
+ }
347
+ async function sendWebResponse(res, response) {
348
+ const body = response.body ? await response.text() : void 0;
349
+ res.writeHead(response.status, Object.fromEntries(response.headers)).end(body);
350
+ }
310
351
  function aiReady(options = {}) {
311
352
  const cfg = resolveOptions(options);
353
+ const gate = cfg.oauth ? createOAuthGate(cfg.oauth) : null;
354
+ const metadataPaths = cfg.oauth ? prmPaths(cfg.oauth.resource) : /* @__PURE__ */ new Set();
312
355
  const cwd = options.cwd ?? process.cwd();
313
356
  const mounts = options.mounts;
314
357
  try {
@@ -318,26 +361,120 @@ function aiReady(options = {}) {
318
361
  let handlerPromise = null;
319
362
  let disabled = false;
320
363
  let warned = false;
321
- return (req, res, next) => {
364
+ const logReady = startupLogEnabled(options.startupLog);
365
+ const auth = describeAuth(cfg.auth, cfg.oauth);
366
+ let reported = null;
367
+ const logSurface = (routes, docs, note) => {
368
+ const summary = toolsSummary(routes, docs, cfg);
369
+ const key = [summary.tools, ...summary.details, docs].join("|");
370
+ if (key === reported) return;
371
+ const first = reported === null;
372
+ reported = key;
373
+ if (summary.empty) {
374
+ console.warn(formatWarning(LOG_PREFIX, `MCP is up at ${MCP_PATH} but exposes no route tools`, NO_TOOLS_FIX));
375
+ return;
376
+ }
377
+ if (!logReady) return;
378
+ const details = [...summary.details, ...note && !first ? [note] : []];
379
+ console.log(formatReady(LOG_PREFIX, { endpoint: MCP_PATH, tools: summary.tools, details, docs, auth }));
380
+ };
381
+ const middleware = (req, res, next) => {
322
382
  const path = req.path ?? req.url.split("?")[0];
383
+ if (gate && metadataPaths.has(path)) {
384
+ void sendWebResponse(res, gate.metadataResponse(req.method));
385
+ return;
386
+ }
323
387
  if (path !== MCP_PATH && !path.startsWith(`${MCP_PATH}/`)) return next();
324
388
  if (disabled) {
325
389
  res.writeHead(503).end("agentready is disabled due to an internal error");
326
390
  return;
327
391
  }
328
- handlerPromise ??= initialize(req.app, cfg, cwd, mounts);
329
- handlerPromise.then((handler) => handler(req, res)).catch((err) => {
392
+ const authorized = gate ? gate.check(headersOf(req)).then(async (result) => {
393
+ if (result.ok) return { claims: result.claims };
394
+ await sendWebResponse(res, result.response);
395
+ return null;
396
+ }, (err) => {
397
+ console.warn("[agentready] oauth gate failed:", err);
398
+ if (!res.headersSent) res.writeHead(503).end("authorization check failed");
399
+ return null;
400
+ }) : Promise.resolve({});
401
+ authorized.then((authContext) => {
402
+ if (!authContext) return;
403
+ handlerPromise ??= initialize(
404
+ req.app,
405
+ cfg,
406
+ cwd,
407
+ mounts,
408
+ (surface) => logSurface(surface.routes, surface.docs.length, "updated: routes registered after startup")
409
+ );
410
+ return handlerPromise.then((handler) => handler(req, res, authContext));
411
+ }).catch((err) => {
330
412
  disabled = true;
331
413
  handlerPromise = null;
332
414
  if (!warned) {
333
415
  warned = true;
334
- console.warn("[agentready] disabled after internal error:", err);
416
+ console.error(
417
+ formatFailed(
418
+ LOG_PREFIX,
419
+ errorMessage(err),
420
+ `${MCP_PATH} now answers 503 until the process restarts (your own routes are unaffected); full error below`
421
+ ),
422
+ err
423
+ );
335
424
  }
336
425
  if (!res.headersSent) res.writeHead(503).end("agentready failed to initialize");
337
426
  });
338
427
  };
428
+ middleware[MOUNT_OWNER_TAG] = true;
429
+ setImmediate(() => {
430
+ void startupCheck().catch(() => {
431
+ });
432
+ });
433
+ async function startupCheck() {
434
+ const owner = getMountOwner(middleware);
435
+ if (!owner) {
436
+ return;
437
+ }
438
+ const probe = gate ? gate.probe() : Promise.resolve(null);
439
+ try {
440
+ const routes = routesFromExpress({ _router: owner }, { mounts });
441
+ const docs = await loadDocsFromFs2(cfg.docs, cwd);
442
+ logSurface(routes, docs.length);
443
+ } catch (err) {
444
+ console.error(
445
+ formatFailed(
446
+ LOG_PREFIX,
447
+ errorMessage(err),
448
+ `your app keeps serving its own routes; ${MCP_PATH} will retry on its first request and answer 503 if it fails again`
449
+ ),
450
+ err
451
+ );
452
+ }
453
+ const problem = await probe;
454
+ if (problem && cfg.oauth) {
455
+ console.warn(
456
+ formatWarning(
457
+ LOG_PREFIX,
458
+ `can't verify access tokens from oauth.issuer ${cfg.oauth.issuer}: ${problem}`,
459
+ "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"
460
+ )
461
+ );
462
+ }
463
+ }
464
+ return middleware;
339
465
  }
340
- async function initialize(app, cfg, cwd, mounts) {
466
+ function errorMessage(err) {
467
+ return err instanceof Error ? err.message : String(err);
468
+ }
469
+ function headersOf(req) {
470
+ const headers = new Headers();
471
+ for (const [name, value] of Object.entries(req.headers)) {
472
+ if (typeof value === "string") headers.set(name, value);
473
+ else if (Array.isArray(value)) headers.set(name, value.join(", "));
474
+ }
475
+ return headers;
476
+ }
477
+ async function initialize(app, cfg, cwd, mounts, onReady) {
341
478
  let surface = null;
342
479
  let kbPromise = null;
343
480
  const getSurface = async () => {
@@ -349,8 +486,12 @@ async function initialize(app, cfg, cwd, mounts) {
349
486
  kbPromise ??= loadOrBuildKb(s.docs, join2(cwd, ".exposify"));
350
487
  return kbPromise;
351
488
  };
352
- await getSurface();
489
+ const built = await getSurface();
353
490
  await getKb();
491
+ try {
492
+ onReady(built);
493
+ } catch {
494
+ }
354
495
  return createNodeMcpHandler({ getSurface, getKb, cfg });
355
496
  }
356
497
  export {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exposify/express",
3
- "version": "0.3.7",
3
+ "version": "0.3.8",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -23,7 +23,7 @@
23
23
  "prepack": "yarn build"
24
24
  },
25
25
  "dependencies": {
26
- "@exposify/core": "0.3.11",
26
+ "@exposify/core": "0.3.12",
27
27
  "light-my-request": "^6.0.0"
28
28
  },
29
29
  "peerDependencies": {
@@ -36,6 +36,7 @@
36
36
  "express": "npm:express@^5.1.0",
37
37
  "express4": "npm:express@^4.19.0",
38
38
  "express5": "npm:express@^5.1.0",
39
+ "jose": "^6.1.3",
39
40
  "tsup": "^8.3.0",
40
41
  "typescript": "^5.6.0",
41
42
  "vitest": "^3.0.0"