@ultimat3/mcp 22.9.0 → 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 CHANGED
@@ -35,6 +35,7 @@ import. The CLI wires it.
35
35
  | `meta-surface.ts` | `surface: 'meta'` catalog: `list_resources` / `describe_resource`, `groups:` boot checks; dispatch stays in `server.ts` |
36
36
  | `list-params.ts` | `listParams` whitelist → the schema `manage_resource` enforces (flat keys: `status_eq`) |
37
37
  | `meta-errors.ts` · `framework-error.ts` | meta boot refusals · a thrown value read and rendered (split from `server.ts`) |
38
+ | `server-voice.ts` · `surface-budget.ts` | `instructions`, `errorAudience`, the invalid-args RESULT · `tools/list`/`list_resources` size, measured and budgeted |
38
39
 
39
40
  ## Invariants
40
41
 
@@ -266,6 +267,10 @@ import. The CLI wires it.
266
267
  carrying a `fix`, then the rest of that line is DISCARDED to the next newline: what follows an
267
268
  over-long message on the same line is its tail, never a message of its own.
268
269
  - New mutating tool ⇒ set `destructive: true`, or it is metered as cheap read chatter.
270
+ - **Invalid arguments are an `isError` RESULT (`X_INPUT_INVALID`), `As of 22.10`** — clients hide a
271
+ `-32602` from the model. Audit outcome stays `invalid-args`. `-32602` only for a call that is not
272
+ one. An app server renders `callerFix` (`errorAudience: 'caller'`); a `docs` other than
273
+ `ERROR_DOCS_URL` is a 4th line — branded errors only, `fix`'s trust rule.
269
274
 
270
275
  ## Commands
271
276
 
package/README.md CHANGED
@@ -111,6 +111,7 @@ export const mcp = defineAppMcp({
111
111
  },
112
112
  },
113
113
  scopes: { 'admin:seats': ['seatReport'] }, // scope name → tool NAMES, by string
114
+ instructions: 'Admin console. seatReport({}) answers seat questions; writes are audited.',
114
115
  resolveToken: (token) => sessions.resolveAgentToken(token),
115
116
  });
116
117
 
@@ -201,7 +202,7 @@ defineAppMcp({
201
202
 
202
203
  | Tool | Answers |
203
204
  |---|---|
204
- | `list_resources` | `{ resources: [{ name, description, actions: [{ name, kind: 'query'\|'action', description, params, confirms?, scope? }] }] }` — `params` is a one-line hint (`batchId: string, note?: string`) |
205
+ | `list_resources` | PLAIN TEXT (`As of 22.10`; JSON until then): one line per resource, and under it one line per action — ` previewPayoutBatch (action; confirms; scope payouts:write) {batchId: string, note?: string} — <description>`. The same catalog as data: `server.catalog(caller)` (`MetaResource[]`, `undefined` for a flat caller); the renderer is `renderCatalog` |
205
206
  | `describe_resource` | `{ resources: string[] }` (batched) → each action's full `inputSchema` |
206
207
  | `manage_resource` | `{ resource, action, params }` → the flat tool's answer, byte for byte |
207
208
 
@@ -218,9 +219,39 @@ meta tool name taken by an app tool (`X_MCP_TOOL_DUPLICATE`).
218
219
  maxLimit: 100 } }`. Keys are FLAT — `status_eq`, `createdAt_gt`, `sort: '-createdAt'`, `fields`,
219
220
  `cursor` (opaque keyset), `limit` — because a query's input travels as a query string; the query's
220
221
  own `input` declares them and implements them. `describe_resource` publishes the whitelist and
221
- `manage_resource` refuses anything outside it (`X_MCP_ARGS_INVALID`) before the query runs; a
222
+ `manage_resource` refuses anything outside it (an `isError` result carrying `X_INPUT_INVALID`) before the query runs; a
222
223
  whitelisted key the input does not declare is `X_MCP_LIST_PARAMS_INVALID` at boot.
223
224
 
225
+ ### What a client is told beyond the schema
226
+
227
+ `As of 22.10`, MCP 2025-06-18. The projection derives each from the declaration; the primitive's
228
+ `mcp` block overrides.
229
+
230
+ | Field | Source | Default |
231
+ |---|---|---|
232
+ | `initialize.instructions` | `defineAppMcp({ instructions })` / `createMcpServer({ instructions })` — a string, or `(caller) => string \| undefined` | none; a function that throws or answers blank sends none |
233
+ | `title` | `mcp: { title }` · hand-written `title` | none |
234
+ | `annotations` | `deriveAnnotations(primitive)`, then `mcp: { annotations }` key by key | query `{ readOnlyHint: true }`; action `{ readOnlyHint: false, destructiveHint: true, idempotentHint: <idempotent> }`; `openWorldHint` only when declared |
235
+ | `outputSchema` | an action's `output` with an object root; a query's `rows` as `{ rows: [<row>] }` — `toOutputSchema` / `toRowsOutputSchema`, structure only (no bounds, patterns or `additionalProperties`: a client refuses a result that misses its schema) | none |
236
+ | `structuredContent` | `structuredResult(value, wrap?)`: the serialized answer read back, beside the text block | absent |
237
+
238
+ A tool's text is **compact JSON** (`jsonResult`; the 2-space form until 22.10). The meta tools
239
+ carry their own hints: `list_resources` and `describe_resource` read-only, `manage_resource` a
240
+ write.
241
+
242
+ **Budget test.** `measureMcpSurface(server, caller)` answers `{ toolsList, listResources,
243
+ instructions }` in characters, as serialized on the wire; `assertMcpSurfaceBudget(server, caller,
244
+ { toolsList, listResources, instructions })` throws `X_MCP_SURFACE_OVER_BUDGET` naming every
245
+ surface over its ceiling. Put the derivation beside the number.
246
+
247
+ **Whose fix.** `errorAudience: 'caller'` (`defineAppMcp`'s default) renders an error's
248
+ `callerFix` — `X_FORBIDDEN`: ask the account owner for the permission; `X_MCP_SCOPE_DENIED`: ask for
249
+ a token with the scope; `X_INPUT_INVALID`: correct the named fields — where `fix` names something
250
+ only the app's developer can run. `'developer'` (`createMcpServer`'s default, the dev server's)
251
+ keeps `fix`. An error whose `docs` is not the framework's one Error-Codes page — an app's
252
+ `docs://recipes/...` — renders a fourth `docs:` line. Byte-identical to
253
+ `UltimateError.format({ audience, docs })`.
254
+
224
255
  ## Transports
