@exposify/express 0.3.6 → 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/LICENSE +1 -1
- package/README.md +114 -3
- package/dist/index.cjs +138 -6
- package/dist/index.js +148 -7
- package/package.json +3 -11
package/LICENSE
CHANGED
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",
|
|
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,11 +191,15 @@ 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
|
|
90
201
|
known tradeoff tracked in `packages/express/src/surface.ts`.
|
|
91
202
|
|
|
92
|
-
See the
|
|
203
|
+
See the repo README for
|
|
93
204
|
the full picture across all `@exposify/*` packages, including
|
|
94
205
|
`@exposify/supabase`.
|
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
|
-
|
|
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
|
-
|
|
346
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
329
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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"
|
|
@@ -43,15 +44,6 @@
|
|
|
43
44
|
"publishConfig": {
|
|
44
45
|
"access": "public"
|
|
45
46
|
},
|
|
46
|
-
"repository": {
|
|
47
|
-
"type": "git",
|
|
48
|
-
"url": "git+https://github.com/ayadagain/agentready.git",
|
|
49
|
-
"directory": "packages/express"
|
|
50
|
-
},
|
|
51
|
-
"homepage": "https://github.com/ayadagain/agentready/tree/main/packages/express#readme",
|
|
52
|
-
"bugs": {
|
|
53
|
-
"url": "https://github.com/ayadagain/agentready/issues"
|
|
54
|
-
},
|
|
55
47
|
"description": "Turn an Express app into an MCP server in one line: routes become AI agent tools automatically, writes are opt-in, and the caller's own credentials pass straight through.",
|
|
56
48
|
"author": "Exposify",
|
|
57
49
|
"keywords": [
|