Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
15bb302
feat(query-language): add SelectExpr to grammar and token list
thomasleveil Mar 16, 2026
27e10e3
feat(search/parser): extract SelectMode from parsed query tree
thomasleveil Mar 16, 2026
be63399
feat(search): implement select:repo projection in the search pipeline
thomasleveil Mar 16, 2026
655eeb7
feat(ui): render repository list when select:repo is active
thomasleveil Mar 16, 2026
5d9ad83
feat(ui): add select: autocomplete and syntax highlighting in search bar
thomasleveil Mar 16, 2026
ab58387
feat(mcp): add search_repos tool and support repoResults in schemas
thomasleveil Mar 16, 2026
31640f3
test(mcp): add test suite for select:repo — schemas, transform, and tool
thomasleveil Mar 16, 2026
09ccf9e
chore: update CHANGELOG for select:repo PR #1015
thomasleveil Mar 18, 2026
2ae3968
refactor(search): extract shared accumulateRepoMap helper
thomasleveil Mar 18, 2026
e2f9f64
fix(search): derive isSelectRepoMode from Lezer parser instead of raw…
thomasleveil Mar 18, 2026
120e23b
fix(ui): quote repo names with spaces or special chars in navigateToRepo
thomasleveil Mar 18, 2026
14812ec
fix(mcp): wire filterByRepos and filterByFilepaths in search_repos ha…
thomasleveil Mar 18, 2026
6caa4fb
fix(mcp): use word-boundary regex for select:repo guard in search_repos
thomasleveil Mar 18, 2026
6205b5d
fix(ui): show repo-centric streaming status in select:repo mode
thomasleveil Mar 18, 2026
8779b74
test(mcp): document intentional regex isolation in hasModifiers suite
thomasleveil Mar 18, 2026
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Added AGENTS.md with Cursor Cloud development environment instructions. [#1001](https://github.com/sourcebot-dev/sourcebot/pull/1001)
- Added support for configuring SMTP via individual environment variables (SMTP_HOST, SMTP_PORT, SMTP_USERNAME, SMTP_PASSWORD) as an alternative to SMTP_CONNECTION_URL. [#1002](https://github.com/sourcebot-dev/sourcebot/pull/1002)
- Added `select:repo` query modifier that returns a deduplicated list of matching repositories sorted by match count, with a new `RepoResultsPanel` UI and a `search_repos` MCP tool. [#1015](https://github.com/sourcebot-dev/sourcebot/pull/1015)

## [4.15.6] - 2026-03-13

Expand Down
3 changes: 2 additions & 1 deletion packages/mcp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
"scripts": {
"build": "tsc",
"dev": "node ./dist/index.js",
"build:watch": "tsc-watch --preserveWatchOutput"
"build:watch": "tsc-watch --preserveWatchOutput",
"test": "node --import tsx/esm --test src/__tests__/*.test.ts"
},
"devDependencies": {
"@types/express": "^5.0.1",
Expand Down
187 changes: 187 additions & 0 deletions packages/mcp/src/__tests__/select-repo.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
/**
* Tests for the select:repo feature in the MCP server.
*
* Covers:
* 1. repoResultSchema / searchResponseSchema validation
* 2. The hasModifiers transform fix in search_code
* 3. The search_repos tool end-to-end via InMemoryTransport
*
* Run with:
* node --import tsx/esm --test src/__tests__/select-repo.test.ts
*/

import { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
import { repoResultSchema, searchResponseSchema } from '../schemas.js';

// ---- helpers ----------------------------------------------------------------

function makeStats() {
return {
actualMatchCount: 0, totalMatchCount: 0, duration: 0, fileCount: 0,
filesSkipped: 0, contentBytesLoaded: 0, indexBytesLoaded: 0, crashes: 0,
shardFilesConsidered: 0, filesConsidered: 0, filesLoaded: 0,
shardsScanned: 0, shardsSkipped: 0, shardsSkippedFilter: 0,
ngramMatches: 0, ngramLookups: 0, wait: 0,
matchTreeConstruction: 0, matchTreeSearch: 0,
regexpsConsidered: 0, flushReason: 'none',
};
}

function makeSearchResponse(extra: Record<string, unknown> = {}) {
return { stats: makeStats(), files: [], repositoryInfo: [], isSearchExhaustive: true, ...extra };
}

function mockFetch(payload: unknown) {
globalThis.fetch = async (_input: RequestInfo | URL, _init?: RequestInit) =>
new Response(JSON.stringify(payload), { status: 200, headers: { 'Content-Type': 'application/json' } });
}

function captureFetch(payload: unknown, onCall: (body: Record<string, unknown>) => void) {
globalThis.fetch = async (_input: RequestInfo | URL, init?: RequestInit) => {
onCall(JSON.parse((init?.body as string) ?? '{}'));
return new Response(JSON.stringify(payload), { status: 200, headers: { 'Content-Type': 'application/json' } });
};
}

function getText(result: unknown): string {
return (result as { content: Array<{ type: string; text: string }> }).content
.map((c) => c.text).join('\n');
}

// ---- 1. Schema validation ---------------------------------------------------

describe('repoResultSchema', () => {
it('parses a valid RepoResult', () => {
const r = repoResultSchema.safeParse({ repositoryId: 1, repository: 'github.com/acme/frontend', matchCount: 42 });
assert.ok(r.success);
assert.equal(r.data.matchCount, 42);
});

it('parses a RepoResult with optional repositoryInfo', () => {
const r = repoResultSchema.safeParse({
repositoryId: 2, repository: 'github.com/acme/backend', matchCount: 7,
repositoryInfo: { id: 2, codeHostType: 'github', name: 'acme/backend', webUrl: 'https://github.com/acme/backend' },
});
assert.ok(r.success);
assert.equal(r.data.repositoryInfo?.webUrl, 'https://github.com/acme/backend');
});

it('rejects a RepoResult missing matchCount', () => {
const r = repoResultSchema.safeParse({ repositoryId: 1, repository: 'github.com/acme/x' });
assert.ok(!r.success, 'should have failed');
});
});

describe('searchResponseSchema with repoResults', () => {
it('accepts a response without repoResults (backward compat)', () => {
const r = searchResponseSchema.safeParse(makeSearchResponse());
assert.ok(r.success);
assert.equal(r.data.repoResults, undefined);
});

it('accepts a response with repoResults', () => {
const r = searchResponseSchema.safeParse(makeSearchResponse({
repoResults: [
{ repositoryId: 1, repository: 'github.com/acme/a', matchCount: 10 },
{ repositoryId: 2, repository: 'github.com/acme/b', matchCount: 3 },
],
}));
assert.ok(r.success);
assert.equal(r.data.repoResults?.length, 2);
});

it('rejects repoResults with a missing required field', () => {
const r = searchResponseSchema.safeParse(makeSearchResponse({
repoResults: [{ repositoryId: 1, repository: 'github.com/x' }],
}));
assert.ok(!r.success, 'should have failed');
});
});

// ---- 2. hasModifiers transform logic ----------------------------------------

describe('search_code query transform — hasModifiers regex', () => {
// The regex is intentionally defined here rather than imported from the
// implementation. These tests assert the expected contract (which modifiers
// should and should not be detected) independently of the implementation,
// so a change to the source that breaks the contract will fail the tests
// even if the regex itself was updated.
const RE = /(?:^|\s)(?:select|repo|lang|file|case|rev|branch|sym|content):/;

it('detects select:repo modifier', () => assert.ok(RE.test('useState select:repo')));
it('detects lang: modifier', () => assert.ok(RE.test('function lang:TypeScript')));
it('detects repo: at start', () => assert.ok(RE.test('repo:acme/frontend useState')));
it('does not false-positive on plain text', () => {
assert.ok(!RE.test('useState hook'));
assert.ok(!RE.test('async function fetch'));
});
it('does not match partial words (selector:hover)', () => assert.ok(!RE.test('selector:hover')));
});

// ---- 3. search_repos tool (end-to-end) --------------------------------------

describe('search_repos tool', () => {
let client: Client;
let savedFetch: typeof globalThis.fetch;

before(async () => {
savedFetch = globalThis.fetch;
process.env.SOURCEBOT_HOST = 'http://localhost:3000';
process.env.SOURCEBOT_API_KEY = 'test-key';

// Dynamic import so env vars are set first
const { server } = await import('../index.js');
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
client = new Client({ name: 'test-client', version: '0.0.1' });
await client.connect(clientTransport);
});

after(async () => {
await client?.close();
globalThis.fetch = savedFetch;
});

it('returns repo list from API repoResults', async () => {
mockFetch(makeSearchResponse({
repoResults: [
{ repositoryId: 1, repository: 'github.com/acme/frontend', matchCount: 20 },
{ repositoryId: 2, repository: 'github.com/acme/backend', matchCount: 5 },
],
}));
const text = getText(await client.callTool({ name: 'search_repos', arguments: { query: 'useState' } }));
assert.ok(text.includes('github.com/acme/frontend'));
assert.ok(text.includes('github.com/acme/backend'));
assert.ok(text.includes('matches: 20'));
});

it('returns no-results message when repoResults is empty', async () => {
mockFetch(makeSearchResponse({ repoResults: [] }));
const text = getText(await client.callTool({ name: 'search_repos', arguments: { query: 'nonExistentSymbol' } }));
assert.ok(text.toLowerCase().includes('no repositories'));
});

it('appends select:repo and lang: filters to the query', async () => {
let captured = '';
captureFetch(makeSearchResponse({ repoResults: [] }), (body) => { captured = body.query as string; });
await client.callTool({ name: 'search_repos', arguments: { query: 'useState', filterByLanguages: ['TypeScript', 'JavaScript'] } });
assert.ok(captured.includes('lang:TypeScript'), `query: ${captured}`);
assert.ok(captured.includes('lang:JavaScript'), `query: ${captured}`);
assert.ok(captured.includes('select:repo'), `query: ${captured}`);
});

it('respects maxResults limit', async () => {
const repos = Array.from({ length: 10 }, (_, i) => ({
repositoryId: i, repository: `github.com/acme/repo-${i}`, matchCount: 10 - i,
}));
mockFetch(makeSearchResponse({ repoResults: repos }));
const text = getText(await client.callTool({ name: 'search_repos', arguments: { query: 'test', maxResults: 3 } }));
assert.ok(text.includes('10 repositor'), `missing total: ${text}`);
assert.ok(text.includes('top 3'), `missing limit notice: ${text}`);
const lines = text.split('\n').filter((l: string) => l.startsWith('repo:'));
assert.equal(lines.length, 3);
});
});
Loading