@ultimat3/mcp 8.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 +28 -0
- package/README.md +1 -1
- package/package.json +8 -8
- package/src/audit.ts +37 -2
- package/src/errors.ts +6 -13
- package/src/readonly-sql.ts +50 -2
- package/src/registry.ts +10 -0
- package/src/server.ts +7 -3
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": "
|
|
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": "
|
|
35
|
-
"@ultimat3/core": "
|
|
36
|
-
"@ultimat3/entity": "
|
|
37
|
-
"@ultimat3/jobs": "
|
|
38
|
-
"@ultimat3/policy": "
|
|
39
|
-
"@ultimat3/query": "
|
|
40
|
-
"@ultimat3/schema": "
|
|
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([
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/readonly-sql.ts
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
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
|
|
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);
|