225
256
 
226
257
  | Transport | Entry | Auth |
@@ -287,6 +318,12 @@ implements the emitted subset (objects, arrays, enums, `required`, `additionalPr
287
318
  bounds, `default`) and applies declared defaults. Actions still re-parse authoritatively
288
319
  inside their own handler.
289
320
 
321
+ A call that fails it is a **tool result** with `isError: true` — `X_INPUT_INVALID`, the code HTTP
322
+ answers for the same input, each issue addressed by path (`input for tool "publishPost" failed
323
+ validation: postId: required`) — `As of 22.10`. It was a JSON-RPC `-32602` until then, and clients
324
+ surface a protocol error to the human and hide it from the model, which then retried blind.
325
+ `-32602` remains for a call that is not one: no `params`, a non-string `name`.
326
+
290
327
  ## Idempotency over MCP
291
328
 
292
329
  An `idempotent: true` action's tool carries one reserved, optional argument,
@@ -301,7 +338,7 @@ key — so an agent retrying a timed-out call with the same key gets the first r
301
338
  ```
302
339
 
303
340
  The action's own input never sees the key. A non-idempotent tool does not advertise it and refuses
304
- it (`X_MCP_ARGS_INVALID`). An argument, not `params._meta`: `_meta` is for host metadata and a
341
+ it (`X_INPUT_INVALID`, as an `isError` result). An argument, not `params._meta`: `_meta` is for host metadata and a
305
342
  model-driven client cannot set it. An idempotent action whose input already names `idempotencyKey`
306
343
  is `X_MCP_IDEMPOTENCY_KEY_SHADOWED` at boot.
307
344
 
@@ -313,7 +350,9 @@ is `X_MCP_IDEMPOTENCY_KEY_SHADOWED` at boot.
313
350
  | `X_MCP_SCOPE_DENIED` | visible, but the connection's token lacks the scope |
314
351
  | `X_MCP_SCOPE_UNKNOWN` | `defineAppMcp`'s `scopes:` names a tool this server does not project |
315
352
  | `X_MCP_SCOPE_CONFLICT` | two scopes in `defineAppMcp`'s `scopes:` claim one tool |
316
- | `X_MCP_ARGS_INVALID` | arguments failed the declared schema |
353
+ | `X_INPUT_INVALID` | arguments failed the published `inputSchema` — an `isError` result since 22.10 |
354
+ | `X_MCP_ARGS_INVALID` | no longer raised on the wire (22.10); `McpArgsInvalidError` stays exported |
355
+ | `X_MCP_SURFACE_OVER_BUDGET` | `assertMcpSurfaceBudget` measured a surface over its declared ceiling |
317
356
  | `X_MCP_IDEMPOTENCY_KEY_SHADOWED` | an idempotent action's input declares `idempotencyKey`, the reserved retry-key argument |
318
357
  | `X_MCP_PROTOCOL` | malformed envelope, unknown method, bad auth header |
319
358
  | `X_MCP_QUERY_REJECTED` | `db.query` given anything but one read-only statement |
@@ -347,6 +386,7 @@ a job boundary the class is gone and the `code` is what survives — match on th
347
386
  | `McpScopeConflictError` | `X_MCP_SCOPE_CONFLICT` | `src/errors.ts` |
348
387
  | `McpScopeDeniedError` | `X_MCP_SCOPE_DENIED` | `src/errors.ts` |
349
388
  | `McpScopeUnknownError` | `X_MCP_SCOPE_UNKNOWN` | `src/errors.ts` |
389
+ | `McpSurfaceOverBudgetError` | `X_MCP_SURFACE_OVER_BUDGET` | `src/errors.ts` |
350
390
  | `McpToolDuplicateError` | `X_MCP_TOOL_DUPLICATE` | `src/errors.ts` |
351
391
  | `McpToolUndeclaredError` | `X_MCP_TOOL_UNDECLARED` | `src/errors.ts` |
352
392
  | `McpToolUnknownError` | `X_MCP_TOOL_UNKNOWN` | `src/errors.ts` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/mcp",
3
- "version": "22.9.0",
3
+ "version": "22.10.0",
4
4
  "description": "MCP server, dev tools, and the action-to-tool projection \u2014 one authz system, two surfaces",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -31,13 +31,13 @@
31
31
  "test": "bun test"
32
32
  },
