@ultimat3/mcp 22.9.1 → 22.11.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 +5 -0
- package/README.md +44 -4
- package/package.json +9 -9
- package/src/app-tool.ts +23 -4
- package/src/app-tools.ts +27 -0
- package/src/errors.ts +25 -0
- package/src/framework-error.ts +44 -7
- package/src/from-action.ts +83 -3
- package/src/index.ts +16 -2
- package/src/input-schema.ts +63 -0
- package/src/meta-call.ts +12 -4
- package/src/meta-surface.ts +42 -0
- package/src/projectable.ts +42 -5
- package/src/registry.ts +118 -12
- package/src/server-voice.ts +64 -0
- package/src/server.ts +59 -33
- package/src/surface-budget.ts +113 -0
- package/src/tool-thrown.ts +51 -0
package/src/input-schema.ts
CHANGED
|
@@ -75,3 +75,66 @@ function narrowAdditional(value: boolean | RichJsonSchema | undefined): {
|
|
|
75
75
|
if (value === undefined) return {};
|
|
76
76
|
return { additionalProperties: typeof value === 'boolean' ? value : true };
|
|
77
77
|
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* A Standard Schema → the `outputSchema` a tool publishes, or `undefined` when its root is not an
|
|
81
|
+
* object (MCP's `structuredContent` is one) or it cannot be introspected.
|
|
82
|
+
*
|
|
83
|
+
* STRUCTURE ONLY — type, properties, required, items, enum, const, anyOf, title, description. A
|
|
84
|
+
* client validates `structuredContent` against this document and refuses the call on a miss, so
|
|
85
|
+
* every keyword here is a promise about a value the server produced: a bound, a pattern or
|
|
86
|
+
* `additionalProperties: false` on a query's rows (read from the database, never re-parsed) is a
|
|
87
|
+
* promise nothing enforces on the way out. Fewer constraints is the honest projection of an output.
|
|
88
|
+
*/
|
|
89
|
+
export function toOutputSchema(schema: unknown): JsonSchema | undefined {
|
|
90
|
+
let rich: RichJsonSchema;
|
|
91
|
+
try {
|
|
92
|
+
rich = toMcpInputSchema(schema);
|
|
93
|
+
} catch {
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
if (rich.type !== 'object') return undefined;
|
|
97
|
+
return shape(rich);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** `{ rows: [<row>] }` — a query's answer as `structuredContent`. `undefined` when no row schema. */
|
|
101
|
+
export function toRowsOutputSchema(rows: unknown): JsonSchema | undefined {
|
|
102
|
+
if (rows === undefined) return undefined;
|
|
103
|
+
const row = toOutputSchema(rows);
|
|
104
|
+
if (row === undefined) return undefined;
|
|
105
|
+
return {
|
|
106
|
+
type: 'object',
|
|
107
|
+
properties: { rows: { type: 'array', items: row } },
|
|
108
|
+
required: ['rows'],
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function shape(source: RichJsonSchema): JsonSchema {
|
|
113
|
+
return {
|
|
114
|
+
...(source.type === undefined ? {} : { type: source.type }),
|
|
115
|
+
...(source.title === undefined ? {} : { title: source.title }),
|
|
116
|
+
...(source.description === undefined ? {} : { description: source.description }),
|
|
117
|
+
...(source.properties === undefined ? {} : { properties: shapeProperties(source.properties) }),
|
|
118
|
+
...(source.required === undefined ? {} : { required: source.required }),
|
|
119
|
+
...(source.items === undefined ? {} : { items: shape(source.items) }),
|
|
120
|
+
...(source.enum === undefined ? {} : { enum: source.enum }),
|
|
121
|
+
...(source.const === undefined ? {} : { const: source.const }),
|
|
122
|
+
...(source.anyOf === undefined ? {} : { anyOf: source.anyOf.map(shape) }),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function shapeProperties(
|
|
127
|
+
properties: Readonly<Record<string, RichJsonSchema>>,
|
|
128
|
+
): Readonly<Record<string, JsonSchema>> {
|
|
129
|
+
const out: Record<string, JsonSchema> = {};
|
|
130
|
+
for (const [key, child] of Object.entries(properties)) {
|
|
131
|
+
// `narrowProperties`' reason: `out['__proto__'] = …` re-prototypes instead of adding a key.
|
|
132
|
+
Object.defineProperty(out, key, {
|
|
133
|
+
value: shape(child),
|
|
134
|
+
writable: true,
|
|
135
|
+
enumerable: true,
|
|
136
|
+
configurable: true,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
return out;
|
|
140
|
+
}
|
package/src/meta-call.ts
CHANGED
|
@@ -5,9 +5,15 @@
|
|
|
5
5
|
import { auditToolCall } from './audit';
|
|
6
6
|
import { META_UNKNOWN_FIX } from './meta-errors';
|
|
7
7
|
import type { MetaSurface } from './meta-surface';
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
DESCRIBE_RESOURCE,
|
|
10
|
+
LIST_RESOURCES,
|
|
11
|
+
META_TOOL_ENTRIES,
|
|
12
|
+
renderCatalog,
|
|
13
|
+
schemaOf,
|
|
14
|
+
} from './meta-surface';
|
|
9
15
|
import type { McpCaller, ToolRegistry, ToolResolution } from './registry';
|
|
10
|
-
import { jsonResult } from './registry';
|
|
16
|
+
import { jsonResult, textResult } from './registry';
|
|
11
17
|
import { validateArgs } from './validate-args';
|
|
12
18
|
import type { JsonRpcId, JsonRpcResponse } from './wire';
|
|
13
19
|
import { errorResponse, METHOD_NOT_FOUND, resultResponse } from './wire';
|
|
@@ -48,7 +54,7 @@ export async function metaCall(
|
|
|
48
54
|
}
|
|
49
55
|
if (name === LIST_RESOURCES) {
|
|
50
56
|
auditToolCall({ tool: name, outcome: 'ok', caller });
|
|
51
|
-
return resultResponse(id,
|
|
57
|
+
return resultResponse(id, textResult(renderCatalog(meta.listResources(caller))));
|
|
52
58
|
}
|
|
53
59
|
if (name === DESCRIBE_RESOURCE) {
|
|
54
60
|
const described = meta.describe(args.value['resources'] as readonly string[], caller);
|
|
@@ -77,7 +83,9 @@ export async function metaCall(
|
|
|
77
83
|
let resolved = host.tools.resolve(action, input, caller);
|
|
78
84
|
if (resolved.kind !== 'scope-denied' && resolved.kind !== 'not-found') {
|
|
79
85
|
const listed = validateArgs(schemaOf(tool), input);
|
|
80
|
-
if (!listed.ok)
|
|
86
|
+
if (!listed.ok) {
|
|
87
|
+
resolved = { kind: 'invalid-args', name: action, issues: listed.issues, tool };
|
|
88
|
+
}
|
|
81
89
|
}
|
|
82
90
|
return host.dispatch(id, action, resolved, caller);
|
|
83
91
|
}
|
package/src/meta-surface.ts
CHANGED
|
@@ -50,6 +50,7 @@ export const META_TOOL_NAMES: readonly string[] = [
|
|
|
50
50
|
export const META_TOOL_ENTRIES: readonly ToolListEntry[] = [
|
|
51
51
|
{
|
|
52
52
|
name: DESCRIBE_RESOURCE,
|
|
53
|
+
title: 'Describe resources',
|
|
53
54
|
description:
|
|
54
55
|
"Full input schemas for the actions of one or more resources (batched). Call it before manage_resource when list_resources' one-line params are not enough. Read-only.",
|
|
55
56
|
inputSchema: {
|
|
@@ -58,15 +59,19 @@ export const META_TOOL_ENTRIES: readonly ToolListEntry[] = [
|
|
|
58
59
|
required: ['resources'],
|
|
59
60
|
additionalProperties: false,
|
|
60
61
|
},
|
|
62
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
61
63
|
},
|
|
62
64
|
{
|
|
63
65
|
name: LIST_RESOURCES,
|
|
66
|
+
title: 'List resources',
|
|
64
67
|
description:
|
|
65
68
|
'The catalog: every resource this caller may use, each with its actions (query or action), a one-line parameter hint and whether a write waits for a human to confirm. Read-only.',
|
|
66
69
|
inputSchema: NO_ARGS,
|
|
70
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
67
71
|
},
|
|
68
72
|
{
|
|
69
73
|
name: MANAGE_RESOURCE,
|
|
74
|
+
title: 'Manage a resource',
|
|
70
75
|
description:
|
|
71
76
|
'Run one action of one resource: { resource, action, params }. Same policies, scopes and audit as calling the action directly. Writes may change state or send mail — read the action first.',
|
|
72
77
|
inputSchema: {
|
|
@@ -79,6 +84,9 @@ export const META_TOOL_ENTRIES: readonly ToolListEntry[] = [
|
|
|
79
84
|
required: ['resource', 'action'],
|
|
80
85
|
additionalProperties: false,
|
|
81
86
|
},
|
|
87
|
+
// The door to every grouped tool, reads and writes alike: a client has to assume the worst of
|
|
88
|
+
// it. `list_resources` says per action which is which.
|
|
89
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false },
|
|
82
90
|
},
|
|
83
91
|
];
|
|
84
92
|
|
|
@@ -254,6 +262,40 @@ export function schemaOf(tool: AnyMcpTool): JsonSchema {
|
|
|
254
262
|
: listParamsSchema(tool.listParams, tool.inputSchema);
|
|
255
263
|
}
|
|
256
264
|
|
|
265
|
+
/**
|
|
266
|
+
* `list_resources` as the model reads it: PLAIN TEXT, one line per action under one line per
|
|
267
|
+
* resource. The JSON form it answered until 22.10 repeated every key on every action and escaped
|
|
268
|
+
* every quote — a staff catalog measured 32.5k characters — and `JSON.parse` was never its reader's
|
|
269
|
+
* job: the model's is. Same facts, same order; `describe_resource` stays JSON because a schema is.
|
|
270
|
+
*
|
|
271
|
+
* ```text
|
|
272
|
+
* cases — Court cases of the account.
|
|
273
|
+
* listCases (query) {radicado?: string} — List the account's cases.
|
|
274
|
+
* closeCase (action; confirms; scope cases:write) {id: string} — Close a case.
|
|
275
|
+
* ```
|
|
276
|
+
*/
|
|
277
|
+
export function renderCatalog(resources: readonly MetaResource[]): string {
|
|
278
|
+
if (resources.length === 0) return 'No resources are available to this caller.';
|
|
279
|
+
const lines = [
|
|
280
|
+
`${resources.length} resource(s). Run one with manage_resource({resource, action, params}); describe_resource({resources:["<name>"]}) has the full input schemas.`,
|
|
281
|
+
];
|
|
282
|
+
for (const resource of resources) {
|
|
283
|
+
lines.push('', `${resource.name} — ${oneLine(resource.description)}`);
|
|
284
|
+
for (const action of resource.actions) {
|
|
285
|
+
const tags = [
|
|
286
|
+
action.kind,
|
|
287
|
+
...(action.confirms === true ? ['confirms'] : []),
|
|
288
|
+
...(action.scope === undefined ? [] : [`scope ${action.scope}`]),
|
|
289
|
+
].join('; ');
|
|
290
|
+
lines.push(` ${action.name} (${tags}) {${action.params}} — ${oneLine(action.description)}`);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return lines.join('\n');
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** A description authored across lines is still one catalog line. */
|
|
297
|
+
const oneLine = (text: string): string => text.replace(/\s+/g, ' ').trim();
|
|
298
|
+
|
|
257
299
|
function headOf(tool: AnyMcpTool): Omit<MetaAction, 'params'> {
|
|
258
300
|
return {
|
|
259
301
|
name: tool.name,
|
package/src/projectable.ts
CHANGED
|
@@ -4,15 +4,27 @@
|
|
|
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 {
|
|
8
|
-
|
|
7
|
+
import {
|
|
8
|
+
guardBeforeInput as actionGuardBeforeInput,
|
|
9
|
+
actionName,
|
|
10
|
+
actorOf,
|
|
11
|
+
invoke,
|
|
12
|
+
isAction,
|
|
13
|
+
} from '@ultimat3/action';
|
|
14
|
+
import { type Actor, isMcpExposed, useContext } from '@ultimat3/core';
|
|
9
15
|
import type { AnyQuery } from '@ultimat3/query';
|
|
10
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
isQuery,
|
|
18
|
+
guardBeforeInput as queryGuardBeforeInput,
|
|
19
|
+
queryName,
|
|
20
|
+
sourceFor,
|
|
21
|
+
} from '@ultimat3/query';
|
|
11
22
|
import { asCallerContext } from './caller-context';
|
|
12
23
|
import type { McpExposure, ProjectablePrimitive } from './from-action';
|
|
13
24
|
import { takeIdempotencyKeyArg, withIdempotencyKeyArg } from './idempotency-arg';
|
|
14
|
-
import { toWireSchema } from './input-schema';
|
|
25
|
+
import { toOutputSchema, toRowsOutputSchema, toWireSchema } from './input-schema';
|
|
15
26
|
import type { McpListParams } from './list-params';
|
|
27
|
+
import type { McpToolAnnotations } from './registry';
|
|
16
28
|
|
|
17
29
|
/**
|
|
18
30
|
* What `defineAppMcp`'s `actions:`/`queries:` accept.
|
|
@@ -48,12 +60,21 @@ export function primitiveFromAction(target: AnyAction): ProjectablePrimitive {
|
|
|
48
60
|
// `idempotent: true` is honoured on this surface exactly as over HTTP: the caller's key reaches
|
|
49
61
|
// `invoke`, which files it under the action, the caller and the key (`idempotencyKeyFor`).
|
|
50
62
|
const keyed = target.describe().idempotent;
|
|
63
|
+
const output = toOutputSchema(target.output);
|
|
51
64
|
return {
|
|
52
65
|
name,
|
|
53
66
|
...(exposure === undefined ? {} : { mcp: exposure }),
|
|
54
67
|
...(exposure?.description === undefined ? {} : { description: exposure.description }),
|
|
55
68
|
inputJsonSchema: keyed ? withIdempotencyKeyArg(name, wire) : wire,
|
|
69
|
+
...(output === undefined ? {} : { outputJsonSchema: output }),
|
|
56
70
|
mutates: true,
|
|
71
|
+
idempotent: keyed,
|
|
72
|
+
// The same gate `invoke` opens with, asked by the server before it answers `invalid-args`.
|
|
73
|
+
admit: (actor: Actor) =>
|
|
74
|
+
asCallerContext(actor, () => {
|
|
75
|
+
const ctx = useContext();
|
|
76
|
+
actionGuardBeforeInput(target.policy, { actor: actorOf(ctx), ctx, action: name }, 'mcp');
|
|
77
|
+
}),
|
|
57
78
|
// The actor rides in on the options: `invoke` swaps it inside the one execution path.
|
|
58
79
|
run: keyed
|
|
59
80
|
? ({ input: args, actor }) => {
|
|
@@ -66,12 +87,24 @@ export function primitiveFromAction(target: AnyAction): ProjectablePrimitive {
|
|
|
66
87
|
|
|
67
88
|
export function primitiveFromQuery(target: AnyQuery): ProjectablePrimitive {
|
|
68
89
|
const exposure = exposureOf(target.mcp);
|
|
90
|
+
// Only a query that DECLARED its rows (`rows: Post.$schema`) has a known answer shape; the tool
|
|
91
|
+
// answers the rows as a list, so the structured copy is `{ rows }` — `structuredContent` is an
|
|
92
|
+
// object by the spec.
|
|
93
|
+
const output = toRowsOutputSchema(target.rows);
|
|
94
|
+
const name = queryName(target);
|
|
69
95
|
return {
|
|
70
|
-
name
|
|
96
|
+
name,
|
|
71
97
|
...(exposure === undefined ? {} : { mcp: exposure }),
|
|
72
98
|
...(exposure?.description === undefined ? {} : { description: exposure.description }),
|
|
73
99
|
inputJsonSchema: toWireSchema(target.input),
|
|
100
|
+
...(output === undefined ? {} : { outputJsonSchema: output, outputWrap: 'rows' }),
|
|
74
101
|
mutates: false,
|
|
102
|
+
// `sourceFor`'s opening gate, on the surface it runs on ('server'), asked before `invalid-args`.
|
|
103
|
+
admit: (actor: Actor) =>
|
|
104
|
+
asCallerContext(actor, () => {
|
|
105
|
+
const ctx = useContext();
|
|
106
|
+
queryGuardBeforeInput(target.policy, { actor: actorOf(ctx), ctx, query: name }, 'server');
|
|
107
|
+
}),
|
|
75
108
|
run: ({ input, actor }) =>
|
|
76
109
|
asCallerContext(actor, async () => {
|
|
77
110
|
// `sourceFor` is the authorized front half of `runQuery` — validate, guard, build —
|
|
@@ -103,6 +136,8 @@ function exposureOf(declared: DeclaredMcp | undefined): McpExposure | undefined
|
|
|
103
136
|
...(declared.description === undefined ? {} : { description: declared.description }),
|
|
104
137
|
...(declared.visibleTo === undefined ? {} : { visibleTo: declared.visibleTo }),
|
|
105
138
|
...(declared.listParams === undefined ? {} : { listParams: declared.listParams }),
|
|
139
|
+
...(declared.title === undefined ? {} : { title: declared.title }),
|
|
140
|
+
...(declared.annotations === undefined ? {} : { annotations: declared.annotations }),
|
|
106
141
|
};
|
|
107
142
|
}
|
|
108
143
|
|
|
@@ -113,4 +148,6 @@ interface DeclaredMcp {
|
|
|
113
148
|
readonly visibleTo?: readonly string[];
|
|
114
149
|
/** `QueryMcp` only; an action has no list to compose. */
|
|
115
150
|
readonly listParams?: McpListParams;
|
|
151
|
+
readonly title?: string;
|
|
152
|
+
readonly annotations?: McpToolAnnotations;
|
|
116
153
|
}
|
package/src/registry.ts
CHANGED
|
@@ -67,6 +67,12 @@ export type ContentBlock =
|
|
|
67
67
|
export interface McpToolResult {
|
|
68
68
|
readonly content: readonly ContentBlock[];
|
|
69
69
|
readonly isError?: boolean;
|
|
70
|
+
/**
|
|
71
|
+
* The same answer as a JSON object, for a client that consumes it programmatically (MCP
|
|
72
|
+
* 2025-06-18). Set by the projection only when the tool publishes an `outputSchema`, and then it
|
|
73
|
+
* conforms to it; the text block above still carries the serialized JSON for every other client.
|
|
74
|
+
*/
|
|
75
|
+
readonly structuredContent?: Readonly<Record<string, unknown>>;
|
|
70
76
|
/**
|
|
71
77
|
* The `X_*` code an `isError` result refused with. AUDIT ONLY — never written to the wire,
|
|
72
78
|
* because the code is already in the rendered body the model reads.
|
|
@@ -81,11 +87,45 @@ export interface McpToolResult {
|
|
|
81
87
|
|
|
82
88
|
export type ToolArgs = Record<string, unknown>;
|
|
83
89
|
|
|
90
|
+
/**
|
|
91
|
+
* MCP's tool annotations (2025-06-18), spelled as the spec spells them. HINTS for a client — which
|
|
92
|
+
* calls to confirm with a human, which to retry — never a security boundary: the policy, the scope
|
|
93
|
+
* and the visibility gate decide every call whatever these say.
|
|
94
|
+
*
|
|
95
|
+
* The projection derives them from the primitive (`from-action.ts`); an action or query overrides
|
|
96
|
+
* any of them in its `mcp: { annotations }` block.
|
|
97
|
+
*/
|
|
98
|
+
export interface McpToolAnnotations {
|
|
99
|
+
/** `true`: the tool changes nothing. A query's default. */
|
|
100
|
+
readonly readOnlyHint?: boolean;
|
|
101
|
+
/**
|
|
102
|
+
* Meaningful only when `readOnlyHint` is false. `true`: it may delete or overwrite. An action's
|
|
103
|
+
* default is `true` — the spec's own default and the safe one: the framework cannot tell an
|
|
104
|
+
* additive write from a destructive one, so a client is told to confirm until the author says
|
|
105
|
+
* otherwise (`annotations: { destructiveHint: false }`).
|
|
106
|
+
*/
|
|
107
|
+
readonly destructiveHint?: boolean;
|
|
108
|
+
/** Meaningful only when `readOnlyHint` is false. `true`: a repeat with the same args is a no-op. */
|
|
109
|
+
readonly idempotentHint?: boolean;
|
|
110
|
+
/** `true`: it reaches entities outside this app (mail, a payment provider, the web). */
|
|
111
|
+
readonly openWorldHint?: boolean;
|
|
112
|
+
}
|
|
113
|
+
|
|
84
114
|
export interface McpTool<A extends ToolArgs = ToolArgs> {
|
|
85
115
|
readonly name: string;
|
|
116
|
+
/** Display name for a client's UI (MCP 2025-06-18). Absent: the client shows `name`. */
|
|
117
|
+
readonly title?: string;
|
|
86
118
|
readonly description: string;
|
|
87
119
|
/** The only argument contract. Handed verbatim to the agent by `tools/list`. */
|
|
88
120
|
readonly inputSchema: JsonSchema;
|
|
121
|
+
/**
|
|
122
|
+
* The shape of `structuredContent` (MCP 2025-06-18) — a JSON Schema whose root is an object.
|
|
123
|
+
* Published by `tools/list` only when present; a tool that declares it answers
|
|
124
|
+
* `structuredContent` conforming to it on every successful call.
|
|
125
|
+
*/
|
|
126
|
+
readonly outputSchema?: JsonSchema;
|
|
127
|
+
/** See `McpToolAnnotations`. Published by `tools/list` when present. */
|
|
128
|
+
readonly annotations?: McpToolAnnotations;
|
|
89
129
|
/** Required scope. Absent = no scope gate (the tool's own policy is the gate). */
|
|
90
130
|
readonly scope?: string;
|
|
91
131
|
/** Who may see and call this tool. Absent = everyone. See `McpVisibility`. */
|
|
@@ -107,6 +147,14 @@ export interface McpTool<A extends ToolArgs = ToolArgs> {
|
|
|
107
147
|
* `list_resources` so an agent plans for the hand-off; it changes nothing about the call.
|
|
108
148
|
*/
|
|
109
149
|
readonly confirms?: boolean;
|
|
150
|
+
/**
|
|
151
|
+
* The tool's policy, decided on the CALLER alone — throws the same denial `handle` would, or
|
|
152
|
+
* returns when the caller may call it (or when only the arguments can decide). The server asks
|
|
153
|
+
* it before answering `invalid-args`, so a caller who may never call this tool gets the 403,
|
|
154
|
+
* never an issue list describing its arguments. Projected actions and queries and app tools
|
|
155
|
+
* carry one; a hand-written tool without it answers `invalid-args` as before.
|
|
156
|
+
*/
|
|
157
|
+
admit?(caller: McpCaller): void;
|
|
110
158
|
// Method syntax (not a property) so a tool declared with narrower args stays assignable.
|
|
111
159
|
handle(args: A, caller: McpCaller): Promise<McpToolResult>;
|
|
112
160
|
}
|
|
@@ -116,8 +164,26 @@ export type AnyMcpTool = McpTool<ToolArgs>;
|
|
|
116
164
|
/** One `tools/list` row — complete and standalone, no follow-up fetch required. */
|
|
117
165
|
export interface ToolListEntry {
|
|
118
166
|
readonly name: string;
|
|
167
|
+
readonly title?: string;
|
|
119
168
|
readonly description: string;
|
|
120
169
|
readonly inputSchema: JsonSchema;
|
|
170
|
+
readonly outputSchema?: JsonSchema;
|
|
171
|
+
readonly annotations?: McpToolAnnotations;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The `tools/list` row of one tool: every optional member attached only when the tool carries it,
|
|
176
|
+
* so a tool declaring none of them lists exactly the three keys it always did.
|
|
177
|
+
*/
|
|
178
|
+
export function toolListEntry(tool: AnyMcpTool): ToolListEntry {
|
|
179
|
+
return {
|
|
180
|
+
name: tool.name,
|
|
181
|
+
...(tool.title === undefined ? {} : { title: tool.title }),
|
|
182
|
+
description: tool.description,
|
|
183
|
+
inputSchema: tool.inputSchema,
|
|
184
|
+
...(tool.outputSchema === undefined ? {} : { outputSchema: tool.outputSchema }),
|
|
185
|
+
...(tool.annotations === undefined ? {} : { annotations: tool.annotations }),
|
|
186
|
+
};
|
|
121
187
|
}
|
|
122
188
|
|
|
123
189
|
/** Rate-limit class of a call. Derived from `destructive`, never declared twice. */
|
|
@@ -164,7 +230,13 @@ export type ToolResolution =
|
|
|
164
230
|
| { readonly kind: 'ok'; readonly tool: AnyMcpTool; readonly args: ToolArgs }
|
|
165
231
|
| { readonly kind: 'not-found'; readonly name: string }
|
|
166
232
|
| { readonly kind: 'scope-denied'; readonly name: string; readonly scope: string }
|
|
167
|
-
| {
|
|
233
|
+
| {
|
|
234
|
+
readonly kind: 'invalid-args';
|
|
235
|
+
readonly name: string;
|
|
236
|
+
readonly issues: readonly ArgIssue[];
|
|
237
|
+
/** The tool the arguments were refused for — asked to `admit` the caller first. */
|
|
238
|
+
readonly tool?: AnyMcpTool;
|
|
239
|
+
};
|
|
168
240
|
|
|
169
241
|
export class ToolRegistry {
|
|
170
242
|
readonly #tools = new Map<string, AnyMcpTool>();
|
|
@@ -198,7 +270,7 @@ export class ToolRegistry {
|
|
|
198
270
|
const all = [...this.#tools.values()];
|
|
199
271
|
const visible = caller === undefined ? all : all.filter((t) => visibleToCaller(t, caller));
|
|
200
272
|
return visible
|
|
201
|
-
.map(
|
|
273
|
+
.map(toolListEntry)
|
|
202
274
|
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
203
275
|
}
|
|
204
276
|
|
|
@@ -210,7 +282,8 @@ export class ToolRegistry {
|
|
|
210
282
|
* Both gates then validation, in the only order that is safe:
|
|
211
283
|
* 1. visibility → not-found (never reveals existence)
|
|
212
284
|
* 2. scope → scope-denied (safe: the caller was already shown the tool)
|
|
213
|
-
* 3. args → invalid-args
|
|
285
|
+
* 3. args → invalid-args — unless the tool's `admit` refuses the caller first, which
|
|
286
|
+
* the server asks before answering (the policy's actor half, `dispatch`)
|
|
214
287
|
* 4. policy → inside `tool.handle`, which is why it is not here
|
|
215
288
|
* Validating before the gates would leak a schema to a caller that may not see the tool;
|
|
216
289
|
* running the policy before the scope gate would decide a refusal from attacker-supplied
|
|
@@ -225,7 +298,7 @@ export class ToolRegistry {
|
|
|
225
298
|
return { kind: 'scope-denied', name, scope: tool.scope };
|
|
226
299
|
}
|
|
227
300
|
const validation = validateArgs(tool.inputSchema, rawArgs ?? {});
|
|
228
|
-
if (!validation.ok) return { kind: 'invalid-args', name, issues: validation.issues };
|
|
301
|
+
if (!validation.ok) return { kind: 'invalid-args', name, issues: validation.issues, tool };
|
|
229
302
|
return { kind: 'ok', tool, args: validation.value };
|
|
230
303
|
}
|
|
231
304
|
|
|
@@ -248,7 +321,10 @@ export function textResult(text: string, isError = false): McpToolResult {
|
|
|
248
321
|
}
|
|
249
322
|
|
|
250
323
|
/**
|
|
251
|
-
* JSON payload as a text block —
|
|
324
|
+
* JSON payload as a text block — COMPACT, one line. Every byte of it lands in the caller's context
|
|
325
|
+
* window and stays there for the rest of the session; the 2-space form this printed until 22.10
|
|
326
|
+
* spent a third of a large answer on indentation (a staff catalog measured 32.5k characters).
|
|
327
|
+
* `JSON.stringify` is deterministic for one value, so two calls still diff.
|
|
252
328
|
*
|
|
253
329
|
* TOTAL, because the value is an app's: `toolFromAction` hands an action's own return value
|
|
254
330
|
* straight here, and `JSON.stringify` answers `undefined` for a handler that returned nothing —
|
|
@@ -258,14 +334,44 @@ export function textResult(text: string, isError = false): McpToolResult {
|
|
|
258
334
|
* three-line shape every other expected failure comes back as, and one an agent can act on.
|
|
259
335
|
*/
|
|
260
336
|
export function jsonResult(value: unknown): McpToolResult {
|
|
261
|
-
|
|
337
|
+
const text = serialize(value);
|
|
338
|
+
return text === undefined ? UNSERIALIZABLE : textResult(text);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* `jsonResult` plus `structuredContent`, for a tool that publishes an `outputSchema`. The structured
|
|
343
|
+
* copy is the SERIALIZED value read back — never the value itself — so a `Date` is the string the
|
|
344
|
+
* text block carries and a class instance is its JSON, exactly what a client validating against the
|
|
345
|
+
* schema is handed. `wrap` names the key an array answer is published under (`rows` for a query),
|
|
346
|
+
* because MCP's `structuredContent` is an object.
|
|
347
|
+
*
|
|
348
|
+
* An answer whose JSON is not an object (after `wrap`) gets no structured copy: the schema said
|
|
349
|
+
* object, and a structured copy that contradicts its schema is refused by a validating client.
|
|
350
|
+
*/
|
|
351
|
+
export function structuredResult(value: unknown, wrap?: string): McpToolResult {
|
|
352
|
+
const text = serialize(value);
|
|
353
|
+
if (text === undefined) return UNSERIALIZABLE;
|
|
354
|
+
const parsed: unknown = JSON.parse(text);
|
|
355
|
+
const structured = wrap === undefined ? parsed : { [wrap]: parsed };
|
|
356
|
+
if (typeof structured !== 'object' || structured === null || Array.isArray(structured)) {
|
|
357
|
+
return textResult(text);
|
|
358
|
+
}
|
|
359
|
+
return {
|
|
360
|
+
content: [{ type: 'text', text }],
|
|
361
|
+
structuredContent: structured as Record<string, unknown>,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const UNSERIALIZABLE: McpToolResult = textResult(
|
|
366
|
+
'the tool ran, but its result is not JSON (a bigint, a cycle, or a toJSON that threw) — the tool has to return a JSON-serialisable value',
|
|
367
|
+
true,
|
|
368
|
+
);
|
|
369
|
+
|
|
370
|
+
/** Compact JSON, `'null'` for a value with no JSON form, `undefined` when serializing threw. */
|
|
371
|
+
function serialize(value: unknown): string | undefined {
|
|
262
372
|
try {
|
|
263
|
-
|
|
373
|
+
return JSON.stringify(value) ?? 'null';
|
|
264
374
|
} catch {
|
|
265
|
-
return
|
|
266
|
-
'the tool ran, but its result is not JSON (a bigint, a cycle, or a toJSON that threw) — the tool has to return a JSON-serialisable value',
|
|
267
|
-
true,
|
|
268
|
-
);
|
|
375
|
+
return undefined;
|
|
269
376
|
}
|
|
270
|
-
return textResult(text ?? 'null');
|
|
271
377
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// What the server SAYS, per caller, as opposed to what it decides: `initialize`'s `instructions`,
|
|
2
|
+
// the audience every `fix:` line is written for, and the tool result a mistyped argument earns.
|
|
3
|
+
// Split from `server.ts` for the size ceiling; it holds no gate — dispatch stays there.
|
|
4
|
+
|
|
5
|
+
import { InputInvalidError } from '@ultimat3/action';
|
|
6
|
+
import type { ErrorAudience } from '@ultimat3/core';
|
|
7
|
+
import { formatIssues } from '@ultimat3/schema';
|
|
8
|
+
import type { McpCaller, McpToolResult } from './registry';
|
|
9
|
+
import type { ArgIssue } from './validate-args';
|
|
10
|
+
|
|
11
|
+
/** See `CreateMcpServerInput.instructions`. */
|
|
12
|
+
export type McpInstructions = string | ((caller: McpCaller) => string | undefined);
|
|
13
|
+
|
|
14
|
+
/** The server's per-caller wording: `initialize`'s advice and the audience of every `fix:` line. */
|
|
15
|
+
export interface McpServerVoice {
|
|
16
|
+
readonly instructions?: McpInstructions | undefined;
|
|
17
|
+
readonly errorAudience: ErrorAudience;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The instructions THIS caller is handed, or `undefined`. Fail-closed to none, never a throw. */
|
|
21
|
+
export function instructionsFor(
|
|
22
|
+
instructions: McpInstructions | undefined,
|
|
23
|
+
caller: McpCaller,
|
|
24
|
+
): string | undefined {
|
|
25
|
+
if (instructions === undefined) return undefined;
|
|
26
|
+
let text: unknown;
|
|
27
|
+
try {
|
|
28
|
+
text = typeof instructions === 'function' ? instructions(caller) : instructions;
|
|
29
|
+
} catch {
|
|
30
|
+
// The handshake is the one call every client makes; advice that failed to render must not
|
|
31
|
+
// take the connection down with it.
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
return typeof text === 'string' && text.trim() !== '' ? text : undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A tool RESULT for arguments that failed the published schema — not a JSON-RPC error, since
|
|
39
|
+
* 22.10. Clients surface a protocol error to the human and hide it from the model, so the `-32602`
|
|
40
|
+
* this answered left the agent nothing to correct and it retried blind. `isError` reaches the
|
|
41
|
+
* model carrying the same `X_INPUT_INVALID` an HTTP caller gets for the same input, each issue
|
|
42
|
+
* addressed by path. `-32602` stays for what is not a call at all (no params, a non-string name).
|
|
43
|
+
*/
|
|
44
|
+
export function invalidArgsResult(
|
|
45
|
+
name: string,
|
|
46
|
+
issues: readonly ArgIssue[],
|
|
47
|
+
audience: ErrorAudience,
|
|
48
|
+
): { readonly code: string; readonly result: McpToolResult } {
|
|
49
|
+
const invalid = new InputInvalidError(
|
|
50
|
+
name,
|
|
51
|
+
formatIssues(issues).join('; '),
|
|
52
|
+
issues.map((issue) => ({
|
|
53
|
+
path: issue.path,
|
|
54
|
+
expected: issue.message,
|
|
55
|
+
received: '',
|
|
56
|
+
message: issue.message,
|
|
57
|
+
})),
|
|
58
|
+
'tool',
|
|
59
|
+
);
|
|
60
|
+
return {
|
|
61
|
+
code: invalid.code,
|
|
62
|
+
result: { content: [{ type: 'text', text: invalid.format({ audience }) }], isError: true },
|
|
63
|
+
};
|
|
64
|
+
}
|