@ultimat3/mcp 22.9.1 → 22.10.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 +12 -1
- 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 +75 -3
- package/src/index.ts +16 -2
- package/src/input-schema.ts +63 -0
- package/src/meta-call.ts +9 -3
- package/src/meta-surface.ts +42 -0
- package/src/projectable.ts +14 -1
- package/src/registry.ts +100 -9
- package/src/server-voice.ts +64 -0
- package/src/server.ts +51 -13
- package/src/surface-budget.ts +113 -0
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
|
@@ -11,8 +11,9 @@ import { isQuery, queryName, sourceFor } from '@ultimat3/query';
|
|
|
11
11
|
import { asCallerContext } from './caller-context';
|
|
12
12
|
import type { McpExposure, ProjectablePrimitive } from './from-action';
|
|
13
13
|
import { takeIdempotencyKeyArg, withIdempotencyKeyArg } from './idempotency-arg';
|
|
14
|
-
import { toWireSchema } from './input-schema';
|
|
14
|
+
import { toOutputSchema, toRowsOutputSchema, toWireSchema } from './input-schema';
|
|
15
15
|
import type { McpListParams } from './list-params';
|
|
16
|
+
import type { McpToolAnnotations } from './registry';
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* What `defineAppMcp`'s `actions:`/`queries:` accept.
|
|
@@ -48,12 +49,15 @@ export function primitiveFromAction(target: AnyAction): ProjectablePrimitive {
|
|
|
48
49
|
// `idempotent: true` is honoured on this surface exactly as over HTTP: the caller's key reaches
|
|
49
50
|
// `invoke`, which files it under the action, the caller and the key (`idempotencyKeyFor`).
|
|
50
51
|
const keyed = target.describe().idempotent;
|
|
52
|
+
const output = toOutputSchema(target.output);
|
|
51
53
|
return {
|
|
52
54
|
name,
|
|
53
55
|
...(exposure === undefined ? {} : { mcp: exposure }),
|
|
54
56
|
...(exposure?.description === undefined ? {} : { description: exposure.description }),
|
|
55
57
|
inputJsonSchema: keyed ? withIdempotencyKeyArg(name, wire) : wire,
|
|
58
|
+
...(output === undefined ? {} : { outputJsonSchema: output }),
|
|
56
59
|
mutates: true,
|
|
60
|
+
idempotent: keyed,
|
|
57
61
|
// The actor rides in on the options: `invoke` swaps it inside the one execution path.
|
|
58
62
|
run: keyed
|
|
59
63
|
? ({ input: args, actor }) => {
|
|
@@ -66,11 +70,16 @@ export function primitiveFromAction(target: AnyAction): ProjectablePrimitive {
|
|
|
66
70
|
|
|
67
71
|
export function primitiveFromQuery(target: AnyQuery): ProjectablePrimitive {
|
|
68
72
|
const exposure = exposureOf(target.mcp);
|
|
73
|
+
// Only a query that DECLARED its rows (`rows: Post.$schema`) has a known answer shape; the tool
|
|
74
|
+
// answers the rows as a list, so the structured copy is `{ rows }` — `structuredContent` is an
|
|
75
|
+
// object by the spec.
|
|
76
|
+
const output = toRowsOutputSchema(target.rows);
|
|
69
77
|
return {
|
|
70
78
|
name: queryName(target),
|
|
71
79
|
...(exposure === undefined ? {} : { mcp: exposure }),
|
|
72
80
|
...(exposure?.description === undefined ? {} : { description: exposure.description }),
|
|
73
81
|
inputJsonSchema: toWireSchema(target.input),
|
|
82
|
+
...(output === undefined ? {} : { outputJsonSchema: output, outputWrap: 'rows' }),
|
|
74
83
|
mutates: false,
|
|
75
84
|
run: ({ input, actor }) =>
|
|
76
85
|
asCallerContext(actor, async () => {
|
|
@@ -103,6 +112,8 @@ function exposureOf(declared: DeclaredMcp | undefined): McpExposure | undefined
|
|
|
103
112
|
...(declared.description === undefined ? {} : { description: declared.description }),
|
|
104
113
|
...(declared.visibleTo === undefined ? {} : { visibleTo: declared.visibleTo }),
|
|
105
114
|
...(declared.listParams === undefined ? {} : { listParams: declared.listParams }),
|
|
115
|
+
...(declared.title === undefined ? {} : { title: declared.title }),
|
|
116
|
+
...(declared.annotations === undefined ? {} : { annotations: declared.annotations }),
|
|
106
117
|
};
|
|
107
118
|
}
|
|
108
119
|
|
|
@@ -113,4 +124,6 @@ interface DeclaredMcp {
|
|
|
113
124
|
readonly visibleTo?: readonly string[];
|
|
114
125
|
/** `QueryMcp` only; an action has no list to compose. */
|
|
115
126
|
readonly listParams?: McpListParams;
|
|
127
|
+
readonly title?: string;
|
|
128
|
+
readonly annotations?: McpToolAnnotations;
|
|
116
129
|
}
|
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`. */
|
|
@@ -116,8 +156,26 @@ export type AnyMcpTool = McpTool<ToolArgs>;
|
|
|
116
156
|
/** One `tools/list` row — complete and standalone, no follow-up fetch required. */
|
|
117
157
|
export interface ToolListEntry {
|
|
118
158
|
readonly name: string;
|
|
159
|
+
readonly title?: string;
|
|
119
160
|
readonly description: string;
|
|
120
161
|
readonly inputSchema: JsonSchema;
|
|
162
|
+
readonly outputSchema?: JsonSchema;
|
|
163
|
+
readonly annotations?: McpToolAnnotations;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The `tools/list` row of one tool: every optional member attached only when the tool carries it,
|
|
168
|
+
* so a tool declaring none of them lists exactly the three keys it always did.
|
|
169
|
+
*/
|
|
170
|
+
export function toolListEntry(tool: AnyMcpTool): ToolListEntry {
|
|
171
|
+
return {
|
|
172
|
+
name: tool.name,
|
|
173
|
+
...(tool.title === undefined ? {} : { title: tool.title }),
|
|
174
|
+
description: tool.description,
|
|
175
|
+
inputSchema: tool.inputSchema,
|
|
176
|
+
...(tool.outputSchema === undefined ? {} : { outputSchema: tool.outputSchema }),
|
|
177
|
+
...(tool.annotations === undefined ? {} : { annotations: tool.annotations }),
|
|
178
|
+
};
|
|
121
179
|
}
|
|
122
180
|
|
|
123
181
|
/** Rate-limit class of a call. Derived from `destructive`, never declared twice. */
|
|
@@ -198,7 +256,7 @@ export class ToolRegistry {
|
|
|
198
256
|
const all = [...this.#tools.values()];
|
|
199
257
|
const visible = caller === undefined ? all : all.filter((t) => visibleToCaller(t, caller));
|
|
200
258
|
return visible
|
|
201
|
-
.map(
|
|
259
|
+
.map(toolListEntry)
|
|
202
260
|
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
203
261
|
}
|
|
204
262
|
|
|
@@ -248,7 +306,10 @@ export function textResult(text: string, isError = false): McpToolResult {
|
|
|
248
306
|
}
|
|
249
307
|
|
|
250
308
|
/**
|
|
251
|
-
* JSON payload as a text block —
|
|
309
|
+
* JSON payload as a text block — COMPACT, one line. Every byte of it lands in the caller's context
|
|
310
|
+
* window and stays there for the rest of the session; the 2-space form this printed until 22.10
|
|
311
|
+
* spent a third of a large answer on indentation (a staff catalog measured 32.5k characters).
|
|
312
|
+
* `JSON.stringify` is deterministic for one value, so two calls still diff.
|
|
252
313
|
*
|
|
253
314
|
* TOTAL, because the value is an app's: `toolFromAction` hands an action's own return value
|
|
254
315
|
* straight here, and `JSON.stringify` answers `undefined` for a handler that returned nothing —
|
|
@@ -258,14 +319,44 @@ export function textResult(text: string, isError = false): McpToolResult {
|
|
|
258
319
|
* three-line shape every other expected failure comes back as, and one an agent can act on.
|
|
259
320
|
*/
|
|
260
321
|
export function jsonResult(value: unknown): McpToolResult {
|
|
261
|
-
|
|
322
|
+
const text = serialize(value);
|
|
323
|
+
return text === undefined ? UNSERIALIZABLE : textResult(text);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* `jsonResult` plus `structuredContent`, for a tool that publishes an `outputSchema`. The structured
|
|
328
|
+
* copy is the SERIALIZED value read back — never the value itself — so a `Date` is the string the
|
|
329
|
+
* text block carries and a class instance is its JSON, exactly what a client validating against the
|
|
330
|
+
* schema is handed. `wrap` names the key an array answer is published under (`rows` for a query),
|
|
331
|
+
* because MCP's `structuredContent` is an object.
|
|
332
|
+
*
|
|
333
|
+
* An answer whose JSON is not an object (after `wrap`) gets no structured copy: the schema said
|
|
334
|
+
* object, and a structured copy that contradicts its schema is refused by a validating client.
|
|
335
|
+
*/
|
|
336
|
+
export function structuredResult(value: unknown, wrap?: string): McpToolResult {
|
|
337
|
+
const text = serialize(value);
|
|
338
|
+
if (text === undefined) return UNSERIALIZABLE;
|
|
339
|
+
const parsed: unknown = JSON.parse(text);
|
|
340
|
+
const structured = wrap === undefined ? parsed : { [wrap]: parsed };
|
|
341
|
+
if (typeof structured !== 'object' || structured === null || Array.isArray(structured)) {
|
|
342
|
+
return textResult(text);
|
|
343
|
+
}
|
|
344
|
+
return {
|
|
345
|
+
content: [{ type: 'text', text }],
|
|
346
|
+
structuredContent: structured as Record<string, unknown>,
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const UNSERIALIZABLE: McpToolResult = textResult(
|
|
351
|
+
'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',
|
|
352
|
+
true,
|
|
353
|
+
);
|
|
354
|
+
|
|
355
|
+
/** Compact JSON, `'null'` for a value with no JSON form, `undefined` when serializing threw. */
|
|
356
|
+
function serialize(value: unknown): string | undefined {
|
|
262
357
|
try {
|
|
263
|
-
|
|
358
|
+
return JSON.stringify(value) ?? 'null';
|
|
264
359
|
} 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
|
-
);
|
|
360
|
+
return undefined;
|
|
269
361
|
}
|
|
270
|
-
return textResult(text ?? 'null');
|
|
271
362
|
}
|
|
@@ -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
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -3,13 +3,14 @@
|
|
|
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 {
|
|
6
|
+
import type { ErrorAudience } from '@ultimat3/core';
|
|
7
|
+
import { fixFor } from '@ultimat3/core';
|
|
7
8
|
import { auditResourceRead, auditToolCall, outcomeForCode, outcomeForResult } from './audit';
|
|
8
9
|
import { McpProtocolError, McpScopeDeniedError, TOOL_UNKNOWN_FIX } from './errors';
|
|
9
10
|
import { asFrameworkError, renderFrameworkError } from './framework-error';
|
|
10
11
|
import { metaCall } from './meta-call';
|
|
11
12
|
import { META_UNKNOWN_FIX } from './meta-errors';
|
|
12
|
-
import type { McpResourceGroups, McpSurfaceOption } from './meta-surface';
|
|
13
|
+
import type { McpResourceGroups, McpSurfaceOption, MetaResource } from './meta-surface';
|
|
13
14
|
import { MANAGE_RESOURCE, META_TOOL_ENTRIES, META_TOOL_NAMES, MetaSurface } from './meta-surface';
|
|
14
15
|
import { promptListEntry, promptsGet } from './prompts-get';
|
|
15
16
|
import type {
|
|
@@ -23,6 +24,8 @@ import type {
|
|
|
23
24
|
import { ToolRegistry } from './registry';
|
|
24
25
|
import type { McpPrompt, McpResource } from './resources';
|
|
25
26
|
import { ResourceRegistry } from './resources';
|
|
27
|
+
import type { McpInstructions, McpServerVoice } from './server-voice';
|
|
28
|
+
import { instructionsFor, invalidArgsResult } from './server-voice';
|
|
26
29
|
import type { JsonRpcId, JsonRpcRequest, JsonRpcResponse, ServerInfo } from './wire';
|
|
27
30
|
import {
|
|
28
31
|
defaultServerInfo,
|
|
@@ -64,6 +67,20 @@ export interface CreateMcpServerInput {
|
|
|
64
67
|
readonly surface?: McpSurfaceOption | undefined;
|
|
65
68
|
/** The meta catalog's resources. Required with a surface that can be `'meta'`. */
|
|
66
69
|
readonly groups?: McpResourceGroups | undefined;
|
|
70
|
+
/**
|
|
71
|
+
* `initialize`'s `instructions`: how to use this server, for a client that injects it into the
|
|
72
|
+
* model's context (Claude Code does; many clients do not — never load-bear on it). A function
|
|
73
|
+
* answers per caller population — a staff surface and a customer one read different advice —
|
|
74
|
+
* and a function that throws or answers anything but a non-empty string sends none.
|
|
75
|
+
*/
|
|
76
|
+
readonly instructions?: McpInstructions | undefined;
|
|
77
|
+
/**
|
|
78
|
+
* Whose `fix:` line a refusal carries. `'developer'` (the default, and the dev server's): the
|
|
79
|
+
* fix the author wrote — `x policy explain …`, a declaration to edit. `'caller'`: the error's
|
|
80
|
+
* `callerFix` where it declares one — what a REMOTE agent can do about it. `defineAppMcp`
|
|
81
|
+
* serves `'caller'`.
|
|
82
|
+
*/
|
|
83
|
+
readonly errorAudience?: ErrorAudience | undefined;
|
|
67
84
|
}
|
|
68
85
|
|
|
69
86
|
/** The set of JSON-RPC methods this server answers. Kept in sync with `classify`. */
|
|
@@ -92,6 +109,7 @@ export function createMcpServer(input: CreateMcpServerInput = {}): McpServer {
|
|
|
92
109
|
input.prompts ?? [],
|
|
93
110
|
input.serverInfo ?? defaultServerInfo(),
|
|
94
111
|
meta,
|
|
112
|
+
{ instructions: input.instructions, errorAudience: input.errorAudience ?? 'developer' },
|
|
95
113
|
);
|
|
96
114
|
}
|
|
97
115
|
|
|
@@ -102,6 +120,7 @@ export class McpServer {
|
|
|
102
120
|
private readonly serverInfo: ServerInfo;
|
|
103
121
|
/** `undefined` for a flat-only server — every existing app, byte for byte. */
|
|
104
122
|
private readonly meta: MetaSurface | undefined;
|
|
123
|
+
private readonly voice: McpServerVoice;
|
|
105
124
|
|
|
106
125
|
constructor(
|
|
107
126
|
tools: ToolRegistry,
|
|
@@ -109,12 +128,14 @@ export class McpServer {
|
|
|
109
128
|
prompts: readonly McpPrompt[],
|
|
110
129
|
serverInfo: ServerInfo,
|
|
111
130
|
meta?: MetaSurface,
|
|
131
|
+
voice: McpServerVoice = { errorAudience: 'developer' },
|
|
112
132
|
) {
|
|
113
133
|
this.tools = tools;
|
|
114
134
|
this.resources = resources;
|
|
115
135
|
this.prompts = prompts;
|
|
116
136
|
this.serverInfo = serverInfo;
|
|
117
137
|
this.meta = meta;
|
|
138
|
+
this.voice = voice;
|
|
118
139
|
}
|
|
119
140
|
|
|
120
141
|
async handle(
|
|
@@ -151,7 +172,8 @@ export class McpServer {
|
|
|
151
172
|
const id = body.id ?? null;
|
|
152
173
|
|
|
153
174
|
switch (body.method) {
|
|
154
|
-
case 'initialize':
|
|
175
|
+
case 'initialize': {
|
|
176
|
+
const instructions = instructionsFor(this.voice.instructions, caller);
|
|
155
177
|
return resultResponse(id, {
|
|
156
178
|
protocolVersion: MCP_PROTOCOL_VERSION,
|
|
157
179
|
capabilities: {
|
|
@@ -160,7 +182,9 @@ export class McpServer {
|
|
|
160
182
|
prompts: { listChanged: false },
|
|
161
183
|
},
|
|
162
184
|
serverInfo: this.serverInfo,
|
|
185
|
+
...(instructions === undefined ? {} : { instructions }),
|
|
163
186
|
});
|
|
187
|
+
}
|
|
164
188
|
case 'tools/list':
|
|
165
189
|
return resultResponse(id, { tools: this.list(caller) });
|
|
166
190
|
case 'tools/call':
|
|
@@ -194,6 +218,14 @@ export class McpServer {
|
|
|
194
218
|
);
|
|
195
219
|
}
|
|
196
220
|
|
|
221
|
+
/**
|
|
222
|
+
* The meta catalog THIS caller would read from `list_resources`, as data — `undefined` for a caller
|
|
223
|
+
* served the flat surface. For a test or a tool asserting on the catalog: the wire form is text.
|
|
224
|
+
*/
|
|
225
|
+
catalog(caller: McpCaller): readonly MetaResource[] | undefined {
|
|
226
|
+
return this.metaFor(caller)?.listResources(caller);
|
|
227
|
+
}
|
|
228
|
+
|
|
197
229
|
/** The meta surface when THIS caller is served it, else `undefined`. */
|
|
198
230
|
private metaFor(caller: McpCaller): MetaSurface | undefined {
|
|
199
231
|
const meta = this.meta;
|
|
@@ -272,6 +304,7 @@ export class McpServer {
|
|
|
272
304
|
resolved: ToolResolution,
|
|
273
305
|
caller: McpCaller,
|
|
274
306
|
): Promise<JsonRpcResponse> {
|
|
307
|
+
const audience = this.voice.errorAudience;
|
|
275
308
|
// Three outcomes, deliberately different — and every one of them audited, including the
|
|
276
309
|
// one that tells the caller nothing. See `audit.ts`.
|
|
277
310
|
switch (resolved.kind) {
|
|
@@ -301,16 +334,15 @@ export class McpServer {
|
|
|
301
334
|
return errorResponse(id, INVALID_REQUEST, `missing scope: ${resolved.scope}`, {
|
|
302
335
|
code: denial.code,
|
|
303
336
|
scope: resolved.scope,
|
|
304
|
-
fix: denial
|
|
337
|
+
fix: fixFor(denial, audience),
|
|
305
338
|
docs: denial.docs,
|
|
306
339
|
});
|
|
307
340
|
}
|
|
308
|
-
case 'invalid-args':
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
});
|
|
341
|
+
case 'invalid-args': {
|
|
342
|
+
const invalid = invalidArgsResult(name, resolved.issues, audience);
|
|
343
|
+
auditToolCall({ tool: name, outcome: 'invalid-args', caller, code: invalid.code });
|
|
344
|
+
return resultResponse(id, invalid.result);
|
|
345
|
+
}
|
|
314
346
|
case 'ok':
|
|
315
347
|
break;
|
|
316
348
|
}
|
|
@@ -332,7 +364,7 @@ export class McpServer {
|
|
|
332
364
|
code: framework.code,
|
|
333
365
|
});
|
|
334
366
|
return resultResponse(id, {
|
|
335
|
-
content: [{ type: 'text', text: renderFrameworkError(framework) }],
|
|
367
|
+
content: [{ type: 'text', text: renderFrameworkError(framework, audience) }],
|
|
336
368
|
isError: true,
|
|
337
369
|
});
|
|
338
370
|
}
|
|
@@ -351,6 +383,11 @@ export class McpServer {
|
|
|
351
383
|
});
|
|
352
384
|
// `code` is audit-only and never reaches the wire: it is already inside the rendered body.
|
|
353
385
|
const payload: Record<string, unknown> = { content: result.content };
|
|
386
|
+
// Through `manage_resource` too, byte for byte: the meta door answers what the flat one does,
|
|
387
|
+
// and MCP allows `structuredContent` beside a tool (the dispatcher) that publishes no schema.
|
|
388
|
+
if (result.structuredContent !== undefined && result.isError !== true) {
|
|
389
|
+
payload['structuredContent'] = result.structuredContent;
|
|
390
|
+
}
|
|
354
391
|
if (result.isError === true) payload['isError'] = true;
|
|
355
392
|
return resultResponse(id, payload);
|
|
356
393
|
}
|
|
@@ -399,7 +436,7 @@ export class McpServer {
|
|
|
399
436
|
return errorResponse(id, INVALID_REQUEST, `missing scope: ${resolved.scope}`, {
|
|
400
437
|
code: denial.code,
|
|
401
438
|
scope: resolved.scope,
|
|
402
|
-
fix: denial.
|
|
439
|
+
fix: fixFor(denial, this.voice.errorAudience),
|
|
403
440
|
docs: denial.docs,
|
|
404
441
|
});
|
|
405
442
|
}
|
|
@@ -429,7 +466,8 @@ export class McpServer {
|
|
|
429
466
|
return errorResponse(id, INTERNAL_ERROR, `resource "${uri}" could not be read`, {
|
|
430
467
|
code: framework.code,
|
|
431
468
|
cause: framework.cause,
|
|
432
|
-
fix: framework.
|
|
469
|
+
fix: fixFor(framework, this.voice.errorAudience),
|
|
470
|
+
...(framework.docs === undefined ? {} : { docs: framework.docs }),
|
|
433
471
|
});
|
|
434
472
|
}
|
|
435
473
|
// No internals: a provider's own message names a path, a query or a host the caller has no
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// The size of what an agent READS from this server, measured off the wire, and a budget an app's
|
|
2
|
+
// test holds it to. `tools/list` is re-sent with every step of every turn and `list_resources` is
|
|
3
|
+
// read once a session and then sits in the transcript — both are standing context the model pays
|
|
4
|
+
// for whether or not it uses them, so a number with a test beside it is the only way they stay
|
|
5
|
+
// small (gold standard: context-budget.md, "guard the numbers with tests").
|
|
6
|
+
|
|
7
|
+
import { McpSurfaceOverBudgetError } from './errors';
|
|
8
|
+
import { LIST_RESOURCES } from './meta-surface';
|
|
9
|
+
import type { McpCaller } from './registry';
|
|
10
|
+
import type { McpServer } from './server';
|
|
11
|
+
|
|
12
|
+
/** Characters, as serialized on the wire — what a client hands its model. */
|
|
13
|
+
export interface McpSurfaceSize {
|
|
14
|
+
/** `JSON.stringify` of the `tools/list` result this caller gets. */
|
|
15
|
+
readonly toolsList: number;
|
|
16
|
+
/** The text of `list_resources({})`, or `undefined` for a caller served the flat surface. */
|
|
17
|
+
readonly listResources: number | undefined;
|
|
18
|
+
/** `initialize`'s `instructions`; `0` when none are sent to this caller. */
|
|
19
|
+
readonly instructions: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A ceiling per surface, in characters. An omitted surface is measured and not judged. */
|
|
23
|
+
export interface McpSurfaceBudget {
|
|
24
|
+
readonly toolsList?: number;
|
|
25
|
+
readonly listResources?: number;
|
|
26
|
+
readonly instructions?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Measure the three surfaces for ONE caller — a per-population server answers each differently. */
|
|
30
|
+
export async function measureMcpSurface(
|
|
31
|
+
server: McpServer,
|
|
32
|
+
caller: McpCaller,
|
|
33
|
+
): Promise<McpSurfaceSize> {
|
|
34
|
+
const init = resultOf(
|
|
35
|
+
await server.handle({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }, caller),
|
|
36
|
+
);
|
|
37
|
+
const instructions = init?.['instructions'];
|
|
38
|
+
const list = resultOf(
|
|
39
|
+
await server.handle({ jsonrpc: '2.0', id: 2, method: 'tools/list' }, caller),
|
|
40
|
+
);
|
|
41
|
+
const tools = Array.isArray(list?.['tools'])
|
|
42
|
+
? (list['tools'] as readonly { name?: unknown }[])
|
|
43
|
+
: [];
|
|
44
|
+
let listResources: number | undefined;
|
|
45
|
+
if (tools.some((tool) => tool.name === LIST_RESOURCES)) {
|
|
46
|
+
const called = resultOf(
|
|
47
|
+
await server.handle(
|
|
48
|
+
{
|
|
49
|
+
jsonrpc: '2.0',
|
|
50
|
+
id: 3,
|
|
51
|
+
method: 'tools/call',
|
|
52
|
+
params: { name: LIST_RESOURCES, arguments: {} },
|
|
53
|
+
},
|
|
54
|
+
caller,
|
|
55
|
+
),
|
|
56
|
+
);
|
|
57
|
+
listResources = textLength(called?.['content']);
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
toolsList: JSON.stringify(list ?? {}).length,
|
|
61
|
+
listResources,
|
|
62
|
+
instructions: typeof instructions === 'string' ? instructions.length : 0,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Measure, then refuse any surface over its budget with `X_MCP_SURFACE_OVER_BUDGET` naming every
|
|
68
|
+
* one. Returns the measurement so a test can print it next to the number it chose.
|
|
69
|
+
*
|
|
70
|
+
* ```ts
|
|
71
|
+
* test('the staff surface stays small', async () => {
|
|
72
|
+
* await assertMcpSurfaceBudget(mcp.server, staffCaller, { toolsList: 4_000, listResources: 12_000 });
|
|
73
|
+
* });
|
|
74
|
+
* ```
|
|
75
|
+
*
|
|
76
|
+
* Size a budget from the measured value times a margin, and write the derivation beside it.
|
|
77
|
+
*/
|
|
78
|
+
export async function assertMcpSurfaceBudget(
|
|
79
|
+
server: McpServer,
|
|
80
|
+
caller: McpCaller,
|
|
81
|
+
budget: McpSurfaceBudget,
|
|
82
|
+
): Promise<McpSurfaceSize> {
|
|
83
|
+
const size = await measureMcpSurface(server, caller);
|
|
84
|
+
const over: { surface: string; size: number; budget: number }[] = [];
|
|
85
|
+
const judge = (surface: string, measured: number | undefined, ceiling: number | undefined) => {
|
|
86
|
+
if (measured !== undefined && ceiling !== undefined && measured > ceiling) {
|
|
87
|
+
over.push({ surface, size: measured, budget: ceiling });
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
judge('tools/list', size.toolsList, budget.toolsList);
|
|
91
|
+
judge('list_resources', size.listResources, budget.listResources);
|
|
92
|
+
judge('instructions', size.instructions, budget.instructions);
|
|
93
|
+
if (over.length > 0) throw new McpSurfaceOverBudgetError({ over });
|
|
94
|
+
return size;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function resultOf(
|
|
98
|
+
response: { readonly result?: unknown } | null,
|
|
99
|
+
): Record<string, unknown> | undefined {
|
|
100
|
+
const result = response?.result;
|
|
101
|
+
return typeof result === 'object' && result !== null
|
|
102
|
+
? (result as Record<string, unknown>)
|
|
103
|
+
: undefined;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function textLength(content: unknown): number {
|
|
107
|
+
if (!Array.isArray(content)) return 0;
|
|
108
|
+
let total = 0;
|
|
109
|
+
for (const block of content as readonly { readonly text?: unknown }[]) {
|
|
110
|
+
if (typeof block.text === 'string') total += block.text.length;
|
|
111
|
+
}
|
|
112
|
+
return total;
|
|
113
|
+
}
|