@typeship-ax/mcp 0.6.0 → 0.9.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/AGENTS.md +31 -0
- package/README.md +67 -10
- package/api.json +6735 -3243
- package/api.md +8537 -248
- package/dist/api-identity.d.ts +40 -0
- package/dist/api-identity.d.ts.map +1 -0
- package/dist/api-identity.js +128 -0
- package/dist/auth-profiles.d.ts +30 -0
- package/dist/auth-profiles.d.ts.map +1 -0
- package/dist/auth-profiles.js +138 -0
- package/dist/core/http.d.ts +21 -92
- package/dist/core/http.d.ts.map +1 -1
- package/dist/core/http.js +143 -221
- package/dist/core/pagination.d.ts.map +1 -1
- package/dist/core/pagination.js +6 -34
- package/dist/credential-storage.d.ts +24 -0
- package/dist/credential-storage.d.ts.map +1 -0
- package/dist/credential-storage.js +207 -0
- package/dist/dates.d.ts +0 -2
- package/dist/dates.d.ts.map +1 -1
- package/dist/dates.js +0 -1
- package/dist/docs.d.ts +36 -0
- package/dist/docs.d.ts.map +1 -0
- package/dist/docs.js +258 -0
- package/dist/errors.d.ts +42 -34
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +30 -20
- package/dist/index.d.ts +27 -12
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +40 -14
- package/dist/mcp-authorization.d.ts +52 -0
- package/dist/mcp-authorization.d.ts.map +1 -0
- package/dist/mcp-authorization.js +232 -0
- package/dist/mcp-protocol.d.ts +69 -25
- package/dist/mcp-protocol.d.ts.map +1 -1
- package/dist/mcp-protocol.js +386 -138
- package/dist/mcp.d.ts +21 -3
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +199 -85
- package/dist/named-credentials.d.ts +21 -0
- package/dist/named-credentials.d.ts.map +1 -0
- package/dist/named-credentials.js +86 -0
- package/dist/oauth-request.d.ts +21 -0
- package/dist/oauth-request.d.ts.map +1 -0
- package/dist/oauth-request.js +119 -0
- package/dist/oauth-session.d.ts +106 -0
- package/dist/oauth-session.d.ts.map +1 -0
- package/dist/oauth-session.js +244 -0
- package/dist/ops.d.ts +18 -0
- package/dist/ops.d.ts.map +1 -1
- package/dist/ops.js +31 -17
- package/dist/resources/account.d.ts +4 -4
- package/dist/resources/account.d.ts.map +1 -1
- package/dist/resources/account.js +1 -0
- package/dist/resources/api-keys.d.ts +13 -8
- package/dist/resources/api-keys.d.ts.map +1 -1
- package/dist/resources/api-keys.js +5 -1
- package/dist/resources/definition-revisions.d.ts +58 -0
- package/dist/resources/definition-revisions.d.ts.map +1 -0
- package/dist/resources/definition-revisions.js +114 -0
- package/dist/resources/definitions.d.ts +35 -0
- package/dist/resources/definitions.d.ts.map +1 -0
- package/dist/resources/definitions.js +60 -0
- package/dist/resources/generate.d.ts +18 -7
- package/dist/resources/generate.d.ts.map +1 -1
- package/dist/resources/generate.js +13 -5
- package/dist/resources/generations.d.ts +6 -6
- package/dist/resources/generations.d.ts.map +1 -1
- package/dist/resources/generations.js +3 -1
- package/dist/resources/projects.d.ts +111 -35
- package/dist/resources/projects.d.ts.map +1 -1
- package/dist/resources/projects.js +125 -15
- package/dist/resources/targets.d.ts +97 -0
- package/dist/resources/targets.d.ts.map +1 -0
- package/dist/resources/targets.js +197 -0
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +135 -62
- package/dist/types.d.ts +2072 -267
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +20 -3
- package/dist/worker.js +4 -4
- package/package.json +11 -1
- package/server.json +42 -0
- package/src/api-identity.ts +98 -0
- package/src/auth-profiles.ts +114 -0
- package/src/core/http.ts +156 -305
- package/src/core/pagination.ts +6 -30
- package/src/credential-storage.ts +183 -0
- package/src/dates.ts +0 -1
- package/src/docs.ts +239 -0
- package/src/errors.ts +52 -41
- package/src/index.ts +49 -14
- package/src/mcp-authorization.ts +211 -0
- package/src/mcp-protocol.ts +432 -133
- package/src/mcp.ts +204 -90
- package/src/named-credentials.ts +74 -0
- package/src/oauth-request.ts +90 -0
- package/src/oauth-session.ts +258 -0
- package/src/ops.ts +56 -17
- package/src/resources/account.ts +6 -3
- package/src/resources/api-keys.ts +27 -7
- package/src/resources/definition-revisions.ts +207 -0
- package/src/resources/definitions.ts +122 -0
- package/src/resources/generate.ts +29 -6
- package/src/resources/generations.ts +9 -4
- package/src/resources/projects.ts +274 -41
- package/src/resources/targets.ts +378 -0
- package/src/schemas.ts +135 -62
- package/src/types.ts +2273 -322
- package/src/worker.ts +4 -4
- package/dist/resources/spec-revisions.d.ts +0 -47
- package/dist/resources/spec-revisions.d.ts.map +0 -1
- package/dist/resources/spec-revisions.js +0 -90
- package/src/resources/spec-revisions.ts +0 -150
package/src/index.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
HttpCore,
|
|
6
6
|
formatDebugEvent,
|
|
7
7
|
type AuthValue,
|
|
8
|
+
type SecurityCredential,
|
|
8
9
|
type DebugEvent,
|
|
9
10
|
type RequestContext,
|
|
10
11
|
bearerAuth,
|
|
@@ -13,14 +14,16 @@ import { DEFS, SCHEMAS } from "./schemas.js";
|
|
|
13
14
|
|
|
14
15
|
import { GenerateResource } from "./resources/generate.js";
|
|
15
16
|
import { ProjectsResource } from "./resources/projects.js";
|
|
17
|
+
import { DefinitionsResource } from "./resources/definitions.js";
|
|
18
|
+
import { TargetsResource } from "./resources/targets.js";
|
|
16
19
|
import { GenerationsResource } from "./resources/generations.js";
|
|
17
|
-
import {
|
|
20
|
+
import { DefinitionRevisionsResource } from "./resources/definition-revisions.js";
|
|
18
21
|
import { AccountResource } from "./resources/account.js";
|
|
19
22
|
import { ApiKeysResource } from "./resources/api-keys.js";
|
|
20
23
|
|
|
21
24
|
/** This package's version, also sent as the `User-Agent`. */
|
|
22
|
-
export const VERSION = "0.
|
|
23
|
-
const USER_AGENT = "@typeship-ax/mcp/0.
|
|
25
|
+
export const VERSION = "0.9.1";
|
|
26
|
+
const USER_AGENT = "@typeship-ax/mcp/0.9.1 (typeship)";
|
|
24
27
|
|
|
25
28
|
export interface ClientOptions {
|
|
26
29
|
/** Override the server URL. Default: `https://typeship.dev/api/v1` */
|
|
@@ -30,6 +33,13 @@ export interface ClientOptions {
|
|
|
30
33
|
* before every attempt.
|
|
31
34
|
*/
|
|
32
35
|
bearerToken?: string | (() => string | Promise<string>);
|
|
36
|
+
/**
|
|
37
|
+
* Credentials keyed by the Definition's security scheme names. Only one complete alternative is
|
|
38
|
+
* sent for each operation. Named values take precedence over convenience options.
|
|
39
|
+
*/
|
|
40
|
+
credentials?: {
|
|
41
|
+
"apiKey"?: AuthValue;
|
|
42
|
+
};
|
|
33
43
|
/** Per-attempt timeout in milliseconds. Default: 30000. */
|
|
34
44
|
timeoutMs?: number;
|
|
35
45
|
/** Retries after the first attempt (retryable failures only). Default: 2. */
|
|
@@ -69,21 +79,25 @@ export interface ClientOptions {
|
|
|
69
79
|
}
|
|
70
80
|
|
|
71
81
|
/**
|
|
72
|
-
* typeship — v0.
|
|
82
|
+
* typeship — v0.9.1
|
|
73
83
|
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
84
|
+
* Resolve an OpenAPI or GraphQL Definition, diagnose it, and keep every
|
|
85
|
+
* selected SDK, CLI, and MCP Target current.
|
|
76
86
|
*
|
|
77
|
-
* Every operation but one requires
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
87
|
+
* Every operation but one requires a bearer credential: an organization
|
|
88
|
+
* API key from the console, or an OAuth access token carrying the operation's
|
|
89
|
+
* read, generate, or write capability and the organization selected during
|
|
90
|
+
* consent. OAuth grants cannot switch organizations after consent. A browser
|
|
91
|
+
* session is not a credential for this API. The exception is POST /generate,
|
|
92
|
+
* which works anonymously with the free plan's limits.
|
|
81
93
|
*/
|
|
82
94
|
export class TypeshipClient {
|
|
83
95
|
readonly generate: GenerateResource;
|
|
84
96
|
readonly projects: ProjectsResource;
|
|
97
|
+
readonly definitions: DefinitionsResource;
|
|
98
|
+
readonly targets: TargetsResource;
|
|
85
99
|
readonly generations: GenerationsResource;
|
|
86
|
-
readonly
|
|
100
|
+
readonly definitionRevisions: DefinitionRevisionsResource;
|
|
87
101
|
readonly account: AccountResource;
|
|
88
102
|
readonly apiKeys: ApiKeysResource;
|
|
89
103
|
|
|
@@ -99,7 +113,23 @@ export class TypeshipClient {
|
|
|
99
113
|
? (event: DebugEvent) => console.error(formatDebugEvent("typeship", event))
|
|
100
114
|
: undefined;
|
|
101
115
|
const query: Record<string, AuthValue> = {};
|
|
102
|
-
|
|
116
|
+
const authHeaders: Record<string, AuthValue> = {};
|
|
117
|
+
const authQuery: Record<string, AuthValue> = {};
|
|
118
|
+
|
|
119
|
+
let bearerCredential: AuthValue | undefined;
|
|
120
|
+
|
|
121
|
+
if (options.bearerToken !== undefined) bearerCredential = bearerAuth(options.bearerToken);
|
|
122
|
+
|
|
123
|
+
const allowedCredentials = new Set<string>(["apiKey"]);
|
|
124
|
+
for (const name of Object.keys(options.credentials ?? {})) {
|
|
125
|
+
if (!allowedCredentials.has(name)) throw new Error("Unknown security scheme: " + name);
|
|
126
|
+
}
|
|
127
|
+
const credentials: Record<string, SecurityCredential> = Object.create(null);
|
|
128
|
+
{
|
|
129
|
+
const named = options.credentials && Object.hasOwn(options.credentials, "apiKey") ? options.credentials["apiKey"] : undefined;
|
|
130
|
+
const value = named !== undefined ? bearerAuth(named) : bearerCredential;
|
|
131
|
+
if (value !== undefined) credentials["apiKey"] = { headers: { ["Authorization"]: value } };
|
|
132
|
+
}
|
|
103
133
|
const v = options.validate;
|
|
104
134
|
const validate = v
|
|
105
135
|
? {
|
|
@@ -112,6 +142,7 @@ export class TypeshipClient {
|
|
|
112
142
|
baseUrl: options.baseUrl ?? "https://typeship.dev/api/v1",
|
|
113
143
|
headers,
|
|
114
144
|
query,
|
|
145
|
+
credentials,
|
|
115
146
|
fetch: options.fetch ?? fetch,
|
|
116
147
|
timeoutMs: options.timeoutMs ?? 30_000,
|
|
117
148
|
maxRetries: options.maxRetries ?? 2,
|
|
@@ -125,8 +156,10 @@ export class TypeshipClient {
|
|
|
125
156
|
});
|
|
126
157
|
this.generate = new GenerateResource(core);
|
|
127
158
|
this.projects = new ProjectsResource(core);
|
|
159
|
+
this.definitions = new DefinitionsResource(core);
|
|
160
|
+
this.targets = new TargetsResource(core);
|
|
128
161
|
this.generations = new GenerationsResource(core);
|
|
129
|
-
this.
|
|
162
|
+
this.definitionRevisions = new DefinitionRevisionsResource(core);
|
|
130
163
|
this.account = new AccountResource(core);
|
|
131
164
|
this.apiKeys = new ApiKeysResource(core);
|
|
132
165
|
}
|
|
@@ -147,7 +180,9 @@ export { Page, PagePromise } from "./core/pagination.js";
|
|
|
147
180
|
|
|
148
181
|
export * from "./resources/generate.js";
|
|
149
182
|
export * from "./resources/projects.js";
|
|
183
|
+
export * from "./resources/definitions.js";
|
|
184
|
+
export * from "./resources/targets.js";
|
|
150
185
|
export * from "./resources/generations.js";
|
|
151
|
-
export * from "./resources/
|
|
186
|
+
export * from "./resources/definition-revisions.js";
|
|
152
187
|
export * from "./resources/account.js";
|
|
153
188
|
export * from "./resources/api-keys.js";
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { oauthJsonRequest } from "./oauth-request.js";
|
|
2
|
+
|
|
3
|
+
export interface McpAuthorizationConfiguration {
|
|
4
|
+
/** Exact authorization-server issuer. Never inferred from an incoming token. */
|
|
5
|
+
issuer: string;
|
|
6
|
+
/** Canonical public MCP endpoint, including its path. */
|
|
7
|
+
resource: string;
|
|
8
|
+
/** Optional public signing-key endpoint; otherwise use issuer discovery. */
|
|
9
|
+
jwksUrl?: string;
|
|
10
|
+
/** Minimum connection scopes; upstream API permissions are independent. */
|
|
11
|
+
scopes?: readonly string[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface McpPrincipal {
|
|
15
|
+
readonly issuer: string;
|
|
16
|
+
readonly subject: string;
|
|
17
|
+
readonly resource: string;
|
|
18
|
+
readonly scopes: readonly string[];
|
|
19
|
+
/** Provider claims verified by signature or authenticated introspection. */
|
|
20
|
+
readonly claims: Readonly<Record<string, unknown>>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Server-only credentials. Supply at runtime, never in generation config. */
|
|
24
|
+
export interface McpTokenIntrospectionConfiguration {
|
|
25
|
+
/** Fixed provider endpoint authorized to receive this issuer's tokens. */
|
|
26
|
+
url: string;
|
|
27
|
+
clientId: string;
|
|
28
|
+
clientSecret: string;
|
|
29
|
+
authMethod?: "client_secret_basic" | "client_secret_post";
|
|
30
|
+
/** Entire response deadline, including the body; defaults to 10 seconds. */
|
|
31
|
+
timeoutMs?: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class McpAuthorizationError extends Error {
|
|
35
|
+
constructor(readonly status: 401 | 403 | 503, readonly code: "invalid_token" | "insufficient_scope" | "authorization_unavailable") {
|
|
36
|
+
super(code === "invalid_token" ? "A valid MCP access token is required." : code === "insufficient_scope" ? "The access token lacks the required MCP permissions." : "MCP authorization is unavailable. Check the server's authorization configuration.");
|
|
37
|
+
this.name = "McpAuthorizationError";
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const invalid = () => new McpAuthorizationError(401, "invalid_token");
|
|
42
|
+
const unavailable = () => new McpAuthorizationError(503, "authorization_unavailable");
|
|
43
|
+
const scopePattern = /^[\x21\x23-\x5B\x5D-\x7E]+$/;
|
|
44
|
+
|
|
45
|
+
function endpoint(value: string, query = false): string {
|
|
46
|
+
try {
|
|
47
|
+
const url = new URL(value);
|
|
48
|
+
if (value !== value.trim() || /[\u0000-\u0020\u007F"\\]/.test(value) || url.username || url.password || url.hash || (!query && url.search) ||
|
|
49
|
+
!(url.protocol === "https:" || url.protocol === "http:" && ["127.0.0.1", "[::1]", "localhost"].includes(url.hostname))) throw unavailable();
|
|
50
|
+
return value;
|
|
51
|
+
} catch { throw unavailable(); }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Validate configuration independently of any request or token claims. */
|
|
55
|
+
export function mcpAuthorizationConfiguration(value: McpAuthorizationConfiguration): McpAuthorizationConfiguration {
|
|
56
|
+
if (!value || typeof value.issuer !== "string" || typeof value.resource !== "string") throw unavailable();
|
|
57
|
+
const issuer = endpoint(value.issuer), resource = endpoint(value.resource);
|
|
58
|
+
const scopes = value.scopes ?? [];
|
|
59
|
+
if (!Array.isArray(scopes) || scopes.length > 20 || scopes.some(s => typeof s !== "string" || s.length > 500 || !scopePattern.test(s))) throw unavailable();
|
|
60
|
+
return Object.freeze({ issuer, resource, ...(value.jwksUrl ? { jwksUrl: endpoint(value.jwksUrl, true) } : {}), scopes: Object.freeze([...new Set(scopes)]) });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function bytes(value: string): Uint8Array<ArrayBuffer> {
|
|
64
|
+
if (!/^[A-Za-z0-9_-]+$/.test(value) || value.length % 4 === 1) throw invalid();
|
|
65
|
+
try {
|
|
66
|
+
const decoded = Uint8Array.from(atob(value.replace(/-/g, "+").replace(/_/g, "/")), ch => ch.charCodeAt(0));
|
|
67
|
+
if (btoa(String.fromCharCode(...decoded)).replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_") !== value) throw invalid();
|
|
68
|
+
return decoded;
|
|
69
|
+
} catch { throw invalid(); }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function object(value: unknown): value is Record<string, unknown> { return !!value && typeof value === "object" && !Array.isArray(value); }
|
|
73
|
+
function decode(value: string): Record<string, unknown> {
|
|
74
|
+
try { const parsed = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes(value))); if (!object(parsed)) throw invalid(); return parsed; }
|
|
75
|
+
catch { throw invalid(); }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface SigningKey { kty?: string; kid?: string; alg?: string; use?: string; key_ops?: string[]; n?: string; e?: string; crv?: string; x?: string; y?: string; d?: string; k?: string }
|
|
79
|
+
|
|
80
|
+
function principalFor(claims: Record<string, unknown>, configuration: McpAuthorizationConfiguration): McpPrincipal {
|
|
81
|
+
const now = Date.now() / 1000;
|
|
82
|
+
const audiences = typeof claims.aud === "string" ? [claims.aud] : claims.aud;
|
|
83
|
+
if (claims.cnf !== undefined || claims.iss !== configuration.issuer || typeof claims.sub !== "string" || !claims.sub || claims.sub.length > 1000 ||
|
|
84
|
+
!Array.isArray(audiences) || !audiences.every(a => typeof a === "string") || !audiences.includes(configuration.resource) ||
|
|
85
|
+
typeof claims.exp !== "number" || !Number.isFinite(claims.exp) || claims.exp <= now ||
|
|
86
|
+
(claims.nbf !== undefined && (typeof claims.nbf !== "number" || !Number.isFinite(claims.nbf) || claims.nbf > now)) ||
|
|
87
|
+
(claims.iat !== undefined && (typeof claims.iat !== "number" || !Number.isFinite(claims.iat) || claims.iat > now + 60)) ||
|
|
88
|
+
(claims.scope !== undefined && (typeof claims.scope !== "string" || claims.scope.length > 10_000))) throw invalid();
|
|
89
|
+
const scopes = typeof claims.scope === "string" ? claims.scope.split(" ").filter(Boolean) : [];
|
|
90
|
+
if (scopes.some(s => !scopePattern.test(s))) throw invalid();
|
|
91
|
+
if (configuration.scopes!.some(s => !scopes.includes(s))) throw new McpAuthorizationError(403, "insufficient_scope");
|
|
92
|
+
return Object.freeze({ issuer: configuration.issuer, subject: claims.sub, resource: configuration.resource, scopes: Object.freeze([...new Set(scopes)]), claims: Object.freeze(claims) });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function introspector(input: McpTokenIntrospectionConfiguration, configuration: McpAuthorizationConfiguration) {
|
|
96
|
+
const url = endpoint(input.url);
|
|
97
|
+
const method = input.authMethod ?? "client_secret_basic", timeoutMs = input.timeoutMs ?? 10_000;
|
|
98
|
+
if (!["client_secret_basic", "client_secret_post"].includes(method) || !Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 30_000 ||
|
|
99
|
+
[input.clientId, input.clientSecret].some(value => typeof value !== "string" || !value || value.length > 16_000 || /[\u0000-\u001F\u007F]/.test(value))) throw unavailable();
|
|
100
|
+
// Snapshot secrets in a private closure. Public metadata/config never exposes them.
|
|
101
|
+
const { clientId, clientSecret } = input;
|
|
102
|
+
const form = (value: string) => new URLSearchParams({ v: value }).toString().slice(2);
|
|
103
|
+
const authorization = method === "client_secret_basic" ? "Basic " + btoa(form(clientId) + ":" + form(clientSecret)) : undefined;
|
|
104
|
+
return async (token: string): Promise<McpPrincipal> => {
|
|
105
|
+
const body = new URLSearchParams({ token, token_type_hint: "access_token" });
|
|
106
|
+
if (method === "client_secret_post") { body.set("client_id", clientId); body.set("client_secret", clientSecret); }
|
|
107
|
+
let data: Record<string, unknown> | undefined;
|
|
108
|
+
try {
|
|
109
|
+
const response = await oauthJsonRequest(url, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json", ...(authorization ? { Authorization: authorization } : {}) }, body }, timeoutMs);
|
|
110
|
+
if (response.status !== 200) throw unavailable();
|
|
111
|
+
data = response.data;
|
|
112
|
+
} catch { throw unavailable(); }
|
|
113
|
+
if (!data || typeof data.active !== "boolean") throw unavailable();
|
|
114
|
+
if (!data.active || (data.token_type !== undefined && (typeof data.token_type !== "string" || data.token_type.toLowerCase() !== "bearer"))) throw invalid();
|
|
115
|
+
// RFC 7662 permits omitted issuer: the owner-pinned authenticated endpoint
|
|
116
|
+
// establishes it. Audience, subject and expiry are mandatory for this handler.
|
|
117
|
+
// Never substitute client_id for the MCP resource audience.
|
|
118
|
+
const claims = { ...data, iss: data.iss === undefined ? configuration.issuer : data.iss };
|
|
119
|
+
for (const key of ["token", "access_token", "refresh_token", "id_token", "client_secret"]) delete (claims as Record<string, unknown>)[key];
|
|
120
|
+
return principalFor(claims, configuration);
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Resource-server validation using platform crypto or explicit introspection. The caller supplies
|
|
125
|
+
* only a token; issuer, resource, discovery and key destinations are fixed by
|
|
126
|
+
* the owner. No Authorization header is sent during discovery/key fetching.
|
|
127
|
+
* Introspection replaces JWT verification for every request: no fallback and
|
|
128
|
+
* no token-result cache, so provider revocation is checked on the next request. */
|
|
129
|
+
export function createMcpAuthorizer(input: McpAuthorizationConfiguration, introspection?: McpTokenIntrospectionConfiguration) {
|
|
130
|
+
const configuration = mcpAuthorizationConfiguration(input);
|
|
131
|
+
const introspect = introspection === undefined ? undefined : introspector(introspection, configuration);
|
|
132
|
+
const cacheMs = 300_000, retryMs = 30_000;
|
|
133
|
+
let keys: SigningKey[] = [], expiresAt = 0, lastAttempt = -Infinity;
|
|
134
|
+
let pending: Promise<void> | undefined;
|
|
135
|
+
async function refresh(): Promise<void> {
|
|
136
|
+
if (pending) return pending;
|
|
137
|
+
if (Date.now() - lastAttempt < retryMs) return;
|
|
138
|
+
lastAttempt = Date.now();
|
|
139
|
+
pending = (async () => {
|
|
140
|
+
try {
|
|
141
|
+
let jwksUrl = configuration.jwksUrl;
|
|
142
|
+
if (!jwksUrl) {
|
|
143
|
+
const issuer = new URL(configuration.issuer), path = issuer.pathname.replace(/\/$/, "");
|
|
144
|
+
const candidates = [...new Set([
|
|
145
|
+
issuer.origin + "/.well-known/oauth-authorization-server" + path,
|
|
146
|
+
issuer.origin + "/.well-known/openid-configuration" + path,
|
|
147
|
+
issuer.origin + path + "/.well-known/openid-configuration",
|
|
148
|
+
])];
|
|
149
|
+
for (const url of candidates) {
|
|
150
|
+
const response = await oauthJsonRequest(url, {}, 10_000);
|
|
151
|
+
if (response.status === 404) continue;
|
|
152
|
+
if (response.status !== 200 || response.data?.issuer !== configuration.issuer || typeof response.data.jwks_uri !== "string") throw unavailable();
|
|
153
|
+
jwksUrl = endpoint(response.data.jwks_uri, true);
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
if (!jwksUrl) throw unavailable();
|
|
157
|
+
}
|
|
158
|
+
const response = await oauthJsonRequest(jwksUrl, {}, 10_000);
|
|
159
|
+
if (response.status !== 200 || !Array.isArray(response.data?.keys) || response.data.keys.length === 0 || response.data.keys.length > 100) throw unavailable();
|
|
160
|
+
keys = response.data.keys.filter(object) as SigningKey[];
|
|
161
|
+
expiresAt = Date.now() + cacheMs;
|
|
162
|
+
} catch { throw unavailable(); }
|
|
163
|
+
})().finally(() => { pending = undefined; });
|
|
164
|
+
return pending;
|
|
165
|
+
}
|
|
166
|
+
function eligible(header: Record<string, unknown>): SigningKey[] {
|
|
167
|
+
return keys.filter(key => (!header.kid || key.kid === header.kid) && (!key.alg || key.alg === header.alg) &&
|
|
168
|
+
(!key.use || key.use === "sig") && (!key.key_ops || Array.isArray(key.key_ops) && key.key_ops.includes("verify")) && !key.d && !key.k &&
|
|
169
|
+
(header.alg === "RS256" && key.kty === "RSA" || header.alg === "ES256" && key.kty === "EC" && key.crv === "P-256" || header.alg === "EdDSA" && key.kty === "OKP" && key.crv === "Ed25519"));
|
|
170
|
+
}
|
|
171
|
+
async function verifySignature(header: Record<string, unknown>, data: Uint8Array<ArrayBuffer>, signature: Uint8Array<ArrayBuffer>): Promise<boolean> {
|
|
172
|
+
const matches = eligible(header);
|
|
173
|
+
if (matches.length !== 1) return false;
|
|
174
|
+
const key = matches[0]!;
|
|
175
|
+
try {
|
|
176
|
+
const algorithm = header.alg === "RS256" ? { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" } : header.alg === "ES256" ? { name: "ECDSA", namedCurve: "P-256", hash: "SHA-256" } : { name: "Ed25519" };
|
|
177
|
+
if (header.alg === "RS256") {
|
|
178
|
+
if (!key.n || bytes(key.n).byteLength < 256) return false;
|
|
179
|
+
}
|
|
180
|
+
const imported = await crypto.subtle.importKey("jwk", key, algorithm, false, ["verify"]);
|
|
181
|
+
if (header.alg === "RS256" && ((imported.algorithm as { modulusLength?: number }).modulusLength ?? 0) < 2048) return false;
|
|
182
|
+
return await crypto.subtle.verify(algorithm, imported, signature, data);
|
|
183
|
+
} catch { return false; }
|
|
184
|
+
}
|
|
185
|
+
return {
|
|
186
|
+
configuration,
|
|
187
|
+
metadata() { return { resource: configuration.resource, authorization_servers: [configuration.issuer], bearer_methods_supported: ["header"], scopes_supported: [...configuration.scopes!] }; },
|
|
188
|
+
challenge(error?: "invalid_token" | "insufficient_scope") {
|
|
189
|
+
const resource = new URL(configuration.resource);
|
|
190
|
+
const metadata = resource.origin + "/.well-known/oauth-protected-resource" + resource.pathname.replace(/\/$/, "");
|
|
191
|
+
return 'Bearer resource_metadata="' + metadata + '"' + (configuration.scopes!.length ? ', scope="' + configuration.scopes!.join(" ") + '"' : "") + (error ? ', error="' + error + '"' : "");
|
|
192
|
+
},
|
|
193
|
+
async authorize(header: string | null): Promise<McpPrincipal> {
|
|
194
|
+
if (!header || header.length > 16_400 || !/^Bearer [A-Za-z0-9._~+/-]+=*$/i.test(header)) throw invalid();
|
|
195
|
+
const token = header.slice(7), parts = token.split(".");
|
|
196
|
+
if (introspect) return introspect(token);
|
|
197
|
+
if (parts.length !== 3) throw invalid();
|
|
198
|
+
const protectedHeader = decode(parts[0]!), claims = decode(parts[1]!);
|
|
199
|
+
if (!["RS256", "ES256", "EdDSA"].includes(String(protectedHeader.alg)) || protectedHeader.crit !== undefined || protectedHeader.jku !== undefined || protectedHeader.x5u !== undefined ||
|
|
200
|
+
(protectedHeader.typ !== undefined && !["at+jwt", "JWT"].includes(String(protectedHeader.typ))) ||
|
|
201
|
+
(protectedHeader.kid !== undefined && (typeof protectedHeader.kid !== "string" || !protectedHeader.kid || protectedHeader.kid.length > 200))) throw invalid();
|
|
202
|
+
const signed = new TextEncoder().encode(parts[0] + "." + parts[1]), signature = bytes(parts[2]!);
|
|
203
|
+
if (expiresAt <= Date.now()) { await refresh(); if (expiresAt <= Date.now()) throw unavailable(); }
|
|
204
|
+
let verified = await verifySignature(protectedHeader, signed, signature);
|
|
205
|
+
if (!verified && Date.now() - lastAttempt >= retryMs) { await refresh(); verified = await verifySignature(protectedHeader, signed, signature); }
|
|
206
|
+
if (!verified) throw invalid();
|
|
207
|
+
// No bearer token or unverified claims cross into credential resolution.
|
|
208
|
+
return principalFor(claims, configuration);
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
}
|