@ultimat3/mcp 22.5.1 → 22.6.1
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 +1 -0
- package/README.md +33 -0
- package/package.json +9 -9
- package/src/app-tools.ts +15 -0
- package/src/errors.ts +32 -0
- package/src/idempotency-arg.ts +73 -0
- package/src/index.ts +12 -0
- package/src/oauth-metadata.ts +114 -0
- package/src/projectable.ts +16 -5
- package/src/transport-http.ts +57 -6
package/CLAUDE.md
CHANGED
|
@@ -22,6 +22,7 @@ import. The CLI wires it.
|
|
|
22
22
|
| `dev-server.ts` | the 13 dev tools; depends only on an injected `DevHost` |
|
|
23
23
|
| `dev-host.ts` | wires `describe*` from entity/action/query/jobs into a `DevHost` |
|
|
24
24
|
| `transport-http.ts` | `POST /mcp` route descriptor, bearer → agent actor, and the per-caller rate limit it enforces itself |
|
|
25
|
+
| `oauth-metadata.ts` | RFC 9728 protected-resource metadata + the 401 `resource_metadata` challenge (`defineAppMcp({ oauth })`); the authorization server is the app's |
|
|
25
26
|
| `transport-stdio.ts` | NDJSON on stdin/stdout for `x mcp serve` |
|
|
26
27
|
| `app-tools.ts` | `defineAppMcp` — a generated app's own MCP surface, one call |
|
|
27
28
|
| `app-tool.ts` | the authored `tools: { name: {...} }` record → `ProjectablePrimitive` |
|
package/README.md
CHANGED
|
@@ -253,6 +253,19 @@ did not.
|
|
|
253
253
|
| the body cap | `mcpHttpRoute({ bodyLimitBytes })` · `defineAppMcp({ bodyLimitBytes })` | `DEFAULT_MCP_BODY_LIMIT_BYTES`, 1 MiB |
|
|
254
254
|
| the numbers | `mcpHttpRoute({ rateLimits })` · `defineAppMcp({ rateLimits })` | `MCP_RATE_LIMITS` |
|
|
255
255
|
| 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 })` |
|
|
256
|
+
| OAuth discovery | `mcpHttpRoute({ oauth })` · `defineAppMcp({ oauth })` | absent: the 401 is `Bearer realm="ultimate-mcp"` |
|
|
257
|
+
|
|
258
|
+
### OAuth discovery (RFC 9728)
|
|
259
|
+
|
|
260
|
+
`defineAppMcp({ oauth: { authorizationServers: ['https://www.example.com'] } })` (`As of 22.6.0`;
|
|
261
|
+
MCP authorization spec 2025-06-18, unchanged in 2025-11-25): every 401 carries
|
|
262
|
+
`WWW-Authenticate: Bearer realm="ultimate-mcp", resource_metadata="<origin>/.well-known/oauth-protected-resource<path>"`
|
|
263
|
+
(`error="invalid_token"` when a token was sent), and `route.protectedResource` carries the document
|
|
264
|
+
and the two paths it is served at — path-inserted per RFC 9728 §3.1 and the root — which
|
|
265
|
+
`@ultimat3/cli` mounts as public `GET`s beside `POST /mcp`. `resource` defaults to the request's
|
|
266
|
+
PUBLIC origin (`handle(request, { origin })`, which the boot fills from `ctx.https`) + the mount
|
|
267
|
+
path; `scopes_supported` to the `scopes` map's keys. Issuers must be https (http on loopback):
|
|
268
|
+
`X_MCP_OAUTH_INVALID`. The authorization server is the app's own routes.
|
|
256
269
|
|
|
257
270
|
## Resources
|
|
258
271
|
|
|
@@ -274,6 +287,24 @@ implements the emitted subset (objects, arrays, enums, `required`, `additionalPr
|
|
|
274
287
|
bounds, `default`) and applies declared defaults. Actions still re-parse authoritatively
|
|
275
288
|
inside their own handler.
|
|
276
289
|
|
|
290
|
+
## Idempotency over MCP
|
|
291
|
+
|
|
292
|
+
An `idempotent: true` action's tool carries one reserved, optional argument,
|
|
293
|
+
`MCP_IDEMPOTENCY_KEY_ARG` (`'idempotencyKey'`, string, 1–255), advertised in `tools/list` and held
|
|
294
|
+
to that schema here. `tools/call` takes it out of the arguments and hands it to `invoke` as the
|
|
295
|
+
idempotency key — what the `Idempotency-Key` header is over HTTP, filed under action, caller and
|
|
296
|
+
key — so an agent retrying a timed-out call with the same key gets the first result back:
|
|
297
|
+
|
|
298
|
+
```json
|
|
299
|
+
{ "method": "tools/call", "params": { "name": "sendNotification",
|
|
300
|
+
"arguments": { "caseId": "c1", "idempotencyKey": "7f0c…" } } }
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
The action's own input never sees the key. A non-idempotent tool does not advertise it and refuses
|
|
304
|
+
it (`X_MCP_ARGS_INVALID`). An argument, not `params._meta`: `_meta` is for host metadata and a
|
|
305
|
+
model-driven client cannot set it. An idempotent action whose input already names `idempotencyKey`
|
|
306
|
+
is `X_MCP_IDEMPOTENCY_KEY_SHADOWED` at boot.
|
|
307
|
+
|
|
277
308
|
## Errors
|
|
278
309
|
|
|
279
310
|
| Code | Meaning |
|
|
@@ -283,6 +314,7 @@ inside their own handler.
|
|
|
283
314
|
| `X_MCP_SCOPE_UNKNOWN` | `defineAppMcp`'s `scopes:` names a tool this server does not project |
|
|
284
315
|
| `X_MCP_SCOPE_CONFLICT` | two scopes in `defineAppMcp`'s `scopes:` claim one tool |
|
|
285
316
|
| `X_MCP_ARGS_INVALID` | arguments failed the declared schema |
|
|
317
|
+
| `X_MCP_IDEMPOTENCY_KEY_SHADOWED` | an idempotent action's input declares `idempotencyKey`, the reserved retry-key argument |
|
|
286
318
|
| `X_MCP_PROTOCOL` | malformed envelope, unknown method, bad auth header |
|
|
287
319
|
| `X_MCP_QUERY_REJECTED` | `db.query` given anything but one read-only statement |
|
|
288
320
|
| `X_MCP_NOT_BRANCH_DB` | `db.migrate` aimed at a production or otherwise non-branch database |
|
|
@@ -302,6 +334,7 @@ a job boundary the class is gone and the `code` is what survives — match on th
|
|
|
302
334
|
| `McpAppUnmountedError` | `X_MCP_APP_UNMOUNTED` | `src/errors.ts` |
|
|
303
335
|
| `McpArgsInvalidError` | `X_MCP_ARGS_INVALID` | `src/errors.ts` |
|
|
304
336
|
| `McpBodyTooLargeError` | `X_MCP_BODY_TOO_LARGE` | `src/errors.ts` |
|
|
337
|
+
| `McpIdempotencyKeyShadowedError` | `X_MCP_IDEMPOTENCY_KEY_SHADOWED` | `src/errors.ts` |
|
|
305
338
|
| `McpGroupConflictError` | `X_MCP_GROUP_CONFLICT` | `src/meta-errors.ts` |
|
|
306
339
|
| `McpGroupUnknownError` | `X_MCP_GROUP_UNKNOWN` | `src/meta-errors.ts` |
|
|
307
340
|
| `McpListParamsInvalidError` | `X_MCP_LIST_PARAMS_INVALID` | `src/meta-errors.ts` |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/mcp",
|
|
3
|
-
"version": "22.
|
|
3
|
+
"version": "22.6.1",
|
|
4
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",
|
|
@@ -31,13 +31,13 @@
|
|
|
31
31
|
"test": "bun test"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@ultimat3/action": "22.
|
|
35
|
-
"@ultimat3/core": "22.
|
|
36
|
-
"@ultimat3/entity": "22.
|
|
37
|
-
"@ultimat3/http": "22.
|
|
38
|
-
"@ultimat3/jobs": "22.
|
|
39
|
-
"@ultimat3/policy": "22.
|
|
40
|
-
"@ultimat3/query": "22.
|
|
41
|
-
"@ultimat3/schema": "22.
|
|
34
|
+
"@ultimat3/action": "22.6.1",
|
|
35
|
+
"@ultimat3/core": "22.6.1",
|
|
36
|
+
"@ultimat3/entity": "22.6.1",
|
|
37
|
+
"@ultimat3/http": "22.6.1",
|
|
38
|
+
"@ultimat3/jobs": "22.6.1",
|
|
39
|
+
"@ultimat3/policy": "22.6.1",
|
|
40
|
+
"@ultimat3/query": "22.6.1",
|
|
41
|
+
"@ultimat3/schema": "22.6.1"
|
|
42
42
|
}
|
|
43
43
|
}
|
package/src/app-tools.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { McpToolDuplicateError } from './errors';
|
|
|
16
16
|
import { exposedPrimitives } from './exposed';
|
|
17
17
|
import { toolsFrom, toolsListed } from './from-action';
|
|
18
18
|
import type { McpResourceGroups, McpSurfaceOption } from './meta-surface';
|
|
19
|
+
import type { McpOAuth } from './oauth-metadata';
|
|
19
20
|
import type { ListedPrimitive } from './projectable';
|
|
20
21
|
import { asProjectable } from './projectable';
|
|
21
22
|
import type { AnyMcpTool, McpVerbClass } from './registry';
|
|
@@ -118,6 +119,17 @@ export interface DefineAppMcpInput<TSchemas extends AppToolSchemas = AppToolSche
|
|
|
118
119
|
* `postgresRateLimitStore({ executor })` the web role takes.
|
|
119
120
|
*/
|
|
120
121
|
readonly rateLimitStore?: RateLimitStore | undefined;
|
|
122
|
+
/**
|
|
123
|
+
* OAuth discovery for remote MCP clients (the MCP authorization spec, RFC 9728): the route's 401
|
|
124
|
+
* says `WWW-Authenticate: Bearer resource_metadata="<origin>/.well-known/oauth-protected-resource/mcp"`,
|
|
125
|
+
* and the boot serves that document (and the root one) naming `authorizationServers`. The
|
|
126
|
+
* authorization server — consent, `/oauth/token` — is the app's own routes.
|
|
127
|
+
*
|
|
128
|
+
* ```ts
|
|
129
|
+
* oauth: { authorizationServers: ['https://www.example.com'], resourceName: 'Example' },
|
|
130
|
+
* ```
|
|
131
|
+
*/
|
|
132
|
+
readonly oauth?: McpOAuth | undefined;
|
|
121
133
|
}
|
|
122
134
|
|
|
123
135
|
export interface AppMcp {
|
|
@@ -201,6 +213,9 @@ export function defineAppMcp<TSchemas extends AppToolSchemas>(
|
|
|
201
213
|
...(input.bodyLimitBytes !== undefined ? { bodyLimitBytes: input.bodyLimitBytes } : {}),
|
|
202
214
|
...(input.rateLimits !== undefined ? { rateLimits: input.rateLimits } : {}),
|
|
203
215
|
...(input.rateLimitStore !== undefined ? { rateLimitStore: input.rateLimitStore } : {}),
|
|
216
|
+
...(input.oauth === undefined
|
|
217
|
+
? {}
|
|
218
|
+
: { oauth: input.oauth, scopes: Object.keys(input.scopes ?? {}) }),
|
|
204
219
|
});
|
|
205
220
|
|
|
206
221
|
return { server, tools: projected, route };
|
package/src/errors.ts
CHANGED
|
@@ -23,6 +23,8 @@ export const MCP_ERROR_CODES = [
|
|
|
23
23
|
'X_MCP_GROUP_CONFLICT',
|
|
24
24
|
'X_MCP_SURFACE_INVALID',
|
|
25
25
|
'X_MCP_LIST_PARAMS_INVALID',
|
|
26
|
+
'X_MCP_OAUTH_INVALID',
|
|
27
|
+
'X_MCP_IDEMPOTENCY_KEY_SHADOWED',
|
|
26
28
|
] as const;
|
|
27
29
|
|
|
28
30
|
export type McpErrorCode = (typeof MCP_ERROR_CODES)[number];
|
|
@@ -47,6 +49,9 @@ export const MCP_ERROR_TITLES: Readonly<Record<McpErrorCode, string>> = {
|
|
|
47
49
|
X_MCP_GROUP_CONFLICT: 'two resource groups claim one MCP tool',
|
|
48
50
|
X_MCP_SURFACE_INVALID: 'the MCP meta surface is declared inconsistently',
|
|
49
51
|
X_MCP_LIST_PARAMS_INVALID: "a tool's listParams names a key its own input does not accept",
|
|
52
|
+
X_MCP_OAUTH_INVALID: 'the MCP oauth block cannot be published as protected-resource metadata',
|
|
53
|
+
X_MCP_IDEMPOTENCY_KEY_SHADOWED:
|
|
54
|
+
"an idempotent action's input declares idempotencyKey, the MCP argument reserved for the idempotency key",
|
|
50
55
|
};
|
|
51
56
|
|
|
52
57
|
// Titles must be registered for `format()` to render the contract's first line. Every code above is
|
|
@@ -417,3 +422,30 @@ export class McpRateLimitedError extends UltimateError {
|
|
|
417
422
|
});
|
|
418
423
|
}
|
|
419
424
|
}
|
|
425
|
+
|
|
426
|
+
/** `defineAppMcp({ oauth })` refused at boot — see `oauth-metadata.ts`. */
|
|
427
|
+
export class McpOAuthInvalidError extends UltimateError {
|
|
428
|
+
constructor(reason: string) {
|
|
429
|
+
super({
|
|
430
|
+
code: 'X_MCP_OAUTH_INVALID',
|
|
431
|
+
cause: `defineAppMcp({ oauth }) cannot be published as protected-resource metadata: ${reason}`,
|
|
432
|
+
fix: "defineAppMcp({ ..., oauth: { authorizationServers: ['https://www.example.com'] } }) — at least one absolute https (or http://localhost) issuer URL; resource and resourceDocumentation, when set, absolute URLs with no fragment",
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* An idempotent action's tool carries the reserved `idempotencyKey` argument, and this action's
|
|
439
|
+
* own input already names a field that. One argument cannot be both the input a handler reads and
|
|
440
|
+
* the key a retry replays under, so the projection refuses at boot instead of guessing which.
|
|
441
|
+
*/
|
|
442
|
+
export class McpIdempotencyKeyShadowedError extends UltimateError {
|
|
443
|
+
constructor(input: { readonly name: string; readonly argument: string }) {
|
|
444
|
+
super({
|
|
445
|
+
code: 'X_MCP_IDEMPOTENCY_KEY_SHADOWED',
|
|
446
|
+
cause: `${input.name} is idempotent and its input declares "${input.argument}", the MCP argument reserved for the idempotency key`,
|
|
447
|
+
fix: `rename ${input.name}'s "${input.argument}" input field — MCP clients send the Idempotency-Key as the reserved "${input.argument}" argument, and the action receives the rest`,
|
|
448
|
+
meta: { action: input.name, argument: input.argument },
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// The MCP half of `idempotent: true`: the Idempotency-Key an HTTP caller sends as a header, sent
|
|
2
|
+
// by an MCP caller as one reserved, optional tool ARGUMENT. An argument and not `params._meta`:
|
|
3
|
+
// `_meta` is protocol metadata a host fills (a progress token), and no model-driven client lets the
|
|
4
|
+
// model set it — the retry that needs a key is the agent's own, so the key goes where the agent
|
|
5
|
+
// writes. It is advertised in `tools/list`, enforced by `validate-args.ts` like every other field,
|
|
6
|
+
// and taken back out before `invoke`, so the action's own input parse never sees it.
|
|
7
|
+
|
|
8
|
+
import { MAX_IDEMPOTENCY_KEY_LENGTH } from '@ultimat3/action';
|
|
9
|
+
import { McpIdempotencyKeyShadowedError } from './errors';
|
|
10
|
+
import type { JsonSchema } from './wire';
|
|
11
|
+
|
|
12
|
+
/** The reserved argument name on every idempotent action's tool. */
|
|
13
|
+
export const MCP_IDEMPOTENCY_KEY_ARG = 'idempotencyKey';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Contract text, not UI text — it is what `tools/list` publishes, the same bytes for every caller,
|
|
17
|
+
* like an action's `mcp.description`.
|
|
18
|
+
*/
|
|
19
|
+
const DESCRIPTION =
|
|
20
|
+
'Idempotency key. Retrying this call with the same key and the same arguments returns the ' +
|
|
21
|
+
'first result instead of running it again; the same key with different arguments is refused. ' +
|
|
22
|
+
'Any unique string, e.g. a UUID — reuse it only to retry.';
|
|
23
|
+
|
|
24
|
+
const KEY_SCHEMA: JsonSchema = {
|
|
25
|
+
type: 'string',
|
|
26
|
+
description: DESCRIPTION,
|
|
27
|
+
minLength: 1,
|
|
28
|
+
maxLength: MAX_IDEMPOTENCY_KEY_LENGTH,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* `schema` with the optional key argument. An input that is not an object has no argument to
|
|
33
|
+
* add, and is left alone; one that already names the argument is refused, never merged.
|
|
34
|
+
*/
|
|
35
|
+
export function withIdempotencyKeyArg(name: string, schema: JsonSchema): JsonSchema {
|
|
36
|
+
if (schema.type !== 'object') return schema;
|
|
37
|
+
const properties = schema.properties ?? {};
|
|
38
|
+
if (Object.hasOwn(properties, MCP_IDEMPOTENCY_KEY_ARG)) {
|
|
39
|
+
throw new McpIdempotencyKeyShadowedError({ name, argument: MCP_IDEMPOTENCY_KEY_ARG });
|
|
40
|
+
}
|
|
41
|
+
return { ...schema, properties: { ...properties, [MCP_IDEMPOTENCY_KEY_ARG]: KEY_SCHEMA } };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The call's arguments split into the action's input and the key. Absent, empty-handed or not an
|
|
46
|
+
* object: the input as given and no key — `invoke` then runs the action un-keyed, as HTTP does
|
|
47
|
+
* without the header.
|
|
48
|
+
*/
|
|
49
|
+
export function takeIdempotencyKeyArg(args: unknown): {
|
|
50
|
+
readonly input: unknown;
|
|
51
|
+
readonly idempotencyKey: string | null;
|
|
52
|
+
} {
|
|
53
|
+
if (typeof args !== 'object' || args === null || Array.isArray(args)) {
|
|
54
|
+
return { input: args, idempotencyKey: null };
|
|
55
|
+
}
|
|
56
|
+
if (!Object.hasOwn(args, MCP_IDEMPOTENCY_KEY_ARG)) return { input: args, idempotencyKey: null };
|
|
57
|
+
const input: Record<string, unknown> = {};
|
|
58
|
+
let key: unknown;
|
|
59
|
+
for (const [field, value] of Object.entries(args)) {
|
|
60
|
+
if (field === MCP_IDEMPOTENCY_KEY_ARG) key = value;
|
|
61
|
+
// defineProperty, never `input[field] =`: a `__proto__` field would re-prototype the copy.
|
|
62
|
+
else
|
|
63
|
+
Object.defineProperty(input, field, {
|
|
64
|
+
value,
|
|
65
|
+
writable: true,
|
|
66
|
+
enumerable: true,
|
|
67
|
+
configurable: true,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
// `validate-args.ts` already held the value to the advertised string schema, so a non-string can
|
|
71
|
+
// only come from a caller that skipped it — and a value that is not a string is not a key.
|
|
72
|
+
return { input, idempotencyKey: typeof key === 'string' ? key : null };
|
|
73
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -63,7 +63,9 @@ export {
|
|
|
63
63
|
McpAppUnmountedError,
|
|
64
64
|
McpArgsInvalidError,
|
|
65
65
|
McpBodyTooLargeError,
|
|
66
|
+
McpIdempotencyKeyShadowedError,
|
|
66
67
|
McpNotBranchDbError,
|
|
68
|
+
McpOAuthInvalidError,
|
|
67
69
|
McpProtocolError,
|
|
68
70
|
McpQueryRejectedError,
|
|
69
71
|
McpRateLimitedError,
|
|
@@ -85,6 +87,7 @@ export {
|
|
|
85
87
|
toolsFrom,
|
|
86
88
|
toolsListed,
|
|
87
89
|
} from './from-action';
|
|
90
|
+
export { MCP_IDEMPOTENCY_KEY_ARG } from './idempotency-arg';
|
|
88
91
|
export { toWireSchema } from './input-schema';
|
|
89
92
|
export type { ListFilterOp, McpListParams } from './list-params';
|
|
90
93
|
export { DEFAULT_LIST_MAX_LIMIT, listParamsSchema } from './list-params';
|
|
@@ -111,6 +114,13 @@ export {
|
|
|
111
114
|
META_TOOL_NAMES,
|
|
112
115
|
oneLineParams,
|
|
113
116
|
} from './meta-surface';
|
|
117
|
+
export type { McpOAuth } from './oauth-metadata';
|
|
118
|
+
export {
|
|
119
|
+
metadataPaths,
|
|
120
|
+
metadataUrlFor,
|
|
121
|
+
PROTECTED_RESOURCE_WELL_KNOWN,
|
|
122
|
+
protectedResourceMetadata,
|
|
123
|
+
} from './oauth-metadata';
|
|
114
124
|
export type { ListedPrimitive } from './projectable';
|
|
115
125
|
export { asProjectable } from './projectable';
|
|
116
126
|
export type { QueryLimits, QueryResult, QueryRows } from './query-limits';
|
|
@@ -156,6 +166,8 @@ export type { CreateMcpServerInput, McpWire } from './server';
|
|
|
156
166
|
export { createMcpServer, McpServer } from './server';
|
|
157
167
|
export type {
|
|
158
168
|
McpHttpTransportInput,
|
|
169
|
+
McpProtectedResource,
|
|
170
|
+
McpRequestOrigin,
|
|
159
171
|
McpRouteDescriptor,
|
|
160
172
|
ResolvedToken,
|
|
161
173
|
} from './transport-http';
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// OAuth 2.0 Protected Resource Metadata (RFC 9728) for the MCP endpoint — the framework half of
|
|
2
|
+
// the MCP authorization spec (2025-06-18, unchanged in 2025-11-25): a 401 names where the metadata
|
|
3
|
+
// lives (`WWW-Authenticate: Bearer resource_metadata="…"`), and the metadata names the
|
|
4
|
+
// authorization server(s) a client goes to for a token. The authorization server itself is the
|
|
5
|
+
// APP's — its consent page, its token endpoint, its revocation list — never this package's.
|
|
6
|
+
|
|
7
|
+
import { McpOAuthInvalidError } from './errors';
|
|
8
|
+
|
|
9
|
+
/** The well-known suffix RFC 9728 §3 defines. */
|
|
10
|
+
export const PROTECTED_RESOURCE_WELL_KNOWN = '/.well-known/oauth-protected-resource';
|
|
11
|
+
|
|
12
|
+
export interface McpOAuth {
|
|
13
|
+
/**
|
|
14
|
+
* Issuer identifiers of the authorization servers that mint tokens this endpoint accepts
|
|
15
|
+
* (`authorization_servers`). At least one; an absolute https URL, or http on localhost.
|
|
16
|
+
*/
|
|
17
|
+
readonly authorizationServers: readonly string[];
|
|
18
|
+
/**
|
|
19
|
+
* The canonical URL of this MCP endpoint (RFC 8707 `resource`), e.g.
|
|
20
|
+
* `https://www.example.com/mcp`. Omitted, it is the request's own public origin + the mount
|
|
21
|
+
* path — right for one image deployed behind many hosts.
|
|
22
|
+
*/
|
|
23
|
+
readonly resource?: string;
|
|
24
|
+
/** `scopes_supported`. Omitted, the keys of `defineAppMcp({ scopes })`. */
|
|
25
|
+
readonly scopesSupported?: readonly string[];
|
|
26
|
+
/** `resource_name` — shown by a client's consent screen. */
|
|
27
|
+
readonly resourceName?: string;
|
|
28
|
+
/** `resource_documentation` — an absolute URL. */
|
|
29
|
+
readonly resourceDocumentation?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]']);
|
|
33
|
+
|
|
34
|
+
/** https anywhere, http only on loopback (RFC 9728 §3.3, OAuth 2.1 §1.5), and never a fragment. */
|
|
35
|
+
function isIssuerUrl(value: string): boolean {
|
|
36
|
+
const url = URL.parse(value);
|
|
37
|
+
if (url === null || url.hash !== '') return false;
|
|
38
|
+
return url.protocol === 'https:' || (url.protocol === 'http:' && LOOPBACK.has(url.hostname));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** At `defineAppMcp`, never on a request. */
|
|
42
|
+
export function assertMcpOAuth(oauth: McpOAuth): void {
|
|
43
|
+
if (oauth.authorizationServers.length === 0) {
|
|
44
|
+
throw new McpOAuthInvalidError('authorizationServers is empty');
|
|
45
|
+
}
|
|
46
|
+
for (const issuer of oauth.authorizationServers) {
|
|
47
|
+
if (!isIssuerUrl(issuer)) {
|
|
48
|
+
throw new McpOAuthInvalidError(
|
|
49
|
+
`${JSON.stringify(issuer)} is not an absolute https issuer URL`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
for (const key of ['resource', 'resourceDocumentation'] as const) {
|
|
54
|
+
const value = oauth[key];
|
|
55
|
+
if (value !== undefined && !isIssuerUrl(value)) {
|
|
56
|
+
throw new McpOAuthInvalidError(
|
|
57
|
+
`${key} ${JSON.stringify(value)} is not an absolute https URL`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The resource this request addresses: the declared one, else `<origin><path>`. */
|
|
64
|
+
export function resourceUrl(oauth: McpOAuth, origin: string, path: string): string {
|
|
65
|
+
return oauth.resource ?? `${origin.replace(/\/+$/, '')}${path}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* RFC 9728 §3.1: the well-known suffix INSERTED between the host and the resource's path —
|
|
70
|
+
* `https://x.test/mcp` → `https://x.test/.well-known/oauth-protected-resource/mcp`.
|
|
71
|
+
*/
|
|
72
|
+
export function metadataUrlFor(resource: string): string {
|
|
73
|
+
const url = new URL(resource);
|
|
74
|
+
const path = url.pathname === '/' ? '' : url.pathname.replace(/\/+$/, '');
|
|
75
|
+
return `${url.origin}${PROTECTED_RESOURCE_WELL_KNOWN}${path}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The two paths a host serves the document at: path-inserted (RFC 9728), and the root. */
|
|
79
|
+
export function metadataPaths(mountPath: string): readonly string[] {
|
|
80
|
+
const inserted = `${PROTECTED_RESOURCE_WELL_KNOWN}${mountPath === '/' ? '' : mountPath}`;
|
|
81
|
+
return inserted === PROTECTED_RESOURCE_WELL_KNOWN
|
|
82
|
+
? [PROTECTED_RESOURCE_WELL_KNOWN]
|
|
83
|
+
: [inserted, PROTECTED_RESOURCE_WELL_KNOWN];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The metadata document for `resource`. Keys in RFC 9728 §2's names. */
|
|
87
|
+
export function protectedResourceMetadata(
|
|
88
|
+
oauth: McpOAuth,
|
|
89
|
+
resource: string,
|
|
90
|
+
scopes: readonly string[],
|
|
91
|
+
): Record<string, unknown> {
|
|
92
|
+
const supported = oauth.scopesSupported ?? scopes;
|
|
93
|
+
return {
|
|
94
|
+
resource,
|
|
95
|
+
authorization_servers: [...oauth.authorizationServers],
|
|
96
|
+
bearer_methods_supported: ['header'],
|
|
97
|
+
...(supported.length === 0 ? {} : { scopes_supported: [...supported].sort() }),
|
|
98
|
+
...(oauth.resourceName === undefined ? {} : { resource_name: oauth.resourceName }),
|
|
99
|
+
...(oauth.resourceDocumentation === undefined
|
|
100
|
+
? {}
|
|
101
|
+
: { resource_documentation: oauth.resourceDocumentation }),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The challenge a 401 carries. `error="invalid_token"` only when a token WAS sent (RFC 6750
|
|
107
|
+
* §3.1: a request with no credential gets no error code).
|
|
108
|
+
*/
|
|
109
|
+
export function bearerChallenge(metadataUrl: string | undefined, tokenSent: boolean): string {
|
|
110
|
+
const params = ['realm="ultimate-mcp"'];
|
|
111
|
+
if (tokenSent) params.push('error="invalid_token"');
|
|
112
|
+
if (metadataUrl !== undefined) params.push(`resource_metadata="${metadataUrl}"`);
|
|
113
|
+
return `Bearer ${params.join(', ')}`;
|
|
114
|
+
}
|
package/src/projectable.ts
CHANGED
|
@@ -10,6 +10,7 @@ import type { AnyQuery } from '@ultimat3/query';
|
|
|
10
10
|
import { isQuery, queryName, sourceFor } from '@ultimat3/query';
|
|
11
11
|
import { asCallerContext } from './caller-context';
|
|
12
12
|
import type { McpExposure, ProjectablePrimitive } from './from-action';
|
|
13
|
+
import { takeIdempotencyKeyArg, withIdempotencyKeyArg } from './idempotency-arg';
|
|
13
14
|
import { toWireSchema } from './input-schema';
|
|
14
15
|
import type { McpListParams } from './list-params';
|
|
15
16
|
|
|
@@ -40,16 +41,26 @@ export function asProjectable(listed: ListedPrimitive): ProjectablePrimitive {
|
|
|
40
41
|
|
|
41
42
|
export function primitiveFromAction(target: AnyAction): ProjectablePrimitive {
|
|
42
43
|
const exposure = exposureOf(target.mcp);
|
|
44
|
+
// Throws `X_ACTION_UNREGISTERED` on an unnamed action rather than projecting a tool called
|
|
45
|
+
// `''`: a nameless tool is unaddressable by the scope map, by `tools/call`, and by the author.
|
|
46
|
+
const name = actionName(target);
|
|
47
|
+
const wire = toWireSchema(target.input);
|
|
48
|
+
// `idempotent: true` is honoured on this surface exactly as over HTTP: the caller's key reaches
|
|
49
|
+
// `invoke`, which files it under the action, the caller and the key (`idempotencyKeyFor`).
|
|
50
|
+
const keyed = target.describe().idempotent;
|
|
43
51
|
return {
|
|
44
|
-
|
|
45
|
-
// `''`: a nameless tool is unaddressable by the scope map, by `tools/call`, and by the author.
|
|
46
|
-
name: actionName(target),
|
|
52
|
+
name,
|
|
47
53
|
...(exposure === undefined ? {} : { mcp: exposure }),
|
|
48
54
|
...(exposure?.description === undefined ? {} : { description: exposure.description }),
|
|
49
|
-
inputJsonSchema:
|
|
55
|
+
inputJsonSchema: keyed ? withIdempotencyKeyArg(name, wire) : wire,
|
|
50
56
|
mutates: true,
|
|
51
57
|
// The actor rides in on the options: `invoke` swaps it inside the one execution path.
|
|
52
|
-
run:
|
|
58
|
+
run: keyed
|
|
59
|
+
? ({ input: args, actor }) => {
|
|
60
|
+
const { input, idempotencyKey } = takeIdempotencyKeyArg(args);
|
|
61
|
+
return invoke(target, input, { surface: 'mcp', actor, idempotencyKey });
|
|
62
|
+
}
|
|
63
|
+
: ({ input, actor }) => invoke(target, input, { surface: 'mcp', actor }),
|
|
53
64
|
};
|
|
54
65
|
}
|
|
55
66
|
|
package/src/transport-http.ts
CHANGED
|
@@ -24,6 +24,15 @@ import { finiteCount, readWithinLimit, systemClock } from '@ultimat3/core';
|
|
|
24
24
|
import type { RateLimitStore } from '@ultimat3/http';
|
|
25
25
|
import { memoryRateLimitStore, toBucket } from '@ultimat3/http';
|
|
26
26
|
import { McpBodyTooLargeError, McpRateLimitedError } from './errors';
|
|
27
|
+
import type { McpOAuth } from './oauth-metadata';
|
|
28
|
+
import {
|
|
29
|
+
assertMcpOAuth,
|
|
30
|
+
bearerChallenge,
|
|
31
|
+
metadataPaths,
|
|
32
|
+
metadataUrlFor,
|
|
33
|
+
protectedResourceMetadata,
|
|
34
|
+
resourceUrl,
|
|
35
|
+
} from './oauth-metadata';
|
|
27
36
|
import type { McpCaller, McpRole, McpVerbClass } from './registry';
|
|
28
37
|
import type { McpServer } from './server';
|
|
29
38
|
import type { JsonRpcResponse } from './wire';
|
|
@@ -75,6 +84,28 @@ export interface McpHttpTransportInput {
|
|
|
75
84
|
readonly rateLimitStore?: RateLimitStore | undefined;
|
|
76
85
|
/** The one clock the buckets refill on. Defaulted, never read inline, so a test can freeze it. */
|
|
77
86
|
readonly clock?: Clock | undefined;
|
|
87
|
+
/**
|
|
88
|
+
* Publish RFC 9728 protected-resource metadata and name it in every 401's `WWW-Authenticate` —
|
|
89
|
+
* the discovery an OAuth-capable MCP client (a remote connector) starts from. Absent, the 401
|
|
90
|
+
* says `Bearer realm="ultimate-mcp"` as it always did.
|
|
91
|
+
*/
|
|
92
|
+
readonly oauth?: McpOAuth | undefined;
|
|
93
|
+
/** `scopes_supported` when `oauth.scopesSupported` is not stated: the app's scope names. */
|
|
94
|
+
readonly scopes?: readonly string[] | undefined;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** How the host saw the request — what a bare `Request` behind a TLS-terminating proxy cannot say. */
|
|
98
|
+
export interface McpRequestOrigin {
|
|
99
|
+
/** The PUBLIC origin, `https://www.example.com`. Defaults to the request URL's own. */
|
|
100
|
+
readonly origin?: string | undefined;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The metadata half of an `oauth` route: where to serve it and what to answer. */
|
|
104
|
+
export interface McpProtectedResource {
|
|
105
|
+
/** `/.well-known/oauth-protected-resource/mcp` and the root one. `GET`, public. */
|
|
106
|
+
readonly paths: readonly string[];
|
|
107
|
+
/** The document, for the public origin the request arrived on. */
|
|
108
|
+
document(origin: string): Record<string, unknown>;
|
|
78
109
|
}
|
|
79
110
|
|
|
80
111
|
export interface McpRouteDescriptor {
|
|
@@ -84,7 +115,9 @@ export interface McpRouteDescriptor {
|
|
|
84
115
|
rateLimitClass(body: unknown): McpVerbClass;
|
|
85
116
|
/** Requests per minute per caller, by class — what `handle` actually spends against. */
|
|
86
117
|
readonly limits: Readonly<Record<McpVerbClass, number>>;
|
|
87
|
-
|
|
118
|
+
/** Present exactly when the route was built with `oauth`. */
|
|
119
|
+
readonly protectedResource?: McpProtectedResource;
|
|
120
|
+
handle(request: Request, seen?: McpRequestOrigin): Promise<Response>;
|
|
88
121
|
}
|
|
89
122
|
|
|
90
123
|
const JSON_HEADERS = { 'content-type': 'application/json' } as const;
|
|
@@ -118,23 +151,41 @@ export function mcpHttpRoute(input: McpHttpTransportInput): McpRouteDescriptor {
|
|
|
118
151
|
});
|
|
119
152
|
|
|
120
153
|
const path = input.path ?? '/mcp';
|
|
154
|
+
const oauth = input.oauth;
|
|
155
|
+
if (oauth !== undefined) assertMcpOAuth(oauth);
|
|
156
|
+
const scopes = input.scopes ?? [];
|
|
157
|
+
const resourceOf = (request: Request, seen: McpRequestOrigin | undefined): string | undefined =>
|
|
158
|
+
oauth === undefined
|
|
159
|
+
? undefined
|
|
160
|
+
: resourceUrl(oauth, seen?.origin ?? new URL(request.url).origin, path);
|
|
121
161
|
|
|
122
162
|
return {
|
|
123
163
|
method: 'POST',
|
|
124
164
|
path,
|
|
125
165
|
limits,
|
|
126
166
|
rateLimitClass: (body) => server.classify(body),
|
|
167
|
+
...(oauth === undefined
|
|
168
|
+
? {}
|
|
169
|
+
: {
|
|
170
|
+
protectedResource: {
|
|
171
|
+
paths: metadataPaths(path),
|
|
172
|
+
document: (origin: string) =>
|
|
173
|
+
protectedResourceMetadata(oauth, resourceUrl(oauth, origin, path), scopes),
|
|
174
|
+
},
|
|
175
|
+
}),
|
|
127
176
|
|
|
128
|
-
async handle(request: Request): Promise<Response> {
|
|
177
|
+
async handle(request: Request, seen?: McpRequestOrigin): Promise<Response> {
|
|
129
178
|
// Every authentication answer lands BEFORE the body is read. Parsing first meant a caller
|
|
130
179
|
// holding a rejected token still learned whether its JSON was well formed — `400 parse
|
|
131
180
|
// error` for one payload and `401` for the next is exactly the oracle the 401 exists to
|
|
132
181
|
// remove, and it costs nothing to close: the body is not an input to any of these.
|
|
182
|
+
const resource = resourceOf(request, seen);
|
|
183
|
+
const metadataUrl = resource === undefined ? undefined : metadataUrlFor(resource);
|
|
133
184
|
const token = bearerToken(request);
|
|
134
|
-
if (token === null) return unauthorized();
|
|
185
|
+
if (token === null) return unauthorized(bearerChallenge(metadataUrl, false));
|
|
135
186
|
|
|
136
187
|
const resolved = await input.resolveToken(token);
|
|
137
|
-
if (resolved === null) return unauthorized();
|
|
188
|
+
if (resolved === null) return unauthorized(bearerChallenge(metadataUrl, true));
|
|
138
189
|
if (!isAgentActor(resolved.actor)) return notAnAgent();
|
|
139
190
|
|
|
140
191
|
// Read through the counting reader, never `request.json()`: the cap has to be enforced
|
|
@@ -253,7 +304,7 @@ function notAnAgent(): Response {
|
|
|
253
304
|
);
|
|
254
305
|
}
|
|
255
306
|
|
|
256
|
-
function unauthorized(): Response {
|
|
307
|
+
function unauthorized(challenge: string): Response {
|
|
257
308
|
return new Response(
|
|
258
309
|
JSON.stringify({
|
|
259
310
|
code: 'X_MCP_PROTOCOL',
|
|
@@ -262,7 +313,7 @@ function unauthorized(): Response {
|
|
|
262
313
|
}),
|
|
263
314
|
{
|
|
264
315
|
status: 401,
|
|
265
|
-
headers: { ...JSON_HEADERS, 'www-authenticate':
|
|
316
|
+
headers: { ...JSON_HEADERS, 'www-authenticate': challenge },
|
|
266
317
|
},
|
|
267
318
|
);
|
|
268
319
|
}
|