@ultimat3/mcp 11.3.0 → 12.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
@@ -3,6 +3,10 @@
3
3
  Tier 4. May import tier 0–3: `core schema i18n money time cache seo entity policy http action
4
4
  query jobs realtime`. **Never** `render manifest ai pwa ui admin testing cli`.
5
5
 
6
+ `@ultimat3/http` is a DIRECT dependency since 2026-08-24 (`transport-http.ts`, the rate limiter) and
7
+ was already a transitive one through `action` and `query` — declaring it added nothing to the
8
+ install graph and made the edge readable.
9
+
6
10
  Same-tier data (routes, manifest, policy catalog) arrives as an **injected thunk**, never an
7
11
  import. The CLI wires it.
8
12
 
@@ -19,7 +23,7 @@ import. The CLI wires it.
19
23
  | `resources.ts` | resources + prompts, stable `ultimate://` URIs |
20
24
  | `dev-server.ts` | the 13 dev tools; depends only on an injected `DevHost` |
21
25
  | `dev-host.ts` | wires `describe*` from entity/action/query/jobs into a `DevHost` |
22
- | `transport-http.ts` | `POST /mcp` route descriptor, bearer → agent actor |
26
+ | `transport-http.ts` | `POST /mcp` route descriptor, bearer → agent actor, and the per-caller rate limit it enforces itself |
23
27
  | `transport-stdio.ts` | NDJSON on stdin/stdout for `x mcp serve` |
24
28
  | `app-tools.ts` | `defineAppMcp` — a generated app's own MCP surface, one call |
25
29
  | `app-tool.ts` | the authored `tools: { name: {...} }` record → `ProjectablePrimitive` |
@@ -247,6 +251,32 @@ import. The CLI wires it.
247
251
  - The caps run in the **tool**, not the host. A host that forgets them answers a million rows
248
252
  into a model's context. `guards` names the layers that engaged; a layer that could not engage
249
253
  is absent from the list, never assumed present.
254
+ - **`MCP_RATE_LIMITS` is ENFORCED, in `handle`, and it has to be there** (2026-08-24). `limits` and
255
+ `rateLimitClass` were published on `McpRouteDescriptor` and read by no mount point — `x mcp serve`
256
+ runs `route.handle` in a bare `Bun.serve` and `defineAppMcp` hands its route to the app — so the
257
+ type promised 20 writes a minute while the real ceiling was Bun's accept rate: an agent looping on
258
+ `db.query` was never UNSAFE (the `readonly-sql` parse and `query-limits` caps hold per call) and
259
+ never BOUNDED. It cannot be enforced from OUTSIDE, which is why deleting the option was the wrong
260
+ half of the choice: `rateLimitClass(body)` takes an ALREADY-PARSED body and `handle` is the only
261
+ thing that parses one, so a limiter above it would have to consume the request stream first and a
262
+ `Request` body reads once. The maths, the `Bucket`, `toBucket`, `rateLimitKey` and the store are
263
+ `@ultimat3/http`'s — tier 2, a legal downward import — and **never** a second token bucket written
264
+ here. Metered after the parse and before dispatch; an unauthenticated caller is answered 401 first,
265
+ so a token nobody issued cannot spend an actor's allowance. The key is
266
+ `mcp:<class>|actor:<id>`, never the TOKEN: a bucket key reaches a log and an error reporter, and a
267
+ credential in one is a leak wearing a throttle's clothes.
268
+ - **`X_MCP_RATE_LIMITED`, not `@ultimat3/http`'s `X_RATE_LIMITED`, and the reason is the KNOB.** The
269
+ enforcement is shared to the last function; only the sentence differs. `X_RATE_LIMITED`'s `fix:`
270
+ says to raise `rateLimit.buckets` in `app.config.ts`, which governs the HTTP pipeline and has no
271
+ effect on this route — an instruction that runs and changes nothing is worse than none, which is
272
+ the same call `@ultimat3/realtime`'s `SubscriptionLimitError` makes when it names the knob rather
273
+ than the default. The 429 renders `{ code, cause, fix }` plus `Retry-After`, the shape this file's
274
+ 401 and 403 already use, never a JSON-RPC envelope: the transport refused before dispatch, so
275
+ there is no call to answer.
276
+ - **A per-process store is the default and a lie for a fleet.** `mcpHttpRoute({ rateLimitStore })`
277
+ and `defineAppMcp({ rateLimitStore })` are the seam; N replicas on the memory store enforce N x
278
+ every number, silently and only in production. Forwarded through `defineAppMcp` deliberately —
279
+ an app that builds its route through that one call has no other way to reach it.
250
280
  - **Every authentication answer lands before `request.json()`.** A missing token, a token
