From 3a26f4cd5891a7559ea7681bbcc41f41eb628c23 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 20 Jun 2026 18:54:10 +0000 Subject: [PATCH 1/9] chore(deps): bump js-yaml in the production-deps group Bumps the production-deps group with 1 update: [js-yaml](https://github.com/nodeca/js-yaml). Updates `js-yaml` from 4.2.0 to 5.0.0 - [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md) - [Commits](https://github.com/nodeca/js-yaml/compare/4.2.0...5.0.0) --- updated-dependencies: - dependency-name: js-yaml dependency-version: 5.0.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: production-deps ... Signed-off-by: dependabot[bot] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b2c6a71..2db63ef 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "dompurify": "^3.4.3", "highlight.js": "^11.11.1", "i18next": "^26.0.10", - "js-yaml": "^4.1.0", + "js-yaml": "^5.0.0", "listr2": "^10.2.1", "lucide-react": "^1.14.0", "marked": "^18.0.3", From af9cc92b3f9f3cee210b3567dc4f80a16cf80d1b Mon Sep 17 00:00:00 2001 From: Antonio Zaitoun Date: Sun, 28 Jun 2026 21:58:51 +0300 Subject: [PATCH 2/9] fix: fall back to unauthenticated fetch on expired git token, guard server against empty responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #127 — remote skills whose URL is on a known git host (e.g. raw.githubusercontent.com) could not be installed when the user had a stored-but-expired GitHub token. AuthenticatedFetch now returns null from ensureFreshIntegration instead of throwing, so public URLs proceed unauthenticated; private URLs still surface a 401/403 which the existing handler converts into the friendly re-auth prompt. Fixes #126 — the local capa server could return net::ERR_EMPTY_RESPONSE when an MCP server was unreachable: - Added a top-level try/catch in handleRequest so no request can ever close the connection without sending an HTTP response. - Wrapped handleGetOAuth2Servers (whole handler + per-server OAuth detection loop) in try/catch, mirroring the pattern used by the configure endpoint. - Added OAUTH_DETECT_TIMEOUT_MS (10 s) to all outbound fetches in OAuth2Manager.detectOAuth2Requirement so a hung server fails fast. - Added MCP_CONNECT_TIMEOUT_MS (15 s) race in MCPProxy.createHttpClient and createStdioClient so a non-responsive MCP server does not stall indefinitely. Co-authored-by: Cursor --- src/server/__tests__/oauth-manager.test.ts | 28 +++++ src/server/index.ts | 116 +++++++++++------- src/server/mcp-proxy.ts | 17 ++- src/server/oauth-manager.ts | 14 ++- .../__tests__/authenticated-fetch.test.ts | 40 ++++-- src/shared/authenticated-fetch.ts | 19 ++- 6 files changed, 171 insertions(+), 63 deletions(-) diff --git a/src/server/__tests__/oauth-manager.test.ts b/src/server/__tests__/oauth-manager.test.ts index a075b62..31e6f31 100644 --- a/src/server/__tests__/oauth-manager.test.ts +++ b/src/server/__tests__/oauth-manager.test.ts @@ -63,6 +63,34 @@ describe('OAuth2Manager', () => { }); }); + describe('detectOAuth2Requirement', () => { + const originalFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it('returns null (does not throw) when the server is unreachable', async () => { + // Simulate a connection-refused / aborted fetch — the same class of error + // that an unreachable MCP server produces at the network layer. + globalThis.fetch = (async () => { + throw new DOMException('The operation was aborted.', 'AbortError'); + }) as typeof fetch; + + const manager = new OAuth2Manager(makeMockDb()); + const result = await manager.detectOAuth2Requirement('http://192.0.2.1:9999/mcp'); + expect(result).toBeNull(); + }); + + it('returns null when the MCP server returns a non-401 status', async () => { + globalThis.fetch = (async () => new Response('', { status: 200 })) as typeof fetch; + + const manager = new OAuth2Manager(makeMockDb()); + const result = await manager.detectOAuth2Requirement('http://localhost:9999/mcp'); + expect(result).toBeNull(); + }); + }); + describe('tlsSkipVerify wiring', () => { it('returns false when env is unset even if config requests skip', () => { expect(shouldSkipTlsVerify(true, 'OAuth2 detection (test)')).toBe(false); diff --git a/src/server/index.ts b/src/server/index.ts index aa99c3e..7dfbaf5 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -211,6 +211,18 @@ class CapaServer { private async handleRequest(request: Request, server: any): Promise { + try { + return await this._handleRequest(request, server); + } catch (error: any) { + this.logger.failure(`Unhandled error in request handler: ${error?.message ?? error}`); + return new Response( + JSON.stringify({ error: 'Internal server error' }), + { status: 500, headers: { 'Content-Type': 'application/json' } } + ); + } + } + + private async _handleRequest(request: Request, server: any): Promise { const url = new URL(request.url); const path = url.pathname; @@ -1213,57 +1225,69 @@ class CapaServer { private async handleGetOAuth2Servers(projectId: string): Promise { const apiLogger = this.logger.child('API'); apiLogger.info(`Get OAuth2 servers for project: ${projectId}`); - const capabilities = this.sessionManager.getProjectCapabilities(projectId); - if (!capabilities) { - return new Response( - JSON.stringify({ error: 'Project not configured' }), - { status: 404, headers: { 'Content-Type': 'application/json' } } - ); - } + try { + const capabilities = this.sessionManager.getProjectCapabilities(projectId); + if (!capabilities) { + return new Response( + JSON.stringify({ error: 'Project not configured' }), + { status: 404, headers: { 'Content-Type': 'application/json' } } + ); + } - // Ensure URL-based servers that require OAuth have def.oauth2 set (on-demand detection) - let capabilitiesUpdated = false; - for (const server of capabilities.servers) { - const hasExplicitAuthOnDemand = server.def.headers && - Object.keys(server.def.headers).some(k => k.toLowerCase() === 'authorization'); - if (server.def.url && !server.def.oauth2 && !hasExplicitAuthOnDemand) { - const oauth2Config = await this.oauth2Manager.detectOAuth2Requirement(server.def.url, { tlsSkipVerify: server.def.tlsSkipVerify }); - if (oauth2Config) { - apiLogger.debug(`OAuth2 detected for ${server.id} (on-demand)`); - server.def.oauth2 = oauth2Config; - capabilitiesUpdated = true; + // Ensure URL-based servers that require OAuth have def.oauth2 set (on-demand detection) + let capabilitiesUpdated = false; + for (const server of capabilities.servers) { + const hasExplicitAuthOnDemand = server.def.headers && + Object.keys(server.def.headers).some(k => k.toLowerCase() === 'authorization'); + if (server.def.url && !server.def.oauth2 && !hasExplicitAuthOnDemand) { + try { + const oauth2Config = await this.oauth2Manager.detectOAuth2Requirement(server.def.url, { tlsSkipVerify: server.def.tlsSkipVerify }); + if (oauth2Config) { + apiLogger.debug(`OAuth2 detected for ${server.id} (on-demand)`); + server.def.oauth2 = oauth2Config; + capabilitiesUpdated = true; + } + } catch (detectionError: any) { + apiLogger.warn(`OAuth2 detection failed for ${server.id}: ${detectionError?.message ?? detectionError}`); + } } } - } - if (capabilitiesUpdated) { - this.sessionManager.setProjectCapabilities(projectId, capabilities); - } + if (capabilitiesUpdated) { + this.sessionManager.setProjectCapabilities(projectId, capabilities); + } - const oauth2Servers = capabilities.servers - .filter((s: any) => s.def.oauth2) - .map((s: MCPServer) => { - const isConnected = this.oauth2Manager.isServerConnected(projectId, s.id); - let expiresAt: number | undefined; - - if (isConnected) { - const tokenData = this.db.getOAuthToken(projectId, s.id); - expiresAt = tokenData?.expires_at ?? undefined; - } - - return { - serverId: s.id, - serverUrl: s.def.url, - displayName: s.displayName ?? s.id, - isConnected: isConnected, - expiresAt: expiresAt, - oauth2Config: s.def.oauth2, - }; - }); + const oauth2Servers = capabilities.servers + .filter((s: any) => s.def.oauth2) + .map((s: MCPServer) => { + const isConnected = this.oauth2Manager.isServerConnected(projectId, s.id); + let expiresAt: number | undefined; - return new Response( - JSON.stringify({ servers: oauth2Servers }), - { headers: { 'Content-Type': 'application/json' } } - ); + if (isConnected) { + const tokenData = this.db.getOAuthToken(projectId, s.id); + expiresAt = tokenData?.expires_at ?? undefined; + } + + return { + serverId: s.id, + serverUrl: s.def.url, + displayName: s.displayName ?? s.id, + isConnected: isConnected, + expiresAt: expiresAt, + oauth2Config: s.def.oauth2, + }; + }); + + return new Response( + JSON.stringify({ servers: oauth2Servers }), + { headers: { 'Content-Type': 'application/json' } } + ); + } catch (error: any) { + apiLogger.failure(`Error getting OAuth2 servers: ${error?.message ?? error}`); + return new Response( + JSON.stringify({ error: error?.message ?? 'Internal server error' }), + { status: 500, headers: { 'Content-Type': 'application/json' } } + ); + } } private uiOrigin(): string { diff --git a/src/server/mcp-proxy.ts b/src/server/mcp-proxy.ts index c256fe0..969f079 100644 --- a/src/server/mcp-proxy.ts +++ b/src/server/mcp-proxy.ts @@ -11,6 +11,9 @@ import { OAuth2Manager } from './oauth-manager'; import { logger } from '../shared/logger'; import { shouldSkipTlsVerify } from '../shared/tls-skip-verify'; +/** Timeout for MCP client.connect() — prevents hanging on an unresponsive server (ms). */ +const MCP_CONNECT_TIMEOUT_MS = 15_000; + export interface MCPToolResult { success: boolean; result?: any; @@ -303,7 +306,12 @@ export class MCPProxy { }; this.logger.debug('Connecting client...'); - await client.connect(transport); + await Promise.race([ + client.connect(transport), + new Promise((_, reject) => + setTimeout(() => reject(new Error(`MCP connect timed out after ${MCP_CONNECT_TIMEOUT_MS}ms`)), MCP_CONNECT_TIMEOUT_MS) + ), + ]); this.clients.set(serverId, client); this.logger.success('Client connected'); @@ -345,7 +353,12 @@ export class MCPProxy { }; this.logger.debug('Connecting client...'); - await client.connect(transport); + await Promise.race([ + client.connect(transport), + new Promise((_, reject) => + setTimeout(() => reject(new Error(`MCP connect timed out after ${MCP_CONNECT_TIMEOUT_MS}ms`)), MCP_CONNECT_TIMEOUT_MS) + ), + ]); this.clients.set(serverId, client); this.logger.success('Client connected'); diff --git a/src/server/oauth-manager.ts b/src/server/oauth-manager.ts index b06c122..fd966a8 100644 --- a/src/server/oauth-manager.ts +++ b/src/server/oauth-manager.ts @@ -17,6 +17,9 @@ import { isPermanentRefreshFailure } from '../shared/oauth-refresh'; // Re-exported for backwards compatibility with existing import sites. export { isPermanentRefreshFailure }; +/** Timeout for outbound HTTP requests made during OAuth2 detection (ms). */ +const OAUTH_DETECT_TIMEOUT_MS = 10_000; + export class OAuth2Manager { private db: CapaDatabase; private oauth2ConfigCache = new Map(); @@ -66,6 +69,7 @@ export class OAuth2Manager { clientInfo: { name: 'capa-oauth-detection', version: '1.0.0' }, }, }), + signal: AbortSignal.timeout(OAUTH_DETECT_TIMEOUT_MS), ...this.tlsFetchOptions(tlsSkipVerify), } as RequestInit); @@ -160,7 +164,10 @@ export class OAuth2Manager { */ private async fetchProtectedResourceMetadata(url: string, tlsSkipVerify?: boolean): Promise { try { - const response = await fetch(url, { ...this.tlsFetchOptions(tlsSkipVerify) } as RequestInit); + const response = await fetch(url, { + signal: AbortSignal.timeout(OAUTH_DETECT_TIMEOUT_MS), + ...this.tlsFetchOptions(tlsSkipVerify), + } as RequestInit); if (!response.ok) { return null; } @@ -180,7 +187,10 @@ export class OAuth2Manager { const wellKnownUrl = new URL('/.well-known/oauth-authorization-server', authServerUrl).toString(); this.logger.debug(`Fetching OAuth metadata from: ${wellKnownUrl}`); - const response = await fetch(wellKnownUrl, { ...this.tlsFetchOptions(tlsSkipVerify) } as RequestInit); + const response = await fetch(wellKnownUrl, { + signal: AbortSignal.timeout(OAUTH_DETECT_TIMEOUT_MS), + ...this.tlsFetchOptions(tlsSkipVerify), + } as RequestInit); if (!response.ok) { this.logger.warn(`OAuth metadata fetch failed: ${response.status}`); return null; diff --git a/src/shared/__tests__/authenticated-fetch.test.ts b/src/shared/__tests__/authenticated-fetch.test.ts index bba40da..f1481da 100644 --- a/src/shared/__tests__/authenticated-fetch.test.ts +++ b/src/shared/__tests__/authenticated-fetch.test.ts @@ -73,7 +73,7 @@ describe('AuthenticatedFetch', () => { globalThis.fetch = originalFetch; }); - it('throws a clear error when the token is expired and refresh is unavailable', async () => { + it('falls back to unauthenticated fetch when the token is expired and refresh is unavailable', async () => { const db = makeDb( makeIntegration({ expires_at: Date.now() - 60_000, @@ -82,9 +82,14 @@ describe('AuthenticatedFetch', () => { ); const authFetch = new AuthenticatedFetch(db); - await expect(authFetch.fetch(GITHUB_RAW_URL)).rejects.toThrow(/expired/i); - await expect(authFetch.fetch(GITHUB_RAW_URL)).rejects.toThrow(/capa auth/i); - expect(fetchCalls).toHaveLength(0); + // Should not throw — the request proceeds without auth so public URLs still work. + const response = await authFetch.fetch(GITHUB_RAW_URL); + expect(response.status).toBe(200); + + // Fetch must have been called once (unauthenticated — no Authorization header). + expect(fetchCalls).toHaveLength(1); + const headers = fetchCalls[0]!.init?.headers as Headers | undefined; + expect(headers?.get?.('Authorization') ?? null).toBeNull(); }); it('includes the auth header when the token is valid and not expired', async () => { @@ -143,12 +148,14 @@ describe('AuthenticatedFetch', () => { }); describe('refresh failure classification', () => { - it('keeps the stored token when the cloud refresh endpoint returns a transient 5xx', async () => { + it('keeps the stored token and falls back to unauthenticated fetch when the cloud refresh endpoint returns a transient 5xx', async () => { + let targetFetchCalled = false; globalThis.fetch = (async (input: RequestInfo | URL) => { const url = typeof input === 'string' ? input : input.toString(); if (url.includes('/auth/refresh')) { return new Response('bad gateway', { status: 502 }); } + targetFetchCalled = true; return new Response('ok', { status: 200 }); }) as typeof fetch; @@ -160,19 +167,25 @@ describe('AuthenticatedFetch', () => { ); const authFetch = new AuthenticatedFetch(harness.db); - await expect(authFetch.fetch(GITHUB_RAW_URL)).rejects.toThrow(/expired/i); + // Falls back to unauthenticated — does not throw. + const response = await authFetch.fetch(GITHUB_RAW_URL); + expect(response.status).toBe(200); + expect(targetFetchCalled).toBe(true); + // Stored token must NOT be deleted on a transient failure. expect(harness.deletes).toHaveLength(0); expect(harness.current).not.toBeNull(); expect(harness.current?.refresh_token).toBe('still-valid-refresh'); }); - it('keeps the stored token when the refresh request throws a network error', async () => { + it('keeps the stored token and falls back to unauthenticated fetch when the refresh request throws a network error', async () => { + let targetFetchCalled = false; globalThis.fetch = (async (input: RequestInfo | URL) => { const url = typeof input === 'string' ? input : input.toString(); if (url.includes('/auth/refresh')) { throw new Error('ENOTFOUND capa.infragate.ai'); } + targetFetchCalled = true; return new Response('ok', { status: 200 }); }) as typeof fetch; @@ -184,13 +197,16 @@ describe('AuthenticatedFetch', () => { ); const authFetch = new AuthenticatedFetch(harness.db); - await expect(authFetch.fetch(GITHUB_RAW_URL)).rejects.toThrow(/expired/i); + const response = await authFetch.fetch(GITHUB_RAW_URL); + expect(response.status).toBe(200); + expect(targetFetchCalled).toBe(true); expect(harness.deletes).toHaveLength(0); expect(harness.current).not.toBeNull(); }); - it('deletes the stored token when the refresh_token is rejected as invalid_grant', async () => { + it('deletes the stored token and falls back to unauthenticated fetch when the refresh_token is rejected as invalid_grant', async () => { + let targetFetchCalled = false; globalThis.fetch = (async (input: RequestInfo | URL) => { const url = typeof input === 'string' ? input : input.toString(); if (url.includes('/auth/refresh')) { @@ -199,6 +215,7 @@ describe('AuthenticatedFetch', () => { headers: { 'Content-Type': 'application/json' }, }); } + targetFetchCalled = true; return new Response('ok', { status: 200 }); }) as typeof fetch; @@ -210,7 +227,10 @@ describe('AuthenticatedFetch', () => { ); const authFetch = new AuthenticatedFetch(harness.db); - await expect(authFetch.fetch(GITHUB_RAW_URL)).rejects.toThrow(/expired/i); + // Falls back to unauthenticated after clearing the bad token. + const response = await authFetch.fetch(GITHUB_RAW_URL); + expect(response.status).toBe(200); + expect(targetFetchCalled).toBe(true); expect(harness.deletes).toHaveLength(1); expect(harness.deletes[0]).toEqual({ platform: 'github', host: null }); diff --git a/src/shared/authenticated-fetch.ts b/src/shared/authenticated-fetch.ts index d4382f8..4a9db96 100644 --- a/src/shared/authenticated-fetch.ts +++ b/src/shared/authenticated-fetch.ts @@ -13,7 +13,9 @@ import { isPermanentRefreshFailure } from './oauth-refresh'; const CLOUD_OAUTH_ENDPOINT = 'https://capa.infragate.ai/auth'; -const TOKEN_EXPIRED_MESSAGE = +// Kept for reference in tests; no longer thrown by ensureFreshIntegration so that +// public URLs on known git hosts still work when the stored token is expired. +export const TOKEN_EXPIRED_MESSAGE = 'Git integration token has expired. Run `capa auth` again to re-authenticate.'; function getExpiresAt(integration: GitIntegration): number | null { @@ -134,12 +136,16 @@ export class AuthenticatedFetch { /** * Ensure the integration has a non-expired token, refreshing when possible. + * Returns null when the token is expired and cannot be refreshed — callers + * should fall back to an unauthenticated request rather than aborting, so + * that public URLs on known git hosts (e.g. raw.githubusercontent.com) still + * work even when the user's stored token has expired. */ private async ensureFreshIntegration( platform: GitPlatform, host: string | undefined, integration: GitIntegration - ): Promise { + ): Promise { if (!isTokenExpired(integration)) { return integration; } @@ -152,7 +158,10 @@ export class AuthenticatedFetch { } } - throw new Error(TOKEN_EXPIRED_MESSAGE); + // Token is expired and refresh failed. Return null so the caller can fall + // back to an unauthenticated request. Private repos will still get a 401/403 + // which the install flow already converts into a friendly re-auth prompt. + return null; } /** @@ -172,6 +181,10 @@ export class AuthenticatedFetch { } const fresh = await this.ensureFreshIntegration(platform, host, integration); + if (!fresh) { + // Token expired and could not be refreshed; proceed without auth headers. + return null; + } const gp = getGitProvider(platform); if (gp) { From dd9a0daf4e87eab20b4519a3a7340bd52d954376 Mon Sep 17 00:00:00 2001 From: Antonio Zaitoun Date: Sun, 28 Jun 2026 22:11:27 +0300 Subject: [PATCH 3/9] fix(test): cast mock fetch through unknown to satisfy stricter typeof fetch Co-authored-by: Cursor --- src/server/__tests__/oauth-manager.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/server/__tests__/oauth-manager.test.ts b/src/server/__tests__/oauth-manager.test.ts index 31e6f31..e6b3c29 100644 --- a/src/server/__tests__/oauth-manager.test.ts +++ b/src/server/__tests__/oauth-manager.test.ts @@ -75,7 +75,7 @@ describe('OAuth2Manager', () => { // that an unreachable MCP server produces at the network layer. globalThis.fetch = (async () => { throw new DOMException('The operation was aborted.', 'AbortError'); - }) as typeof fetch; + }) as unknown as typeof fetch; const manager = new OAuth2Manager(makeMockDb()); const result = await manager.detectOAuth2Requirement('http://192.0.2.1:9999/mcp'); @@ -83,7 +83,7 @@ describe('OAuth2Manager', () => { }); it('returns null when the MCP server returns a non-401 status', async () => { - globalThis.fetch = (async () => new Response('', { status: 200 })) as typeof fetch; + globalThis.fetch = (async () => new Response('', { status: 200 })) as unknown as typeof fetch; const manager = new OAuth2Manager(makeMockDb()); const result = await manager.detectOAuth2Requirement('http://localhost:9999/mcp'); From e53105a139cab78bb983e0f5144db963ed971682 Mon Sep 17 00:00:00 2001 From: Antonio Zaitoun Date: Sun, 28 Jun 2026 22:32:23 +0300 Subject: [PATCH 4/9] fix(server): surface unreachable MCP servers and stop stray socket-closed errors Follow-up to the #126 work, addressing two remaining symptoms when an MCP server is unreachable (e.g. VPN dropped): 1. Stray "The socket connection was closed unexpectedly" printed during install even though it succeeded. The connect-timeout added previously used a Promise.race that left the losing promise dangling: when the socket closed after we'd timed out (or the timer fired after a successful connect), the rejection surfaced as an unhandled rejection. Replaced it with connectWithTimeout(), which attaches a no-op catch to the connect promise and clears the timer in finally. Added process-level unhandledRejection/uncaughtException handlers as a safety net for the MCP SDK transport's background sockets. 2. The /tools endpoint returned 200 { tools: [] } for unreachable servers, so the UI could not tell "no tools" apart from "unreachable". listServerTools now accepts options and handleGetServerTools calls it with throwOnError, returning 502 with a clear message ("Server unreachable: ..."; the OAuth-disconnected message is passed through untouched). The web UI tools panel now renders that error, and useServerTools no longer retries so the error shows immediately. Co-authored-by: Cursor --- .../__tests__/mcp-handler.integration.test.ts | 52 +++++++++++++++++++ src/server/index.ts | 30 +++++++++-- src/server/mcp-handler.ts | 8 ++- src/server/mcp-proxy.ts | 48 ++++++++++++----- .../projects/components/ServerToolsPanel.tsx | 2 +- web-ui/src/features/projects/hooks.ts | 2 + web-ui/src/locales/en/projects.json | 3 +- 7 files changed, 125 insertions(+), 20 deletions(-) diff --git a/src/server/__tests__/mcp-handler.integration.test.ts b/src/server/__tests__/mcp-handler.integration.test.ts index 241121f..6cb26c4 100644 --- a/src/server/__tests__/mcp-handler.integration.test.ts +++ b/src/server/__tests__/mcp-handler.integration.test.ts @@ -610,3 +610,55 @@ describe('getAllShellTools / getShellToolSchema (lazy MCP schema)', () => { expect(schema.inputSchema.properties.pattern).toBeDefined(); }); }); + +// ─── listServerTools: surface unreachable servers (issue #126) ─────────────── +// +// The /tools API handler relies on `listServerTools` forwarding +// `throwOnError` so an unreachable MCP server produces an HTTP error the UI can +// render ("Server unreachable") instead of a silent empty list that looks like +// "this server has no tools". + +describe('listServerTools (throwOnError pass-through)', () => { + let h: Harness; + + const caps: Capabilities = { + providers: ['claude-code'], + options: {}, + skills: [], + servers: [{ id: 'brave', def: { url: 'http://127.0.0.1:1/mcp' } } as any], + tools: [], + }; + + beforeEach(() => { + h = makeHarness(caps); + }); + + afterEach(() => destroyHarness(h)); + + it('forwards throwOnError to the proxy', async () => { + let seenOptions: any; + (h.mcp as any).mcpProxy.listTools = async (_id: string, _def: unknown, options: unknown) => { + seenOptions = options; + return []; + }; + + await h.mcp.listServerTools('brave', caps, { throwOnError: true }); + expect(seenOptions).toEqual({ throwOnError: true }); + }); + + it('propagates connection failures instead of swallowing them', async () => { + (h.mcp as any).mcpProxy.listTools = async () => { + throw new Error('Could not connect to MCP server "brave"'); + }; + + await expect( + h.mcp.listServerTools('brave', caps, { throwOnError: true }), + ).rejects.toThrow(/Could not connect/); + }); + + it('returns an empty list for a reachable server with no tools', async () => { + (h.mcp as any).mcpProxy.listTools = async () => []; + const tools = await h.mcp.listServerTools('brave', caps, { throwOnError: true }); + expect(tools).toEqual([]); + }); +}); diff --git a/src/server/index.ts b/src/server/index.ts index 7dfbaf5..0b2f697 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -713,17 +713,26 @@ class CapaServer { ); } - const tools = await mcpServer.listServerTools(serverId, capabilities); + // throwOnError surfaces connection/auth failures instead of silently + // returning an empty list, so the UI can distinguish "server has no tools" + // (200 + []) from "server unreachable" (502 + error). + const tools = await mcpServer.listServerTools(serverId, capabilities, { throwOnError: true }); apiLogger.success(`Found ${tools.length} tools for server ${serverId}`); return new Response( JSON.stringify({ tools }), { headers: { 'Content-Type': 'application/json' } } ); } catch (error: any) { - apiLogger.failure(`Error: ${error.message}`); + const detail = error?.message ?? String(error); + apiLogger.failure(`Error: ${detail}`); + // An OAuth-disconnected server is not "unreachable" — surface the auth + // message as-is so the UI can prompt the user to reconnect. Everything + // else (connect failure, timeout) is reported as a 502 Bad Gateway. + const needsAuth = /authentication failed|reconnect oauth2/i.test(detail); + const message = needsAuth ? detail : `Server unreachable: ${detail}`; return new Response( - JSON.stringify({ error: error.message }), - { status: 500, headers: { 'Content-Type': 'application/json' } } + JSON.stringify({ error: message }), + { status: 502, headers: { 'Content-Type': 'application/json' } } ); } } @@ -2242,6 +2251,19 @@ class CapaServer { // Main const server = new CapaServer(); +// Safety net for stray async failures. The MCP SDK's HTTP/stdio transports keep +// background sockets open; when a remote server becomes unreachable (e.g. VPN +// dropped) those can reject after we've already returned a response, which Bun +// would otherwise print as a raw "The socket connection was closed unexpectedly" +// error. Log these instead of letting them crash the process or leak to stderr. +process.on('unhandledRejection', (reason) => { + const message = reason instanceof Error ? reason.message : String(reason); + logger.warn(`Unhandled promise rejection (ignored): ${message}`); +}); +process.on('uncaughtException', (error) => { + logger.error(`Uncaught exception (ignored): ${error?.message ?? error}`); +}); + // Handle shutdown signals process.on('SIGTERM', () => server.stop()); process.on('SIGINT', () => server.stop()); diff --git a/src/server/mcp-handler.ts b/src/server/mcp-handler.ts index 0e4ed87..b287e6b 100644 --- a/src/server/mcp-handler.ts +++ b/src/server/mcp-handler.ts @@ -685,10 +685,14 @@ export class CapaMCPServer { * List tools available on a specific MCP server by ID. * Returns the raw MCP tool list (name, description, inputSchema). */ - async listServerTools(serverId: string, capabilities: Capabilities): Promise { + async listServerTools( + serverId: string, + capabilities: Capabilities, + options: { throwOnError?: boolean } = {}, + ): Promise { const serverDef = capabilities.servers.find((s) => s.id === serverId); if (!serverDef) return []; - return await this.mcpProxy.listTools(serverId, serverDef.def); + return await this.mcpProxy.listTools(serverId, serverDef.def, options); } /** diff --git a/src/server/mcp-proxy.ts b/src/server/mcp-proxy.ts index 969f079..4cec15f 100644 --- a/src/server/mcp-proxy.ts +++ b/src/server/mcp-proxy.ts @@ -306,12 +306,7 @@ export class MCPProxy { }; this.logger.debug('Connecting client...'); - await Promise.race([ - client.connect(transport), - new Promise((_, reject) => - setTimeout(() => reject(new Error(`MCP connect timed out after ${MCP_CONNECT_TIMEOUT_MS}ms`)), MCP_CONNECT_TIMEOUT_MS) - ), - ]); + await this.connectWithTimeout(client, transport); this.clients.set(serverId, client); this.logger.success('Client connected'); @@ -353,12 +348,7 @@ export class MCPProxy { }; this.logger.debug('Connecting client...'); - await Promise.race([ - client.connect(transport), - new Promise((_, reject) => - setTimeout(() => reject(new Error(`MCP connect timed out after ${MCP_CONNECT_TIMEOUT_MS}ms`)), MCP_CONNECT_TIMEOUT_MS) - ), - ]); + await this.connectWithTimeout(client, transport); this.clients.set(serverId, client); this.logger.success('Client connected'); @@ -369,6 +359,40 @@ export class MCPProxy { } } + /** + * Connect a client with a bounded timeout. + * + * `client.connect()` can hang indefinitely against an unreachable/unresponsive + * server. We race it against a timer, but two subtleties matter for avoiding + * stray "socket connection was closed unexpectedly" unhandled rejections: + * + * 1. If the timeout wins, the original connect promise is still pending and + * may reject later (e.g. the socket closes after we've given up). We attach + * a no-op `.catch` so that late rejection is considered handled. + * 2. If connect wins, we must clear the timer so the timeout promise never + * rejects into the void. + */ + private async connectWithTimeout(client: Client, transport: Transport): Promise { + let timer: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error(`MCP connect timed out after ${MCP_CONNECT_TIMEOUT_MS}ms`)), + MCP_CONNECT_TIMEOUT_MS, + ); + }); + + const connectPromise = client.connect(transport); + // Swallow a late rejection (socket closed after we already timed out) so it + // doesn't surface as an unhandled rejection on the process. + connectPromise.catch(() => {}); + + try { + await Promise.race([connectPromise, timeout]); + } finally { + if (timer) clearTimeout(timer); + } + } + /** * Close all clients */ diff --git a/web-ui/src/features/projects/components/ServerToolsPanel.tsx b/web-ui/src/features/projects/components/ServerToolsPanel.tsx index 0eefd90..5a573c3 100644 --- a/web-ui/src/features/projects/components/ServerToolsPanel.tsx +++ b/web-ui/src/features/projects/components/ServerToolsPanel.tsx @@ -32,7 +32,7 @@ export function ServerToolsPanel({ projectId, serverId, search = '', prefetchedT if (!prefetchedTools && error) { return (
- Failed to load tools: {(error as Error).message} + {(error as Error).message || t('tool.serverUnreachable')}
); } diff --git a/web-ui/src/features/projects/hooks.ts b/web-ui/src/features/projects/hooks.ts index b8d73b7..e08dd77 100644 --- a/web-ui/src/features/projects/hooks.ts +++ b/web-ui/src/features/projects/hooks.ts @@ -65,5 +65,7 @@ export function useServerTools(projectId: string | null, serverId: string | null enabled: !!projectId && !!serverId, select: (data) => data.tools, staleTime: 60_000, + // Don't retry an unreachable MCP server — surface the error immediately. + retry: false, }); } diff --git a/web-ui/src/locales/en/projects.json b/web-ui/src/locales/en/projects.json index a27d034..25526bc 100644 --- a/web-ui/src/locales/en/projects.json +++ b/web-ui/src/locales/en/projects.json @@ -81,7 +81,8 @@ "from": "from", "cmd": "cmd", "requiredBy": "required by", - "noToolsOnServer": "No tools found on this server" + "noToolsOnServer": "No tools found on this server", + "serverUnreachable": "Server unreachable" }, "stat": { "skill": "skill", From e987d3ea2b005c438b3194754cc24b9235b6a3a4 Mon Sep 17 00:00:00 2001 From: Antonio Zaitoun Date: Sun, 28 Jun 2026 22:52:38 +0300 Subject: [PATCH 5/9] fix(mcp-proxy): harden connect timeout against leaked timers and late rejections Half-open MCP servers (accept the TCP connection but never complete the handshake, e.g. return an empty response) made client.connect() hang until our timeout fired. Two edge cases could still leak a stray "MCP connect timed out after 15000ms" unhandled rejection out to the process/UI: - If client.connect() threw synchronously, the timeout timer was never cleared and rejected ~15s later into the void. - On timeout we left the half-open client/transport open, so its socket could emit further late errors. connectWithTimeout now: builds the timer only after connect starts, always clears it in finally (covering synchronous throws), best-effort closes the client + transport on timeout, and keeps the no-op catch on the connect promise so a late socket-close rejection stays handled. The timeout is now parameterized so it can be unit-tested with a short window. Adds tests asserting the timeout path tears down the connection and never produces an unhandled rejection across the timeout, late-rejection, success, and synchronous-throw cases. Co-authored-by: Cursor --- src/server/__tests__/mcp-proxy.test.ts | 86 ++++++++++++++++++++++++++ src/server/mcp-proxy.ts | 63 +++++++++++++------ 2 files changed, 129 insertions(+), 20 deletions(-) diff --git a/src/server/__tests__/mcp-proxy.test.ts b/src/server/__tests__/mcp-proxy.test.ts index 004b3ca..7f980ba 100644 --- a/src/server/__tests__/mcp-proxy.test.ts +++ b/src/server/__tests__/mcp-proxy.test.ts @@ -78,6 +78,92 @@ describe('mcp-proxy', () => { }); }); + describe('connectWithTimeout', () => { + // Capture unhandled rejections during each test so we can assert the + // timeout path never leaks one (the bug behind the stray + // "MCP connect timed out after 15000ms" the user saw in the UI/logs). + let unhandled: unknown[]; + const onUnhandled = (reason: unknown) => unhandled.push(reason); + + beforeEach(() => { + unhandled = []; + process.on('unhandledRejection', onUnhandled); + }); + + afterEach(() => { + process.off('unhandledRejection', onUnhandled); + }); + + function makeProxy(): any { + return new MCPProxy(makeMockDb(), 'proj-1', '/tmp/project'); + } + + it('rejects with a timeout error and tears down a hung connect', async () => { + const proxy = makeProxy(); + let closed = false; + const client = { + connect: () => new Promise(() => {}), // never resolves + close: async () => { + closed = true; + }, + }; + const transport = { close: async () => {} }; + + await expect(proxy.connectWithTimeout(client, transport, 30)).rejects.toThrow(/timed out/); + expect(closed).toBe(true); + }); + + it('does not leak an unhandled rejection when the connect rejects after the timeout', async () => { + const proxy = makeProxy(); + let rejectConnect: (e: unknown) => void = () => {}; + const client = { + connect: () => + new Promise((_, reject) => { + rejectConnect = reject; + }), + close: async () => {}, + }; + const transport = { close: async () => {} }; + + await expect(proxy.connectWithTimeout(client, transport, 20)).rejects.toThrow(/timed out/); + + // Simulate the real socket closing *after* we already gave up. + rejectConnect(new Error('The socket connection was closed unexpectedly')); + await new Promise((r) => setTimeout(r, 20)); + + expect(unhandled).toHaveLength(0); + }); + + it('resolves on a successful connect and clears the timer (no late rejection)', async () => { + const proxy = makeProxy(); + const client = { connect: async () => {}, close: async () => {} }; + const transport = { close: async () => {} }; + + await expect(proxy.connectWithTimeout(client, transport, 50)).resolves.toBeUndefined(); + + // Wait well past the timeout window to prove the timer was cleared. + await new Promise((r) => setTimeout(r, 80)); + expect(unhandled).toHaveLength(0); + }); + + it('propagates a synchronous connect throw without leaking a timer', async () => { + const proxy = makeProxy(); + const client = { + connect: () => { + throw new Error('boom'); + }, + close: async () => {}, + }; + const transport = { close: async () => {} }; + + await expect(proxy.connectWithTimeout(client, transport, 30)).rejects.toThrow(/boom/); + + // If the timer had been scheduled before the throw, it would fire here. + await new Promise((r) => setTimeout(r, 50)); + expect(unhandled).toHaveLength(0); + }); + }); + describe('OAuth2 disconnected handling', () => { function makeOauthServerDef(): MCPServerDefinition { return { diff --git a/src/server/mcp-proxy.ts b/src/server/mcp-proxy.ts index 4cec15f..40183bf 100644 --- a/src/server/mcp-proxy.ts +++ b/src/server/mcp-proxy.ts @@ -362,32 +362,55 @@ export class MCPProxy { /** * Connect a client with a bounded timeout. * - * `client.connect()` can hang indefinitely against an unreachable/unresponsive - * server. We race it against a timer, but two subtleties matter for avoiding - * stray "socket connection was closed unexpectedly" unhandled rejections: + * `client.connect()` can hang indefinitely against an unreachable or + * half-open server (one that accepts the TCP connection but never completes + * the MCP handshake, e.g. it returns an empty response). We race the connect + * against a timer. Several subtleties matter so that a timeout never leaks a + * stray "MCP connect timed out" / "socket connection was closed unexpectedly" + * unhandled rejection out of the request and into the process/UI: * - * 1. If the timeout wins, the original connect promise is still pending and - * may reject later (e.g. the socket closes after we've given up). We attach - * a no-op `.catch` so that late rejection is considered handled. - * 2. If connect wins, we must clear the timer so the timeout promise never - * rejects into the void. + * 1. Attach a no-op `.catch` to the connect promise so that if it rejects + * *after* we've already timed out (the socket closing late), that + * rejection is considered handled. + * 2. Always clear the timer in `finally` — including when `client.connect()` + * throws synchronously — so the timeout promise can't reject into the void + * and trip the global unhandledRejection handler ~`timeoutMs` later. + * 3. On timeout, best-effort tear down the half-open client/transport so its + * underlying socket doesn't linger and emit further errors. + * + * Rejections from this method are expected to be caught by the caller + * (`createHttpClient` / `createStdioClient`), which log and return `null`. */ - private async connectWithTimeout(client: Client, transport: Transport): Promise { + private async connectWithTimeout( + client: Client, + transport: Transport, + timeoutMs: number = MCP_CONNECT_TIMEOUT_MS, + ): Promise { let timer: ReturnType | undefined; - const timeout = new Promise((_, reject) => { - timer = setTimeout( - () => reject(new Error(`MCP connect timed out after ${MCP_CONNECT_TIMEOUT_MS}ms`)), - MCP_CONNECT_TIMEOUT_MS, - ); - }); - - const connectPromise = client.connect(transport); - // Swallow a late rejection (socket closed after we already timed out) so it - // doesn't surface as an unhandled rejection on the process. - connectPromise.catch(() => {}); + let timedOut = false; try { + const connectPromise = client.connect(transport); + // Swallow a late rejection (socket closed after we already timed out) so + // it doesn't surface as an unhandled rejection on the process. + connectPromise.catch(() => {}); + + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => { + timedOut = true; + reject(new Error(`MCP connect timed out after ${timeoutMs}ms`)); + }, timeoutMs); + }); + await Promise.race([connectPromise, timeout]); + } catch (error) { + if (timedOut) { + // Best-effort cleanup of the half-open connection so the dangling + // socket doesn't keep the process busy or emit late errors. + try { await client.close(); } catch { /* ignore */ } + try { await transport.close?.(); } catch { /* ignore */ } + } + throw error; } finally { if (timer) clearTimeout(timer); } From f97d77c126b91210dc13c4130b4fc4133b2bd8fb Mon Sep 17 00:00:00 2001 From: Antonio Zaitoun Date: Sun, 28 Jun 2026 23:08:10 +0300 Subject: [PATCH 6/9] fix(server): return sanitized error messages instead of raw exception text CodeQL (js/stack-trace-exposure) flagged the /tools handler returning raw error.message to the client, which can leak stack-trace-derived detail. Both handleGetServerTools and the handleGetOAuth2Servers catch now log the full detail server-side but return controlled, generic messages: tools failures report "Server unreachable: \"\" could not be contacted" (or an auth-required prompt for OAuth-disconnected servers), and the oauth-servers handler returns a generic "Failed to load OAuth2 servers". The UI still shows a clear, actionable error without exposing internal exception text. Co-authored-by: Cursor --- src/server/index.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/server/index.ts b/src/server/index.ts index 0b2f697..a81fef7 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -724,12 +724,15 @@ class CapaServer { ); } catch (error: any) { const detail = error?.message ?? String(error); - apiLogger.failure(`Error: ${detail}`); - // An OAuth-disconnected server is not "unreachable" — surface the auth - // message as-is so the UI can prompt the user to reconnect. Everything - // else (connect failure, timeout) is reported as a 502 Bad Gateway. + apiLogger.failure(`Error listing tools for ${serverId}: ${detail}`); + // Return a controlled, sanitized message — never echo raw exception text + // (which CodeQL flags as potential stack-trace exposure) back to the + // client. Full detail is logged above. An OAuth-disconnected server is + // not "unreachable", so distinguish that case to prompt re-auth. const needsAuth = /authentication failed|reconnect oauth2/i.test(detail); - const message = needsAuth ? detail : `Server unreachable: ${detail}`; + const message = needsAuth + ? `Authentication required for "${serverId}". Please reconnect this server's OAuth2 connection.` + : `Server unreachable: "${serverId}" could not be contacted.`; return new Response( JSON.stringify({ error: message }), { status: 502, headers: { 'Content-Type': 'application/json' } } @@ -1291,9 +1294,11 @@ class CapaServer { { headers: { 'Content-Type': 'application/json' } } ); } catch (error: any) { + // Log full detail server-side, but return a generic message so raw + // exception text (stack-trace exposure) never reaches the client. apiLogger.failure(`Error getting OAuth2 servers: ${error?.message ?? error}`); return new Response( - JSON.stringify({ error: error?.message ?? 'Internal server error' }), + JSON.stringify({ error: 'Failed to load OAuth2 servers' }), { status: 500, headers: { 'Content-Type': 'application/json' } } ); } From 348e0920001aaa64c7d844388ee49ed78cc82283 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 28 Jun 2026 23:15:54 +0300 Subject: [PATCH 7/9] chore(deps): bump actions/checkout from 6 to 7 (#122) Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v6...v7) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/release.yml | 4 ++-- .github/workflows/security.yml | 4 ++-- .github/workflows/test.yml | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4de4104..fd565ed 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -60,7 +60,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup Bun uses: oven-sh/setup-bun@v2 @@ -131,7 +131,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Download all artifacts uses: actions/download-artifact@v8 diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index ce01397..4af97ac 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -26,7 +26,7 @@ jobs: language: [javascript-typescript] steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Initialize CodeQL uses: github/codeql-action/init@v4 @@ -48,7 +48,7 @@ jobs: pull-requests: write steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Review PR-introduced dependencies uses: actions/dependency-review-action@v5 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 77c099a..29035e2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -18,7 +18,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup Bun uses: oven-sh/setup-bun@v2 @@ -52,7 +52,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup Bun uses: oven-sh/setup-bun@v2 From a624319bc06bb54bc910d7ac7ee58584e87fc865 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 28 Jun 2026 23:15:58 +0300 Subject: [PATCH 8/9] chore(deps-dev): bump @types/node in the development-deps group (#124) Bumps the development-deps group with 1 update: [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node). Updates `@types/node` from 25.9.4 to 26.0.0 - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node) --- updated-dependencies: - dependency-name: "@types/node" dependency-version: 26.0.0 dependency-type: direct:development update-type: version-update:semver-major dependency-group: development-deps ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b2c6a71..ed80226 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,7 @@ "@types/bun": "latest", "@types/dompurify": "^3.0.5", "@types/js-yaml": "^4.0.9", - "@types/node": "^25.9.1", + "@types/node": "^26.0.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "tailwindcss": "^4.2.4" From 89793df05f430f2aa84bd380d25473b7a2db73d5 Mon Sep 17 00:00:00 2001 From: Antonio Zaitoun Date: Sun, 28 Jun 2026 23:22:55 +0300 Subject: [PATCH 9/9] fix(deps): migrate codebase to js-yaml v5 API js-yaml 5.0.0 removed the default export (and now ships its own types), which broke `import yaml from 'js-yaml'` and every suite that loads YAML. - Switch to namespace imports (`import * as yaml`) in lockfile, capabilities, rules-installer, and the lockfile test. - Guard empty input in parseCapabilitiesFile since v5 `load()` throws on empty input instead of returning undefined, preserving our clearer "empty or not an object" error. - Drop @types/js-yaml; v5 bundles its own type definitions. The dump options in use (indent, lineWidth, noRefs) remain valid in v5. Co-authored-by: Cursor --- bun.lock | 7 ++----- package.json | 1 - src/cli/utils/rules-installer.ts | 2 +- src/shared/__tests__/lockfile.test.ts | 2 +- src/shared/capabilities.ts | 7 ++++++- src/shared/lockfile.ts | 2 +- 6 files changed, 11 insertions(+), 10 deletions(-) diff --git a/bun.lock b/bun.lock index 9ccf1ce..30fb58a 100644 --- a/bun.lock +++ b/bun.lock @@ -23,7 +23,7 @@ "dompurify": "^3.4.3", "highlight.js": "^11.11.1", "i18next": "^26.0.10", - "js-yaml": "^4.1.0", + "js-yaml": "^5.0.0", "listr2": "^10.2.1", "lucide-react": "^1.14.0", "marked": "^18.0.3", @@ -44,7 +44,6 @@ "@tailwindcss/cli": "^4.2.4", "@types/bun": "latest", "@types/dompurify": "^3.0.5", - "@types/js-yaml": "^4.0.9", "@types/node": "^25.9.1", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -233,8 +232,6 @@ "@types/dompurify": ["@types/dompurify@3.2.0", "", { "dependencies": { "dompurify": "*" } }, "sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg=="], - "@types/js-yaml": ["@types/js-yaml@4.0.9", "", {}, "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="], - "@types/node": ["@types/node@25.9.2", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw=="], "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], @@ -401,7 +398,7 @@ "jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="], - "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": "bin/js-yaml.js" }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + "js-yaml": ["js-yaml@5.2.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.mjs" } }, "sha512-YeLUMlvR4Ou1B119LIaM0r65JvbOBooJDc9yEu0dClb/uSC5P4FrLU8OCCz/HXWvtPoIrR0dRzABTjo1sTN9Bw=="], "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], diff --git a/package.json b/package.json index 2db63ef..bedcd0a 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,6 @@ "@tailwindcss/cli": "^4.2.4", "@types/bun": "latest", "@types/dompurify": "^3.0.5", - "@types/js-yaml": "^4.0.9", "@types/node": "^25.9.1", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", diff --git a/src/cli/utils/rules-installer.ts b/src/cli/utils/rules-installer.ts index 7aa1dc6..9b64a24 100644 --- a/src/cli/utils/rules-installer.ts +++ b/src/cli/utils/rules-installer.ts @@ -1,6 +1,6 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, unlinkSync } from 'fs'; import { join, dirname, basename, sep } from 'path'; -import yaml from 'js-yaml'; +import * as yaml from 'js-yaml'; import type { Rule } from '../../types/rules'; import { getAllProviders, getProvider } from '../../shared/providers'; import { buildRuleFrontmatter } from '../../shared/providers/handlers'; diff --git a/src/shared/__tests__/lockfile.test.ts b/src/shared/__tests__/lockfile.test.ts index aef59cb..fd2b9b9 100644 --- a/src/shared/__tests__/lockfile.test.ts +++ b/src/shared/__tests__/lockfile.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach, spyOn } from 'bun:test'; import { mkdtempSync, rmSync, writeFileSync, existsSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; -import yaml from 'js-yaml'; +import * as yaml from 'js-yaml'; import { logger } from '../logger'; import { LOCKFILE_NAME, diff --git a/src/shared/capabilities.ts b/src/shared/capabilities.ts index b88d16a..8376d6d 100644 --- a/src/shared/capabilities.ts +++ b/src/shared/capabilities.ts @@ -1,4 +1,4 @@ -import yaml from 'js-yaml'; +import * as yaml from 'js-yaml'; import { parseDocument, isSeq } from 'yaml'; import { z } from 'zod'; import type { Capabilities, CapabilitiesFormat } from '../types/capabilities'; @@ -65,6 +65,11 @@ export async function parseCapabilitiesFile( if (format === 'json') { return normalizeCapabilities(JSON.parse(content)); } else { + // js-yaml v5 throws on empty input instead of returning undefined, so guard + // here to keep emitting our own clearer "empty or not an object" error. + if (content.trim() === '') { + return normalizeCapabilities(undefined); + } return normalizeCapabilities(yaml.load(content)); } } diff --git a/src/shared/lockfile.ts b/src/shared/lockfile.ts index 5045d98..22f8432 100644 --- a/src/shared/lockfile.ts +++ b/src/shared/lockfile.ts @@ -9,7 +9,7 @@ import { existsSync } from 'fs'; import { logger } from './logger'; import { join } from 'path'; -import yaml from 'js-yaml'; +import * as yaml from 'js-yaml'; import type { Lockfile, LockfileFormat,