@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/app-tools.ts CHANGED
@@ -9,10 +9,13 @@
9
9
  // `frameworkResources` and `mcpHttpRoute` exist.
10
10
 
11
11
  import type { ErrorAudience } from '@ultimat3/core';
12
- import type { RateLimitStore } from '@ultimat3/http';
12
+ import type { RequestFacts as McpRequestFacts, RateLimitStore } from '@ultimat3/http';
13
13
  import type { StandardSchemaV1 } from '@ultimat3/schema';
14
14
  import type { AnyAppToolDefinition, AppTools } from './app-tool';
15
15
  import { appToolPrimitives } from './app-tool';
16
+ import type { McpAuditHook } from './audit-hook';
17
+ import { withConfirmations } from './confirmation-gate';
18
+ import type { McpConfirmations } from './confirmations';
16
19
  import { McpToolDuplicateError } from './errors';
17
20
  import { exposedPrimitives } from './exposed';
18
21
  import { toolsFrom, toolsListed } from './from-action';
@@ -20,15 +23,15 @@ import type { McpResourceGroups, McpSurfaceOption } from './meta-surface';
20
23
  import type { McpOAuth } from './oauth-metadata';
21
24
  import type { ListedPrimitive } from './projectable';
22
25
  import { asProjectable } from './projectable';
23
- import type { AnyMcpTool, McpVerbClass } from './registry';
26
+ import type { AnyMcpTool } from './registry';
24
27
  import type { McpPrompt, McpResource } from './resources';
25
28
  import { toPrompts } from './resources';
26
29
  import type { McpScopes } from './scopes';
27
30
  import { withScopes } from './scopes';
28
- import type { CreateMcpServerInput } from './server';
29
- import { createMcpServer, type McpServer } from './server';
31
+ import type { McpServerInput } from './server';
32
+ import { type McpServer, mcpServer } from './server';
30
33
  import type { McpInstructions } from './server-voice';
31
- import type { McpRouteDescriptor, ResolvedToken } from './transport-http';
34
+ import type { McpRateLimits, McpRouteDescriptor, ResolvedToken } from './transport-http';
32
35
  import { mcpHttpRoute } from './transport-http';
33
36
 
34
37
  /** Schema map behind the authored `tools` record; inferred per tool, never written by hand. */
@@ -121,8 +124,27 @@ export interface DefineAppMcpInput<TSchemas extends AppToolSchemas = AppToolSche
121
124
  * `x policy explain …`, which stays in the log line. `'developer'` restores the author's fix.
122
125
  */
123
126
  readonly errorAudience?: ErrorAudience;
124
- /** Bearer-token resolution. Omit to expose no HTTP route (stdio/embedded only). */
125
- resolveToken?(token: string): Promise<ResolvedToken | null> | ResolvedToken | null;
127
+ /**
128
+ * Bearer-token resolution, handed how the request arrived (`McpRequestFacts`) as a second
129
+ * argument it may ignore. Omit to expose no HTTP route (stdio/embedded only).
130
+ */
131
+ resolveToken?(
132
+ token: string,
133
+ facts: McpRequestFacts,
134
+ ): Promise<ResolvedToken | null> | ResolvedToken | null;
135
+ /**
136
+ * Every gate decision as data — each `tools/call` and `resources/read` outcome and every refused
137
+ * token — after its `mcp.*` log line is written. What an action or query DID still goes to
138
+ * core's `setAuditSink`; this is what the gate decided, mostly before anything ran. A hook that
139
+ * throws changes no answer (`audit-hook.ts`).
140
+ */
141
+ readonly onAudit?: McpAuditHook | undefined;
142
+ /**
143
+ * Tools whose every call waits for a person: `mcpConfirmations({ tools, store, permission })`,
144
+ * which IS the approve/reject action — register it like any other so a person can reach it. A
145
+ * name it gates that this server does not project is `X_MCP_CONFIRMATION_TOOL_UNKNOWN` at boot.
146
+ */
147
+ readonly confirmations?: McpConfirmations | undefined;
126
148
  /** Mount path. Defaults to `/mcp`. */
127
149
  readonly path?: string;
128
150
  /**
@@ -133,10 +155,11 @@ export interface DefineAppMcpInput<TSchemas extends AppToolSchemas = AppToolSche
133
155
  */
134
156
  readonly bodyLimitBytes?: number | undefined;
