machine-bridge-mcp 1.0.8 → 1.1.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.
@@ -0,0 +1,246 @@
1
+ import http from "node:http";
2
+ import https from "node:https";
3
+ import { appName } from "./state.mjs";
4
+ import { proxyAgentForHttp } from "./network-proxy.mjs";
5
+
6
+ const MAX_HEALTH_BODY_BYTES = 64 * 1024;
7
+ const DEFAULT_HEALTH_TIMEOUT_MS = 5_000;
8
+ const NON_RETRYABLE_HEALTH_ERRORS = new Set(["missing_worker_url", "invalid_worker_url", "proxy_configuration"]);
9
+ const WORKER_NAME = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
10
+ const WORKERS_DEV_HOST = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.workers\.dev$/;
11
+
12
+ export async function workerHealth(workerUrl, expectedVersion, options = {}) {
13
+ if (!workerUrl) return { ok: false, error: "missing_worker_url" };
14
+ let healthUrl;
15
+ try {
16
+ healthUrl = workerHealthUrl(workerUrl, options.expectedWorkerName);
17
+ } catch {
18
+ return { ok: false, error: "invalid_worker_url" };
19
+ }
20
+
21
+ try {
22
+ const probe = typeof options.probe === "function" ? options.probe : requestAllowedWorkerHealthJson;
23
+ const result = await probe(healthUrl, {
24
+ timeoutMs: options.timeoutMs,
25
+ proxyResolver: options.proxyResolver,
26
+ proxyAgentForUrl: options.proxyAgentForUrl,
27
+ });
28
+ const networkRoute = result.networkRoute || "direct";
29
+ if (result.statusCode !== 200) return { ok: false, error: `HTTP ${result.statusCode}`, networkRoute };
30
+ const body = result.body;
31
+ if (body?.ok !== true || body?.server !== appName) return { ok: false, error: "unexpected_health_response", networkRoute };
32
+ if (body?.version !== expectedVersion) return { ok: false, error: `version_mismatch:${body?.version || "unknown"}!=${expectedVersion}`, networkRoute };
33
+ return { ok: true, version: body.version, networkRoute };
34
+ } catch (error) {
35
+ return {
36
+ ok: false,
37
+ error: workerHealthError(error),
38
+ networkRoute: error?.networkRoute || (hasErrorCode(error, "http_proxy_configuration") ? "invalid-proxy-configuration" : "unknown"),
39
+ };
40
+ }
41
+ }
42
+
43
+ export async function retryWorkerHealth(workerUrl, expectedVersion, attempts, options = {}) {
44
+ const count = Math.max(1, Number.parseInt(String(attempts), 10) || 1);
45
+ const wait = typeof options.wait === "function" ? options.wait : sleep;
46
+ let last = { ok: false, error: "not_checked" };
47
+ for (let index = 0; index < count; index += 1) {
48
+ last = await workerHealth(workerUrl, expectedVersion, options);
49
+ if (last.ok) return last;
50
+ if (!isRetryableWorkerHealthError(last.error)) return last;
51
+ if (index + 1 < count) await wait(1_000 + index * 500);
52
+ }
53
+ return last;
54
+ }
55
+
56
+ export function workerHealthUrl(workerUrl, expectedWorkerName = "") {
57
+ const base = new URL(`${String(workerUrl).replace(/\/+$/, "")}/`);
58
+ const hostname = base.hostname.toLowerCase();
59
+ const expectedName = String(expectedWorkerName || "").toLowerCase();
60
+ if (base.protocol !== "https:" || base.port || base.username || base.password || base.search || base.hash || base.pathname !== "/") {
61
+ throw new Error("Worker URL must be an HTTPS workers.dev origin");
62
+ }
63
+ if (!WORKERS_DEV_HOST.test(hostname)) throw new Error("Worker URL must use a workers.dev hostname");
64
+ if (expectedName && (!WORKER_NAME.test(expectedName) || hostname.split(".")[0] !== expectedName)) {
65
+ throw new Error("Worker URL hostname does not match the recorded Worker name");
66
+ }
67
+ return `https://${hostname}/healthz`;
68
+ }
69
+
70
+ export function isRetryableWorkerHealthError(value) {
71
+ return !NON_RETRYABLE_HEALTH_ERRORS.has(String(value || ""));
72
+ }
73
+
74
+ export function workerHealthRequiresRedeploy(value) {
75
+ const reason = String(value || "");
76
+ return reason.startsWith("version_mismatch:")
77
+ || reason === "unexpected_health_response"
78
+ || reason === "HTTP 404"
79
+ || reason === "HTTP 410";
80
+ }
81
+
82
+ export function workerHealthUserReason(value) {
83
+ const reason = String(value || "");
84
+ if (reason.startsWith("version_mismatch:")) return "deployed version does not match the local package";
85
+ if (/^HTTP \d+$/.test(reason)) return "health endpoint returned an HTTP error";
86
+ if (reason === "unexpected_health_response") return "health endpoint returned an unexpected response";
87
+ if (reason === "timeout") return "health check timed out";
88
+ if (reason === "tls_error") return "TLS validation failed";
89
+ if (reason === "network_error") return "network request failed";
90
+ if (reason === "proxy_configuration") return "HTTP proxy configuration is invalid";
91
+ if (reason === "missing_worker_url") return "Worker URL is missing";
92
+ if (reason === "invalid_worker_url") return "Worker URL is invalid";
93
+ return "health check failed";
94
+ }
95
+
96
+ export function workerHealthError(error) {
97
+ if (hasErrorCode(error, "http_proxy_configuration")) return "proxy_configuration";
98
+ if (hasErrorCode(error, "ETIMEDOUT") || hasErrorCode(error, "ABORT_ERR") || /timeout|timed out|aborted/i.test(errorMessages(error))) return "timeout";
99
+ if (hasTlsError(error)) return "tls_error";
100
+ if (hasNetworkError(error)) return "network_error";
101
+ return "request_failed";
102
+ }
103
+
104
+ export function requestWorkerHealthJson(url, options = {}) {
105
+ return requestJson(new URL(String(url)), {
106
+ timeoutMs: boundedTimeout(options.timeoutMs),
107
+ proxyResolver: options.proxyResolver,
108
+ proxyAgentForUrl: options.proxyAgentForUrl,
109
+ });
110
+ }
111
+
112
+ function requestAllowedWorkerHealthJson(url, options = {}) {
113
+ const target = new URL(String(url));
114
+ if (target.protocol !== "https:" || target.port || target.pathname !== "/healthz" || target.search || target.hash || !WORKERS_DEV_HOST.test(target.hostname)) {
115
+ const error = new Error("health request target is not an allowed workers.dev endpoint");
116
+ error.code = "ERR_INVALID_WORKER_HEALTH_URL";
117
+ throw error;
118
+ }
119
+ return requestJson(target, {
120
+ timeoutMs: boundedTimeout(options.timeoutMs),
121
+ proxyResolver: options.proxyResolver,
122
+ proxyAgentForUrl: options.proxyAgentForUrl,
123
+ });
124
+ }
125
+
126
+ function requestJson(target, options) {
127
+ return new Promise((resolvePromise, rejectPromise) => {
128
+ const selectProxy = typeof options.proxyAgentForUrl === "function" ? options.proxyAgentForUrl : proxyAgentForHttp;
129
+ let proxy;
130
+ try {
131
+ proxy = selectProxy(target.href, options.proxyResolver);
132
+ } catch (error) {
133
+ rejectPromise(withNetworkRoute(error, "invalid-proxy-configuration"));
134
+ return;
135
+ }
136
+ const networkRoute = proxy?.agent ? "proxy" : "direct";
137
+ const transport = target.protocol === "https:" ? https : http;
138
+ const request = transport.request({
139
+ protocol: target.protocol,
140
+ hostname: target.hostname,
141
+ port: target.port || undefined,
142
+ method: "GET",
143
+ path: `${target.pathname}${target.search}`,
144
+ headers: {
145
+ Accept: "application/json",
146
+ "User-Agent": "machine-bridge-mcp-health",
147
+ },
148
+ ...(proxy?.agent ? { agent: proxy.agent } : {}),
149
+ });
150
+ let settled = false;
151
+ const finish = (callback) => {
152
+ if (settled) return;
153
+ settled = true;
154
+ clearTimeout(timer);
155
+ callback();
156
+ };
157
+ const rejectWithRoute = (error) => finish(() => rejectPromise(withNetworkRoute(error, networkRoute)));
158
+ const timer = setTimeout(() => {
159
+ const error = new Error(`health request timed out after ${options.timeoutMs}ms`);
160
+ error.code = "ETIMEDOUT";
161
+ request.destroy(error);
162
+ }, options.timeoutMs);
163
+ timer.unref?.();
164
+
165
+ request.on("response", (response) => {
166
+ const statusCode = Number(response.statusCode || 0);
167
+ const chunks = [];
168
+ let bytes = 0;
169
+ response.on("data", (chunk) => {
170
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
171
+ bytes += buffer.length;
172
+ if (bytes > MAX_HEALTH_BODY_BYTES) {
173
+ const error = new Error("health response exceeded the size limit");
174
+ error.code = "ERR_RESPONSE_TOO_LARGE";
175
+ response.destroy(error);
176
+ return;
177
+ }
178
+ chunks.push(buffer);
179
+ });
180
+ response.on("error", rejectWithRoute);
181
+ response.on("end", () => {
182
+ if (settled) return;
183
+ let body = null;
184
+ try {
185
+ body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
186
+ } catch {
187
+ // Invalid JSON is reported through the normal unexpected-health-response classification.
188
+ }
189
+ finish(() => resolvePromise({ statusCode, body, networkRoute }));
190
+ });
191
+ });
192
+ request.on("error", rejectWithRoute);
193
+ request.end();
194
+ });
195
+ }
196
+
197
+ function withNetworkRoute(error, networkRoute) {
198
+ const value = error instanceof Error ? error : new Error(String(error));
199
+ if (!value.networkRoute) value.networkRoute = networkRoute;
200
+ return value;
201
+ }
202
+
203
+ function hasTlsError(error) {
204
+ const codes = errorCodes(error);
205
+ if (codes.some((code) => code.startsWith("ERR_TLS_") || code.startsWith("CERT_") || code.includes("CERTIFICATE") || code === "UNABLE_TO_VERIFY_LEAF_SIGNATURE" || code === "DEPTH_ZERO_SELF_SIGNED_CERT")) return true;
206
+ return /certificate|\bTLS\b|\bSSL\b/i.test(errorMessages(error));
207
+ }
208
+
209
+ function hasNetworkError(error) {
210
+ const networkCodes = new Set([
211
+ "ECONNABORTED", "ECONNREFUSED", "ECONNRESET", "EHOSTUNREACH", "ENETDOWN", "ENETRESET",
212
+ "ENETUNREACH", "ENOTFOUND", "EAI_AGAIN", "EPIPE", "UND_ERR_CONNECT_TIMEOUT",
213
+ ]);
214
+ if (errorCodes(error).some((code) => networkCodes.has(code))) return true;
215
+ return /fetch failed|network|socket hang up|connection reset|connection refused/i.test(errorMessages(error));
216
+ }
217
+
218
+ function hasErrorCode(error, expected) {
219
+ return errorCodes(error).includes(expected);
220
+ }
221
+
222
+ function errorCodes(error) {
223
+ const codes = [];
224
+ for (let current = error; current && typeof current === "object"; current = current.cause) {
225
+ if (typeof current.code === "string") codes.push(current.code);
226
+ }
227
+ return codes;
228
+ }
229
+
230
+ function errorMessages(error) {
231
+ const messages = [];
232
+ for (let current = error; current; current = current?.cause) {
233
+ messages.push(String(current?.message || current));
234
+ if (typeof current !== "object") break;
235
+ }
236
+ return messages.join(" ");
237
+ }
238
+
239
+ function boundedTimeout(value) {
240
+ const numeric = Number(value);
241
+ return Number.isFinite(numeric) && numeric > 0 ? Math.min(60_000, Math.floor(numeric)) : DEFAULT_HEALTH_TIMEOUT_MS;
242
+ }
243
+
244
+ function sleep(ms) {
245
+ return new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
246
+ }
@@ -9,10 +9,11 @@ import { sanitizeDaemonPolicy, sanitizeDaemonTools, type DaemonPolicy } from "./
9
9
  import { accountRoleAllowsTool, accountRoleToolNames, type AccountRole } from "./access";
