@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/README.md +180 -7
- package/dist/cli.js +295 -0
- package/dist/index.cjs +367 -55
- package/dist/index.d.cts +30 -2
- package/dist/index.d.ts +30 -2
- package/dist/index.js +367 -40
- package/dist/scan-preload.cjs +7150 -0
- package/dist/scan-preload.js +7154 -0
- package/package.json +7 -4
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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
|
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 {
|
|
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(
|
|
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,
|
|
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
|
};
|