@ultimat3/mcp 11.3.0 → 13.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 +31 -1
- package/README.md +17 -5
- package/package.json +10 -9
- package/src/app-tool.ts +12 -2
- package/src/app-tools.ts +16 -1
- package/src/errors.ts +31 -0
- package/src/index.ts +2 -0
- package/src/transport-http.ts +83 -9
- package/src/validate-args.ts +12 -2
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:
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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": "
|
|
4
|
-
"description": "MCP server, dev tools, and the action-to-tool projection
|
|
3
|
+
"version": "13.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": "
|
|
35
|
-
"@ultimat3/core": "
|
|
36
|
-
"@ultimat3/entity": "
|
|
37
|
-
"@ultimat3/
|
|
38
|
-
"@ultimat3/
|
|
39
|
-
"@ultimat3/
|
|
40
|
-
"@ultimat3/
|
|
34
|
+
"@ultimat3/action": "13.0.0",
|
|
35
|
+
"@ultimat3/core": "13.0.0",
|
|
36
|
+
"@ultimat3/entity": "13.0.0",
|
|
37
|
+
"@ultimat3/http": "13.0.0",
|
|
38
|
+
"@ultimat3/jobs": "13.0.0",
|
|
39
|
+
"@ultimat3/policy": "13.0.0",
|
|
40
|
+
"@ultimat3/query": "13.0.0",
|
|
41
|
+
"@ultimat3/schema": "13.0.0"
|
|
41
42
|
}
|
|
42
43
|
}
|
package/src/app-tool.ts
CHANGED
|
@@ -16,7 +16,7 @@ import { useContext } from '@ultimat3/core';
|
|
|
16
16
|
import type { KnownPermission } from '@ultimat3/policy';
|
|
17
17
|
import { can } from '@ultimat3/policy';
|
|
18
18
|
import type { InferOutput, StandardSchemaV1 } from '@ultimat3/schema';
|
|
19
|
-
import { formatIssues, validateAsync } from '@ultimat3/schema';
|
|
19
|
+
import { formatIssues, toValidationIssues, validateAsync } from '@ultimat3/schema';
|
|
20
20
|
import { asCallerContext } from './caller-context';
|
|
21
21
|
import { McpToolUnsafeError } from './errors';
|
|
22
22
|
import type { ProjectablePrimitive } from './from-action';
|
|
@@ -123,7 +123,17 @@ async function parseInput(
|
|
|
123
123
|
): Promise<InferOutput<StandardSchemaV1>> {
|
|
124
124
|
const result = await validateAsync(schema, raw);
|
|
125
125
|
if (result.issues !== undefined) {
|
|
126
|
-
|
|
126
|
+
// `toValidationIssues`, never the raw `result.issues` — the same normalisation
|
|
127
|
+
// `packages/action/src/validate.ts` does, for the same two reasons. A conforming library's
|
|
128
|
+
// issue object may carry members Ultimate's shape does not, INCLUDING the rejected value, and
|
|
129
|
+
// this list reaches an agent and the audit record. And the list is what a caller addresses a
|
|
130
|
+
// field by: passing only the joined line made an MCP rejection carry strictly less than the
|
|
131
|
+
// HTTP one, which puts the list on the problem document (`error-facts.ts`).
|
|
132
|
+
//
|
|
133
|
+
// `formatIssues(issues)` is byte-identical to `formatIssues(result.issues)`: that renderer
|
|
134
|
+
// reads `path` and `message`, which is exactly what `toValidationIssues` copies out.
|
|
135
|
+
const issues = toValidationIssues(result.issues);
|
|
136
|
+
throw new InputInvalidError(name, formatIssues(issues).join('; '), issues);
|
|
127
137
|
}
|
|
128
138
|
return result.value;
|
|
129
139
|
}
|
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';
|
package/src/transport-http.ts
CHANGED
|
@@ -1,19 +1,29 @@
|
|
|
1
1
|
// `POST /mcp` — the HTTP transport.
|
|
2
2
|
//
|
|
3
|
-
// Exported as a route DESCRIPTOR rather than a mounted handler:
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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. `
|
|
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
|
-
/**
|
|
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
|
|
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({
|
package/src/validate-args.ts
CHANGED
|
@@ -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
|
-
|
|
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 &&
|
|
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);
|