diff --git a/src/resources/extensions/mcp-client/auth.ts b/src/resources/extensions/mcp-client/auth.ts new file mode 100644 index 000000000..52a3f86c8 --- /dev/null +++ b/src/resources/extensions/mcp-client/auth.ts @@ -0,0 +1,149 @@ +/** + * MCP Client OAuth / Auth helpers + * + * Builds transport options (headers, OAuthClientProvider) from MCP server + * config entries so that HTTP transports can authenticate with remote + * servers (Sentry, Linear, etc.). + * + * Fixes #2160 — MCP HTTP transport lacked an OAuth auth provider. + */ + +import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; +import type { StreamableHTTPClientTransportOptions } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface McpHttpAuthHeaders { + /** Static headers to attach to every request, e.g. `{ Authorization: "Bearer ${TOKEN}" }`. */ + headers?: Record; +} + +export interface McpHttpOAuthConfig { + /** OAuth configuration for servers that require the full OAuth flow. */ + oauth?: { + clientId: string; + clientSecret?: string; + scopes?: string[]; + redirectUrl?: string; + }; +} + +/** Union of all auth-related config fields for an HTTP MCP server. */ +export type McpHttpAuthConfig = McpHttpAuthHeaders & McpHttpOAuthConfig; + +// ─── Env resolution ─────────────────────────────────────────────────────────── + +/** Resolve `${VAR}` references in a string against `process.env`. */ +function resolveEnvValue(value: string): string { + return value.replace( + /\$\{([^}]+)\}/g, + (_match, varName) => process.env[varName] ?? "", + ); +} + +function resolveHeaders(raw: Record): Record { + const resolved: Record = {}; + for (const [key, value] of Object.entries(raw)) { + resolved[key] = typeof value === "string" ? resolveEnvValue(value) : value; + } + return resolved; +} + +// ─── OAuth provider (minimal CLI-friendly implementation) ───────────────────── + +/** + * Creates a minimal `OAuthClientProvider` suitable for CLI / headless use. + * + * This provider supports: + * - Pre-configured client credentials (client_id, optional client_secret) + * - Token storage in memory (per-session) + * - Scopes + * + * For full interactive OAuth flows (browser redirect), a richer provider would + * be needed, but for server-to-server and pre-authed scenarios this is + * sufficient. + */ +function createCliOAuthProvider(config: NonNullable): OAuthClientProvider { + let storedTokens: { access_token: string; token_type: string; refresh_token?: string } | undefined; + let storedCodeVerifier = ""; + + return { + get redirectUrl() { + return config.redirectUrl ?? "http://localhost:0/callback"; + }, + + get clientMetadata() { + return { + redirect_uris: [config.redirectUrl ?? "http://localhost:0/callback"], + client_name: "gsd", + ...(config.scopes ? { scope: config.scopes.join(" ") } : {}), + }; + }, + + clientInformation() { + return { + client_id: config.clientId, + ...(config.clientSecret ? { client_secret: config.clientSecret } : {}), + }; + }, + + tokens() { + return storedTokens; + }, + + saveTokens(tokens) { + storedTokens = tokens as typeof storedTokens; + }, + + redirectToAuthorization(authorizationUrl: URL) { + // In a CLI context we can't open a browser automatically. + // Log the URL so the user can manually visit it. + // eslint-disable-next-line no-console + console.error( + `[MCP OAuth] Authorization required. Visit:\n ${authorizationUrl.toString()}`, + ); + }, + + saveCodeVerifier(codeVerifier: string) { + storedCodeVerifier = codeVerifier; + }, + + codeVerifier() { + return storedCodeVerifier; + }, + }; +} + +// ─── Public API ─────────────────────────────────────────────────────────────── + +/** + * Build `StreamableHTTPClientTransportOptions` from an MCP server config's + * auth-related fields. + * + * Supports two auth strategies: + * 1. **`headers`** — static Authorization (or other) headers, with `${VAR}` env resolution. + * 2. **`oauth`** — full OAuthClientProvider for servers that implement MCP OAuth. + * + * When both are provided, `oauth` takes precedence (the SDK's built-in OAuth + * flow handles token refresh automatically). + */ +export function buildHttpTransportOpts( + authConfig: McpHttpAuthConfig, +): StreamableHTTPClientTransportOptions { + const opts: StreamableHTTPClientTransportOptions = {}; + + // OAuth takes precedence + if (authConfig.oauth) { + opts.authProvider = createCliOAuthProvider(authConfig.oauth); + return opts; + } + + // Static headers (with env var resolution) + if (authConfig.headers && Object.keys(authConfig.headers).length > 0) { + opts.requestInit = { + headers: resolveHeaders(authConfig.headers), + }; + } + + return opts; +} diff --git a/src/resources/extensions/mcp-client/index.ts b/src/resources/extensions/mcp-client/index.ts index f62173455..3cfb5b51b 100644 --- a/src/resources/extensions/mcp-client/index.ts +++ b/src/resources/extensions/mcp-client/index.ts @@ -25,6 +25,8 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js" import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { readFileSync, existsSync } from "node:fs"; import { join } from "node:path"; +import { buildHttpTransportOpts } from "./auth.js"; +import type { McpHttpAuthConfig } from "./auth.js"; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -36,6 +38,10 @@ interface McpServerConfig { env?: Record; url?: string; cwd?: string; + /** Static headers for HTTP transport (supports ${VAR} env resolution). */ + headers?: Record; + /** OAuth config for HTTP transport. */ + oauth?: McpHttpAuthConfig["oauth"]; } interface McpToolSchema { @@ -87,6 +93,9 @@ function readConfigs(): McpServerConfig[] { ? "http" : "unknown"; + const hasHeaders = hasUrl && config.headers && typeof config.headers === "object"; + const hasOAuth = hasUrl && config.oauth && typeof config.oauth === "object"; + servers.push({ name, transport, @@ -99,6 +108,8 @@ function readConfigs(): McpServerConfig[] { cwd: typeof config.cwd === "string" ? config.cwd : undefined, }), ...(hasUrl && { url: config.url as string }), + headers: hasHeaders ? config.headers as Record : undefined, + oauth: hasOAuth ? config.oauth as McpHttpAuthConfig["oauth"] : undefined, }); } } catch { @@ -159,7 +170,11 @@ async function getOrConnect(name: string, signal?: AbortSignal): Promise /\$\{([^}]+)\}/g, (_, varName) => process.env[varName] ?? "", ); - transport = new StreamableHTTPClientTransport(new URL(resolvedUrl)); + const httpOpts = buildHttpTransportOpts({ + headers: config.headers, + oauth: config.oauth, + }); + transport = new StreamableHTTPClientTransport(new URL(resolvedUrl), httpOpts); } else { throw new Error(`Server "${config.name}" has unsupported transport: ${config.transport}`); } diff --git a/src/tests/mcp-client-oauth.test.ts b/src/tests/mcp-client-oauth.test.ts new file mode 100644 index 000000000..568e28eab --- /dev/null +++ b/src/tests/mcp-client-oauth.test.ts @@ -0,0 +1,219 @@ +/** + * Tests for MCP client OAuth auth provider support on HTTP transport. + * + * Verifies that: + * 1. HTTP server configs with `headers` pass them to the transport via requestInit + * 2. HTTP server configs with `oauth` config construct an OAuthClientProvider + * 3. Servers without auth still connect without an auth provider + * 4. Environment variable references in headers are resolved + * + * Reproduces issue #2160 — MCP HTTP transport lacks OAuth auth provider, + * causing 401 errors when connecting to remote MCP servers (Sentry, Linear, etc.) + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { buildHttpTransportOpts } from "../resources/extensions/mcp-client/auth.ts"; + +// ── Transport construction (SDK sanity checks) ─────────────────────────────── + +test("HTTP transport without auth config creates transport with no authProvider", async () => { + const { StreamableHTTPClientTransport } = await import( + "@modelcontextprotocol/sdk/client/streamableHttp.js" + ); + + const transport = new StreamableHTTPClientTransport( + new URL("https://example.com/mcp"), + ); + assert.ok(transport, "Transport should be created without auth"); +}); + +test("HTTP transport with authProvider creates transport that can authenticate", async () => { + const { StreamableHTTPClientTransport } = await import( + "@modelcontextprotocol/sdk/client/streamableHttp.js" + ); + + // Minimal OAuthClientProvider mock + const mockAuthProvider = { + get redirectUrl() { return "http://localhost:3000/callback"; }, + get clientMetadata() { + return { + redirect_uris: ["http://localhost:3000/callback"], + client_name: "gsd-test", + }; + }, + clientInformation: () => undefined, + tokens: () => ({ access_token: "test-token", token_type: "Bearer" }), + saveTokens: () => {}, + redirectToAuthorization: () => {}, + saveCodeVerifier: () => {}, + codeVerifier: () => "verifier", + }; + + const transport = new StreamableHTTPClientTransport( + new URL("https://example.com/mcp"), + { authProvider: mockAuthProvider }, + ); + assert.ok(transport, "Transport should accept authProvider option"); +}); + +test("HTTP transport with requestInit headers passes them to requests", async () => { + const { StreamableHTTPClientTransport } = await import( + "@modelcontextprotocol/sdk/client/streamableHttp.js" + ); + + const transport = new StreamableHTTPClientTransport( + new URL("https://example.com/mcp"), + { + requestInit: { + headers: { + Authorization: "Bearer my-token", + }, + }, + }, + ); + assert.ok(transport, "Transport should accept requestInit with headers"); +}); + +// ── buildHttpTransportOpts ────────────────────────────────────────────────── + +test("buildHttpTransportOpts returns empty opts for config without auth", () => { + const opts = buildHttpTransportOpts({}); + assert.deepEqual(opts, {}, "No auth config should produce empty opts"); +}); + +test("buildHttpTransportOpts returns requestInit.headers for config with headers", () => { + const opts = buildHttpTransportOpts({ + headers: { Authorization: "Bearer tok_123" }, + }); + + assert.ok(opts.requestInit, "Should produce requestInit"); + const headers = opts.requestInit!.headers as Record; + assert.equal(headers.Authorization, "Bearer tok_123"); +}); + +test("buildHttpTransportOpts resolves env vars in header values", () => { + process.env.__TEST_MCP_TOKEN = "secret-456"; + + const opts = buildHttpTransportOpts({ + headers: { Authorization: "Bearer ${__TEST_MCP_TOKEN}" }, + }); + + const headers = opts.requestInit!.headers as Record; + assert.equal( + headers.Authorization, + "Bearer secret-456", + "Env vars in headers should be resolved", + ); + + delete process.env.__TEST_MCP_TOKEN; +}); + +test("buildHttpTransportOpts resolves multiple env vars in a single header", () => { + process.env.__TEST_MCP_USER = "alice"; + process.env.__TEST_MCP_PASS = "s3cret"; + + const opts = buildHttpTransportOpts({ + headers: { "X-Custom": "${__TEST_MCP_USER}:${__TEST_MCP_PASS}" }, + }); + + const headers = opts.requestInit!.headers as Record; + assert.equal(headers["X-Custom"], "alice:s3cret"); + + delete process.env.__TEST_MCP_USER; + delete process.env.__TEST_MCP_PASS; +}); + +test("buildHttpTransportOpts replaces missing env vars with empty string", () => { + delete process.env.__NONEXISTENT_VAR; + + const opts = buildHttpTransportOpts({ + headers: { Authorization: "Bearer ${__NONEXISTENT_VAR}" }, + }); + + const headers = opts.requestInit!.headers as Record; + assert.equal(headers.Authorization, "Bearer "); +}); + +test("buildHttpTransportOpts creates OAuthClientProvider for oauth config", () => { + const opts = buildHttpTransportOpts({ + oauth: { + clientId: "my-client", + scopes: ["read"], + }, + }); + + assert.ok(opts.authProvider, "OAuth config should produce an authProvider"); + assert.ok(opts.authProvider.clientMetadata, "authProvider should have clientMetadata"); + assert.equal(typeof opts.authProvider.tokens, "function", "authProvider.tokens should be a function"); + assert.equal(typeof opts.authProvider.saveTokens, "function", "authProvider.saveTokens should be a function"); + assert.equal(typeof opts.authProvider.redirectToAuthorization, "function"); + assert.equal(typeof opts.authProvider.codeVerifier, "function"); + assert.equal(typeof opts.authProvider.saveCodeVerifier, "function"); +}); + +test("OAuth provider clientInformation includes clientId", () => { + const opts = buildHttpTransportOpts({ + oauth: { + clientId: "test-id-123", + clientSecret: "test-secret", + }, + }); + + const info = opts.authProvider!.clientInformation(); + assert.ok(info, "clientInformation should return data"); + assert.equal(info!.client_id, "test-id-123"); + assert.equal((info as any).client_secret, "test-secret"); +}); + +test("OAuth provider clientMetadata includes scopes", () => { + const opts = buildHttpTransportOpts({ + oauth: { + clientId: "scoped-client", + scopes: ["issues:read", "issues:write"], + }, + }); + + const meta = opts.authProvider!.clientMetadata; + assert.ok(meta, "clientMetadata should exist"); + assert.equal((meta as any).scope, "issues:read issues:write"); +}); + +test("OAuth provider stores and retrieves tokens", () => { + const opts = buildHttpTransportOpts({ + oauth: { clientId: "token-test" }, + }); + + const provider = opts.authProvider!; + + // Initially no tokens + assert.equal(provider.tokens(), undefined); + + // Save tokens + const tokens = { access_token: "at_123", token_type: "Bearer", refresh_token: "rt_456" }; + provider.saveTokens(tokens); + + // Retrieve tokens + const stored = provider.tokens(); + assert.ok(stored); + assert.equal(stored!.access_token, "at_123"); +}); + +test("OAuth provider stores and retrieves code verifier", () => { + const opts = buildHttpTransportOpts({ + oauth: { clientId: "pkce-test" }, + }); + + const provider = opts.authProvider!; + provider.saveCodeVerifier("my-verifier-string"); + assert.equal(provider.codeVerifier(), "my-verifier-string"); +}); + +test("OAuth takes precedence over headers when both are provided", () => { + const opts = buildHttpTransportOpts({ + headers: { Authorization: "Bearer static-token" }, + oauth: { clientId: "oauth-client" }, + }); + + assert.ok(opts.authProvider, "OAuth should be used when both are provided"); + assert.ok(!opts.requestInit, "requestInit should not be set when OAuth is active"); +});