10
10
  import { accountAuthoritySnapshot, decorateProjectOverview, describeDaemonCeiling } from "./authority";
11
11
  import { accountAdminAuthorized, handleAccountAdminOperation } from "./account-admin";
12
+ import { exchangeOAuthToken } from "./oauth-tokens";
12
13
  import { serverInfoTool, workspaceTools } from "./tool-catalog";
13
14
  import {
14
- AUTH_BLOCK_SECONDS, accountByName, authorizationIdentity, emptyOAuthStore, isCurrentOAuthStore,
15
- pkceS256, pruneAuthFailures, pruneClientRecordByExpiry, pruneRecordByExpiry, randomToken,
15
+ AUTH_BLOCK_SECONDS, OFFLINE_ACCESS_SCOPE, accountByName, authorizationIdentity, emptyOAuthStore,
16
+ isCurrentOAuthStore, pruneAuthFailures, pruneClientRecordByExpiry, pruneRecordByExpiry, randomToken,
16
17
  recordAuthorizationFailure, safeEqual, sha256Hex, validateAuthorizationRequest, verifyAccountPassword,
17
18
  type OAuthClient, type OAuthStore, type ValidatedAuthorization,
18
19
  } from "./oauth-state";
@@ -23,13 +24,12 @@ import {
23
24
  } from "./http";
