diff --git a/packages/agent-core-v2/src/mcpCore/config-schema.ts b/packages/agent-core-v2/src/mcpCore/config-schema.ts index cebe88eaa3f..d96f509e6b8 100644 --- a/packages/agent-core-v2/src/mcpCore/config-schema.ts +++ b/packages/agent-core-v2/src/mcpCore/config-schema.ts @@ -4,6 +4,12 @@ * Owns the `McpServerConfig` schema and its transport variants. These describe * the shape of MCP server entries as they appear in configuration (whether in * `config.toml` or an MCP-specific config file). + * + * Remote variants accept `auth: "oauth"`, mirroring v1: OAuth is still + * discovered from a remote server's 401 response; the flag records that the + * user explicitly chose OAuth, so static `headers` on the same entry are + * treated as plain request headers (capability/identity declarations) rather + * than as the server's credentials. */ import { z } from 'zod'; @@ -37,6 +43,7 @@ export const McpServerHttpConfigSchema = z.object({ transport: z.literal('http'), url: z.string().url(), headers: StringRecordSchema.optional(), + auth: z.literal('oauth').optional(), bearerTokenEnvVar: z.string().min(1).optional(), ...McpServerCommonFields, }); @@ -47,6 +54,7 @@ export const McpServerSseConfigSchema = z.object({ transport: z.literal('sse'), url: z.string().url(), headers: StringRecordSchema.optional(), + auth: z.literal('oauth').optional(), bearerTokenEnvVar: z.string().min(1).optional(), ...McpServerCommonFields, }); diff --git a/packages/agent-core-v2/src/mcpCore/connection-manager.ts b/packages/agent-core-v2/src/mcpCore/connection-manager.ts index 4e55a902284..f206380efc6 100644 --- a/packages/agent-core-v2/src/mcpCore/connection-manager.ts +++ b/packages/agent-core-v2/src/mcpCore/connection-manager.ts @@ -410,7 +410,7 @@ export class McpConnectionManager implements McpConnectionView { if (this.oauthService === undefined) return false; if (!isRemoteMcpConfig(entry.config)) return false; if (entry.config.bearerTokenEnvVar !== undefined) return false; - if (entry.config.headers !== undefined) return false; + if (entry.config.headers !== undefined && entry.config.auth !== 'oauth') return false; return isUnauthorizedLikeError(error); } diff --git a/packages/agent-core-v2/test/mcpCore/connection-manager.test.ts b/packages/agent-core-v2/test/mcpCore/connection-manager.test.ts index c6bd4f92aaf..3ef77f85c4a 100644 --- a/packages/agent-core-v2/test/mcpCore/connection-manager.test.ts +++ b/packages/agent-core-v2/test/mcpCore/connection-manager.test.ts @@ -549,6 +549,65 @@ describe('McpConnectionManager', () => { } }, 15000); + it('marks an explicitly OAuth HTTP server as needs-auth when non-auth headers accompany a 401', async () => { + const server: HttpServer = createHttpServer((_req, res) => { + res.writeHead(401, { + 'content-type': 'application/json', + 'www-authenticate': + 'Bearer realm="mcp", resource_metadata="http://x/.well-known/oauth-protected-resource"', + }); + res.end(JSON.stringify({ error: 'unauthorized' })); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const port = (server.address() as HttpAddress).port; + const oauthService = new McpOAuthService({ store: createMemoryMcpOAuthStore() }); + const cm = new McpConnectionManager({ oauthService }); + try { + await cm.connectAll({ + gated: { + transport: 'http', + url: `http://127.0.0.1:${port}/mcp`, + headers: { 'X-Tenant': 'example' }, + auth: 'oauth', + startupTimeoutMs: 5_000, + }, + }); + const entry = cm.get('gated'); + expect(entry?.status).toBe('needs-auth'); + expect(entry?.error).toContain('run /mcp-config login gated'); + expect(entry?.toolCount).toBe(0); + } finally { + await cm.shutdown(); + await closeServer(server); + } + }, 15000); + + it('keeps a headers-only HTTP server failed (not needs-auth) on 401', async () => { + const server: HttpServer = createHttpServer((_req, res) => { + res.writeHead(401, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ error: 'unauthorized' })); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const port = (server.address() as HttpAddress).port; + const oauthService = new McpOAuthService({ store: createMemoryMcpOAuthStore() }); + const cm = new McpConnectionManager({ oauthService }); + try { + await cm.connectAll({ + keyed: { + transport: 'http', + url: `http://127.0.0.1:${port}/mcp`, + headers: { Authorization: 'Bearer static-key' }, + startupTimeoutMs: 5_000, + }, + }); + const entry = cm.get('keyed'); + expect(entry?.status).toBe('failed'); + } finally { + await cm.shutdown(); + await closeServer(server); + } + }, 15000); + it('flips SSE servers into needs-auth when the server returns 401 and no static token is set', async () => { const server: HttpServer = createHttpServer((_req, res) => { res.writeHead(401, {