@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.
@@ -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 { DESCRIBE_RESOURCE, LIST_RESOURCES, META_TOOL_ENTRIES, schemaOf } from './meta-surface';
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, jsonResult({ resources: meta.listResources(caller) }));
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) resolved = { kind: 'invalid-args', name: action, issues: listed.issues };
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
  }
@@ -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,
@@ -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 { actionName, invoke, isAction } from '@ultimat3/action';
8
- import { isMcpExposed } from '@ultimat3/core';
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 { isQuery, queryName, sourceFor } from '@ultimat3/query';
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: queryName(target),
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
- | { readonly kind: 'invalid-args'; readonly name: string; readonly issues: readonly ArgIssue[] };
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((t) => ({ name: t.name, description: t.description, inputSchema: t.inputSchema }))
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 — stable 2-space form so an agent can diff two calls.
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
- let text: string | undefined;
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
- text = JSON.stringify(value, null, 2);
373
+ return JSON.stringify(value) ?? 'null';
264
374
  } 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
- );
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
+ }