@aotter/mantle 0.2.0-alpha.7 → 0.2.0-alpha.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 +10 -5
- package/dist/auth/caller.js +2 -1
- package/dist/auth/identity.d.ts +2 -2
- package/dist/auth/oauth.d.ts +33 -1
- package/dist/auth/oauth.js +121 -28
- package/dist/auth/routes.js +3 -2
- package/dist/build/compiler.d.ts +44 -122
- package/dist/build/compiler.js +202 -277
- package/dist/build/spec/domain/model/ManifestGrammar.d.ts +6 -5
- package/dist/build/spec/domain/model/SqlIr.d.ts +2 -0
- package/dist/build/spec/domain/service/PlanFingerprint.d.ts +2 -0
- package/dist/build/spec/kernel/diagnostic.d.ts +1 -1
- package/dist/domain/describe/{chunk-ZIQVLA7X.js → chunk-ZVXBGP7Y.js} +69 -8
- package/dist/domain/describe/index.js +1 -1
- package/dist/domain/describe/target-hash.js +1 -1
- package/dist/domain/describe/targets.d.ts +1 -1
- package/dist/domain/generated-tools.js +2 -0
- package/dist/domain/paging.js +41 -15
- package/dist/infrastructure/bundle.js +105 -15
- package/dist/infrastructure/cli.js +11 -15
- package/dist/infrastructure/generate.d.ts +1 -3
- package/dist/infrastructure/generate.js +2 -2
- package/dist/infrastructure/generateAuth.js +6 -0
- package/dist/infrastructure/inputs.js +8 -3
- package/dist/infrastructure/wrangler.js +35 -12
- package/dist/kernel/drain.d.ts +5 -0
- package/dist/kernel/drain.js +35 -0
- package/dist/kernel/driver/postgres.js +3 -0
- package/dist/kernel/host-caller.d.ts +1 -0
- package/dist/kernel/host-caller.js +1 -0
- package/dist/kernel/mcp-apps.d.ts +29 -9
- package/dist/kernel/mcp-apps.js +2 -2
- package/dist/kernel/mcp-handler.js +100 -17
- package/dist/kernel/mcp.d.ts +7 -2
- package/dist/kernel/mcp.js +29 -5
- package/dist/kernel/rest-router.js +3 -2
- package/dist/kernel/site.d.ts +1 -3
- package/dist/kernel/site.js +2 -2
- package/dist/kernel/web.d.ts +3 -1
- package/dist/kernel/web.js +3 -0
- package/dist/usecase/adminBindings.js +2 -3
- package/dist/usecase/adminClient.js +87 -0
- package/dist/usecase/adminDescriptors.js +19 -65
- package/dist/usecase/generate.js +5 -4
- package/dist/usecase/mcp-media.js +2 -2
- package/dist/usecase/mcp.js +16 -12
- package/dist/usecase/paging.js +2 -3
- package/dist/usecase/publishing.js +2 -2
- package/dist/usecase/tool-version.js +9 -0
- package/dist/usecase/translations.js +1 -1
- package/dist/usecase/validation.js +120 -19
- package/dist/usecase/web.js +3 -2
- package/docs/examples/README.md +1 -1
- package/docs/examples/app-shell/README.md +39 -1
- package/docs/examples/app-shell/package.json +14 -6
- package/docs/examples/app-shell/smoke-kit.mjs +54 -0
- package/docs/examples/app-shell/src/auth.ts +4 -2
- package/docs/examples/app-shell/src/service.ts +2 -2
- package/docs/examples/app-shell/tsconfig.json +4 -1
- package/docs/examples/app-shell/worker-configuration.d.ts +2 -2
- package/docs/examples/app-shell/wrangler.jsonc +5 -4
- package/docs/examples/generated-web/README.md +6 -2
- package/docs/examples/generated-web/wrangler.jsonc +1 -0
- package/docs/examples/purchasing/README.md +1 -1
- package/docs/examples/purchasing/manifests/purchasing.yaml +1 -1
- package/docs/examples/purchasing/smoke.mjs +10 -4
- package/docs/examples/purchasing/src/admin.tsx +13 -0
- package/docs/examples/purchasing/wrangler.jsonc +5 -4
- package/docs/examples/recipes/wrangler.jsonc +5 -4
- package/docs/handbook/cloudflare/authentication.md +22 -2
- package/docs/handbook/cloudflare/deploy-and-operate.md +21 -0
- package/docs/handbook/cloudflare/service-entry.md +55 -28
- package/docs/handbook/concepts/authorization.md +1 -1
- package/docs/handbook/concepts/mcp-and-agents.md +19 -0
- package/docs/handbook/examples/hub.md +1 -1
- package/docs/handbook/guides/admin-ui.md +107 -1
- package/docs/handbook/guides/agent-setup.md +27 -9
- package/docs/handbook/guides/mcp-apps.md +445 -6
- package/docs/handbook/guides/native-admin.md +296 -51
- package/docs/handbook/guides/oauth.md +61 -5
- package/docs/handbook/guides/public-pages.md +2 -2
- package/docs/handbook/guides/size-and-startup.md +1 -1
- package/docs/handbook/navigation.json +0 -4
- package/docs/handbook/reference/admin-descriptors.md +0 -35
- package/docs/handbook/reference/procedure.md +5 -1
- package/docs/handbook/reference/schema.md +13 -3
- package/docs/handbook/reference/surface.md +123 -13
- package/docs/handbook/reference/trigger.md +34 -1
- package/docs/handbook/reference/view.md +1 -1
- package/docs/handbook/releases/index.md +74 -2
- package/docs/handbook/start/overview.md +1 -1
- package/docs/handbook/start/project-and-cli.md +8 -8
- package/docs/handbook/start/quickstart-worker.md +1 -1
- package/docs/skills/develop/SKILL.md +4 -3
- package/docs/skills/integrate/SKILL.md +1 -1
- package/docs/skills/media-gc/SKILL.md +7 -5
- package/docs/skills/provision/SKILL.md +4 -1
- package/docs/skills/update/SKILL.md +1 -1
- package/docs/upgrade-0.1-to-0.2.md +6 -6
- package/package.json +2 -1
- package/skills/README.md +11 -16
- package/skills/mantle/SKILL.md +4 -4
- package/skills/mantle/references/local-testing.md +4 -0
- package/skills/mantle/references/web.md +1 -1
- package/skills/mantle/references/website.md +24 -22
- package/docs/examples/app-shell/src/admin.ts +0 -10
- package/docs/examples/purchasing/src/admin.ts +0 -12
- package/docs/handbook/guides/admin-extensions.md +0 -20
|
@@ -109,7 +109,7 @@ export async function generateFromSources(request, schema) {
|
|
|
109
109
|
// a trusted all-scope read does not acquire a fictional scope predicate.
|
|
110
110
|
if (!Object.hasOwn(paging, name))
|
|
111
111
|
paging[name] = provePaging(compiled.plan, ast);
|
|
112
|
-
return
|
|
112
|
+
return ast;
|
|
113
113
|
},
|
|
114
114
|
project(ast, kind, name, page) {
|
|
115
115
|
if (kind === "procedure")
|
|
@@ -155,7 +155,7 @@ export async function generateFromSources(request, schema) {
|
|
|
155
155
|
const adminReads = request.admin ? await adminBindingsOf(compiled.plan, dialect, paging, wire, refusals) : undefined;
|
|
156
156
|
const { adminPages, adminEntries } = adminReads ?? {};
|
|
157
157
|
const report = await describeApp(compiled.plan);
|
|
158
|
-
const result = generate({ description: report, target: request.target, admin: request.admin, adminPages, adminEntries, site: request.site, plan: compiled.plan, lowered, wireLowered, viewKeys, newId: coreNewId(dialect, compiled.plan.schemas),
|
|
158
|
+
const result = generate({ description: report, target: request.target, admin: request.admin, adminPages, adminEntries, site: request.site, plan: compiled.plan, lowered, wireLowered, viewKeys, newId: coreNewId(dialect, compiled.plan.schemas), paging });
|
|
159
159
|
if (refusals.length) {
|
|
160
160
|
const diagnostics = result.ok ? [] : result.diagnostics.filter(d => !searchProblems.some(p => p.path === d.path) ||
|
|
161
161
|
!["Every reachable visibility mode must have canonical lowered SQL.", "Both caller and trusted SQL must be lowered."].includes(d.message));
|
|
@@ -6,6 +6,12 @@ export function generateFromSources(request) {
|
|
|
6
6
|
return generate(request, instances.length ? () => authSql(instances, request.target.endsWith("postgres") ? "postgres" : "sqlite") : undefined);
|
|
7
7
|
}
|
|
8
8
|
function authSql(instances, dialect) {
|
|
9
|
+
for (const options of instances) {
|
|
10
|
+
const ids = (options.plugins ?? []).map(plugin => plugin.id);
|
|
11
|
+
// Offline OAuth grants outlive sessions; without the revocation plugin a password reset or ban would not end them. mcp() spreads oauthProvider, id included.
|
|
12
|
+
if (ids.includes("oauth-provider") && !ids.includes("mantle-oauth-grant-revocation") /* oauthGrantRevocation().id */)
|
|
13
|
+
throw new Error("Better Auth has an OAuth provider (oauthProvider or mcp) without oauthGrantRevocation(): add it from @aotter/mantle/auth/oauth to the plugins, so a password reset, an admin ban or session revocation ends that user's OAuth grants.");
|
|
14
|
+
}
|
|
9
15
|
const statements = [...new Set(instances.flatMap(options => authSchemaSql(options, dialect)))];
|
|
10
16
|
// CREATE TABLE IF NOT EXISTS would silently keep the first of two different definitions.
|
|
11
17
|
// Identical statements are already merged, so a repeated table name means two different definitions.
|
|
@@ -7,10 +7,15 @@ export const INPUTS_FILE = ".mantle-inputs.json";
|
|
|
7
7
|
const MANTLE_VERSION = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")).version;
|
|
8
8
|
// Line endings are normalized so a CRLF checkout (core.autocrlf) matches the LF fingerprint.
|
|
9
9
|
const digest = (path) => existsSync(path) ? createHash("sha256").update(readFileSync(path, "utf8").replaceAll("\r\n", "\n")).digest("hex") : null;
|
|
10
|
-
/** The manifests and option modules (`--auth`, `--site
|
|
11
|
-
export function inputsFingerprint(destination, paths) {
|
|
10
|
+
/** The target and the manifests and option modules (`--auth`, `--site`) one generate run read, by path relative to its output. */
|
|
11
|
+
export function inputsFingerprint(destination, paths, target) {
|
|
12
12
|
const inputs = Object.fromEntries(paths.map(path => [relative(resolve(destination), resolve(path)).replaceAll("\\", "/"), digest(path)]));
|
|
13
|
-
return JSON.stringify({ mantle: MANTLE_VERSION, inputs }, null, 2) + "\n";
|
|
13
|
+
return JSON.stringify({ mantle: MANTLE_VERSION, target, inputs }, null, 2) + "\n";
|
|
14
|
+
}
|
|
15
|
+
/** The generate target recorded beside `generatedModule` (undefined without a fingerprint, or one written before targets were recorded). */
|
|
16
|
+
export function generatedTarget(generatedModule) {
|
|
17
|
+
const file = resolve(dirname(generatedModule), INPUTS_FILE);
|
|
18
|
+
return existsSync(file) ? JSON.parse(readFileSync(file, "utf8")).target : undefined;
|
|
14
19
|
}
|
|
15
20
|
/** Why the generated output that holds `generatedModule` no longer matches its inputs (empty when it does, or when it has no fingerprint). */
|
|
16
21
|
export function staleInputs(generatedModule) {
|
|
@@ -3,6 +3,7 @@ import { createRequire } from "node:module";
|
|
|
3
3
|
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
4
4
|
import { isDeepStrictEqual } from "node:util";
|
|
5
5
|
import { applyEdits, modify, parse } from "jsonc-parser";
|
|
6
|
+
const requiredFlags = ["nodejs_compat", "global_fetch_strictly_public"];
|
|
6
7
|
/** Newest date the project's installed workerd supports, as create-cloudflare picks it; a fixed date before Wrangler is installed. */
|
|
7
8
|
function compatibilityDate(dir) {
|
|
8
9
|
try {
|
|
@@ -16,22 +17,26 @@ function compatibilityDate(dir) {
|
|
|
16
17
|
catch { }
|
|
17
18
|
return "2026-09-28";
|
|
18
19
|
}
|
|
19
|
-
|
|
20
|
+
/** `assets`: with `--admin`, the static assets directory `mantle bundle` builds Admin into (`<assets>/admin`). */
|
|
21
|
+
export function configureWrangler(configPath, entry, outdir, generated, check = false, assets, publicFetchRequired = false) {
|
|
20
22
|
const configDir = dirname(resolve(configPath));
|
|
21
23
|
const local = (path) => relative(configDir, resolve(path)).replaceAll("\\", "/") || ".";
|
|
22
24
|
const output = local(outdir);
|
|
23
25
|
const quote = (arg) => /^[\w./-]+$/.test(arg) ? arg : `'${arg.replaceAll("'", "'\\''")}'`;
|
|
26
|
+
const worker = `${output}/worker`;
|
|
27
|
+
const assetsFlag = assets !== undefined && resolve(assets) !== resolve(outdir, "client") ? ` --assets ${quote(assets)}` : "";
|
|
24
28
|
const keys = {
|
|
25
|
-
main: `${
|
|
26
|
-
find_additional_modules: true, base_dir:
|
|
29
|
+
main: `${worker}/${basename(entry).replace(/\.[^.]+$/, "")}.js`, no_bundle: true,
|
|
30
|
+
find_additional_modules: true, base_dir: worker,
|
|
27
31
|
rules: [{ type: "ESModule", globs: ["**/*.js"], fallthrough: true }],
|
|
28
|
-
build: { command: `mantle bundle ${quote(entry)} ${quote(outdir)}`, watch_dir: [local(dirname(entry)), local(generated)] },
|
|
32
|
+
build: { command: `mantle bundle ${quote(entry)} ${quote(outdir)}${assetsFlag}`, watch_dir: [local(dirname(entry)), local(generated)] },
|
|
29
33
|
};
|
|
30
34
|
if (configPath.endsWith(".toml")) {
|
|
31
35
|
if (check)
|
|
32
36
|
throw new Error("--check requires a JSON or JSONC Wrangler config; TOML generation only prints manual edits.");
|
|
33
|
-
console.log(`Leave ${configPath} unchanged. Add these Wrangler TOML keys (include nodejs_compat in
|
|
34
|
-
`main = ${JSON.stringify(keys.main)}\nno_bundle = true\nfind_additional_modules = true\nbase_dir = ${JSON.stringify(
|
|
37
|
+
console.log(`Leave ${configPath} unchanged. Add these Wrangler TOML keys (include nodejs_compat and global_fetch_strictly_public in every compatibility_flags override):\n` +
|
|
38
|
+
`main = ${JSON.stringify(keys.main)}\nno_bundle = true\nfind_additional_modules = true\nbase_dir = ${JSON.stringify(worker)}\ncompatibility_flags = ${JSON.stringify(requiredFlags)}\n\n` +
|
|
39
|
+
(assets === undefined ? "" : `[assets]\ndirectory = ${JSON.stringify(local(assets))}\n\n`) +
|
|
35
40
|
`[[rules]]\ntype = "ESModule"\nglobs = ["**/*.js"]\nfallthrough = true\n\n[build]\ncommand = ${JSON.stringify(keys.build.command)}\nwatch_dir = ${JSON.stringify(keys.build.watch_dir)}`);
|
|
36
41
|
return false;
|
|
37
42
|
}
|
|
@@ -42,10 +47,6 @@ export function configureWrangler(configPath, entry, outdir, generated, check =
|
|
|
42
47
|
const config = parse(text, errors, { allowTrailingComma: true });
|
|
43
48
|
if (errors.length || !config || typeof config !== "object" || Array.isArray(config))
|
|
44
49
|
throw new Error(`${configPath} must be a valid JSONC object.`);
|
|
45
|
-
if (config.compatibility_flags !== undefined && (!Array.isArray(config.compatibility_flags) || !config.compatibility_flags.every((flag) => typeof flag === "string"))) {
|
|
46
|
-
throw new Error(`${configPath}: compatibility_flags must be an array of strings.`);
|
|
47
|
-
}
|
|
48
|
-
const flags = config.compatibility_flags ?? [];
|
|
49
50
|
const indent = text.match(/\n([ \t]+)"/)?.[1] ?? " ";
|
|
50
51
|
const formattingOptions = { insertSpaces: !indent.includes("\t"), tabSize: indent.length, eol: text.includes("\r\n") ? "\r\n" : "\n" };
|
|
51
52
|
// Merge, never replace: the user's own module rules and build options survive.
|
|
@@ -54,14 +55,36 @@ export function configureWrangler(configPath, entry, outdir, generated, check =
|
|
|
54
55
|
if (!isDeepStrictEqual(path.reduce((v, k) => v?.[k], config), value))
|
|
55
56
|
text = applyEdits(text, modify(text, path, value, { formattingOptions }));
|
|
56
57
|
};
|
|
58
|
+
// Public routing is the default; an explicit private origin remains app-owned without OAuth/CIMD.
|
|
59
|
+
const ensureFlags = (path, flags) => {
|
|
60
|
+
if (flags !== undefined && (!Array.isArray(flags) || !flags.every(flag => typeof flag === "string")))
|
|
61
|
+
throw new Error(`${configPath}: ${path.join(".")} must be an array of strings.`);
|
|
62
|
+
const privateFetch = Array.isArray(flags) && flags.includes("global_fetch_private_origin");
|
|
63
|
+
if (privateFetch && (publicFetchRequired || flags.includes("global_fetch_strictly_public")))
|
|
64
|
+
throw new Error(`${configPath}: ${path.join(".")} includes global_fetch_private_origin, which conflicts with public OAuth/CIMD fetch routing. Remove it and use global_fetch_strictly_public.`);
|
|
65
|
+
const required = privateFetch ? ["nodejs_compat"] : requiredFlags;
|
|
66
|
+
if (flags === undefined)
|
|
67
|
+
edit(path, required);
|
|
68
|
+
else
|
|
69
|
+
for (const flag of required)
|
|
70
|
+
if (!flags.includes(flag))
|
|
71
|
+
edit([...path, -1], flag);
|
|
72
|
+
};
|
|
73
|
+
ensureFlags(["compatibility_flags"], config.compatibility_flags);
|
|
74
|
+
// Wrangler environment arrays replace the top-level flags rather than inheriting them.
|
|
75
|
+
for (const [name, environment] of Object.entries(config.env ?? {})) {
|
|
76
|
+
if (environment?.compatibility_flags !== undefined)
|
|
77
|
+
ensureFlags(["env", name, "compatibility_flags"], environment.compatibility_flags);
|
|
78
|
+
}
|
|
57
79
|
for (const [key, value] of Object.entries(top))
|
|
58
80
|
edit([key], value);
|
|
59
81
|
for (const [key, value] of Object.entries(buildKeys))
|
|
60
82
|
edit(["build", key], value);
|
|
83
|
+
// Only the directory: the binding, run_worker_first and html_handling stay the application's.
|
|
84
|
+
if (assets !== undefined)
|
|
85
|
+
edit(["assets", "directory"], local(assets));
|
|
61
86
|
if (!(config.rules ?? []).some((r) => isDeepStrictEqual(r, rule)))
|
|
62
87
|
edit(config.rules === undefined ? ["rules"] : ["rules", -1], config.rules === undefined ? [rule] : rule);
|
|
63
|
-
if (!flags.includes("nodejs_compat"))
|
|
64
|
-
edit(config.compatibility_flags === undefined ? ["compatibility_flags"] : ["compatibility_flags", -1], config.compatibility_flags === undefined ? ["nodejs_compat"] : "nodejs_compat");
|
|
65
88
|
const changed = !exists || text !== original;
|
|
66
89
|
if (changed && !check)
|
|
67
90
|
writeFileSync(configPath, text);
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** Request-time only: no Worker startup path imports this. */
|
|
2
|
+
/** A request handler that drains the body once it has answered or thrown. `undefined` and `null` mean the request is not this
|
|
3
|
+
* handler's, so the next handler can still read it. A handler that answers and reads the request later (in `waitUntil`) must
|
|
4
|
+
* read or lock the body before answering. */
|
|
5
|
+
export declare const draining: <A extends [Request, ...any[]], R>(handle: (...args: A) => Promise<R>) => (...args: A) => Promise<R>;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** Request-time only: no Worker startup path imports this. */
|
|
2
|
+
/** Read and discard what is left of a request body, at most 1 MiB and 1 s. A response sent over an unread POST body makes local
|
|
3
|
+
* `wrangler dev` break the connection, and the next request fails with "Network connection lost". */
|
|
4
|
+
async function drain(request) {
|
|
5
|
+
let reader;
|
|
6
|
+
const timer = setTimeout(() => reader?.cancel().catch(() => { }), 1000);
|
|
7
|
+
try {
|
|
8
|
+
reader = request.body?.getReader(); // a body the app consumed is done; one it still holds is locked and throws
|
|
9
|
+
for (let read = 0, chunk; reader && !(chunk = await reader.read()).done;)
|
|
10
|
+
if ((read += chunk.value.byteLength) > 1024 * 1024) {
|
|
11
|
+
await reader.cancel();
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
catch { /* the client went away */ }
|
|
16
|
+
finally {
|
|
17
|
+
clearTimeout(timer);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/** A request handler that drains the body once it has answered or thrown. `undefined` and `null` mean the request is not this
|
|
21
|
+
* handler's, so the next handler can still read it. A handler that answers and reads the request later (in `waitUntil`) must
|
|
22
|
+
* read or lock the body before answering. */
|
|
23
|
+
export const draining = (handle) => async (...args) => {
|
|
24
|
+
let response;
|
|
25
|
+
try {
|
|
26
|
+
response = await handle(...args);
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
await drain(args[0]);
|
|
30
|
+
throw error;
|
|
31
|
+
}
|
|
32
|
+
if (response != null)
|
|
33
|
+
await drain(args[0]);
|
|
34
|
+
return response;
|
|
35
|
+
};
|
|
@@ -59,6 +59,9 @@ export async function transaction(db, fn, options = {}, path = "store/transactio
|
|
|
59
59
|
guard(db);
|
|
60
60
|
if (transactions.has(db))
|
|
61
61
|
throw new Error("Nested transactions are not supported.");
|
|
62
|
+
// a transaction handle the app opened (postgres.js types it without `begin`) only suits reads and single statements
|
|
63
|
+
if (typeof db.begin !== "function")
|
|
64
|
+
throw new Error("This PostgreSQL handle is a transaction the app opened and cannot begin another: open the transaction with store(pool, caller).transaction and call Procedures through its tx.");
|
|
62
65
|
let committing = false;
|
|
63
66
|
const state = { outer: db };
|
|
64
67
|
try {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
/** Request-only host contract validation. */
|
|
2
|
+
export { draining } from "./drain.js";
|
|
2
3
|
import { type Caller, type Gateway, type RequestContext } from "./contracts.js";
|
|
3
4
|
import type { UserFields, UserValue } from "./user-fields.js";
|
|
4
5
|
/** The complete JSON supplied as ctx.props.mantle by a trusted gateway. */
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
/** Request-only host contract validation. */
|
|
2
|
+
export { draining } from "./drain.js";
|
|
2
3
|
import { forwarded, ContractError, surfaceError, surfaceFailure } from "./contracts.js";
|
|
3
4
|
import { userCaller } from "./user-caller.js";
|
|
4
5
|
import { identities, isHostCaller } from "./caller-identity.js";
|
|
@@ -1,18 +1,34 @@
|
|
|
1
1
|
/** MCP Apps (ADR-0029 D7): UI resources served beside the tools that render in them. */
|
|
2
2
|
export declare const RESOURCE_MIME_TYPE = "text/html;profile=mcp-app";
|
|
3
|
+
/** What `resources/read` passes an HTML loader. */
|
|
4
|
+
export interface McpAppRender {
|
|
5
|
+
/** The tools this caller's `tools/list` shows on the endpoint, so the HTML names none it cannot see. */
|
|
6
|
+
readonly tools: ReadonlySet<string>;
|
|
7
|
+
/** The request's environment as the handler received it: Worker bindings such as `ASSETS`, or whatever a Bun host passes. */
|
|
8
|
+
readonly env: unknown;
|
|
9
|
+
/** The `resources/read` request, for its origin. */
|
|
10
|
+
readonly request: Request;
|
|
11
|
+
}
|
|
12
|
+
/** The CSP a resource asks its host for, as `_meta.ui.csp`. */
|
|
13
|
+
export type McpAppCsp = {
|
|
14
|
+
readonly connectDomains?: readonly string[];
|
|
15
|
+
readonly resourceDomains?: readonly string[];
|
|
16
|
+
readonly frameDomains?: readonly string[];
|
|
17
|
+
readonly baseUriDomains?: readonly string[];
|
|
18
|
+
};
|
|
19
|
+
/** What a loader may return beside a plain string: the HTML and a CSP known only when it is read. It is sent on `resources/read` and replaces the resource's static `csp` (lists are not merged). */
|
|
20
|
+
export interface McpAppPage {
|
|
21
|
+
readonly html: string;
|
|
22
|
+
readonly csp?: McpAppCsp;
|
|
23
|
+
}
|
|
3
24
|
/** One `ui://` resource. The HTML is a reusable asset and never carries caller data: that travels in tool results. */
|
|
4
25
|
export interface McpAppResource {
|
|
5
26
|
readonly uri: string;
|
|
6
27
|
readonly name: string;
|
|
7
28
|
readonly title?: string;
|
|
8
29
|
readonly description?: string;
|
|
9
|
-
readonly html: string | (() => string | Promise<string>);
|
|
10
|
-
readonly csp?:
|
|
11
|
-
readonly connectDomains?: readonly string[];
|
|
12
|
-
readonly resourceDomains?: readonly string[];
|
|
13
|
-
readonly frameDomains?: readonly string[];
|
|
14
|
-
readonly baseUriDomains?: readonly string[];
|
|
15
|
-
};
|
|
30
|
+
readonly html: string | ((render: McpAppRender) => string | McpAppPage | Promise<string | McpAppPage>);
|
|
31
|
+
readonly csp?: McpAppCsp;
|
|
16
32
|
readonly permissions?: {
|
|
17
33
|
readonly camera?: Record<string, never>;
|
|
18
34
|
readonly microphone?: Record<string, never>;
|
|
@@ -20,6 +36,8 @@ export interface McpAppResource {
|
|
|
20
36
|
readonly clipboardWrite?: Record<string, never>;
|
|
21
37
|
};
|
|
22
38
|
readonly prefersBorder?: boolean;
|
|
39
|
+
/** `_meta.ui.domain`: the dedicated sandbox origin a host should serve this App from, in the form that host documents. */
|
|
40
|
+
readonly domain?: string;
|
|
23
41
|
/** Tool names whose results this resource renders. */
|
|
24
42
|
readonly renders?: readonly string[];
|
|
25
43
|
/** Views only this App may call (`visibility: ["app"]`); a client without MCP Apps does not see them. A Procedure cannot be hidden from the model. */
|
|
@@ -28,9 +46,11 @@ export interface McpAppResource {
|
|
|
28
46
|
export interface McpApps {
|
|
29
47
|
readonly resources: readonly McpAppResource[];
|
|
30
48
|
}
|
|
31
|
-
/**
|
|
49
|
+
/** An application-owned contributor resolved after the endpoint's caller gate. */
|
|
50
|
+
export type McpAppsSource = McpApps | ((context: Pick<McpAppRender, "env" | "request">) => McpApps | Promise<McpApps>);
|
|
51
|
+
/** Flatten contributors; `mcp(...)` reads static resources getters once at construction. */
|
|
32
52
|
export declare const resourcesOf: (apps: McpApps | readonly McpApps[]) => McpApps;
|
|
33
|
-
/**
|
|
53
|
+
/** Static contributors are checked at construction; resolved factories are checked per request. A request-bound tool table defers tool-link checks until its first request. */
|
|
34
54
|
export declare function linkApps(apps: McpApps | readonly McpApps[], tools?: ReadonlySet<string>, views?: ReadonlySet<string>): {
|
|
35
55
|
resources: McpAppResource[];
|
|
36
56
|
renders: Map<string, string>;
|
package/dist/kernel/mcp-apps.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { ContractError } from "./contracts.js";
|
|
2
2
|
/** MCP Apps (ADR-0029 D7): UI resources served beside the tools that render in them. */
|
|
3
3
|
export const RESOURCE_MIME_TYPE = "text/html;profile=mcp-app";
|
|
4
|
-
/**
|
|
4
|
+
/** Flatten contributors; `mcp(...)` reads static resources getters once at construction. */
|
|
5
5
|
export const resourcesOf = (apps) => ({ resources: (Array.isArray(apps) ? apps : [apps]).flatMap(app => app.resources) });
|
|
6
|
-
/**
|
|
6
|
+
/** Static contributors are checked at construction; resolved factories are checked per request. A request-bound tool table defers tool-link checks until its first request. */
|
|
7
7
|
export function linkApps(apps, tools, views) {
|
|
8
8
|
const resources = resourcesOf(apps).resources;
|
|
9
9
|
const uris = new Set(), names = new Set(), renders = new Map(), appOnly = new Set();
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/** Request-only MCP implementation, excluded from Worker startup. */
|
|
2
2
|
import { authorized, authorizeTarget } from "./authorization.js";
|
|
3
3
|
import { forwarded, isAdmin, ContractError, checkOrigin, surfaceCaller, surfaceError, wire } from "./contracts.js";
|
|
4
|
+
import { draining } from "./drain.js";
|
|
4
5
|
import { callToolLazy as callTool } from "./mcp-call.js";
|
|
5
|
-
import { RESOURCE_MIME_TYPE, linkSurface } from "./mcp-apps.js";
|
|
6
|
+
import { RESOURCE_MIME_TYPE, linkSurface, resourcesOf } from "./mcp-apps.js";
|
|
6
7
|
const text = (value, locale) => {
|
|
7
8
|
if (typeof value === "string")
|
|
8
9
|
return value;
|
|
@@ -18,11 +19,18 @@ function observe(listener, event) {
|
|
|
18
19
|
catch { }
|
|
19
20
|
}
|
|
20
21
|
const paging = (schema) => ({ ...schema, properties: { ...schema.properties, limit: { type: "integer", minimum: 1, maximum: 500 }, cursor: { type: "string" } } });
|
|
22
|
+
/**
|
|
23
|
+
* Characters of `JSON.stringify(page)` an MCP View page may hold. A result carries its rows twice (text block and
|
|
24
|
+
* `structuredContent`) and hosts are reported to truncate near 150k characters, so one copy stays at 60k.
|
|
25
|
+
* ponytail: one fixed number; raise it or make it an option if hosts publish their limits.
|
|
26
|
+
*/
|
|
27
|
+
const MCP_PAGE_BUDGET = 60_000;
|
|
21
28
|
/** The scopes a challenge asks for: the credential's, plus what the request needs. */
|
|
22
29
|
const union = (scopes, extra) => [...new Set([...scopes, ...extra])].join(" ");
|
|
23
30
|
/** The handler for `/mcp` (public tools) and `/mcp/admin` (tools for `admin` roles); other paths are undefined. */
|
|
24
31
|
export function handler(tools, options, apps = {}, reads = []) {
|
|
25
|
-
|
|
32
|
+
const allowed = options.allowedOrigins ?? [];
|
|
33
|
+
return draining(async (request, env, ctx) => {
|
|
26
34
|
const access = options.gateway ? (await import("./host-access.js")).accessRequest(ctx, options) : undefined;
|
|
27
35
|
let resolved;
|
|
28
36
|
try {
|
|
@@ -30,6 +38,18 @@ export function handler(tools, options, apps = {}, reads = []) {
|
|
|
30
38
|
const url = new URL(request.url), path = url.pathname.replace(/\/+$/, ""), surface = path === "/mcp" ? "public" : path === "/mcp/admin" ? "admin" : undefined;
|
|
31
39
|
if (!surface)
|
|
32
40
|
return;
|
|
41
|
+
// MCP transport MUST: a present Origin is a browser, answered only for loopback or `allowedOrigins`. The request's own
|
|
42
|
+
// host is not implied, since a DNS-rebinding page sends Origin == its rebound Host; a missing Origin is a server client.
|
|
43
|
+
if (request.headers.has("origin")) {
|
|
44
|
+
const sdk = await import("@modelcontextprotocol/server");
|
|
45
|
+
const foreign = sdk.originValidationResponse(request, [...sdk.localhostAllowedOrigins(), ...allowed]);
|
|
46
|
+
if (foreign) {
|
|
47
|
+
access?.finish(new ContractError("AUTH_DENIED", "Origin is not allowed.", "request:origin"));
|
|
48
|
+
observe(options.onObservation, { kind: "request-refused", at: Date.now(), surface, status: 403 });
|
|
49
|
+
observe(options.onRefusal, { at: Date.now(), status: 403, reason: "origin" });
|
|
50
|
+
return foreign;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
33
53
|
const select = async () => (typeof tools === "function" ? await tools(request, env) : tools).filter(tool => tool.surface === surface);
|
|
34
54
|
// Dynamic admin tables stay behind the admin gate; cache classification uses generated metadata only.
|
|
35
55
|
let selected = surface === "public" ? await select() : [];
|
|
@@ -100,7 +120,9 @@ export function handler(tools, options, apps = {}, reads = []) {
|
|
|
100
120
|
}
|
|
101
121
|
if (surface === "admin")
|
|
102
122
|
selected = await select();
|
|
103
|
-
|
|
123
|
+
// Before per-caller filtering, so every caller of a deploy sees one catalogue version.
|
|
124
|
+
const version = catalogueVersion(selected);
|
|
125
|
+
const { McpServer, ProtocolError, ProtocolErrorCode, createMcpHandler, fromJsonSchema } = await import("@modelcontextprotocol/server");
|
|
104
126
|
if (request.method === "POST" && !cookieOnly && !options.gateway)
|
|
105
127
|
await parse();
|
|
106
128
|
if (tooLarge && access)
|
|
@@ -149,7 +171,17 @@ export function handler(tools, options, apps = {}, reads = []) {
|
|
|
149
171
|
}
|
|
150
172
|
}
|
|
151
173
|
const app = options.apps?.[surface];
|
|
152
|
-
|
|
174
|
+
let links;
|
|
175
|
+
if (app) {
|
|
176
|
+
const sources = (Array.isArray(app) ? app : [app]);
|
|
177
|
+
const dynamic = sources.some(source => typeof source === "function");
|
|
178
|
+
const resolved = resourcesOf(await Promise.all(sources.map(source => typeof source === "function" ? source({ env, request }) : source)));
|
|
179
|
+
// Equal tool names say nothing about request-bound resource identity or ownership.
|
|
180
|
+
const current = linkSurface(resolved, selected, dynamic ? undefined : apps[surface]);
|
|
181
|
+
if (!dynamic)
|
|
182
|
+
apps[surface] = current;
|
|
183
|
+
links = current.links;
|
|
184
|
+
}
|
|
153
185
|
if (options.authorize) {
|
|
154
186
|
try {
|
|
155
187
|
for (const message of Array.isArray(parsedBody) ? parsedBody : [parsedBody]) {
|
|
@@ -161,7 +193,8 @@ export function handler(tools, options, apps = {}, reads = []) {
|
|
|
161
193
|
tool.check?.(caller);
|
|
162
194
|
await authorizeTarget(options.authorize, tool.target ?? { kind: tool.page ? "view" : "procedure", name: tool.name }, caller);
|
|
163
195
|
}
|
|
164
|
-
|
|
196
|
+
// an App's HTML is rendered for the same tools the caller's tools/list shows
|
|
197
|
+
if ((Array.isArray(parsedBody) ? parsedBody : [parsedBody]).some(message => message?.method === "tools/list" || message?.method === "resources/read")) {
|
|
165
198
|
const visible = [];
|
|
166
199
|
for (const tool of selected) {
|
|
167
200
|
if (caller.kind === "anonymous" && tool.closed || !options.gateway && caller.kind === "user" && caller.credential === "oauth" && tool.scopes?.some(scope => !caller.scopes.includes(scope)))
|
|
@@ -184,17 +217,31 @@ export function handler(tools, options, apps = {}, reads = []) {
|
|
|
184
217
|
return challenge(error);
|
|
185
218
|
}
|
|
186
219
|
}
|
|
220
|
+
const serverInfo = { name: "aotter.mantle", ...options.serverInfo, version: options.serverInfo?.version ?? version };
|
|
187
221
|
// Host calls execute in the SDK callback, after transport validation.
|
|
188
222
|
const handler = createMcpHandler(() => {
|
|
189
223
|
const schema = (value) => fromJsonSchema(value, { getValidator() { return (input) => ({ valid: true, data: input, errorMessage: undefined }); } });
|
|
190
|
-
|
|
224
|
+
// Tools capability after construction: given to the constructor, the SDK installs its tools/call handler before the wrap below.
|
|
225
|
+
const server = new McpServer(serverInfo, options.instructions ? { instructions: options.instructions } : {});
|
|
226
|
+
server.server.registerCapabilities({ tools: { listChanged: false } });
|
|
191
227
|
if (host)
|
|
192
228
|
server.server.onclose = () => access?.finish(isRecord(parsedBody) && parsedBody.method === "initialize" ? undefined : new ContractError("INPUT_VALIDATION_FAILED", "MCP request refused.", "mcp", { status: 400 }));
|
|
193
|
-
const appMeta = links ? registerApps(server, links, parsedBody) : undefined;
|
|
229
|
+
const appMeta = links ? registerApps(server, links, parsedBody, new Set(selected.map(tool => tool.name)), { env, request }) : undefined;
|
|
230
|
+
// A host's stale copy names a tool this catalogue lacks: say so, with the catalogue version, instead of the SDK's bare "not found".
|
|
231
|
+
// Wrapped where the SDK stores it, so transport and request validation still answer first.
|
|
232
|
+
const registered = new Set(), set = server.server.setRequestHandler.bind(server.server);
|
|
233
|
+
server.server.setRequestHandler = ((method, handler) => set(method, method === "tools/call" && typeof handler === "function" ? (request, context) => {
|
|
234
|
+
if (!registered.has(request.params.name))
|
|
235
|
+
throw new ProtocolError(ProtocolErrorCode.InvalidParams, `No tool named ${JSON.stringify(request.params.name)} exists in the current catalogue (catalogue version ${serverInfo.version}).`);
|
|
236
|
+
return handler(request, context);
|
|
237
|
+
} : handler));
|
|
194
238
|
for (const tool of selected) {
|
|
195
239
|
const meta = appMeta?.(tool.name);
|
|
196
240
|
if (meta === false)
|
|
197
241
|
continue;
|
|
242
|
+
registered.add(tool.name);
|
|
243
|
+
// Not part of structured output: the version, and on failure the current input contract.
|
|
244
|
+
const about = (contract) => { const text = [tool.version && `tool version ${tool.version}`, contract && `input schema ${JSON.stringify(tool.page ? paging(tool.inputSchema) : tool.inputSchema)}`].filter(Boolean).join("; "); return text ? [{ type: "text", text }] : []; };
|
|
198
245
|
server.registerTool(tool.name, { ...(meta ? { _meta: meta } : {}), ...(tool.title ? { title: text(tool.title, locale) } : {}), description: text(tool.description, locale) ?? text(tool.title, locale) ?? tool.name, inputSchema: schema(tool.page ? paging(tool.inputSchema) : tool.inputSchema), ...(tool.outputSchema ? { outputSchema: schema(tool.outputSchema) } : {}), ...(tool.annotations ? { annotations: tool.annotations } : {}) }, async (raw) => {
|
|
199
246
|
const metadata = options.onObservation ? { kind: "invocation", surface, invocationId: crypto.randomUUID(), tool: tool.name, caller: Object.freeze(caller.kind === "user" ? { ...caller, scopes: Object.freeze([...caller.scopes]) } : { ...caller }) } : undefined;
|
|
200
247
|
const started = metadata ? performance.now() : 0;
|
|
@@ -206,10 +253,34 @@ export function handler(tools, options, apps = {}, reads = []) {
|
|
|
206
253
|
tool.check?.(caller);
|
|
207
254
|
if (tool.run && (!host || !tool.readOnly))
|
|
208
255
|
checkOrigin(request, caller);
|
|
209
|
-
|
|
256
|
+
let db;
|
|
257
|
+
const run = (input) => callTool(tool, input, () => db ??= options.db(env), caller, options.handlers, access?.dryRun ? () => ({ dryRun: true, event: access.finish() }) : undefined);
|
|
258
|
+
let data = await run(raw);
|
|
259
|
+
// A page is cut before the first row that overflows the budget: the same page re-runs with the limit that fits, so the cursor is the View's own.
|
|
260
|
+
// Data can change between reads, so the re-run is measured again. A View without an order is one page and refuses a smaller limit: it stays whole.
|
|
261
|
+
for (let tries = 0; tool.page && isRecord(data) && Array.isArray(data.rows); tries++) {
|
|
262
|
+
const rows = data.rows, total = JSON.stringify(data).length;
|
|
263
|
+
if (total <= MCP_PAGE_BUDGET)
|
|
264
|
+
break;
|
|
265
|
+
if (rows.length === 1 || tries === 4)
|
|
266
|
+
break;
|
|
267
|
+
const sizes = rows.map(row => JSON.stringify(row).length);
|
|
268
|
+
let size = total - sizes.reduce((sum, n) => sum + n, 0) - (rows.length - 1), fit = 0;
|
|
269
|
+
for (const n of sizes) {
|
|
270
|
+
size += n + (fit ? 1 : 0);
|
|
271
|
+
if (size > MCP_PAGE_BUDGET)
|
|
272
|
+
break;
|
|
273
|
+
fit++;
|
|
274
|
+
}
|
|
275
|
+
const cut = await run({ ...raw, limit: Math.max(1, fit) }).catch(() => undefined);
|
|
276
|
+
if (cut === undefined)
|
|
277
|
+
break;
|
|
278
|
+
data = cut;
|
|
279
|
+
}
|
|
210
280
|
if (host)
|
|
211
281
|
access?.finish();
|
|
212
|
-
|
|
282
|
+
const text = JSON.stringify(data), notice = tool.page && text.length > MCP_PAGE_BUDGET;
|
|
283
|
+
return { ...(meta ? { _meta: { "net.aotter.mantle/tool": tool.name } } : {}), content: [{ type: "text", text }, ...about(false), ...(notice ? [{ type: "text", text: `This page is larger than the ${MCP_PAGE_BUDGET}-character page size budget (a single row over it, or a View without an order, is returned whole), so a host may truncate it. Continue with nextCursor, if any.` }] : [])], ...(data && typeof data === "object" && !Array.isArray(data) ? { structuredContent: data } : {}) };
|
|
213
284
|
}
|
|
214
285
|
catch (error) {
|
|
215
286
|
if (host)
|
|
@@ -220,7 +291,7 @@ export function handler(tools, options, apps = {}, reads = []) {
|
|
|
220
291
|
if (diagnostic.path === "request:origin")
|
|
221
292
|
observe(options.onRefusal, { at: Date.now(), status: 403, reason: "origin" });
|
|
222
293
|
const payload = { diagnostics: [diagnostic] };
|
|
223
|
-
return { ...(meta ? { _meta: { "net.aotter.mantle/tool": tool.name } } : {}), isError: true, content: [{ type: "text", text: JSON.stringify(payload) }], ...(tool.outputSchema ? {} : { structuredContent: payload }) };
|
|
294
|
+
return { ...(meta ? { _meta: { "net.aotter.mantle/tool": tool.name } } : {}), isError: true, content: [{ type: "text", text: JSON.stringify(payload) }, ...about(outcome === "failed")], ...(tool.outputSchema ? {} : { structuredContent: payload }) };
|
|
224
295
|
}
|
|
225
296
|
finally {
|
|
226
297
|
if (metadata)
|
|
@@ -228,6 +299,11 @@ export function handler(tools, options, apps = {}, reads = []) {
|
|
|
228
299
|
}
|
|
229
300
|
});
|
|
230
301
|
}
|
|
302
|
+
// An empty catalogue still answers tools/list and refuses calls the same way.
|
|
303
|
+
if (registered.size === 0) {
|
|
304
|
+
server.server.setRequestHandler("tools/list", () => ({ tools: [] }));
|
|
305
|
+
server.server.setRequestHandler("tools/call", () => ({ content: [] }));
|
|
306
|
+
}
|
|
231
307
|
return server;
|
|
232
308
|
}, { legacy: "stateless", maxRequestBodySize: 1024 * 1024, onerror: error => console.error("[mantle mcp] request failed", error) });
|
|
233
309
|
const response = await handler.fetch(request, { parsedBody });
|
|
@@ -241,7 +317,12 @@ export function handler(tools, options, apps = {}, reads = []) {
|
|
|
241
317
|
access?.finish(error);
|
|
242
318
|
return (await import("./host-caller.js")).hostFailure(error, resolved, "mcp");
|
|
243
319
|
}
|
|
244
|
-
};
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
/** A hash of the tools an endpoint serves (`surface:name:version`, sorted), as 32-bit FNV-1a hex: it changes with any tool's definition. */
|
|
323
|
+
function catalogueVersion(tools) {
|
|
324
|
+
const text = JSON.stringify(tools.map(tool => `${tool.surface}:${tool.name}:${tool.version ?? ""}`).sort());
|
|
325
|
+
return [...text].reduce((h, c) => Math.imul(h ^ c.codePointAt(0), 16777619) >>> 0, 2166136261).toString(16).padStart(8, "0");
|
|
245
326
|
}
|
|
246
327
|
const CLIENT_CAPABILITIES_META_KEY = "io.modelcontextprotocol/clientCapabilities";
|
|
247
328
|
const isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
@@ -259,22 +340,24 @@ function clientUiSupport(message) {
|
|
|
259
340
|
}
|
|
260
341
|
return "unknown";
|
|
261
342
|
}
|
|
262
|
-
function appMeta(r) {
|
|
263
|
-
const ui = { ...(
|
|
343
|
+
function appMeta(r, csp = r.csp) {
|
|
344
|
+
const ui = { ...(csp ? { csp } : {}), ...(r.permissions ? { permissions: r.permissions } : {}), ...(r.domain !== undefined ? { domain: r.domain } : {}), ...(r.prefersBorder !== undefined ? { prefersBorder: r.prefersBorder } : {}) };
|
|
264
345
|
return Object.keys(ui).length > 0 ? { ui } : undefined;
|
|
265
346
|
}
|
|
266
347
|
/** Registration uses the official SDK; HTML loaders run only on resources/read. */
|
|
267
|
-
function registerApps(server, links, message) {
|
|
348
|
+
function registerApps(server, links, message, tools, request) {
|
|
268
349
|
const enabled = clientUiSupport(message) !== "unsupported";
|
|
269
350
|
if (enabled)
|
|
270
351
|
for (const resource of links.resources) {
|
|
271
352
|
const meta = appMeta(resource);
|
|
272
353
|
server.registerResource(resource.name, resource.uri, { mimeType: RESOURCE_MIME_TYPE, title: resource.title, description: resource.description, ...(meta ? { _meta: meta } : {}) }, async () => {
|
|
273
354
|
try {
|
|
274
|
-
|
|
355
|
+
const loaded = typeof resource.html === "string" ? resource.html : await resource.html({ tools, ...request });
|
|
356
|
+
const page = typeof loaded === "string" ? { html: loaded } : loaded, read = page.csp ? appMeta(resource, page.csp) : meta;
|
|
357
|
+
return { contents: [{ uri: resource.uri, mimeType: RESOURCE_MIME_TYPE, text: page.html, ...(read ? { _meta: read } : {}) }] };
|
|
275
358
|
}
|
|
276
|
-
catch {
|
|
277
|
-
console.error("[mantle mcp] resource rendering failed");
|
|
359
|
+
catch (error) {
|
|
360
|
+
console.error("[mantle mcp] resource rendering failed:", error instanceof Error ? error.message : String(error));
|
|
278
361
|
throw new Error("Internal error.");
|
|
279
362
|
}
|
|
280
363
|
});
|
package/dist/kernel/mcp.d.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
/** MCP request handling for a generated tool table; the official SDK loads on the first MCP request. */
|
|
2
2
|
import type { Caller, Target, Authorize, Gateway, RequestContext, GatewayOptions } from "./contracts.js";
|
|
3
3
|
import type { Conversions } from "./surface.js";
|
|
4
|
+
import { type McpAppsSource } from "./mcp-apps.js";
|
|
4
5
|
export { callTool, callToolLazy } from "./mcp-call.js";
|
|
5
|
-
export type { McpApps, McpAppResource } from "./mcp-apps.js";
|
|
6
|
+
export type { McpApps, McpAppsSource, McpAppResource, McpAppRender, McpAppPage, McpAppCsp } from "./mcp-apps.js";
|
|
6
7
|
/** Options of a generated `mcp(...)`; it adds required `handlers` when the app declares ref Procedures. */
|
|
7
8
|
export interface Options<Env, Database> extends GatewayOptions {
|
|
8
|
-
apps?: Readonly<Partial<Record<"public" | "admin",
|
|
9
|
+
apps?: Readonly<Partial<Record<"public" | "admin", McpAppsSource | readonly McpAppsSource[]>>>;
|
|
9
10
|
authorize?: Authorize;
|
|
10
11
|
gateway?: Gateway;
|
|
11
12
|
fields?: import("./user-fields.js").UserFields;
|
|
@@ -28,6 +29,8 @@ export interface Options<Env, Database> extends GatewayOptions {
|
|
|
28
29
|
};
|
|
29
30
|
requiredScopes?: readonly string[];
|
|
30
31
|
resourceMetadata?: string;
|
|
32
|
+
/** Browser origins MCP answers besides loopback, compared by hostname: `https://console.example` or the official SDK's bare `console.example`. The site's own origin is not implied. */
|
|
33
|
+
allowedOrigins?: readonly string[];
|
|
31
34
|
onObservation?: (event: McpObservation) => void | Promise<void>;
|
|
32
35
|
onRefusal?: (event: CallerRefusal) => void | Promise<void>;
|
|
33
36
|
}
|
|
@@ -79,6 +82,8 @@ export interface Tool<Database, Handlers = never> {
|
|
|
79
82
|
readonly idempotentHint?: boolean;
|
|
80
83
|
};
|
|
81
84
|
readonly inputSchema: unknown;
|
|
85
|
+
/** The listed definition's version; results carry it so a model holding an older definition can tell. */
|
|
86
|
+
readonly version?: string;
|
|
82
87
|
/** Declares an auth requirement: anonymous callers are challenged. */
|
|
83
88
|
readonly closed?: boolean;
|
|
84
89
|
/** OAuth scopes the requirement names. */
|
package/dist/kernel/mcp.js
CHANGED
|
@@ -3,18 +3,29 @@ export { callTool, callToolLazy } from "./mcp-call.js";
|
|
|
3
3
|
/** The selected MCP routes; all request work loads only when an MCP request arrives. */
|
|
4
4
|
export function mcp(tools, options, reads = []) {
|
|
5
5
|
let serve;
|
|
6
|
-
//
|
|
6
|
+
// Check known static App configuration now; factories wait for the request caller gate.
|
|
7
7
|
const linked = {};
|
|
8
8
|
const apps = {};
|
|
9
9
|
for (const surface of ["public", "admin"]) {
|
|
10
10
|
const declared = options.apps?.[surface];
|
|
11
11
|
if (declared) {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
// Read each static contributor's resources getter once, including mixed arrays.
|
|
13
|
+
const sources = (Array.isArray(declared) ? declared : [declared])
|
|
14
|
+
.map(source => typeof source === "function" ? source : resourcesOf(source));
|
|
15
|
+
const fixed = resourcesOf(sources.filter(source => typeof source !== "function"));
|
|
16
|
+
const dynamic = sources.some(source => typeof source === "function");
|
|
17
|
+
apps[surface] = dynamic ? sources : fixed;
|
|
18
|
+
// Known static conflicts still fail before any request or dynamic factory runs.
|
|
19
|
+
if (typeof tools === "function")
|
|
20
|
+
linkApps(fixed);
|
|
21
|
+
else {
|
|
22
|
+
const current = linkSurface(fixed, tools.filter(tool => tool.surface === surface));
|
|
23
|
+
if (!dynamic)
|
|
24
|
+
linked[surface] = current;
|
|
25
|
+
}
|
|
15
26
|
}
|
|
16
27
|
}
|
|
17
|
-
options = { ...options, apps };
|
|
28
|
+
options = { ...options, apps, allowedOrigins: originHostnames(options.allowedOrigins) };
|
|
18
29
|
return async (request, env, ctx) => {
|
|
19
30
|
if (!/^\/mcp(?:\/admin)?\/*$/.test(new URL(request.url).pathname))
|
|
20
31
|
return;
|
|
@@ -23,3 +34,16 @@ export function mcp(tools, options, reads = []) {
|
|
|
23
34
|
return serve(request, env, ctx);
|
|
24
35
|
};
|
|
25
36
|
}
|
|
37
|
+
/** `allowedOrigins` as the hostnames the official SDK compares; a bad entry fails here, not on each request. */
|
|
38
|
+
function originHostnames(origins = []) {
|
|
39
|
+
return Object.freeze(origins.map(origin => {
|
|
40
|
+
let url;
|
|
41
|
+
try {
|
|
42
|
+
url = new URL(/^[a-z][a-z\d+.-]*:\/\//i.test(origin) ? origin : `http://${origin}`);
|
|
43
|
+
}
|
|
44
|
+
catch { }
|
|
45
|
+
if (origin.includes("*") || !url?.hostname || url.username || url.password || url.pathname !== "/" || url.search || url.hash)
|
|
46
|
+
throw new TypeError(`mcp allowedOrigins: "${origin}" is not an origin (https://host[:port]) or a host[:port]; wildcards never match.`);
|
|
47
|
+
return url.hostname;
|
|
48
|
+
}));
|
|
49
|
+
}
|