@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/CLAUDE.md +54 -51
- package/README.md +153 -21
- package/package.json +12 -11
- package/src/app-tool.ts +7 -8
- package/src/app-tools.ts +38 -13
- package/src/audit-hook.ts +108 -0
- package/src/audit.ts +17 -0
- package/src/caller-context.ts +2 -4
- package/src/confirmation-decide.ts +116 -0
- package/src/confirmation-errors.ts +137 -0
- package/src/confirmation-gate.ts +101 -0
- package/src/confirmation-postgres.ts +143 -0
- package/src/confirmation-schema.ts +34 -0
- package/src/confirmation-store.ts +136 -0
- package/src/confirmations.ts +147 -0
- package/src/dev-host.ts +4 -4
- package/src/errors-transport.ts +94 -0
- package/src/errors.ts +44 -102
- package/src/framework-error.ts +31 -3
- package/src/from-action.ts +30 -58
- package/src/index.ts +58 -19
- package/src/list-params.ts +9 -4
- package/src/meta-call.ts +5 -4
- package/src/meta-surface.ts +86 -19
- package/src/mount-errors.ts +1 -1
- package/src/oauth-metadata.ts +1 -1
- package/src/projectable.ts +44 -40
- package/src/readonly-sql-calls.ts +106 -0
- package/src/readonly-sql.ts +26 -97
- package/src/registry.ts +6 -5
- package/src/scopes.ts +15 -4
- package/src/server-voice.ts +1 -1
- package/src/server.ts +38 -36
- package/src/tool-thrown.ts +5 -3
- package/src/transport-http.ts +119 -17
- package/src/transport-stdio.ts +1 -1
- package/src/type-pins.ts +52 -0
- package/src/validate-args.ts +12 -4
- package/src/wire.ts +21 -40
- package/src/input-schema.ts +0 -140
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
|
|
69
|
-
//
|
|
70
|
-
//
|
|
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
|
-
* `
|
|
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
|
-
//
|
|
237
|
-
//
|
|
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
|
package/src/framework-error.ts
CHANGED
|
@@ -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 (`
|
|
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(
|
|
91
|
-
` fix: ${singleLine(fixFor(
|
|
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');
|
package/src/from-action.ts
CHANGED
|
@@ -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`
|
|
17
|
-
// what THIS server publishes; this file owns the execution half. It is
|
|
18
|
-
// `
|
|
19
|
-
// `
|
|
20
|
-
//
|
|
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 {
|
|
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
|
-
|
|
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 (`
|
|
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
|
|
108
|
-
*
|
|
109
|
-
*
|
|
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
|
-
|
|
79
|
+
function exposed(primitive: ProjectablePrimitive): boolean {
|
|
112
80
|
return isMcpExposed(primitive.mcp);
|
|
113
81
|
}
|
|
114
82
|
|
|
115
83
|
/**
|
|
116
|
-
* Project one primitive
|
|
117
|
-
*
|
|
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
|
|
120
|
-
const
|
|
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(
|
|
207
|
-
.map(
|
|
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) => !
|
|
192
|
+
const undeclared = primitives.filter((primitive) => !exposed(primitive));
|
|
221
193
|
if (undeclared.length > 0) {
|
|
222
|
-
// The primitive's own name
|
|
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 {
|
|
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 {
|
|
18
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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 {
|
|
178
|
-
export {
|
|
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';
|
package/src/list-params.ts
CHANGED
|
@@ -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
|
|
94
|
-
*
|
|
95
|
-
*
|
|
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
|
-
...
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|