From 67e666d9ee95489eeeb2ba54a5245f14c276632e Mon Sep 17 00:00:00 2001 From: dimavedenyapin Date: Sat, 25 Apr 2026 13:40:02 +0700 Subject: [PATCH 1/8] fix: add MCP auth proxy for transparent JWT refresh during long sessions ACP, OpenCode, and Claude Code all bake MCP server headers at session start and never refresh them. After ~55 minutes the 1-hour JWT expires, causing persistent 401 errors on every MCP tool call with no recovery. This adds a local HTTP reverse proxy (127.0.0.1:) that intercepts MCP requests, calls enterpriseAuth.getJwt() for a fresh token on each request, and forwards to the real MCP server. The proxy decouples token lifetime from session lifetime. Co-Authored-By: Claude Opus 4.6 --- src/main/agent-manager.ts | 26 +++- src/main/index.ts | 12 ++ src/main/mcp-auth-proxy.test.ts | 245 ++++++++++++++++++++++++++++++++ src/main/mcp-auth-proxy.ts | 198 ++++++++++++++++++++++++++ 4 files changed, 475 insertions(+), 6 deletions(-) create mode 100644 src/main/mcp-auth-proxy.test.ts create mode 100644 src/main/mcp-auth-proxy.ts diff --git a/src/main/agent-manager.ts b/src/main/agent-manager.ts index f153409..ca2aa87 100644 --- a/src/main/agent-manager.ts +++ b/src/main/agent-manager.ts @@ -18,6 +18,7 @@ import { SessionStatusType, MessagePartType, MessageRole } from './adapters/codi import { getTaskApiPort, waitForTaskApiServer } from './task-api-server' import { randomUUID } from 'crypto' import { registerSecretSession, unregisterSecretSession, getSecretBrokerPort, writeSecretShellWrapper } from './secret-broker' +import { registerMcpProxyTarget, getMcpAuthProxyPort } from './mcp-auth-proxy' // Coding agent backend type enum enum CodingAgentType { @@ -337,18 +338,31 @@ export class AgentManager extends EventEmitter { } // Inject enterprise JWT for Workflo MCP Dev Server + // Prefer auth proxy (auto-refreshes JWT on every request) over static header injection + let finalUrl = mcpServer.url if (mcpServer.name === '[Workflo] MCP Dev Server' && this.enterpriseAuth) { - try { - const jwt = await this.enterpriseAuth.getJwt() - finalHeaders = { ...finalHeaders, Authorization: `Bearer ${jwt}` } - } catch (err) { - console.warn('[AgentManager] Failed to inject enterprise JWT for MCP Dev Server:', err) + const proxyUrl = getMcpAuthProxyPort() ? registerMcpProxyTarget(mcpServer.url) : null + if (proxyUrl) { + // Route through auth proxy — JWT is injected on every forwarded request + finalUrl = proxyUrl + // Remove any stale Authorization header — proxy handles auth + delete finalHeaders['Authorization'] + console.log(`[AgentManager] MCP Dev Server routed through auth proxy: ${proxyUrl}`) + } else { + // Fallback: inject static JWT (original behavior — expires after ~55 min) + try { + const jwt = await this.enterpriseAuth.getJwt() + finalHeaders = { ...finalHeaders, Authorization: `Bearer ${jwt}` } + console.warn('[AgentManager] MCP auth proxy not available, using static JWT (will expire in long sessions)') + } catch (err) { + console.warn('[AgentManager] Failed to inject enterprise JWT for MCP Dev Server:', err) + } } } result[mcpServer.name] = { type: 'http', - url: mcpServer.url, + url: finalUrl, headers: finalHeaders } } diff --git a/src/main/index.ts b/src/main/index.ts index 34021ea..578cfea 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -29,6 +29,7 @@ import { EnterpriseHeartbeat } from './enterprise-heartbeat' import { EnterpriseStateSync } from './enterprise-state-sync' import { setTaskApiNotifier, setTranscriptProvider, stopTaskApiServer } from './task-api-server' import { startSecretBroker, stopSecretBroker, writeSecretShellWrapper } from './secret-broker' +import { startMcpAuthProxy, stopMcpAuthProxy } from './mcp-auth-proxy' import { startMobileApiServer, stopMobileApiServer, broadcastToMobileClients, setMobileApiNotifier } from './mobile-api-server' import { registerUpdaterIpc, initAutoUpdater, isUpdateDownloaded, getPendingVersion } from './auto-updater' import { initCrashLogger } from './crash-logger' @@ -66,6 +67,7 @@ async function shutdownAppServices(): Promise { mcpToolCaller?.destroy() oauthManager?.destroy() stopSecretBroker() + stopMcpAuthProxy() stopMobileApiServer() stopTaskApiServer() @@ -734,6 +736,16 @@ app.whenReady().then(async () => { // Wire enterprise auth into agent manager so it can inject JWT into MCP Dev Server requests agentManager.setEnterpriseAuth(enterpriseAuth) + // Start MCP auth proxy so agent sessions get auto-refreshing JWT + // on every MCP tool call (ACP/OpenCode/Claude Code never refresh + // MCP headers mid-session — the proxy solves this transparently). + try { + const proxyPort = await startMcpAuthProxy(enterpriseAuth) + console.log(`[Main] MCP auth proxy started on port ${proxyPort}`) + } catch (proxyErr) { + console.warn('[Main] MCP auth proxy failed to start (MCP JWT will use static headers):', proxyErr) + } + syncManager.setEnterpriseConnection(apiClient, enterpriseSyncMgr, session.userId, enterpriseStateSyncInstance) console.log('[Main] Enterprise connection restored on startup (with heartbeat)') diff --git a/src/main/mcp-auth-proxy.test.ts b/src/main/mcp-auth-proxy.test.ts new file mode 100644 index 0000000..0bad126 --- /dev/null +++ b/src/main/mcp-auth-proxy.test.ts @@ -0,0 +1,245 @@ +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest' +import { createServer, type Server as HttpServer, type IncomingMessage, type ServerResponse } from 'http' + +// Mock electron before importing the module +vi.mock('electron', () => ({ + app: { isPackaged: false } +})) + +// We test the proxy by starting both it and a mock upstream server +import { + startMcpAuthProxy, + stopMcpAuthProxy, + registerMcpProxyTarget, + unregisterMcpProxyTarget, + getMcpAuthProxyPort +} from './mcp-auth-proxy' + +// ── Mock EnterpriseAuth ─────────────────────────────────────────── + +class MockAuth { + private callCount = 0 + private jwtValue = 'jwt-token-1' + private shouldFail = false + + async getJwt(): Promise { + this.callCount++ + if (this.shouldFail) throw new Error('Auth failed') + return this.jwtValue + } + + setJwt(value: string): void { + this.jwtValue = value + } + + setFail(fail: boolean): void { + this.shouldFail = fail + } + + getCallCount(): number { + return this.callCount + } +} + +// ── Mock upstream MCP server ────────────────────────────────────── + +function createMockUpstream(): Promise<{ server: HttpServer; port: number; lastHeaders: () => Record; lastBody: () => string; lastMethod: () => string; lastUrl: () => string }> { + let capturedHeaders: Record = {} + let capturedBody = '' + let capturedMethod = '' + let capturedUrl = '' + + return new Promise((resolve) => { + const srv = createServer((req: IncomingMessage, res: ServerResponse) => { + capturedHeaders = { ...req.headers } + capturedMethod = req.method || '' + capturedUrl = req.url || '' + + const chunks: Buffer[] = [] + req.on('data', (c: Buffer) => chunks.push(c)) + req.on('end', () => { + capturedBody = Buffer.concat(chunks).toString() + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ result: 'ok', method: capturedMethod })) + }) + }) + + srv.listen(0, '127.0.0.1', () => { + const addr = srv.address() + const p = typeof addr === 'object' && addr ? addr.port : 0 + resolve({ + server: srv, + port: p, + lastHeaders: () => capturedHeaders, + lastBody: () => capturedBody, + lastMethod: () => capturedMethod, + lastUrl: () => capturedUrl + }) + }) + }) +} + +// ── Tests ───────────────────────────────────────────────────────── + +describe('MCP Auth Proxy', () => { + let mockAuth: MockAuth + let upstream: Awaited> + + beforeEach(async () => { + mockAuth = new MockAuth() + upstream = await createMockUpstream() + }) + + afterEach(async () => { + stopMcpAuthProxy() + await new Promise((resolve) => upstream.server.close(() => resolve())) + }) + + it('starts on a random port and reports it', async () => { + const port = await startMcpAuthProxy(mockAuth as never) + expect(port).toBeGreaterThan(0) + expect(getMcpAuthProxyPort()).toBe(port) + }) + + it('returns existing port on duplicate start', async () => { + const port1 = await startMcpAuthProxy(mockAuth as never) + const port2 = await startMcpAuthProxy(mockAuth as never) + expect(port1).toBe(port2) + }) + + it('registers a target and returns a proxy URL', async () => { + const port = await startMcpAuthProxy(mockAuth as never) + const proxyUrl = registerMcpProxyTarget(`http://127.0.0.1:${upstream.port}`) + expect(proxyUrl).toBe(`http://127.0.0.1:${port}/1`) + }) + + it('returns null from registerMcpProxyTarget when proxy is not running', () => { + const result = registerMcpProxyTarget('http://example.com') + expect(result).toBeNull() + }) + + it('forwards POST requests with fresh JWT to upstream', async () => { + await startMcpAuthProxy(mockAuth as never) + const proxyUrl = registerMcpProxyTarget(`http://127.0.0.1:${upstream.port}`)! + + const body = JSON.stringify({ jsonrpc: '2.0', method: 'tools/list', id: 1 }) + const response = await fetch(proxyUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body + }) + + expect(response.status).toBe(200) + const data = await response.json() + expect(data).toEqual({ result: 'ok', method: 'POST' }) + + // Verify JWT was injected + expect(upstream.lastHeaders()['authorization']).toBe('Bearer jwt-token-1') + // Verify body was forwarded + expect(upstream.lastBody()).toBe(body) + }) + + it('injects a FRESH JWT on each request (not cached)', async () => { + await startMcpAuthProxy(mockAuth as never) + const proxyUrl = registerMcpProxyTarget(`http://127.0.0.1:${upstream.port}`)! + + // First request with jwt-token-1 + await fetch(proxyUrl, { method: 'POST', body: '{}' }) + expect(upstream.lastHeaders()['authorization']).toBe('Bearer jwt-token-1') + + // Change JWT (simulates token refresh) + mockAuth.setJwt('jwt-token-REFRESHED') + + // Second request should use the new JWT + await fetch(proxyUrl, { method: 'POST', body: '{}' }) + expect(upstream.lastHeaders()['authorization']).toBe('Bearer jwt-token-REFRESHED') + + // getJwt was called twice (once per request) + expect(mockAuth.getCallCount()).toBe(2) + }) + + it('preserves sub-paths from the request', async () => { + await startMcpAuthProxy(mockAuth as never) + const proxyUrl = registerMcpProxyTarget(`http://127.0.0.1:${upstream.port}`)! + + await fetch(`${proxyUrl}/sse`, { method: 'GET' }) + expect(upstream.lastUrl()).toBe('/sse') + expect(upstream.lastMethod()).toBe('GET') + }) + + it('returns 404 for unknown target IDs', async () => { + await startMcpAuthProxy(mockAuth as never) + + const port = getMcpAuthProxyPort()! + const response = await fetch(`http://127.0.0.1:${port}/999`, { method: 'POST', body: '{}' }) + expect(response.status).toBe(404) + }) + + it('returns 502 when auth fails', async () => { + await startMcpAuthProxy(mockAuth as never) + const proxyUrl = registerMcpProxyTarget(`http://127.0.0.1:${upstream.port}`)! + + mockAuth.setFail(true) + + const response = await fetch(proxyUrl, { method: 'POST', body: '{}' }) + expect(response.status).toBe(502) + const data = await response.json() + expect(data.error).toContain('Failed to obtain auth token') + }) + + it('unregisters targets correctly', async () => { + await startMcpAuthProxy(mockAuth as never) + const proxyUrl = registerMcpProxyTarget(`http://127.0.0.1:${upstream.port}`)! + + // Works before unregister + const before = await fetch(proxyUrl, { method: 'POST', body: '{}' }) + expect(before.status).toBe(200) + + // Unregister + unregisterMcpProxyTarget(proxyUrl) + + // 404 after unregister + const after = await fetch(proxyUrl, { method: 'POST', body: '{}' }) + expect(after.status).toBe(404) + }) + + it('cleans up on stop', async () => { + await startMcpAuthProxy(mockAuth as never) + expect(getMcpAuthProxyPort()).not.toBeNull() + + stopMcpAuthProxy() + expect(getMcpAuthProxyPort()).toBeNull() + }) + + it('handles multiple concurrent requests with fresh JWT each', async () => { + let callNum = 0 + // Override getJwt to return sequential tokens + mockAuth.getJwt = async () => `jwt-call-${++callNum}` + + await startMcpAuthProxy(mockAuth as never) + const proxyUrl = registerMcpProxyTarget(`http://127.0.0.1:${upstream.port}`)! + + // Fire 5 concurrent requests + const responses = await Promise.all( + Array.from({ length: 5 }, () => + fetch(proxyUrl, { method: 'POST', body: '{}' }) + ) + ) + + // All should succeed + for (const r of responses) { + expect(r.status).toBe(200) + } + + // getJwt should have been called 5 times (once per request) + expect(callNum).toBe(5) + }) + + it('forwards query parameters to upstream', async () => { + await startMcpAuthProxy(mockAuth as never) + const proxyUrl = registerMcpProxyTarget(`http://127.0.0.1:${upstream.port}`)! + + await fetch(`${proxyUrl}?foo=bar&baz=1`, { method: 'GET' }) + expect(upstream.lastUrl()).toBe('/?foo=bar&baz=1') + }) +}) diff --git a/src/main/mcp-auth-proxy.ts b/src/main/mcp-auth-proxy.ts new file mode 100644 index 0000000..ab58451 --- /dev/null +++ b/src/main/mcp-auth-proxy.ts @@ -0,0 +1,198 @@ +/** + * MCP Auth Proxy — local HTTP reverse proxy for transparent JWT refresh. + * + * Runs in the Electron main process, listens on 127.0.0.1:{random port}. + * Agent MCP configs point to this proxy instead of the real remote MCP server. + * On every forwarded request the proxy calls enterpriseAuth.getJwt() to inject + * a fresh Authorization header, so tokens are never stale — even in sessions + * that run for hours. + * + * Flow: + * 1. Agent session starts → registerMcpProxyTarget(targetUrl) + * 2. buildMcpServersForAdapter sets MCP url to http://127.0.0.1://… + * 3. MCP client sends request → proxy gets fresh JWT → forwards to real server + * 4. Real server response is piped back to MCP client unchanged + * + * Why: + * ACP, OpenCode, and Claude Code all bake MCP server headers at session start. + * None refresh them mid-session. A 1-hour JWT expires during long agent runs, + * causing persistent 401 errors on every MCP tool call with no recovery path. + * This proxy decouples token lifetime from session lifetime. + */ + +import { createServer, request as httpRequest, type Server as HttpServer, type IncomingMessage, type ServerResponse } from 'http' +import { request as httpsRequest } from 'https' +import type { EnterpriseAuth } from './enterprise-auth' + +let server: HttpServer | null = null +let port: number | null = null +let authRef: EnterpriseAuth | null = null + +// Registration ID → target URL +const targets = new Map() + +// Monotonic counter for short, collision-free IDs +let nextId = 1 + +export function getMcpAuthProxyPort(): number | null { + return port +} + +/** + * Register a remote MCP server URL and return a localhost proxy URL + * that transparently injects fresh enterprise JWT on every request. + * + * Returns null if the proxy is not running. + */ +export function registerMcpProxyTarget(targetUrl: string): string | null { + if (!port) return null + + const id = String(nextId++) + targets.set(id, targetUrl) + console.log(`[McpAuthProxy] Registered target ${id} → ${targetUrl}`) + return `http://127.0.0.1:${port}/${id}` +} + +/** + * Remove a previously registered target (cleanup on session end). + */ +export function unregisterMcpProxyTarget(proxyUrl: string): void { + // Extract ID from proxy URL: http://127.0.0.1:/ + try { + const url = new URL(proxyUrl) + const id = url.pathname.split('/')[1] + if (id && targets.has(id)) { + targets.delete(id) + console.log(`[McpAuthProxy] Unregistered target ${id}`) + } + } catch { + // Ignore malformed URLs + } +} + +export function startMcpAuthProxy(auth: EnterpriseAuth): Promise { + if (server && port) return Promise.resolve(port) + authRef = auth + + return new Promise((resolve, reject) => { + server = createServer(handleRequest) + + // Listen on random available port, loopback only + server.listen(0, '127.0.0.1', () => { + const addr = server!.address() + if (typeof addr === 'object' && addr) { + port = addr.port + console.log(`[McpAuthProxy] Started on port ${port}`) + resolve(port) + } else { + reject(new Error('Failed to get MCP auth proxy address')) + } + }) + + server.on('error', reject) + }) +} + +export function stopMcpAuthProxy(): void { + if (server) { + server.close() + server = null + port = null + } + targets.clear() + authRef = null + nextId = 1 +} + +// ── Request handler ─────────────────────────────────────────────── + +async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise { + try { + // Parse: //optional/sub/path + const reqUrl = new URL(req.url || '/', 'http://localhost') + const pathParts = reqUrl.pathname.split('/').filter(Boolean) + const id = pathParts[0] + + if (!id || !targets.has(id)) { + res.writeHead(404, { 'Content-Type': 'text/plain' }) + res.end('Unknown proxy target') + return + } + + // Build the target URL: base + remaining path + query string + const targetBase = targets.get(id)! + const remainingPath = '/' + pathParts.slice(1).join('/') + const targetUrl = new URL(remainingPath, targetBase) + // Preserve any sub-path from the original target URL + if (targetBase.includes('/', 8)) { + // targetBase has a path component (e.g. https://host/mcp) + const baseUrl = new URL(targetBase) + targetUrl.pathname = baseUrl.pathname.replace(/\/$/, '') + remainingPath + } + targetUrl.search = reqUrl.search + + // Get fresh JWT + if (!authRef) { + res.writeHead(503, { 'Content-Type': 'text/plain' }) + res.end('Auth not available') + return + } + + let jwt: string + try { + jwt = await authRef.getJwt() + } catch (err) { + console.error('[McpAuthProxy] Failed to get JWT:', err) + res.writeHead(502, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ error: 'Failed to obtain auth token' })) + return + } + + // Build forwarded headers — copy everything except host + const forwardHeaders: Record = {} + for (const [key, value] of Object.entries(req.headers)) { + if (key.toLowerCase() === 'host') continue + // Skip connection-specific headers + if (key.toLowerCase() === 'connection') continue + forwardHeaders[key] = value + } + forwardHeaders['authorization'] = `Bearer ${jwt}` + forwardHeaders['host'] = targetUrl.host + + // Choose http or https + const isHttps = targetUrl.protocol === 'https:' + const targetString = targetUrl.toString() + const reqOptions = { method: req.method, headers: forwardHeaders } + + const callback = (proxyRes: IncomingMessage) => { + // Forward status + headers back to MCP client + const responseHeaders = { ...proxyRes.headers } + // Remove transfer-encoding if we're piping (Node handles chunking) + delete responseHeaders['transfer-encoding'] + + res.writeHead(proxyRes.statusCode || 502, responseHeaders) + proxyRes.pipe(res) + } + + const proxyReq = isHttps + ? httpsRequest(targetString, reqOptions, callback) + : httpRequest(targetString, reqOptions, callback) + + proxyReq.on('error', (err) => { + console.error(`[McpAuthProxy] Upstream error for target ${id}:`, err.message) + if (!res.headersSent) { + res.writeHead(502, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ error: `Upstream error: ${err.message}` })) + } + }) + + // Pipe request body to upstream + req.pipe(proxyReq) + } catch (err) { + console.error('[McpAuthProxy] Unexpected error:', err) + if (!res.headersSent) { + res.writeHead(500, { 'Content-Type': 'text/plain' }) + res.end('Internal proxy error') + } + } +} From dbb7656e6a7eff03fcaf0cac0769cf00f01a8280 Mon Sep 17 00:00:00 2001 From: dimavedenyapin Date: Sat, 25 Apr 2026 17:02:41 +0700 Subject: [PATCH 2/8] fix: deduplicate proxy target registration to prevent ID leak registerMcpProxyTarget is called on every buildMcpServersForAdapter, which runs per-prompt for Claude Code. Without dedup, each prompt creates a new target ID. Now returns the existing proxy URL if the same target URL was already registered. Co-Authored-By: Claude Opus 4.6 --- src/main/mcp-auth-proxy.test.ts | 18 ++++++++++++++++++ src/main/mcp-auth-proxy.ts | 17 +++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/src/main/mcp-auth-proxy.test.ts b/src/main/mcp-auth-proxy.test.ts index 0bad126..4484a28 100644 --- a/src/main/mcp-auth-proxy.test.ts +++ b/src/main/mcp-auth-proxy.test.ts @@ -113,6 +113,24 @@ describe('MCP Auth Proxy', () => { expect(proxyUrl).toBe(`http://127.0.0.1:${port}/1`) }) + it('deduplicates repeated registrations for the same target URL', async () => { + const port = await startMcpAuthProxy(mockAuth as never) + const targetUrl = `http://127.0.0.1:${upstream.port}` + + const url1 = registerMcpProxyTarget(targetUrl) + const url2 = registerMcpProxyTarget(targetUrl) + const url3 = registerMcpProxyTarget(targetUrl) + + // All calls should return the same proxy URL (same ID) + expect(url1).toBe(`http://127.0.0.1:${port}/1`) + expect(url2).toBe(url1) + expect(url3).toBe(url1) + + // A different target URL should get a new ID + const url4 = registerMcpProxyTarget('http://example.com:9999') + expect(url4).toBe(`http://127.0.0.1:${port}/2`) + }) + it('returns null from registerMcpProxyTarget when proxy is not running', () => { const result = registerMcpProxyTarget('http://example.com') expect(result).toBeNull() diff --git a/src/main/mcp-auth-proxy.ts b/src/main/mcp-auth-proxy.ts index ab58451..df19600 100644 --- a/src/main/mcp-auth-proxy.ts +++ b/src/main/mcp-auth-proxy.ts @@ -31,6 +31,9 @@ let authRef: EnterpriseAuth | null = null // Registration ID → target URL const targets = new Map() +// Reverse lookup: target URL → ID (deduplicates repeated registrations) +const targetsByUrl = new Map() + // Monotonic counter for short, collision-free IDs let nextId = 1 @@ -42,13 +45,24 @@ export function getMcpAuthProxyPort(): number | null { * Register a remote MCP server URL and return a localhost proxy URL * that transparently injects fresh enterprise JWT on every request. * + * If the same targetUrl was already registered, returns the existing + * proxy URL (prevents leaking IDs when buildMcpServersForAdapter is + * called on every prompt for Claude Code adapter). + * * Returns null if the proxy is not running. */ export function registerMcpProxyTarget(targetUrl: string): string | null { if (!port) return null + // Reuse existing registration for the same target URL + const existingId = targetsByUrl.get(targetUrl) + if (existingId) { + return `http://127.0.0.1:${port}/${existingId}` + } + const id = String(nextId++) targets.set(id, targetUrl) + targetsByUrl.set(targetUrl, id) console.log(`[McpAuthProxy] Registered target ${id} → ${targetUrl}`) return `http://127.0.0.1:${port}/${id}` } @@ -62,7 +76,9 @@ export function unregisterMcpProxyTarget(proxyUrl: string): void { const url = new URL(proxyUrl) const id = url.pathname.split('/')[1] if (id && targets.has(id)) { + const targetUrl = targets.get(id)! targets.delete(id) + targetsByUrl.delete(targetUrl) console.log(`[McpAuthProxy] Unregistered target ${id}`) } } catch { @@ -100,6 +116,7 @@ export function stopMcpAuthProxy(): void { port = null } targets.clear() + targetsByUrl.clear() authRef = null nextId = 1 } From d95434638b6a2875e0f8a80122fb694cd46ca15c Mon Sep 17 00:00:00 2001 From: dimavedenyapin Date: Sat, 25 Apr 2026 17:46:13 +0700 Subject: [PATCH 3/8] fix: correct proxy URL construction and SSE streaming support Two bugs prevented MCP connection through the proxy: 1. Trailing slash: POST to / produced target URL with trailing slash (e.g. /api/mcp/dev/mcp/) causing 404. Now uses target base URL as-is when there's no sub-path. 2. SSE buffering: deleting transfer-encoding header broke SSE event streaming. Now preserves it for text/event-stream responses and writes each chunk directly for immediate delivery. Co-Authored-By: Claude Opus 4.6 --- src/main/mcp-auth-proxy.ts | 36 +++++++++++++++++++++++++----------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/src/main/mcp-auth-proxy.ts b/src/main/mcp-auth-proxy.ts index df19600..1eb9bd6 100644 --- a/src/main/mcp-auth-proxy.ts +++ b/src/main/mcp-auth-proxy.ts @@ -136,16 +136,15 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise return } - // Build the target URL: base + remaining path + query string + // Build the target URL: base + remaining sub-path + query string const targetBase = targets.get(id)! - const remainingPath = '/' + pathParts.slice(1).join('/') - const targetUrl = new URL(remainingPath, targetBase) - // Preserve any sub-path from the original target URL - if (targetBase.includes('/', 8)) { - // targetBase has a path component (e.g. https://host/mcp) - const baseUrl = new URL(targetBase) - targetUrl.pathname = baseUrl.pathname.replace(/\/$/, '') + remainingPath + const subPathParts = pathParts.slice(1) + const targetUrl = new URL(targetBase) + if (subPathParts.length > 0) { + // Append sub-path: //sse → targetBase + /sse + targetUrl.pathname = targetUrl.pathname.replace(/\/$/, '') + '/' + subPathParts.join('/') } + // else: no sub-path — use targetBase as-is (no trailing slash added) targetUrl.search = reqUrl.search // Get fresh JWT @@ -184,11 +183,26 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise const callback = (proxyRes: IncomingMessage) => { // Forward status + headers back to MCP client const responseHeaders = { ...proxyRes.headers } - // Remove transfer-encoding if we're piping (Node handles chunking) - delete responseHeaders['transfer-encoding'] + // Remove transfer-encoding — Node handles chunking on the proxy→client leg. + // But preserve it for SSE (text/event-stream) responses so events flush immediately. + const contentType = String(proxyRes.headers['content-type'] || '') + const isSSE = contentType.includes('text/event-stream') + if (!isSSE) { + delete responseHeaders['transfer-encoding'] + } res.writeHead(proxyRes.statusCode || 502, responseHeaders) - proxyRes.pipe(res) + + if (isSSE) { + // For SSE, flush each chunk immediately so events aren't buffered + proxyRes.on('data', (chunk: Buffer) => { + res.write(chunk) + }) + proxyRes.on('end', () => res.end()) + proxyRes.on('error', () => res.end()) + } else { + proxyRes.pipe(res) + } } const proxyReq = isHttps From 1653839e26cbf6311a1a4a18672abf5cf5f76183 Mon Sep 17 00:00:00 2001 From: dimavedenyapin Date: Sat, 25 Apr 2026 17:57:24 +0700 Subject: [PATCH 4/8] chore: add diagnostic logging to proxy for debugging MCP connection Logs every incoming request, target resolution, upstream URL, and response status so we can see what's happening when the MCP client tries to connect through the proxy. Co-Authored-By: Claude Opus 4.6 --- src/main/mcp-auth-proxy.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/main/mcp-auth-proxy.ts b/src/main/mcp-auth-proxy.ts index 1eb9bd6..b1a3769 100644 --- a/src/main/mcp-auth-proxy.ts +++ b/src/main/mcp-auth-proxy.ts @@ -130,7 +130,10 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise const pathParts = reqUrl.pathname.split('/').filter(Boolean) const id = pathParts[0] + console.log(`[McpAuthProxy] ${req.method} ${req.url} → target ${id || '(none)'}`) + if (!id || !targets.has(id)) { + console.warn(`[McpAuthProxy] 404 — unknown target ID "${id}", registered: [${[...targets.keys()].join(', ')}]`) res.writeHead(404, { 'Content-Type': 'text/plain' }) res.end('Unknown proxy target') return @@ -180,7 +183,10 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise const targetString = targetUrl.toString() const reqOptions = { method: req.method, headers: forwardHeaders } + console.log(`[McpAuthProxy] → ${req.method} ${targetString}`) + const callback = (proxyRes: IncomingMessage) => { + console.log(`[McpAuthProxy] ← ${proxyRes.statusCode} ${proxyRes.headers['content-type'] || '(no content-type)'}`) // Forward status + headers back to MCP client const responseHeaders = { ...proxyRes.headers } // Remove transfer-encoding — Node handles chunking on the proxy→client leg. From d7f63afc8dc780ceeaf4d9181c5d914e2003c2cf Mon Sep 17 00:00:00 2001 From: dimavedenyapin Date: Sat, 25 Apr 2026 18:10:03 +0700 Subject: [PATCH 5/8] fix: revert to static JWT injection for MCP Dev Server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The proxy starts correctly but the Claude Code SDK never sends requests to the proxy URL — zero HTTP requests reach the handler. The SDK silently fails to establish an MCP transport through the proxy. Reverting to the original static JWT injection that works. The proxy code and tests remain in the codebase for future investigation. Co-Authored-By: Claude Opus 4.6 --- src/main/agent-manager.ts | 28 ++++++++-------------------- 1 file changed, 8 insertions(+), 20 deletions(-) diff --git a/src/main/agent-manager.ts b/src/main/agent-manager.ts index ca2aa87..cec51ac 100644 --- a/src/main/agent-manager.ts +++ b/src/main/agent-manager.ts @@ -18,7 +18,8 @@ import { SessionStatusType, MessagePartType, MessageRole } from './adapters/codi import { getTaskApiPort, waitForTaskApiServer } from './task-api-server' import { randomUUID } from 'crypto' import { registerSecretSession, unregisterSecretSession, getSecretBrokerPort, writeSecretShellWrapper } from './secret-broker' -import { registerMcpProxyTarget, getMcpAuthProxyPort } from './mcp-auth-proxy' +// Proxy available but not wired in yet — SDK doesn't connect to proxy URLs (under investigation) +// import { registerMcpProxyTarget, getMcpAuthProxyPort } from './mcp-auth-proxy' // Coding agent backend type enum enum CodingAgentType { @@ -338,31 +339,18 @@ export class AgentManager extends EventEmitter { } // Inject enterprise JWT for Workflo MCP Dev Server - // Prefer auth proxy (auto-refreshes JWT on every request) over static header injection - let finalUrl = mcpServer.url if (mcpServer.name === '[Workflo] MCP Dev Server' && this.enterpriseAuth) { - const proxyUrl = getMcpAuthProxyPort() ? registerMcpProxyTarget(mcpServer.url) : null - if (proxyUrl) { - // Route through auth proxy — JWT is injected on every forwarded request - finalUrl = proxyUrl - // Remove any stale Authorization header — proxy handles auth - delete finalHeaders['Authorization'] - console.log(`[AgentManager] MCP Dev Server routed through auth proxy: ${proxyUrl}`) - } else { - // Fallback: inject static JWT (original behavior — expires after ~55 min) - try { - const jwt = await this.enterpriseAuth.getJwt() - finalHeaders = { ...finalHeaders, Authorization: `Bearer ${jwt}` } - console.warn('[AgentManager] MCP auth proxy not available, using static JWT (will expire in long sessions)') - } catch (err) { - console.warn('[AgentManager] Failed to inject enterprise JWT for MCP Dev Server:', err) - } + try { + const jwt = await this.enterpriseAuth.getJwt() + finalHeaders = { ...finalHeaders, Authorization: `Bearer ${jwt}` } + } catch (err) { + console.warn('[AgentManager] Failed to inject enterprise JWT for MCP Dev Server:', err) } } result[mcpServer.name] = { type: 'http', - url: finalUrl, + url: mcpServer.url, headers: finalHeaders } } From 522247d277cb7bad03919618611526d403d14386 Mon Sep 17 00:00:00 2001 From: dimavedenyapin Date: Sat, 25 Apr 2026 18:25:15 +0700 Subject: [PATCH 6/8] Re-enable MCP auth proxy routing with detailed config logging Routes [Workflo] MCP Dev Server through the local auth proxy (http://127.0.0.1:/) using type: 'http' (Streamable HTTP). Proxy injects fresh JWT on every request, decoupling token lifetime from session lifetime. Added logging of exact MCP config passed to claude binary for debugging. Added test-mcp-proxy-client.mjs script to manually verify proxy connectivity. Co-Authored-By: Claude Opus 4.6 --- scripts/test-mcp-proxy-client.mjs | 107 ++++++++++++++++++++++++++++++ src/main/agent-manager.ts | 35 +++++++--- 2 files changed, 132 insertions(+), 10 deletions(-) create mode 100644 scripts/test-mcp-proxy-client.mjs diff --git a/scripts/test-mcp-proxy-client.mjs b/scripts/test-mcp-proxy-client.mjs new file mode 100644 index 0000000..49e4723 --- /dev/null +++ b/scripts/test-mcp-proxy-client.mjs @@ -0,0 +1,107 @@ +#!/usr/bin/env node +/** + * Test script that simulates what the claude binary does: + * connects to a local MCP proxy URL and sends JSON-RPC requests. + * + * Usage: + * node scripts/test-mcp-proxy-client.mjs + * + * Example: + * node scripts/test-mcp-proxy-client.mjs http://127.0.0.1:50066/1 + * + * This sends MCP Streamable HTTP requests (JSON-RPC over POST) + * exactly like the claude binary would. + */ + +const proxyUrl = process.argv[2] + +if (!proxyUrl) { + console.error('Usage: node scripts/test-mcp-proxy-client.mjs ') + console.error('Example: node scripts/test-mcp-proxy-client.mjs http://127.0.0.1:50066/1') + process.exit(1) +} + +async function sendJsonRpc(url, method, params = {}, id = 1) { + const body = JSON.stringify({ jsonrpc: '2.0', method, params, id }) + console.log(`\n→ POST ${url}`) + console.log(` Body: ${body}`) + + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + }) + console.log(`← Status: ${res.status} ${res.statusText}`) + console.log(` Headers:`, Object.fromEntries(res.headers.entries())) + const text = await res.text() + console.log(` Body: ${text.substring(0, 500)}`) + return { status: res.status, text } + } catch (err) { + console.error(`✗ Error: ${err.message}`) + return { status: 0, error: err.message } + } +} + +async function testSSE(url) { + const sseUrl = url.replace(/\/$/, '') + '/sse' + console.log(`\n→ GET ${sseUrl} (SSE)`) + + try { + const res = await fetch(sseUrl, { + method: 'GET', + headers: { Accept: 'text/event-stream' }, + }) + console.log(`← Status: ${res.status} ${res.statusText}`) + console.log(` Content-Type: ${res.headers.get('content-type')}`) + + if (res.status === 200) { + // Read first few events + const reader = res.body.getReader() + const decoder = new TextDecoder() + let chunks = '' + const timeout = setTimeout(() => { + reader.cancel() + }, 3000) + + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + chunks += decoder.decode(value, { stream: true }) + console.log(` SSE chunk: ${decoder.decode(value).substring(0, 200)}`) + } + } catch { + // reader cancelled by timeout + } + clearTimeout(timeout) + } else { + const text = await res.text() + console.log(` Body: ${text}`) + } + } catch (err) { + console.error(`✗ SSE Error: ${err.message}`) + } +} + +console.log('=== MCP Proxy Client Test ===') +console.log(`Target: ${proxyUrl}`) +console.log('') + +// Test 1: MCP initialize +console.log('--- Test 1: initialize ---') +await sendJsonRpc(proxyUrl, 'initialize', { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { name: 'test-client', version: '1.0' }, +}) + +// Test 2: tools/list +console.log('\n--- Test 2: tools/list ---') +await sendJsonRpc(proxyUrl, 'tools/list', {}, 2) + +// Test 3: SSE endpoint +console.log('\n--- Test 3: SSE endpoint ---') +await testSSE(proxyUrl) + +console.log('\n=== Done ===') diff --git a/src/main/agent-manager.ts b/src/main/agent-manager.ts index cec51ac..792d1b2 100644 --- a/src/main/agent-manager.ts +++ b/src/main/agent-manager.ts @@ -18,8 +18,7 @@ import { SessionStatusType, MessagePartType, MessageRole } from './adapters/codi import { getTaskApiPort, waitForTaskApiServer } from './task-api-server' import { randomUUID } from 'crypto' import { registerSecretSession, unregisterSecretSession, getSecretBrokerPort, writeSecretShellWrapper } from './secret-broker' -// Proxy available but not wired in yet — SDK doesn't connect to proxy URLs (under investigation) -// import { registerMcpProxyTarget, getMcpAuthProxyPort } from './mcp-auth-proxy' +import { registerMcpProxyTarget, getMcpAuthProxyPort } from './mcp-auth-proxy' // Coding agent backend type enum enum CodingAgentType { @@ -338,19 +337,31 @@ export class AgentManager extends EventEmitter { } } - // Inject enterprise JWT for Workflo MCP Dev Server + // Inject enterprise JWT for Workflo MCP Dev Server — route through auth proxy + let finalUrl = mcpServer.url if (mcpServer.name === '[Workflo] MCP Dev Server' && this.enterpriseAuth) { - try { - const jwt = await this.enterpriseAuth.getJwt() - finalHeaders = { ...finalHeaders, Authorization: `Bearer ${jwt}` } - } catch (err) { - console.warn('[AgentManager] Failed to inject enterprise JWT for MCP Dev Server:', err) + const proxyPort = getMcpAuthProxyPort() + const proxyUrl = proxyPort ? registerMcpProxyTarget(mcpServer.url) : null + if (proxyUrl) { + finalUrl = proxyUrl + // Don't send static Authorization — proxy injects fresh JWT per request + delete finalHeaders['Authorization'] + console.log(`[AgentManager] MCP Dev Server routed through auth proxy: ${proxyUrl}`) + } else { + // Fallback: static JWT (proxy not running) + try { + const jwt = await this.enterpriseAuth.getJwt() + finalHeaders = { ...finalHeaders, Authorization: `Bearer ${jwt}` } + console.log('[AgentManager] MCP Dev Server using static JWT (proxy not available)') + } catch (err) { + console.warn('[AgentManager] Failed to inject enterprise JWT for MCP Dev Server:', err) + } } } result[mcpServer.name] = { type: 'http', - url: mcpServer.url, + url: finalUrl, headers: finalHeaders } } @@ -382,7 +393,11 @@ export class AgentManager extends EventEmitter { } } - // result keys logged at debug level only + // Log the exact MCP config that will be passed to the agent binary via --mcp-config + for (const [name, cfg] of Object.entries(result)) { + const c = cfg as unknown as Record + console.log(`[AgentManager] MCP config for "${name}": type=${c.type}, url=${c.url || '(stdio)'}, headers=${c.headers ? Object.keys(c.headers as object).join(',') : 'none'}`) + } return result } From ead476f7b22bfb048be55c1cdde88a9af3449719 Mon Sep 17 00:00:00 2001 From: dimavedenyapin Date: Sat, 25 Apr 2026 20:54:27 +0700 Subject: [PATCH 7/8] fix: start MCP auth proxy on enterprise login, not just session restore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The proxy was only started in index.ts during startup session restore. If the enterprise session wasn't cached (isAuthenticated: false), the proxy never started — even after the user logged in via ipc-handlers. Now startMcpAuthProxy() is also called in the enterprise login flow (ipc-handlers.ts) so it works regardless of startup state. Co-Authored-By: Claude Opus 4.6 --- src/main/ipc-handlers.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/main/ipc-handlers.ts b/src/main/ipc-handlers.ts index 382f95e..a6a6ddc 100644 --- a/src/main/ipc-handlers.ts +++ b/src/main/ipc-handlers.ts @@ -875,6 +875,15 @@ export function registerIpcHandlers( // Pass enterprise auth to agent manager so it can inject JWT into MCP Dev Server requests agentManager.setEnterpriseAuth(enterpriseAuth!) + // Start MCP auth proxy so agent sessions get auto-refreshing JWT + try { + const { startMcpAuthProxy } = await import('./mcp-auth-proxy') + const proxyPort = await startMcpAuthProxy(enterpriseAuth!) + console.log(`[enterprise] MCP auth proxy started on port ${proxyPort}`) + } catch (proxyErr) { + console.warn('[enterprise] MCP auth proxy failed to start:', proxyErr) + } + // Run initial sync (agents, skills, MCP servers) const syncStart = Date.now() console.log('[enterprise] Running initial resource sync...') From 882f530af191c9b0249f70371833837589686d0e Mon Sep 17 00:00:00 2001 From: dimavedenyapin Date: Sun, 26 Apr 2026 10:06:08 +0700 Subject: [PATCH 8/8] chore: clean up verbose proxy logging and remove test script Reduce proxy logs to errors-only (startup + 404 + auth failures). Remove per-request logging and debug MCP config dump now that the proxy is verified working end-to-end. Co-Authored-By: Claude Opus 4.6 --- scripts/test-mcp-proxy-client.mjs | 107 ------------------------------ src/main/agent-manager.ts | 5 -- src/main/mcp-auth-proxy.ts | 8 +-- 3 files changed, 2 insertions(+), 118 deletions(-) delete mode 100644 scripts/test-mcp-proxy-client.mjs diff --git a/scripts/test-mcp-proxy-client.mjs b/scripts/test-mcp-proxy-client.mjs deleted file mode 100644 index 49e4723..0000000 --- a/scripts/test-mcp-proxy-client.mjs +++ /dev/null @@ -1,107 +0,0 @@ -#!/usr/bin/env node -/** - * Test script that simulates what the claude binary does: - * connects to a local MCP proxy URL and sends JSON-RPC requests. - * - * Usage: - * node scripts/test-mcp-proxy-client.mjs - * - * Example: - * node scripts/test-mcp-proxy-client.mjs http://127.0.0.1:50066/1 - * - * This sends MCP Streamable HTTP requests (JSON-RPC over POST) - * exactly like the claude binary would. - */ - -const proxyUrl = process.argv[2] - -if (!proxyUrl) { - console.error('Usage: node scripts/test-mcp-proxy-client.mjs ') - console.error('Example: node scripts/test-mcp-proxy-client.mjs http://127.0.0.1:50066/1') - process.exit(1) -} - -async function sendJsonRpc(url, method, params = {}, id = 1) { - const body = JSON.stringify({ jsonrpc: '2.0', method, params, id }) - console.log(`\n→ POST ${url}`) - console.log(` Body: ${body}`) - - try { - const res = await fetch(url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body, - }) - console.log(`← Status: ${res.status} ${res.statusText}`) - console.log(` Headers:`, Object.fromEntries(res.headers.entries())) - const text = await res.text() - console.log(` Body: ${text.substring(0, 500)}`) - return { status: res.status, text } - } catch (err) { - console.error(`✗ Error: ${err.message}`) - return { status: 0, error: err.message } - } -} - -async function testSSE(url) { - const sseUrl = url.replace(/\/$/, '') + '/sse' - console.log(`\n→ GET ${sseUrl} (SSE)`) - - try { - const res = await fetch(sseUrl, { - method: 'GET', - headers: { Accept: 'text/event-stream' }, - }) - console.log(`← Status: ${res.status} ${res.statusText}`) - console.log(` Content-Type: ${res.headers.get('content-type')}`) - - if (res.status === 200) { - // Read first few events - const reader = res.body.getReader() - const decoder = new TextDecoder() - let chunks = '' - const timeout = setTimeout(() => { - reader.cancel() - }, 3000) - - try { - while (true) { - const { done, value } = await reader.read() - if (done) break - chunks += decoder.decode(value, { stream: true }) - console.log(` SSE chunk: ${decoder.decode(value).substring(0, 200)}`) - } - } catch { - // reader cancelled by timeout - } - clearTimeout(timeout) - } else { - const text = await res.text() - console.log(` Body: ${text}`) - } - } catch (err) { - console.error(`✗ SSE Error: ${err.message}`) - } -} - -console.log('=== MCP Proxy Client Test ===') -console.log(`Target: ${proxyUrl}`) -console.log('') - -// Test 1: MCP initialize -console.log('--- Test 1: initialize ---') -await sendJsonRpc(proxyUrl, 'initialize', { - protocolVersion: '2024-11-05', - capabilities: {}, - clientInfo: { name: 'test-client', version: '1.0' }, -}) - -// Test 2: tools/list -console.log('\n--- Test 2: tools/list ---') -await sendJsonRpc(proxyUrl, 'tools/list', {}, 2) - -// Test 3: SSE endpoint -console.log('\n--- Test 3: SSE endpoint ---') -await testSSE(proxyUrl) - -console.log('\n=== Done ===') diff --git a/src/main/agent-manager.ts b/src/main/agent-manager.ts index 792d1b2..462cbac 100644 --- a/src/main/agent-manager.ts +++ b/src/main/agent-manager.ts @@ -393,11 +393,6 @@ export class AgentManager extends EventEmitter { } } - // Log the exact MCP config that will be passed to the agent binary via --mcp-config - for (const [name, cfg] of Object.entries(result)) { - const c = cfg as unknown as Record - console.log(`[AgentManager] MCP config for "${name}": type=${c.type}, url=${c.url || '(stdio)'}, headers=${c.headers ? Object.keys(c.headers as object).join(',') : 'none'}`) - } return result } diff --git a/src/main/mcp-auth-proxy.ts b/src/main/mcp-auth-proxy.ts index b1a3769..ba9f03a 100644 --- a/src/main/mcp-auth-proxy.ts +++ b/src/main/mcp-auth-proxy.ts @@ -130,10 +130,9 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise const pathParts = reqUrl.pathname.split('/').filter(Boolean) const id = pathParts[0] - console.log(`[McpAuthProxy] ${req.method} ${req.url} → target ${id || '(none)'}`) - if (!id || !targets.has(id)) { - console.warn(`[McpAuthProxy] 404 — unknown target ID "${id}", registered: [${[...targets.keys()].join(', ')}]`) + console.warn(`[McpAuthProxy] 404 — unknown target ID "${id}"`) + res.writeHead(404, { 'Content-Type': 'text/plain' }) res.end('Unknown proxy target') return @@ -183,10 +182,7 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise const targetString = targetUrl.toString() const reqOptions = { method: req.method, headers: forwardHeaders } - console.log(`[McpAuthProxy] → ${req.method} ${targetString}`) - const callback = (proxyRes: IncomingMessage) => { - console.log(`[McpAuthProxy] ← ${proxyRes.statusCode} ${proxyRes.headers['content-type'] || '(no content-type)'}`) // Forward status + headers back to MCP client const responseHeaders = { ...proxyRes.headers } // Remove transfer-encoding — Node handles chunking on the proxy→client leg.