33
33
  "dependencies": {
34
- "@ultimat3/action": "22.9.0",
35
- "@ultimat3/core": "22.9.0",
36
- "@ultimat3/entity": "22.9.0",
37
- "@ultimat3/http": "22.9.0",
38
- "@ultimat3/jobs": "22.9.0",
39
- "@ultimat3/policy": "22.9.0",
40
- "@ultimat3/query": "22.9.0",
41
- "@ultimat3/schema": "22.9.0"
34
+ "@ultimat3/action": "22.10.0",
35
+ "@ultimat3/core": "22.10.0",
36
+ "@ultimat3/entity": "22.10.0",
37
+ "@ultimat3/http": "22.10.0",
38
+ "@ultimat3/jobs": "22.10.0",
39
+ "@ultimat3/policy": "22.10.0",
40
+ "@ultimat3/query": "22.10.0",
41
+ "@ultimat3/schema": "22.10.0"
42
42
  }
43
43
  }
package/src/app-tool.ts CHANGED
@@ -21,7 +21,7 @@ import { asCallerContext } from './caller-context';
21
21
  import { McpToolUnsafeError } from './errors';
22
22
  import type { ProjectablePrimitive } from './from-action';
23
23
  import { toWireSchema } from './input-schema';
24
- import type { McpRole } from './registry';
24
+ import type { McpRole, McpToolAnnotations } from './registry';
25
25
 
26
26
  export interface AppToolArgs<TInput extends StandardSchemaV1> {
27
27
  readonly input: InferOutput<TInput>;
@@ -43,6 +43,13 @@ export interface AppToolDefinition<TInput extends StandardSchemaV1 = StandardSch
43
43
  * rate-limit bucket, so forgetting the flag costs throughput rather than safety.
44
44
  */
45
45
  readonly destructive?: boolean;
46
+ /** Display name for a client's UI. Absent: none published. */
47
+ readonly title?: string;
48
+ /**
49
+ * Overrides of the derived hints — `readOnlyHint: !destructive`, and for a write
50
+ * `destructiveHint: true`, `idempotentHint: false`. See `McpToolAnnotations`.
51
+ */
52
+ readonly annotations?: McpToolAnnotations;
46
53
  // Method syntax (not a property) so a tool declared with narrower args stays assignable.
47
54
  handle(args: AppToolArgs<TInput>): unknown;
48
55
  }
