@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 +1 -0
- package/README.md +13 -0
- package/package.json +9 -9
- package/src/app-tools.ts +15 -0
- package/src/errors.ts +13 -0
- package/src/index.ts +10 -0
- package/src/oauth-metadata.ts +114 -0
- 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
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/mcp",
|
|
3
|
-
"version": "22.
|
|
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.
|
|
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.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
|
+
}
|
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
|
}
|