@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.
@@ -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,
@@ -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((t) => ({ name: t.name, description: t.description, inputSchema: t.inputSchema }))
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 — stable 2-space form so an agent can diff two calls.
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
- let text: string | undefined;
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
- text = JSON.stringify(value, null, 2);
358
+ return JSON.stringify(value) ?? 'null';
264
359
  } catch {
265
- return textResult(
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 { formatIssues } from '@ultimat3/schema';
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.fix,
337
+ fix: fixFor(denial, audience),
305
338
  docs: denial.docs,
306
339
  });
307
340
  }
308
- case 'invalid-args':
309
- auditToolCall({ tool: name, outcome: 'invalid-args', caller, code: 'X_MCP_ARGS_INVALID' });
310
- return errorResponse(id, INVALID_PARAMS, `invalid arguments for ${name}`, {
311
- code: 'X_MCP_ARGS_INVALID',
312
- issues: formatIssues(resolved.issues),
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.fix,
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.fix,
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
+ }