From 9be5086386be9a4c46f780ed567d105a28cefbe3 Mon Sep 17 00:00:00 2001 From: Christopher Date: Mon, 3 Aug 2026 10:21:56 +1000 Subject: [PATCH 1/4] test(mcp): add OAuth smoke test and dummy-server e2e coverage The existing HTTP MCP proxy OAuth client (mcp-http-stdio-proxy.ts) had no coverage beyond one browser-launcher unit test. Add: - scripts/smoke-mcp-oauth.ts: an interactive smoke test that connects to a real OAuth-protected remote MCP server via `allagents mcp proxy`, lists its tools, and asks a question -- for manually confirming the OAuth flow against a live private server. - tests/helpers/dummy-mcp-oauth-server.ts: a local, CI-safe stand-in OAuth IdP + MCP server (PKCE, dynamic client registration, auto-approving authorization) so the OAuth flow can be exercised without a human or network access to a real server. - tests/e2e/mcp-proxy-oauth.test.ts: covers first-connection OAuth, cached-token reuse on a second connection, and automatic token refresh on an expired access token -- all with zero browser/human interaction required. - tests/helpers/mcp-proxy-client.ts: shared helper connecting an SDK Client to the proxy over stdio, used by both the smoke script and the e2e tests so both exercise the same production code path. mcp-http-stdio-proxy.ts: export hashServerUrl and AUTH_URL_LOG_PREFIX for test reuse, and add an ALLAGENTS_MCP_OAUTH_NO_BROWSER escape hatch so e2e tests never spawn a real OS browser. --- scripts/smoke-mcp-oauth.ts | 183 ++++++++++++ src/core/mcp-http-stdio-proxy.ts | 15 +- tests/e2e/mcp-proxy-oauth.test.ts | 129 ++++++++ tests/helpers/dummy-mcp-oauth-server.ts | 371 ++++++++++++++++++++++++ tests/helpers/mcp-proxy-client.ts | 78 +++++ 5 files changed, 773 insertions(+), 3 deletions(-) create mode 100644 scripts/smoke-mcp-oauth.ts create mode 100644 tests/e2e/mcp-proxy-oauth.test.ts create mode 100644 tests/helpers/dummy-mcp-oauth-server.ts create mode 100644 tests/helpers/mcp-proxy-client.ts diff --git a/scripts/smoke-mcp-oauth.ts b/scripts/smoke-mcp-oauth.ts new file mode 100644 index 0000000..6059cfb --- /dev/null +++ b/scripts/smoke-mcp-oauth.ts @@ -0,0 +1,183 @@ +#!/usr/bin/env bun +import { connectToMcpProxy } from '../tests/helpers/mcp-proxy-client.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] ?? 'https://knowledge.mcp.wtg.zone', + 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, question, tool } = parseArgs(process.argv.slice(2)); + + console.log(`Connecting to ${serverUrl} via 'allagents mcp proxy'...`); + console.log( + 'If this server requires OAuth, a browser window will open — complete the login there, then return here.', + ); + + const connection = await connectToMcpProxy({ + serverUrl, + onAuthorizationUrl: (url) => { + 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'); + console.log( + 'Smoke test complete. Re-run this script again to confirm no second OAuth prompt appears (cached token reused).', + ); + } finally { + await connection.close(); + } +} + +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(), + }; +} From 07f764a33331e8cfd3af88f5a843b947370c7933 Mon Sep 17 00:00:00 2001 From: Christopher Date: Mon, 3 Aug 2026 10:22:04 +1000 Subject: [PATCH 2/4] fix(cli): downgrade chalk to v4 to fix Bun CJS/ESM interop crash cmd-ts's compiled CJS output does require("chalk") internally, but chalk v5 is pure ESM. Under Bun this intermittently throws "TypeError: require() async module ... is unsupported" -- observed on ~50% of dev-path CLI invocations (bun run src/cli/index.ts), including in already-merged e2e tests, though not in the built dist binary real users run. Force chalk to v4.1.2 everywhere via a package.json `overrides` entry, since cmd-ts declares its own "chalk": "^5.4.1" dependency independent of this project's own version pin -- changing only this project's direct dependency would leave a nested v5 copy for cmd-ts to crash on. Confirmed 0 crashes across 10 repeated CLI invocations after the fix (previously ~5/10), and the full test suite (including e2e) passes consistently across repeated runs. --- bun.lock | 17 +++++++++++++++-- package.json | 6 +++++- 2 files changed, 20 insertions(+), 3 deletions(-) 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..efd3c29 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "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", "typecheck": "tsc --noEmit", "lint": "biome lint src", "lint:fix": "biome lint --write src", @@ -55,7 +56,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 +76,9 @@ "shx": "^0.4.0", "typescript": "^5.3.3" }, + "overrides": { + "chalk": "^4.1.2" + }, "engines": { "node": ">=18.0.0", "bun": ">=1.0.0" From dff84a50a6efe47a65b968faef3dfa492902004d Mon Sep 17 00:00:00 2001 From: Christopher Date: Mon, 3 Aug 2026 14:40:48 +1000 Subject: [PATCH 3/4] fix(mcp): require an explicit server URL for the OAuth smoke test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The script defaulted to a private company MCP endpoint. The whole point of this tooling is to not depend on any internal/company URL — that's what the dummy server + e2e suite are for. Require --url-style positional input instead, with a clear usage message. --- scripts/smoke-mcp-oauth.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/scripts/smoke-mcp-oauth.ts b/scripts/smoke-mcp-oauth.ts index 6059cfb..a08ad9b 100644 --- a/scripts/smoke-mcp-oauth.ts +++ b/scripts/smoke-mcp-oauth.ts @@ -7,6 +7,9 @@ interface ParsedArgs { tool?: string; } +const USAGE = + 'Usage: bun run smoke:mcp-oauth [question] [--tool ]'; + function parseArgs(argv: string[]): ParsedArgs { const positionals: string[] = []; let tool: string | undefined; @@ -20,8 +23,17 @@ function parseArgs(argv: string[]): ParsedArgs { } } + const serverUrl = positionals[0]; + if (!serverUrl) { + console.error(USAGE); + console.error( + 'Provide the URL of any OAuth-protected remote MCP server you want to smoke-test — none is hardcoded here on purpose.', + ); + process.exit(1); + } + return { - serverUrl: positionals[0] ?? 'https://knowledge.mcp.wtg.zone', + serverUrl, question: positionals[1] ?? 'how to rename a company branch', tool, }; From 7b801ab1de8d0e93a9603aa7f3163073e91b6c81 Mon Sep 17 00:00:00 2001 From: Christopher Date: Mon, 3 Aug 2026 15:17:32 +1000 Subject: [PATCH 4/4] feat(mcp): default smoke test to a self-contained local dummy server Requiring an explicit URL was correct in spirit (don't hardcode a private endpoint) but missed the actual point: the smoke test should work entirely on localhost by default, with zero external dependency and zero setup. - scripts/smoke-mcp-oauth.ts: with no URL argument, spins up tests/helpers/dummy-mcp-oauth-server.ts itself and runs the full connect -> OAuth -> list-tools -> call-tool flow against it. OAuth completes via a plain fetch() to the dummy IdP's auto-approving /authorize endpoint -- the same "simulate the browser with curl" trick the e2e tests already use, no real login screen involved. Passing an explicit URL still runs the original real-OAuth path (opens a real browser) unchanged, for validating against an actual private server when you have one. - scripts/dev-mcp-server.ts: new standalone script that starts the same dummy server and keeps it running (Ctrl+C to stop), so it can be used for general local development against `allagents mcp` -- not just this smoke test. --- package.json | 1 + scripts/dev-mcp-server.ts | 36 +++++++++++ scripts/smoke-mcp-oauth.ts | 122 +++++++++++++++++++++++-------------- 3 files changed, 113 insertions(+), 46 deletions(-) create mode 100644 scripts/dev-mcp-server.ts diff --git a/package.json b/package.json index efd3c29..3ca0e9e 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "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", 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 index a08ad9b..d74121c 100644 --- a/scripts/smoke-mcp-oauth.ts +++ b/scripts/smoke-mcp-oauth.ts @@ -1,15 +1,13 @@ #!/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; + serverUrl?: string; question: string; tool?: string; } -const USAGE = - 'Usage: bun run smoke:mcp-oauth [question] [--tool ]'; - function parseArgs(argv: string[]): ParsedArgs { const positionals: string[] = []; let tool: string | undefined; @@ -23,17 +21,8 @@ function parseArgs(argv: string[]): ParsedArgs { } } - const serverUrl = positionals[0]; - if (!serverUrl) { - console.error(USAGE); - console.error( - 'Provide the URL of any OAuth-protected remote MCP server you want to smoke-test — none is hardcoded here on purpose.', - ); - process.exit(1); - } - return { - serverUrl, + serverUrl: positionals[0], question: positionals[1] ?? 'how to rename a company branch', tool, }; @@ -140,49 +129,90 @@ function pickTool( } async function main() { - const { serverUrl, question, tool } = parseArgs(process.argv.slice(2)); - - console.log(`Connecting to ${serverUrl} via 'allagents mcp proxy'...`); - console.log( - 'If this server requires OAuth, a browser window will open — complete the login there, then return here.', + const { serverUrl: explicitUrl, question, tool } = parseArgs( + process.argv.slice(2), ); - const connection = await connectToMcpProxy({ - serverUrl, - onAuthorizationUrl: (url) => { - console.log(`Authorization URL (in case the browser didn't open): ${url}`); - }, - }); + const selfContained = !explicitUrl; + const dummyServer = selfContained + ? await startDummyMcpOAuthServer() + : undefined; + const serverUrl = explicitUrl ?? dummyServer?.mcpUrl; + if (!serverUrl) throw new Error('unreachable'); - try { - console.log('Connected. Listing tools...'); - const { tools } = await connection.client.listTools(); + if (selfContained) { console.log( - `Available tools: ${tools.map((t) => t.name).join(', ') || '(none)'}`, + 'No server URL provided — started a local dummy MCP+OAuth server (fully self-contained, no external network, no real login screen).', ); - - 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(`Connecting to ${serverUrl} via 'allagents mcp proxy'...`); + if (!selfContained) { console.log( - `Calling tool '${selected.name}' with arguments ${JSON.stringify(args)}...`, + 'If this server requires OAuth, a browser window will open — complete the login there, then return here.', ); + } - const result = await connection.client.callTool({ - name: selected.name, - arguments: args, + 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}`, + ); + } + }, }); - console.log('\n--- Response ---'); - console.log(JSON.stringify(result, null, 2)); - console.log('----------------\n'); - console.log( - 'Smoke test complete. Re-run this script again to confirm no second OAuth prompt appears (cached token reused).', - ); + 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 connection.close(); + await dummyServer?.stop(); } }