135
157
  /**
136
- * Requests per minute per caller, by class. Defaults to `MCP_RATE_LIMITS` and is ENFORCED by the
137
- * route, so this is the app's one knob over what an agent may spend here.
158
+ * Requests per minute per caller, by class — defaults to `MCP_RATE_LIMITS` — and failed
159
+ * authentications per minute per address (`unauthenticated`). ENFORCED by the route, so this is
160
+ * the app's one knob over what an agent, or a stranger guessing tokens, may spend here.
138
161
  */
139
- readonly rateLimits?: Readonly<Record<McpVerbClass, number>> | undefined;
162
+ readonly rateLimits?: McpRateLimits | undefined;
140
163
  /**
141
164
  * Where those buckets are counted. Forwarded rather than left to the route's per-process default
142
165
  * because an app behind N replicas has no other way to reach it — and N processes each holding
@@ -215,9 +238,10 @@ export function defineAppMcp<TSchemas extends AppToolSchemas>(
215
238
  { source: "include: 'exposed'", tools: included },
216
239
  { source: 'tools:', tools: written },
217
240
  ]);
218
- const projected = withScopes(named, input.scopes);
241
+ // Scopes, then the gate: the gate wraps `handle`, which every resolve reaches last.
242
+ const projected = withConfirmations(withScopes(named, input.scopes), input.confirmations);
219
243
 
220
- const config: CreateMcpServerInput = {
244
+ const config: McpServerInput = {
221
245
  tools: projected,
222
246
  resources: input.resources ?? [],
223
247
  prompts: toPrompts(input.prompts ?? []),
@@ -226,8 +250,9 @@ export function defineAppMcp<TSchemas extends AppToolSchemas>(
226
250
  ...(input.groups === undefined ? {} : { groups: input.groups }),
227
251
  ...(input.instructions === undefined ? {} : { instructions: input.instructions }),
228
252
  errorAudience: input.errorAudience ?? 'caller',
253
+ ...(input.onAudit === undefined ? {} : { onAudit: input.onAudit }),
229
254
  };
230
- const server = createMcpServer(config);
255
+ const server = mcpServer(config);
231
256
 
232
257
  const resolveToken = input.resolveToken;
233
258
  const route =
@@ -0,0 +1,108 @@
1
+ // `onAudit`: the gate's decisions, as data, handed on AFTER `audit.ts` has written the log line —
2
+ // one audit path with a second destination, never a second path. Core's `AuditSink` records what
3
+ // an action or query DID (`surface: 'mcp'`); this records what the MCP gate decided, mostly before
4
+ // any primitive ran: hidden, scope-denied, invalid-args, a refused token. No `AuditRecord` can
5
+ // carry those — there is no `ctx`, often no primitive, and nothing ran.
6
+
7
+ import type { Clock, Logger } from '@ultimat3/core';
8
+ import { logger, stringField, systemClock } from '@ultimat3/core';
9
+ import type { RequestFacts as McpRequestFacts } from '@ultimat3/http';
10
+ import type { McpAuditEntry, McpResourceAuditEntry } from './audit';
11
+ import { auditResourceRead, auditToolCall } from './audit';
12
+
13
+ /** Why the transport refused before a caller existed. The 429 is `throttled` whichever came first. */
14
+ export type McpAuthRefusal = 'missing-token' | 'rejected-token' | 'not-an-agent' | 'throttled';
15
+
16
+ /** One decision. `at` is the auditor's clock, never `new Date()`; the token never travels. */
17
+ export type McpAuditEvent =
18
+ | ({ readonly kind: 'tool-call'; readonly at: Date } & McpAuditEntry)
19
+ | ({ readonly kind: 'resource-read'; readonly at: Date } & McpResourceAuditEntry)
20
+ | {
21
+ readonly kind: 'auth-refused';
22
+ readonly at: Date;
23
+ readonly reason: McpAuthRefusal;
24
+ /** The HTTP status the caller was answered: 401, 403 or 429. */
25
+ readonly status: number;
26
+ readonly facts: McpRequestFacts;
27
+ };
28
+
29
+ /**
30
+ * Where the app keeps them: a table, a SIEM, a queue. Fire-and-forget by design — see `deliver`.
31
+ * `mcpServer({ onAudit })` and `defineAppMcp({ onAudit })`; the route reads its server's.
32
+ */
33
+ export type McpAuditHook = (event: McpAuditEvent) => void | Promise<void>;
34
+
35
+ export interface McpAuditor {
36
+ toolCall(entry: McpAuditEntry): void;
37
+ resourceRead(entry: McpResourceAuditEntry): void;
38
+ authRefused(reason: McpAuthRefusal, status: number, facts: McpRequestFacts): void;
39
+ }
40
+
41
+ export interface McpAuditorInput {
42
+ readonly onAudit?: McpAuditHook | undefined;
43
+ /** Stamps `at`. Defaulted, never read inline, so a test can freeze it. */
44
+ readonly clock?: Clock | undefined;
45
+ /** Where the lines go. The process logger unless a test reads them. */
46
+ readonly log?: Logger | undefined;
47
+ }
48
+
49
+ export function mcpAuditor(input: McpAuditorInput = {}): McpAuditor {
50
+ const log = input.log ?? logger;
51
+ const clock = input.clock ?? systemClock;
52
+ const hook = input.onAudit;
53
+ return {
54
+ toolCall(entry) {
55
+ auditToolCall(entry, log);
56
+ if (hook !== undefined) deliver(hook, { kind: 'tool-call', at: clock.now(), ...entry }, log);
57
+ },
58
+ resourceRead(entry) {
59
+ auditResourceRead(entry, log);
60
+ if (hook !== undefined) {
61
+ deliver(hook, { kind: 'resource-read', at: clock.now(), ...entry }, log);
62
+ }
63
+ },
64
+ authRefused(reason, status, facts) {
65
+ // `warn`, the level of every refusal a prober can drive: a token walk is the same shape as a
66
+ // tool-name walk one step earlier. The address and path carry the decision; the user agent
67
+ // and origin stay in the event only — caller-controlled text an alert rule has no use for.
68
+ log.warn(`mcp.auth.${reason}`, {
69
+ surface: 'mcp',
70
+ reason,
71
+ status,
72
+ path: facts.path,
73
+ ...(facts.address === null ? {} : { address: facts.address }),
74
+ });
75
+ if (hook !== undefined) {
76
+ deliver(hook, { kind: 'auth-refused', at: clock.now(), reason, status, facts }, log);
77
+ }
78
+ },
79
+ };
80
+ }
81
+
82
+ /** The auditor a server built without `onAudit` uses: the log line, and nothing else. */
83
+ export const LOG_ONLY_AUDITOR: McpAuditor = mcpAuditor();
84
+
85
+ /**
86
+ * A hook that throws or rejects changes NO answer. The log line — the record that cannot be turned
87
+ * off — is already written, and a refusal that turned into a 500 when the app's sink was down
88
+ * would be a signal a prober could read. So the failure is reported, once, at `error`, carrying the
89
+ * thrown value's `code` when it has one and never its message: a sink's message names a host.
90
+ * Not awaited, so a slow sink cannot hold a JSON-RPC answer; a hook that must not lose an event
91
+ * queues it itself.
92
+ */
93
+ function deliver(hook: McpAuditHook, event: McpAuditEvent, log: Logger): void {
94
+ const failed = (thrown: unknown): void => {
95
+ const code = stringField(thrown, 'code');
96
+ log.error('mcp.audit-hook.failed', {
97
+ surface: 'mcp',
98
+ event: event.kind,
99
+ ...(code === undefined ? {} : { code }),
100
+ });
101
+ };
102
+ try {
103
+ const pending = hook(event);
104
+ if (pending instanceof Promise) pending.catch(failed);
105
+ } catch (thrown) {
106
+ failed(thrown);
107
+ }
108
+ }
package/src/audit.ts CHANGED
@@ -23,6 +23,11 @@ export type McpOutcome =
23
23
  | 'policy-denied'
