@ultimat3/mcp 9.0.0 → 10.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 CHANGED
@@ -113,6 +113,23 @@ import. The CLI wires it.
113
113
  terminal. `server.ts` renders it; the test pins it against `format()`, never a literal.
114
114
  - Every outcome is audited via `audit.ts`, hidden included, at `warn`. Never log arguments
115
115
  or row data — a denial reason naming a row is a leak wearing an audit line's clothes.
116
+ - **A tool that renders its OWN `isError` result may NAME the code it refused with**
117
+ (`McpToolResult.code`), and `outcomeForResult` sends it through the same `outcomeForCode` a
118
+ THROWN error goes through. Audit-only: `server.ts` never puts it on the wire, because the code is
119
+ already inside the rendered body. Without it every self-rendered refusal was `policy-denied` at
120
+ `warn` — so `@ultimat3/admin`'s `X_ADMIN_INVALID` (a client mistyping an argument the published
121
+ JSON Schema could not have refused: admin publishes a `type` per field and nothing else) sat in
122
+ the bucket this package's enumeration alert watches. `X_ADMIN_INVALID` is `ARGUMENT_CODES`;
123
+ `X_INPUT_INVALID` deliberately stays `failed`, because a projected action publishes its WHOLE
124
+ schema and input this server already validated failing inside it means the two have drifted.
125
+ A result naming no code keeps the conservative `policy-denied`.
126
+ - **A `--` comment ends at the first CR *or* LF, because that is Postgres' own boundary set**
127
+ (`readonly-sql.ts`). `non_newline` is `[^\n\r]`, so a bare CR ends the comment for the SERVER
128
+ and did not for this scanner: `select 1;--\rupdate members set role='admin'` was one statement
129
+ with no mutating keyword to all five layer-3 checks at once — the statement split, the read-leader
130
+ check, the write-keyword scan, the forbidden-call scan and the `FOR UPDATE` regex all read the
131
+ stripped form — and `verbatim()` handed the caller's bytes back to run. `endOfLineComment` is the
132
+ lexer's set, never one character of it, the same shape `skipSingleQuoted` already had.
116
133
  - `security.test.ts` and `app-security.test.ts` are the executable contract for all of the
117
134
  above — the first over hand-built tools (each gate in isolation), the second over what an app
118
135
  actually declares (`defineAppMcp` projecting real actions and queries). Extend them, never
@@ -191,6 +208,17 @@ import. The CLI wires it.
191
208
  - `db.query` / `db.migrate` refuse structurally, in `readonly-sql.ts`, before the host runs
192
209
  (`X_MCP_QUERY_REJECTED` / `X_MCP_NOT_BRANCH_DB` — one code each, because they want different
193
210
  next commands).
211
+ - `pg_notify` and the server-control / replication families are banned for the reason every other
212
+ family is: the same ban already exists in another spelling. `notify`/`listen`/`unlisten` are
213
+ WRITE KEYWORDS, so `pg_notify()` is `NOTIFY` as a call the keyword scan cannot see;
214
+ `pg_cancel_backend`/`pg_terminate_backend` establish that server control belongs, so
215
+ `pg_reload_*`, `pg_rotate_*`, `pg_switch_*`, `pg_promote` and `pg_wal_replay_*` join them; and
216
+ `pg_logical_slot_get_changes` is `nextval`'s argument exactly — it advances a slot's confirmed
217
+ position, a write with no keyword that no `ROLLBACK` undoes — which brings `pg_create_*`,
218
+ `pg_drop_*`, `pg_replication_*` and `pg_logical_*` with it. `pg_file_*` is the writing half of
219
+ `pg_read_*`. `txid_current`/`pg_current_xact_id` ASSIGN a transaction id a rollback does not
220
+ return. The catalog VIEWS beside them (`pg_replication_slots`, `pg_stat_replication`) are read
221
+ `from` and never called, so the call scan never sees them.
194
222
  - Banned SQL functions are matched as a **prefix of a CALLED function name**, so the family is the
195
223
  unit and a spelling nobody wrote down is refused rather than admitted — an exact-name list let
