@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 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": "10.0.0",
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": "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"
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 the outcomes that
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 = Object.freeze<Record<McpOutcome, 'info' | 'warn' | 'error'>>({
97
- ok: 'info',
98
- hidden: 'warn',
99
- 'scope-denied': 'warn',
100
- 'policy-denied': 'warn',
101
- 'invalid-args': 'info',
102
- failed: 'error',
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
- const fields: LogFields = {
115
- surface: 'mcp',
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
  }