@ultimat3/mcp 24.0.0 → 25.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.
@@ -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>>;
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
+ };