24
24
  /** Arguments failed the schema the agent was handed. Not an authz outcome. */
25
25
  | 'invalid-args'
26
+ /**
27
+ * Did not run because no live human approval exists for this exact call: one is pending, or the
28
+ * one there was expired. Expected — `mcpConfirmations` answers it by design — so `info`.
29
+ */
30
+ | 'unconfirmed'
26
31
  /**
27
32
  * The handler threw something that is not an authz denial: a non-authz framework refusal
28
33
  * (`db.migrate` aimed at prod), or a bug. Both want a human — a projected action rejecting
@@ -39,6 +44,7 @@ export type McpOutcome =
39
44
  const DENIAL_CODES: ReadonlySet<string> = new Set([
40
45
  'X_ADMIN_DENIED',
41
46
  'X_FORBIDDEN',
47
+ 'X_MCP_CONFIRMATION_REJECTED',
42
48
  'X_UNAUTHENTICATED',
43
49
  ]);
44
50
 
@@ -54,9 +60,19 @@ const DENIAL_CODES: ReadonlySet<string> = new Set([
54
60
  */
55
61
  const ARGUMENT_CODES: ReadonlySet<string> = new Set(['X_ADMIN_INVALID']);
56
62
 
63
+ /**
64
+ * `mcpConfirmations`' answers for a call no live approval covers. A rejection is NOT here: a human
65
+ * saying no is a denial, and it belongs in the bucket the denial alert reads.
66
+ */
67
+ const UNCONFIRMED_CODES: ReadonlySet<string> = new Set([
68
+ 'X_MCP_CONFIRMATION_PENDING',
69
+ 'X_MCP_CONFIRMATION_EXPIRED',
70
+ ]);
71
+
57
72
  /** Classify a code a tool threw. Denials are outcome 3; everything else wants a human. */