251
281
  `resolveToken` rejects and a non-agent actor all return before the body is read: parsing first
252
282
  answered `400 parse error` for a malformed payload and `401` for a well-formed one under the
package/README.md CHANGED
@@ -174,12 +174,23 @@ stays the only rule that reads the input. A name this server does not project is
174
174
  | HTTP | `mcpHttpRoute({ server, resolveToken })` → `POST /mcp` | `Authorization: Bearer <token>` → `Actor { kind: 'agent' }` |
175
175
  | stdio | `serveStdio({ server, caller })` | none — the peer already owns the shell |
176
176
 
177
- The HTTP transport exports a route *descriptor*, not a mounted handler: `@ultimat3/http`
178
- owns the lifecycle, and the descriptor stays drivable from a bare `Request` in a test. It
179
- carries `rateLimitClass(body)` because all MCP traffic is one URL — a per-route bucket would
180
- charge `initialize` to the write bucket and throttle an agent on its handshake.
177
+ The HTTP transport exports a route *descriptor*, not a mounted handler: a host owns the lifecycle,
178
+ and the descriptor stays drivable from a bare `Request` in a test. It carries
179
+ `rateLimitClass(body)` because all MCP traffic is one URL — a per-route bucket would charge
180
+ `initialize` to the write bucket and throttle an agent on its handshake.
181
181
 
182
- Reads: 120/min. Writes: 20/min. Unresolvable calls bill the write bucket (fail-closed).
182
+ Reads: 120/min per caller. Writes: 20/min. Unresolvable calls bill the write bucket (fail-closed).
183
+
184
+ **`handle` enforces those numbers itself, `As of 2026-08-24`** — they were published on the
185
+ descriptor and applied by no mount point before that, so the ceiling was really Bun's accept rate.
186
+ It cannot be done from outside: `rateLimitClass(body)` takes an already-parsed body and `handle` is
187
+ the only thing that parses one. The bucket is `@ultimat3/http`'s, keyed per actor per class; over
188
+ the limit is `429` + `Retry-After` + `X_MCP_RATE_LIMITED`.
189
+
190
+ | Knob | Where | Default |
191
+ |---|---|---|
192
+ | the numbers | `mcpHttpRoute({ rateLimits })` · `defineAppMcp({ rateLimits })` | `MCP_RATE_LIMITS` |
193
+ | where they are counted | `mcpHttpRoute({ rateLimitStore })` · `defineAppMcp({ rateLimitStore })` | a per-**process** memory store — N replicas behind one URL each enforce the full allowance, so a fleet passes `postgresRateLimitStore({ executor })` |
183
194
 
184
195
  ## Resources
185
196
 
@@ -214,3 +225,4 @@ inside their own handler.
214
225
  | `X_MCP_QUERY_REJECTED` | `db.query` given anything but one read-only statement |
215
226
  | `X_MCP_NOT_BRANCH_DB` | `db.migrate` aimed at a production or otherwise non-branch database |
216
227
  | `X_MCP_RESOURCE_DUPLICATE` | two resources claim one `ultimate://` URI — refused at registration, as a duplicate tool name is |