@@ -54,6 +61,8 @@ export interface AnyAppToolDefinition {
54
61
  readonly policy: KnownPermission;
55
62
  readonly visibleTo?: readonly McpRole[];
56
63
  readonly destructive?: boolean;
64
+ readonly title?: string;
65
+ readonly annotations?: McpToolAnnotations;
57
66
  handle(args: { readonly input: unknown; readonly ctx: Ctx }): unknown;
58
67
  }
59
68
 
@@ -89,6 +98,8 @@ export function appToolPrimitive(name: string, def: AnyAppToolDefinition): Proje
89
98
  expose: true,
90
99
  description: def.description,
91
100
  ...(visibleTo !== undefined ? { visibleTo } : {}),
101
+ ...(def.title !== undefined ? { title: def.title } : {}),
102
+ ...(def.annotations !== undefined ? { annotations: def.annotations } : {}),
92
103
  },
93
104
  inputJsonSchema: toWireSchema(def.input),
94
105
  mutates: def.destructive ?? true,
package/src/app-tools.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  // Deliberately one function: an app author should never have to know that `ToolRegistry`,
9
9
  // `frameworkResources` and `mcpHttpRoute` exist.
10
10
 
11
+ import type { ErrorAudience } from '@ultimat3/core';
11
12
  import type { RateLimitStore } from '@ultimat3/http';
12
13
  import type { StandardSchemaV1 } from '@ultimat3/schema';
13
14
  import type { AnyAppToolDefinition, AppTools } from './app-tool';
@@ -26,6 +27,7 @@ import type { McpScopes } from './scopes';
26
27
  import { withScopes } from './scopes';
27
28
  import type { CreateMcpServerInput } from './server';
28
29
  import { createMcpServer, type McpServer } from './server';
30
+ import type { McpInstructions } from './server-voice';
29
31
  import type { McpRouteDescriptor, ResolvedToken } from './transport-http';
30
32
  import { mcpHttpRoute } from './transport-http';
31
33
 
@@ -96,6 +98,29 @@ export interface DefineAppMcpInput<TSchemas extends AppToolSchemas = AppToolSche
96
98
  * answers to is `X_MCP_GROUP_UNKNOWN`, a tool in two groups `X_MCP_GROUP_CONFLICT`.
97
99
  */
98
100
  readonly groups?: McpResourceGroups;
101
+ /**
102
+ * `initialize`'s `instructions` — the advice a client that honours it puts in the model's context:
103
+ * the entry-point call, the three or four goals most sessions are, the traps. A string, or a
104
+ * function of the caller for per-population advice (the same shape as `surface`):
105
+ *
106
+ * ```ts
107
+ * instructions: (caller) =>
108
+ * caller.role?.startsWith('staff')
109
+ * ? 'Staff console. Start with list_resources({}); writes are audited.'
110
+ * : 'Start with docs({}) — recipes for sending a notification and reading its evidence.',
111
+ * ```
112
+ *
113
+ * An upgrade, never a dependency: many clients drop it, so the same advice belongs in the tool
114
+ * descriptions too.
115
+ */
116
+ readonly instructions?: McpInstructions;
117
+ /**
118
+ * Whose `fix:` line a refusal carries. Defaults to `'caller'`: this server answers remote agents,
119
+ * so an error that declares a `callerFix` (a denial, a missing scope, an invalid argument) renders
120
+ * it — "ask the account owner to grant post:publish" — instead of the developer's
121
+ * `x policy explain …`, which stays in the log line. `'developer'` restores the author's fix.
122
+ */
123
+ readonly errorAudience?: ErrorAudience;
99
124
  /** Bearer-token resolution. Omit to expose no HTTP route (stdio/embedded only). */
100
125
  resolveToken?(token: string): Promise<ResolvedToken | null> | ResolvedToken | null;
101
126
  /** Mount path. Defaults to `/mcp`. */
@@ -199,6 +224,8 @@ export function defineAppMcp<TSchemas extends AppToolSchemas>(
199
224
  serverInfo: { name: input.name ?? 'ultimate-app', version: input.version ?? '0.0.0' },
200
225
  ...(input.surface === undefined ? {} : { surface: input.surface }),
201
226
  ...(input.groups === undefined ? {} : { groups: input.groups }),
227
+ ...(input.instructions === undefined ? {} : { instructions: input.instructions }),
228
+ errorAudience: input.errorAudience ?? 'caller',
202
229
  };
203
230
  const server = createMcpServer(config);
204
231
 
