From 098d2acdf5f9c8f4f68ed19023f96295dd97c3a6 Mon Sep 17 00:00:00 2001 From: wcf778 <79058088+wcf778@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:41:54 +0800 Subject: [PATCH 1/2] fix(oauth): support exact resource aliases --- docs/configuration.md | 7 +++++++ schema/v1/devspace.schema.json | 8 ++++++++ src/config-schema.ts | 1 + src/config.test.ts | 5 +++++ src/config.ts | 1 + src/oauth-provider.ts | 25 +++++++++++++++++++++---- src/oauth-store.test.ts | 25 +++++++++++++++++++++---- src/server.ts | 4 ++-- 8 files changed, 66 insertions(+), 10 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 6a6f607bd..03ac34c31 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -68,6 +68,7 @@ Run `devspace init` to create both files. `devspace config set publicBaseUrl "accessTokenTtlSeconds": 3600, "refreshTokenTtlSeconds": 2592000, "scopes": ["devspace"], + "allowedResourceUrls": [], "allowedRedirectHosts": ["chatgpt.com", "localhost", "127.0.0.1"], }, } @@ -77,6 +78,12 @@ Omitted sections and keys use the defaults shown above. An empty `workspaces.allowedRoots` uses the current working directory. Unknown keys are rejected so spelling mistakes cannot silently alter behavior. +`oauth.allowedResourceUrls` accepts exact alternate MCP resource URLs for +clients that connect through a resource alias, such as a secure MCP tunnel. +The normal `server.publicBaseUrl` `/mcp` resource remains allowed automatically. +Configure the complete alias URL, not a hostname or origin; aliases do not +change OAuth discovery URLs or proxy routing. + ## Tool modes and UI `tools.mode` accepts two values: diff --git a/schema/v1/devspace.schema.json b/schema/v1/devspace.schema.json index e7c18466e..22dd83452 100644 --- a/schema/v1/devspace.schema.json +++ b/schema/v1/devspace.schema.json @@ -277,6 +277,14 @@ "minLength": 1 } }, + "allowedResourceUrls": { + "default": [], + "type": "array", + "items": { + "type": "string", + "format": "uri" + } + }, "allowedRedirectHosts": { "default": [ "chatgpt.com", diff --git a/src/config-schema.ts b/src/config-schema.ts index c30bb3612..d64ec78f4 100644 --- a/src/config-schema.ts +++ b/src/config-schema.ts @@ -54,6 +54,7 @@ const oauthConfigSchema = z.object({ accessTokenTtlSeconds: z.number().int().positive().default(60 * 60), refreshTokenTtlSeconds: z.number().int().positive().default(30 * 24 * 60 * 60), scopes: z.array(z.string().trim().min(1)).min(1).default(["devspace"]), + allowedResourceUrls: z.array(z.string().trim().url()).default([]), allowedRedirectHosts: z.array(z.string().trim().min(1)).min(1).default([ "chatgpt.com", "localhost", diff --git a/src/config.test.ts b/src/config.test.ts index 5fd24b490..71f746c48 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -23,6 +23,7 @@ try { assert.equal(defaults.skillsEnabled, true); assert.equal(defaults.artifactsEnabled, false); assert.deepEqual(defaults.subagents, { enabled: false, providers: [] }); + assert.deepEqual(defaults.oauth.allowedResourceUrls, []); assert.deepEqual(defaults.logging, { level: "info", format: "json", @@ -67,6 +68,7 @@ try { accessTokenTtlSeconds: 120, refreshTokenTtlSeconds: 240, scopes: ["devspace", "admin"], + allowedResourceUrls: ["https://tunnel.example.com/v1/mcp/tunnel_123"], allowedRedirectHosts: ["chatgpt.com", "example.com"], }, }, env); @@ -99,6 +101,9 @@ try { assert.equal(configured.oauth.ownerToken, "persisted-owner-token-long-enough"); assert.equal(configured.oauth.accessTokenTtlSeconds, 120); assert.deepEqual(configured.oauth.scopes, ["devspace", "admin"]); + assert.deepEqual(configured.oauth.allowedResourceUrls, [ + "https://tunnel.example.com/v1/mcp/tunnel_123", + ]); assert.deepEqual(configured.logging, { level: "debug", format: "pretty", diff --git a/src/config.ts b/src/config.ts index e53305268..34fcdfc25 100644 --- a/src/config.ts +++ b/src/config.ts @@ -59,6 +59,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { accessTokenTtlSeconds: stored.oauth.accessTokenTtlSeconds, refreshTokenTtlSeconds: stored.oauth.refreshTokenTtlSeconds, scopes: stored.oauth.scopes, + allowedResourceUrls: stored.oauth.allowedResourceUrls, allowedRedirectHosts: stored.oauth.allowedRedirectHosts, }, allowedRoots: normalizePaths(stored.workspaces.allowedRoots, [process.cwd()]), diff --git a/src/oauth-provider.ts b/src/oauth-provider.ts index e65037884..e122d131a 100644 --- a/src/oauth-provider.ts +++ b/src/oauth-provider.ts @@ -17,6 +17,7 @@ export interface OAuthConfig { accessTokenTtlSeconds: number; refreshTokenTtlSeconds: number; scopes: string[]; + allowedResourceUrls: string[]; allowedRedirectHosts: string[]; } @@ -116,6 +117,7 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { private readonly codes = new Map(); private readonly oauthStore: SqliteOAuthStore; private readonly resourceServerUrl: URL; + private readonly allowedResourceUrls: Set; constructor( private readonly config: OAuthConfig, @@ -123,6 +125,9 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { stateDir: string, ) { this.resourceServerUrl = resourceUrlFromServerUrl(resourceServerUrl); + this.allowedResourceUrls = new Set( + config.allowedResourceUrls.map((url) => resourceUrlFromServerUrl(url).href), + ); this.oauthStore = new SqliteOAuthStore(stateDir); this.clientsStore = new SqliteOAuthClientsStore(this.oauthStore, config.allowedRedirectHosts); } @@ -132,7 +137,7 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { params: AuthorizationParams, res: Response, ): Promise { - if (!params.resource || !checkResourceAllowed({ requestedResource: params.resource, configuredResource: this.resourceServerUrl })) { + if (!params.resource || !this.isResourceAllowed(params.resource)) { throw new InvalidRequestError("Invalid or missing OAuth resource"); } if (!requestedScopesAllowed(params.scopes ?? [], this.config.scopes)) { @@ -199,7 +204,7 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { if (redirectUri && redirectUri !== record.params.redirectUri) { throw new InvalidGrantError("redirect_uri does not match the authorization request"); } - if (resource && !checkResourceAllowed({ requestedResource: resource, configuredResource: this.resourceServerUrl })) { + if (resource && (!record.params.resource || !sameResource(resource, record.params.resource))) { throw new InvalidGrantError("Invalid resource"); } @@ -218,7 +223,8 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { if (!record || record.clientId !== client.client_id || record.expiresAt < Math.floor(Date.now() / 1000)) { throw new InvalidGrantError("Invalid refresh token"); } - if (resource && !checkResourceAllowed({ requestedResource: resource, configuredResource: this.resourceServerUrl })) { + const recordedResource = record.resource ? new URL(record.resource) : undefined; + if (resource && (!recordedResource || !sameResource(resource, recordedResource))) { throw new InvalidGrantError("Invalid resource"); } @@ -230,7 +236,7 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { return this.issueTokens( client.client_id, requestedScopes, - resource ?? (record.resource ? new URL(record.resource) : undefined), + resource ?? recordedResource, refreshTokenHash, ); } @@ -260,6 +266,13 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { this.oauthStore.close(); } + isResourceAllowed(resource: URL): boolean { + return checkResourceAllowed({ + requestedResource: resource, + configuredResource: this.resourceServerUrl, + }) || this.allowedResourceUrls.has(resourceUrlFromServerUrl(resource).href); + } + private validCodeRecord( client: OAuthClientInformationFull, authorizationCode: string, @@ -335,3 +348,7 @@ function authorizationFormFields( function hashToken(token: string): string { return createHash("sha256").update(token).digest("base64url"); } + +function sameResource(left: URL, right: URL): boolean { + return resourceUrlFromServerUrl(left).href === resourceUrlFromServerUrl(right).href; +} diff --git a/src/oauth-store.test.ts b/src/oauth-store.test.ts index 225f9fdf5..f1c2a33bd 100644 --- a/src/oauth-store.test.ts +++ b/src/oauth-store.test.ts @@ -14,9 +14,11 @@ const oauthConfig = { accessTokenTtlSeconds: 3600, refreshTokenTtlSeconds: 2592000, scopes: ["devspace"], + allowedResourceUrls: ["https://tunnel.example.com/v1/mcp/tunnel_123"], allowedRedirectHosts: ["chatgpt.com"], }; const mcpUrl = new URL("https://agent.example.com/mcp"); +const tunnelUrl = new URL(oauthConfig.allowedResourceUrls[0]!); const redirectUri = "https://chatgpt.com/connector_platform_oauth_redirect"; try { @@ -188,6 +190,11 @@ function testTransactionalTokenRotation(stateDir: string): void { async function testProviderRestartRotationAndRevocation(stateDir: string): Promise { const firstProvider = new SingleUserOAuthProvider(oauthConfig, mcpUrl, stateDir); + assert.equal(firstProvider.isResourceAllowed(mcpUrl), true); + assert.equal(firstProvider.isResourceAllowed(new URL(`${mcpUrl.href}/session`)), true); + assert.equal(firstProvider.isResourceAllowed(tunnelUrl), true); + assert.equal(firstProvider.isResourceAllowed(new URL(`${tunnelUrl.href}/session`)), false); + assert.equal(firstProvider.isResourceAllowed(new URL(`${tunnelUrl.href}?other=1`)), false); const client = await firstProvider.clientsStore.registerClient?.({ redirect_uris: [redirectUri], client_name: "ChatGPT", @@ -201,16 +208,20 @@ async function testProviderRestartRotationAndRevocation(stateDir: string): Promi redirectUri, codeChallenge: "challenge", scopes: ["devspace"], - resource: mcpUrl, + resource: tunnelUrl, }, expiresAtMs: Date.now() + 60_000, }); + await assert.rejects( + firstProvider.exchangeAuthorizationCode(client, code, undefined, redirectUri, mcpUrl), + InvalidGrantError, + ); const issued = await firstProvider.exchangeAuthorizationCode( client, code, undefined, redirectUri, - mcpUrl, + tunnelUrl, ); assert.ok(issued.refresh_token); firstProvider.close(); @@ -219,18 +230,24 @@ async function testProviderRestartRotationAndRevocation(stateDir: string): Promi try { const verified = await secondProvider.verifyAccessToken(issued.access_token); assert.equal(verified.clientId, client.client_id); + assert.equal(verified.resource?.href, tunnelUrl.href); + + await assert.rejects( + secondProvider.exchangeRefreshToken(client, issued.refresh_token, ["devspace"], mcpUrl), + InvalidGrantError, + ); const refreshed = await secondProvider.exchangeRefreshToken( client, issued.refresh_token, ["devspace"], - mcpUrl, + tunnelUrl, ); assert.ok(refreshed.refresh_token); assert.notEqual(refreshed.access_token, issued.access_token); await assert.rejects( - secondProvider.exchangeRefreshToken(client, issued.refresh_token, ["devspace"], mcpUrl), + secondProvider.exchangeRefreshToken(client, issued.refresh_token, ["devspace"], tunnelUrl), InvalidGrantError, ); diff --git a/src/server.ts b/src/server.ts index 9e7ded7fd..5f92b5cdd 100644 --- a/src/server.ts +++ b/src/server.ts @@ -8,7 +8,7 @@ import { mcpAuthRouter, getOAuthProtectedResourceMetadataUrl } from "@modelconte import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; -import { checkResourceAllowed, resourceUrlFromServerUrl } from "@modelcontextprotocol/sdk/shared/auth-utils.js"; +import { resourceUrlFromServerUrl } from "@modelcontextprotocol/sdk/shared/auth-utils.js"; import { registerAppResource, registerAppTool, @@ -842,7 +842,7 @@ export function createServer( }); if (res.headersSent) return; - if (!req.auth?.resource || !checkResourceAllowed({ requestedResource: req.auth.resource, configuredResource: resourceServerUrl })) { + if (!req.auth?.resource || !oauthProvider.isResourceAllowed(req.auth.resource)) { logEvent(config.logging, "warn", "auth_denied", { requestId, method: req.method, From f7fbc1bfb6586b81a68cc9fcb5b242491c47df84 Mon Sep 17 00:00:00 2001 From: wcf778 <79058088+wcf778@users.noreply.github.com> Date: Tue, 8 Sep 2026 13:59:57 +0800 Subject: [PATCH 2/2] fix: enforce current OAuth resource policy on refresh --- docs/configuration.md | 4 ++ schema/v1/devspace.schema.json | 3 +- src/config-schema.test.ts | 21 ++++++++++ src/config-schema.ts | 8 +++- src/oauth-provider.ts | 5 ++- src/oauth-store.test.ts | 47 ++++++++++++++++++++-- src/server-oauth.test.ts | 71 ++++++++++++++++++++++++++++++++++ 7 files changed, 152 insertions(+), 7 deletions(-) create mode 100644 src/server-oauth.test.ts diff --git a/docs/configuration.md b/docs/configuration.md index 03ac34c31..b7939353a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -83,6 +83,10 @@ clients that connect through a resource alias, such as a secure MCP tunnel. The normal `server.publicBaseUrl` `/mcp` resource remains allowed automatically. Configure the complete alias URL, not a hostname or origin; aliases do not change OAuth discovery URLs or proxy routing. +Resource URLs must use HTTPS; HTTP is allowed only for `localhost`, `127.0.0.1`, +or `[::1]`, with optional ports. Restart DevSpace after changing +`oauth.allowedResourceUrls`: the provider reads this policy at server creation. +After restarting, refresh tokens for removed aliases can no longer mint tokens. ## Tool modes and UI diff --git a/schema/v1/devspace.schema.json b/schema/v1/devspace.schema.json index 22dd83452..dbdb66acd 100644 --- a/schema/v1/devspace.schema.json +++ b/schema/v1/devspace.schema.json @@ -282,7 +282,8 @@ "type": "array", "items": { "type": "string", - "format": "uri" + "format": "uri", + "description": "Exact resource URL: HTTPS, or HTTP on localhost, 127.0.0.1, or [::1]." } }, "allowedRedirectHosts": { diff --git a/src/config-schema.test.ts b/src/config-schema.test.ts index 1b6ce9883..4149f5d8e 100644 --- a/src/config-schema.test.ts +++ b/src/config-schema.test.ts @@ -10,6 +10,27 @@ assert.throws( /Unrecognized key/, ); +for (const url of [ + "https://tunnel.example.com/v1/mcp/tunnel_123", + "http://localhost:7676/mcp", + "http://127.0.0.1:7676/mcp", + "http://[::1]:7676/mcp", +]) { + assert.doesNotThrow(() => devspaceConfigSchema.parse({ + configVersion: 1, oauth: { allowedResourceUrls: [url] }, + }), url); +} +for (const url of [ + "http://tunnel.example.com/mcp", "http://192.168.1.1/mcp", + "http://localhost.example.com/mcp", "http://127.0.0.1.example.com/mcp", + "http://[::2]/mcp", "ftp://localhost/mcp", "file:///mcp", + "custom://tunnel.example.com/mcp", "not-a-url", +]) { + assert.equal(devspaceConfigSchema.safeParse({ + configVersion: 1, oauth: { allowedResourceUrls: [url] }, + }).success, false, url); +} + const generatedSchema = `${JSON.stringify(devspaceConfigJsonSchema(), null, 2)}\n`; const committedSchema = readFileSync( new URL("../schema/v1/devspace.schema.json", import.meta.url), diff --git a/src/config-schema.ts b/src/config-schema.ts index d64ec78f4..52be2e351 100644 --- a/src/config-schema.ts +++ b/src/config-schema.ts @@ -54,7 +54,13 @@ const oauthConfigSchema = z.object({ accessTokenTtlSeconds: z.number().int().positive().default(60 * 60), refreshTokenTtlSeconds: z.number().int().positive().default(30 * 24 * 60 * 60), scopes: z.array(z.string().trim().min(1)).min(1).default(["devspace"]), - allowedResourceUrls: z.array(z.string().trim().url()).default([]), + allowedResourceUrls: z.array(z.string().trim().url().refine((value) => { + const url = URL.parse(value); + return url !== null && (url.protocol === "https:" + || (url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname))); + }, "Resource URLs must use HTTPS, or HTTP on localhost, 127.0.0.1, or [::1]") + .describe("Exact resource URL: HTTPS, or HTTP on localhost, 127.0.0.1, or [::1].")) + .default([]), allowedRedirectHosts: z.array(z.string().trim().min(1)).min(1).default([ "chatgpt.com", "localhost", diff --git a/src/oauth-provider.ts b/src/oauth-provider.ts index e122d131a..4fef99550 100644 --- a/src/oauth-provider.ts +++ b/src/oauth-provider.ts @@ -224,7 +224,10 @@ export class SingleUserOAuthProvider implements OAuthServerProvider { throw new InvalidGrantError("Invalid refresh token"); } const recordedResource = record.resource ? new URL(record.resource) : undefined; - if (resource && (!recordedResource || !sameResource(resource, recordedResource))) { + if (!recordedResource || !this.isResourceAllowed(recordedResource)) { + throw new InvalidGrantError("Invalid resource"); + } + if (resource && !sameResource(resource, recordedResource)) { throw new InvalidGrantError("Invalid resource"); } diff --git a/src/oauth-store.test.ts b/src/oauth-store.test.ts index f1c2a33bd..b6cc7993d 100644 --- a/src/oauth-store.test.ts +++ b/src/oauth-store.test.ts @@ -27,6 +27,7 @@ try { testExpiredTokenCleanup(join(root, "expiration")); testTransactionalTokenRotation(join(root, "rotation")); await testProviderRestartRotationAndRevocation(join(root, "provider")); + await testRefreshResourcePolicy(join(root, "refresh-policy")); } finally { await rm(root, { recursive: true, force: true }); } @@ -226,6 +227,20 @@ async function testProviderRestartRotationAndRevocation(stateDir: string): Promi assert.ok(issued.refresh_token); firstProvider.close(); + const removedAliasProvider = new SingleUserOAuthProvider( + { ...oauthConfig, allowedResourceUrls: [] }, mcpUrl, stateDir, + ); + try { + for (const resource of [undefined, tunnelUrl, mcpUrl]) { + await assert.rejects( + removedAliasProvider.exchangeRefreshToken(client, issued.refresh_token, undefined, resource), + InvalidGrantError, + ); + } + } finally { + removedAliasProvider.close(); + } + const secondProvider = new SingleUserOAuthProvider(oauthConfig, mcpUrl, stateDir); try { const verified = await secondProvider.verifyAccessToken(issued.access_token); @@ -246,17 +261,21 @@ async function testProviderRestartRotationAndRevocation(stateDir: string): Promi assert.ok(refreshed.refresh_token); assert.notEqual(refreshed.access_token, issued.access_token); + const inherited = await secondProvider.exchangeRefreshToken(client, refreshed.refresh_token); + assert.ok(inherited.refresh_token); + assert.equal((await secondProvider.verifyAccessToken(inherited.access_token)).resource?.href, tunnelUrl.href); + await assert.rejects( secondProvider.exchangeRefreshToken(client, issued.refresh_token, ["devspace"], tunnelUrl), InvalidGrantError, ); - await secondProvider.revokeToken(client, { token: refreshed.access_token }); - await assert.rejects(secondProvider.verifyAccessToken(refreshed.access_token), InvalidTokenError); + await secondProvider.revokeToken(client, { token: inherited.access_token }); + await assert.rejects(secondProvider.verifyAccessToken(inherited.access_token), InvalidTokenError); - await secondProvider.revokeToken(client, { token: refreshed.refresh_token }); + await secondProvider.revokeToken(client, { token: inherited.refresh_token }); await assert.rejects( - secondProvider.exchangeRefreshToken(client, refreshed.refresh_token, ["devspace"], mcpUrl), + secondProvider.exchangeRefreshToken(client, inherited.refresh_token, ["devspace"], tunnelUrl), InvalidGrantError, ); } finally { @@ -264,6 +283,26 @@ async function testProviderRestartRotationAndRevocation(stateDir: string): Promi } } +async function testRefreshResourcePolicy(stateDir: string): Promise { + const store = new SqliteOAuthStore(stateDir); + const client = store.registerClient({ redirect_uris: [redirectUri] }, oauthConfig.allowedRedirectHosts); + for (const [token, resource] of [["canonical", mcpUrl.href], ["missing", undefined]] as const) { + store.saveRefreshToken(hashToken(token), { + clientId: client.client_id, scopes: ["devspace"], + expiresAt: Math.floor(Date.now() / 1000) + 3600, resource, + }); + } + store.close(); + const provider = new SingleUserOAuthProvider({ ...oauthConfig, allowedResourceUrls: [] }, mcpUrl, stateDir); + try { + await assert.rejects(provider.exchangeRefreshToken(client, "missing"), InvalidGrantError); + const tokens = await provider.exchangeRefreshToken(client, "canonical"); + assert.equal((await provider.verifyAccessToken(tokens.access_token)).resource?.href, mcpUrl.href); + } finally { + provider.close(); + } +} + function hashToken(token: string): string { return createHash("sha256").update(token).digest("base64url"); } diff --git a/src/server-oauth.test.ts b/src/server-oauth.test.ts new file mode 100644 index 000000000..1ab4fcfb7 --- /dev/null +++ b/src/server-oauth.test.ts @@ -0,0 +1,71 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { once } from "node:events"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { loadConfig } from "./config.js"; +import { SqliteOAuthStore } from "./oauth-store.js"; +import { createServer } from "./server.js"; +import { writeTestDevspaceConfig } from "./test-support/config.test.js"; + +test("HTTP MCP enforces canonical and exact alias bearer resources", async (t) => { + const root = await mkdtemp(join(tmpdir(), "devspace-http-oauth-")); + const canonical = "https://agent.example.com/mcp"; + const alias = "https://tunnel.example.com/v1/mcp/tunnel_123"; + const config = loadConfig(writeTestDevspaceConfig(join(root, "config"), { + server: { publicBaseUrl: "https://agent.example.com" }, + storage: { stateDir: join(root, "state") }, + workspaces: { allowedRoots: [root] }, + oauth: { allowedResourceUrls: [alias] }, + logging: { level: "silent" }, + })); + const store = new SqliteOAuthStore(config.stateDir); + const client = store.registerClient({ + redirect_uris: ["https://chatgpt.com/connector_platform_oauth_redirect"], + }, config.oauth.allowedRedirectHosts); + const cases = [ + { resource: canonical, accepted: true }, + { resource: alias, accepted: true }, + { resource: `${alias}/child`, accepted: false }, + { resource: `${alias}?other=1`, accepted: false }, + { resource: "https://tunnel.example.com/v1/mcp/other", accepted: false }, + ]; + for (const { resource } of cases) { + store.saveAccessToken(createHash("sha256").update(resource).digest("base64url"), { + clientId: client.client_id, scopes: ["devspace"], + expiresAt: Math.floor(Date.now() / 1000) + 3600, resource, + }); + } + store.close(); + const running = createServer(config); + const listener = running.app.listen(0, "127.0.0.1"); + t.after(async () => { + await running.close(); + listener.closeAllConnections(); + await new Promise((resolve, reject) => listener.close((error) => error ? reject(error) : resolve())); + await rm(root, { recursive: true, force: true }); + }); + await once(listener, "listening"); + const address = listener.address(); + assert.ok(address && typeof address !== "string"); + for (const { resource, accepted } of cases) { + const response: Response = await fetch(`http://127.0.0.1:${address.port}/mcp`, { + method: "POST", + headers: { + Authorization: `Bearer ${resource}`, + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + }, + body: JSON.stringify({ + jsonrpc: "2.0", id: 1, method: "initialize", + params: { protocolVersion: "2025-03-26", capabilities: {}, clientInfo: { name: "resource-test", version: "1.0.0" } }, + }), + signal: AbortSignal.timeout(5000), + }); + const body = await response.text(); + assert.equal(response.status, accepted ? 200 : 401, `${resource}: ${body}`); + if (accepted) assert.match(body, /"serverInfo"/); + } +});