24
25
 
25
26
  const SERVER_NAME = String(serverMetadata.name);
26
- const SERVER_VERSION = "1.0.8";
27
+ const SERVER_VERSION = "1.1.1";
27
28
  const MCP_PROTOCOL_VERSION = String(serverMetadata.protocolVersion);
28
29
  const MCP_SUPPORTED_PROTOCOL_VERSIONS = serverMetadata.supportedProtocolVersions.map((value) => String(value));
29
30
  const JSONRPC_VERSION = "2.0";
30
31
  const DEFAULT_MAX_BODY_BYTES = 8 * 1024 * 1024;
31
32
  const MAX_BODY_BYTES = 16 * 1024 * 1024;
32
- const TOKEN_TTL_SECONDS = 60 * 60 * 24 * 30;
33
33
  const OAUTH_BODY_LIMIT_BYTES = 64 * 1024;
34
34
  const MAX_PENDING_CALLS = 32;
35
35
  const MAX_DAEMON_MESSAGE_BYTES = 8 * 1024 * 1024;
@@ -38,10 +38,8 @@ const OAUTH_UNUSED_CLIENT_TTL_SECONDS = 60 * 60;
38
38
  const MAX_OAUTH_CLIENTS = 50;
39
39
  const MAX_OAUTH_CLIENTS_PER_IDENTITY = 5;