228
+ | `X_MCP_RATE_LIMITED` | the caller spent its per-minute allowance for this request's class. Its own code rather than `@ultimat3/http`'s `X_RATE_LIMITED` because the KNOB differs — `rateLimits` on the route, never `rateLimit.buckets` in `app.config.ts` |
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ultimat3/mcp",
3
- "version": "11.3.0",
4
- "description": "MCP server, dev tools, and the action-to-tool projection one authz system, two surfaces",
3
+ "version": "12.0.0",
4
+ "description": "MCP server, dev tools, and the action-to-tool projection \u2014 one authz system, two surfaces",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "repository": {
@@ -31,12 +31,13 @@
31
31
  "test": "bun test"
32
32
  },
33
33
  "dependencies": {
34
- "@ultimat3/action": "11.3.0",
35
- "@ultimat3/core": "11.3.0",
36
- "@ultimat3/entity": "11.3.0",
37
- "@ultimat3/jobs": "11.3.0",
38
- "@ultimat3/policy": "11.3.0",
39
- "@ultimat3/query": "11.3.0",
40
- "@ultimat3/schema": "11.3.0"
34
+ "@ultimat3/action": "12.0.0",
35
+ "@ultimat3/core": "12.0.0",
36
+ "@ultimat3/entity": "12.0.0",
37
+ "@ultimat3/http": "12.0.0",
38
+ "@ultimat3/jobs": "12.0.0",
39
+ "@ultimat3/policy": "12.0.0",
40
+ "@ultimat3/query": "12.0.0",
41
+ "@ultimat3/schema": "12.0.0"
41
42
  }
42
43
  }
package/src/app-tools.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  // Deliberately one function: an app author should never have to know that `ToolRegistry`,
9
9
  // `frameworkResources` and `mcpHttpRoute` exist.
10
10
 
11
+ import type { RateLimitStore } from '@ultimat3/http';
11
12
  import type { StandardSchemaV1 } from '@ultimat3/schema';
12
13
  import type { AnyAppToolDefinition, AppTools } from './app-tool';
13
14
  import { appToolPrimitives } from './app-tool';
@@ -16,7 +17,7 @@ import { exposedPrimitives } from './exposed';
16
17
  import { toolsFrom, toolsListed } from './from-action';
17
18
  import type { ListedPrimitive } from './projectable';
18
19
  import { asProjectable } from './projectable';
19
- import type { AnyMcpTool } from './registry';
20
+ import type { AnyMcpTool, McpVerbClass } from './registry';
20
21
  import type { McpPrompt, McpResource } from './resources';
21
22
  import { toPrompts } from './resources';
22
23
  import type { McpScopes } from './scopes';
@@ -77,6 +78,18 @@ export interface DefineAppMcpInput<TSchemas extends AppToolSchemas = AppToolSche
77
78
  resolveToken?(token: string): Promise<ResolvedToken | null> | ResolvedToken | null;
78
79
  /** Mount path. Defaults to `/mcp`. */
79
80
  readonly path?: string;
81
+ /**
82
+ * Requests per minute per caller, by class. Defaults to `MCP_RATE_LIMITS` and is ENFORCED by the
83
+ * route, so this is the app's one knob over what an agent may spend here.
84
+ */
85
+ readonly rateLimits?: Readonly<Record<McpVerbClass, number>> | undefined;
86
+ /**
87
+ * Where those buckets are counted. Forwarded rather than left to the route's per-process default
88
+ * because an app behind N replicas has no other way to reach it — and N processes each holding
89
+ * their own counters enforce N x every number above, silently and only in production. The same
90
+ * `postgresRateLimitStore({ executor })` the web role takes.
91
+ */
92
+ readonly rateLimitStore?: RateLimitStore | undefined;
80
93
  }
81
94
 
82
95
  export interface AppMcp {
@@ -155,6 +168,8 @@ export function defineAppMcp<TSchemas extends AppToolSchemas>(
155
168
  server,
156
169
  resolveToken,
157
170
  ...(input.path !== undefined ? { path: input.path } : {}),
171
+ ...(input.rateLimits !== undefined ? { rateLimits: input.rateLimits } : {}),
172
+ ...(input.rateLimitStore !== undefined ? { rateLimitStore: input.rateLimitStore } : {}),
158
173
  });
159
174
 
