@ultimat3/mcp 22.5.0 → 22.6.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
@@ -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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/mcp",
3
- "version": "22.5.0",
3
+ "version": "22.6.0",
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.0",
35
- "@ultimat3/core": "22.5.0",
36
- "@ultimat3/entity": "22.5.0",
37
- "@ultimat3/http": "22.5.0",
38
- "@ultimat3/jobs": "22.5.0",
39
- "@ultimat3/policy": "22.5.0",
40
- "@ultimat3/query": "22.5.0",
41
- "@ultimat3/schema": "22.5.0"
34
+ "@ultimat3/action": "22.6.0",
35
+ "@ultimat3/core": "22.6.0",
36
+ "@ultimat3/entity": "22.6.0",
37
+ "@ultimat3/http": "22.6.0",
38
+ "@ultimat3/jobs": "22.6.0",
39
+ "@ultimat3/policy": "22.6.0",
40
+ "@ultimat3/query": "22.6.0",
41
+ "@ultimat3/schema": "22.6.0"
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,7 @@ 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',
26
27
  ] as const;
27
28
 
28
29
  export type McpErrorCode = (typeof MCP_ERROR_CODES)[number];
@@ -47,6 +48,7 @@ export const MCP_ERROR_TITLES: Readonly<Record<McpErrorCode, string>> = {
47
48
  X_MCP_GROUP_CONFLICT: 'two resource groups claim one MCP tool',
48
49
  X_MCP_SURFACE_INVALID: 'the MCP meta surface is declared inconsistently',
49
50
  X_MCP_LIST_PARAMS_INVALID: "a tool's listParams names a key its own input does not accept",
51
+ X_MCP_OAUTH_INVALID: 'the MCP oauth block cannot be published as protected-resource metadata',
50
52
  };
51
53
 
52
54
  // Titles must be registered for `format()` to render the contract's first line. Every code above is
@@ -417,3 +419,14 @@ export class McpRateLimitedError extends UltimateError {
417
419
  });
418
420
  }
419
421
  }
422
+
423
+ /** `defineAppMcp({ oauth })` refused at boot — see `oauth-metadata.ts`. */
424
+ export class McpOAuthInvalidError extends UltimateError {
425
+ constructor(reason: string) {
426
+ super({
427
+ code: 'X_MCP_OAUTH_INVALID',
428
+ cause: `defineAppMcp({ oauth }) cannot be published as protected-resource metadata: ${reason}`,
429
+ 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",
430
+ });
431
+ }
432
+ }
package/src/index.ts CHANGED
@@ -64,6 +64,7 @@ export {
64
64
  McpArgsInvalidError,
65
65
  McpBodyTooLargeError,
66
66
  McpNotBranchDbError,
67
+ McpOAuthInvalidError,
67
68
  McpProtocolError,
68
69
  McpQueryRejectedError,
69
70
  McpRateLimitedError,
@@ -111,6 +112,13 @@ export {
111
112
  META_TOOL_NAMES,
112
113
  oneLineParams,
113
114
  } from './meta-surface';
115
+ export type { McpOAuth } from './oauth-metadata';
116
+ export {
117
+ metadataPaths,
118
+ metadataUrlFor,
119
+ PROTECTED_RESOURCE_WELL_KNOWN,
120
+ protectedResourceMetadata,
121
+ } from './oauth-metadata';
114
122
  export type { ListedPrimitive } from './projectable';
115
123
  export { asProjectable } from './projectable';
116
124
  export type { QueryLimits, QueryResult, QueryRows } from './query-limits';
@@ -156,6 +164,8 @@ export type { CreateMcpServerInput, McpWire } from './server';
156
164
  export { createMcpServer, McpServer } from './server';
157
165
  export type {
158
166
  McpHttpTransportInput,
167
+ McpProtectedResource,
168
+ McpRequestOrigin,
159
169
  McpRouteDescriptor,
160
170
  ResolvedToken,
161
171
  } 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
+ }
@@ -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
  }