@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
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// The shared confirmation store: one Postgres table, the open row held unique by a partial index so
|
|
2
|
+
// two identical agent calls racing open ONE row, and every transition a single conditional UPDATE.
|
|
3
|
+
// Statements are spelled out so an operator can run the exact one a log line names.
|
|
4
|
+
|
|
5
|
+
import type { PgExecutor } from '@ultimat3/core';
|
|
6
|
+
import { McpConfirmationContestedError } from './confirmation-errors';
|
|
7
|
+
import type {
|
|
8
|
+
McpConfirmation,
|
|
9
|
+
McpConfirmationStatus,
|
|
10
|
+
McpConfirmationStore,
|
|
11
|
+
} from './confirmation-store';
|
|
12
|
+
|
|
13
|
+
export const SQL_MCP_CONFIRMATION_OPEN = `
|
|
14
|
+
insert into x_mcp_confirmations
|
|
15
|
+
(id, actor_id, org_id, tool, input_digest, sealed_arguments, created_at, expires_at)
|
|
16
|
+
values ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
17
|
+
on conflict (actor_id, tool, input_digest) where consumed_at is null do nothing
|
|
18
|
+
returning *
|
|
19
|
+
`;
|
|
20
|
+
|
|
21
|
+
export const SQL_MCP_CONFIRMATION_OPEN_ROW = `
|
|
22
|
+
select * from x_mcp_confirmations
|
|
23
|
+
where actor_id = $1 and tool = $2 and input_digest = $3 and consumed_at is null
|
|
24
|
+
`;
|
|
25
|
+
|
|
26
|
+
export const SQL_MCP_CONFIRMATION_GET = 'select * from x_mcp_confirmations where id = $1';
|
|
27
|
+
|
|
28
|
+
export const SQL_MCP_CONFIRMATION_DECIDE = `
|
|
29
|
+
update x_mcp_confirmations set status = $2, decided_by = $3, decided_at = $4
|
|
30
|
+
where id = $1 and status = 'pending' and consumed_at is null and expires_at > $4
|
|
31
|
+
returning *
|
|
32
|
+
`;
|
|
33
|
+
|
|
34
|
+
export const SQL_MCP_CONFIRMATION_CONSUME = `
|
|
35
|
+
update x_mcp_confirmations set consumed_at = $2 where id = $1 and consumed_at is null returning id
|
|
36
|
+
`;
|
|
37
|
+
|
|
38
|
+
export const SQL_MCP_CONFIRMATION_PURGE = `
|
|
39
|
+
with purged as (delete from x_mcp_confirmations where expires_at < $1 returning 1)
|
|
40
|
+
select count(*)::int as purged from purged
|
|
41
|
+
`;
|
|
42
|
+
|
|
43
|
+
interface Row {
|
|
44
|
+
readonly id: string;
|
|
45
|
+
readonly actor_id: string;
|
|
46
|
+
readonly org_id: string | null;
|
|
47
|
+
readonly tool: string;
|
|
48
|
+
readonly input_digest: string;
|
|
49
|
+
readonly sealed_arguments: string;
|
|
50
|
+
readonly status: string;
|
|
51
|
+
readonly created_at: Date | string;
|
|
52
|
+
readonly expires_at: Date | string;
|
|
53
|
+
readonly decided_at: Date | string | null;
|
|
54
|
+
readonly decided_by: string | null;
|
|
55
|
+
readonly consumed_at: Date | string | null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface PostgresConfirmationStoreOptions {
|
|
59
|
+
/**
|
|
60
|
+
* A real `PgExecutor` over the process's client — read on every call, so a declaration evaluated
|
|
61
|
+
* before the boot opened the pool still reaches it: `{ query: (text, values) => db().query(…) }`.
|
|
62
|
+
*/
|
|
63
|
+
readonly executor: PgExecutor;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function postgresConfirmationStore(
|
|
67
|
+
options: PostgresConfirmationStoreOptions,
|
|
68
|
+
): McpConfirmationStore {
|
|
69
|
+
const exec = options.executor;
|
|
70
|
+
const first = async (sql: string, params: readonly unknown[]) => {
|
|
71
|
+
const row = (await exec.query<Row>(sql, params))[0];
|
|
72
|
+
return row === undefined ? undefined : toConfirmation(row);
|
|
73
|
+
};
|
|
74
|
+
return {
|
|
75
|
+
async open(draft) {
|
|
76
|
+
// Bounded: the insert and the read can both come back empty only when a concurrent consume
|
|
77
|
+
// freed the key between them, and the next insert then wins it.
|
|
78
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
79
|
+
const created = await first(SQL_MCP_CONFIRMATION_OPEN, [
|
|
80
|
+
draft.id,
|
|
81
|
+
draft.actorId,
|
|
82
|
+
draft.orgId,
|
|
83
|
+
draft.tool,
|
|
84
|
+
draft.inputDigest,
|
|
85
|
+
draft.sealedArguments,
|
|
86
|
+
draft.createdAt,
|
|
87
|
+
draft.expiresAt,
|
|
88
|
+
]);
|
|
89
|
+
if (created !== undefined) return { row: created, created: true };
|
|
90
|
+
const held = await first(SQL_MCP_CONFIRMATION_OPEN_ROW, [
|
|
91
|
+
draft.actorId,
|
|
92
|
+
draft.tool,
|
|
93
|
+
draft.inputDigest,
|
|
94
|
+
]);
|
|
95
|
+
if (held !== undefined) return { row: held, created: false };
|
|
96
|
+
}
|
|
97
|
+
// Never reported as created: the caller would tell the agent a row exists that does not.
|
|
98
|
+
throw new McpConfirmationContestedError(draft.tool);
|
|
99
|
+
},
|
|
100
|
+
get: (id) => first(SQL_MCP_CONFIRMATION_GET, [id]),
|
|
101
|
+
decide: (id, status, by, at) => first(SQL_MCP_CONFIRMATION_DECIDE, [id, status, by, at]),
|
|
102
|
+
async consume(id, at) {
|
|
103
|
+
return (await exec.query(SQL_MCP_CONFIRMATION_CONSUME, [id, at])).length === 1;
|
|
104
|
+
},
|
|
105
|
+
async purge(before) {
|
|
106
|
+
const rows = await exec.query<{ purged: number | string }>(SQL_MCP_CONFIRMATION_PURGE, [
|
|
107
|
+
before,
|
|
108
|
+
]);
|
|
109
|
+
return Number(rows[0]?.purged ?? 0);
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const STATUSES: ReadonlySet<string> = new Set<McpConfirmationStatus>([
|
|
115
|
+
'pending',
|
|
116
|
+
'approved',
|
|
117
|
+
'rejected',
|
|
118
|
+
]);
|
|
119
|
+
|
|
120
|
+
/** Every Postgres client answers `timestamptz` as a `Date` or an ISO string; both read the same. */
|
|
121
|
+
const dateOf = (value: Date | string): Date => (value instanceof Date ? value : new Date(value));
|
|
122
|
+
const maybeDate = (value: Date | string | null): Date | null =>
|
|
123
|
+
value === null ? null : dateOf(value);
|
|
124
|
+
|
|
125
|
+
function toConfirmation(row: Row): McpConfirmation {
|
|
126
|
+
// A status this file never writes is a row someone edited by hand: read as `rejected`, the one
|
|
127
|
+
// reading that can neither run the call nor be decided again.
|
|
128
|
+
const status = STATUSES.has(row.status) ? (row.status as McpConfirmationStatus) : 'rejected';
|
|
129
|
+
return {
|
|
130
|
+
id: row.id,
|
|
131
|
+
actorId: row.actor_id,
|
|
132
|
+
orgId: row.org_id,
|
|
133
|
+
tool: row.tool,
|
|
134
|
+
inputDigest: row.input_digest,
|
|
135
|
+
sealedArguments: row.sealed_arguments,
|
|
136
|
+
status,
|
|
137
|
+
createdAt: dateOf(row.created_at),
|
|
138
|
+
expiresAt: dateOf(row.expires_at),
|
|
139
|
+
decidedAt: maybeDate(row.decided_at),
|
|
140
|
+
decidedBy: row.decided_by,
|
|
141
|
+
consumedAt: maybeDate(row.consumed_at),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// The DDL of `mcpConfirmations`' one framework table, and nothing else: a LEAF module, so the
|
|
2
|
+
// boot's `FRAMEWORK_SCHEMA` (`@ultimat3/cli`) applies it in every role through
|
|
3
|
+
// `@ultimat3/mcp/schema` without pulling the MCP server, its transports or `@ultimat3/action`
|
|
4
|
+
// into a worker's module graph. Imports nothing, by design.
|
|
5
|
+
|
|
6
|
+
/** The table. One name, read by this DDL and by `confirmation-postgres.ts`'s statements. */
|
|
7
|
+
export const MCP_CONFIRMATIONS_TABLE = 'x_mcp_confirmations';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Applied by the boot, never by an app migration — the rule `SQL_IDEMPOTENCY_TABLE` follows.
|
|
11
|
+
* `create … if not exists` throughout, so every boot can run it; a new column is an
|
|
12
|
+
* `alter table … add column if not exists`, never an edit to the `create`.
|
|
13
|
+
*/
|
|
14
|
+
export const SQL_MCP_CONFIRMATIONS_TABLE = `
|
|
15
|
+
create table if not exists x_mcp_confirmations (
|
|
16
|
+
id uuid primary key,
|
|
17
|
+
actor_id text not null,
|
|
18
|
+
org_id text,
|
|
19
|
+
tool text not null,
|
|
20
|
+
input_digest text not null,
|
|
21
|
+
sealed_arguments text not null,
|
|
22
|
+
status text not null default 'pending',
|
|
23
|
+
created_at timestamptz not null,
|
|
24
|
+
expires_at timestamptz not null,
|
|
25
|
+
decided_at timestamptz,
|
|
26
|
+
decided_by text,
|
|
27
|
+
consumed_at timestamptz
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
create unique index if not exists x_mcp_confirmations_open_idx
|
|
31
|
+
on x_mcp_confirmations (actor_id, tool, input_digest) where consumed_at is null;
|
|
32
|
+
|
|
33
|
+
create index if not exists x_mcp_confirmations_expires_at_idx on x_mcp_confirmations (expires_at);
|
|
34
|
+
`;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// Where a pending MCP confirmation lives between the agent's call and a human's decision: the row,
|
|
2
|
+
// the store contract both drivers answer, and the memory driver. Postgres is
|
|
3
|
+
// `confirmation-postgres.ts`; `confirmation-store.contract.test.ts` holds the two to one answer.
|
|
4
|
+
|
|
5
|
+
/** `pending` until a human decides; a decision is final. Expiry and consumption are timestamps. */
|
|
6
|
+
export type McpConfirmationStatus = 'pending' | 'approved' | 'rejected';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* One call an agent made that a human has to confirm: WHO asked, WHICH tool, and WHAT with — the
|
|
10
|
+
* arguments SEALED (never plaintext at rest) so the person deciding sees exactly what they approve,
|
|
11
|
+
* and their keyed digest, which the approval must match.
|
|
12
|
+
*/
|
|
13
|
+
export interface McpConfirmation {
|
|
14
|
+
readonly id: string;
|
|
15
|
+
/** The asking agent actor's id. */
|
|
16
|
+
readonly actorId: string;
|
|
17
|
+
/** Its org, so an app's `check` can keep a decision inside the tenant. `null` when it has none. */
|
|
18
|
+
readonly orgId: string | null;
|
|
19
|
+
readonly tool: string;
|
|
20
|
+
/**
|
|
21
|
+
* `keyedFingerprint` of the validated arguments (`h1:<key id>:<HMAC>`) — keyed because it is stored.
|
|
22
|
+
* The approval binds to it; a rotated signing secret makes the same call a new confirmation.
|
|
23
|
+
*/
|
|
24
|
+
readonly inputDigest: string;
|
|
25
|
+
/** The validated arguments as JSON, `seal()`ed for `MCP_CONFIRMATION_ARGUMENTS_PURPOSE`. */
|
|
26
|
+
readonly sealedArguments: string;
|
|
27
|
+
readonly status: McpConfirmationStatus;
|
|
28
|
+
readonly createdAt: Date;
|
|
29
|
+
/** After this instant the row can be neither decided nor used. */
|
|
30
|
+
readonly expiresAt: Date;
|
|
31
|
+
readonly decidedAt: Date | null;
|
|
32
|
+
/** The deciding actor's id. */
|
|
33
|
+
readonly decidedBy: string | null;
|
|
34
|
+
/**
|
|
35
|
+
* When the asking agent's next identical call took the outcome — ran it, or was told it was
|
|
36
|
+
* rejected or expired. A consumed row is history: the same call afterwards asks again.
|
|
37
|
+
*/
|
|
38
|
+
readonly consumedAt: Date | null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type McpConfirmationDraft = Pick<
|
|
42
|
+
McpConfirmation,
|
|
43
|
+
| 'id'
|
|
44
|
+
| 'actorId'
|
|
45
|
+
| 'orgId'
|
|
46
|
+
| 'tool'
|
|
47
|
+
| 'inputDigest'
|
|
48
|
+
| 'sealedArguments'
|
|
49
|
+
| 'createdAt'
|
|
50
|
+
| 'expiresAt'
|
|
51
|
+
>;
|
|
52
|
+
|
|
53
|
+
export interface McpConfirmationStore {
|
|
54
|
+
/**
|
|
55
|
+
* The OPEN (unconsumed) row for this actor + tool + digest, or a new pending one built from
|
|
56
|
+
* `draft` when there is none — atomically, so two identical calls racing open ONE row.
|
|
57
|
+
*/
|
|
58
|
+
open(draft: McpConfirmationDraft): Promise<{ row: McpConfirmation; created: boolean }>;
|
|
59
|
+
get(id: string): Promise<McpConfirmation | undefined>;
|
|
60
|
+
/**
|
|
61
|
+
* Pending, unconsumed and unexpired at `at` → `status`. `undefined` when the row is not in that
|
|
62
|
+
* state (or does not exist): a decision is taken once, and the caller re-reads to say why.
|
|
63
|
+
*/
|
|
64
|
+
decide(
|
|
65
|
+
id: string,
|
|
66
|
+
status: 'approved' | 'rejected',
|
|
67
|
+
by: string,
|
|
68
|
+
at: Date,
|
|
69
|
+
): Promise<McpConfirmation | undefined>;
|
|
70
|
+
/** Unconsumed → consumed at `at`. `false` when another call took it first. */
|
|
71
|
+
consume(id: string, at: Date): Promise<boolean>;
|
|
72
|
+
/** Delete every row that expired before `before`, and answer how many — run it from a `task`. */
|
|
73
|
+
purge(before: Date): Promise<number>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const openKey = (row: Pick<McpConfirmation, 'actorId' | 'tool' | 'inputDigest'>): string =>
|
|
77
|
+
JSON.stringify([row.actorId, row.tool, row.inputDigest]);
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Per PROCESS: right for a test and for `x dev`, and a lie for a fleet — an agent's call opens the
|
|
81
|
+
* row on one replica and a human's approval lands on another. A deployment passes
|
|
82
|
+
* `postgresConfirmationStore({ executor })`.
|
|
83
|
+
*/
|
|
84
|
+
export function memoryConfirmationStore(): McpConfirmationStore {
|
|
85
|
+
const rows = new Map<string, McpConfirmation>();
|
|
86
|
+
/** open-key → id of the row no call has consumed yet. At most one per key, as Postgres' index. */
|
|
87
|
+
const open = new Map<string, string>();
|
|
88
|
+
const put = (row: McpConfirmation): McpConfirmation => {
|
|
89
|
+
rows.set(row.id, row);
|
|
90
|
+
return row;
|
|
91
|
+
};
|
|
92
|
+
return {
|
|
93
|
+
async open(draft) {
|
|
94
|
+
const existing = open.get(openKey(draft));
|
|
95
|
+
const held = existing === undefined ? undefined : rows.get(existing);
|
|
96
|
+
if (held !== undefined) return { row: held, created: false };
|
|
97
|
+
const row = put({
|
|
98
|
+
...draft,
|
|
99
|
+
status: 'pending',
|
|
100
|
+
decidedAt: null,
|
|
101
|
+
decidedBy: null,
|
|
102
|
+
consumedAt: null,
|
|
103
|
+
});
|
|
104
|
+
open.set(openKey(row), row.id);
|
|
105
|
+
return { row, created: true };
|
|
106
|
+
},
|
|
107
|
+
async get(id) {
|
|
108
|
+
return rows.get(id);
|
|
109
|
+
},
|
|
110
|
+
async decide(id, status, by, at) {
|
|
111
|
+
const row = rows.get(id);
|
|
112
|
+
if (row === undefined || row.status !== 'pending' || row.consumedAt !== null)
|
|
113
|
+
return undefined;
|
|
114
|
+
if (row.expiresAt.getTime() <= at.getTime()) return undefined;
|
|
115
|
+
return put({ ...row, status, decidedBy: by, decidedAt: at });
|
|
116
|
+
},
|
|
117
|
+
async consume(id, at) {
|
|
118
|
+
const row = rows.get(id);
|
|
119
|
+
if (row === undefined || row.consumedAt !== null) return false;
|
|
120
|
+
put({ ...row, consumedAt: at });
|
|
121
|
+
open.delete(openKey(row));
|
|
122
|
+
return true;
|
|
123
|
+
},
|
|
124
|
+
async purge(before) {
|
|
125
|
+
let purged = 0;
|
|
126
|
+
for (const row of [...rows.values()]) {
|
|
127
|
+
if (row.expiresAt.getTime() >= before.getTime()) continue;
|
|
128
|
+
rows.delete(row.id);
|
|
129
|
+
// An unconsumed row IS its key's open row: `open` holds exactly the unconsumed one per key.
|
|
130
|
+
if (row.consumedAt === null) open.delete(openKey(row));
|
|
131
|
+
purged += 1;
|
|
132
|
+
}
|
|
133
|
+
return purged;
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// `mcpConfirmations` — human confirmation for named MCP tools, as a FACTORY OVER `action`, never a
|
|
2
|
+
// ninth primitive. What it returns IS the decision action (`{ id, decision: 'approve' | 'reject' }`):
|
|
3
|
+
// registered like any other, it has its route, OpenAPI operation, typed client and audit record, and
|
|
4
|
+
// its policy is decided by `guard()` like every other write. The gate the agent meets lives in
|
|
5
|
+
// `confirmation-gate.ts`, applied by `defineAppMcp({ confirmations })`.
|
|
6
|
+
|
|
7
|
+
import type { Action } from '@ultimat3/action';
|
|
8
|
+
import { action } from '@ultimat3/action';
|
|
9
|
+
import type { Clock, SealKeySource } from '@ultimat3/core';
|
|
10
|
+
import { finiteCount, systemClock } from '@ultimat3/core';
|
|
11
|
+
import type { KnownPermission, PolicyArgs, PolicyDecision } from '@ultimat3/policy';
|
|
12
|
+
import { can, denied } from '@ultimat3/policy';
|
|
13
|
+
import type { JsonValue } from '@ultimat3/schema';
|
|
14
|
+
import { t } from '@ultimat3/schema';
|
|
15
|
+
import { decideConfirmation } from './confirmation-decide';
|
|
16
|
+
import { McpConfirmationToolUnknownError } from './confirmation-errors';
|
|
17
|
+
import type { McpConfirmation, McpConfirmationStore } from './confirmation-store';
|
|
18
|
+
|
|
19
|
+
/** Ten minutes: long enough for a person to read a request, short enough to go stale. */
|
|
20
|
+
export const DEFAULT_MCP_CONFIRMATION_TTL_MS = 600_000;
|
|
21
|
+
|
|
22
|
+
const decisionInput = () =>
|
|
23
|
+
t.object({
|
|
24
|
+
id: t.uuid,
|
|
25
|
+
decision: t.enum(['view', 'approve', 'reject'] as const),
|
|
26
|
+
/** What the person approves: the `arguments` `view` returned. Required to approve. */
|
|
27
|
+
arguments: t.optional(t.record(t.json())),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const decisionOutput = () =>
|
|
31
|
+
t.object({
|
|
32
|
+
id: t.string,
|
|
33
|
+
tool: t.string,
|
|
34
|
+
status: t.enum(['pending', 'approved', 'rejected'] as const),
|
|
35
|
+
decidedAt: t.nullable(t.date),
|
|
36
|
+
expiresAt: t.date,
|
|
37
|
+
arguments: t.record(t.json()),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
type DecisionInput = ReturnType<typeof decisionInput>;
|
|
41
|
+
type DecisionOutput = ReturnType<typeof decisionOutput>;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* What a decision is asked with. `view` reads the request exactly as the agent sent it; `approve`
|
|
45
|
+
* must carry those `arguments` back, so what a person approves is what they were shown.
|
|
46
|
+
*/
|
|
47
|
+
export interface McpConfirmationDecision {
|
|
48
|
+
readonly id: string;
|
|
49
|
+
readonly decision: 'view' | 'approve' | 'reject';
|
|
50
|
+
readonly arguments?: Readonly<Record<string, JsonValue>>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface McpConfirmationsInput<TTools extends string = string> {
|
|
54
|
+
/** The tools whose every call waits for a person. Each must be one the server projects. */
|
|
55
|
+
readonly tools: readonly TTools[];
|
|
56
|
+
/** `memoryConfirmationStore()` for a test or `x dev`; `postgresConfirmationStore()` for a fleet. */
|
|
57
|
+
readonly store: McpConfirmationStore;
|
|
58
|
+
/** What the deciding actor must hold — the action's permission, read by `can()`. */
|
|
59
|
+
readonly permission: KnownPermission;
|
|
60
|
+
/**
|
|
61
|
+
* WHO may view and decide WHICH confirmation — the app's rule, over the decider and the loaded
|
|
62
|
+
* row (`row.orgId`, `row.actorId`): "an admin, or the user whose agent asked". Absent, the decider
|
|
63
|
+
* must be in the asking agent's org (`row.orgId === (actor.orgId ?? null)`) and a missing row is
|
|
64
|
+
* refused; crossing tenants takes an explicit `check`. An agent never decides one whatever this says.
|
|
65
|
+
*/
|
|
66
|
+
readonly check?: (args: PolicyArgs<unknown, McpConfirmation>) => boolean | PolicyDecision;
|
|
67
|
+
/** How long a confirmation stays decidable and usable. Defaults to `DEFAULT_MCP_CONFIRMATION_TTL_MS`. */
|
|
68
|
+
readonly ttlMs?: number | undefined;
|
|
69
|
+
/** Stamps creation, expiry and decision. Defaulted, never read inline, so a test can move it. */
|
|
70
|
+
readonly clock?: Clock | undefined;
|
|
71
|
+
/** Record every decision through core's `AuditSink`, like any `audit: true` action. */
|
|
72
|
+
readonly audit?: boolean | undefined;
|
|
73
|
+
/** Where the seal key is read (`.secrets.key` / `ULTIMATE_SECRETS_KEY`). Defaults to the app's. */
|
|
74
|
+
readonly sealKeys?: SealKeySource | undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The decision action, carrying what the gate needs. `defineAppMcp({ confirmations })` reads it. */
|
|
78
|
+
export interface McpConfirmations<TTools extends string = string>
|
|
79
|
+
extends Action<DecisionInput, DecisionOutput> {
|
|
80
|
+
/** The gated names, as written — typed, so a test or a screen can name one without a cast. */
|
|
81
|
+
readonly tools: readonly TTools[];
|
|
82
|
+
readonly store: McpConfirmationStore;
|
|
83
|
+
readonly ttlMs: number;
|
|
84
|
+
readonly clock: Clock;
|
|
85
|
+
readonly sealKeys: SealKeySource;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* ```ts
|
|
90
|
+
* // apps/web/app/orders/actions/confirm-refunds.ts
|
|
91
|
+
* export const confirmRefunds = mcpConfirmations({
|
|
92
|
+
* tools: ['refundOrder'],
|
|
93
|
+
* store: postgresConfirmationStore({ executor }),
|
|
94
|
+
* permission: 'order:refund',
|
|
95
|
+
* });
|
|
96
|
+
* // apps/web/app/mcp.ts
|
|
97
|
+
* export const mcp = defineAppMcp({ include: 'exposed', confirmations: confirmRefunds, … });
|
|
98
|
+
* ```
|
|
99
|
+
*/
|
|
100
|
+
export function mcpConfirmations<const TTools extends string>(
|
|
101
|
+
input: McpConfirmationsInput<TTools>,
|
|
102
|
+
): McpConfirmations<TTools> {
|
|
103
|
+
if (input.tools.length === 0)
|
|
104
|
+
throw new McpConfirmationToolUnknownError({ unknown: [], known: [] });
|
|
105
|
+
const ttlMs = finiteCount(
|
|
106
|
+
'mcpConfirmations',
|
|
107
|
+
'ttlMs',
|
|
108
|
+
input.ttlMs ?? DEFAULT_MCP_CONFIRMATION_TTL_MS,
|
|
109
|
+
1,
|
|
110
|
+
);
|
|
111
|
+
const clock = input.clock ?? systemClock;
|
|
112
|
+
const { store, check } = input;
|
|
113
|
+
const sealKeys: SealKeySource = input.sealKeys ?? {};
|
|
114
|
+
|
|
115
|
+
const decide = action({
|
|
116
|
+
input: decisionInput(),
|
|
117
|
+
output: decisionOutput(),
|
|
118
|
+
// The agent refusal is IN the policy, so it is decided by `guard()` — the one authz path — and
|
|
119
|
+
// reads as the same `X_FORBIDDEN` on every surface. A confirmation an agent can grant is a
|
|
120
|
+
// confirmation the agent that asked can grant.
|
|
121
|
+
policy: can<unknown, McpConfirmation>(input.permission, (args) => {
|
|
122
|
+
if (args.actor?.kind === 'agent') return denied('mcp-confirmation.agent-decider');
|
|
123
|
+
return (check ?? sameTenant)(args);
|
|
124
|
+
}),
|
|
125
|
+
...(input.audit === true ? { audit: true } : {}),
|
|
126
|
+
row: async ({ input: asked }) => (await store.get(asked.id)) ?? null,
|
|
127
|
+
// The view and the decision: state only — the policy above has already decided who.
|
|
128
|
+
handle: ({ input: asked, ctx }) =>
|
|
129
|
+
decideConfirmation({ store, clock, sealKeys }, asked, ctx.actor.id),
|
|
130
|
+
});
|
|
131
|
+
return Object.assign(decide, {
|
|
132
|
+
tools: Object.freeze([...input.tools]),
|
|
133
|
+
store,
|
|
134
|
+
ttlMs,
|
|
135
|
+
clock,
|
|
136
|
+
sealKeys,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The default `check`: the decider is in the asking agent's org, and a missing row is refused — so
|
|
142
|
+
* "unknown id" and "another tenant's id" are one answer. An org-less agent's row is decided only by
|
|
143
|
+
* an org-less person; anything wider is an explicit `check`.
|
|
144
|
+
*/
|
|
145
|
+
function sameTenant(args: PolicyArgs<unknown, McpConfirmation>): boolean {
|
|
146
|
+
return args.row !== null && args.row.orgId === (args.actor?.orgId ?? null);
|
|
147
|
+
}
|
package/src/dev-host.ts
CHANGED
|
@@ -12,7 +12,7 @@ import type { DevCapabilities, DevHost, DevIntrospection } from './dev-server';
|
|
|
12
12
|
import { devTools } from './dev-server';
|
|
13
13
|
import type { FrameworkResourceProviders } from './resources';
|
|
14
14
|
import { frameworkResources } from './resources';
|
|
15
|
-
import {
|
|
15
|
+
import { type McpServer, mcpServer } from './server';
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
18
|
* The description half of a real app's `DevHost`. `routes` and `policies` are supplied by
|
|
@@ -39,14 +39,14 @@ export function frameworkIntrospection(
|
|
|
39
39
|
};
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
export interface
|
|
42
|
+
export interface DevMcpServerInput {
|
|
43
43
|
readonly host: DevHost;
|
|
44
44
|
readonly resources?: FrameworkResourceProviders;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
/** `x mcp serve` and `POST /mcp` in dev both build the server through this. */
|
|
48
|
-
export function
|
|
49
|
-
return
|
|
48
|
+
export function devMcpServer(input: DevMcpServerInput): McpServer {
|
|
49
|
+
return mcpServer({
|
|
50
50
|
tools: devTools(input.host),
|
|
51
51
|
resources: frameworkResources(input.resources ?? {}),
|
|
52
52
|
serverInfo: { name: 'ultimate-dev', version: frameworkVersion() },
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// The refusals a TRANSPORT raises before dispatch: one message too large for the wire, a caller
|
|
2
|
+
// past its rate allowance, an `oauth` block that cannot be published. Split from `errors.ts`,
|
|
3
|
+
// which keeps the code registry; this module loads it, so every title is registered first.
|
|
4
|
+
|
|
5
|
+
import { UltimateError } from '@ultimat3/core';
|
|
6
|
+
// why a side-effect import: the codes these classes raise are REGISTERED by `errors.ts`, and a
|
|
7
|
+
// process that imports only a transport must still render their titles.
|
|
8
|
+
import './errors';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* One message larger than the transport holds. Its own code, for the reason `X_MCP_RATE_LIMITED`
|
|
12
|
+
* below is not `X_RATE_LIMITED`: `@ultimat3/http`'s `X_BODY_INVALID` names `bodyLimitBytes` on the
|
|
13
|
+
* HTTP pipeline, and this route never passes through it — the knob is `mcpHttpRoute`'s (or
|
|
14
|
+
* `defineAppMcp`'s, which forwards it), and over stdio it is `serveStdio({ lineLimitBytes })`, a
|
|
15
|
+
* ceiling in characters rather than bytes. One class for both transports, because the condition is
|
|
16
|
+
* one: the peer sent more in one message than this end agreed to hold, and the two answers must
|
|
17
|
+
* name the same code so an agent learns it once.
|
|
18
|
+
*
|
|
19
|
+
* Measured through ai-maxxing's `POST /mcp` on 2026-09-07: the 413 was a bare `-32600` reading
|
|
20
|
+
* `request body is at least N bytes, limit is M` — two numbers and no next step, while the 401,
|
|
21
|
+
* 403 and 429 beside it all carried `{ code, cause, fix }`. A box agent sending a large
|
|
22
|
+
* `promptSession` had nothing to act on. The fix names both moves, send less or raise the cap,
|
|
23
|
+
* because which one is right is the author's call: a prompt can be split, a paged result cannot
|
|
24
|
+
* always be, and a cap set by default was never a decision anyone made about this app.
|
|
25
|
+
*/
|
|
26
|
+
export class McpBodyTooLargeError extends UltimateError {
|
|
27
|
+
readonly limit: number;
|
|
28
|
+
|
|
29
|
+
constructor(input: { transport: 'http' | 'stdio'; limit: number; over?: number | undefined }) {
|
|
30
|
+
const http = input.transport === 'http';
|
|
31
|
+
// A NUMBER in the fix, never a `<n>`: the contract is a one-line edit that runs as written. The
|
|
32
|
+
// cap itself would run and change nothing; twice the larger of the cap and what arrived is a
|
|
33
|
+
// value that admits this message, whichever of the two was the shorter measure. Both are
|
|
34
|
+
// finite non-negative integers by the transports' own screens, so the product is too.
|
|
35
|
+
const raised = 2 * Math.max(input.limit, input.over ?? 0);
|
|
36
|
+
super({
|
|
37
|
+
code: 'X_MCP_BODY_TOO_LARGE',
|
|
38
|
+
cause: http
|
|
39
|
+
? `request body is at least ${input.over ?? input.limit} bytes, limit is ${input.limit}`
|
|
40
|
+
: `one message exceeded ${input.limit} characters and was dropped`,
|
|
41
|
+
fix: http
|
|
42
|
+
? `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} })`
|
|
43
|
+
: `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} })`,
|
|
44
|
+
// The numbers as FIELDS, not only prose: a `--json` reader and the wire's `data` both want
|
|
45
|
+
// the limit without re-parsing a sentence.
|
|
46
|
+
meta: {
|
|
47
|
+
transport: input.transport,
|
|
48
|
+
limit: input.limit,
|
|
49
|
+
...(input.over === undefined ? {} : { over: input.over }),
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
this.limit = input.limit;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The transport's own throttle, and its own CODE rather than `@ultimat3/http`'s `X_RATE_LIMITED`
|
|
58
|
+
* — not because the maths differ (they are the same `Bucket`, the same store and the same
|
|
59
|
+
* `rateLimitDecision`) but because the KNOB does. `X_RATE_LIMITED` tells an operator to raise
|
|
60
|
+
* `rateLimit.buckets` through `configureHttp()`, which governs the HTTP pipeline and not this route: all
|
|
61
|
+
* MCP traffic is one URL and the class comes from the parsed body, so `mcpHttpRoute` meters itself.
|
|
62
|
+
* A fix line that runs and changes nothing is worse than none — the same rule
|
|
63
|
+
* `@ultimat3/realtime`'s `SubscriptionLimitError` follows when it names the knob over the default.
|
|
64
|
+
*
|
|
65
|
+
* The bucket key names the ACTOR and never reaches the caller: a 429 is provokable by anyone
|
|
66
|
+
* holding a valid token, and an org id or an actor id in the body of one is a leak wearing a
|
|
67
|
+
* throttle's clothes.
|
|
68
|
+
*/
|
|
69
|
+
export class McpRateLimitedError extends UltimateError {
|
|
70
|
+
constructor(input: { verbClass: string; limit: number; retryAfterSeconds: number }) {
|
|
71
|
+
super({
|
|
72
|
+
code: 'X_MCP_RATE_LIMITED',
|
|
73
|
+
cause: `this caller has spent its ${input.limit} ${input.verbClass} requests per minute; the bucket refills in ${input.retryAfterSeconds}s`,
|
|
74
|
+
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 })`,
|
|
75
|
+
// why `meta` and not only the sentence: `transport-http.ts` writes `Retry-After` by hand, so
|
|
76
|
+
// this route is correct today. `@ultimat3/http`'s `retryAfterOf` reads the header off
|
|
77
|
+
// `meta.retryAfterSeconds` — so the day an MCP host is mounted inside that pipeline, an error
|
|
78
|
+
// carrying the number only in its prose sheds a caller with no delay to honour, which is the
|
|
79
|
+
// stampede `admit` exists to spread.
|
|
80
|
+
meta: { retryAfterSeconds: input.retryAfterSeconds },
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** `defineAppMcp({ oauth })` refused at boot — see `oauth-metadata.ts`. */
|
|
86
|
+
export class McpOAuthInvalidError extends UltimateError {
|
|
87
|
+
constructor(reason: string) {
|
|
88
|
+
super({
|
|
89
|
+
code: 'X_MCP_OAUTH_INVALID',
|
|
90
|
+
cause: `defineAppMcp({ oauth }) cannot be published as protected-resource metadata: ${reason}`,
|
|
91
|
+
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",
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|