package/src/errors.ts CHANGED
@@ -25,6 +25,7 @@ export const MCP_ERROR_CODES = [
25
25
  'X_MCP_LIST_PARAMS_INVALID',
26
26
  'X_MCP_OAUTH_INVALID',
27
27
  'X_MCP_IDEMPOTENCY_KEY_SHADOWED',
28
+ 'X_MCP_SURFACE_OVER_BUDGET',
28
29
  ] as const;
29
30
 
30
31
  export type McpErrorCode = (typeof MCP_ERROR_CODES)[number];
@@ -52,6 +53,7 @@ export const MCP_ERROR_TITLES: Readonly<Record<McpErrorCode, string>> = {
52
53
  X_MCP_OAUTH_INVALID: 'the MCP oauth block cannot be published as protected-resource metadata',
53
54
  X_MCP_IDEMPOTENCY_KEY_SHADOWED:
54
55
  "an idempotent action's input declares idempotencyKey, the MCP argument reserved for the idempotency key",
56
+ X_MCP_SURFACE_OVER_BUDGET: "an MCP surface an agent reads is larger than the app's budget for it",
55
57
  };
56
58
 
57
59
  // Titles must be registered for `format()` to render the contract's first line. Every code above is
@@ -119,6 +121,8 @@ export class McpScopeDeniedError extends UltimateError {
119
121
  subject === 'tool'
120
122
  ? `reconnect with a token whose scopes include "${input.scope}" — the app's resolveToken(token) is what returns them — or drop "${input.scope}" from defineAppMcp({ scopes }); scopes are fixed for the life of a connection`
121
123
  : `reconnect with a token whose scopes include "${input.scope}" — the app's resolveToken(token) is what returns them — or drop scope: '${input.scope}' from the resource declaring "${input.name}"; scopes are fixed for the life of a connection`,
124
+ // The agent's half: it cannot edit `defineAppMcp`, and its token is what it can change.
125
+ callerFix: `this token was not granted scope "${input.scope}": ask the account owner for a token that includes it, then reconnect — scopes are fixed for the life of a connection, so retrying on this one is refused the same way`,
122
126
  });
123
127
  this.scope = input.scope;
124
128
  }
@@ -449,3 +453,24 @@ export class McpIdempotencyKeyShadowedError extends UltimateError {
449
453
  });
450
454
  }
451
455
  }
456
+
457
+ /**
458
+ * `assertMcpSurfaceBudget` measured a surface over the character budget the app declared for it.
459
+ * Every over-budget surface is named in one throw, so one edit closes all of them.
460
+ */
461
+ export class McpSurfaceOverBudgetError extends UltimateError {
462
+ constructor(input: {
463
+ readonly over: readonly {
464
+ readonly surface: string;
465
+ readonly size: number;
466
+ readonly budget: number;
467
+ }[];
468
+ }) {
469
+ super({
470
+ code: 'X_MCP_SURFACE_OVER_BUDGET',
471
+ cause: `over budget: ${input.over.map((o) => `${o.surface} is ${o.size} characters, budget ${o.budget}`).join('; ')}`,
472
+ fix: "shorten the tool descriptions or instructions it names, group tools behind surface: 'meta', or raise the budget with the measured size and the reason in the same diff",
473
+ meta: { over: input.over },
474
+ });
475
+ }
476
+ }
@@ -2,7 +2,15 @@
2
2
  // and its rendering, byte-identical to `UltimateError.format()`. Split from `server.ts`, whose two
3
3
  // dispatch paths (a flat `tools/call` and `manage_resource`) and resource reads all share it.
4
4
 
5
- import { FRAMEWORK_CODE, singleLine, stringField } from '@ultimat3/core';
5
+ import type { ErrorAudience } from '@ultimat3/core';
6
+ import {
7
+ ERROR_DOCS_URL,
8
+ FRAMEWORK_CODE,
9
+ fixFor,
10
+ isUltimateError,
11
+ singleLine,
12
+ stringField,
13
+ } from '@ultimat3/core';
6
14
 
