Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
13 changes: 5 additions & 8 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 2 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -57,8 +57,7 @@
"@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/node": "^26.0.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"tailwindcss": "^4.2.4"
Expand Down
2 changes: 1 addition & 1 deletion src/cli/utils/rules-installer.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
52 changes: 52 additions & 0 deletions src/server/__tests__/mcp-handler.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);
});
});
86 changes: 86 additions & 0 deletions src/server/__tests__/mcp-proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>(() => {}), // 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<void>((_, 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 {
Expand Down
28 changes: 28 additions & 0 deletions src/server/__tests__/oauth-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 unknown 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 unknown 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);
Expand Down
Loading
Loading