@ultimat3/mcp 24.0.0 → 25.1.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/src/server.ts CHANGED
@@ -3,9 +3,11 @@
3
3
  // and an already-resolved caller, and returns a response or `null` for a notification.
4
4
  // Both transports (http, stdio) and every test drive this one function.
5
5
 
6
- import type { ErrorAudience } from '@ultimat3/core';
6
+ import type { Clock, ErrorAudience } from '@ultimat3/core';
7
7
  import { fixFor } from '@ultimat3/core';
8
- import { auditResourceRead, auditToolCall, outcomeForResult } from './audit';
8
+ import { outcomeForResult } from './audit';
9
+ import type { McpAuditHook, McpAuditor } from './audit-hook';
10
+ import { LOG_ONLY_AUDITOR, mcpAuditor } from './audit-hook';
9
11
  import { McpProtocolError, McpScopeDeniedError, TOOL_UNKNOWN_FIX } from './errors';
10
12
  import { asFrameworkError, forAudience } from './framework-error';
11
13
  import { metaCall } from './meta-call';
@@ -27,7 +29,7 @@ import { ResourceRegistry } from './resources';
27
29
  import type { McpInstructions, McpServerVoice } from './server-voice';
28
30
  import { instructionsFor, invalidArgsResult } from './server-voice';
29
31
  import { admitted, thrownResponse } from './tool-thrown';