40
40
  const OAUTH_CLIENT_IDLE_TTL_SECONDS = 60 * 60 * 24 * 90;
41
- const MAX_TOKENS_PER_CLIENT = 20;
42
41
  const MAX_CODES_PER_CLIENT = 10;
43
42
  const MAX_OAUTH_CODES = 200;
44
- const MAX_OAUTH_TOKENS = 500;
45
43
  const MAX_AUTH_FAILURE_IDENTITIES = 200;
46
44
  const AUTHORIZATION_FIELDS = new Set(["response_type", "client_id", "redirect_uri", "code_challenge", "code_challenge_method", "scope", "resource", "state"]);
47
45
 
@@ -591,10 +589,10 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
591
589
  token_endpoint: `${base}/oauth/token`,
592
590
  registration_endpoint: `${base}/oauth/register`,
593
591
  response_types_supported: ["code"],
594
- grant_types_supported: ["authorization_code"],
592
+ grant_types_supported: ["authorization_code", "refresh_token"],
595
593
  token_endpoint_auth_methods_supported: ["none"],
596
594
  code_challenge_methods_supported: ["S256"],
597
- scopes_supported: [SERVER_NAME],
595
+ scopes_supported: [SERVER_NAME, OFFLINE_ACCESS_SCOPE],
598
596
  };
