@ultimat3/mcp 24.0.0 → 25.0.0
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/CLAUDE.md +40 -43
- package/README.md +147 -18
- package/package.json +12 -11
- package/src/app-tool.ts +6 -6
- package/src/app-tools.ts +32 -8
- package/src/audit-hook.ts +108 -0
- package/src/audit.ts +17 -0
- package/src/caller-context.ts +2 -4
- package/src/confirmation-decide.ts +116 -0
- package/src/confirmation-errors.ts +137 -0
- package/src/confirmation-gate.ts +101 -0
- package/src/confirmation-postgres.ts +143 -0
- package/src/confirmation-schema.ts +34 -0
- package/src/confirmation-store.ts +136 -0
- package/src/confirmations.ts +147 -0
- package/src/dev-host.ts +4 -4
- package/src/errors-transport.ts +94 -0
- package/src/errors.ts +44 -102
- package/src/framework-error.ts +1 -1
- package/src/from-action.ts +28 -50
- package/src/index.ts +55 -17
- package/src/meta-call.ts +5 -4
- package/src/meta-surface.ts +87 -22
- package/src/mount-errors.ts +1 -1
- package/src/oauth-metadata.ts +1 -1
- package/src/projectable.ts +18 -34
- package/src/registry.ts +6 -5
- package/src/scopes.ts +15 -4
- package/src/server-voice.ts +1 -1
- package/src/server.ts +34 -33
- package/src/tool-thrown.ts +5 -3
- package/src/transport-http.ts +42 -18
- package/src/transport-stdio.ts +1 -1
- package/src/type-pins.ts +52 -0
- package/src/wire.ts +17 -0
package/src/meta-surface.ts
CHANGED
|
@@ -268,40 +268,82 @@ export function schemaOf(tool: AnyMcpTool): JsonSchema {
|
|
|
268
268
|
* every quote — a staff catalog measured 32.5k characters — and `JSON.parse` was never its reader's
|
|
269
269
|
* job: the model's is. Same facts, same order; `describe_resource` stays JSON because a schema is.
|
|
270
270
|
*
|
|
271
|
+
* Every per-action tag costs once per action, so each is stated where it is cheapest and still
|
|
272
|
+
* exact (#590): a scope shared by every action of the resource — or of one kind — is said once on
|
|
273
|
+
* the resource line (`hoistScopes`); only `action` is tagged, because untagged means `query` and
|
|
274
|
+
* the header says so. The kind comes from `destructive` (`headOf`), never from the name.
|
|
275
|
+
*
|
|
271
276
|
* ```text
|
|
272
|
-
* cases — Court cases of the account.
|
|
273
|
-
* listCases
|
|
274
|
-
* closeCase (action; confirms
|
|
277
|
+
* cases (scope cases) — Court cases of the account.
|
|
278
|
+
* listCases {radicado?: string} — List the account's cases.
|
|
279
|
+
* closeCase (action; confirms) {id: string} — Close a case.
|
|
275
280
|
* ```
|
|
276
281
|
*/
|
|
277
282
|
export function renderCatalog(resources: readonly MetaResource[]): string {
|
|
278
283
|
if (resources.length === 0) return 'No resources are available to this caller.';
|
|
279
284
|
const lines = [
|
|
280
|
-
`${resources.length} resource(s). Run one with manage_resource({resource, action, params}); describe_resource({resources:["<name>"]}) has the full input schemas.`,
|
|
285
|
+
`${resources.length} resource(s). Run one with manage_resource({resource, action, params}); describe_resource({resources:["<name>"]}) has the full input schemas. Untagged = read-only query; (action) may write; (confirms) waits for a human; … = more in describe_resource.`,
|
|
281
286
|
];
|
|
282
287
|
for (const resource of resources) {
|
|
283
|
-
|
|
288
|
+
const hoisted = hoistScopes(resource.actions);
|
|
289
|
+
const said = hoisted.line === '' ? '' : ` (${hoisted.line})`;
|
|
290
|
+
lines.push('', `${resource.name}${said} — ${oneLine(resource.description)}`);
|
|
284
291
|
for (const action of resource.actions) {
|
|
285
292
|
const tags = [
|
|
286
|
-
action.kind,
|
|
293
|
+
...(action.kind === 'action' ? ['action'] : []),
|
|
287
294
|
...(action.confirms === true ? ['confirms'] : []),
|
|
288
|
-
...(action.scope === undefined
|
|
289
|
-
|
|
290
|
-
|
|
295
|
+
...(action.scope === undefined || hoisted.kinds.has(action.kind)
|
|
296
|
+
? []
|
|
297
|
+
: [`scope ${action.scope}`]),
|
|
298
|
+
];
|
|
299
|
+
const tagged = tags.length === 0 ? '' : ` (${tags.join('; ')})`;
|
|
300
|
+
lines.push(` ${action.name}${tagged} {${action.params}} — ${oneLine(action.description)}`);
|
|
291
301
|
}
|
|
292
302
|
}
|
|
293
303
|
return lines.join('\n');
|
|
294
304
|
}
|
|
295
305
|
|
|
306
|
+
/**
|
|
307
|
+
* The scopes said once on the resource line, and the kinds whose actions no longer repeat theirs.
|
|
308
|
+
* One scope for every action → `scope cases`. Otherwise per kind, because a token split into
|
|
309
|
+
* `cases:read` / `cases:write` is the common shape: `query scope cases:read; action scope
|
|
310
|
+
* cases:write`. A kind is hoisted only when EVERY action of it carries the SAME scope — one
|
|
311
|
+
* unscoped action among scoped ones must not inherit a gate the registry does not enforce.
|
|
312
|
+
* Computed over what THIS caller sees: the catalog is per caller.
|
|
313
|
+
*/
|
|
314
|
+
function hoistScopes(actions: readonly MetaAction[]): {
|
|
315
|
+
readonly line: string;
|
|
316
|
+
readonly kinds: ReadonlySet<MetaAction['kind']>;
|
|
317
|
+
} {
|
|
318
|
+
const all = sharedScope(actions);
|
|
319
|
+
if (all !== undefined) return { line: `scope ${all}`, kinds: new Set(['query', 'action']) };
|
|
320
|
+
const parts: string[] = [];
|
|
321
|
+
const kinds = new Set<MetaAction['kind']>();
|
|
322
|
+
for (const kind of ['query', 'action'] as const) {
|
|
323
|
+
const scope = sharedScope(actions.filter((action) => action.kind === kind));
|
|
324
|
+
if (scope === undefined) continue;
|
|
325
|
+
parts.push(`${kind} scope ${scope}`);
|
|
326
|
+
kinds.add(kind);
|
|
327
|
+
}
|
|
328
|
+
return { line: parts.join('; '), kinds };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** The scope every action carries, or `undefined` when there is none, one lacks it or two differ. */
|
|
332
|
+
function sharedScope(actions: readonly MetaAction[]): string | undefined {
|
|
333
|
+
const first = actions[0]?.scope;
|
|
334
|
+
if (first === undefined) return undefined;
|
|
335
|
+
return actions.every((action) => action.scope === first) ? first : undefined;
|
|
336
|
+
}
|
|
337
|
+
|
|
296
338
|
/** A description authored across lines is still one catalog line. */
|
|
297
339
|
const oneLine = (text: string): string => text.replace(/\s+/g, ' ').trim();
|
|
298
340
|
|
|
299
341
|
function headOf(tool: AnyMcpTool): Omit<MetaAction, 'params'> {
|
|
300
342
|
return {
|
|
301
343
|
name: tool.name,
|
|
302
|
-
// `===
|
|
303
|
-
//
|
|
304
|
-
kind: tool.destructive ===
|
|
344
|
+
// `=== false`, as `ToolRegistry.verbClass` meters it — one answer per tool. An omitted flag is
|
|
345
|
+
// an action: the header's "untagged = read-only query" holds only for a DECLARED read.
|
|
346
|
+
kind: tool.destructive === false ? 'query' : 'action',
|
|
305
347
|
description: tool.description,
|
|
306
348
|
...(tool.confirms === true ? { confirms: true } : {}),
|
|
307
349
|
...(tool.scope === undefined ? {} : { scope: tool.scope }),
|
|
@@ -312,27 +354,44 @@ function sorted<V>(map: ReadonlyMap<string, V>): readonly [string, V][] {
|
|
|
312
354
|
return [...map.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
313
355
|
}
|
|
314
356
|
|
|
315
|
-
/**
|
|
316
|
-
const
|
|
357
|
+
/** Fields a hint names before `…` — every required one is named regardless. */
|
|
358
|
+
const HINT_FIELDS = 4;
|
|
359
|
+
/** Optional fields stop being added once the hint is this long; required ones never stop. */
|
|
360
|
+
const HINT_MAX = 100;
|
|
361
|
+
/** Literals an enum hint names before `|…`. */
|
|
362
|
+
const HINT_LITERALS = 8;
|
|
317
363
|
|
|
318
364
|
/**
|
|
319
365
|
* `name: type` per property, `?` when optional, an enum as its literals — the hint
|
|
320
366
|
* `list_resources` carries so a common call needs no `describe_resource` round-trip.
|
|
367
|
+
*
|
|
368
|
+
* Cut by FIELDS, never by characters (#590): a character cut sliced `limit?: integer` into
|
|
369
|
+
* `limit?: i…`. Every required field is named, in declared order, because a call without one
|
|
370
|
+
* fails; optional fields follow until `HINT_FIELDS` names or `HINT_MAX` characters, and a trailing
|
|
371
|
+
* `…` says some were left out. `describe_resource` always has the full schema.
|
|
321
372
|
*/
|
|
322
373
|
export function oneLineParams(schema: JsonSchema): string {
|
|
323
374
|
const required = new Set(schema.required ?? []);
|
|
324
|
-
const
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
375
|
+
const fields = Object.entries(schema.properties ?? {}).map(([key, child]) => ({
|
|
376
|
+
required: required.has(key),
|
|
377
|
+
text: `${key}${required.has(key) ? '' : '?'}: ${typeHint(child)}`,
|
|
378
|
+
}));
|
|
379
|
+
const named = new Set(fields.filter((field) => field.required));
|
|
380
|
+
let length = [...named].reduce((sum, field) => sum + field.text.length + 2, 0);
|
|
381
|
+
for (const field of fields) {
|
|
382
|
+
if (field.required) continue;
|
|
383
|
+
if (named.size >= HINT_FIELDS || length + field.text.length > HINT_MAX) break;
|
|
384
|
+
named.add(field);
|
|
385
|
+
length += field.text.length + 2;
|
|
386
|
+
}
|
|
387
|
+
const parts = fields.filter((field) => named.has(field)).map((field) => field.text);
|
|
388
|
+
return named.size < fields.length ? [...parts, '…'].join(', ') : parts.join(', ');
|
|
330
389
|
}
|
|
331
390
|
|
|
332
391
|
function typeHint(schema: JsonSchema): string {
|
|
333
|
-
if (schema.enum !== undefined) return schema.enum.map((v) => JSON.stringify(v))
|
|
392
|
+
if (schema.enum !== undefined) return literals(schema.enum.map((v) => JSON.stringify(v)));
|
|
334
393
|
if (schema.const !== undefined) return JSON.stringify(schema.const);
|
|
335
|
-
if (schema.anyOf !== undefined) return schema.anyOf.map(typeHint)
|
|
394
|
+
if (schema.anyOf !== undefined) return literals(schema.anyOf.map(typeHint));
|
|
336
395
|
if (schema.type === 'array') {
|
|
337
396
|
if (schema.items === undefined) return 'array';
|
|
338
397
|
const item = typeHint(schema.items);
|
|
@@ -340,3 +399,9 @@ function typeHint(schema: JsonSchema): string {
|
|
|
340
399
|
}
|
|
341
400
|
return schema.type ?? 'any';
|
|
342
401
|
}
|
|
402
|
+
|
|
403
|
+
/** A union cut at an alternative, never inside one. */
|
|
404
|
+
function literals(alternatives: readonly string[]): string {
|
|
405
|
+
if (alternatives.length <= HINT_LITERALS) return alternatives.join('|');
|
|
406
|
+
return [...alternatives.slice(0, HINT_LITERALS), '…'].join('|');
|
|
407
|
+
}
|
package/src/mount-errors.ts
CHANGED
|
@@ -23,7 +23,7 @@ export class McpPathDuplicateError extends UltimateError {
|
|
|
23
23
|
super({
|
|
24
24
|
code: 'X_MCP_PATH_DUPLICATE',
|
|
25
25
|
cause: `${input.file} exports mcp endpoints #${first} and #${second}, and both claim ${input.method} ${input.path}`,
|
|
26
|
-
fix: `give endpoint #${second} in ${input.file} its own path — defineAppMcp({ path: '/mcp/<population>', ... }); endpoint
|
|
26
|
+
fix: `give endpoint #${second} in ${input.file} its own path — defineAppMcp({ path: '/mcp/<population>', ... }); every endpoint mounts at its own defineAppMcp path (default /mcp)`,
|
|
27
27
|
});
|
|
28
28
|
this.path = input.path;
|
|
29
29
|
this.endpoints = input.endpoints;
|
package/src/oauth-metadata.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// authorization server(s) a client goes to for a token. The authorization server itself is the
|
|
5
5
|
// APP's — its consent page, its token endpoint, its revocation list — never this package's.
|
|
6
6
|
|
|
7
|
-
import { McpOAuthInvalidError } from './errors';
|
|
7
|
+
import { McpOAuthInvalidError } from './errors-transport';
|
|
8
8
|
|
|
9
9
|
/** The well-known suffix RFC 9728 §3 defines. */
|
|
10
10
|
export const PROTECTED_RESOURCE_WELL_KNOWN = '/.well-known/oauth-protected-resource';
|
package/src/projectable.ts
CHANGED
|
@@ -4,28 +4,20 @@
|
|
|
4
4
|
// primitive out is a different way to NAME a tool, never a second way to run one.
|
|
5
5
|
|
|
6
6
|
import type { AnyAction } from '@ultimat3/action';
|
|
7
|
+
import { actionName, guardActionBeforeInput, invoke, isAction } from '@ultimat3/action';
|
|
7
8
|
import {
|
|
8
|
-
|
|
9
|
-
actionName,
|
|
9
|
+
type Actor,
|
|
10
10
|
actorOf,
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
isMcpExposed,
|
|
12
|
+
type McpExposureDeclaration,
|
|
13
|
+
useContext,
|
|
14
|
+
} from '@ultimat3/core';
|
|
15
15
|
import type { AnyQuery } from '@ultimat3/query';
|
|
16
|
-
import {
|
|
17
|
-
isQuery,
|
|
18
|
-
guardBeforeInput as queryGuardBeforeInput,
|
|
19
|
-
queryName,
|
|
20
|
-
readAnswer,
|
|
21
|
-
sourceFor,
|
|
22
|
-
} from '@ultimat3/query';
|
|
16
|
+
import { guardQueryBeforeInput, isQuery, queryName, readAnswer, sourceFor } from '@ultimat3/query';
|
|
23
17
|
import { toWireOutputSchema, toWireSchema } from '@ultimat3/schema';
|
|
24
18
|
import { asCallerContext } from './caller-context';
|
|
25
|
-
import type {
|
|
19
|
+
import type { ProjectablePrimitive } from './from-action';
|
|
26
20
|
import { takeIdempotencyKeyArg, withIdempotencyKeyArg } from './idempotency-arg';
|
|
27
|
-
import type { McpListParams } from './list-params';
|
|
28
|
-
import type { McpToolAnnotations } from './registry';
|
|
29
21
|
import type { JsonSchema } from './wire';
|
|
30
22
|
|
|
31
23
|
/**
|
|
@@ -33,7 +25,7 @@ import type { JsonSchema } from './wire';
|
|
|
33
25
|
*
|
|
34
26
|
* The real primitives come first because they are what an app writes: `actions: [publishPost]`.
|
|
35
27
|
* Until 2026-08 this list took `ProjectablePrimitive` alone, which no `action()` or `query()`
|
|
36
|
-
* structurally satisfies — they carry `as
|
|
28
|
+
* structurally satisfies — they carry `as` and their projections, never `run` — so the only value that could
|
|
37
29
|
* reach `X_MCP_TOOL_UNDECLARED` was a hand-built fake, and the gate refused nothing an app could
|
|
38
30
|
* actually declare. `ProjectablePrimitive` stays in the union for surfaces that build their
|
|
39
31
|
* catalog programmatically (`@ultimat3/admin`) and for tests that project a stand-in.
|
|
@@ -75,7 +67,7 @@ export function primitiveFromAction(target: AnyAction): ProjectablePrimitive {
|
|
|
75
67
|
admit: (actor: Actor) =>
|
|
76
68
|
asCallerContext(actor, () => {
|
|
77
69
|
const ctx = useContext();
|
|
78
|
-
|
|
70
|
+
guardActionBeforeInput(target.policy, { actor: actorOf(ctx), ctx, action: name }, 'mcp');
|
|
79
71
|
}),
|
|
80
72
|
// The actor rides in on the options: `invoke` swaps it inside the one execution path.
|
|
81
73
|
run: keyed
|
|
@@ -90,7 +82,7 @@ export function primitiveFromAction(target: AnyAction): ProjectablePrimitive {
|
|
|
90
82
|
export function primitiveFromQuery(target: AnyQuery): ProjectablePrimitive {
|
|
91
83
|
const exposure = exposureOf(target.mcp);
|
|
92
84
|
// A `single: true` read answers ONE row, or not-found — what its route answers (404) and what
|
|
93
|
-
// `
|
|
85
|
+
// `@ultimat3/query`'s `readAnswer` makes every read answer. Handing an agent `[]` for a missing id said "found, and empty".
|
|
94
86
|
const single = target.single === true;
|
|
95
87
|
// Only a query that DECLARED its rows (`rows: Post.$schema`) has a known answer shape. A list
|
|
96
88
|
// read answers the rows as a list, so the structured copy is `{ rows }` — `structuredContent`
|
|
@@ -110,7 +102,7 @@ export function primitiveFromQuery(target: AnyQuery): ProjectablePrimitive {
|
|
|
110
102
|
admit: (actor: Actor) =>
|
|
111
103
|
asCallerContext(actor, () => {
|
|
112
104
|
const ctx = useContext();
|
|
113
|
-
|
|
105
|
+
guardQueryBeforeInput(target.policy, { actor: actorOf(ctx), ctx, query: name }, 'server');
|
|
114
106
|
}),
|
|
115
107
|
run: ({ input, actor }) =>
|
|
116
108
|
asCallerContext(actor, async () => {
|
|
@@ -118,7 +110,8 @@ export function primitiveFromQuery(target: AnyQuery): ProjectablePrimitive {
|
|
|
118
110
|
// and is what `live`, `paginate` and `explain` build on too. Executed without the
|
|
119
111
|
// cache tiers on purpose: an agent diffing two tool calls must be reading the rows,
|
|
120
112
|
// not a TTL.
|
|
121
|
-
|
|
113
|
+
// `'mcp'`: an agent's call spends the read's declared `rateLimit:`, the one bucket its route draws on.
|
|
114
|
+
const source = await sourceFor(target, input, { surface: 'mcp' });
|
|
122
115
|
// `@ultimat3/query`'s one rule for what a read answers: rows, or a single read's row.
|
|
123
116
|
return readAnswer(target, await source.execute());
|
|
124
117
|
}),
|
|
@@ -138,7 +131,7 @@ export function toRowsOutputSchema(rows: unknown): JsonSchema | undefined {
|
|
|
138
131
|
}
|
|
139
132
|
|
|
140
133
|
/**
|
|
141
|
-
* An action and a query declare
|
|
134
|
+
* An action and a query declare core's ONE `McpExposureDeclaration`, so one typed path reads
|
|
142
135
|
* both. Narrow on purpose, through `@ultimat3/core`'s `isMcpExposed`: only a literal
|
|
143
136
|
* `expose: true` counts, so nothing is exposed by accident — an undeclared `mcp` block yields
|
|
144
137
|
* no exposure at all.
|
|
@@ -149,7 +142,9 @@ export function toRowsOutputSchema(rows: unknown): JsonSchema | undefined {
|
|
|
149
142
|
* projected tool was visible to every caller and the first outcome existed only for the
|
|
150
143
|
* hand-written tools that build their own `McpTool`.
|
|
151
144
|
*/
|
|
152
|
-
function exposureOf(
|
|
145
|
+
function exposureOf(
|
|
146
|
+
declared: McpExposureDeclaration | undefined,
|
|
147
|
+
): McpExposureDeclaration | undefined {
|
|
153
148
|
if (declared === undefined) return undefined;
|
|
154
149
|
return {
|
|
155
150
|
expose: isMcpExposed(declared),
|
|
@@ -160,14 +155,3 @@ function exposureOf(declared: DeclaredMcp | undefined): McpExposure | undefined
|
|
|
160
155
|
...(declared.annotations === undefined ? {} : { annotations: declared.annotations }),
|
|
161
156
|
};
|
|
162
157
|
}
|
|
163
|
-
|
|
164
|
-
/** `ActionMcp` and `QueryMcp` are the same shape; restating it binds to neither. */
|
|
165
|
-
interface DeclaredMcp {
|
|
166
|
-
readonly expose: boolean;
|
|
167
|
-
readonly description?: string;
|
|
168
|
-
readonly visibleTo?: readonly string[];
|
|
169
|
-
/** `QueryMcp` only; an action has no list to compose. */
|
|
170
|
-
readonly listParams?: McpListParams;
|
|
171
|
-
readonly title?: string;
|
|
172
|
-
readonly annotations?: McpToolAnnotations;
|
|
173
|
-
}
|
package/src/registry.ts
CHANGED
|
@@ -131,9 +131,10 @@ export interface McpTool<A extends ToolArgs = ToolArgs> {
|
|
|
131
131
|
/** Who may see and call this tool. Absent = everyone. See `McpVisibility`. */
|
|
132
132
|
readonly visibleTo?: McpVisibility;
|
|
133
133
|
/**
|
|
134
|
-
* Marks a tool that changes state. Drives the transport's rate-limit bucket and
|
|
135
|
-
*
|
|
136
|
-
*
|
|
134
|
+
* Marks a tool that changes state. Drives the transport's rate-limit bucket and the meta
|
|
135
|
+
* catalog's `(action)` tag. Only `false` declares a read: an OMITTED flag counts as a write
|
|
136
|
+
* (`As of 2026-10`), as a projected action and an app tool already default — so a new mutating
|
|
137
|
+
* tool cannot be metered or listed as cheap read chatter by omission.
|
|
137
138
|
*/
|
|
138
139
|
readonly destructive?: boolean;
|
|
139
140
|
/**
|
|
@@ -309,7 +310,7 @@ export class ToolRegistry {
|
|
|
309
310
|
verbClass(name: string): McpVerbClass {
|
|
310
311
|
const tool = this.#tools.get(name);
|
|
311
312
|
if (tool === undefined) return 'write';
|
|
312
|
-
return tool.destructive ===
|
|
313
|
+
return tool.destructive === false ? 'read' : 'write';
|
|
313
314
|
}
|
|
314
315
|
}
|
|
315
316
|
|
|
@@ -326,7 +327,7 @@ export function textResult(text: string, isError = false): McpToolResult {
|
|
|
326
327
|
* spent a third of a large answer on indentation (a staff catalog measured 32.5k characters).
|
|
327
328
|
* `JSON.stringify` is deterministic for one value, so two calls still diff.
|
|
328
329
|
*
|
|
329
|
-
* TOTAL, because the value is an app's: `
|
|
330
|
+
* TOTAL, because the value is an app's: `toolFrom` hands an action's own return value
|
|
330
331
|
* straight here, and `JSON.stringify` answers `undefined` for a handler that returned nothing —
|
|
331
332
|
* a `text` that is not a string is an invalid MCP frame — and THROWS on a bigint, a cycle or a
|
|
332
333
|
* `toJSON` the value carries. A throw would leave the server's catch reporting a bug in the tool
|
package/src/scopes.ts
CHANGED
|
@@ -4,13 +4,13 @@
|
|
|
4
4
|
// other is its authz rule, and both belong beside the code they guard. A SCOPE is neither.
|
|
5
5
|
// It is a capability of the CONNECTION — what the token was issued to do — so it is grouped
|
|
6
6
|
// here, once per scope, naming the tools that capability covers. That is also why
|
|
7
|
-
// `
|
|
7
|
+
// `toolFrom` never invents one: a projection cannot know what a token means.
|
|
8
8
|
//
|
|
9
9
|
// Without this map the second outcome is unreachable for a generated app. `ToolRegistry`
|
|
10
10
|
// enforces `scope`, the framework's own dev tools declare one, and until 2026-08 nothing an
|
|
11
11
|
// app could write ever set the field — an enforced gate no app declaration could engage.
|
|
12
12
|
|
|
13
|
-
import { McpScopeConflictError, McpScopeUnknownError } from './errors';
|
|
13
|
+
import { McpScopeConflictError, McpScopeUncoveredError, McpScopeUnknownError } from './errors';
|
|
14
14
|
import type { AnyMcpTool } from './registry';
|
|
15
15
|
|
|
16
16
|
/**
|
|
@@ -25,14 +25,19 @@ export type McpScopes = Readonly<Record<string, readonly string[]>>;
|
|
|
25
25
|
/**
|
|
26
26
|
* Attach each declared scope to the tool it covers, refusing anything ambiguous at BOOT.
|
|
27
27
|
*
|
|
28
|
-
*
|
|
28
|
+
* Three refusals, because each silently ships an ungated tool otherwise:
|
|
29
29
|
*
|
|
30
30
|
* - a name no tool in the catalog answers to (`X_MCP_SCOPE_UNKNOWN`) — a typo, or a primitive
|
|
31
31
|
* that was renamed or never listed. Skipping it leaves the tool reachable with no scope at
|
|
32
32
|
* all, which is the opposite of what the author wrote;
|
|
33
33
|
* - one tool claimed by two scopes (`X_MCP_SCOPE_CONFLICT`) — a tool carries ONE scope, so
|
|
34
34
|
* the second claim would either overwrite the first or be dropped, and which one wins would
|
|
35
|
-
* be decided by object key order
|
|
35
|
+
* be decided by object key order;
|
|
36
|
+
* - a projected tool no scope names (`X_MCP_SCOPE_UNCOVERED`). The registry gates a tool only
|
|
37
|
+
* when it carries a scope, so an unnamed one answered EVERY token — while `bearerMount`, handed
|
|
38
|
+
* the same map, serves an unnamed primitive to nobody. Giving a map is declaring the cut; a
|
|
39
|
+
* tool outside it is a mistake, not a public tool. A tool that arrived with its own `scope`
|
|
40
|
+
* (a ready `McpTool`) is covered by it.
|
|
36
41
|
*
|
|
37
42
|
* Runs AFTER duplicate names are refused, so a name resolves to exactly one tool.
|
|
38
43
|
*/
|
|
@@ -64,6 +69,12 @@ export function withScopes(
|
|
|
64
69
|
}
|
|
65
70
|
}
|
|
66
71
|
|
|
72
|
+
const uncovered = tools
|
|
73
|
+
.filter((tool) => tool.scope === undefined && !required.has(tool.name))
|
|
74
|
+
.map((tool) => tool.name)
|
|
75
|
+
.sort();
|
|
76
|
+
if (uncovered.length > 0) throw new McpScopeUncoveredError({ uncovered });
|
|
77
|
+
|
|
67
78
|
return tools.map((tool) => {
|
|
68
79
|
const scope = required.get(tool.name);
|
|
69
80
|
return scope === undefined ? tool : { ...tool, scope };
|
package/src/server-voice.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { formatIssues } from '@ultimat3/schema';
|
|
|
8
8
|
import type { McpCaller, McpToolResult } from './registry';
|
|
9
9
|
import type { ArgIssue } from './validate-args';
|
|
10
10
|
|
|
11
|
-
/** See `
|
|
11
|
+
/** See `McpServerInput.instructions`. */
|
|
12
12
|
export type McpInstructions = string | ((caller: McpCaller) => string | undefined);
|
|
13
13
|
|
|
14
14
|
/** The server's per-caller wording: `initialize`'s advice and the audience of every `fix:` line. */
|
package/src/server.ts
CHANGED
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
// and an already-resolved caller, and returns a response or `null` for a notification.
|
|
4
4
|
// Both transports (http, stdio) and every test drive this one function.
|
|
5
5
|
|
|
6
|
-
import type { ErrorAudience } from '@ultimat3/core';
|
|
6
|
+
import type { Clock, ErrorAudience } from '@ultimat3/core';
|
|
7
7
|
import { fixFor } from '@ultimat3/core';
|
|
8
|
-
import {
|
|
8
|
+
import { outcomeForResult } from './audit';
|
|
9
|
+
import type { McpAuditHook, McpAuditor } from './audit-hook';
|
|
10
|
+
import { LOG_ONLY_AUDITOR, mcpAuditor } from './audit-hook';
|
|
9
11
|
import { McpProtocolError, McpScopeDeniedError, TOOL_UNKNOWN_FIX } from './errors';
|
|
10
12
|
import { asFrameworkError, forAudience } from './framework-error';
|
|
11
13
|
import { metaCall } from './meta-call';
|
|
@@ -27,7 +29,7 @@ import { ResourceRegistry } from './resources';
|
|
|
27
29
|
import type { McpInstructions, McpServerVoice } from './server-voice';
|
|
28
30
|
import { instructionsFor, invalidArgsResult } from './server-voice';
|
|
29
31
|
import { admitted, thrownResponse } from './tool-thrown';
|
|
30
|
-
import type { JsonRpcId, JsonRpcRequest, JsonRpcResponse, ServerInfo } from './wire';
|
|
32
|
+
import type { JsonRpcId, JsonRpcRequest, JsonRpcResponse, McpWire, ServerInfo } from './wire';
|
|
31
33
|
import {
|
|
32
34
|
defaultServerInfo,
|
|
33
35
|
errorResponse,
|
|
@@ -38,28 +40,14 @@ import {
|
|
|
38
40
|
isNotification,
|
|
39
41
|
MCP_PROTOCOL_VERSION,
|
|
40
42
|
METHOD_NOT_FOUND,
|
|
43
|
+
messageUnitOf,
|
|
41
44
|
paramsOf,
|
|
42
45
|
resultResponse,
|
|
43
46
|
} from './wire';
|
|
44
47
|
|
|
45
|
-
|
|
46
|
-
* Which wire one message arrived on, so a refusal can name the unit THAT transport counts in.
|
|
47
|
-
* `handle` is transport-independent and stays so — this is a hint for the wording of one fix, never
|
|
48
|
-
* a branch in dispatch. HTTP carries its mounted path because `mcpHttpRoute({ path })` is a knob:
|
|
49
|
-
* a batch refusal that told every client to retry `POST /mcp` was wrong for an app mounted at
|
|
50
|
-
* `/app-mcp`, which is how this came to be threaded rather than spelled.
|
|
51
|
-
*/
|
|
52
|
-
export type McpWire =
|
|
53
|
-
| { readonly transport: 'http'; readonly path: string }
|
|
54
|
-
| { readonly transport: 'stdio' };
|
|
55
|
-
|
|
56
|
-
/** The unit a transport counts one request in — or the neutral word when no wire said. */
|
|
57
|
-
const messageUnitOf = (wire: McpWire | undefined): string => {
|
|
58
|
-
if (wire === undefined) return 'message';
|
|
59
|
-
return wire.transport === 'http' ? `POST ${wire.path}` : 'line';
|
|
60
|
-
};
|
|
48
|
+
export type { McpWire } from './wire';
|
|
61
49
|
|
|
62
|
-
export interface
|
|
50
|
+
export interface McpServerInput {
|
|
63
51
|
readonly tools?: readonly AnyMcpTool[];
|
|
64
52
|
readonly resources?: readonly McpResource[];
|
|
65
53
|
readonly prompts?: readonly McpPrompt[];
|
|
@@ -82,6 +70,10 @@ export interface CreateMcpServerInput {
|
|
|
82
70
|
* serves `'caller'`.
|
|
83
71
|
*/
|
|
84
72
|
readonly errorAudience?: ErrorAudience | undefined;
|
|
73
|
+
/** Every gate decision as data, after its log line — see `audit-hook.ts`. The route reads it too. */
|
|
74
|
+
readonly onAudit?: McpAuditHook | undefined;
|
|
75
|
+
/** Stamps each `onAudit` event's `at`. */
|
|
76
|
+
readonly clock?: Clock | undefined;
|
|
85
77
|
}
|
|
86
78
|
|
|
87
79
|
/** The set of JSON-RPC methods this server answers. Kept in sync with `classify`. */
|
|
@@ -96,7 +88,7 @@ const METHODS = [
|
|
|
96
88
|
'prompts/get',
|
|
97
89
|
] as const;
|
|
98
90
|
|
|
99
|
-
export function
|
|
91
|
+
export function mcpServer(input: McpServerInput = {}): McpServer {
|
|
100
92
|
const tools = new ToolRegistry().registerAll(input.tools ?? []);
|
|
101
93
|
const resources = new ResourceRegistry().registerAll(input.resources ?? []);
|
|
102
94
|
const meta = MetaSurface.build({
|
|
@@ -111,6 +103,9 @@ export function createMcpServer(input: CreateMcpServerInput = {}): McpServer {
|
|
|
111
103
|
input.serverInfo ?? defaultServerInfo(),
|
|
112
104
|
meta,
|
|
113
105
|
{ instructions: input.instructions, errorAudience: input.errorAudience ?? 'developer' },
|
|
106
|
+
input.onAudit === undefined && input.clock === undefined
|
|
107
|
+
? LOG_ONLY_AUDITOR
|
|
108
|
+
: mcpAuditor({ onAudit: input.onAudit, clock: input.clock }),
|
|
114
109
|
);
|
|
115
110
|
}
|
|
116
111
|
|
|
@@ -122,6 +117,8 @@ export class McpServer {
|
|
|
122
117
|
/** `undefined` for a flat-only server — every existing app, byte for byte. */
|
|
123
118
|
private readonly meta: MetaSurface | undefined;
|
|
124
119
|
private readonly voice: McpServerVoice;
|
|
120
|
+
/** Where every decision is audited — the log line, then `onAudit`. Public for the HTTP route. */
|
|
121
|
+
readonly audit: McpAuditor;
|
|
125
122
|
|
|
126
123
|
constructor(
|
|
127
124
|
tools: ToolRegistry,
|
|
@@ -130,6 +127,7 @@ export class McpServer {
|
|
|
130
127
|
serverInfo: ServerInfo,
|
|
131
128
|
meta?: MetaSurface,
|
|
132
129
|
voice: McpServerVoice = { errorAudience: 'developer' },
|
|
130
|
+
audit: McpAuditor = LOG_ONLY_AUDITOR,
|
|
133
131
|
) {
|
|
134
132
|
this.tools = tools;
|
|
135
133
|
this.resources = resources;
|
|
@@ -137,6 +135,7 @@ export class McpServer {
|
|
|
137
135
|
this.serverInfo = serverInfo;
|
|
138
136
|
this.meta = meta;
|
|
139
137
|
this.voice = voice;
|
|
138
|
+
this.audit = audit;
|
|
140
139
|
}
|
|
141
140
|
|
|
142
141
|
async handle(
|
|
@@ -275,6 +274,7 @@ export class McpServer {
|
|
|
275
274
|
const answered = await metaCall(
|
|
276
275
|
{
|
|
277
276
|
tools: this.tools,
|
|
277
|
+
audit: this.audit,
|
|
278
278
|
dispatch: (at, tool, resolved, who) => this.dispatch(at, tool, resolved, who),
|
|
279
279
|
notFound: (at, tool, who, fix) => this.notFound(at, tool, who, fix),
|
|
280
280
|
},
|
|
@@ -293,7 +293,7 @@ export class McpServer {
|
|
|
293
293
|
|
|
294
294
|
/** OUTCOME 1, the one answer for absent and hidden alike. See `dispatch`. */
|
|
295
295
|
private notFound(id: JsonRpcId, name: string, caller: McpCaller, fix: string): JsonRpcResponse {
|
|
296
|
-
|
|
296
|
+
this.audit.toolCall({ tool: name, outcome: 'hidden', caller, code: 'X_MCP_TOOL_UNKNOWN' });
|
|
297
297
|
return errorResponse(id, METHOD_NOT_FOUND, `tool not found: ${name} — ${fix}`);
|
|
298
298
|
}
|
|
299
299
|
|
|
@@ -327,7 +327,7 @@ export class McpServer {
|
|
|
327
327
|
// nothing — and the fix travels with it, built by the error that owns the wording.
|
|
328
328
|
case 'scope-denied': {
|
|
329
329
|
const denial = new McpScopeDeniedError({ name, scope: resolved.scope });
|
|
330
|
-
|
|
330
|
+
this.audit.toolCall({
|
|
331
331
|
tool: name,
|
|
332
332
|
outcome: 'scope-denied',
|
|
333
333
|
caller,
|
|
@@ -346,9 +346,10 @@ export class McpServer {
|
|
|
346
346
|
// they send gets the denial `handle` would give, never the issue list — which describes
|
|
347
347
|
// the arguments of a tool they may not call.
|
|
348
348
|
const refused = admitted(resolved.tool, caller);
|
|
349
|
-
if (refused !== undefined)
|
|
349
|
+
if (refused !== undefined)
|
|
350
|
+
return thrownResponse(id, name, refused, caller, audience, this.audit);
|
|
350
351
|
const invalid = invalidArgsResult(name, resolved.issues, audience);
|
|
351
|
-
|
|
352
|
+
this.audit.toolCall({ tool: name, outcome: 'invalid-args', caller, code: invalid.code });
|
|
352
353
|
return resultResponse(id, invalid.result);
|
|
353
354
|
}
|
|
354
355
|
case 'ok':
|
|
@@ -359,13 +360,13 @@ export class McpServer {
|
|
|
359
360
|
try {
|
|
360
361
|
result = await resolved.tool.handle(resolved.args, caller);
|
|
361
362
|
} catch (error) {
|
|
362
|
-
return thrownResponse(id, name, error, caller, audience);
|
|
363
|
+
return thrownResponse(id, name, error, caller, audience, this.audit);
|
|
363
364
|
}
|
|
364
365
|
|
|
365
366
|
// A tool may answer `isError` itself (admin renders its own denial). Outcome 3 unless it
|
|
366
367
|
// NAMED the code it refused with, in which case the same classifier a thrown error goes
|
|
367
368
|
// through decides — a tool's own argument check is not a denial a prober drove.
|
|
368
|
-
|
|
369
|
+
this.audit.toolCall({
|
|
369
370
|
tool: name,
|
|
370
371
|
outcome: outcomeForResult(result),
|
|
371
372
|
caller,
|
|
@@ -406,7 +407,7 @@ export class McpServer {
|
|
|
406
407
|
// `code` on the audit line either, because the wire carries none — the tool surface's
|
|
407
408
|
// `X_MCP_TOOL_UNKNOWN` is an error class this branch has no twin for.
|
|
408
409
|
case 'not-found':
|
|
409
|
-
|
|
410
|
+
this.audit.resourceRead({ uri, outcome: 'hidden', caller });
|
|
410
411
|
return errorResponse(id, METHOD_NOT_FOUND, `resource not found: ${uri}`);
|
|
411
412
|
// OUTCOME 2. The caller can already see this resource, so naming the missing scope leaks
|
|
412
413
|
// nothing — and the fix travels with it, built by the error that owns the wording.
|
|
@@ -416,7 +417,7 @@ export class McpServer {
|
|
|
416
417
|
scope: resolved.scope,
|
|
417
418
|
subject: 'resource',
|
|
418
419
|
});
|
|
419
|
-
|
|
420
|
+
this.audit.resourceRead({
|
|
420
421
|
uri,
|
|
421
422
|
outcome: 'scope-denied',
|
|
422
423
|
caller,
|
|
@@ -444,16 +445,16 @@ export class McpServer {
|
|
|
444
445
|
if (contents === undefined) {
|
|
445
446
|
// The resolver said `ok` and the registry then had nothing: a bug here, not a walk, so it
|
|
446
447
|
// is `failed` in the log while the caller still gets the same not-found it would have.
|
|
447
|
-
|
|
448
|
+
this.audit.resourceRead({ uri, outcome: 'failed', caller });
|
|
448
449
|
return errorResponse(id, METHOD_NOT_FOUND, `resource not found: ${uri}`);
|
|
449
450
|
}
|
|
450
|
-
|
|
451
|
+
this.audit.resourceRead({ uri, outcome: 'ok', caller });
|
|
451
452
|
return resultResponse(id, { contents: [contents] });
|
|
452
453
|
} catch (error) {
|
|
453
454
|
const thrown = asFrameworkError(error);
|
|
454
455
|
if (thrown !== undefined) {
|
|
455
456
|
const framework = forAudience(thrown, this.voice.errorAudience);
|
|
456
|
-
|
|
457
|
+
this.audit.resourceRead({ uri, outcome: 'failed', caller, code: framework.code });
|
|
457
458
|
return errorResponse(id, INTERNAL_ERROR, `resource "${uri}" could not be read`, {
|
|
458
459
|
code: framework.code,
|
|
459
460
|
cause: framework.cause,
|
|
@@ -463,7 +464,7 @@ export class McpServer {
|
|
|
463
464
|
}
|
|
464
465
|
// No internals: a provider's own message names a path, a query or a host the caller has no
|
|
465
466
|
// business seeing, exactly as a failing tool's does.
|
|
466
|
-
|
|
467
|
+
this.audit.resourceRead({ uri, outcome: 'failed', caller });
|
|
467
468
|
return errorResponse(id, INTERNAL_ERROR, `resource "${uri}" could not be read`);
|
|
468
469
|
}
|
|
469
470
|
}
|
package/src/tool-thrown.ts
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
// raised it. Split from `server.ts` for the file ceiling; the server is its only caller.
|
|
4
4
|
|
|
5
5
|
import type { ErrorAudience } from '@ultimat3/core';
|
|
6
|
-
import {
|
|
6
|
+
import { outcomeForCode } from './audit';
|
|
7
|
+
import type { McpAuditor } from './audit-hook';
|
|
7
8
|
import { asFrameworkError, renderFrameworkError } from './framework-error';
|
|
8
9
|
import type { AnyMcpTool, McpCaller } from './registry';
|
|
9
10
|
import type { JsonRpcId, JsonRpcResponse } from './wire';
|
|
@@ -21,10 +22,11 @@ export function thrownResponse(
|
|
|
21
22
|
error: unknown,
|
|
22
23
|
caller: McpCaller,
|
|
23
24
|
audience: ErrorAudience,
|
|
25
|
+
audit: McpAuditor,
|
|
24
26
|
): JsonRpcResponse {
|
|
25
27
|
const framework = asFrameworkError(error);
|
|
26
28
|
if (framework !== undefined) {
|
|
27
|
-
|
|
29
|
+
audit.toolCall({
|
|
28
30
|
tool: name,
|
|
29
31
|
outcome: outcomeForCode(framework.code),
|
|
30
32
|
caller,
|
|
@@ -35,7 +37,7 @@ export function thrownResponse(
|
|
|
35
37
|
isError: true,
|
|
36
38
|
});
|
|
37
39
|
}
|
|
38
|
-
|
|
40
|
+
audit.toolCall({ tool: name, outcome: 'failed', caller });
|
|
39
41
|
return errorResponse(id, INTERNAL_ERROR, `tool "${name}" failed unexpectedly`);
|
|
40
42
|
}
|
|
41
43
|
|