@ultimat3/mcp 23.0.0 → 25.0.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/src/errors.ts CHANGED
@@ -27,6 +27,15 @@ export const MCP_ERROR_CODES = [
27
27
  'X_MCP_IDEMPOTENCY_KEY_SHADOWED',
28
28
  'X_MCP_SURFACE_OVER_BUDGET',
29
29
  'X_MCP_PATH_DUPLICATE',
30
+ 'X_MCP_SCOPE_UNCOVERED',
31
+ 'X_MCP_CONFIRMATION_PENDING',
32
+ 'X_MCP_CONFIRMATION_EXPIRED',
33
+ 'X_MCP_CONFIRMATION_REJECTED',
34
+ 'X_MCP_CONFIRMATION_DECIDED',
35
+ 'X_MCP_CONFIRMATION_UNKNOWN',
36
+ 'X_MCP_CONFIRMATION_TOOL_UNKNOWN',
37
+ 'X_MCP_CONFIRMATION_CONTESTED',
38
+ 'X_MCP_CONFIRMATION_ARGUMENTS_MISMATCH',
30
39
  ] as const;
31
40
 
32
41
  export type McpErrorCode = (typeof MCP_ERROR_CODES)[number];
@@ -56,6 +65,16 @@ export const MCP_ERROR_TITLES: Readonly<Record<McpErrorCode, string>> = {
56
65
  "an idempotent action's input declares idempotencyKey, the MCP argument reserved for the idempotency key",
57
66
  X_MCP_SURFACE_OVER_BUDGET: "an MCP surface an agent reads is larger than the app's budget for it",
58
67
  X_MCP_PATH_DUPLICATE: 'two MCP endpoints claim one path',
68
+ X_MCP_SCOPE_UNCOVERED: 'an MCP tool is covered by no scope in the scopes map',
69
+ X_MCP_CONFIRMATION_PENDING: 'a gated MCP tool call waits for a person to approve it',
70
+ X_MCP_CONFIRMATION_EXPIRED: 'an MCP confirmation expired before it was used or decided',
71
+ X_MCP_CONFIRMATION_REJECTED: 'a person rejected this MCP tool call',
72
+ X_MCP_CONFIRMATION_DECIDED: 'an MCP confirmation was already decided',
73
+ X_MCP_CONFIRMATION_UNKNOWN: 'no MCP confirmation has that id',
74
+ X_MCP_CONFIRMATION_TOOL_UNKNOWN: 'mcpConfirmations gates a tool this server does not project',
75
+ X_MCP_CONFIRMATION_CONTESTED: 'identical concurrent MCP calls kept racing one confirmation',
76
+ X_MCP_CONFIRMATION_ARGUMENTS_MISMATCH:
77
+ 'an MCP confirmation approval did not carry the arguments the agent sent',
59
78
  };
60
79
 
61
80
  // Titles must be registered for `format()` to render the contract's first line. Every code above is
@@ -65,12 +84,9 @@ registerErrorCodes(
65
84
  Object.fromEntries(Object.entries(MCP_ERROR_TITLES).map(([code, title]) => [code, { title }])),
66
85
  );
67
86
 
68
- // No `docs:` on the subclasses below. `UltimateError` fills it from `describeErrorCode(code).docs`,
69
- // which is `@ultimat3/core`'s `ERROR_DOCS_URL` — one page for every code, never one per code, because
70
- // `wiki/` is the framework's only public documentation surface and a code lives there in a TABLE ROW,
71
- // which has no anchor. The `https://ultimate.dev/errors/<code>` links this file built until 9.x
72
- // answered 404, host included, on every error it has ever thrown; restating the replacement here
73
- // would be the same constant in eight places waiting to drift again.
87
+ // No `docs:` on the subclasses below: `UltimateError` fills it from `describeErrorCode(code).docs`,
88
+ // core's one `ERROR_DOCS_URL` — a code lives in a `wiki/` TABLE ROW, which has no anchor. The
89
+ // per-code links this file built until 9.x answered 404; restating one here would drift again.
74
90
 