58
73
  export function outcomeForCode(code: string): McpOutcome {
59
74
  if (DENIAL_CODES.has(code)) return 'policy-denied';
75
+ if (UNCONFIRMED_CODES.has(code)) return 'unconfirmed';
60
76
  return ARGUMENT_CODES.has(code) ? 'invalid-args' : 'failed';
61
77
  }
62
78
 
@@ -108,6 +124,7 @@ const LEVEL: ReadonlyMap<McpOutcome, 'info' | 'warn' | 'error'> = new Map([
108
124
  ['scope-denied', 'warn'],
109
125
  ['policy-denied', 'warn'],
110
126
  ['invalid-args', 'info'],
127
+ ['unconfirmed', 'info'],
111
128
  ['failed', 'error'],
112
129
  ]);
113
130
  // An outcome is a closed union, so the `Map` is total; `?? 'error'` is the type's witness, not a
@@ -5,7 +5,7 @@
5
5
  // came to answer differently by transport.
6
6
 
7
7
  import type { Actor } from '@ultimat3/core';
8
- import { createContext, hasContext, runWithContext, withChildContext } from '@ultimat3/core';
8
+ import { ctxOf, hasContext, runWithContext, withChildContext } from '@ultimat3/core';
9
9
 
10
10
  /**
11
11
  * Run `fn` with `actor` as the ambient identity, whether or not a request is already in flight.
@@ -18,7 +18,5 @@ import { createContext, hasContext, runWithContext, withChildContext } from '@ul
18
18
  * reads off `ctx.actor` the same identity by construction rather than by two call sites agreeing.
19
19
  */
20
20
  export function asCallerContext<T>(actor: Actor, fn: () => T): T {
21
- return hasContext()
22
- ? withChildContext({ actor }, fn)
23
- : runWithContext(createContext({ actor }), fn);
21
+ return hasContext() ? withChildContext({ actor }, fn) : runWithContext(ctxOf({ actor }), fn);
24
22
  }
