feat(mcp-client): add OAuth auth provider for HTTP transport (#3295)

* feat(mcp-client): add OAuth auth provider for HTTP transport

MCP HTTP transport was created without any auth options, causing 401
errors when connecting to remote servers (Sentry, Linear, etc.) that
require OAuth or bearer token authentication.

Add a new auth module that builds StreamableHTTPClientTransport options
from two config fields:
- `headers`: static headers with ${VAR} env resolution (for bearer tokens)
- `oauth`: full OAuthClientProvider for servers implementing MCP OAuth

The config is parsed from .mcp.json / .gsd/mcp.json and passed through
to the SDK transport constructor.

Fixes #2160

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: retrigger CI

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: trek-e <trek-e@users.noreply.github.com>
This commit is contained in:
Tom Boucher 2026-04-05 01:05:10 -04:00 committed by GitHub
parent d9cea627bf
commit 2a40f3f35d
3 changed files with 384 additions and 1 deletions

View file

@ -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<string, string>;
}
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<string, string>): Record<string, string> {
const resolved: Record<string, string> = {};
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<McpHttpOAuthConfig["oauth"]>): 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;
}

View file

@ -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<string, string>;
url?: string;
cwd?: string;
/** Static headers for HTTP transport (supports ${VAR} env resolution). */
headers?: Record<string, string>;
/** 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<string, string> : undefined,
oauth: hasOAuth ? config.oauth as McpHttpAuthConfig["oauth"] : undefined,
});
}
} catch {
@ -159,7 +170,11 @@ async function getOrConnect(name: string, signal?: AbortSignal): Promise<Client>
/\$\{([^}]+)\}/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}`);
}

View file

@ -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<string, string>;
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<string, string>;
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<string, string>;
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<string, string>;
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");
});