@ultimat3/mcp 10.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 +9 -1
- package/package.json +8 -8
- package/src/audit.ts +56 -15
- package/src/index.ts +2 -2
- package/src/server.ts +23 -2
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,14 @@ 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.
|
|
116
124
|
- **A tool that renders its OWN `isError` result may NAME the code it refused with**
|
|
117
125
|
(`McpToolResult.code`), and `outcomeForResult` sends it through the same `outcomeForCode` a
|
|
118
126
|
THROWN error goes through. Audit-only: `server.ts` never puts it on the wire, because the code is
|
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
|
|
@@ -88,19 +88,31 @@ export interface McpAuditEntry {
|
|
|
88
88
|
readonly code?: string;
|
|
89
89
|
}
|
|
90
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
|
+
|
|
91
100
|
/**
|
|
92
101
|
* Severity per outcome. Every refusal a prober can drive is `warn` so one alert rule covers
|
|
93
102
|
* the whole enumeration surface; `invalid-args` is a well-behaved client misreading a schema,
|
|
94
103
|
* and an unexpected throw is the only `error` because it is the only one that is a bug.
|
|
95
104
|
*/
|
|
96
|
-
const LEVEL
|
|
97
|
-
ok
|
|
98
|
-
hidden
|
|
99
|
-
'scope-denied'
|
|
100
|
-
'policy-denied'
|
|
101
|
-
'invalid-args'
|
|
102
|
-
failed
|
|
103
|
-
|
|
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';
|
|
104
116
|
|
|
105
117
|
/**
|
|
106
118
|
* Audit one call. `log` is a parameter so a test can read the line it produced; production
|
|
@@ -111,9 +123,41 @@ const LEVEL = Object.freeze<Record<McpOutcome, 'info' | 'warn' | 'error'>>({
|
|
|
111
123
|
* that names `post p_42 in org o_9` is a row leak wearing an audit line's clothes.
|
|
112
124
|
*/
|
|
113
125
|
export function auditToolCall(entry: McpAuditEntry, log: Logger = logger): void {
|
|
114
|
-
|
|
115
|
-
|
|
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}`, {
|
|
116
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',
|
|
117
161
|
outcome: entry.outcome,
|
|
118
162
|
actor: entry.caller.actor.id,
|
|
119
163
|
actorKind: entry.caller.actor.kind,
|
|
@@ -121,7 +165,4 @@ export function auditToolCall(entry: McpAuditEntry, log: Logger = logger): void
|
|
|
121
165
|
...(entry.scope === undefined ? {} : { scope: entry.scope }),
|
|
122
166
|
...(entry.code === undefined ? {} : { code: entry.code }),
|
|
123
167
|
};
|
|
124
|
-
// `<subsystem>.<event>.<outcome>`, matching every other structured line in the framework,
|
|
125
|
-
// so `x logs --json | grep mcp.tool-call.hidden` is the whole enumeration alert.
|
|
126
|
-
log[LEVEL[entry.outcome]](`mcp.tool-call.${entry.outcome}`, fields);
|
|
127
168
|
}
|
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/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, outcomeForResult } 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';
|
|
@@ -221,6 +221,11 @@ export class McpServer {
|
|
|
221
221
|
* The same three-outcome shape `toolsCall` above applies, on the document surface. It took no
|
|
222
222
|
* caller at all until 2026-08: every accepted token could list every URI and read every one of
|
|
223
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.
|
|
224
229
|
*/
|
|
225
230
|
private async resourcesRead(req: JsonRpcRequest, caller: McpCaller): Promise<JsonRpcResponse> {
|
|
226
231
|
const id = req.id ?? null;
|
|
@@ -232,8 +237,11 @@ export class McpServer {
|
|
|
232
237
|
const resolved = this.resources.resolve(uri, caller);
|
|
233
238
|
switch (resolved.kind) {
|
|
234
239
|
// OUTCOME 1. Absent AND hidden collapse to one answer with no `data`: this branch used to
|
|
235
|
-
// 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.
|
|
236
243
|
case 'not-found':
|
|
244
|
+
auditResourceRead({ uri, outcome: 'hidden', caller });
|
|
237
245
|
return errorResponse(id, METHOD_NOT_FOUND, `resource not found: ${uri}`);
|
|
238
246
|
// OUTCOME 2. The caller can already see this resource, so naming the missing scope leaks
|
|
239
247
|
// nothing — and the fix travels with it, built by the error that owns the wording.
|
|
@@ -243,6 +251,13 @@ export class McpServer {
|
|
|
243
251
|
scope: resolved.scope,
|
|
244
252
|
subject: 'resource',
|
|
245
253
|
});
|
|
254
|
+
auditResourceRead({
|
|
255
|
+
uri,
|
|
256
|
+
outcome: 'scope-denied',
|
|
257
|
+
caller,
|
|
258
|
+
scope: resolved.scope,
|
|
259
|
+
code: denial.code,
|
|
260
|
+
});
|
|
246
261
|
return errorResponse(id, INVALID_REQUEST, `missing scope: ${resolved.scope}`, {
|
|
247
262
|
code: denial.code,
|
|
248
263
|
scope: resolved.scope,
|
|
@@ -262,12 +277,17 @@ export class McpServer {
|
|
|
262
277
|
try {
|
|
263
278
|
const contents = await this.resources.read(uri);
|
|
264
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 });
|
|
265
283
|
return errorResponse(id, METHOD_NOT_FOUND, `resource not found: ${uri}`);
|
|
266
284
|
}
|
|
285
|
+
auditResourceRead({ uri, outcome: 'ok', caller });
|
|
267
286
|
return resultResponse(id, { contents: [contents] });
|
|
268
287
|
} catch (error) {
|
|
269
288
|
const framework = asFrameworkError(error);
|
|
270
289
|
if (framework !== undefined) {
|
|
290
|
+
auditResourceRead({ uri, outcome: 'failed', caller, code: framework.code });
|
|
271
291
|
return errorResponse(id, INTERNAL_ERROR, `resource "${uri}" could not be read`, {
|
|
272
292
|
code: framework.code,
|
|
273
293
|
cause: framework.cause,
|
|
@@ -276,6 +296,7 @@ export class McpServer {
|
|
|
276
296
|
}
|
|
277
297
|
// No internals: a provider's own message names a path, a query or a host the caller has no
|
|
278
298
|
// business seeing, exactly as a failing tool's does.
|
|
299
|
+
auditResourceRead({ uri, outcome: 'failed', caller });
|
|
279
300
|
return errorResponse(id, INTERNAL_ERROR, `resource "${uri}" could not be read`);
|
|
280
301
|
}
|
|
281
302
|
}
|