@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 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.5.1",
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.5.1",
35
- "@ultimat3/core": "22.5.1",
36
- "@ultimat3/entity": "22.5.1",
37
- "@ultimat3/http": "22.5.1",
38
- "@ultimat3/jobs": "22.5.1",
39
- "@ultimat3/policy": "22.5.1",
40
- "@ultimat3/query": "22.5.1",
41
- "@ultimat3/schema": "22.5.1"
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
+ }
@@ -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
- // 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
- name: actionName(target),
52
+ name,
47
53
  ...(exposure === undefined ? {} : { mcp: exposure }),
48
54
  ...(exposure?.description === undefined ? {} : { description: exposure.description }),
49
- inputJsonSchema: toWireSchema(target.input),
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: ({ input, actor }) => invoke(target, input, { surface: 'mcp', actor }),
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
 
@@ -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
- handle(request: Request): Promise<Response>;
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': 'Bearer realm="ultimate-mcp"' },
316
+ headers: { ...JSON_HEADERS, 'www-authenticate': challenge },
266
317
  },
267
318
  );
268
319
  }