196
224
  `pg_sleep_for` past a ban on `pg_sleep`, and `set_config` past `SET`, which is already a write
package/README.md CHANGED
@@ -79,7 +79,7 @@ strand a well-behaved client.
79
79
  | A predicate audience sees the caller and nothing else | it is handed `McpCaller` — never the call arguments, so two calls with different inputs cannot answer differently. Must return the literal `true`; if it throws, the tool is hidden |
80
80
  | `tools/list` is answered per caller | filtered on every call against the caller the transport resolved — one per HTTP request, one per stdio connection — never a static catalog |
81
81
  | Gate order | visibility → scope → arguments → policy; the scope gate never waits on a policy run against attacker-supplied input |
82
- | Every outcome is audited | one line per `tools/call`; hidden/scope/policy at `warn`, ok at `info` — see `audit.ts` |
82
+ | Every outcome is audited | one line per `tools/call`; hidden/scope/policy at `warn`, ok and invalid-args at `info` — see `audit.ts`. A tool that renders its OWN `isError` result may name the code it refused with (`McpToolResult.code`, audit-only, never on the wire) and is then classified by the same `outcomeForCode` a thrown error is — otherwise every self-rendered refusal lands in the `policy-denied` bucket a prober's name walk is alerted from |
83
83
  | Audit lines carry no payload | tool, outcome, actor, code. Never arguments, never rows |
84
84
  | No trusted-tool mode | there is no flag that skips policy evaluation |
85
85
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/mcp",
3
- "version": "9.0.0",
3
+ "version": "10.0.0",
4
4
  "description": "MCP server, dev tools, and the action-to-tool projection — one authz system, two surfaces",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -31,12 +31,12 @@
31
31
  "test": "bun test"
32
32
  },
33
33
  "dependencies": {
34
- "@ultimat3/action": "9.0.0",
35
- "@ultimat3/core": "9.0.0",
36
- "@ultimat3/entity": "9.0.0",
37
- "@ultimat3/jobs": "9.0.0",
38
- "@ultimat3/policy": "9.0.0",
39
- "@ultimat3/query": "9.0.0",
40
- "@ultimat3/schema": "9.0.0"
34
+ "@ultimat3/action": "10.0.0",
35
+ "@ultimat3/core": "10.0.0",
36
+ "@ultimat3/entity": "10.0.0",
37
+ "@ultimat3/jobs": "10.0.0",
38
+ "@ultimat3/policy": "10.0.0",
39
+ "@ultimat3/query": "10.0.0",
40
+ "@ultimat3/schema": "10.0.0"
41
41
  }
42
42
  }
package/src/audit.ts CHANGED
@@ -36,11 +36,46 @@ export type McpOutcome =
36
36
  * entry classified a code no build can produce — and every real denial that reached it still had
37
37
  * to be recognised by one of the other two.
38
38
  */
39
- const DENIAL_CODES: ReadonlySet<string> = new Set(['X_FORBIDDEN', 'X_UNAUTHENTICATED']);
39
+ const DENIAL_CODES: ReadonlySet<string> = new Set([
40
+ 'X_ADMIN_DENIED',
41
+ 'X_FORBIDDEN',
42
+ 'X_UNAUTHENTICATED',
43
+ ]);
44
+
45
+ /**
46
+ * Codes that mean the CALLER got the arguments wrong, in a way the published JSON Schema could
47
+ * not have refused. `X_ADMIN_INVALID` is the case: `@ultimat3/admin` publishes a `type` per field
48
+ * and nothing else, so an entity's own rules are the first thing a value meets, and a mistyped
49
+ * `admin.create` is a client misreading a schema — `invalid-args`, at `info`.
50
+ *
51
+ * `X_INPUT_INVALID` deliberately stays OUT: a projected action publishes its whole schema, so
52
+ * input this server already validated failing inside the action means the two have drifted, and
53
+ * that wants a human.
54
+ */
55
+ const ARGUMENT_CODES: ReadonlySet<string> = new Set(['X_ADMIN_INVALID']);
40
56
 
41
57
  /** Classify a code a tool threw. Denials are outcome 3; everything else wants a human. */