30
- import type { JsonRpcId, JsonRpcRequest, JsonRpcResponse, ServerInfo } from './wire';
32
+ import type { JsonRpcId, JsonRpcRequest, JsonRpcResponse, McpWire, ServerInfo } from './wire';
31
33
  import {
32
34
  defaultServerInfo,
33
35
  errorResponse,
@@ -38,28 +40,14 @@ import {
38
40
  isNotification,
39
41
  MCP_PROTOCOL_VERSION,
40
42
  METHOD_NOT_FOUND,
43
+ messageUnitOf,
41
44
  paramsOf,
42
45
  resultResponse,
43
46
  } from './wire';
44
47
 
45
- /**
46
- * Which wire one message arrived on, so a refusal can name the unit THAT transport counts in.
47
- * `handle` is transport-independent and stays so — this is a hint for the wording of one fix, never
48
- * a branch in dispatch. HTTP carries its mounted path because `mcpHttpRoute({ path })` is a knob:
49
- * a batch refusal that told every client to retry `POST /mcp` was wrong for an app mounted at
50
- * `/app-mcp`, which is how this came to be threaded rather than spelled.
51
- */
52
- export type McpWire =
53
- | { readonly transport: 'http'; readonly path: string }
54
- | { readonly transport: 'stdio' };
55
-
56
- /** The unit a transport counts one request in — or the neutral word when no wire said. */
57
- const messageUnitOf = (wire: McpWire | undefined): string => {
58
- if (wire === undefined) return 'message';
59
- return wire.transport === 'http' ? `POST ${wire.path}` : 'line';
60
- };
48
+ export type { McpWire } from './wire';
61
49
 
62
- export interface CreateMcpServerInput {
50
+ export interface McpServerInput {
63
51
  readonly tools?: readonly AnyMcpTool[];
64
52
  readonly resources?: readonly McpResource[];
65
53
  readonly prompts?: readonly McpPrompt[];
@@ -82,6 +70,10 @@ export interface CreateMcpServerInput {
82
70
  * serves `'caller'`.
83
71
  */
84
72
  readonly errorAudience?: ErrorAudience | undefined;
73
+ /** Every gate decision as data, after its log line — see `audit-hook.ts`. The route reads it too. */
74
+ readonly onAudit?: McpAuditHook | undefined;
75
+ /** Stamps each `onAudit` event's `at`. */
76
+ readonly clock?: Clock | undefined;
85
77
  }
86
78
 
87
79
  /** The set of JSON-RPC methods this server answers. Kept in sync with `classify`. */
@@ -96,7 +88,7 @@ const METHODS = [
96
88
  'prompts/get',
97
89
  ] as const;
98
90
 
99
- export function createMcpServer(input: CreateMcpServerInput = {}): McpServer {
91
+ export function mcpServer(input: McpServerInput = {}): McpServer {
100
92
  const tools = new ToolRegistry().registerAll(input.tools ?? []);
101
93
  const resources = new ResourceRegistry().registerAll(input.resources ?? []);
102
94
  const meta = MetaSurface.build({
@@ -111,6 +103,9 @@ export function createMcpServer(input: CreateMcpServerInput = {}): McpServer {
111
103
  input.serverInfo ?? defaultServerInfo(),
112
104
  meta,
113
105
  { instructions: input.instructions, errorAudience: input.errorAudience ?? 'developer' },
106
+ input.onAudit === undefined && input.clock === undefined
107
+ ? LOG_ONLY_AUDITOR
108
+ : mcpAuditor({ onAudit: input.onAudit, clock: input.clock }),
114
109
  );
115
110
  }
116
111
 
@@ -122,6 +117,8 @@ export class McpServer {
122
117
  /** `undefined` for a flat-only server — every existing app, byte for byte. */
123
118
  private readonly meta: MetaSurface | undefined;
124
119
  private readonly voice: McpServerVoice;
120
+ /** Where every decision is audited — the log line, then `onAudit`. Public for the HTTP route. */
121
+ readonly audit: McpAuditor;
125
122
 
126
123
  constructor(
127
124
  tools: ToolRegistry,
@@ -130,6 +127,7 @@ export class McpServer {
130
127
  serverInfo: ServerInfo,
131
128
  meta?: MetaSurface,
132
129
  voice: McpServerVoice = { errorAudience: 'developer' },
130
+ audit: McpAuditor = LOG_ONLY_AUDITOR,
133
131
  ) {
134
132
  this.tools = tools;
135
133
  this.resources = resources;
@@ -137,6 +135,7 @@ export class McpServer {
137
135
  this.serverInfo = serverInfo;
138
136
  this.meta = meta;
139
137
  this.voice = voice;
138
+ this.audit = audit;
140
139
  }
141
140
 
142
141
  async handle(
@@ -275,6 +274,7 @@ export class McpServer {
275
274
  const answered = await metaCall(
276
275
  {
277
276
  tools: this.tools,
277
+ audit: this.audit,
278
278
  dispatch: (at, tool, resolved, who) => this.dispatch(at, tool, resolved, who),
279
279
  notFound: (at, tool, who, fix) => this.notFound(at, tool, who, fix),
280
280
  },
@@ -293,7 +293,7 @@ export class McpServer {
293
293
 
294
294
  /** OUTCOME 1, the one answer for absent and hidden alike. See `dispatch`. */
295
295
  private notFound(id: JsonRpcId, name: string, caller: McpCaller, fix: string): JsonRpcResponse {
296
- auditToolCall({ tool: name, outcome: 'hidden', caller, code: 'X_MCP_TOOL_UNKNOWN' });
296
+ this.audit.toolCall({ tool: name, outcome: 'hidden', caller, code: 'X_MCP_TOOL_UNKNOWN' });
297
297
  return errorResponse(id, METHOD_NOT_FOUND, `tool not found: ${name} — ${fix}`);
298
298
  }
299
299
 
@@ -327,7 +327,7 @@ export class McpServer {
327
327
  // nothing — and the fix travels with it, built by the error that owns the wording.
328
328
  case 'scope-denied': {
329
329
  const denial = new McpScopeDeniedError({ name, scope: resolved.scope });
330
- auditToolCall({
330
+ this.audit.toolCall({
331
331
  tool: name,
332
332
  outcome: 'scope-denied',
333
333
  caller,
@@ -346,9 +346,10 @@ export class McpServer {
346
346
  // they send gets the denial `handle` would give, never the issue list — which describes
347
347
  // the arguments of a tool they may not call.
348
348
  const refused = admitted(resolved.tool, caller);
349
- if (refused !== undefined) return thrownResponse(id, name, refused, caller, audience);
349
+ if (refused !== undefined)
350
+ return thrownResponse(id, name, refused, caller, audience, this.audit);
350
351
  const invalid = invalidArgsResult(name, resolved.issues, audience);
351
- auditToolCall({ tool: name, outcome: 'invalid-args', caller, code: invalid.code });
352
+ this.audit.toolCall({ tool: name, outcome: 'invalid-args', caller, code: invalid.code });
352
353
  return resultResponse(id, invalid.result);
353
354
  }
354
355
  case 'ok':
@@ -359,13 +360,13 @@ export class McpServer {
359
360
  try {
360
361
  result = await resolved.tool.handle(resolved.args, caller);
361
362
  } catch (error) {
362
- return thrownResponse(id, name, error, caller, audience);
363
+ return thrownResponse(id, name, error, caller, audience, this.audit);
363
364
  }
364
365
 
365
366
  // A tool may answer `isError` itself (admin renders its own denial). Outcome 3 unless it
366
367
  // NAMED the code it refused with, in which case the same classifier a thrown error goes
367
368
  // through decides — a tool's own argument check is not a denial a prober drove.
368
- auditToolCall({
369
+ this.audit.toolCall({
369
370
  tool: name,
370
371
  outcome: outcomeForResult(result),
371
372
  caller,
@@ -406,7 +407,7 @@ export class McpServer {
406
407
  // `code` on the audit line either, because the wire carries none — the tool surface's
407
408
  // `X_MCP_TOOL_UNKNOWN` is an error class this branch has no twin for.
408
409
  case 'not-found':
409
- auditResourceRead({ uri, outcome: 'hidden', caller });
410
+ this.audit.resourceRead({ uri, outcome: 'hidden', caller });
410
411
  return errorResponse(id, METHOD_NOT_FOUND, `resource not found: ${uri}`);
411
412
  // OUTCOME 2. The caller can already see this resource, so naming the missing scope leaks
412
413
  // nothing — and the fix travels with it, built by the error that owns the wording.
@@ -416,7 +417,7 @@ export class McpServer {
416
417
  scope: resolved.scope,
417
418
  subject: 'resource',
418
419
  });
419
- auditResourceRead({
420
+ this.audit.resourceRead({
420
421
  uri,
421
422
  outcome: 'scope-denied',
422
423
  caller,
@@ -444,16 +445,16 @@ export class McpServer {
444
445
  if (contents === undefined) {
445
446
  // The resolver said `ok` and the registry then had nothing: a bug here, not a walk, so it
446
447
  // is `failed` in the log while the caller still gets the same not-found it would have.
447
- auditResourceRead({ uri, outcome: 'failed', caller });
448
+ this.audit.resourceRead({ uri, outcome: 'failed', caller });
448
449
  return errorResponse(id, METHOD_NOT_FOUND, `resource not found: ${uri}`);
449
450
  }
450
- auditResourceRead({ uri, outcome: 'ok', caller });
451
+ this.audit.resourceRead({ uri, outcome: 'ok', caller });
451
452
  return resultResponse(id, { contents: [contents] });
452
453
  } catch (error) {
453
454
  const thrown = asFrameworkError(error);
454
455
  if (thrown !== undefined) {
455
456
  const framework = forAudience(thrown, this.voice.errorAudience);
456
- auditResourceRead({ uri, outcome: 'failed', caller, code: framework.code });
457
+ this.audit.resourceRead({ uri, outcome: 'failed', caller, code: framework.code });
457
458
  return errorResponse(id, INTERNAL_ERROR, `resource "${uri}" could not be read`, {
458
459
  code: framework.code,
459
460
  cause: framework.cause,
@@ -463,7 +464,7 @@ export class McpServer {
463
464
  }
464
465
  // No internals: a provider's own message names a path, a query or a host the caller has no
465
466
  // business seeing, exactly as a failing tool's does.
466
- auditResourceRead({ uri, outcome: 'failed', caller });
467
+ this.audit.resourceRead({ uri, outcome: 'failed', caller });
467
468
  return errorResponse(id, INTERNAL_ERROR, `resource "${uri}" could not be read`);
468
469
  }
469
470
  }
@@ -3,7 +3,8 @@
3
3
  // raised it. Split from `server.ts` for the file ceiling; the server is its only caller.
4
4
 
5
5
  import type { ErrorAudience } from '@ultimat3/core';
6
- import { auditToolCall, outcomeForCode } from './audit';
6
+ import { outcomeForCode } from './audit';
7
+ import type { McpAuditor } from './audit-hook';
7
8
  import { asFrameworkError, renderFrameworkError } from './framework-error';
8
9
  import type { AnyMcpTool, McpCaller } from './registry';
9
10
  import type { JsonRpcId, JsonRpcResponse } from './wire';
@@ -21,10 +22,11 @@ export function thrownResponse(
21
22
  error: unknown,
22
23
  caller: McpCaller,
23
24
  audience: ErrorAudience,
25
+ audit: McpAuditor,
24
26
  ): JsonRpcResponse {
25
27
  const framework = asFrameworkError(error);
26
28
  if (framework !== undefined) {
27
- auditToolCall({
29
+ audit.toolCall({
28
30
  tool: name,
29
31
  outcome: outcomeForCode(framework.code),
30
32
  caller,
@@ -35,7 +37,7 @@ export function thrownResponse(
35
37
  isError: true,
36
38
  });
37
39
  }
38
- auditToolCall({ tool: name, outcome: 'failed', caller });
40
+ audit.toolCall({ tool: name, outcome: 'failed', caller });
39
41
  return errorResponse(id, INTERNAL_ERROR, `tool "${name}" failed unexpectedly`);
40
42
  }
41
43
 
@@ -21,9 +21,9 @@
21
21
 
22
22
  import type { Actor, Clock } from '@ultimat3/core';
23
23
  import { finiteCount, readWithinLimit, systemClock } from '@ultimat3/core';
24
- import type { Bucket, RateLimitStore } from '@ultimat3/http';
25
- import { memoryRateLimitStore, toBucket } from '@ultimat3/http';
26
- import { McpBodyTooLargeError, McpRateLimitedError } from './errors';
24
+ import type { Bucket, RequestFacts as McpRequestFacts, RateLimitStore } from '@ultimat3/http';
25
+ import { memoryRateLimitStore, requestFacts, toBucket } from '@ultimat3/http';
26
+ import { McpBodyTooLargeError, McpRateLimitedError } from './errors-transport';
27
27
  import type { McpOAuth } from './oauth-metadata';
28
28
  import {
29
29
  assertMcpOAuth,
@@ -43,7 +43,7 @@ import { errorResponse, INVALID_REQUEST, PARSE_ERROR, refusalMessage } from './w
43
43
  /**
44
44
  * The same 1 MiB `@ultimat3/http`'s `bodyLimitBytes` defaults to. This descriptor is driven from a
45
45
  * bare `Request` and never passes through that pipeline, so without a cap here Bun's 128 MiB
46
- * default was the only ceiling — and `x mcp serve` and `createServer` both pass no
46
+ * default was the only ceiling — and `x mcp serve` and `httpServer` both pass no
47
47
  * `maxRequestBodySize`.
48
48
  */
49
49
  export const DEFAULT_MCP_BODY_LIMIT_BYTES = 1_048_576;
@@ -79,10 +79,16 @@ export interface ResolvedToken {
79
79
  export interface McpHttpTransportInput {
80
80
  readonly server: McpServer;
81
81
  /**
82
- * Resolve an OAuth bearer / personal token to a caller. The framework supplies the token
83
- * string only — credential storage belongs to `@ultimat3/policy` and the app, never here.
82
+ * Resolve an OAuth bearer / personal token to a caller. The framework supplies the token and
83
+ * how the request arrived (`McpRequestFacts`: the host-resolved address, user agent, `Origin`,
84
+ * path) — so a token can be bound to a network or refused from a browser origin. Credential
85
+ * storage belongs to `@ultimat3/policy` and the app, never here. A one-argument resolver is
86
+ * still one: the facts are an extra argument it may ignore.
84
87
  */
85
- resolveToken(token: string): Promise<ResolvedToken | null> | ResolvedToken | null;
88
+ resolveToken(
89
+ token: string,
90
+ facts: McpRequestFacts,
91
+ ): Promise<ResolvedToken | null> | ResolvedToken | null;
86
92
  /** Route path. Overridable so an app can mount a second, app-scoped surface. */
87
93
  readonly path?: string;
88
94
  /** Bytes this transport will hold for one request. Defaults to `DEFAULT_MCP_BODY_LIMIT_BYTES`. */
@@ -231,24 +237,42 @@ export function mcpHttpRoute(input: McpHttpTransportInput): McpRouteDescriptor {
231
237
  // An address that has spent its failures is refused BEFORE the credential store is asked:
232
238
  // counting only after the fact turned a 401 into a 429 and still answered a valid guess.
233
239
  const address = seen?.address;
240
+ // Built once per request: the resolver's second argument and every refusal's audit facts.
241
+ // The address inside is the host's answer (`seen.address`), never a header read here.
242
+ const facts = requestFacts({ headers: request.headers, url: request.url, address });
243
+ const audit = server.audit;
234
244
  const exhausted = await failures.refusing(address);
235
- if (exhausted !== undefined) return exhausted;
245
+ if (exhausted !== undefined) {
246
+ audit.authRefused('throttled', exhausted.status, facts);
247
+ return exhausted;
248
+ }
249
+ // A failure spends the address's allowance; the 429 wins when that one did not fit.
250
+ const refuse = async (
251
+ reason: 'missing-token' | 'rejected-token',
252
+ challenge: string,
253
+ ): Promise<Response> => {
254
+ const throttledNow = await failures.spend(address);
255
+ const response = throttledNow ?? unauthorized(challenge);
256
+ audit.authRefused(
257
+ throttledNow === undefined ? reason : 'throttled',
258
+ response.status,
259
+ facts,
260
+ );
261
+ return response;
262
+ };
236
263
  const token = bearerToken(request);
237
264
  if (token === null) {
238
- return (
239
- (await failures.spend(address)) ??
240
- unauthorized(bearerChallenge(metadataUrl, false, challengeScopes))
241
- );
265
+ return refuse('missing-token', bearerChallenge(metadataUrl, false, challengeScopes));
242
266
  }
243
267
 
244
- const resolved = await input.resolveToken(token);
268
+ const resolved = await input.resolveToken(token, facts);
245
269
  if (resolved === null) {
246
- return (
247
- (await failures.spend(address)) ??
248
- unauthorized(bearerChallenge(metadataUrl, true, challengeScopes))
249
- );
270
+ return refuse('rejected-token', bearerChallenge(metadataUrl, true, challengeScopes));
271
+ }
272
+ if (!isAgentActor(resolved.actor)) {
273
+ audit.authRefused('not-an-agent', 403, facts);
274
+ return notAnAgent();
250
275
  }
251
- if (!isAgentActor(resolved.actor)) return notAnAgent();
252
276
 
253
277
  // Read through the counting reader, never `request.json()`: the cap has to be enforced
254
278
  // WHILE the bytes arrive, or a `transfer-encoding: chunked` payload is materialised in full
@@ -9,7 +9,7 @@
9
9
  // stderr and this file never calls `console.log`.
10
10
 
11
11
  import { finiteCount } from '@ultimat3/core';
12
- import { McpBodyTooLargeError } from './errors';
12
+ import { McpBodyTooLargeError } from './errors-transport';
13
13
  import type { McpCaller } from './registry';
14
14
  import type { McpServer } from './server';
15
15
  import { errorResponse, INVALID_REQUEST, PARSE_ERROR, refusalMessage } from './wire';
@@ -0,0 +1,52 @@
1
+ // Compile-time pins for this package's public types. Source, not a `.test.ts`, on purpose:
2
+ // `tsconfig.json` excludes `src/**/*.test.ts`, so `tsc -b` never reads a test file and a
3
+ // type-level claim written in one can never fail. This module emits nothing and exports nothing
4
+ // anybody imports — a regression here is a build error, the only enforcement that counts.
5
+
6
+ import type {
7
+ McpListParams as CoreListParams,
8
+ McpAnnotationHints,
9
+ McpExposureDeclaration,
10
+ } from '@ultimat3/core';
11
+ import type { ProjectablePrimitive } from './from-action';
12
+ import type { McpListParams } from './list-params';
13
+ import type { McpToolAnnotations } from './registry';
14
+
15
+ /** Fails to compile when `T` is anything but `true`. The whole mechanism. */
16
+ type Assert<T extends true> = T;
17
+
18
+ type Equals<A, B> = [A] extends [B] ? ([B] extends [A] ? true : false) : false;
19
+
20
+ /**
21
+ * IDENTICAL, not merely mutually assignable: an object type without an optional key is assignable
22
+ * to one with it and back, so `Equals` cannot see an optional field added on one side only — and
23
+ * every field of the `mcp` block is optional but `expose`.
24
+ */
25
+ type Identical<A, B> =
26
+ (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false;
27
+
28
+ type Projected = NonNullable<ProjectablePrimitive['mcp']>;
29
+
30
+ /**
31
+ * 25.0.0: the projection reads core's ONE `mcp` block. `McpExposure` restated it here, a field
32
+ * looser (`expose?`) than the block an action or query is held to; a twin coming back as a
33
+ * different shape flips this to `false`.
34
+ */
35
+ export type _ProjectionReadsCoresBlock = Assert<Identical<Projected, McpExposureDeclaration>>;
36
+
37
+ /**
38
+ * 25.0.0 (plan 101, M4): a projected tool is named by its primitive's `name` and by nothing
39
+ * else. `McpExposure.name` was a second naming field no declaration could set; one coming back
40
+ * on core's block flips this to `false`.
41
+ */
42
+ export type _McpBlockCarriesNoName = Assert<Equals<Extract<keyof Projected, 'name'>, never>>;
43
+
44
+ /**
45
+ * The tool's own wire types (`McpTool.annotations`, `McpTool.listParams`) are what the block's
46
+ * fields become, so they must stay the block's shapes exactly: a hint or a whitelist key added
47
+ * on one side only is a declaration the other silently drops.
48
+ */
49
+ export type _ToolAnnotationsAreTheBlocks = Assert<
50
+ Identical<McpToolAnnotations, McpAnnotationHints>
51
+ >;
52
+ export type _ToolListParamsAreTheBlocks = Assert<Identical<McpListParams, CoreListParams>>;
@@ -121,6 +121,15 @@ function array(schema: JsonSchema, input: unknown, path: string, issues: ArgIssu
121
121
  issues.push({ path, message: 'must be an array' });
122
122
  return input;
123
123
  }
124
+ // `@ultimat3/schema` publishes `t.array(items, { min, max })` as these two; a bound on the wire
125
+ // that nothing here checks is an agent obeying a rule a hand-written tool never enforced.
126
+ const count = (n: number): string => `${n} item${n === 1 ? '' : 's'}`;
127
+ if (schema.minItems !== undefined && input.length < schema.minItems) {
128
+ issues.push({ path, message: `must have at least ${count(schema.minItems)}` });
129
+ }
130
+ if (schema.maxItems !== undefined && input.length > schema.maxItems) {
131
+ issues.push({ path, message: `must have at most ${count(schema.maxItems)}` });
132
+ }
124
133
  const items = schema.items;
125
134
  if (items === undefined) return input;
126
135
  return input.map((item, index) => walk(items, item, `${path}[${index}]`, issues));
package/src/wire.ts CHANGED
@@ -113,3 +113,20 @@ export function paramsOf(req: JsonRpcRequest): Record<string, unknown> | null {
113
113
  if (typeof req.params !== 'object' || req.params === null) return null;
114
114
  return req.params as Record<string, unknown>;
115
115
  }
116
+
117
+ /**
118
+ * Which wire one message arrived on, so a refusal can name the unit THAT transport counts in.
119
+ * `handle` is transport-independent and stays so — this is a hint for the wording of one fix, never
120
+ * a branch in dispatch. HTTP carries its mounted path because `mcpHttpRoute({ path })` is a knob:
121
+ * a batch refusal that told every client to retry `POST /mcp` was wrong for an app mounted at
122
+ * `/app-mcp`, which is how this came to be threaded rather than spelled.
123
+ */
124
+ export type McpWire =
125
+ | { readonly transport: 'http'; readonly path: string }
126
+ | { readonly transport: 'stdio' };
127
+
128
+ /** The unit a transport counts one request in — or the neutral word when no wire said. */
129
+ export const messageUnitOf = (wire: McpWire | undefined): string => {
130
+ if (wire === undefined) return 'message';
131
+ return wire.transport === 'http' ? `POST ${wire.path}` : 'line';
132
+ };