599
597
  }
600
598
 
@@ -602,7 +600,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
602
600
  return {
603
601
  resource: `${base}/mcp`,
604
602
  authorization_servers: [base],
605
- scopes_supported: [SERVER_NAME],
603
+ scopes_supported: [SERVER_NAME, OFFLINE_ACCESS_SCOPE],
606
604
  bearer_methods_supported: ["header"],
607
605
  resource_name: SERVER_NAME,
608
606
  };
@@ -646,7 +644,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
646
644
  delete client.registration_identity;
647
645
  changed = true;
648
646
  }
649
- const ttl = client.last_used_at === client.created_at ? OAUTH_UNUSED_CLIENT_TTL_SECONDS : OAUTH_CLIENT_IDLE_TTL_SECONDS;
647
+ const ttl = client.has_been_authorized === false ? OAUTH_UNUSED_CLIENT_TTL_SECONDS : OAUTH_CLIENT_IDLE_TTL_SECONDS;
650
648
  if (!activeClientIds.has(clientId) && client.last_used_at + ttl <= now) {
651
649
  delete store.clients[clientId];
652
650
  changed = true;
@@ -689,9 +687,11 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
689
687
  return this.withOAuthLock(async () => {
690
688
  const store = await this.oauthStore();
691
689
  const registrationIdentity = await authorizationIdentity(request, this.identityKey());
692
- const identityClientCount = Object.values(store.clients).filter((client) => client.registration_identity === registrationIdentity).length;
693
- if (identityClientCount >= MAX_OAUTH_CLIENTS_PER_IDENTITY) {
694
- return json({ error: "too_many_requests", error_description: "client registration limit reached for this source" }, 429);
690
+ const pendingIdentityClientCount = Object.values(store.clients).filter((client) => (
691
+ client.registration_identity === registrationIdentity && client.has_been_authorized === false
692
+ )).length;
693
+ if (pendingIdentityClientCount >= MAX_OAUTH_CLIENTS_PER_IDENTITY) {
694
+ return json({ error: "too_many_requests", error_description: "pending client registration limit reached for this source" }, 429);
695
695
  }
696
696
  if (Object.keys(store.clients).length >= MAX_OAUTH_CLIENTS) {
697
697
  return json({ error: "temporarily_unavailable", error_description: "client registry is full; remove stale state or retry after inactive clients expire" }, 503);
@@ -703,6 +703,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
703
703
  redirect_uris: normalized,
704
704
  created_at: now,
705
705
  last_used_at: now,
706
+ has_been_authorized: false,
706
707
  registration_identity: registrationIdentity,
707
708
  };
708
709
  store.clients[client.client_id] = client;
@@ -711,7 +712,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
711
712
  client_id: client.client_id,
712
713
  client_name: client.client_name,
713
714
  redirect_uris: client.redirect_uris,
714
- grant_types: ["authorization_code"],
715
+ grant_types: ["authorization_code", "refresh_token"],
715
716
  response_types: ["code"],
716
717
  token_endpoint_auth_method: "none",
717
718
  client_id_issued_at: client.created_at,
@@ -807,6 +808,7 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
807
808
  }
808
809
  delete store.auth_failures[identity];
809
810
  client.last_used_at = now;
811
+ client.has_been_authorized = true;
810
812
 
811
813
  const code = randomToken("mcp_code");
812
814
  const redirectLocation = authorizationRedirectLocation(redirectUri, code, state);
@@ -829,45 +831,13 @@ export class BridgeRoom extends DurableObject<BridgeEnv> {
829
831
  });
830
832
  }
831
833
 
832
- private async exchangeToken(request: Request, base: string): Promise<Response> {
833
- const body = await parseRequestBody(request, OAUTH_BODY_LIMIT_BYTES);
834
- if (String(body.grant_type ?? "") !== "authorization_code") return json({ error: "unsupported_grant_type" }, 400);
835
-
836
- const code = String(body.code ?? "");
837
- const verifier = String(body.code_verifier ?? "");
838
- if (!/^[A-Za-z0-9._~-]{43,128}$/.test(verifier)) return json({ error: "invalid_grant", error_description: "invalid code_verifier" }, 400);
839
- return this.withOAuthLock(async () => {
840
- const store = await this.oauthStore();
841
- const record = store.codes[code];
842
- if (!record) return json({ error: "invalid_grant" }, 400);
843
- if (String(body.client_id ?? "") !== record.client_id || String(body.redirect_uri ?? "") !== record.redirect_uri) {
844
- return json({ error: "invalid_grant", error_description: "client or redirect mismatch" }, 400);
845
- }
846
- if (String(body.resource ?? record.resource) !== record.resource) {
847
- return json({ error: "invalid_target", error_description: "resource mismatch" }, 400);
848
- }
849
- if (!(await safeEqual(await pkceS256(verifier), record.code_challenge))) {
850
- return json({ error: "invalid_grant", error_description: "invalid code_verifier" }, 400);
851
- }
852
-
853
- const tokenVersion = this.env.OAUTH_TOKEN_VERSION ?? "";
854
- if (!tokenVersion) return json({ error: "server_error", error_description: "OAuth token version is not configured" }, 503);
855
- delete store.codes[code];
856
- const accessToken = randomToken("mcp_at");
857
- store.tokens[`sha256:${await sha256Hex(accessToken)}`] = {
858
- client_id: record.client_id,
859
- account_id: record.account_id,
860
- account_version: record.account_version,
861
- role: record.role,
862
- scope: record.scope,
863
- resource: `${base}/mcp`,
864
- version: tokenVersion,
865
- expires_at: Math.floor(Date.now() / 1000) + TOKEN_TTL_SECONDS,
866
- };
867
- pruneClientRecordByExpiry(store.tokens, record.client_id, MAX_TOKENS_PER_CLIENT);
868
- pruneRecordByExpiry(store.tokens, MAX_OAUTH_TOKENS);
869
- await this.ctx.storage.put("oauth", store);
870
- return json({ access_token: accessToken, token_type: "Bearer", expires_in: TOKEN_TTL_SECONDS, scope: record.scope });
834
+ private exchangeToken(request: Request, base: string): Promise<Response> {
835
+ return exchangeOAuthToken(request, base, {
836
+ storage: this.ctx.storage,
837
+ tokenVersion: this.env.OAUTH_TOKEN_VERSION ?? "",
838
+ serverName: SERVER_NAME,
839
+ loadOAuthStore: () => this.oauthStore(),
840
+ withLock: (callback) => this.withOAuthLock(callback),
871
841
  });
872
842
  }
873
843
 
@@ -1,6 +1,8 @@
1
1
  import { normalizeAccountRole, type AccountRole } from "./access";
2
2
 
3
3
  const OAUTH_STORE_SCHEMA_VERSION = 1;
4
+ const OAUTH_REFRESH_STORE_SCHEMA_VERSION = 1;
5
+ export const OFFLINE_ACCESS_SCOPE = "offline_access";
4
6
  const PASSWORD_TOKEN_PATTERN = /^[a-z][a-z0-9_]{2,31}_[A-Za-z0-9_-]{43}$/;
5
7
 
6
8
  export interface AccountRecord {
@@ -22,6 +24,7 @@ export interface OAuthClient {
22
24
  redirect_uris: string[];
23
25
  created_at: number;
24
26
  last_used_at: number;
27
+ has_been_authorized?: boolean;
25
28
  registration_identity?: string;
26
29
  }
27
30
 
@@ -48,6 +51,13 @@ export interface OAuthToken {
48
51
  expires_at: number;
49
52
  }
50
53
 
54
+ export type OAuthRefreshToken = OAuthToken;
55
+
56
+ export interface OAuthRefreshStore {
57
+ schema_version: number;
58
+ tokens: Record<string, OAuthRefreshToken>;
59
+ }
60
+
51
61
  export interface OAuthFailure {
52
62
  count: number;
53
63
  window_started: number;
@@ -89,6 +99,13 @@ export function emptyOAuthStore(): OAuthStore {
89
99
  };
90
100
  }
91
101
 
102
+ export function emptyOAuthRefreshStore(): OAuthRefreshStore {
103
+ return {
104
+ schema_version: OAUTH_REFRESH_STORE_SCHEMA_VERSION,
105
+ tokens: {},
106
+ };
107
+ }
108
+
92
109
  export function isCurrentOAuthStore(value: unknown): value is OAuthStore {
93
110
  if (!value || typeof value !== "object" || Array.isArray(value)) return false;
94
111
  const store = value as Partial<OAuthStore>;
@@ -100,6 +117,24 @@ export function isCurrentOAuthStore(value: unknown): value is OAuthStore {
100
117
  && isRecord(store.auth_failures);
101
118
  }
102
119
 
120
+ export function isCurrentOAuthRefreshStore(value: unknown): value is OAuthRefreshStore {
121
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
122
+ const store = value as Partial<OAuthRefreshStore>;
123
+ return store.schema_version === OAUTH_REFRESH_STORE_SCHEMA_VERSION && isRecord(store.tokens);
124
+ }
125
+
126
+ export function normalizeOAuthScope(value: unknown, serverName: string): string | null {
127
+ const raw = value === undefined ? serverName : String(value).trim();
128
+ if (!raw) return null;
129
+ const requested = raw.split(/\s+/);
130
+ const scopes = new Set(requested);
131
+ if (scopes.size !== requested.length || !scopes.has(serverName)) return null;
132
+ for (const scope of scopes) {
133
+ if (scope !== serverName && scope !== OFFLINE_ACCESS_SCOPE) return null;
134
+ }
135
+ return scopes.has(OFFLINE_ACCESS_SCOPE) ? `${serverName} ${OFFLINE_ACCESS_SCOPE}` : serverName;
136
+ }
137
+
103
138
  export function validateAuthorizationRequest(
104
139
  body: Record<string, unknown>,
105
140
  base: string,
@@ -112,12 +147,12 @@ export function validateAuthorizationRequest(
112
147
  const codeChallenge = String(body.code_challenge ?? "");
113
148
  const codeChallengeMethod = String(body.code_challenge_method ?? "");
114
149
  const requestedResource = String(body.resource ?? `${base}/mcp`);
115
- const scope = String(body.scope ?? serverName).trim();
150
+ const scope = normalizeOAuthScope(body.scope, serverName);
116
151
  const state = body.state === undefined ? "" : typeof body.state === "string" ? body.state : "";
117
152
 
118
153
  if (responseType !== "code") return { error: "response_type must be code.", status: 400 };
119
154
  if (requestedResource !== `${base}/mcp`) return { error: "resource mismatch.", status: 400 };
120
- if (scope !== serverName) return { error: "unsupported scope.", status: 400 };
155
+ if (!scope) return { error: "unsupported scope.", status: 400 };
121
156
  if (body.state !== undefined && typeof body.state !== "string") return { error: "state must be a string.", status: 400 };
122
157
  if (state.length > 1024) return { error: "state is too long.", status: 400 };
123
158
  if (codeChallengeMethod !== "S256" || !/^[A-Za-z0-9_-]{43}$/.test(codeChallenge)) {