160
175
  return { server, tools: projected, route };
package/src/errors.ts CHANGED
@@ -16,6 +16,7 @@ export const MCP_ERROR_CODES = [
16
16
  'X_MCP_RESOURCE_DUPLICATE',
17
17
  'X_MCP_SCOPE_UNKNOWN',
18
18
  'X_MCP_SCOPE_CONFLICT',
19
+ 'X_MCP_RATE_LIMITED',
19
20
  ] as const;
20
21
 
21
22
  export type McpErrorCode = (typeof MCP_ERROR_CODES)[number];
@@ -33,6 +34,7 @@ export const MCP_ERROR_TITLES: Readonly<Record<McpErrorCode, string>> = {
33
34
  X_MCP_RESOURCE_DUPLICATE: 'two resources claim one MCP resource URI',
34
35
  X_MCP_SCOPE_UNKNOWN: 'defineAppMcp scopes a tool this server does not project',
35
36
  X_MCP_SCOPE_CONFLICT: 'two scopes claim one MCP tool',
37
+ X_MCP_RATE_LIMITED: "the caller has spent its allowance for this request's class",
36
38
  };
37
39
 
38
40
  // Titles must be registered for `format()` to render the contract's first line. Every code above is
@@ -292,3 +294,32 @@ export class McpNotBranchDbError extends UltimateError {
292
294
  });
293
295
  }
294
296
  }
