diff --git a/bun.lock b/bun.lock index b5d365d..54b32c6 100644 --- a/bun.lock +++ b/bun.lock @@ -7,7 +7,7 @@ "dependencies": { "@clack/prompts": "^1.0.0", "@modelcontextprotocol/sdk": "^1.29.0", - "chalk": "^5.6.2", + "chalk": "^4.1.2", "cmd-ts": "^0.14.3", "execa": "^8.0.1", "fast-glob": "^3.3.3", @@ -29,6 +29,9 @@ }, }, }, + "overrides": { + "chalk": "^4.1.2", + }, "packages": { "@biomejs/biome": ["@biomejs/biome@1.9.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "1.9.4", "@biomejs/cli-darwin-x64": "1.9.4", "@biomejs/cli-linux-arm64": "1.9.4", "@biomejs/cli-linux-arm64-musl": "1.9.4", "@biomejs/cli-linux-x64": "1.9.4", "@biomejs/cli-linux-x64-musl": "1.9.4", "@biomejs/cli-win32-arm64": "1.9.4", "@biomejs/cli-win32-x64": "1.9.4" }, "bin": { "biome": "bin/biome" } }, "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog=="], @@ -84,6 +87,8 @@ "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], @@ -98,10 +103,14 @@ "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], - "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "cmd-ts": ["cmd-ts@0.14.3", "", { "dependencies": { "chalk": "^5.4.1", "debug": "^4.4.1", "didyoumean": "^1.2.2", "strip-ansi": "^7.1.0" } }, "sha512-i9miaLBHGPn4T4vUFUdAdWoQ9HI8ato6usFrhubO21o/MQGqI6Nsqyd4ncZusT2chvPetNMmTt0JWkbaAOdWPg=="], + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + "content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], @@ -182,6 +191,8 @@ "gray-matter": ["gray-matter@4.0.3", "", { "dependencies": { "js-yaml": "^3.13.1", "kind-of": "^6.0.2", "section-matter": "^1.0.0", "strip-bom-string": "^1.0.0" } }, "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q=="], + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], @@ -354,6 +365,8 @@ "strip-final-newline": ["strip-final-newline@3.0.0", "", {}, "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw=="], + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], diff --git a/package.json b/package.json index fab4613..3ca0e9e 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,8 @@ "test:watch": "bun test --watch", "test:coverage": "bun test --coverage", "test:e2e": "bun test tests/e2e", + "smoke:mcp-oauth": "bun run scripts/smoke-mcp-oauth.ts", + "dev:mcp-server": "bun run scripts/dev-mcp-server.ts", "typecheck": "tsc --noEmit", "lint": "biome lint src", "lint:fix": "biome lint --write src", @@ -55,7 +57,7 @@ "dependencies": { "@clack/prompts": "^1.0.0", "@modelcontextprotocol/sdk": "^1.29.0", - "chalk": "^5.6.2", + "chalk": "^4.1.2", "cmd-ts": "^0.14.3", "execa": "^8.0.1", "fast-glob": "^3.3.3", @@ -75,6 +77,9 @@ "shx": "^0.4.0", "typescript": "^5.3.3" }, + "overrides": { + "chalk": "^4.1.2" + }, "engines": { "node": ">=18.0.0", "bun": ">=1.0.0" diff --git a/scripts/dev-mcp-server.ts b/scripts/dev-mcp-server.ts new file mode 100644 index 0000000..40808d3 --- /dev/null +++ b/scripts/dev-mcp-server.ts @@ -0,0 +1,36 @@ +#!/usr/bin/env bun +import { startDummyMcpOAuthServer } from '../tests/helpers/dummy-mcp-oauth-server.ts'; + +async function main() { + const server = await startDummyMcpOAuthServer(); + + console.log('Local dummy MCP + OAuth server running:'); + console.log(` MCP endpoint: ${server.mcpUrl}`); + console.log(` OAuth issuer: ${server.idpIssuer}`); + console.log(''); + console.log('Point allagents at it, e.g.:'); + console.log(` allagents mcp add local-dev ${server.mcpUrl} --proxy`); + console.log(` bun run scripts/smoke-mcp-oauth.ts ${server.mcpUrl}`); + console.log(''); + console.log( + 'Authorization requests auto-approve immediately (no login screen) -- this is a', + ); + console.log('CI-safe test double, not a real identity provider.'); + console.log(''); + console.log('Press Ctrl+C to stop.'); + + const shutdown = async () => { + await server.stop(); + process.exit(0); + }; + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); +} + +main().catch((error) => { + console.error( + 'Failed to start dev MCP server:', + error instanceof Error ? error.message : error, + ); + process.exit(1); +}); diff --git a/scripts/smoke-mcp-oauth.ts b/scripts/smoke-mcp-oauth.ts new file mode 100644 index 0000000..d74121c --- /dev/null +++ b/scripts/smoke-mcp-oauth.ts @@ -0,0 +1,225 @@ +#!/usr/bin/env bun +import { connectToMcpProxy } from '../tests/helpers/mcp-proxy-client.ts'; +import { startDummyMcpOAuthServer } from '../tests/helpers/dummy-mcp-oauth-server.ts'; + +interface ParsedArgs { + serverUrl?: string; + question: string; + tool?: string; +} + +function parseArgs(argv: string[]): ParsedArgs { + const positionals: string[] = []; + let tool: string | undefined; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === '--tool') { + tool = argv[++i]; + } else { + positionals.push(arg ?? ''); + } + } + + return { + serverUrl: positionals[0], + question: positionals[1] ?? 'how to rename a company branch', + tool, + }; +} + +const QUESTION_KEYS = ['question', 'query', 'q', 'prompt', 'text']; + +interface JsonSchemaProperty { + type?: string; + minLength?: number; + items?: { enum?: unknown[] }; +} + +function defaultValueForProperty( + propSchema: JsonSchemaProperty | undefined, + question: string, +): unknown { + switch (propSchema?.type) { + case 'array': { + const enumValues = propSchema.items?.enum; + return Array.isArray(enumValues) && enumValues.length > 0 + ? [enumValues[0]] + : []; + } + case 'boolean': + return false; + case 'number': + case 'integer': + return 1; + case 'object': + return {}; + default: + return (propSchema?.minLength ?? 0) > 0 + ? `Automated smoke test — answering: ${question}` + : ''; + } +} + +/** + * Fills every required property, not just a guessed "question" field — real tools + * (e.g. this server's search-knowledge-digested) require auxiliary fields like + * "explanation" or "sources" alongside the query itself, some with minLength/minItems + * constraints that plain empty defaults would fail. + */ +function buildToolArguments( + inputSchema: unknown, + question: string, +): Record { + const schema = + inputSchema && typeof inputSchema === 'object' ? inputSchema : undefined; + const properties = + schema && 'properties' in schema && schema.properties && typeof schema.properties === 'object' + ? (schema.properties as Record) + : undefined; + const required = + schema && 'required' in schema && Array.isArray((schema as { required: unknown }).required) + ? ((schema as { required: string[] }).required as string[]) + : []; + + if (!properties) return { question }; + + const args: Record = {}; + const questionKey = + QUESTION_KEYS.find((key) => key in properties) ?? required[0]; + if (questionKey) args[questionKey] = question; + + for (const key of required) { + if (key in args) continue; + args[key] = defaultValueForProperty(properties[key], question); + } + + return args; +} + +function pickTool( + tools: Array<{ name: string; description?: string; inputSchema?: unknown }>, + requested: string | undefined, +): (typeof tools)[number] { + if (requested) { + const match = tools.find((t) => t.name === requested); + if (!match) { + throw new Error( + `Tool '${requested}' not found. Available: ${tools.map((t) => t.name).join(', ')}`, + ); + } + return match; + } + + if (tools.length === 1) { + return tools[0] as (typeof tools)[number]; + } + + const byPreference = [/digest/i, /ask/i, /search|query|question|knowledge/i]; + for (const pattern of byPreference) { + const match = tools.find((t) => pattern.test(t.name)); + if (match) return match; + } + + throw new Error( + `Multiple tools available and none matched a search/ask heuristic. Pass --tool . Available: ${tools + .map((t) => t.name) + .join(', ')}`, + ); +} + +async function main() { + const { serverUrl: explicitUrl, question, tool } = parseArgs( + process.argv.slice(2), + ); + + const selfContained = !explicitUrl; + const dummyServer = selfContained + ? await startDummyMcpOAuthServer() + : undefined; + const serverUrl = explicitUrl ?? dummyServer?.mcpUrl; + if (!serverUrl) throw new Error('unreachable'); + + if (selfContained) { + console.log( + 'No server URL provided — started a local dummy MCP+OAuth server (fully self-contained, no external network, no real login screen).', + ); + } + console.log(`Connecting to ${serverUrl} via 'allagents mcp proxy'...`); + if (!selfContained) { + console.log( + 'If this server requires OAuth, a browser window will open — complete the login there, then return here.', + ); + } + + try { + const connection = await connectToMcpProxy({ + serverUrl, + env: selfContained ? { ALLAGENTS_MCP_OAUTH_NO_BROWSER: '1' } : undefined, + onAuthorizationUrl: (url) => { + if (selfContained) { + // Same trick the e2e tests use: the dummy IdP auto-approves any + // request, so a plain fetch (curl-equivalent) completes the login. + console.log('Completing OAuth automatically against the dummy IdP...'); + fetch(url).catch((error) => { + console.error('Auto-authorize request failed:', error); + }); + } else { + console.log( + `Authorization URL (in case the browser didn't open): ${url}`, + ); + } + }, + }); + + try { + console.log('Connected. Listing tools...'); + const { tools } = await connection.client.listTools(); + console.log( + `Available tools: ${tools.map((t) => t.name).join(', ') || '(none)'}`, + ); + + const selected = pickTool(tools, tool); + console.log( + `Selected tool '${selected.name}'. Input schema: ${JSON.stringify(selected.inputSchema)}`, + ); + const args = buildToolArguments(selected.inputSchema, question); + console.log( + `Calling tool '${selected.name}' with arguments ${JSON.stringify(args)}...`, + ); + + const result = await connection.client.callTool({ + name: selected.name, + arguments: args, + }); + + console.log('\n--- Response ---'); + console.log(JSON.stringify(result, null, 2)); + console.log('----------------\n'); + if (selfContained) { + console.log( + '(This is a fixture response from the local dummy server, not real data.\n' + + 'Each run starts a fresh dummy server on a new port, so this mode cannot\n' + + 'demonstrate cached-token reuse — that\'s covered by tests/e2e/mcp-proxy-oauth.test.ts.\n' + + 'Pass a real server URL as the first argument to test against something persistent.)', + ); + } else { + console.log( + 'Smoke test complete. Re-run this script again to confirm no second OAuth prompt appears (cached token reused).', + ); + } + } finally { + await connection.close(); + } + } finally { + await dummyServer?.stop(); + } +} + +main().catch((error) => { + console.error('Smoke test failed:', error instanceof Error ? error.message : error); + console.error( + "If this looks like a stale OAuth cache, inspect/clear the relevant directory under '~/.allagents/oauth-proxy/'.", + ); + process.exit(1); +}); diff --git a/src/core/mcp-http-stdio-proxy.ts b/src/core/mcp-http-stdio-proxy.ts index 979e627..5605a11 100644 --- a/src/core/mcp-http-stdio-proxy.ts +++ b/src/core/mcp-http-stdio-proxy.ts @@ -36,8 +36,9 @@ import { } from '@modelcontextprotocol/sdk/types.js'; const AUTH_TIMEOUT_MS = 5 * 60 * 1000; +export const AUTH_URL_LOG_PREFIX = 'If the browser does not open, visit: '; -function hashServerUrl(serverUrl: string): string { +export function hashServerUrl(serverUrl: string): string { return createHash('sha256').update(serverUrl).digest('hex').slice(0, 16); } @@ -379,9 +380,17 @@ class FileOAuthClientProvider implements OAuthClientProvider { server.listen(this.port, '127.0.0.1', () => { console.error('Opening browser for authorization...'); console.error( - `If the browser does not open, visit: ${authorizationUrl.toString()}`, + `${AUTH_URL_LOG_PREFIX}${authorizationUrl.toString()}`, ); - void tryOpenBrowser(authorizationUrl.toString()); + // Test-only escape hatch: e2e tests fetch the URL themselves against a local + // dummy IdP, and skipping the real OS browser-open avoids ever launching one. + if (process.env.ALLAGENTS_MCP_OAUTH_NO_BROWSER === '1') { + console.error( + 'Skipping automatic browser open (ALLAGENTS_MCP_OAUTH_NO_BROWSER=1).', + ); + } else { + void tryOpenBrowser(authorizationUrl.toString()); + } }); }); } diff --git a/tests/e2e/mcp-proxy-oauth.test.ts b/tests/e2e/mcp-proxy-oauth.test.ts new file mode 100644 index 0000000..641905c --- /dev/null +++ b/tests/e2e/mcp-proxy-oauth.test.ts @@ -0,0 +1,129 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { mkdirSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { hashServerUrl } from '../../src/core/mcp-http-stdio-proxy.ts'; +import { + type DummyMcpOAuthServer, + FIXTURE_ANSWER, + FIXTURE_TOOL_NAME, + startDummyMcpOAuthServer, +} from '../helpers/dummy-mcp-oauth-server.ts'; +import { + connectToMcpProxy, + type McpProxyConnection, +} from '../helpers/mcp-proxy-client.ts'; + +function connectAndAutoAuthorize( + serverUrl: string, + homeDir: string, +): Promise { + return connectToMcpProxy({ + serverUrl, + env: { HOME: homeDir, ALLAGENTS_MCP_OAUTH_NO_BROWSER: '1' }, + // Simulates the browser: the dummy IdP auto-approves and 302s straight to the + // loopback callback, so a plain fetch completes the flow with no human involved. + onAuthorizationUrl: (url) => { + fetch(url).catch((error) => { + console.error('auto-authorize fetch failed:', error); + }); + }, + }); +} + +describe('mcp proxy OAuth e2e', () => { + let homeDir: string; + let dummy: DummyMcpOAuthServer | undefined; + + beforeEach(() => { + homeDir = join( + tmpdir(), + `allagents-e2e-oauth-home-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + mkdirSync(homeDir, { recursive: true }); + }); + + afterEach(async () => { + rmSync(homeDir, { recursive: true, force: true }); + await dummy?.stop(); + dummy = undefined; + }); + + test('completes OAuth and calls a tool on the first connection', async () => { + dummy = await startDummyMcpOAuthServer(); + const connection = await connectAndAutoAuthorize(dummy.mcpUrl, homeDir); + + try { + const { tools } = await connection.client.listTools(); + expect(tools.map((t) => t.name)).toContain(FIXTURE_TOOL_NAME); + + const result = await connection.client.callTool({ + name: FIXTURE_TOOL_NAME, + arguments: { question: 'how to rename a company branch' }, + }); + expect(result.content).toEqual([{ type: 'text', text: FIXTURE_ANSWER }]); + expect(dummy.authorizeCallCount).toBe(1); + + const cacheDir = join( + homeDir, + '.allagents', + 'oauth-proxy', + hashServerUrl(dummy.mcpUrl), + ); + const clientInfo = JSON.parse( + readFileSync(join(cacheDir, 'client-info.json'), 'utf-8'), + ); + const tokens = JSON.parse( + readFileSync(join(cacheDir, 'tokens.json'), 'utf-8'), + ); + expect(clientInfo.client_id).toBeTruthy(); + expect(tokens.access_token).toBeTruthy(); + } finally { + await connection.close(); + } + }, 15000); + + test('reuses the cached token on a second connection without re-authorizing', async () => { + dummy = await startDummyMcpOAuthServer(); + + const first = await connectAndAutoAuthorize(dummy.mcpUrl, homeDir); + await first.close(); + expect(dummy.authorizeCallCount).toBe(1); + + const second = await connectAndAutoAuthorize(dummy.mcpUrl, homeDir); + try { + const result = await second.client.callTool({ + name: FIXTURE_TOOL_NAME, + arguments: { question: 'how to rename a company branch' }, + }); + expect(result.content).toEqual([{ type: 'text', text: FIXTURE_ANSWER }]); + expect(dummy.authorizeCallCount).toBe(1); + } finally { + await second.close(); + } + }, 20000); + + test('refreshes an expired access token without a new browser flow', async () => { + dummy = await startDummyMcpOAuthServer({ accessTokenTtlMs: 1500 }); + + const first = await connectAndAutoAuthorize(dummy.mcpUrl, homeDir); + await first.close(); + expect(dummy.authorizeCallCount).toBe(1); + expect(dummy.tokenCallCounts.authorization_code).toBe(1); + + await new Promise((resolve) => setTimeout(resolve, 2000)); + + const second = await connectAndAutoAuthorize(dummy.mcpUrl, homeDir); + try { + const result = await second.client.callTool({ + name: FIXTURE_TOOL_NAME, + arguments: { question: 'how to rename a company branch' }, + }); + expect(result.content).toEqual([{ type: 'text', text: FIXTURE_ANSWER }]); + expect(dummy.authorizeCallCount).toBe(1); + expect(dummy.tokenCallCounts.refresh_token).toBeGreaterThanOrEqual(1); + } finally { + await second.close(); + } + }, 20000); +}); diff --git a/tests/helpers/dummy-mcp-oauth-server.ts b/tests/helpers/dummy-mcp-oauth-server.ts new file mode 100644 index 0000000..e3af80e --- /dev/null +++ b/tests/helpers/dummy-mcp-oauth-server.ts @@ -0,0 +1,371 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { + createServer, + type IncomingMessage, + type Server as HttpServer, + type ServerResponse, +} from 'node:http'; +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { + CallToolRequestSchema, + ListToolsRequestSchema, +} from '@modelcontextprotocol/sdk/types.js'; + +/** + * A local, CI-safe stand-in for a real OAuth-protected remote MCP server: a fake + * identity provider that auto-approves authorization requests (no human login step) + * plus a fake MCP endpoint that requires a bearer token. Used to exercise the OAuth + * client in src/core/mcp-http-stdio-proxy.ts end-to-end without a human or the real + * private server. + */ +export const FIXTURE_TOOL_NAME = 'ask_question'; +export const FIXTURE_ANSWER = + 'To rename a company branch, go to Settings > Branches > Rename. (fixture response)'; + +interface AuthCodeRecord { + redirectUri: string; + codeChallenge: string; +} + +interface TokenRecord { + refreshToken: string; + expiresAt: number; +} + +export interface StartDummyMcpOAuthServerOptions { + /** Access token lifetime in ms. Short values let tests exercise the refresh path. */ + accessTokenTtlMs?: number; +} + +export interface DummyMcpOAuthServer { + mcpUrl: string; + idpIssuer: string; + readonly authorizeCallCount: number; + readonly tokenCallCounts: { authorization_code: number; refresh_token: number }; + stop(): Promise; +} + +function base64UrlEncode(buffer: Buffer): string { + return buffer + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); +} + +function verifyPkce(codeVerifier: string, codeChallenge: string): boolean { + const hash = createHash('sha256').update(codeVerifier).digest(); + return base64UrlEncode(hash) === codeChallenge; +} + +function sendJson(res: ServerResponse, status: number, body: unknown): void { + res.writeHead(status, { 'content-type': 'application/json' }); + res.end(JSON.stringify(body)); +} + +async function readBody(req: IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(chunk as Buffer); + return Buffer.concat(chunks).toString('utf-8'); +} + +function parseBody( + contentType: string | undefined, + raw: string, +): Record { + if (contentType?.includes('application/json')) { + return JSON.parse(raw) as Record; + } + return Object.fromEntries(new URLSearchParams(raw)); +} + +function listen(server: HttpServer): Promise { + return new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + if (typeof address === 'object' && address) { + resolve(address.port); + } else { + reject(new Error('Failed to determine listening port')); + } + }); + }); +} + +function close(server: HttpServer): Promise { + return new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); +} + +export async function startDummyMcpOAuthServer( + options: StartDummyMcpOAuthServerOptions = {}, +): Promise { + const accessTokenTtlMs = options.accessTokenTtlMs ?? 60_000; + + const registeredClientIds = new Set(); + const authCodes = new Map(); + const accessTokens = new Map(); + const refreshTokens = new Map(); + + const counters = { + authorizeCallCount: 0, + tokenCallCounts: { authorization_code: 0, refresh_token: 0 }, + }; + + let idpIssuer = ''; + let mcpUrl = ''; + + const idpServer = createServer((req, res) => { + void handleIdpRequest(req, res); + }); + + async function handleIdpRequest( + req: IncomingMessage, + res: ServerResponse, + ): Promise { + const url = new URL(req.url ?? '/', idpIssuer); + + if (req.method === 'GET' && url.pathname === '/.well-known/oauth-authorization-server') { + sendJson(res, 200, { + issuer: idpIssuer, + authorization_endpoint: `${idpIssuer}/authorize`, + token_endpoint: `${idpIssuer}/token`, + registration_endpoint: `${idpIssuer}/register`, + response_types_supported: ['code'], + grant_types_supported: ['authorization_code', 'refresh_token'], + code_challenge_methods_supported: ['S256'], + token_endpoint_auth_methods_supported: ['none'], + }); + return; + } + + if (req.method === 'POST' && url.pathname === '/register') { + const raw = await readBody(req); + const body = raw ? (JSON.parse(raw) as Record) : {}; + const clientId = randomUUID(); + registeredClientIds.add(clientId); + sendJson(res, 201, { + client_id: clientId, + client_id_issued_at: Math.floor(Date.now() / 1000), + redirect_uris: body.redirect_uris ?? [], + grant_types: body.grant_types ?? ['authorization_code', 'refresh_token'], + response_types: body.response_types ?? ['code'], + token_endpoint_auth_method: body.token_endpoint_auth_method ?? 'none', + client_name: body.client_name, + }); + return; + } + + if (req.method === 'GET' && url.pathname === '/authorize') { + counters.authorizeCallCount++; + const clientId = url.searchParams.get('client_id'); + const redirectUri = url.searchParams.get('redirect_uri'); + const codeChallenge = url.searchParams.get('code_challenge'); + const state = url.searchParams.get('state'); + + if (!clientId || !registeredClientIds.has(clientId) || !redirectUri || !codeChallenge) { + res.writeHead(400, { 'content-type': 'text/plain' }); + res.end('invalid_request'); + return; + } + + const code = randomUUID(); + authCodes.set(code, { redirectUri, codeChallenge }); + + // Auto-approve immediately — this is what makes the dummy IdP CI-safe: there is + // no human login step, so a plain fetch() of this URL completes the flow exactly + // like a real browser with an active session would. + const redirectUrl = new URL(redirectUri); + redirectUrl.searchParams.set('code', code); + if (state) redirectUrl.searchParams.set('state', state); + res.writeHead(302, { location: redirectUrl.toString() }); + res.end(); + return; + } + + if (req.method === 'POST' && url.pathname === '/token') { + const raw = await readBody(req); + const params = parseBody(req.headers['content-type'], raw); + + if (params.grant_type === 'authorization_code') { + counters.tokenCallCounts.authorization_code++; + const record = params.code ? authCodes.get(params.code) : undefined; + if (!record) { + sendJson(res, 400, { error: 'invalid_grant' }); + return; + } + authCodes.delete(params.code); + if (!verifyPkce(params.code_verifier ?? '', record.codeChallenge)) { + sendJson(res, 400, { + error: 'invalid_grant', + error_description: 'PKCE verification failed', + }); + return; + } + const accessToken = randomUUID(); + const refreshToken = randomUUID(); + const tokenRecord: TokenRecord = { + refreshToken, + expiresAt: Date.now() + accessTokenTtlMs, + }; + accessTokens.set(accessToken, tokenRecord); + refreshTokens.set(refreshToken, tokenRecord); + sendJson(res, 200, { + access_token: accessToken, + token_type: 'Bearer', + expires_in: Math.floor(accessTokenTtlMs / 1000), + refresh_token: refreshToken, + scope: 'profile email', + }); + return; + } + + if (params.grant_type === 'refresh_token') { + counters.tokenCallCounts.refresh_token++; + const existing = params.refresh_token + ? refreshTokens.get(params.refresh_token) + : undefined; + if (!existing) { + sendJson(res, 400, { error: 'invalid_grant' }); + return; + } + const accessToken = randomUUID(); + const tokenRecord: TokenRecord = { + refreshToken: existing.refreshToken, + expiresAt: Date.now() + accessTokenTtlMs, + }; + accessTokens.set(accessToken, tokenRecord); + refreshTokens.set(existing.refreshToken, tokenRecord); + sendJson(res, 200, { + access_token: accessToken, + token_type: 'Bearer', + expires_in: Math.floor(accessTokenTtlMs / 1000), + refresh_token: existing.refreshToken, + scope: 'profile email', + }); + return; + } + + sendJson(res, 400, { error: 'unsupported_grant_type' }); + return; + } + + res.writeHead(404, { 'content-type': 'text/plain' }); + res.end('not found'); + } + + // Each spawned `mcp proxy` process opens its own independent MCP session against + // this same long-lived dummy server, so sessions are tracked by Mcp-Session-Id + // rather than sharing one Server/transport pair (which only supports one session). + const sessions = new Map(); + + function createSession(): StreamableHTTPServerTransport { + const server = new Server( + { name: 'dummy-mcp-server', version: '0.0.1' }, + { capabilities: { tools: {} } }, + ); + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: FIXTURE_TOOL_NAME, + description: 'Fixture tool for OAuth e2e tests', + inputSchema: { + type: 'object', + properties: { question: { type: 'string' } }, + required: ['question'], + }, + }, + ], + })); + server.setRequestHandler(CallToolRequestSchema, async () => ({ + content: [{ type: 'text', text: FIXTURE_ANSWER }], + })); + + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: (sessionId) => { + sessions.set(sessionId, transport); + }, + onsessionclosed: (sessionId) => { + sessions.delete(sessionId); + }, + }); + transport.onerror = (error) => { + console.error('[dummy-mcp-server] transport error:', error); + }; + void server.connect(transport); + return transport; + } + + const mcpHttpServer = createServer((req, res) => { + void handleMcpRequest(req, res); + }); + + async function handleMcpRequest( + req: IncomingMessage, + res: ServerResponse, + ): Promise { + const url = new URL(req.url ?? '/', mcpUrl); + + if (req.method === 'GET' && url.pathname === '/.well-known/oauth-protected-resource') { + sendJson(res, 200, { + resource: mcpUrl, + authorization_servers: [idpIssuer], + scopes_supported: ['profile', 'email'], + }); + return; + } + + const authHeader = req.headers.authorization; + const token = authHeader?.startsWith('Bearer ') + ? authHeader.slice('Bearer '.length) + : undefined; + const record = token ? accessTokens.get(token) : undefined; + const isValid = record !== undefined && record.expiresAt > Date.now(); + + if (!isValid) { + res.writeHead(401, { + 'content-type': 'text/plain', + 'www-authenticate': `Bearer resource_metadata="${mcpUrl}/.well-known/oauth-protected-resource"`, + }); + res.end('Unauthorized'); + return; + } + + const sessionIdHeader = req.headers['mcp-session-id']; + const existing = typeof sessionIdHeader === 'string' ? sessions.get(sessionIdHeader) : undefined; + const transport = existing ?? createSession(); + + try { + await transport.handleRequest(req, res); + } catch (error) { + console.error('[dummy-mcp-server] handleRequest error:', error); + if (!res.headersSent) { + res.writeHead(500, { 'content-type': 'text/plain' }); + res.end('Internal Server Error'); + } + } + } + + const [idpPort, mcpPort] = await Promise.all([listen(idpServer), listen(mcpHttpServer)]); + idpIssuer = `http://127.0.0.1:${idpPort}`; + mcpUrl = `http://127.0.0.1:${mcpPort}`; + + return { + mcpUrl, + idpIssuer, + get authorizeCallCount() { + return counters.authorizeCallCount; + }, + get tokenCallCounts() { + return counters.tokenCallCounts; + }, + async stop() { + await Promise.all([...sessions.values()].map((transport) => transport.close())); + await Promise.all([close(idpServer), close(mcpHttpServer)]); + }, + }; +} diff --git a/tests/helpers/mcp-proxy-client.ts b/tests/helpers/mcp-proxy-client.ts new file mode 100644 index 0000000..6885465 --- /dev/null +++ b/tests/helpers/mcp-proxy-client.ts @@ -0,0 +1,78 @@ +import { join } from 'node:path'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +import { AUTH_URL_LOG_PREFIX } from '../../src/core/mcp-http-stdio-proxy.ts'; + +/** + * Connects an SDK Client to `allagents mcp proxy ` over stdio, the same + * way a real MCP host (Claude Code, Copilot, Cursor) would. Used by both the + * interactive OAuth smoke test and the dummy-server e2e tests so both exercise the + * exact production code path. + */ +export interface ConnectMcpProxyOptions { + serverUrl: string; + headers?: string[]; + env?: Record; + clientName?: string; + /** Called once with the authorization URL the proxy prints when OAuth is required. */ + onAuthorizationUrl?: (url: string) => void; +} + +export interface McpProxyConnection { + client: Client; + transport: StdioClientTransport; + close(): Promise; +} + +const CLI_ENTRY = join(import.meta.dir, '..', '..', 'src', 'cli', 'index.ts'); + +export async function connectToMcpProxy( + options: ConnectMcpProxyOptions, +): Promise { + const args = ['run', CLI_ENTRY, 'mcp', 'proxy', options.serverUrl]; + for (const header of options.headers ?? []) { + args.push('--header', header); + } + + const transport = new StdioClientTransport({ + command: 'bun', + args, + env: { ...process.env, ...(options.env ?? {}) } as Record, + stderr: 'pipe', + }); + + let authUrlFired = false; + let stderrBuffer = ''; + transport.stderr?.setEncoding('utf-8'); + transport.stderr?.on('data', (chunk: string) => { + stderrBuffer += chunk; + if (authUrlFired || !options.onAuthorizationUrl) return; + const prefixIndex = stderrBuffer.indexOf(AUTH_URL_LOG_PREFIX); + if (prefixIndex === -1) return; + const rest = stderrBuffer.slice(prefixIndex + AUTH_URL_LOG_PREFIX.length); + const newlineIndex = rest.indexOf('\n'); + if (newlineIndex === -1) return; // wait for the full line before parsing + const url = rest.slice(0, newlineIndex).trim(); + if (!url) return; + authUrlFired = true; + options.onAuthorizationUrl(url); + }); + + const client = new Client( + { name: options.clientName ?? 'allagents-test-client', version: '0.0.0' }, + { capabilities: {} }, + ); + + try { + await client.connect(transport); + } catch (error) { + await transport.close(); + throw error; + } + + return { + client, + transport, + close: () => transport.close(), + }; +}