@@ -0,0 +1,116 @@
1
+ // The decision action's handle: `view` shows a person the request exactly as the agent sent it;
2
+ // `approve` / `reject` take a decision once, and an approval only for those same arguments. The
3
+ // policy already ran (the factory's `can(…)`, agent refusal and tenant default included), so this
4
+ // file decides STATE, never who.
5
+
6
+ import type { Clock, SealKeySource, UltimateError } from '@ultimat3/core';
7
+ import { compareFingerprint, openText } from '@ultimat3/core';
8
+ import type { JsonValue } from '@ultimat3/schema';
9
+ import {
10
+ McpConfirmationArgumentsMismatchError,
11
+ McpConfirmationDecidedError,
12
+ McpConfirmationExpiredError,
13
+ McpConfirmationUnknownError,
14
+ } from './confirmation-errors';
15
+ import {
16
+ MCP_CONFIRMATION_ARGUMENTS_PURPOSE,
17
+ MCP_CONFIRMATION_DIGEST_PURPOSE,
18
+ } from './confirmation-gate';
19
+ import type { McpConfirmation, McpConfirmationStore } from './confirmation-store';
20
+
21
+ export interface DecisionAsked {
22
+ readonly id: string;
23
+ readonly decision: 'view' | 'approve' | 'reject';
24
+ /** What the person approves — the `arguments` `view` returned. Required to approve. */
25
+ readonly arguments?: Readonly<Record<string, JsonValue>> | undefined;
26
+ }
27
+
28
+ export interface DecisionAnswer {
29
+ readonly id: string;
30
+ readonly tool: string;
31
+ readonly status: 'pending' | 'approved' | 'rejected';
32
+ readonly expiresAt: Date;
33
+ readonly decidedAt: Date | null;
34
+ /** The arguments the agent sent, opened — what the person sees and what an approval binds to. */
35
+ readonly arguments: Record<string, JsonValue>;
36
+ }
37
+
38
+ export interface DecideDeps {
39
+ readonly store: McpConfirmationStore;
40
+ readonly clock: Clock;
41
+ readonly sealKeys: SealKeySource;
42
+ }
43
+
44
+ export async function decideConfirmation(
45
+ deps: DecideDeps,
46
+ asked: DecisionAsked,
47
+ deciderId: string,
48
+ ): Promise<DecisionAnswer> {
49
+ const row = await deps.store.get(asked.id);
50
+ if (row === undefined) throw new McpConfirmationUnknownError(asked.id);
51
+ const shown = await openArguments(row, deps.sealKeys);
52
+ if (asked.decision === 'view') return answer(row, shown);
53
+ const at = deps.clock.now();
54
+ const refused = stateRefusal(row, at);
55
+ if (refused !== undefined) throw refused;
56
+ if (asked.decision === 'approve') assertSameArguments(row, asked.arguments);
57
+ const status = asked.decision === 'approve' ? 'approved' : 'rejected';
58
+ const decided = await deps.store.decide(row.id, status, deciderId, at);
59
+ // The CAS lost to a concurrent decision or the expiry: re-read and say which.
60
+ if (decided === undefined) {
61
+ const now = await deps.store.get(row.id);
62
+ throw (now === undefined ? undefined : stateRefusal(now, at)) ?? expired(row);
63
+ }
64
+ return answer(decided, shown);
65
+ }
66
+
67
+ /**
68
+ * The approval binds to what the person was SHOWN: the keyed digest of the arguments they send back
69
+ * must match the row's, timing-safe (`compareFingerprint`). A swap between `view` and `approve` —
70
+ * or an approval sent with no arguments at all — decides nothing. `unverifiable` is a rotated
71
+ * signing secret: no approval, and the agent's next call opens a fresh confirmation anyway.
72
+ */
73
+ function assertSameArguments(row: McpConfirmation, sent: DecisionAsked['arguments']): void {
74
+ const subject = { id: row.id, tool: row.tool, expiresAt: row.expiresAt };
75
+ if (sent === undefined) {
76
+ throw new McpConfirmationArgumentsMismatchError({ ...subject, reason: 'missing' });
77
+ }
78
+ const verdict = compareFingerprint(row.inputDigest, sent, MCP_CONFIRMATION_DIGEST_PURPOSE);
79
+ if (verdict === 'match') return;
80
+ throw new McpConfirmationArgumentsMismatchError({
81
+ ...subject,
82
+ reason: verdict === 'unverifiable' ? 'rotated' : 'different',
83
+ });
84
+ }
85
+
86
+ /** Why a row cannot be decided now: already decided, or past its expiry (or consumed as such). */
87
+ function stateRefusal(row: McpConfirmation, at: Date): UltimateError | undefined {
88
+ if (row.status !== 'pending')
89
+ return new McpConfirmationDecidedError({ ...row, status: row.status });
90
+ if (row.consumedAt !== null || row.expiresAt.getTime() <= at.getTime()) return expired(row);
91
+ return undefined;
92
+ }
93
+
94
+ const expired = (row: McpConfirmation): UltimateError =>
95
+ new McpConfirmationExpiredError({ ...row, at: 'decision' });
96
+
97
+ async function openArguments(
98
+ row: McpConfirmation,
99
+ sealKeys: SealKeySource,
100
+ ): Promise<Record<string, JsonValue>> {
101
+ const text = await openText(row.sealedArguments, {
102
+ purpose: MCP_CONFIRMATION_ARGUMENTS_PURPOSE,
103
+ ...sealKeys,
104
+ });
105
+ // Sealed by this package from a validated argument record, so the parse is of our own JSON.
106
+ return JSON.parse(text) as Record<string, JsonValue>;
107
+ }
108
+
109
+ const answer = (row: McpConfirmation, shown: Record<string, JsonValue>): DecisionAnswer => ({
110
+ id: row.id,
111
+ tool: row.tool,
112
+ status: row.status,
113
+ expiresAt: row.expiresAt,
114
+ decidedAt: row.decidedAt,
115
+ arguments: shown,
116
+ });
@@ -0,0 +1,137 @@
1
+ // The refusals of `mcpConfirmations`: the agent's call that waits for a human, the human's decision
2
+ // that came too late or twice, and the boot that gates a tool nobody projects. Split from
3
+ // `errors.ts` for its ceiling. Each `cause` names the confirmation id: it is the one fact both the
4
+ // agent relaying the request and the human deciding it need.
5
+
6
+ import { UltimateError } from '@ultimat3/core';
7
+
8
+ interface Subject {
9
+ readonly id: string;
10
+ readonly tool: string;
11
+ readonly expiresAt: Date;
12
+ }
13
+
14
+ /** The instant, in UTC — an ISO string carries its own zone, so no reader has to guess one. */
15
+ const until = (subject: Subject): string => subject.expiresAt.toISOString();
16
+
17
+ const meta = (subject: Subject) => ({
18
+ confirmation: subject.id,
19
+ tool: subject.tool,
20
+ expiresAt: until(subject),
21
+ });
22
+
23
+ /** The decision action, by the name it was registered under — or how to find it before then. */
24
+ const decider = (name: string): string =>
25
+ name.length > 0 ? name : 'the mcpConfirmations() action';
26
+
27
+ /** The call did not run: a human has to approve this exact call first. */
28
+ export class McpConfirmationPendingError extends UltimateError {
29
+ constructor(subject: Subject & { readonly decider: string }) {
30
+ super({
31
+ code: 'X_MCP_CONFIRMATION_PENDING',
32
+ cause: `${subject.tool} needs a human's approval before it runs: confirmation ${subject.id} is pending until ${until(subject)}`,
33
+ fix: `confirmations({ id, decision: 'view' }) # as a person, through ${decider(subject.decider)}, with id ${subject.id}; then confirmations({ id, decision: 'approve', arguments }) with exactly what view returned (or decision: 'reject'), and the agent repeats the same tools/call before ${until(subject)}`,
34
+ callerFix: `ask a person who may approve it to view confirmation ${subject.id} and approve it with exactly the arguments it shows, then call ${subject.tool} again with exactly the same arguments before ${until(subject)}`,
35
+ meta: meta(subject),
36
+ });
37
+ }
38
+ }
39
+
40
+ /** The confirmation lapsed — before the call came back for it, or before a human decided it. */
41
+ export class McpConfirmationExpiredError extends UltimateError {
42
+ constructor(subject: Subject & { readonly at: 'call' | 'decision' }) {
43
+ super({
44
+ code: 'X_MCP_CONFIRMATION_EXPIRED',
45
+ cause: `confirmation ${subject.id} for ${subject.tool} expired at ${until(subject)}${subject.at === 'call' ? '; the call did not run' : ' and can no longer be decided'}`,
46
+ fix:
47
+ subject.at === 'call'
48
+ ? `{ method: 'tools/call', params: { name, arguments } } # ${subject.tool} again with the same arguments opens a new confirmation; decide it before expiry, or raise mcpConfirmations({ ttlMs })`
49
+ : `mcpConfirmations({ ttlMs }) # raise it if people need longer; ask the agent to call ${subject.tool} again, which opens a new confirmation`,
50
+ meta: meta(subject),
51
+ });
52
+ }
53
+ }
54
+
55
+ /** A person rejected this exact call. Audited as a denial — a human saying no is one. */
56
+ export class McpConfirmationRejectedError extends UltimateError {
57
+ constructor(subject: Subject) {
58
+ super({
59
+ code: 'X_MCP_CONFIRMATION_REJECTED',
60
+ cause: `a person rejected confirmation ${subject.id}: ${subject.tool} did not run`,
61
+ fix: `{ method: 'tools/call', params: { name, arguments } } # ${subject.tool} with arguments changed after asking the person who rejected ${subject.id}; the same arguments are rejected again`,
62
+ meta: meta(subject),
63
+ });
64
+ }
65
+ }
66
+
67
+ /** Approve or reject on a row already decided: a decision is taken once. */
68
+ export class McpConfirmationDecidedError extends UltimateError {
69
+ constructor(subject: Subject & { readonly status: string }) {
70
+ super({
71
+ code: 'X_MCP_CONFIRMATION_DECIDED',
72
+ cause: `confirmation ${subject.id} for ${subject.tool} was already ${subject.status}; a decision is taken once`,
73
+ fix: `{ method: 'tools/call', params: { name, arguments } } # ${subject.id} is final; the agent calling ${subject.tool} again opens a new confirmation to decide`,
74
+ meta: { ...meta(subject), status: subject.status },
75
+ });
76
+ }
77
+ }
78
+
79
+ /** No confirmation has this id — mistyped, from another deployment, or purged after its expiry. */
80
+ export class McpConfirmationUnknownError extends UltimateError {
81
+ constructor(id: string) {
82
+ super({
83
+ code: 'X_MCP_CONFIRMATION_UNKNOWN',
84
+ cause: `no MCP confirmation has id ${id}`,
85
+ fix: `confirmations({ id, decision: 'view' }) # with the id from the X_MCP_CONFIRMATION_PENDING answer the agent received; one past its expiry may be purged — ask the agent to call the tool again`,
86
+ meta: { confirmation: id },
87
+ });
88
+ }
89
+ }
90
+
91
+ /** `mcpConfirmations({ tools })` names a tool this server does not project — or none at all. */
92
+ export class McpConfirmationToolUnknownError extends UltimateError {
93
+ constructor(input: { readonly unknown: readonly string[]; readonly known: readonly string[] }) {
94
+ const known = input.known.length === 0 ? '(none)' : input.known.join(', ');
95
+ super({
96
+ code: 'X_MCP_CONFIRMATION_TOOL_UNKNOWN',
97
+ cause:
98
+ input.unknown.length === 0
99
+ ? 'mcpConfirmations({ tools: [] }) gates no tool, so every call it was meant to hold would run unconfirmed'
100
+ : `mcpConfirmations({ tools }) names ${input.unknown.join(', ')}, which this MCP server does not project — that call would run unconfirmed`,
101
+ fix: `mcpConfirmations({ tools: [...] }) # name tools this server projects: ${known}`,
102
+ meta: { unknown: [...input.unknown], known: [...input.known] },
103
+ });
104
+ }
105
+ }
106
+
107
+ /** The store could not settle which row is open for a call, three times running. Retryable. */
108
+ export class McpConfirmationContestedError extends UltimateError {
109
+ constructor(tool: string) {
110
+ super({
111
+ code: 'X_MCP_CONFIRMATION_CONTESTED',
112
+ cause: `identical ${tool} calls from one agent kept opening and consuming its confirmation concurrently`,
113
+ fix: `{ method: 'tools/call', params: { name, arguments } } # retry ${tool} once the agent's concurrent identical calls finish — send one at a time`,
114
+ meta: { tool },
115
+ });
116
+ }
117
+ }
118
+
119
+ /**
120
+ * An approval that does not carry the arguments the agent sent: none, different ones (a swap
121
+ * between the view and the decision), or a row keyed under a rotated signing secret.
122
+ */
123
+ export class McpConfirmationArgumentsMismatchError extends UltimateError {
124
+ constructor(subject: Subject & { readonly reason: 'missing' | 'different' | 'rotated' }) {
125
+ const why = {
126
+ missing: 'the approval carried no arguments',
127
+ different: 'the approval carried arguments other than the ones the agent sent',
128
+ rotated: 'the confirmation was keyed under a signing secret this process no longer holds',
129
+ }[subject.reason];
130
+ super({
131
+ code: 'X_MCP_CONFIRMATION_ARGUMENTS_MISMATCH',
132
+ cause: `confirmation ${subject.id} for ${subject.tool} was not decided: ${why}`,
133
+ fix: `confirmations({ id, decision: 'view' }) # for ${subject.id}, then confirmations({ id, decision: 'approve', arguments }) with exactly the arguments it returns; a rotated one: ask the agent to call ${subject.tool} again`,
134
+ meta: { ...meta(subject), reason: subject.reason },
135
+ });
136
+ }
137
+ }
@@ -0,0 +1,101 @@
1
+ // The agent's side of `mcpConfirmations`: a gated tool's `handle`, wrapped. It runs AFTER the
2
+ // registry's visibility → scope → args and the tool's caller-only `admit`, and BEFORE the tool's own
3
+ // `handle` — where a projected action's full policy runs. So the order is visibility → scope →
4
+ // args → admit → confirmation → policy, and a caller the tool refuses outright never opens a row.
5
+
6
+ import { keyedFingerprint, seal, uuidV7 } from '@ultimat3/core';
7
+ import {
8
+ McpConfirmationContestedError,
9
+ McpConfirmationExpiredError,
10
+ McpConfirmationPendingError,
11
+ McpConfirmationRejectedError,
12
+ McpConfirmationToolUnknownError,
13
+ } from './confirmation-errors';
14
+ import type { McpConfirmations } from './confirmations';
15
+ import type { AnyMcpTool, McpCaller, McpToolResult, ToolArgs } from './registry';
16
+
17
+ /**
18
+ * The gated catalog. Refuses at boot a name the catalog does not hold — that call would run with
19
+ * no person ever asked, which is exactly the silent pass the gate exists to remove.
20
+ */
21
+ export function withConfirmations(
22
+ tools: readonly AnyMcpTool[],
23
+ confirmations: McpConfirmations | undefined,
24
+ ): readonly AnyMcpTool[] {
25
+ if (confirmations === undefined) return tools;
26
+ const known = tools.map((tool) => tool.name);
27
+ const unknown = confirmations.tools.filter((name) => !known.includes(name));
28
+ if (unknown.length > 0) throw new McpConfirmationToolUnknownError({ unknown, known });
29
+ const gated = new Set(confirmations.tools);
30
+ return tools.map((tool) => (gated.has(tool.name) ? gate(tool, confirmations) : tool));
31
+ }
32
+
33
+ /** What the arguments are sealed FOR — bound into the AES-GCM tag, so they open as nothing else. */
34
+ export const MCP_CONFIRMATION_ARGUMENTS_PURPOSE = 'mcp-confirmation.arguments';
35
+
36
+ /** Separates this use of the signing secret from every other keyed fingerprint. */
37
+ export const MCP_CONFIRMATION_DIGEST_PURPOSE = 'mcp-confirmation';
38
+
39
+ /**
40
+ * The digest an approval binds to: core's `keyedFingerprint` over the VALIDATED arguments (defaults
41
+ * applied, the reserved `idempotencyKey` included) — HMAC-SHA-256 under a key derived from the
42
+ * app's signing secret, 128 bits, `h1:<key id>:<mac>`. Keyed, never a plain hash, because the row
43
+ * PERSISTS it: an unkeyed digest of `{ accountNumber }` is brute-forced from a database read. And it
44
+ * binds: a second input sharing an approved digest needs the key.
45
+ *
46
+ * The store finds the open row by EQUALITY on this string, so the key id is part of the lookup:
47
+ * after a secret rotation the same call computes a different value, matches no row, and opens a
48
+ * FRESH confirmation — never a mismatch error, never the old approval. Rows under the old key are
49
+ * orphaned until `purge`. Equality, not `compareFingerprint`: the caller cannot compute the value,
50
+ * so the lookup's timing says nothing, and `unverifiable` could only describe a row this call
51
+ * never finds.
52
+ */
53
+ export function inputDigest(args: ToolArgs): string {
54
+ return keyedFingerprint(args, MCP_CONFIRMATION_DIGEST_PURPOSE);
55
+ }
56
+
57
+ function gate(tool: AnyMcpTool, confirmations: McpConfirmations): AnyMcpTool {
58
+ const { store, clock, ttlMs, sealKeys } = confirmations;
59
+ return {
60
+ ...tool,
61
+ confirms: true,
62
+ async handle(args: ToolArgs, caller: McpCaller): Promise<McpToolResult> {
63
+ // Caller-only half of the policy first: one who may never call this opens no row for a person
64
+ // to look at. Throws the same denial `handle` would, rendered by the server.
65
+ tool.admit?.(caller);
66
+ const now = clock.now();
67
+ const draft = {
68
+ id: uuidV7(clock),
69
+ actorId: caller.actor.id,
70
+ orgId: caller.actor.orgId ?? null,
71
+ tool: tool.name,
72
+ inputDigest: inputDigest(args),
73
+ // Sealed, never plaintext at rest: the person deciding reads them through the decision
74
+ // action's `view`, and approves only by sending them back (`confirmation-decide.ts`).
75
+ sealedArguments: await seal(JSON.stringify(args), {
76
+ purpose: MCP_CONFIRMATION_ARGUMENTS_PURPOSE,
77
+ ...sealKeys,
78
+ }),
79
+ createdAt: now,
80
+ expiresAt: new Date(now.getTime() + ttlMs),
81
+ };
82
+ // Bounded: `consume` loses only to a concurrent identical call by the same agent, which then
83
+ // carries the outcome; this one opens the next row on the following pass.
84
+ for (let attempt = 0; attempt < 3; attempt += 1) {
85
+ const { row, created } = await store.open(draft);
86
+ const subject = { id: row.id, tool: tool.name, expiresAt: row.expiresAt };
87
+ const live = row.expiresAt.getTime() > now.getTime();
88
+ if (created || (row.status === 'pending' && live)) {
89
+ throw new McpConfirmationPendingError({ ...subject, decider: confirmations.name });
90
+ }
91
+ // Every other state is an OUTCOME, delivered once: taking it is what frees this call to
92
+ // ask again, and what makes one approval run one call.
93
+ if (!(await store.consume(row.id, now))) continue;
94
+ if (row.status === 'rejected') throw new McpConfirmationRejectedError(subject);
95
+ if (!live) throw new McpConfirmationExpiredError({ ...subject, at: 'call' });
96
+ return tool.handle(args, caller);
97
+ }
98
+ throw new McpConfirmationContestedError(tool.name);
99
+ },
100
+ };
101
+ }