@ultimat3/mcp 22.6.2 → 22.7.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/package.json +9 -9
- package/src/oauth-metadata.ts +33 -3
- package/src/transport-http.ts +17 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/mcp",
|
|
3
|
-
"version": "22.
|
|
3
|
+
"version": "22.7.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.7.1",
|
|
35
|
+
"@ultimat3/core": "22.7.1",
|
|
36
|
+
"@ultimat3/entity": "22.7.1",
|
|
37
|
+
"@ultimat3/http": "22.7.1",
|
|
38
|
+
"@ultimat3/jobs": "22.7.1",
|
|
39
|
+
"@ultimat3/policy": "22.7.1",
|
|
40
|
+
"@ultimat3/query": "22.7.1",
|
|
41
|
+
"@ultimat3/schema": "22.7.1"
|
|
42
42
|
}
|
|
43
43
|
}
|
package/src/oauth-metadata.ts
CHANGED
|
@@ -89,7 +89,7 @@ export function protectedResourceMetadata(
|
|
|
89
89
|
resource: string,
|
|
90
90
|
scopes: readonly string[],
|
|
91
91
|
): Record<string, unknown> {
|
|
92
|
-
const supported = oauth
|
|
92
|
+
const supported = supportedScopes(oauth, scopes);
|
|
93
93
|
return {
|
|
94
94
|
resource,
|
|
95
95
|
authorization_servers: [...oauth.authorizationServers],
|
|
@@ -102,13 +102,43 @@ export function protectedResourceMetadata(
|
|
|
102
102
|
};
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
/** `scopes_supported` and the challenge's `scope`: the stated list, else the app's scope names. */
|
|
106
|
+
export function supportedScopes(oauth: McpOAuth, scopes: readonly string[]): readonly string[] {
|
|
107
|
+
return [...(oauth.scopesSupported ?? scopes)].sort();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** RFC 6749 §3.3 `scope-token = 1*( %x21 / %x23-5B / %x5D-7E )`. */
|
|
111
|
+
const SCOPE_TOKEN = /^[\x21\x23-\x5B\x5D-\x7E]+$/;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* At construction, beside `assertMcpOAuth`: every scope the challenge will name must be a
|
|
115
|
+
* scope-token. A space would split one scope into two in `scope="a b"`, and a quote or backslash
|
|
116
|
+
* would end the quoted-string early — a header a client parses into something nobody declared.
|
|
117
|
+
*/
|
|
118
|
+
export function assertScopeTokens(scopes: readonly string[]): void {
|
|
119
|
+
for (const scope of scopes) {
|
|
120
|
+
if (!SCOPE_TOKEN.test(scope)) {
|
|
121
|
+
throw new McpOAuthInvalidError(
|
|
122
|
+
`scope ${JSON.stringify(scope)} is not an RFC 6749 scope-token (printable ASCII, no space, quote or backslash)`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
105
128
|
/**
|
|
106
129
|
* 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).
|
|
130
|
+
* §3.1: a request with no credential gets no error code). `scope` names what the resource
|
|
131
|
+
* supports (RFC 6750 §3, MCP authorization 2025-06-18 / 2025-11-25 "scope selection"), so a
|
|
132
|
+
* client can ask for it without first fetching the metadata; omitted when there is none.
|
|
108
133
|
*/
|
|
109
|
-
export function bearerChallenge(
|
|
134
|
+
export function bearerChallenge(
|
|
135
|
+
metadataUrl: string | undefined,
|
|
136
|
+
tokenSent: boolean,
|
|
137
|
+
scopes: readonly string[] = [],
|
|
138
|
+
): string {
|
|
110
139
|
const params = ['realm="ultimate-mcp"'];
|
|
111
140
|
if (tokenSent) params.push('error="invalid_token"');
|
|
112
141
|
if (metadataUrl !== undefined) params.push(`resource_metadata="${metadataUrl}"`);
|
|
142
|
+
if (scopes.length > 0) params.push(`scope="${scopes.join(' ')}"`);
|
|
113
143
|
return `Bearer ${params.join(', ')}`;
|
|
114
144
|
}
|
package/src/transport-http.ts
CHANGED
|
@@ -27,11 +27,13 @@ import { McpBodyTooLargeError, McpRateLimitedError } from './errors';
|
|
|
27
27
|
import type { McpOAuth } from './oauth-metadata';
|
|
28
28
|
import {
|
|
29
29
|
assertMcpOAuth,
|
|
30
|
+
assertScopeTokens,
|
|
30
31
|
bearerChallenge,
|
|
31
32
|
metadataPaths,
|
|
32
33
|
metadataUrlFor,
|
|
33
34
|
protectedResourceMetadata,
|
|
34
35
|
resourceUrl,
|
|
36
|
+
supportedScopes,
|
|
35
37
|
} from './oauth-metadata';
|
|
36
38
|
import type { McpCaller, McpRole, McpVerbClass } from './registry';
|
|
37
39
|
import type { McpServer } from './server';
|
|
@@ -120,7 +122,14 @@ export interface McpRouteDescriptor {
|
|
|
120
122
|
handle(request: Request, seen?: McpRequestOrigin): Promise<Response>;
|
|
121
123
|
}
|
|
122
124
|
|
|
123
|
-
|
|
125
|
+
/**
|
|
126
|
+
* Every answer on this route is one caller's: a 401 challenge, a refusal, a JSON-RPC result for
|
|
127
|
+
* the token that asked. `no-store` on all of them (RFC 6750 §3 / RFC 6749 §5.1 for the auth
|
|
128
|
+
* answers), stated HERE because the descriptor is also served bare by `x mcp serve`, where no
|
|
129
|
+
* pipeline stage decides a cache header for it.
|
|
130
|
+
*/
|
|
131
|
+
const NO_STORE_HEADERS = { 'cache-control': 'no-store' } as const;
|
|
132
|
+
const JSON_HEADERS = { 'content-type': 'application/json', ...NO_STORE_HEADERS } as const;
|
|
124
133
|
|
|
125
134
|
export function mcpHttpRoute(input: McpHttpTransportInput): McpRouteDescriptor {
|
|
126
135
|
const { server } = input;
|
|
@@ -154,6 +163,9 @@ export function mcpHttpRoute(input: McpHttpTransportInput): McpRouteDescriptor {
|
|
|
154
163
|
const oauth = input.oauth;
|
|
155
164
|
if (oauth !== undefined) assertMcpOAuth(oauth);
|
|
156
165
|
const scopes = input.scopes ?? [];
|
|
166
|
+
// What the 401 names in `scope="…"` — only on an `oauth` route, the one a client discovers from.
|
|
167
|
+
const challengeScopes = oauth === undefined ? [] : supportedScopes(oauth, scopes);
|
|
168
|
+
assertScopeTokens(challengeScopes);
|
|
157
169
|
const resourceOf = (request: Request, seen: McpRequestOrigin | undefined): string | undefined =>
|
|
158
170
|
oauth === undefined
|
|
159
171
|
? undefined
|
|
@@ -182,10 +194,11 @@ export function mcpHttpRoute(input: McpHttpTransportInput): McpRouteDescriptor {
|
|
|
182
194
|
const resource = resourceOf(request, seen);
|
|
183
195
|
const metadataUrl = resource === undefined ? undefined : metadataUrlFor(resource);
|
|
184
196
|
const token = bearerToken(request);
|
|
185
|
-
if (token === null) return unauthorized(bearerChallenge(metadataUrl, false));
|
|
197
|
+
if (token === null) return unauthorized(bearerChallenge(metadataUrl, false, challengeScopes));
|
|
186
198
|
|
|
187
199
|
const resolved = await input.resolveToken(token);
|
|
188
|
-
if (resolved === null)
|
|
200
|
+
if (resolved === null)
|
|
201
|
+
return unauthorized(bearerChallenge(metadataUrl, true, challengeScopes));
|
|
189
202
|
if (!isAgentActor(resolved.actor)) return notAnAgent();
|
|
190
203
|
|
|
191
204
|
// Read through the counting reader, never `request.json()`: the cap has to be enforced
|
|
@@ -253,7 +266,7 @@ export function mcpHttpRoute(input: McpHttpTransportInput): McpRouteDescriptor {
|
|
|
253
266
|
// not a spelled `/mcp`.
|
|
254
267
|
const response = await server.handle(body, caller, { transport: 'http', path });
|
|
255
268
|
// A notification has no response. 202 with an empty body is the MCP-correct answer.
|
|
256
|
-
if (response === null) return new Response(null, { status: 202 });
|
|
269
|
+
if (response === null) return new Response(null, { status: 202, headers: NO_STORE_HEADERS });
|
|
257
270
|
// JSON-RPC errors are 200s: the transport succeeded, the call did not. Only a
|
|
258
271
|
// malformed envelope (below) is an HTTP-level failure.
|
|
259
272
|
const status = response.error?.code === INVALID_REQUEST && response.id === null ? 400 : 200;
|