42
58
  export function outcomeForCode(code: string): McpOutcome {
43
- return DENIAL_CODES.has(code) ? 'policy-denied' : 'failed';
59
+ if (DENIAL_CODES.has(code)) return 'policy-denied';
60
+ return ARGUMENT_CODES.has(code) ? 'invalid-args' : 'failed';
61
+ }
62
+
63
+ /**
64
+ * Classify a result a tool RENDERED rather than threw.
65
+ *
66
+ * A tool may answer `isError` itself — `@ultimat3/admin` does, so the model reads code/cause/fix
67
+ * instead of a transport failure — and that answer used to be audited `policy-denied` at `warn`
68
+ * whatever it refused for. So a malformed `admin.create` landed in the bucket this file exists to
69
+ * make alertable: every refusal a prober can drive. A result that NAMES its code goes through
70
+ * `outcomeForCode`, the same classifier a thrown one goes through; one that names none keeps the
71
+ * conservative reading, because a tool cannot be assumed to have refused for a benign reason.
72
+ */
73
+ export function outcomeForResult(result: {
74
+ readonly isError?: boolean;
75
+ readonly code?: string;
76
+ }): McpOutcome {
77
+ if (result.isError !== true) return 'ok';
78
+ return result.code === undefined ? 'policy-denied' : outcomeForCode(result.code);
44
79
  }
45
80
 