7
15
  export interface FrameworkError {
8
16
  readonly code: string;
@@ -10,6 +18,14 @@ export interface FrameworkError {
10
18
  readonly title: string;
11
19
  readonly cause: string;
12
20
  readonly fix: string;
21
+ /** The remote caller's fix, when the error declared one (`UltimateErrorInit.callerFix`). */
22
+ readonly callerFix?: string;
23
+ /**
24
+ * Where the reader goes next, ONLY when it is not the framework's one Error-Codes page — an app's
25
+ * `docs://recipes/...` guide. The default page is the same URL on every error and names nothing
26
+ * about this one, so it is not worth a line of an agent's context.
27
+ */
28
+ readonly docs?: string;
13
29
  }
14
30
 
15
31
  /**
@@ -29,6 +45,12 @@ export function asFrameworkError(error: unknown): FrameworkError | undefined {
29
45
  // `X_SCREAMING_SNAKE` and nothing else, so a value that is not one is not a framework error —
30
46
  // it takes the `-32603` branch, which leaks nothing of the throw.
31
47
  if (code === undefined || !FRAMEWORK_CODE.test(code)) return undefined;
48
+ // BRANDED only, for `@ultimat3/http`'s `factsOf` reason: `callerFix` and `docs` are lines an
49
+ // agent is told to act on, and an `UltimateError` built them in this process. A foreign object's
50
+ // are remote text.
51
+ const branded = isUltimateError(error);
52
+ const callerFix = branded ? stringField(error, 'callerFix') : undefined;
53
+ const docs = branded ? stringField(error, 'docs') : undefined;
32
54
  return {
33
55
  code,
34
56
  title: stringField(error, 'title') ?? '',
@@ -37,22 +59,37 @@ export function asFrameworkError(error: unknown): FrameworkError | undefined {
37
59
  // named no docs and no command; `code` is already narrowed to an `X_` string by the guard
38
60
  // above, so the substitute is the one command that explains exactly this code.
39
61
  fix: stringField(error, 'fix') ?? `x errors explain ${code}`,
62
+ ...(callerFix === undefined ? {} : { callerFix }),
63
+ ...(docs === undefined || docs === '' || docs === ERROR_DOCS_URL ? {} : { docs }),
40
64
  };
41
65
  }
42
66
 
43
67
  /**
44
- * The agent-readable form, BYTE-IDENTICAL to `UltimateError.format()` — one denial reads the
45
- * same over MCP as it does in the terminal, so an agent that learned the shape from `x` does
46
- * not have to learn a second one here. Dropping the title would be a second rendering of the
47
- * same contract, and the two would drift.
68
+ * The agent-readable form, BYTE-IDENTICAL to `UltimateError.format({ audience })` — plus
69
+ * `{ docs: true }` when the error names a page of its own — so one denial reads the same over MCP
70
+ * as it does in the terminal, and an agent that learned the shape from `x` does not have to learn a
71
+ * second one here. Dropping the title would be a second rendering of the same contract, and the two
72
+ * would drift.
73
+ *
74
+ * `audience` is the server's (`createMcpServer({ errorAudience })`): an app's server answers remote
75
+ * agents, who cannot run `x policy explain`, so it renders `callerFix` where the error has one.
48
76
  *
49
77
  * The bare-`code` head is the fallback for a foreign thrown object that carries `code`/`cause`
50
78
  * but no title; a real `UltimateError` always has one.
51
79
  */
52
- export function renderFrameworkError(error: FrameworkError): string {
80
+ export function renderFrameworkError(
81
+ error: FrameworkError,
82
+ audience: ErrorAudience = 'developer',
83
+ ): string {
53
84
  const head =
54
85
  error.title === ''
55
86
  ? singleLine(error.code)
56
87
  : `${singleLine(error.code)}: ${singleLine(error.title)}`;
57
- return `${head}\n cause: ${singleLine(error.cause)}\n fix: ${singleLine(error.fix)}`;
88
+ const lines = [
89
+ head,
90
+ ` cause: ${singleLine(error.cause)}`,
91
+ ` fix: ${singleLine(fixFor(error, audience))}`,
92
+ ];
93
+ if (error.docs !== undefined) lines.push(` docs: ${singleLine(error.docs)}`);
94
+ return lines.join('\n');
58
95
  }
@@ -28,8 +28,15 @@ import type { Actor } from '@ultimat3/core';
28
28
  import { isMcpExposed } from '@ultimat3/core';
29
29
  import { McpToolUndeclaredError } from './errors';
30
30
  import type { McpListParams } from './list-params';
31
- import type { AnyMcpTool, McpCaller, McpRole, McpToolResult, ToolArgs } from './registry';
32
- import { jsonResult } from './registry';
31
+ import type {
32
+ AnyMcpTool,
33
+ McpCaller,
34
+ McpRole,
35
+ McpToolAnnotations,
36
+ McpToolResult,
37
+ ToolArgs,
38
+ } from './registry';
39
+ import { jsonResult, structuredResult } from './registry';
33
40
  import type { JsonSchema } from './wire';
34
41
  import { NO_ARGS } from './wire';
35
42
 
@@ -50,6 +57,13 @@ export interface McpExposure {
50
57
  readonly visibleTo?: readonly McpRole[];
51
58
  /** Override the projected tool name. Defaults to the primitive's own name. */
52
59
  readonly name?: string;
60
+ /** Display name for a client's UI. Contract text, like `description`. Absent: none published. */
61
+ readonly title?: string;
62
+ /**
63
+ * Overrides of the derived hints, key by key — see `deriveAnnotations`. The one declaration an
64
+ * app writes for a write that destroys nothing: `annotations: { destructiveHint: false }`.
65
+ */
66
+ readonly annotations?: McpToolAnnotations;
53
67
  /** A list query's whitelist, carried to the tool for the meta surface. See `McpListParams`. */
54
68
  readonly listParams?: McpListParams;
55
69
  }
@@ -68,6 +82,18 @@ export interface ProjectablePrimitive {
68
82
  readonly inputJsonSchema?: JsonSchema;
69
83
  /** True for a mutation. Drives the rate-limit bucket; queries set it false. */
70
84
  readonly mutates?: boolean;
85
+ /** The action honours an idempotency key — a repeat is answered, not re-run. */
86
+ readonly idempotent?: boolean;
87
+ /**
88
+ * The `outputSchema` to publish, already narrowed (`toOutputSchema`) and with an OBJECT root.
89
+ * Absent: no `outputSchema`, no `structuredContent` — only the text block.
90
+ */
91
+ readonly outputJsonSchema?: JsonSchema;
92
+ /**
93
+ * The key an ARRAY answer is published under in `structuredContent` — `rows` for a query, whose
94
+ * answer is a list and whose `outputSchema` is `{ rows: [<row>] }`. Absent: the answer IS the object.
95
+ */
96
+ readonly outputWrap?: string;
71
97
  /** The one server-authoritative entry point. Runs policy, then the handler. */
72
98
  run(args: { input: unknown; actor: Actor }): Promise<unknown>;
73
99
  }
@@ -92,11 +118,17 @@ export function toolFromAction(primitive: ProjectablePrimitive): AnyMcpTool {
92
118
  const mutates = primitive.mutates ?? true;
93
119
  const visibleTo = primitive.mcp?.visibleTo;
94
120
  const listParams = primitive.mcp?.listParams;
121
+ const title = primitive.mcp?.title;
122
+ const outputSchema = primitive.outputJsonSchema;
123
+ const wrap = primitive.outputWrap;
95
124
 
96
125
  return {
97
126
  name,
127
+ ...(title !== undefined ? { title } : {}),
98
128
  description,
99
129
  inputSchema: primitive.inputJsonSchema ?? NO_ARGS,
130
+ ...(outputSchema !== undefined ? { outputSchema } : {}),
131
+ annotations: deriveAnnotations(primitive),
100
132
  destructive: mutates,
101
133
  ...(visibleTo !== undefined ? { visibleTo } : {}),
102
134
  ...(listParams !== undefined ? { listParams } : {}),
@@ -106,11 +138,51 @@ export function toolFromAction(primitive: ProjectablePrimitive): AnyMcpTool {
106
138
  // same `invoke` with an actor of kind 'user', MCP arrives with kind 'agent'. Same
107
139
  // policy, same decision.
108
140
  const output = await primitive.run({ input: args, actor: caller.actor });
109
- return jsonResult(output);
141
+ return outputSchema === undefined ? jsonResult(output) : structuredResult(output, wrap);
110
142
  },
111
143
  };
112
144
  }
113
145
 
146
+ /**
147
+ * The MCP hints a primitive's KIND already answers, with the author's `mcp.annotations` laid over
148
+ * them key by key:
149
+ *
150
+ * | primitive | readOnlyHint | destructiveHint | idempotentHint |
151
+ * |---|---|---|---|
152
+ * | query (`mutates: false`) | `true` | — | — |
153
+ * | action | `false` | `true` | `idempotent: true` on the action |
154
+ *
155
+ * `destructiveHint: true` for every action is the spec's own default made explicit, and the only
156
+ * safe one: the framework cannot tell an insert from a delete. `openWorldHint` is never derived —
157
+ * only the author knows whether a write sends mail.
158
+ */
159
+ export function deriveAnnotations(primitive: ProjectablePrimitive): McpToolAnnotations {
160
+ const derived: McpToolAnnotations =
161
+ (primitive.mutates ?? true)
162
+ ? {
163
+ readOnlyHint: false,
164
+ destructiveHint: true,
165
+ idempotentHint: primitive.idempotent === true,
166
+ }
167
+ : { readOnlyHint: true };
168
+ return { ...derived, ...definedOnly(primitive.mcp?.annotations) };
169
+ }
170
+
171
+ /**
172
+ * The four hint keys, and only booleans: an override's explicit `undefined` must not erase a
173
+ * derived hint, and a key the spec does not define is not published on its behalf.
174
+ */
175
+ function definedOnly(annotations: McpToolAnnotations | undefined): McpToolAnnotations {
176
+ if (annotations === undefined) return {};
177
+ const { readOnlyHint, destructiveHint, idempotentHint, openWorldHint } = annotations;
178
+ return {
179
+ ...(typeof readOnlyHint === 'boolean' ? { readOnlyHint } : {}),
180
+ ...(typeof destructiveHint === 'boolean' ? { destructiveHint } : {}),
181
+ ...(typeof idempotentHint === 'boolean' ? { idempotentHint } : {}),
182
+ ...(typeof openWorldHint === 'boolean' ? { openWorldHint } : {}),
183
+ };
184
+ }
185
+
114
186
  /** Alias that reads correctly at a query call site — same projection, same guarantees. */
115
187
  export const toolFromQuery = toolFromAction;
116
188
 
package/src/index.ts CHANGED
@@ -73,6 +73,7 @@ export {
73
73
  McpScopeConflictError,
74
74
  McpScopeDeniedError,
75
75
  McpScopeUnknownError,
76
+ McpSurfaceOverBudgetError,
76
77
  McpToolDuplicateError,
77
78
  McpToolUndeclaredError,
78
79
  McpToolUnknownError,
@@ -81,6 +82,7 @@ export {
81
82
  export { exposedPrimitives } from './exposed';
82
83
  export type { McpExposure, ProjectablePrimitive } from './from-action';
83
84
  export {
85
+ deriveAnnotations,
84
86
  isExposed,
85
87
  toolFromAction,
86
88
  toolFromQuery,
@@ -88,7 +90,7 @@ export {
88
90
  toolsListed,
89
91
  } from './from-action';
90
92
  export { MCP_IDEMPOTENCY_KEY_ARG } from './idempotency-arg';
91
- export { toWireSchema } from './input-schema';
93
+ export { toOutputSchema, toRowsOutputSchema, toWireSchema } from './input-schema';
92
94
  export type { ListFilterOp, McpListParams } from './list-params';
93
95
  export { DEFAULT_LIST_MAX_LIMIT, listParamsSchema } from './list-params';
94
96
  export {
@@ -113,6 +115,7 @@ export {
113
115
  MANAGE_RESOURCE,
114
116
  META_TOOL_NAMES,
115
117
  oneLineParams,
118
+ renderCatalog,
116
119
  } from './meta-surface';
117
120
  export type { McpOAuth } from './oauth-metadata';
118
121
  export {
@@ -136,6 +139,7 @@ export type {
136
139
  McpCaller,
137
140
  McpRole,
138
141
  McpTool,
142
+ McpToolAnnotations,
139
143
  McpToolResult,
140
144
  McpVerbClass,
141
145
  McpVisibility,
@@ -143,7 +147,14 @@ export type {
143
147
  ToolListEntry,
144
148
  ToolResolution,
145
149
  } from './registry';
146
- export { jsonResult, ToolRegistry, textResult, visibleToCaller } from './registry';
150
+ export {
151
+ jsonResult,
152
+ structuredResult,
153
+ ToolRegistry,
154
+ textResult,
155
+ toolListEntry,
156
+ visibleToCaller,
157
+ } from './registry';
147
158
  export type {
148
159
  FrameworkResourceProviders,
149
160
  McpPrompt,
@@ -164,6 +175,9 @@ export type { McpScopes } from './scopes';
164
175
  export { withScopes } from './scopes';
165
176
  export type { CreateMcpServerInput, McpWire } from './server';
166
177
  export { createMcpServer, McpServer } from './server';
178
+ export type { McpInstructions, McpServerVoice } from './server-voice';
179
+ export type { McpSurfaceBudget, McpSurfaceSize } from './surface-budget';
180
+ export { assertMcpSurfaceBudget, measureMcpSurface } from './surface-budget';
167
181
  export type {
168
182
  McpHttpTransportInput,
169
183
  McpProtectedResource,
@@ -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);