297
+
298
+ /**
299
+ * The transport's own throttle, and its own CODE rather than `@ultimat3/http`'s `X_RATE_LIMITED`
300
+ * — not because the maths differ (they are the same `Bucket`, the same store and the same
301
+ * `rateLimitDecision`) but because the KNOB does. `X_RATE_LIMITED` tells an operator to raise
302
+ * `rateLimit.buckets` through `configureHttp()`, which governs the HTTP pipeline and not this route: all
303
+ * MCP traffic is one URL and the class comes from the parsed body, so `mcpHttpRoute` meters itself.
304
+ * A fix line that runs and changes nothing is worse than none — the same rule
305
+ * `@ultimat3/realtime`'s `SubscriptionLimitError` follows when it names the knob over the default.
306
+ *
307
+ * The bucket key names the ACTOR and never reaches the caller: a 429 is provokable by anyone
308
+ * holding a valid token, and an org id or an actor id in the body of one is a leak wearing a
309
+ * throttle's clothes.
310
+ */
311
+ export class McpRateLimitedError extends UltimateError {
312
+ constructor(input: { verbClass: string; limit: number; retryAfterSeconds: number }) {
313
+ super({
314
+ code: 'X_MCP_RATE_LIMITED',
315
+ cause: `this caller has spent its ${input.limit} ${input.verbClass} requests per minute; the bucket refills in ${input.retryAfterSeconds}s`,
316
+ fix: `wait ${input.retryAfterSeconds}s — the Retry-After header carries the same number — or raise it where the route is built: mcpHttpRoute({ rateLimits: { ${input.verbClass}: <n> } }), or defineAppMcp({ rateLimits })`,
317
+ // why `meta` and not only the sentence: `transport-http.ts` writes `Retry-After` by hand, so
318
+ // this route is correct today. `@ultimat3/http`'s `retryAfterOf` reads the header off
319
+ // `meta.retryAfterSeconds` — so the day an MCP host is mounted inside that pipeline, an error
320
+ // carrying the number only in its prose sheds a caller with no delay to honour, which is the
321
+ // stampede `admit` exists to spread.
322
+ meta: { retryAfterSeconds: input.retryAfterSeconds },
323
+ });
324
+ }
325
+ }
package/src/index.ts CHANGED
@@ -37,6 +37,7 @@ export {
37
37
  McpNotBranchDbError,
38
38
  McpProtocolError,
39
39
  McpQueryRejectedError,
40
+ McpRateLimitedError,
40
41
  McpResourceDuplicateError,
41
42
  McpScopeConflictError,
42
43
  McpScopeDeniedError,
@@ -111,6 +112,7 @@ export {
111
112
  bearerToken,
112
113
  DEFAULT_MCP_BODY_LIMIT_BYTES,
113
114
  isAgentActor,
115
+ MCP_RATE_LIMIT_WINDOW_MS,
114
116
  MCP_RATE_LIMITS,
115
117
  mcpHttpRoute,
116
118
  } from './transport-http';
@@ -1,19 +1,29 @@
1
1
  // `POST /mcp` — the HTTP transport.
2
2
  //
3
- // Exported as a route DESCRIPTOR rather than a mounted handler: `@ultimat3/http` owns the
4
- // lifecycle (ALS context, tracing, rate limiting) and mounts this, while the descriptor
5
- // stays drivable from a bare `Request` in a test. Two things travel with it that a generic
6
- // route table cannot infer:
3
+ // Exported as a route DESCRIPTOR rather than a mounted handler: a host owns the lifecycle (ALS
4
+ // context, tracing) and mounts this, while the descriptor stays drivable from a bare `Request` in
5
+ // a test. Three things travel with it that a generic route table cannot infer:
7
6
  //
8
7
  // 1. `rateLimitClass(body)` — all MCP traffic is one URL, so a per-route bucket would
9
8
  // charge `initialize` and every read to the write bucket and throttle an agent on its
10
9
  // handshake. The server classifies each body instead.
11
- // 2. `authenticate` a bearer token resolves to an Actor of kind 'agent'. An agent is
10
+ // 2. `limits`, ENFORCED HERE since 2026-08-24 and by nothing before it. The two above were
11
+ // published on the descriptor and read by no mount point: `x mcp serve` runs `handle` in a
12
+ // bare `Bun.serve` and `defineAppMcp` returns the route to the app, so the type promised 20
13
+ // writes a minute and the real ceiling was Bun's accept rate. It cannot be enforced from
14
+ // OUTSIDE either: `rateLimitClass(body)` takes an already-parsed body and `handle` is the only
15
+ // thing that parses one, so a limiter above it would have to consume the request stream first.
16
+ // The maths, the `Bucket` and the store are `@ultimat3/http`'s — tier 2, a downward import,
17
+ // and never a second token bucket written here.
18
+ // 3. `authenticate` — a bearer token resolves to an Actor of kind 'agent'. An agent is
12
19
  // never silently upgraded to the user behind the token; policies see 'agent' and can
13
20
  // refuse what a human would be allowed.
14
21
 
15
- import type { Actor } from '@ultimat3/core';
16
- import { readWithinLimit } from '@ultimat3/core';
22
+ import type { Actor, Clock } from '@ultimat3/core';
23
+ import { readWithinLimit, systemClock } from '@ultimat3/core';
24
+ import type { RateLimitStore } from '@ultimat3/http';
25
+ import { memoryRateLimitStore, toBucket } from '@ultimat3/http';
26
+ import { McpRateLimitedError } from './errors';
17
27
  import type { McpCaller, McpRole, McpVerbClass } from './registry';
18
28
  import type { McpServer } from './server';
19
29
  import type { JsonRpcResponse } from './wire';
@@ -27,7 +37,10 @@ import { errorResponse, INVALID_REQUEST, PARSE_ERROR } from './wire';
27
37
  */
28
38
  export const DEFAULT_MCP_BODY_LIMIT_BYTES = 1_048_576;
29
39
 
30
- /** Requests per minute per token, by class. Reads are cheap; a write may run migrations. */
40
+ /** The window every number in `MCP_RATE_LIMITS` is spent over. One minute, per class, per actor. */
41
+ export const MCP_RATE_LIMIT_WINDOW_MS = 60_000;
42
+
43
+ /** Requests per minute per caller, by class. Reads are cheap; a write may run migrations. */
31
44
  export const MCP_RATE_LIMITS: Readonly<Record<McpVerbClass, number>> = {
32
45
  read: 120,
33
46
  write: 20,
@@ -51,6 +64,17 @@ export interface McpHttpTransportInput {
51
64
  readonly path?: string;
52
65
  /** Bytes this transport will hold for one request. Defaults to `DEFAULT_MCP_BODY_LIMIT_BYTES`. */
53
66
  readonly bodyLimitBytes?: number | undefined;
67
+ /** Requests per minute per caller, by class. Defaults to `MCP_RATE_LIMITS`. */
68
+ readonly rateLimits?: Readonly<Record<McpVerbClass, number>> | undefined;
69
+ /**
70
+ * Where the buckets are counted. Defaults to a per-PROCESS memory store, which is the honest
71
+ * default for `x mcp serve` and a lie for N replicas behind one URL — each would enforce the
72
+ * full allowance on its own. A fleet passes `postgresRateLimitStore({ executor })` from
73
+ * `@ultimat3/http`, the same store the web role's limiter takes.
74
+ */
75
+ readonly rateLimitStore?: RateLimitStore | undefined;
76
+ /** The one clock the buckets refill on. Defaulted, never read inline, so a test can freeze it. */
77
+ readonly clock?: Clock | undefined;
54
78
  }
55
79
 
56
80
  export interface McpRouteDescriptor {
@@ -58,6 +82,7 @@ export interface McpRouteDescriptor {
58
82
  readonly path: string;
59
83
  /** Bucket for one already-parsed body. Metering only — never an authz decision. */
60
84
  rateLimitClass(body: unknown): McpVerbClass;
85
+ /** Requests per minute per caller, by class — what `handle` actually spends against. */
61
86
  readonly limits: Readonly<Record<McpVerbClass, number>>;
62
87
  handle(request: Request): Promise<Response>;
63
88
  }
@@ -67,11 +92,25 @@ const JSON_HEADERS = { 'content-type': 'application/json' } as const;
67
92
  export function mcpHttpRoute(input: McpHttpTransportInput): McpRouteDescriptor {
68
93
  const { server } = input;
69
94
  const bodyLimitBytes = input.bodyLimitBytes ?? DEFAULT_MCP_BODY_LIMIT_BYTES;
95
+ const limits = input.rateLimits ?? MCP_RATE_LIMITS;
96
+ const clock = input.clock ?? systemClock;
97
+ const store = input.rateLimitStore ?? memoryRateLimitStore();
98
+ // Built once, at construction: `toBucket` refuses an unusable pair (`X_RATE_LIMIT_INVALID`), and
99
+ // a number that cannot be enforced must fail where an author can act on it rather than on the
100
+ // first request an agent makes.
101
+ const readBucket = toBucket('mcpHttpRoute rateLimits.read', {
102
+ limit: limits.read,
103
+ windowMs: MCP_RATE_LIMIT_WINDOW_MS,
104
+ });
105
+ const writeBucket = toBucket('mcpHttpRoute rateLimits.write', {
106
+ limit: limits.write,
107
+ windowMs: MCP_RATE_LIMIT_WINDOW_MS,
108
+ });
70
109
 
71
110
  return {
72
111
  method: 'POST',
73
112
  path: input.path ?? '/mcp',
74
- limits: MCP_RATE_LIMITS,
113
+ limits,
75
114
  rateLimitClass: (body) => server.classify(body),
76
115
 
77
116
  async handle(request: Request): Promise<Response> {
@@ -109,6 +148,27 @@ export function mcpHttpRoute(input: McpHttpTransportInput): McpRouteDescriptor {
109
148
  return json(errorResponse(null, PARSE_ERROR, 'request body is not valid JSON'), 400);
110
149
  }
111
150
 
151
+ // Metered AFTER the class is known and BEFORE the tool runs. It cannot move above the
152
+ // parse — the class comes out of the body — and an unauthenticated caller never reaches it,
153
+ // so a token nobody issued cannot spend an actor's allowance.
154
+ const verbClass = server.classify(body);
155
+ const bucket = verbClass === 'write' ? writeBucket : readBucket;
156
+ const decision = await store.take(
157
+ // `<route>|<subject>`, the shape `@ultimat3/http`'s own keys take, spelled here rather
158
+ // than borrowed: that package's key builder answers "actor, else org, else the connection
159
+ // address", a precedence with nothing to decide on this route — an unauthenticated caller
160
+ // was answered 401 four lines up, so there is ALWAYS an actor and never an address. The
161
+ // route half carries the CLASS because `read` and `write` are two allowances, and charging
162
+ // both to one key would let a handshake burst close the writes behind it.
163
+ `mcp:${verbClass}|actor:${resolved.actor.id}`,
164
+ bucket,
165
+ 1,
166
+ clock.now().getTime(),
167
+ );
168
+ if (!decision.allowed) {
169
+ return throttled(verbClass, bucket.capacity, decision.retryAfterSeconds);
170
+ }
171
+
112
172
  const caller: McpCaller = {
113
173
  actor: resolved.actor,
114
174
  scopes: resolved.scopes,
@@ -143,6 +203,20 @@ export function isAgentActor(actor: Actor): boolean {
143
203
  return (actor as { kind?: unknown }).kind === 'agent';
144
204
  }
145
205
 
206
+ /**
207
+ * The refusal, rendered the way this file's 401 and 403 already are: `{ code, cause, fix }` and an
208
+ * HTTP status, never a JSON-RPC envelope — the transport refused before dispatch, so there is no
209
+ * call to answer. `retry-after` carries the same number the `fix:` line names, because an agent
210
+ * reads one of the two and must not get different answers from them.
211
+ */
212
+ function throttled(verbClass: McpVerbClass, limit: number, retryAfterSeconds: number): Response {
213
+ const error = new McpRateLimitedError({ verbClass, limit, retryAfterSeconds });
214
+ return new Response(JSON.stringify({ code: error.code, cause: error.cause, fix: error.fix }), {
215
+ status: 429,
216
+ headers: { ...JSON_HEADERS, 'retry-after': String(retryAfterSeconds) },
217
+ });
218
+ }
219
+
146
220
  function notAnAgent(): Response {
147
221
  return new Response(
148
222
  JSON.stringify({
@@ -6,6 +6,7 @@
6
6
  // still re-parse authoritatively inside their own handler; this pass exists so a wrong
7
7
  // call comes back as a structured issue list instead of a round trip.
8
8
 
9
+ import { charCount } from '@ultimat3/schema';
9
10
  import type { JsonSchema } from './wire';
10
11
 
11
12
  export interface ArgIssue {
@@ -130,10 +131,19 @@ function string(schema: JsonSchema, input: unknown, path: string, issues: ArgIss
130
131
  issues.push({ path, message: 'must be a string' });
131
132
  return input;
132
133
  }
133
- if (schema.minLength !== undefined && input.length < schema.minLength) {
134
+ // `charCount`, never `input.length`: JSON Schema defines these two over CODE POINTS, which is
135
+ // the unit `@ultimat3/schema`'s `char-count.ts` mints them in and the unit the action's own
136
+ // re-parse applies. In code units both directions were wrong on any astral value — `'👍a'` is
137
+ // 3 units and 2 points, so `min(3)` passed here and then answered `X_INPUT_INVALID` inside the
138
+ // handler (the silent pass this file exists to prevent), and `'👍👍'` is 4 units and 2 points,
139
+ // so `max(3)` refused a call the tool would have served, quoting a bound the agent obeyed with
140
+ // no re-parse behind it to disagree. One import rather than a second count: this package is
141
+ // tier 4 and `@ultimat3/schema` is tier 0, so the excuse core's private twin has is not ours.
142
+ const length = charCount(input);
143
+ if (schema.minLength !== undefined && length < schema.minLength) {
134
144
  issues.push({ path, message: `must be at least ${schema.minLength} characters` });
135
145
  }
136
- if (schema.maxLength !== undefined && input.length > schema.maxLength) {
146
+ if (schema.maxLength !== undefined && length > schema.maxLength) {
137
147
  issues.push({ path, message: `must be at most ${schema.maxLength} characters` });
138
148
  }
139
149
  if (schema.pattern !== undefined) matchesPattern(schema, schema.pattern, input, path, issues);