75
91
  /**
76
92
  * The one instruction that is SAFE on both of outcome 1's branches. `server.ts` appends it to the
@@ -183,14 +199,9 @@ export class McpToolUndeclaredError extends UltimateError {
183
199
  }
184
200
  }
185
201
 
186
- /**
187
- * Two primitives project to one tool name. Caught at boot rather than at first call: an agent
188
- * asking for the name reaches whichever copy won, which is the worst failure mode available —
189
- * a call that succeeds against the wrong handler and reports nothing.
190
- */
191
202
  /**
192
203
  * `config.ai.mcp.expose` is `true` — the DEFAULT — and the web role found nothing to mount at
193
- * `config.ai.mcp.path`. Two causes, one code: no `apps/<app>/mcp.ts` exports an `mcp`, or the one
204
+ * any `defineAppMcp` path. Two causes, one code: no `apps/<app>/mcp.ts` exports an `mcp`, or the one
194
205
  * that does was built without `resolveToken`, so `defineAppMcp` built no `route` (a token resolver
195
206
  * is what turns a bearer header into an actor, and an endpoint with no way to name its caller is
196
207
  * not one the framework will serve). Logged once per boot by the web role, never thrown: an app
@@ -224,6 +235,10 @@ export class McpAppUnmountedError extends UltimateError {
224
235
  }
225
236
  }
226
237
 
238
+ /**
239
+ * Two primitives project to one tool name. Refused at boot, not at first call: the agent would
240
+ * reach whichever copy won — a call that succeeds against the wrong handler and reports nothing.
241
+ */
227
242
  export class McpToolDuplicateError extends UltimateError {
228
243
  /**
229
244
  * Which declaration each copy came from, when the projector knows — carried the way
@@ -233,10 +248,8 @@ export class McpToolDuplicateError extends UltimateError {
233
248
  readonly declaredBy: readonly string[];
234
249
 
235
250
  constructor(input: { name: string; declaredBy?: readonly string[] | undefined }) {
236
- // The old `fix:` named "the primitive's export name, or the `tools` record key" for every
237
- // raiser. On `@ultimat3/admin`'s path the colliding string is an `AdminAction.name` and
238
- // neither of those exists, so the reader was sent to two places that do not hold it. Where
239
- // the projector knows the sources, the fix names THEM instead of guessing.
251
+ // Where the projector knows the sources the fix names THEM: on `@ultimat3/admin`'s path the
252
+ // colliding string is an `AdminAction.name`, neither an export name nor a `tools` key.
240
253
  const sites = input.declaredBy ?? [];
241
254
  const from = sites.length > 0 ? ` (declared by ${sites.join(' and ')})` : '';
242
255
  super({
@@ -311,6 +324,21 @@ export class McpScopeConflictError extends UltimateError {
311
324
  }
312
325
  }
313
326
 
327
+ /** `scopes:` names none of these tools, so every token ran them — while a bearer mount over the
328
+ * same map serves them to nobody. Refused at boot, every offender at once. */
329
+ export class McpScopeUncoveredError extends UltimateError {
330
+ readonly uncovered: readonly string[];
331
+ constructor(input: { uncovered: readonly string[] }) {
332
+ const names = input.uncovered.map((name) => `"${name}"`).join(', ');
333
+ super({
334
+ code: 'X_MCP_SCOPE_UNCOVERED',
335
+ cause: `defineAppMcp was given a scopes map that names no scope for ${names}, so no token scope gates them`,
336
+ fix: `[${input.uncovered.map((name) => `'${name}'`).join(', ')}] — append each to the list of the scope a token must hold for it, in the scopes map passed to defineAppMcp; or drop it from include/actions/queries/tools`,
337
+ });
338
+ this.uncovered = input.uncovered;
339
+ }
340
+ }
341
+
314
342
  /** A malformed envelope or an unsupported method — a client bug, not an authz outcome. */
315
343
  export class McpProtocolError extends UltimateError {
316
344
  constructor(input: { cause: string; fix?: string }) {
@@ -363,92 +391,6 @@ export class McpNotBranchDbError extends UltimateError {
363
391
  }
364
392
  }
365
393
 
366
- /**
367
- * One message larger than the transport holds. Its own code, for the reason `X_MCP_RATE_LIMITED`
368
- * below is not `X_RATE_LIMITED`: `@ultimat3/http`'s `X_BODY_INVALID` names `bodyLimitBytes` on the
369
- * HTTP pipeline, and this route never passes through it — the knob is `mcpHttpRoute`'s (or
370
- * `defineAppMcp`'s, which forwards it), and over stdio it is `serveStdio({ lineLimitBytes })`, a
371
- * ceiling in characters rather than bytes. One class for both transports, because the condition is
372
- * one: the peer sent more in one message than this end agreed to hold, and the two answers must
373
- * name the same code so an agent learns it once.
374
- *
375
- * Measured through ai-maxxing's `POST /mcp` on 2026-09-07: the 413 was a bare `-32600` reading
376
- * `request body is at least N bytes, limit is M` — two numbers and no next step, while the 401,
377
- * 403 and 429 beside it all carried `{ code, cause, fix }`. A box agent sending a large
378
- * `promptSession` had nothing to act on. The fix names both moves, send less or raise the cap,
379
- * because which one is right is the author's call: a prompt can be split, a paged result cannot
380
- * always be, and a cap set by default was never a decision anyone made about this app.
381
- */
382
- export class McpBodyTooLargeError extends UltimateError {
383
- readonly limit: number;
384
-
385
- constructor(input: { transport: 'http' | 'stdio'; limit: number; over?: number | undefined }) {
386
- const http = input.transport === 'http';
387
- // A NUMBER in the fix, never a `<n>`: the contract is a one-line edit that runs as written. The
388
- // cap itself would run and change nothing; twice the larger of the cap and what arrived is a
389
- // value that admits this message, whichever of the two was the shorter measure. Both are
390
- // finite non-negative integers by the transports' own screens, so the product is too.
391
- const raised = 2 * Math.max(input.limit, input.over ?? 0);
392
- super({
393
- code: 'X_MCP_BODY_TOO_LARGE',
394
- cause: http
395
- ? `request body is at least ${input.over ?? input.limit} bytes, limit is ${input.limit}`
396
- : `one message exceeded ${input.limit} characters and was dropped`,
397
- fix: http
398
- ? `send less in one request — page a large read, split a long prompt across calls — or raise the cap where the route is built: mcpHttpRoute({ bodyLimitBytes: ${raised} }) or defineAppMcp({ bodyLimitBytes: ${raised} })`
399
- : `send one JSON-RPC message per line, each under the cap — split a large tool result into paged calls — or raise it where the transport is started: serveStdio({ lineLimitBytes: ${raised} })`,
400
- // The numbers as FIELDS, not only prose: a `--json` reader and the wire's `data` both want
401
- // the limit without re-parsing a sentence.
402
- meta: {
403
- transport: input.transport,
404
- limit: input.limit,
405
- ...(input.over === undefined ? {} : { over: input.over }),
406
- },
407
- });
408
- this.limit = input.limit;
409
- }
410
- }
411
-
412
- /**
413
- * The transport's own throttle, and its own CODE rather than `@ultimat3/http`'s `X_RATE_LIMITED`
414
- * — not because the maths differ (they are the same `Bucket`, the same store and the same
415
- * `rateLimitDecision`) but because the KNOB does. `X_RATE_LIMITED` tells an operator to raise
416
- * `rateLimit.buckets` through `configureHttp()`, which governs the HTTP pipeline and not this route: all
417
- * MCP traffic is one URL and the class comes from the parsed body, so `mcpHttpRoute` meters itself.
418
- * A fix line that runs and changes nothing is worse than none — the same rule
419
- * `@ultimat3/realtime`'s `SubscriptionLimitError` follows when it names the knob over the default.
420
- *
421
- * The bucket key names the ACTOR and never reaches the caller: a 429 is provokable by anyone
422
- * holding a valid token, and an org id or an actor id in the body of one is a leak wearing a
423
- * throttle's clothes.
424
- */
425
- export class McpRateLimitedError extends UltimateError {
426
- constructor(input: { verbClass: string; limit: number; retryAfterSeconds: number }) {
427
- super({
428
- code: 'X_MCP_RATE_LIMITED',
429
- cause: `this caller has spent its ${input.limit} ${input.verbClass} requests per minute; the bucket refills in ${input.retryAfterSeconds}s`,
430
- fix: `wait ${input.retryAfterSeconds}s — the Retry-After header carries the same number — or raise it where the route is built: mcpHttpRoute({ rateLimits: { ${input.verbClass}: <n> } }), or defineAppMcp({ rateLimits })`,
431
- // why `meta` and not only the sentence: `transport-http.ts` writes `Retry-After` by hand, so
432
- // this route is correct today. `@ultimat3/http`'s `retryAfterOf` reads the header off
433
- // `meta.retryAfterSeconds` — so the day an MCP host is mounted inside that pipeline, an error
434
- // carrying the number only in its prose sheds a caller with no delay to honour, which is the
435
- // stampede `admit` exists to spread.
436
- meta: { retryAfterSeconds: input.retryAfterSeconds },
437
- });
438
- }
439
- }
440
-
441
- /** `defineAppMcp({ oauth })` refused at boot — see `oauth-metadata.ts`. */
442
- export class McpOAuthInvalidError extends UltimateError {
443
- constructor(reason: string) {
444
- super({
445
- code: 'X_MCP_OAUTH_INVALID',
446
- cause: `defineAppMcp({ oauth }) cannot be published as protected-resource metadata: ${reason}`,
447
- fix: "defineAppMcp({ ..., oauth: { authorizationServers: ['https://www.example.com'] } }) — at least one absolute https (or http://localhost) issuer URL; resource and resourceDocumentation, when set, absolute URLs with no fragment",
448
- });
449
- }
450
- }
451
-
452
394
  /**
453
395
  * An idempotent action's tool carries the reserved `idempotencyKey` argument, and this action's
454
396
  * own input already names a field that. One argument cannot be both the input a handler reads and
@@ -1,16 +1,20 @@
1
1
  // A value a tool THREW, read as a framework error (code, title, cause, fix) or rejected as a bug —
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
+ // Also what a REMOTE caller may read of it: a 5xx cause only when core's `hasPublicCause` says so.
4
5
 
5
6
  import type { ErrorAudience } from '@ultimat3/core';
6
7
  import {
7
8
  ERROR_DOCS_URL,
8
9
  FRAMEWORK_CODE,
9
10
  fixFor,
11
+ hasPublicCause,
10
12
  isUltimateError,
13
+ renderFixShellArg,
11
14
  singleLine,
12
15
  stringField,
13
16
  } from '@ultimat3/core';
17
+ import { statusFor } from '@ultimat3/http';
14
18
 
15
19
  export interface FrameworkError {
16
20
  readonly code: string;
@@ -64,6 +68,29 @@ export function asFrameworkError(error: unknown): FrameworkError | undefined {
64
68
  };
65
69
  }
66
70
 
71
+ /**
72
+ * The fixed sentence a remote caller reads in place of a hidden cause. The real one is in the
73
+ * audit line this call wrote and in the error monitor, never on the wire.
74
+ */
75
+ export const HIDDEN_CAUSE =
76
+ 'the server failed while handling this call; the details are in this process\u2019s logs';
77
+
78
+ /**
79
+ * What THIS audience may read of a thrown error — the verdict `@ultimat3/http`'s `toProblem` gives
80
+ * the problem document, asked of the same two tables. A remote caller (`'caller'`) of a code whose
81
+ * status is 5xx gets the cause only when core's `hasPublicCause` says so: `X_DB_STATEMENT_FAILED`
82
+ * carries the server's message and the statement, and an MCP client is a remote client. The
83
+ * developer's `fix` is withheld with it — a driver's fix is built from the same statement — and
84
+ * `callerFix`, authored for the caller, still goes. `'developer'` (the dev server) keeps both, as
85
+ * HTTP's `dev: true` does: the reader is the author fixing their own SQL.
86
+ */
87
+ export function forAudience(error: FrameworkError, audience: ErrorAudience): FrameworkError {
88
+ if (audience === 'developer') return error;
89
+ if (statusFor(error.code) < 500 || hasPublicCause(error.code)) return error;
90
+ const fix = `x errors explain ${renderFixShellArg(error.code, '<code>')} --json`;
91
+ return { ...error, cause: HIDDEN_CAUSE, fix };
92
+ }
93
+
67
94
  /**
68
95
  * The agent-readable form, BYTE-IDENTICAL to `UltimateError.format({ audience })` — plus
69
96
  * `{ docs: true }` when the error names a page of its own — so one denial reads the same over MCP
@@ -71,7 +98,7 @@ export function asFrameworkError(error: unknown): FrameworkError | undefined {
71
98
  * second one here. Dropping the title would be a second rendering of the same contract, and the two
72
99
  * would drift.
73
100
  *
74
- * `audience` is the server's (`createMcpServer({ errorAudience })`): an app's server answers remote
101
+ * `audience` is the server's (`mcpServer({ errorAudience })`): an app's server answers remote
75
102
  * agents, who cannot run `x policy explain`, so it renders `callerFix` where the error has one.
76
103
  *
77
104
  * The bare-`code` head is the fallback for a foreign thrown object that carries `code`/`cause`
@@ -85,10 +112,11 @@ export function renderFrameworkError(
85
112
  error.title === ''
86
113
  ? singleLine(error.code)
87
114
  : `${singleLine(error.code)}: ${singleLine(error.title)}`;
115
+ const shown = forAudience(error, audience);
88
116
  const lines = [
89
117
  head,
90
- ` cause: ${singleLine(error.cause)}`,
91
- ` fix: ${singleLine(fixFor(error, audience))}`,
118
+ ` cause: ${singleLine(shown.cause)}`,
119
+ ` fix: ${singleLine(fixFor(shown, audience))}`,
92
120
  ];
93
121
  if (error.docs !== undefined) lines.push(` docs: ${singleLine(error.docs)}`);
94
122
  return lines.join('\n');
@@ -13,25 +13,20 @@
13
13
  // tool in `defineAppMcp`'s `scopes:` map (see `scopes.ts`) — declared once, next to the
14
14
  // other tools that same token capability covers, never guessed from the action.
15
15
  //
16
- // `toWireSchema` (`input-schema.ts`), reached through `projectable.ts`, owns the schema half of
17
- // what THIS server publishes; this file owns the execution half. It is deliberately not
18
- // `toMcpTool` in @ultimat3/action — which this comment claimed until 2026-08, while
19
- // `projectable.ts` had always called `toWireSchema`. The two are not interchangeable:
20
- // `toMcpTool` emits the full draft-07 vocabulary and `toWireSchema` narrows to the subset
21
- // `validate-args.ts` can ENFORCE, so a keyword in one and not the other is a contract an agent
22
- // is judged against and was never shown. `pattern` was exactly that until 2026-08.
23
- // `action` is tier 3 and this package is tier 4, so the two cannot share one function today; the
24
- // shared home would be `@ultimat3/schema`. `packages/mcp/src/cross-surface.test.ts` is what keeps
25
- // them from diverging again.
16
+ // `toWireSchema` from `@ultimat3/schema`, reached through `projectable.ts`, owns the schema half
17
+ // of what THIS server publishes; this file owns the execution half. It is the ONE tool projection:
18
+ // `@ultimat3/action`'s `.tool()` and `@ultimat3/query`'s were a second, built a tier below, and
19
+ // disagreed with what `tools/list` served — deleted in 25.0.0 (O-tool), because tier 3 cannot
20
+ // import this one to return it. `cross-surface.test.ts` pins `toolFrom` to the served entry.
26
21
 
27
- import type { Actor } from '@ultimat3/core';
22
+ import type { Actor, McpExposureDeclaration } from '@ultimat3/core';
28
23
  import { isMcpExposed } from '@ultimat3/core';
29
24
  import { McpToolUndeclaredError } from './errors';
30
- import type { McpListParams } from './list-params';
25
+ import type { ListedPrimitive } from './projectable';
26
+ import { asProjectable } from './projectable';
31
27
  import type {
32
28
  AnyMcpTool,
33
29
  McpCaller,
34
- McpRole,
35
30
  McpToolAnnotations,
36
31
  McpToolResult,
37
32
  ToolArgs,
@@ -40,34 +35,6 @@ import { jsonResult, structuredResult } from './registry';
40
35
  import type { JsonSchema } from './wire';
41
36
  import { NO_ARGS } from './wire';
42
37
 
43
- /**
44
- * MCP exposure as declared on an action/query (`mcp: { expose: true, description }`).
45
- * `visibleTo` restricts which roles may enumerate the tool; it is a catalog concern, not
46
- * an authz one — the policy still decides.
47
- */
48
- export interface McpExposure {
49
- readonly expose?: boolean;
50
- /**
51
- * Contract text, not UI text: the same string is the OpenAPI operation `summary`, and
52
- * `buildOpenApi`'s bytes are what `x verify` diffs. Routing it through the ambient,
53
- * request-scoped `t()` would make that artifact locale-dependent — see
54
- * `ActionMcp.description` in @ultimat3/action.
55
- */
56
- readonly description?: string;
57
- readonly visibleTo?: readonly McpRole[];
58
- /** Override the projected tool name. Defaults to the primitive's own name. */
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;
67
- /** A list query's whitelist, carried to the tool for the meta surface. See `McpListParams`. */
68
- readonly listParams?: McpListParams;
69
- }
70
-
71
38
  /**
72
39
  * The surface this projection needs from a primitive. Structurally satisfied by `Action`
73
40
  * and by `query`'s runnable handle from @ultimat3/action / @ultimat3/query — restated here
@@ -77,7 +44,8 @@ export interface McpExposure {
77
44
  export interface ProjectablePrimitive {
78
45
  readonly name: string;
79
46
  readonly description?: string;
80
- readonly mcp?: McpExposure;
47
+ /** Core's ONE `mcp` block, as the action or query declared it — never a restatement here. */
48
+ readonly mcp?: McpExposureDeclaration;
81
49
  /** JSON Schema of the input, narrowed to the wire subset by `toWireSchema`. */
82
50
  readonly inputJsonSchema?: JsonSchema;
83
51
  /** True for a mutation. Drives the rate-limit bucket; queries set it false. */
@@ -85,7 +53,7 @@ export interface ProjectablePrimitive {
85
53
  /** The action honours an idempotency key — a repeat is answered, not re-run. */
86
54
  readonly idempotent?: boolean;
87
55
  /**
88
- * The `outputSchema` to publish, already narrowed (`toOutputSchema`) and with an OBJECT root.
56
+ * The `outputSchema` to publish, already narrowed (`toWireOutputSchema`) with an OBJECT root.
89
57
  * Absent: no `outputSchema`, no `structuredContent` — only the text block.
90
58
  */
91
59
  readonly outputJsonSchema?: JsonSchema;
@@ -104,20 +72,27 @@ export interface ProjectablePrimitive {
104
72
  }
105
73
 
106
74
  /**
107
- * True when the primitive opted into MCP. Opt-in, never opt-out: silence exposes nothing.
108
- * `isMcpExposed` from `@ultimat3/core` is the one predicate — the manifest fact, the OpenAPI
109
- * hint and both tool projections all ask it, so none of them can answer differently.
75
+ * True when the primitive opted into MCP — `@ultimat3/core`'s `isMcpExposed`, the one predicate
76
+ * the manifest fact and the OpenAPI hint ask too. Private: the exported wrapper was a second name
77
+ * for core's function (plan 101, M3), and an app asks core's directly.
110
78
  */
111
- export function isExposed(primitive: ProjectablePrimitive): boolean {
79
+ function exposed(primitive: ProjectablePrimitive): boolean {
112
80
  return isMcpExposed(primitive.mcp);
113
81
  }
114
82
 
115
83
  /**
116
- * Project one primitive. Throws nothing, and does not re-check exposure: the two list
117
- * projections below decide what an un-exposed primitive means — skip it, or refuse it.
84
+ * Project one primitive — an `action()`, a `query()`, or a hand-built `ProjectablePrimitive` —
85
+ * into the tool this server serves. THE projection: `toolListEntry(toolFrom(publishPost))`
86
+ * is, field for field, the entry `tools/list` answers for it. Does not re-check exposure: the two
87
+ * list projections below decide what an un-exposed primitive means — skip it, or refuse it. A
88
+ * real action or query that was never registered throws `X_ACTION_UNREGISTERED` /
89
+ * `X_QUERY_UNREGISTERED`, rather than projecting a tool called `''`.
118
90
  */
119
- export function toolFromAction(primitive: ProjectablePrimitive): AnyMcpTool {
120
- const name = primitive.mcp?.name ?? primitive.name;
91
+ export function toolFrom(listed: ListedPrimitive): AnyMcpTool {
92
+ const primitive = asProjectable(listed);
93
+ // The primitive's own name and nothing else: `mcp.name` was a second naming field no
94
+ // declaration could set, deleted in 25.0.0 (plan 101, M4).
95
+ const name = primitive.name;
121
96
  const description =
122
97
  primitive.mcp?.description ?? primitive.description ?? `Run the "${primitive.name}" action.`;
123
98
  const mutates = primitive.mutates ?? true;
@@ -191,9 +166,6 @@ function definedOnly(annotations: McpToolAnnotations | undefined): McpToolAnnota
191
166
  };
192
167
  }
193
168
 
194
- /** Alias that reads correctly at a query call site — same projection, same guarantees. */
195
- export const toolFromQuery = toolFromAction;
196
-
197
169
  /**
198
170
  * Project every exposed primitive, SKIPPING the rest. Stable name order.
199
171
  *
@@ -203,8 +175,8 @@ export const toolFromQuery = toolFromAction;
203
175
  */
204
176
  export function toolsFrom(primitives: readonly ProjectablePrimitive[]): readonly AnyMcpTool[] {
205
177
  return primitives
206
- .filter(isExposed)
207
- .map(toolFromAction)
178
+ .filter(exposed)
179
+ .map(toolFrom)
208
180
  .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
209
181
  }
210
182
 
@@ -217,9 +189,9 @@ export function toolsFrom(primitives: readonly ProjectablePrimitive[]): readonly
217
189
  * throwing so one edit closes all of them.
218
190
  */
219
191
  export function toolsListed(primitives: readonly ProjectablePrimitive[]): readonly AnyMcpTool[] {
220
- const undeclared = primitives.filter((primitive) => !isExposed(primitive));
192
+ const undeclared = primitives.filter((primitive) => !exposed(primitive));
221
193
  if (undeclared.length > 0) {
222
- // The primitive's own name, not `mcp.name`: it is what the author greps for.
194
+ // The primitive's own name — the tool's, and what the author greps for.
223
195
  throw new McpToolUndeclaredError({ names: undeclared.map((primitive) => primitive.name) });
224
196
  }
225
197
  return toolsFrom(primitives);
package/src/index.ts CHANGED
@@ -1,9 +1,11 @@
1
1
  // Public API of @ultimat3/mcp. Explicit — nothing is re-exported by wildcard, so the
2
2
  // surface an app or an agent can reach is exactly this list.
3
3
 
4
+ /** `@ultimat3/http`'s `RequestFacts`, under the name this package's resolver docs use. One shape. */
5
+ export type { RequestFacts as McpRequestFacts } from '@ultimat3/http';
4
6
  /** Re-exported so a `defineAppMcp` file needs one import, not two. Same object as schema's. */
5
7
  export type { Infer } from '@ultimat3/schema';
6
- export { formatIssues, t } from '@ultimat3/schema';
8
+ export { t } from '@ultimat3/schema';
7
9
  export type {
8
10
  AnyAppToolDefinition,
9
11
  AppToolArgs,
@@ -14,8 +16,48 @@ export type { AppMcp, AppToolSchemas, DefineAppMcpInput } from './app-tools';
14
16
  export { defineAppMcp } from './app-tools';
15
17
  export type { McpAuditEntry, McpOutcome, McpResourceAuditEntry } from './audit';
16
18
  export { auditResourceRead, auditToolCall, outcomeForCode } from './audit';
17
- export type { CreateDevServerInput } from './dev-host';
18
- export { createDevServer, devHost, frameworkIntrospection } from './dev-host';
19
+ export type {
20
+ McpAuditEvent,
21
+ McpAuditHook,
22
+ McpAuditor,
23
+ McpAuditorInput,
24
+ McpAuthRefusal,
25
+ } from './audit-hook';
26
+ export { mcpAuditor } from './audit-hook';
27
+ export {
28
+ McpConfirmationArgumentsMismatchError,
29
+ McpConfirmationContestedError,
30
+ McpConfirmationDecidedError,
31
+ McpConfirmationExpiredError,
32
+ McpConfirmationPendingError,
33
+ McpConfirmationRejectedError,
34
+ McpConfirmationToolUnknownError,
35
+ McpConfirmationUnknownError,
36
+ } from './confirmation-errors';
37
+ export {
38
+ inputDigest,
39
+ MCP_CONFIRMATION_ARGUMENTS_PURPOSE,
40
+ MCP_CONFIRMATION_DIGEST_PURPOSE,
41
+ withConfirmations,
42
+ } from './confirmation-gate';
43
+ export type { PostgresConfirmationStoreOptions } from './confirmation-postgres';
44
+ export { postgresConfirmationStore } from './confirmation-postgres';
45
+ // The confirmation table's DDL is `@ultimat3/mcp/schema`'s alone — one import path for one statement.
46
+ export type {
47
+ McpConfirmation,
48
+ McpConfirmationDraft,
49
+ McpConfirmationStatus,
50
+ McpConfirmationStore,
51
+ } from './confirmation-store';
52
+ export { memoryConfirmationStore } from './confirmation-store';
53
+ export type {
54
+ McpConfirmationDecision,
55
+ McpConfirmations,
56
+ McpConfirmationsInput,
57
+ } from './confirmations';
58
+ export { DEFAULT_MCP_CONFIRMATION_TTL_MS, mcpConfirmations } from './confirmations';
59
+ export type { DevMcpServerInput } from './dev-host';
60
+ export { devHost, devMcpServer, frameworkIntrospection } from './dev-host';
19
61
  export type {
20
62
  DevCapabilities,
21
63
  DevHost,
@@ -62,16 +104,14 @@ export {
62
104
  MCP_ERROR_TITLES,
63
105
  McpAppUnmountedError,
64
106
  McpArgsInvalidError,
65
- McpBodyTooLargeError,
66
107
  McpIdempotencyKeyShadowedError,
67
108
  McpNotBranchDbError,
68
- McpOAuthInvalidError,
69
109
  McpProtocolError,
70
110
  McpQueryRejectedError,
71
- McpRateLimitedError,
72
111
  McpResourceDuplicateError,
73
112
  McpScopeConflictError,
74
113
  McpScopeDeniedError,
114
+ McpScopeUncoveredError,
75
115
  McpScopeUnknownError,
76
116
  McpSurfaceOverBudgetError,
77
117
  McpToolDuplicateError,
@@ -79,18 +119,15 @@ export {
79
119
  McpToolUnknownError,
80
120
  McpToolUnsafeError,
81
121
  } from './errors';
82
- export { exposedPrimitives } from './exposed';
83
- export type { McpExposure, ProjectablePrimitive } from './from-action';
84
122
  export {
85
- deriveAnnotations,
86
- isExposed,
87
- toolFromAction,
88
- toolFromQuery,
89
- toolsFrom,
90
- toolsListed,
91
- } from './from-action';
123
+ McpBodyTooLargeError,
124
+ McpOAuthInvalidError,
125
+ McpRateLimitedError,
126
+ } from './errors-transport';
127
+ export { exposedPrimitives } from './exposed';
128
+ export type { ProjectablePrimitive } from './from-action';
129
+ export { deriveAnnotations, toolFrom, toolsFrom, toolsListed } from './from-action';
92
130
  export { MCP_IDEMPOTENCY_KEY_ARG } from './idempotency-arg';
93
- export { toOutputSchema, toRowsOutputSchema, toWireSchema } from './input-schema';
94
131
  export type { ListFilterOp, McpListParams } from './list-params';
95
132
  export { DEFAULT_LIST_MAX_LIMIT, listParamsSchema } from './list-params';
96
133
  export {
@@ -126,7 +163,7 @@ export {
126
163
  protectedResourceMetadata,
127
164
  } from './oauth-metadata';
128
165
  export type { ListedPrimitive } from './projectable';
129
- export { asProjectable } from './projectable';
166
+ export { asProjectable, toRowsOutputSchema } from './projectable';
130
167
  export type { QueryLimits, QueryResult, QueryRows } from './query-limits';
131
168
  export {
132
169
  capQueryRows,
@@ -174,14 +211,15 @@ export {
174
211
  } from './resources';
175
212
  export type { McpScopes } from './scopes';
176
213
  export { withScopes } from './scopes';
177
- export type { CreateMcpServerInput, McpWire } from './server';
178
- export { createMcpServer, McpServer } from './server';
214
+ export type { McpServer, McpServerInput, McpWire } from './server';
215
+ export { mcpServer } from './server';
179
216
  export type { McpInstructions, McpServerVoice } from './server-voice';
180
217
  export type { McpSurfaceBudget, McpSurfaceSize } from './surface-budget';
181
218
  export { assertMcpSurfaceBudget, measureMcpSurface } from './surface-budget';
182
219
  export type {
183
220
  McpHttpTransportInput,
184
221
  McpProtectedResource,
222
+ McpRateLimits,
185
223
  McpRequestOrigin,
186
224
  McpRouteDescriptor,
187
225
  ResolvedToken,
@@ -189,6 +227,7 @@ export type {
189
227
  export {
190
228
  DEFAULT_MCP_BODY_LIMIT_BYTES,
191
229
  MCP_RATE_LIMITS,
230
+ MCP_UNAUTHENTICATED_LIMIT,
192
231
  mcpHttpRoute,
193
232
  } from './transport-http';
194
233
  export type { StdioTransportInput } from './transport-stdio';
@@ -90,13 +90,18 @@ export function listParamsProperties(spec: McpListParams): readonly [string, Jso
90
90
  }
91
91
 
92
92
  /**
93
- * The schema `manage_resource` validates a list call against: the tool's own input with every
94
- * list key NARROWED to the whitelist, and nothing else admitted. `Object.fromEntries` (a define,
95
- * never `[k] =`) because a field name is the app's string and `__proto__` must land as a key.
93
+ * The schema `manage_resource` validates a list call against: the whitelist, NARROWED, plus the
94
+ * input keys the query REQUIRES (a `projectId` the list cannot run without), and nothing else
95
+ * admitted. Decided by provenance — the author's `required` and the author's whitelist — never by
96
+ * whether a key looks like a filter: an optional `status_in` or `includeDeleted` the whitelist
97
+ * left out was admitted until 2026-10. `Object.fromEntries` (a define, never `[k] =`) because a
98
+ * field name is the app's string and `__proto__` must land as a key.
96
99
  */
97
100
  export function listParamsSchema(spec: McpListParams, base: JsonSchema): JsonSchema {
101
+ const declared = base.properties ?? {};
102
+ const required = (base.required ?? []).filter((key) => Object.hasOwn(declared, key));
98
103
  const properties = Object.fromEntries([
99
- ...Object.entries(base.properties ?? {}),
104
+ ...required.map((key): [string, JsonSchema] => [key, declared[key] ?? {}]),
100
105
  ...listParamsProperties(spec),
101
106
  ]);
102
107
  return {
package/src/meta-call.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  // its flat name. Split from `server.ts` for the size ceiling only — it holds no gate of its own:
3
3
  // every call it runs goes back through the server's `resolve` → `dispatch`, the flat path.
4
4
 
5
- import { auditToolCall } from './audit';
5
+ import type { McpAuditor } from './audit-hook';
6
6
  import { META_UNKNOWN_FIX } from './meta-errors';
7
7
  import type { MetaSurface } from './meta-surface';
8
8
  import {
@@ -21,6 +21,7 @@ import { errorResponse, METHOD_NOT_FOUND, resultResponse } from './wire';
21
21
  /** The server's own two steps, handed in so this file cannot grow a second path. */
22
22
  export interface MetaHost {
23
23
  readonly tools: ToolRegistry;
24
+ readonly audit: McpAuditor;
24
25
  dispatch(
25
26
  id: JsonRpcId,
26
27
  name: string,
@@ -53,21 +54,21 @@ export async function metaCall(
53
54
  return host.dispatch(id, name, { kind: 'invalid-args', name, issues: args.issues }, caller);
54
55
  }
55
56
  if (name === LIST_RESOURCES) {
56
- auditToolCall({ tool: name, outcome: 'ok', caller });
57
+ host.audit.toolCall({ tool: name, outcome: 'ok', caller });
57
58
  return resultResponse(id, textResult(renderCatalog(meta.listResources(caller))));
58
59
  }
59
60
  if (name === DESCRIBE_RESOURCE) {
60
61
  const described = meta.describe(args.value['resources'] as readonly string[], caller);
61
62
  if (!described.ok) {
62
63
  // Absent and hidden are one answer, with no `data`, exactly as for a tool.
63
- auditToolCall({ tool: name, outcome: 'hidden', caller, code: 'X_MCP_TOOL_UNKNOWN' });
64
+ host.audit.toolCall({ tool: name, outcome: 'hidden', caller, code: 'X_MCP_TOOL_UNKNOWN' });
64
65
  return errorResponse(
65
66
  id,
66
67
  METHOD_NOT_FOUND,
67
68
  `resource not found: ${described.name} — ${META_UNKNOWN_FIX}`,
68
69
  );
69
70
  }
70
- auditToolCall({ tool: name, outcome: 'ok', caller });
71
+ host.audit.toolCall({ tool: name, outcome: 'ok', caller });
71
72
  return resultResponse(id, jsonResult({ resources: described.resources }));
72
73
  }
73
74
  // `manage_resource`: address → the SAME resolve and dispatch a flat call takes, audited under