@ultimat3/mcp 9.0.0 → 11.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 +37 -1
- package/README.md +1 -1
- package/package.json +8 -8
- package/src/audit.ts +93 -17
- package/src/errors.ts +6 -13
- package/src/index.ts +2 -2
- package/src/readonly-sql.ts +50 -2
- package/src/registry.ts +10 -0
- package/src/server.ts +29 -4
package/CLAUDE.md
CHANGED
|
@@ -12,7 +12,7 @@ import. The CLI wires it.
|
|
|
12
12
|
|---|---|
|
|
13
13
|
| `wire.ts` | JSON-RPC types, error codes, protocol version, `JsonSchema` subset |
|
|
14
14
|
| `registry.ts` | catalog + the first two security outcomes (visibility, scope) |
|
|
15
|
-
| `audit.ts` | one structured line per `tools/call`, outcome → level |
|
|
15
|
+
| `audit.ts` | one structured line per `tools/call` and per `resources/read`, outcome → level |
|
|
16
16
|
| `validate-args.ts` | JSON-Schema-subset arg validation, applies defaults |
|
|
17
17
|
| `server.ts` | JSON-RPC dispatch, `classify` for rate-limit buckets |
|
|
18
18
|
| `from-action.ts` | action/query → tool; the "one authz system" projection; `toolsFrom` (sweep, skips) vs `toolsListed` (written out, refuses) |
|
|
@@ -113,6 +113,31 @@ 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
|
+
**On both surfaces**: `mcp.tool-call.<outcome>` from `toolsCall`, `mcp.resource-read.<outcome>`
|
|
117
|
+
from `resourcesRead`, one `LEVEL` table and one field builder behind them. `resources/read`
|
|
118
|
+
emitted nothing at all until 2026-08-23, so a URI walk over the four documents that describe an
|
|
119
|
+
app's whole policy and data map left no trace while the identical walk over tool NAMES was one
|
|
120
|
+
`warn` per attempt. Two EVENTS and not one, because an alert that buckets a document read as a
|
|
121
|
+
tool call cannot tell the two walks apart. `resources/list` and `tools/list` are both silent by
|
|
122
|
+
design — each is answered pre-filtered, so it reveals only what the caller could already see.
|
|
123
|
+
`resource-security.test.ts` reads BOTH streams: core's logger puts `error` on stderr.
|
|
124
|
+
- **A tool that renders its OWN `isError` result may NAME the code it refused with**
|
|
125
|
+
(`McpToolResult.code`), and `outcomeForResult` sends it through the same `outcomeForCode` a
|
|
126
|
+
THROWN error goes through. Audit-only: `server.ts` never puts it on the wire, because the code is
|
|
127
|
+
already inside the rendered body. Without it every self-rendered refusal was `policy-denied` at
|
|
128
|
+
`warn` — so `@ultimat3/admin`'s `X_ADMIN_INVALID` (a client mistyping an argument the published
|
|
129
|
+
JSON Schema could not have refused: admin publishes a `type` per field and nothing else) sat in
|
|
130
|
+
the bucket this package's enumeration alert watches. `X_ADMIN_INVALID` is `ARGUMENT_CODES`;
|
|
131
|
+
`X_INPUT_INVALID` deliberately stays `failed`, because a projected action publishes its WHOLE
|
|
132
|
+
schema and input this server already validated failing inside it means the two have drifted.
|
|
133
|
+
A result naming no code keeps the conservative `policy-denied`.
|
|
134
|
+
- **A `--` comment ends at the first CR *or* LF, because that is Postgres' own boundary set**
|
|
135
|
+
(`readonly-sql.ts`). `non_newline` is `[^\n\r]`, so a bare CR ends the comment for the SERVER
|
|
136
|
+
and did not for this scanner: `select 1;--\rupdate members set role='admin'` was one statement
|
|
137
|
+
with no mutating keyword to all five layer-3 checks at once — the statement split, the read-leader
|
|
138
|
+
check, the write-keyword scan, the forbidden-call scan and the `FOR UPDATE` regex all read the
|
|
139
|
+
stripped form — and `verbatim()` handed the caller's bytes back to run. `endOfLineComment` is the
|
|
140
|
+
lexer's set, never one character of it, the same shape `skipSingleQuoted` already had.
|
|
116
141
|
- `security.test.ts` and `app-security.test.ts` are the executable contract for all of the
|
|
117
142
|
above — the first over hand-built tools (each gate in isolation), the second over what an app
|
|
118
143
|
actually declares (`defineAppMcp` projecting real actions and queries). Extend them, never
|
|
@@ -191,6 +216,17 @@ import. The CLI wires it.
|
|
|
191
216
|
- `db.query` / `db.migrate` refuse structurally, in `readonly-sql.ts`, before the host runs
|
|
192
217
|
(`X_MCP_QUERY_REJECTED` / `X_MCP_NOT_BRANCH_DB` — one code each, because they want different
|
|
193
218
|
next commands).
|
|
219
|
+
- `pg_notify` and the server-control / replication families are banned for the reason every other
|
|
220
|
+
family is: the same ban already exists in another spelling. `notify`/`listen`/`unlisten` are
|
|
221
|
+
WRITE KEYWORDS, so `pg_notify()` is `NOTIFY` as a call the keyword scan cannot see;
|
|
222
|
+
`pg_cancel_backend`/`pg_terminate_backend` establish that server control belongs, so
|
|
223
|
+
`pg_reload_*`, `pg_rotate_*`, `pg_switch_*`, `pg_promote` and `pg_wal_replay_*` join them; and
|
|
224
|
+
`pg_logical_slot_get_changes` is `nextval`'s argument exactly — it advances a slot's confirmed
|
|
225
|
+
position, a write with no keyword that no `ROLLBACK` undoes — which brings `pg_create_*`,
|
|
226
|
+
`pg_drop_*`, `pg_replication_*` and `pg_logical_*` with it. `pg_file_*` is the writing half of
|
|
227
|
+
`pg_read_*`. `txid_current`/`pg_current_xact_id` ASSIGN a transaction id a rollback does not
|
|
228
|
+
return. The catalog VIEWS beside them (`pg_replication_slots`, `pg_stat_replication`) are read
|
|
229
|
+
`from` and never called, so the call scan never sees them.
|
|
194
230
|
- Banned SQL functions are matched as a **prefix of a CALLED function name**, so the family is the
|
|
195
231
|
unit and a spelling nobody wrote down is refused rather than admitted — an exact-name list let
|
|
196
232
|
`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": "11.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": "11.0.0",
|
|
35
|
+
"@ultimat3/core": "11.0.0",
|
|
36
|
+
"@ultimat3/entity": "11.0.0",
|
|
37
|
+
"@ultimat3/jobs": "11.0.0",
|
|
38
|
+
"@ultimat3/policy": "11.0.0",
|
|
39
|
+
"@ultimat3/query": "11.0.0",
|
|
40
|
+
"@ultimat3/schema": "11.0.0"
|
|
41
41
|
}
|
|
42
42
|
}
|
package/src/audit.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// One structured line per `tools/call`, whatever the outcome — including
|
|
2
|
-
// deliberately tell the caller nothing.
|
|
1
|
+
// One structured line per `tools/call` and per `resources/read`, whatever the outcome — including
|
|
2
|
+
// the outcomes that deliberately tell the caller nothing.
|
|
3
3
|
//
|
|
4
4
|
// The three-outcome model works by giving a prober no signal. That is a property of the
|
|
5
5
|
// ANSWER, not of the system: enumeration is a pattern across many requests, so the refusal
|
|
@@ -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 {
|
|
@@ -53,19 +88,31 @@ export interface McpAuditEntry {
|
|
|
53
88
|
readonly code?: string;
|
|
54
89
|
}
|
|
55
90
|
|
|
91
|
+
/** The same entry for the DOCUMENT surface, addressed by URI rather than by tool name. */
|
|
92
|
+
export interface McpResourceAuditEntry {
|
|
93
|
+
readonly uri: string;
|
|
94
|
+
readonly outcome: McpOutcome;
|
|
95
|
+
readonly caller: McpCaller;
|
|
96
|
+
readonly scope?: string;
|
|
97
|
+
readonly code?: string;
|
|
98
|
+
}
|
|
99
|
+
|
|
56
100
|
/**
|
|
57
101
|
* Severity per outcome. Every refusal a prober can drive is `warn` so one alert rule covers
|
|
58
102
|
* the whole enumeration surface; `invalid-args` is a well-behaved client misreading a schema,
|
|
59
103
|
* and an unexpected throw is the only `error` because it is the only one that is a bug.
|
|
60
104
|
*/
|
|
61
|
-
const LEVEL
|
|
62
|
-
ok
|
|
63
|
-
hidden
|
|
64
|
-
'scope-denied'
|
|
65
|
-
'policy-denied'
|
|
66
|
-
'invalid-args'
|
|
67
|
-
failed
|
|
68
|
-
|
|
105
|
+
const LEVEL: ReadonlyMap<McpOutcome, 'info' | 'warn' | 'error'> = new Map([
|
|
106
|
+
['ok', 'info'],
|
|
107
|
+
['hidden', 'warn'],
|
|
108
|
+
['scope-denied', 'warn'],
|
|
109
|
+
['policy-denied', 'warn'],
|
|
110
|
+
['invalid-args', 'info'],
|
|
111
|
+
['failed', 'error'],
|
|
112
|
+
]);
|
|
113
|
+
// An outcome is a closed union, so the `Map` is total; `?? 'error'` is the type's witness, not a
|
|
114
|
+
// reachable branch — a `Map` rather than a literal because the key is data (`proto-index`).
|
|
115
|
+
const levelOf = (outcome: McpOutcome): 'info' | 'warn' | 'error' => LEVEL.get(outcome) ?? 'error';
|
|
69
116
|
|
|
70
117
|
/**
|
|
71
118
|
* Audit one call. `log` is a parameter so a test can read the line it produced; production
|
|
@@ -76,9 +123,41 @@ const LEVEL = Object.freeze<Record<McpOutcome, 'info' | 'warn' | 'error'>>({
|
|
|
76
123
|
* that names `post p_42 in org o_9` is a row leak wearing an audit line's clothes.
|
|
77
124
|
*/
|
|
78
125
|
export function auditToolCall(entry: McpAuditEntry, log: Logger = logger): void {
|
|
79
|
-
|
|
80
|
-
|
|
126
|
+
// `<subsystem>.<event>.<outcome>`, matching every other structured line in the framework,
|
|
127
|
+
// so `x logs --json | grep mcp.tool-call.hidden` is the whole enumeration alert.
|
|
128
|
+
log[levelOf(entry.outcome)](`mcp.tool-call.${entry.outcome}`, {
|
|
81
129
|
tool: entry.tool,
|
|
130
|
+
...callerFields(entry),
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Audit one `resources/read`, on the same three outcomes and at the same levels.
|
|
136
|
+
*
|
|
137
|
+
* The document surface owed this and emitted nothing at all: a URI walk over the four documents an
|
|
138
|
+
* app publishes — the manifest, the OpenAPI document, the route table and the entity schema, which
|
|
139
|
+
* together are its whole policy and data map — left no trace anywhere, while the identical walk
|
|
140
|
+
* over tool NAMES was one `warn` per attempt. The refusal that tells the caller nothing is exactly
|
|
141
|
+
* the one that has to reach whoever reads the logs.
|
|
142
|
+
*
|
|
143
|
+
* A separate EVENT rather than a `tool:` field holding a URI: an alert rule that buckets a
|
|
144
|
+
* document read as a tool call cannot tell the two walks apart, and a field named `tool` carrying
|
|
145
|
+
* `ultimate://manifest` is a lie a query has to work around forever.
|
|
146
|
+
*
|
|
147
|
+
* `resources/list` is deliberately NOT audited, exactly as `tools/list` is not — it is answered
|
|
148
|
+
* pre-filtered, so it reveals only what the caller could already see.
|
|
149
|
+
*/
|
|
150
|
+
export function auditResourceRead(entry: McpResourceAuditEntry, log: Logger = logger): void {
|
|
151
|
+
log[levelOf(entry.outcome)](`mcp.resource-read.${entry.outcome}`, {
|
|
152
|
+
resource: entry.uri,
|
|
153
|
+
...callerFields(entry),
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** What every audit line carries whatever it is about. Fields carry the DECISION, never the data. */
|
|
158
|
+
function callerFields(entry: McpResourceAuditEntry | McpAuditEntry): LogFields {
|
|
159
|
+
return {
|
|
160
|
+
surface: 'mcp',
|
|
82
161
|
outcome: entry.outcome,
|
|
83
162
|
actor: entry.caller.actor.id,
|
|
84
163
|
actorKind: entry.caller.actor.kind,
|
|
@@ -86,7 +165,4 @@ export function auditToolCall(entry: McpAuditEntry, log: Logger = logger): void
|
|
|
86
165
|
...(entry.scope === undefined ? {} : { scope: entry.scope }),
|
|
87
166
|
...(entry.code === undefined ? {} : { code: entry.code }),
|
|
88
167
|
};
|
|
89
|
-
// `<subsystem>.<event>.<outcome>`, matching every other structured line in the framework,
|
|
90
|
-
// so `x logs --json | grep mcp.tool-call.hidden` is the whole enumeration alert.
|
|
91
|
-
log[LEVEL[entry.outcome]](`mcp.tool-call.${entry.outcome}`, fields);
|
|
92
168
|
}
|
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/index.ts
CHANGED
|
@@ -13,8 +13,8 @@ export type {
|
|
|
13
13
|
export { appToolPrimitive, appToolPrimitives } from './app-tool';
|
|
14
14
|
export type { AppMcp, AppToolSchemas, DefineAppMcpInput } from './app-tools';
|
|
15
15
|
export { defineAppMcp } from './app-tools';
|
|
16
|
-
export type { McpAuditEntry, McpOutcome } from './audit';
|
|
17
|
-
export { auditToolCall, outcomeForCode } from './audit';
|
|
16
|
+
export type { McpAuditEntry, McpOutcome, McpResourceAuditEntry } from './audit';
|
|
17
|
+
export { auditResourceRead, auditToolCall, outcomeForCode } from './audit';
|
|
18
18
|
export type { CreateDevServerInput } from './dev-host';
|
|
19
19
|
export { createDevServer, devHost, frameworkIntrospection } from './dev-host';
|
|
20
20
|
export type {
|
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 { auditResourceRead, 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);
|
|
@@ -217,6 +221,11 @@ export class McpServer {
|
|
|
217
221
|
* The same three-outcome shape `toolsCall` above applies, on the document surface. It took no
|
|
218
222
|
* caller at all until 2026-08: every accepted token could list every URI and read every one of
|
|
219
223
|
* them — the manifest, the OpenAPI document, the route table and the entity schema.
|
|
224
|
+
*
|
|
225
|
+
* And every outcome is AUDITED, hidden included, exactly as `toolsCall`'s are: this method
|
|
226
|
+
* emitted nothing at all, so a URI walk over those four documents was invisible while the same
|
|
227
|
+
* walk over tool names was one `warn` per attempt. `resources/list` stays silent, as
|
|
228
|
+
* `tools/list` does — it is answered pre-filtered.
|
|
220
229
|
*/
|
|
221
230
|
private async resourcesRead(req: JsonRpcRequest, caller: McpCaller): Promise<JsonRpcResponse> {
|
|
222
231
|
const id = req.id ?? null;
|
|
@@ -228,8 +237,11 @@ export class McpServer {
|
|
|
228
237
|
const resolved = this.resources.resolve(uri, caller);
|
|
229
238
|
switch (resolved.kind) {
|
|
230
239
|
// OUTCOME 1. Absent AND hidden collapse to one answer with no `data`: this branch used to
|
|
231
|
-
// return `available: [...every uri]`, so one wrong guess enumerated the whole catalog.
|
|
240
|
+
// return `available: [...every uri]`, so one wrong guess enumerated the whole catalog. No
|
|
241
|
+
// `code` on the audit line either, because the wire carries none — the tool surface's
|
|
242
|
+
// `X_MCP_TOOL_UNKNOWN` is an error class this branch has no twin for.
|
|
232
243
|
case 'not-found':
|
|
244
|
+
auditResourceRead({ uri, outcome: 'hidden', caller });
|
|
233
245
|
return errorResponse(id, METHOD_NOT_FOUND, `resource not found: ${uri}`);
|
|
234
246
|
// OUTCOME 2. The caller can already see this resource, so naming the missing scope leaks
|
|
235
247
|
// nothing — and the fix travels with it, built by the error that owns the wording.
|
|
@@ -239,6 +251,13 @@ export class McpServer {
|
|
|
239
251
|
scope: resolved.scope,
|
|
240
252
|
subject: 'resource',
|
|
241
253
|
});
|
|
254
|
+
auditResourceRead({
|
|
255
|
+
uri,
|
|
256
|
+
outcome: 'scope-denied',
|
|
257
|
+
caller,
|
|
258
|
+
scope: resolved.scope,
|
|
259
|
+
code: denial.code,
|
|
260
|
+
});
|
|
242
261
|
return errorResponse(id, INVALID_REQUEST, `missing scope: ${resolved.scope}`, {
|
|
243
262
|
code: denial.code,
|
|
244
263
|
scope: resolved.scope,
|
|
@@ -258,12 +277,17 @@ export class McpServer {
|
|
|
258
277
|
try {
|
|
259
278
|
const contents = await this.resources.read(uri);
|
|
260
279
|
if (contents === undefined) {
|
|
280
|
+
// The resolver said `ok` and the registry then had nothing: a bug here, not a walk, so it
|
|
281
|
+
// is `failed` in the log while the caller still gets the same not-found it would have.
|
|
282
|
+
auditResourceRead({ uri, outcome: 'failed', caller });
|
|
261
283
|
return errorResponse(id, METHOD_NOT_FOUND, `resource not found: ${uri}`);
|
|
262
284
|
}
|
|
285
|
+
auditResourceRead({ uri, outcome: 'ok', caller });
|
|
263
286
|
return resultResponse(id, { contents: [contents] });
|
|
264
287
|
} catch (error) {
|
|
265
288
|
const framework = asFrameworkError(error);
|
|
266
289
|
if (framework !== undefined) {
|
|
290
|
+
auditResourceRead({ uri, outcome: 'failed', caller, code: framework.code });
|
|
267
291
|
return errorResponse(id, INTERNAL_ERROR, `resource "${uri}" could not be read`, {
|
|
268
292
|
code: framework.code,
|
|
269
293
|
cause: framework.cause,
|
|
@@ -272,6 +296,7 @@ export class McpServer {
|
|
|
272
296
|
}
|
|
273
297
|
// No internals: a provider's own message names a path, a query or a host the caller has no
|
|
274
298
|
// business seeing, exactly as a failing tool's does.
|
|
299
|
+
auditResourceRead({ uri, outcome: 'failed', caller });
|
|
275
300
|
return errorResponse(id, INTERNAL_ERROR, `resource "${uri}" could not be read`);
|
|
276
301
|
}
|
|
277
302
|
}
|