Skip to content

Commit ab58387

Browse files
committed
feat(mcp): add search_repos tool and support repoResults in schemas
schemas.ts / types.ts - Adds repoResultSchema and RepoResult, mirroring the web package - Adds repoResults?: RepoResult[] to searchResponseSchema so the MCP client can parse select:repo API responses index.ts — new tool: search_repos - Accepts query, filterByLanguages, caseSensitive, ref, maxResults - Appends select:repo to the query before calling the search API - Returns a formatted list: repo name, match count, optional URL - Designed to answer 'which repos use X?' questions directly index.ts — shared helpers (reusable by future tools e.g. search_commits) - searchFilterParamsSchema: zod schema spread into each tool's params - buildQueryFilters(): pure function that appends lang:, repo:, file:, rev: filter tokens to a query string index.ts — exports `server` for use in tests
1 parent 5d9ad83 commit ab58387

File tree

4 files changed

+158
-45
lines changed

4 files changed

+158
-45
lines changed

packages/mcp/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
"scripts": {
88
"build": "tsc",
99
"dev": "node ./dist/index.js",
10-
"build:watch": "tsc-watch --preserveWatchOutput"
10+
"build:watch": "tsc-watch --preserveWatchOutput",
11+
"test": "node --import tsx/esm --test src/__tests__/*.test.ts"
1112
},
1213
"devDependencies": {
1314
"@types/express": "^5.0.1",

packages/mcp/src/index.ts

Lines changed: 146 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,79 @@ import { buildTreeNodeIndex, joinTreePath, normalizeTreePath, sortTreeEntries }
1515
const dedent = _dedent.withOptions({ alignValues: true });
1616

1717
// Create MCP server
18-
const server = new McpServer({
18+
export const server = new McpServer({
1919
name: 'sourcebot-mcp-server',
2020
version: '0.1.0',
2121
});
2222

2323

24+
// ---------------------------------------------------------------------------
25+
// Shared query-building helpers
26+
// ---------------------------------------------------------------------------
27+
28+
/**
29+
* Common filter parameters accepted by every search tool.
30+
* Add new filter params here once and they become available to all tools.
31+
*/
32+
const searchFilterParamsSchema = {
33+
filterByLanguages: z
34+
.array(z.string())
35+
.describe(`Scope the search to the provided languages.`)
36+
.optional(),
37+
filterByRepos: z
38+
.array(z.string())
39+
.describe(`Scope the search to the provided repositories.`)
40+
.optional(),
41+
filterByFilepaths: z
42+
.array(z.string())
43+
.describe(`Scope the search to the provided file paths.`)
44+
.optional(),
45+
ref: z
46+
.string()
47+
.describe(`Commit SHA, branch or tag name to search on. Defaults to the default branch.`)
48+
.optional(),
49+
caseSensitive: z
50+
.boolean()
51+
.describe(`Whether the search should be case sensitive (default: false).`)
52+
.optional(),
53+
useRegex: z
54+
.boolean()
55+
.describe(`Whether to use regular expression matching. When false, substring matching is used. (default: false)`)
56+
.optional(),
57+
};
58+
59+
/**
60+
* Appends zoekt filter tokens (lang:, repo:, file:, rev:) to a base query.
61+
*/
62+
const buildQueryFilters = ({
63+
query,
64+
filterByLanguages = [],
65+
filterByRepos = [],
66+
filterByFilepaths = [],
67+
ref,
68+
}: {
69+
query: string;
70+
filterByLanguages?: string[];
71+
filterByRepos?: string[];
72+
filterByFilepaths?: string[];
73+
ref?: string;
74+
}): string => {
75+
let q = query;
76+
if (filterByRepos.length > 0) {
77+
q += ` (repo:${filterByRepos.map(id => escapeStringRegexp(id)).join(' or repo:')})`;
78+
}
79+
if (filterByLanguages.length > 0) {
80+
q += ` (lang:${filterByLanguages.join(' or lang:')})`;
81+
}
82+
if (filterByFilepaths.length > 0) {
83+
q += ` (file:${filterByFilepaths.map(fp => escapeStringRegexp(fp)).join(' or file:')})`;
84+
}
85+
if (ref) {
86+
q += ` (rev:${ref})`;
87+
}
88+
return q;
89+
};
90+
2491
server.tool(
2592
"search_code",
2693
dedent`
@@ -36,34 +103,13 @@ server.tool(
36103
const escaped = val.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
37104
return `"${escaped}"`;
38105
}),
39-
useRegex: z
40-
.boolean()
41-
.describe(`Whether to use regular expression matching to match the search query against code contents. When false, substring matching is used. (default: false)`)
42-
.optional(),
43-
filterByRepos: z
44-
.array(z.string())
45-
.describe(`Scope the search to the provided repositories.`)
46-
.optional(),
47-
filterByLanguages: z
48-
.array(z.string())
49-
.describe(`Scope the search to the provided languages.`)
50-
.optional(),
51-
filterByFilepaths: z
52-
.array(z.string())
53-
.describe(`Scope the search to the provided filepaths.`)
54-
.optional(),
55-
caseSensitive: z
56-
.boolean()
57-
.describe(`Whether the search should be case sensitive (default: false).`)
58-
.optional(),
106+
107+
...searchFilterParamsSchema,
59108
includeCodeSnippets: z
60109
.boolean()
61110
.describe(`Whether to include the code snippets in the response. If false, only the file's URL, repository, and language will be returned. (default: false)`)
62111
.optional(),
63-
ref: z
64-
.string()
65-
.describe(`Commit SHA, branch or tag name to search on. If not provided, defaults to the default branch (usually 'main' or 'master').`)
66-
.optional(),
112+
67113
maxTokens: numberSchema
68114
.describe(`The maximum number of tokens to return (default: ${env.DEFAULT_MINIMUM_TOKENS}). Higher values provide more context but consume more tokens. Values less than ${env.DEFAULT_MINIMUM_TOKENS} will be ignored.`)
69115
.transform((val) => (val < env.DEFAULT_MINIMUM_TOKENS ? env.DEFAULT_MINIMUM_TOKENS : val))
@@ -80,21 +126,7 @@ server.tool(
80126
ref,
81127
useRegex = false,
82128
}) => {
83-
if (repos.length > 0) {
84-
query += ` (repo:${repos.map(id => escapeStringRegexp(id)).join(' or repo:')})`;
85-
}
86-
87-
if (languages.length > 0) {
88-
query += ` (lang:${languages.join(' or lang:')})`;
89-
}
90-
91-
if (filepaths.length > 0) {
92-
query += ` (file:${filepaths.map(filepath => escapeStringRegexp(filepath)).join(' or file:')})`;
93-
}
94-
95-
if (ref) {
96-
query += ` ( rev:${ref} )`;
97-
}
129+
query = buildQueryFilters({ query, filterByRepos: repos, filterByLanguages: languages, filterByFilepaths: filepaths, ref });
98130

99131
const response = await search({
100132
query,
@@ -445,12 +477,82 @@ server.tool(
445477
}
446478
);
447479
480+
481+
server.tool(
482+
"search_repos",
483+
`Searches code and returns the list of matching repositories (deduplicated), sorted by number of matches. Useful for answering "which repos use X?" questions. Equivalent to appending select:repo to a Sourcebot query.`,
484+
{
485+
query: z
486+
.string()
487+
.describe(`The search pattern to match against code contents. Supports plain text or regex if useRegex is true.`),
488+
489+
...searchFilterParamsSchema,
490+
maxResults: z
491+
.number()
492+
.int()
493+
.positive()
494+
.describe(`Maximum number of repositories to return (default: 50).`)
495+
.optional(),
496+
},
497+
async ({
498+
query,
499+
filterByLanguages: languages = [],
500+
caseSensitive = false,
501+
ref,
502+
useRegex = false,
503+
maxResults = 50,
504+
}) => {
505+
let fullQuery = buildQueryFilters({ query, filterByLanguages: languages, ref });
506+
if (!fullQuery.includes('select:repo')) {
507+
fullQuery += ' select:repo';
508+
}
509+
510+
const response = await search({
511+
query: fullQuery,
512+
matches: env.DEFAULT_MATCHES,
513+
contextLines: 0,
514+
isRegexEnabled: useRegex,
515+
isCaseSensitivityEnabled: caseSensitive,
516+
});
517+
518+
const repos = response.repoResults ?? [];
519+
520+
if (repos.length === 0) {
521+
return {
522+
content: [{
523+
type: "text",
524+
text: `No repositories found matching: ${query}`,
525+
}],
526+
};
527+
}
528+
529+
const limited = repos.slice(0, maxResults);
530+
const lines = limited.map(r =>
531+
`repo: ${r.repository} matches: ${r.matchCount}${r.repositoryInfo?.webUrl ? ` url: ${r.repositoryInfo.webUrl}` : ''}`
532+
);
533+
534+
const text = [
535+
`Found ${repos.length} repositor${repos.length === 1 ? 'y' : 'ies'} matching "${query}"${limited.length < repos.length ? ` (showing top ${maxResults})` : ''}:`,
536+
'',
537+
...lines,
538+
].join('\n');
539+
540+
return {
541+
content: [{ type: "text", text }],
542+
};
543+
}
544+
);
545+
448546
const runServer = async () => {
449547
const transport = new StdioServerTransport();
450548
await server.connect(transport);
451549
}
452550

453-
runServer().catch((error) => {
454-
console.error('Failed to start MCP server:', error);
455-
process.exit(1);
456-
});
551+
// Only auto-start when run directly (not when imported in tests)
552+
const isMain = process.argv[1] && import.meta.url.endsWith(process.argv[1].replace(/\.ts$/, '.js').split('/').pop() ?? '');
553+
if (isMain) {
554+
runServer().catch((error) => {
555+
console.error('Failed to start MCP server:', error);
556+
process.exit(1);
557+
});
558+
}

packages/mcp/src/schemas.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,8 +114,16 @@ export const searchStatsSchema = z.object({
114114
flushReason: z.string(),
115115
});
116116

117+
export const repoResultSchema = z.object({
118+
repositoryId: z.number(),
119+
repository: z.string(),
120+
repositoryInfo: repositoryInfoSchema.optional(),
121+
matchCount: z.number(),
122+
});
123+
117124
export const searchResponseSchema = z.object({
118125
stats: searchStatsSchema,
126+
repoResults: z.array(repoResultSchema).optional(),
119127
files: z.array(z.object({
120128
fileName: z.object({
121129
// The name of the file

packages/mcp/src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import {
44
fileSourceResponseSchema,
55
listReposQueryParamsSchema,
6+
repoResultSchema,
67
locationSchema,
78
searchRequestSchema,
89
searchResponseSchema,
@@ -24,6 +25,7 @@ import {
2425
} from "./schemas.js";
2526
import { z } from "zod";
2627

28+
export type RepoResult = z.infer<typeof repoResultSchema>;
2729
export type SearchRequest = z.infer<typeof searchRequestSchema>;
2830
export type SearchResponse = z.infer<typeof searchResponseSchema>;
2931
export type SearchResultRange = z.infer<typeof rangeSchema>;

0 commit comments

Comments
 (0)