@ultimat3/mcp 24.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 +40 -43
- package/README.md +147 -18
- package/package.json +12 -11
- package/src/app-tool.ts +6 -6
- package/src/app-tools.ts +32 -8
- 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 +1 -1
- package/src/from-action.ts +28 -50
- package/src/index.ts +55 -17
- package/src/meta-call.ts +5 -4
- package/src/meta-surface.ts +87 -22
- package/src/mount-errors.ts +1 -1
- package/src/oauth-metadata.ts +1 -1
- package/src/projectable.ts +18 -34
- package/src/registry.ts +6 -5
- package/src/scopes.ts +15 -4
- package/src/server-voice.ts +1 -1
- package/src/server.ts +34 -33
- package/src/tool-thrown.ts +5 -3
- package/src/transport-http.ts +42 -18
- package/src/transport-stdio.ts +1 -1
- package/src/type-pins.ts +52 -0
- package/src/wire.ts +17 -0
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
|
@@ -98,7 +98,7 @@ export function forAudience(error: FrameworkError, audience: ErrorAudience): Fra
|
|
|
98
98
|
* second one here. Dropping the title would be a second rendering of the same contract, and the two
|
|
99
99
|
* would drift.
|
|
100
100
|
*
|
|
101
|
-
* `audience` is the server's (`
|
|
101
|
+
* `audience` is the server's (`mcpServer({ errorAudience })`): an app's server answers remote
|
|
102
102
|
* agents, who cannot run `x policy explain`, so it renders `callerFix` where the error has one.
|
|
103
103
|
*
|
|
104
104
|
* The bare-`code` head is the fallback for a foreign thrown object that carries `code`/`cause`
|
package/src/from-action.ts
CHANGED
|
@@ -14,18 +14,19 @@
|
|
|
14
14
|
// other tools that same token capability covers, never guessed from the action.
|
|
15
15
|
//
|
|
16
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.
|
|
18
|
-
// `.tool()`
|
|
19
|
-
//
|
|
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.
|
|
20
21
|
|
|
21
|
-
import type { Actor } from '@ultimat3/core';
|
|
22
|
+
import type { Actor, McpExposureDeclaration } from '@ultimat3/core';
|
|
22
23
|
import { isMcpExposed } from '@ultimat3/core';
|
|
23
24
|
import { McpToolUndeclaredError } from './errors';
|
|
24
|
-
import type {
|
|
25
|
+
import type { ListedPrimitive } from './projectable';
|
|
26
|
+
import { asProjectable } from './projectable';
|
|
25
27
|
import type {
|
|
26
28
|
AnyMcpTool,
|
|
27
29
|
McpCaller,
|
|
28
|
-
McpRole,
|
|
29
30
|
McpToolAnnotations,
|
|
30
31
|
McpToolResult,
|
|
31
32
|
ToolArgs,
|
|
@@ -34,34 +35,6 @@ import { jsonResult, structuredResult } from './registry';
|
|
|
34
35
|
import type { JsonSchema } from './wire';
|
|
35
36
|
import { NO_ARGS } from './wire';
|
|
36
37
|
|
|
37
|
-
/**
|
|
38
|
-
* MCP exposure as declared on an action/query (`mcp: { expose: true, description }`).
|
|
39
|
-
* `visibleTo` restricts which roles may enumerate the tool; it is a catalog concern, not
|
|
40
|
-
* an authz one — the policy still decides.
|
|
41
|
-
*/
|
|
42
|
-
export interface McpExposure {
|
|
43
|
-
readonly expose?: boolean;
|
|
44
|
-
/**
|
|
45
|
-
* Contract text, not UI text: the same string is the OpenAPI operation `summary`, and
|
|
46
|
-
* `buildOpenApi`'s bytes are what `x verify` diffs. Routing it through the ambient,
|
|
47
|
-
* request-scoped `t()` would make that artifact locale-dependent — see
|
|
48
|
-
* `ActionMcp.description` in @ultimat3/action.
|
|
49
|
-
*/
|
|
50
|
-
readonly description?: string;
|
|
51
|
-
readonly visibleTo?: readonly McpRole[];
|
|
52
|
-
/** Override the projected tool name. Defaults to the primitive's own name. */
|
|
53
|
-
readonly name?: string;
|
|
54
|
-
/** Display name for a client's UI. Contract text, like `description`. Absent: none published. */
|
|
55
|
-
readonly title?: string;
|
|
56
|
-
/**
|
|
57
|
-
* Overrides of the derived hints, key by key — see `deriveAnnotations`. The one declaration an
|
|
58
|
-
* app writes for a write that destroys nothing: `annotations: { destructiveHint: false }`.
|
|
59
|
-
*/
|
|
60
|
-
readonly annotations?: McpToolAnnotations;
|
|
61
|
-
/** A list query's whitelist, carried to the tool for the meta surface. See `McpListParams`. */
|
|
62
|
-
readonly listParams?: McpListParams;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
38
|
/**
|
|
66
39
|
* The surface this projection needs from a primitive. Structurally satisfied by `Action`
|
|
67
40
|
* and by `query`'s runnable handle from @ultimat3/action / @ultimat3/query — restated here
|
|
@@ -71,7 +44,8 @@ export interface McpExposure {
|
|
|
71
44
|
export interface ProjectablePrimitive {
|
|
72
45
|
readonly name: string;
|
|
73
46
|
readonly description?: string;
|
|
74
|
-
|
|
47
|
+
/** Core's ONE `mcp` block, as the action or query declared it — never a restatement here. */
|
|
48
|
+
readonly mcp?: McpExposureDeclaration;
|
|
75
49
|
/** JSON Schema of the input, narrowed to the wire subset by `toWireSchema`. */
|
|
76
50
|
readonly inputJsonSchema?: JsonSchema;
|
|
77
51
|
/** True for a mutation. Drives the rate-limit bucket; queries set it false. */
|
|
@@ -98,20 +72,27 @@ export interface ProjectablePrimitive {
|
|
|
98
72
|
}
|
|
99
73
|
|
|
100
74
|
/**
|
|
101
|
-
* True when the primitive opted into MCP
|
|
102
|
-
*
|
|
103
|
-
*
|
|
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.
|
|
104
78
|
*/
|
|
105
|
-
|
|
79
|
+
function exposed(primitive: ProjectablePrimitive): boolean {
|
|
106
80
|
return isMcpExposed(primitive.mcp);
|
|
107
81
|
}
|
|
108
82
|
|
|
109
83
|
/**
|
|
110
|
-
* Project one primitive
|
|
111
|
-
*
|
|
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 `''`.
|
|
112
90
|
*/
|
|
113
|
-
export function
|
|
114
|
-
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;
|
|
115
96
|
const description =
|
|
116
97
|
primitive.mcp?.description ?? primitive.description ?? `Run the "${primitive.name}" action.`;
|
|
117
98
|
const mutates = primitive.mutates ?? true;
|
|
@@ -185,9 +166,6 @@ function definedOnly(annotations: McpToolAnnotations | undefined): McpToolAnnota
|
|
|
185
166
|
};
|
|
186
167
|
}
|
|
187
168
|
|
|
188
|
-
/** Alias that reads correctly at a query call site — same projection, same guarantees. */
|
|
189
|
-
export const toolFromQuery = toolFromAction;
|
|
190
|
-
|
|
191
169
|
/**
|
|
192
170
|
* Project every exposed primitive, SKIPPING the rest. Stable name order.
|
|
193
171
|
*
|
|
@@ -197,8 +175,8 @@ export const toolFromQuery = toolFromAction;
|
|
|
197
175
|
*/
|
|
198
176
|
export function toolsFrom(primitives: readonly ProjectablePrimitive[]): readonly AnyMcpTool[] {
|
|
199
177
|
return primitives
|
|
200
|
-
.filter(
|
|
201
|
-
.map(
|
|
178
|
+
.filter(exposed)
|
|
179
|
+
.map(toolFrom)
|
|
202
180
|
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
203
181
|
}
|
|
204
182
|
|
|
@@ -211,9 +189,9 @@ export function toolsFrom(primitives: readonly ProjectablePrimitive[]): readonly
|
|
|
211
189
|
* throwing so one edit closes all of them.
|
|
212
190
|
*/
|
|
213
191
|
export function toolsListed(primitives: readonly ProjectablePrimitive[]): readonly AnyMcpTool[] {
|
|
214
|
-
const undeclared = primitives.filter((primitive) => !
|
|
192
|
+
const undeclared = primitives.filter((primitive) => !exposed(primitive));
|
|
215
193
|
if (undeclared.length > 0) {
|
|
216
|
-
// The primitive's own name
|
|
194
|
+
// The primitive's own name — the tool's, and what the author greps for.
|
|
217
195
|
throw new McpToolUndeclaredError({ names: undeclared.map((primitive) => primitive.name) });
|
|
218
196
|
}
|
|
219
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,16 +119,14 @@ 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
131
|
export type { ListFilterOp, McpListParams } from './list-params';
|
|
94
132
|
export { DEFAULT_LIST_MAX_LIMIT, listParamsSchema } from './list-params';
|
|
@@ -173,8 +211,8 @@ export {
|
|
|
173
211
|
} from './resources';
|
|
174
212
|
export type { McpScopes } from './scopes';
|
|
175
213
|
export { withScopes } from './scopes';
|
|
176
|
-
export type {
|
|
177
|
-
export {
|
|
214
|
+
export type { McpServer, McpServerInput, McpWire } from './server';
|
|
215
|
+
export { mcpServer } from './server';
|
|
178
216
|
export type { McpInstructions, McpServerVoice } from './server-voice';
|
|
179
217
|
export type { McpSurfaceBudget, McpSurfaceSize } from './surface-budget';
|
|
180
218
|
export { assertMcpSurfaceBudget, measureMcpSurface } from './surface-budget';
|
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
|