46
81
  export interface McpAuditEntry {
package/src/errors.ts CHANGED
@@ -42,7 +42,12 @@ registerErrorCodes(
42
42
  Object.fromEntries(Object.entries(MCP_ERROR_TITLES).map(([code, title]) => [code, { title }])),
43
43
  );
44
44
 
45
- const docsFor = (code: McpErrorCode): string => `https://ultimate.dev/errors/${code}`;
45
+ // No `docs:` on the subclasses below. `UltimateError` fills it from `describeErrorCode(code).docs`,
46
+ // which is `@ultimat3/core`'s `ERROR_DOCS_URL` — one page for every code, never one per code, because
47
+ // `wiki/` is the framework's only public documentation surface and a code lives there in a TABLE ROW,
48
+ // which has no anchor. The `https://ultimate.dev/errors/<code>` links this file built until 9.x
49
+ // answered 404, host included, on every error it has ever thrown; restating the replacement here
50
+ // would be the same constant in eight places waiting to drift again.
46
51
 
47
52
  /**
48
53
  * OUTCOME 1 of three: a tool name reached the dispatcher that no VISIBLE tool answers to —
@@ -59,7 +64,6 @@ export class McpToolUnknownError extends UltimateError {
59
64
  input.visible.length > 0 ? input.visible.join(', ') : 'none'
60
65
  })`,
61
66
  fix: 'call tools/list to read the catalog this caller may use',
62
- docs: docsFor('X_MCP_TOOL_UNKNOWN'),
63
67
  });
64
68
  }
65
69
  }
@@ -87,7 +91,6 @@ export class McpScopeDeniedError extends UltimateError {
87
91
  subject === 'tool'
88
92
  ? `reconnect with a token whose scopes include "${input.scope}" — the app's resolveToken(token) is what returns them — or drop "${input.scope}" from defineAppMcp({ scopes }); scopes are fixed for the life of a connection`
89
93
  : `reconnect with a token whose scopes include "${input.scope}" — the app's resolveToken(token) is what returns them — or drop scope: '${input.scope}' from the resource declaring "${input.name}"; scopes are fixed for the life of a connection`,
90
- docs: docsFor('X_MCP_SCOPE_DENIED'),
91
94
  });
92
95
  this.scope = input.scope;
93
96
  }
@@ -100,7 +103,6 @@ export class McpArgsInvalidError extends UltimateError {
100
103
  code: 'X_MCP_ARGS_INVALID',
101
104
  cause: `arguments for "${input.name}" are invalid: ${input.issues.join('; ')}`,
102
105
  fix: `re-read the tool's inputSchema from tools/list and resend`,
103
- docs: docsFor('X_MCP_ARGS_INVALID'),
104
106
  });
105
107
  }
106
108
  }
@@ -116,7 +118,6 @@ export class McpToolUnsafeError extends UltimateError {
116
118
  code: 'X_MCP_TOOL_UNSAFE',
117
119
  cause: `tool "${input.name}" declares no policy; an unguarded tool is a second door into the data`,
118
120
  fix: `add policy: '<resource>:<verb>' to the tool, reusing the permission its action uses`,
119
- docs: docsFor('X_MCP_TOOL_UNSAFE'),
120
121
  });
121
122
  }
122
123
  }
@@ -143,7 +144,6 @@ export class McpToolUndeclaredError extends UltimateError {
143
144
  fix:
144
145
  "add mcp: { expose: true, description: '<what it does>' } beside the policy on each — " +
145
146
  "or drop it from the list and let include: 'exposed' project what opted in",
146
- docs: docsFor('X_MCP_TOOL_UNDECLARED'),
147
147
  });
148
148
  this.names = input.names;
149
149
  }
@@ -176,7 +176,6 @@ export class McpToolDuplicateError extends UltimateError {
176
176
  sites.length > 0
177
177
  ? `rename one — "${input.name}" is projected by ${sites.join(' and ')}; change the name at one of them`
178
178
  : "rename one: the tool name is the primitive's export name, the `tools` record key, or an admin action's `name`",
179
- docs: docsFor('X_MCP_TOOL_DUPLICATE'),
180
179
  });
181
180
  this.declaredBy = sites;
182
181
  }
@@ -194,7 +193,6 @@ export class McpResourceDuplicateError extends UltimateError {
194
193
  code: 'X_MCP_RESOURCE_DUPLICATE',
195
194
  cause: `two resources are registered at "${input.uri}"; a URI addresses one document`,
196
195
  fix: `give one of them its own URI — register({ uri: '${input.uri}-<what-it-is>', … }) — or drop the duplicate registration`,
197
- docs: docsFor('X_MCP_RESOURCE_DUPLICATE'),
198
196
  });
199
197
  }
200
198
  }
@@ -215,7 +213,6 @@ export class McpScopeUnknownError extends UltimateError {
215
213
  code: 'X_MCP_SCOPE_UNKNOWN',
216
214
  cause: `scopes["${input.scope}"] names "${input.name}", which this server does not project (projected: ${projected})`,
217
215
  fix: `in defineAppMcp, spell it as one of the projected names above — or drop "${input.name}" from scopes["${input.scope}"]`,
218
- docs: docsFor('X_MCP_SCOPE_UNKNOWN'),
219
216
  });
220
217
  this.projected = input.projected;
221
218
  }
@@ -239,7 +236,6 @@ export class McpScopeConflictError extends UltimateError {
239
236
  code: 'X_MCP_SCOPE_CONFLICT',
240
237
  cause: `tool "${input.name}" is claimed by two scopes ("${input.scopes[0]}" and "${input.scopes[1]}"); a tool carries one`,
241
238
  fix: `in defineAppMcp, keep "${input.name}" under the single scope a token must hold for it, and remove the other entry`,
242
- docs: docsFor('X_MCP_SCOPE_CONFLICT'),
243
239
  });
244
240
  this.scopes = input.scopes;
245
241
  }
@@ -252,7 +248,6 @@ export class McpProtocolError extends UltimateError {
252
248
  code: 'X_MCP_PROTOCOL',
253
249
  cause: input.cause,
254
250
  fix: input.fix ?? `send a JSON-RPC 2.0 body: { jsonrpc: '2.0', id, method, params }`,
255
- docs: docsFor('X_MCP_PROTOCOL'),
256
251
  });
257
252
  }
258
253
  }
@@ -275,7 +270,6 @@ export class McpQueryRejectedError extends UltimateError {
275
270
  code: 'X_MCP_QUERY_REJECTED',
276
271
  cause: input.cause,
277
272
  fix: input.fix,
278
- docs: docsFor('X_MCP_QUERY_REJECTED'),
279
273
  });
280
274
  }
281
275
  }
@@ -295,7 +289,6 @@ export class McpNotBranchDbError extends UltimateError {
295
289
  code: 'X_MCP_NOT_BRANCH_DB',
296
290
  cause: input.cause,
297
291
  fix: input.fix,
298
- docs: docsFor('X_MCP_NOT_BRANCH_DB'),
299
292
  });
300
293
  }
301
294
  }
@@ -89,7 +89,22 @@ const WRITE_KEYWORDS = new Set([
89
89
  * - ADVANCE A SEQUENCE (`nextval`, `setval`) — a write that leaves no keyword behind, and one
90
90
  * `ROLLBACK` does not undo: a consumed sequence value is gone, so a read can silently burn the
91
91
  * next id a real insert would have taken. `currval`/`lastval` read the session and stay legal.
92
+ * `txid_current`/`pg_current_xact_id` are the same ban one level down: they ASSIGN a real
93
+ * transaction id to a read, and a rollback does not give it back;
94
+ * - PUBLISH A MESSAGE — `pg_notify` is `NOTIFY` spelled as a call, and `notify`, `listen` and
95
+ * `unlisten` are all write keywords above. The keyword scan cannot see it: it is one token;
96
+ * - CONTROL THE SERVER (`pg_reload_*`, `pg_rotate_*`, `pg_switch_*`, `pg_promote`,
97
+ * `pg_wal_replay_*`) — the family `pg_cancel_backend`/`pg_terminate_backend` already
98
+ * established, in the spellings that reconfigure or fail over the server rather than a backend;
99
+ * - CONSUME THE REPLICATION STREAM (`pg_create_*`, `pg_drop_*`, `pg_replication_*`,
100
+ * `pg_logical_*`) — `pg_logical_slot_get_changes` advances a slot's confirmed position, so the
101
+ * changes it returned are gone for the real consumer. Exactly the `nextval` argument: a write
102
+ * with no keyword, and no `ROLLBACK` undoes it. The catalog VIEWS beside them
103
+ * (`pg_replication_slots`, `pg_stat_replication`) are read `from`, never called, so the call
104
+ * scan never sees them;
105
+ * - WRITE A FILE (`pg_file_*`) — the other half of `pg_read_*`, which was banned from the start.
92
106
  *
107
+
93
108
  * The prefix is applied to a CALL — a name followed by `(` — and never to a bare word, so a
94
109
  * column called `pg_sleep_for_seconds` stays readable. Quoting does not evade it: the scan reads
95
110
  * a form where a quoted identifier keeps its content, because `"pg_advisory_lock"(1)` is the same
@@ -101,15 +116,31 @@ const FORBIDDEN_FUNCTIONS = [
101
116
  'nextval',
102
117
  'pg_advisory_',
103
118
  'pg_cancel_backend',
119
+ 'pg_create_',
120
+ 'pg_current_xact_id',
121
+ 'pg_drop_',
122
+ 'pg_file_',
123
+ 'pg_logical_',
104
124
  'pg_ls_',
125
+ 'pg_notify',
126
+ 'pg_promote',
105
127
  'pg_read_',
128
+ 'pg_reload_',
129
+ 'pg_replication_',
130
+ 'pg_rotate_',
106
131
  'pg_sleep',
107
132
  'pg_stat_file',
108
133
  'pg_stat_reset',
134
+ // Not reachable from `pg_stat_reset`: the extension spells the same reset with the statistics
135
+ // view's name in the middle, so a prefix of one is not a prefix of the other.
136
+ 'pg_stat_statements_reset',
137
+ 'pg_switch_',
109
138
  'pg_terminate_backend',
110
139
  'pg_try_advisory_',
140
+ 'pg_wal_replay_',
111
141
  'set_config',
112
142
  'setval',
143
+ 'txid_current',
113
144
  ];
114
145
 
115
146
  /** The family refusing `called`, or `undefined`. A prefix, so a new member is refused by default. */
@@ -291,8 +322,7 @@ function stripLiteralsAndComments(sql: string, identifiers: 'blank' | 'keep' = '
291
322
  while (i < sql.length) {
292
323
  const two = sql.slice(i, i + 2);
293
324
  if (two === '--') {
294
- const end = sql.indexOf('\n', i);
295
- i = end === -1 ? sql.length : end;
325
+ i = endOfLineComment(sql, i);
296
326
  out += ' ';
297
327
  continue;
298
328
  }
@@ -336,6 +366,24 @@ function stripLiteralsAndComments(sql: string, identifiers: 'blank' | 'keep' = '
336
366
  return out;
337
367
  }
338
368
 
369
+ /**
370
+ * Where a `--` comment ends: the first CR **or** LF, or the end of the input.
371
+ *
372
+ * The boundary set is the lexer's, never one character of it. Postgres defines a line comment as
373
+ * `--` followed by `non_newline*`, and `non_newline` is `[^\n\r]` — so a bare CR ends the comment
374
+ * for the server. Scanning for `\n` alone blanked everything after a CR, and that tail is real SQL
375
+ * the server runs: `select 1;--\rupdate members set role='admin'` was one statement with no
376
+ * mutating keyword to every check in this file, and was handed back verbatim to be executed.
377
+ * Same shape as `skipSingleQuoted` below, which reads Postgres' escape rules rather than one of them.
378
+ */
379
+ function endOfLineComment(sql: string, start: number): number {
380
+ for (let i = start + 2; i < sql.length; i += 1) {
381
+ const char = sql[i];
382
+ if (char === '\n' || char === '\r') return i;
383
+ }
384
+ return sql.length;
385
+ }
386
+
339
387
  /** A quoted run's content: the delimiters dropped, SQL's doubled-quote escape collapsed. */
340
388
  function inner(run: string): string {
341
389
  const closed = run.length > 1 && run.endsWith(run[0] ?? '');
package/src/registry.ts CHANGED
@@ -66,6 +66,16 @@ export type ContentBlock =
66
66
  export interface McpToolResult {
67
67
  readonly content: readonly ContentBlock[];
68
68
  readonly isError?: boolean;
69
+ /**
70
+ * The `X_*` code an `isError` result refused with. AUDIT ONLY — never written to the wire,
71
+ * because the code is already in the rendered body the model reads.
72
+ *
73
+ * A tool that renders its own refusal was otherwise audited `policy-denied` whatever it refused
74
+ * for, so a tool's own ARGUMENT check landed in the bucket a prober's name walk is alerted from.
75
+ * Naming the code sends it through `outcomeForCode`, the classifier a THROWN error already goes
76
+ * through. Absent keeps the conservative reading.
77
+ */
78
+ readonly code?: string;
69
79
  }
70
80
 
71
81
  export type ToolArgs = Record<string, unknown>;
package/src/server.ts CHANGED
@@ -5,7 +5,7 @@
5
5
 
6
6
  import { singleLine, stringField } from '@ultimat3/core';
7
7
  import { formatIssues } from '@ultimat3/schema';
8
- import { auditToolCall, outcomeForCode } from './audit';
8
+ import { auditToolCall, outcomeForCode, outcomeForResult } from './audit';
9
9
  import { McpScopeDeniedError } from './errors';
10
10
  import type { AnyMcpTool, McpCaller, McpToolResult, McpVerbClass, ToolListEntry } from './registry';
11
11
  import { ToolRegistry } from './registry';
@@ -202,12 +202,16 @@ export class McpServer {
202
202
  return errorResponse(id, INTERNAL_ERROR, `tool "${name}" failed unexpectedly`);
203
203
  }
204
204
 
205
- // A tool may answer `isError` itself (admin renders its own denial): still outcome 3.
205
+ // A tool may answer `isError` itself (admin renders its own denial). Outcome 3 unless it
206
+ // NAMED the code it refused with, in which case the same classifier a thrown error goes
207
+ // through decides — a tool's own argument check is not a denial a prober drove.
206
208
  auditToolCall({
207
209
  tool: name,
208
- outcome: result.isError === true ? 'policy-denied' : 'ok',
210
+ outcome: outcomeForResult(result),
209
211
  caller,
212
+ ...(result.code === undefined ? {} : { code: result.code }),
210
213
  });
214
+ // `code` is audit-only and never reaches the wire: it is already inside the rendered body.
211
215
  const payload: Record<string, unknown> = { content: result.content };
212
216
  if (result.isError === true) payload['isError'] = true;
213
217
  return resultResponse(id, payload);