Skip to content

Commit e4327ea

Browse files
feat(mcp): add list_tree tool (#870)
* feat(mcp): add list_tree tool This tool allows an agent to list files and directories from a repository path. This can be used as a simple directory listing tool or for a directory tree tool by specifying a depth > 1 * add PR link to mcp CHANGELOG.md * chore(docs): markdown alignment * improved error message for when repo is not found --------- Co-authored-by: bkellam <bshizzle1234@gmail.com>
1 parent bf1ae94 commit e4327ea

File tree

9 files changed

+369
-8
lines changed

9 files changed

+369
-8
lines changed

docs/docs/features/mcp-server.mdx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,22 @@ Parameters:
189189
| `ref` | no | Commit SHA, branch or tag name to fetch the source code for. If not provided, uses the default branch. |
190190

191191

192+
### `list_tree`
193+
194+
Lists files and directories from a repository path. Can be used as a directory listing tool (`depth: 1`) or a repo-tree tool (`depth > 1`).
195+
196+
Parameters:
197+
| Name | Required | Description |
198+
|:---------------------|:---------|:--------------------------------------------------------------------------------------------------------------|
199+
| `repo` | yes | The name of the repository to list files from. |
200+
| `path` | no | Directory path (relative to repo root). If omitted, the repo root is used. |
201+
| `ref` | no | Commit SHA, branch or tag name to list files from. If not provided, uses the default branch. |
202+
| `depth` | no | Number of directory levels to traverse below `path` (min 1, max 10, default: 1). |
203+
| `includeFiles` | no | Whether to include file entries in the output (default: true). |
204+
| `includeDirectories` | no | Whether to include directory entries in the output (default: true). |
205+
| `maxEntries` | no | Maximum number of entries to return before truncating (min 1, max 10000, default: 1000). |
206+
207+
192208
### `list_commits`
193209

194210
Get a list of commits for a given repository.

packages/mcp/CHANGELOG.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
- Added `list_tree` tool for listing files/directories in a repository path with depth controls, suitable for both directory listings and repo-tree workflows. [#870](https://github.com/sourcebot-dev/sourcebot/pull/870)
12+
1013
## [1.0.15] - 2026-02-02
1114

1215
### Added
@@ -94,4 +97,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
9497
## [1.0.0] - 2025-05-07
9598

9699
### Added
97-
- Initial release
100+
- Initial release

packages/mcp/README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,25 @@ Reads the source code for a given file.
214214
| `ref` | no | Commit SHA, branch or tag name to fetch the source code for. If not provided, uses the default branch. |
215215
</details>
216216

217+
### list_tree
218+
219+
Lists files and directories from a repository path. Can be used as a directory listing tool (`depth: 1`) or a repo-tree tool (`depth > 1`).
220+
221+
<details>
222+
<summary>Parameters</summary>
223+
224+
| Name | Required | Description |
225+
|:---------------------|:---------|:--------------------------------------------------------------------------------------------------------------|
226+
| `repo` | yes | The name of the repository to list files from. |
227+
| `path` | no | Directory path (relative to repo root). If omitted, the repo root is used. |
228+
| `ref` | no | Commit SHA, branch or tag name to list files from. If not provided, uses the default branch. |
229+
| `depth` | no | Number of directory levels to traverse below `path` (min 1, max 10, default: 1). |
230+
| `includeFiles` | no | Whether to include file entries in the output (default: true). |
231+
| `includeDirectories` | no | Whether to include directory entries in the output (default: true). |
232+
| `maxEntries` | no | Maximum number of entries to return before truncating (min 1, max 10000, default: 1000). |
233+
234+
</details>
235+
217236
### list_commits
218237

219238
Get a list of commits for a given repository.

packages/mcp/src/client.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { env } from './env.js';
2-
import { listReposResponseSchema, searchResponseSchema, fileSourceResponseSchema, listCommitsResponseSchema, askCodebaseResponseSchema, listLanguageModelsResponseSchema } from './schemas.js';
3-
import { AskCodebaseRequest, AskCodebaseResponse, FileSourceRequest, ListReposQueryParams, SearchRequest, ListCommitsQueryParamsSchema, ListLanguageModelsResponse } from './types.js';
2+
import { listReposResponseSchema, searchResponseSchema, fileSourceResponseSchema, listCommitsResponseSchema, askCodebaseResponseSchema, listLanguageModelsResponseSchema, listTreeApiResponseSchema } from './schemas.js';
3+
import { AskCodebaseRequest, AskCodebaseResponse, FileSourceRequest, ListReposQueryParams, SearchRequest, ListCommitsQueryParamsSchema, ListLanguageModelsResponse, ListTreeApiRequest, ListTreeApiResponse } from './types.js';
44
import { isServiceError, ServiceErrorException } from './utils.js';
55
import { z } from 'zod';
66

@@ -108,6 +108,26 @@ export const listCommits = async (queryParams: ListCommitsQueryParamsSchema) =>
108108
return { commits, totalCount };
109109
}
110110

111+
/**
112+
* Fetches a repository tree (or subtree union) from the Sourcebot tree API.
113+
*
114+
* @param request - Repository name, revision, and path selectors for the tree query
115+
* @returns A tree response rooted at `tree` containing nested `tree`/`blob` nodes
116+
*/
117+
export const listTree = async (request: ListTreeApiRequest): Promise<ListTreeApiResponse> => {
118+
const response = await fetch(`${env.SOURCEBOT_HOST}/api/tree`, {
119+
method: 'POST',
120+
headers: {
121+
'Content-Type': 'application/json',
122+
'X-Sourcebot-Client-Source': 'mcp',
123+
...(env.SOURCEBOT_API_KEY ? { 'X-Sourcebot-Api-Key': env.SOURCEBOT_API_KEY } : {})
124+
},
125+
body: JSON.stringify(request),
126+
});
127+
128+
return parseResponse(response, listTreeApiResponseSchema);
129+
}
130+
111131
/**
112132
* Asks a natural language question about the codebase using the Sourcebot AI agent.
113133
* This is a blocking call that runs the full agent loop and returns when complete.

packages/mcp/src/index.ts

Lines changed: 153 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,11 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
66
import _dedent from "dedent";
77
import escapeStringRegexp from 'escape-string-regexp';
88
import { z } from 'zod';
9-
import { askCodebase, getFileSource, listCommits, listLanguageModels, listRepos, search } from './client.js';
9+
import { askCodebase, getFileSource, listCommits, listLanguageModels, listRepos, listTree, search } from './client.js';
1010
import { env, numberSchema } from './env.js';
11-
import { askCodebaseRequestSchema, fileSourceRequestSchema, listCommitsQueryParamsSchema, listReposQueryParamsSchema } from './schemas.js';
12-
import { AskCodebaseRequest, FileSourceRequest, ListCommitsQueryParamsSchema, ListReposQueryParams, TextContent } from './types.js';
11+
import { askCodebaseRequestSchema, DEFAULT_MAX_TREE_ENTRIES, DEFAULT_TREE_DEPTH, fileSourceRequestSchema, listCommitsQueryParamsSchema, listReposQueryParamsSchema, listTreeRequestSchema, MAX_MAX_TREE_ENTRIES, MAX_TREE_DEPTH } from './schemas.js';
12+
import { AskCodebaseRequest, FileSourceRequest, ListCommitsQueryParamsSchema, ListReposQueryParams, ListTreeEntry, ListTreeRequest, TextContent } from './types.js';
13+
import { buildTreeNodeIndex, joinTreePath, normalizeTreePath, sortTreeEntries } from './utils.js';
1314

1415
const dedent = _dedent.withOptions({ alignValues: true });
1516

@@ -238,6 +239,155 @@ server.tool(
238239
}
239240
);
240241

242+
server.tool(
243+
"list_tree",
244+
dedent`
245+
Lists files and directories from a repository path. This can be used as a repo tree tool or directory listing tool.
246+
Returns a flat list of entries with path metadata and depth relative to the requested path.
247+
`,
248+
listTreeRequestSchema.shape,
249+
async ({
250+
repo,
251+
path = '',
252+
ref = 'HEAD',
253+
depth = DEFAULT_TREE_DEPTH,
254+
includeFiles = true,
255+
includeDirectories = true,
256+
maxEntries = DEFAULT_MAX_TREE_ENTRIES,
257+
}: ListTreeRequest) => {
258+
const normalizedPath = normalizeTreePath(path);
259+
const normalizedDepth = Math.min(depth, MAX_TREE_DEPTH);
260+
const normalizedMaxEntries = Math.min(maxEntries, MAX_MAX_TREE_ENTRIES);
261+
262+
if (!includeFiles && !includeDirectories) {
263+
return {
264+
content: [{
265+
type: "text",
266+
text: JSON.stringify({
267+
repo,
268+
ref,
269+
path: normalizedPath,
270+
entries: [] as ListTreeEntry[],
271+
totalReturned: 0,
272+
truncated: false,
273+
}),
274+
}],
275+
};
276+
}
277+
278+
// BFS frontier of directories still to expand. Each item stores a repo-relative
279+
// directory path plus the current depth from the requested root `path`.
280+
const queue: Array<{ path: string; depth: number }> = [{ path: normalizedPath, depth: 0 }];
281+
282+
// Tracks directory paths that have already been enqueued.
283+
// With the current single-root traversal duplicates are uncommon, but this
284+
// prevents duplicate expansion if we later support overlapping multi-root
285+
// inputs (e.g. ["src", "src/lib"]) or receive overlapping tree data.
286+
const queuedPaths = new Set<string>([normalizedPath]);
287+
288+
const seenEntries = new Set<string>();
289+
const entries: ListTreeEntry[] = [];
290+
let truncated = false;
291+
292+
// Traverse breadth-first by depth, batching all directories at the same
293+
// depth into a single /api/tree request per iteration.
294+
while (queue.length > 0 && !truncated) {
295+
const currentDepth = queue[0]!.depth;
296+
const currentLevelPaths: string[] = [];
297+
298+
// Drain only the current depth level so we can issue one API call
299+
// for all sibling directories before moving deeper.
300+
while (queue.length > 0 && queue[0]!.depth === currentDepth) {
301+
const next = queue.shift()!;
302+
currentLevelPaths.push(next.path);
303+
}
304+
305+
// Ask Sourcebot for a tree spanning all requested paths at this level.
306+
const treeResponse = await listTree({
307+
repoName: repo,
308+
revisionName: ref,
309+
paths: currentLevelPaths.filter(Boolean),
310+
});
311+
const treeNodeIndex = buildTreeNodeIndex(treeResponse.tree);
312+
313+
for (const currentPath of currentLevelPaths) {
314+
const currentNode = currentPath === '' ? treeResponse.tree : treeNodeIndex.get(currentPath);
315+
if (!currentNode || currentNode.type !== 'tree') {
316+
// Skip paths that are missing from the response or resolve to a
317+
// file node. We only iterate children of directories.
318+
continue;
319+
}
320+
321+
for (const child of currentNode.children) {
322+
if (child.type !== 'tree' && child.type !== 'blob') {
323+
// Skip non-standard git object types (e.g. unexpected entries)
324+
// since this tool only exposes directories and files.
325+
continue;
326+
}
327+
328+
const childPath = joinTreePath(currentPath, child.name);
329+
const childDepth = currentDepth + 1;
330+
331+
// Queue child directories for the next depth level only if
332+
// they are within the requested depth bound.
333+
if (child.type === 'tree' && childDepth < normalizedDepth && !queuedPaths.has(childPath)) {
334+
queue.push({ path: childPath, depth: childDepth });
335+
queuedPaths.add(childPath);
336+
}
337+
338+
if ((child.type === 'blob' && !includeFiles) || (child.type === 'tree' && !includeDirectories)) {
339+
// Skip entries filtered out by caller preferences
340+
// (`includeFiles` / `includeDirectories`).
341+
continue;
342+
}
343+
344+
const key = `${child.type}:${childPath}`;
345+
if (seenEntries.has(key)) {
346+
// Skip duplicates when multiple requested paths overlap and
347+
// surface the same child entry.
348+
continue;
349+
}
350+
seenEntries.add(key);
351+
352+
// Stop collecting once the entry budget is exhausted.
353+
if (entries.length >= normalizedMaxEntries) {
354+
truncated = true;
355+
break;
356+
}
357+
358+
entries.push({
359+
type: child.type,
360+
path: childPath,
361+
name: child.name,
362+
parentPath: currentPath,
363+
depth: childDepth,
364+
});
365+
}
366+
367+
if (truncated) {
368+
break;
369+
}
370+
}
371+
}
372+
373+
const sortedEntries = sortTreeEntries(entries);
374+
375+
return {
376+
content: [{
377+
type: "text",
378+
text: JSON.stringify({
379+
repo,
380+
ref,
381+
path: normalizedPath,
382+
entries: sortedEntries,
383+
totalReturned: sortedEntries.length,
384+
truncated,
385+
}),
386+
}]
387+
};
388+
}
389+
);
390+
241391
server.tool(
242392
"list_language_models",
243393
dedent`Lists the available language models configured on the Sourcebot instance. Use this to discover which models can be specified when calling ask_codebase.`,

packages/mcp/src/schemas.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,94 @@ export const fileSourceResponseSchema = z.object({
216216
externalWebUrl: z.string().optional(),
217217
});
218218

219+
type TreeNode = {
220+
type: string;
221+
path: string;
222+
name: string;
223+
children: TreeNode[];
224+
};
225+
226+
const treeNodeSchema: z.ZodType<TreeNode> = z.lazy(() => z.object({
227+
type: z.string(),
228+
path: z.string(),
229+
name: z.string(),
230+
children: z.array(treeNodeSchema),
231+
}));
232+
233+
export const listTreeApiRequestSchema = z.object({
234+
repoName: z.string(),
235+
revisionName: z.string(),
236+
paths: z.array(z.string()),
237+
});
238+
239+
export const listTreeApiResponseSchema = z.object({
240+
tree: treeNodeSchema,
241+
});
242+
243+
export const DEFAULT_TREE_DEPTH = 1;
244+
export const MAX_TREE_DEPTH = 10;
245+
export const DEFAULT_MAX_TREE_ENTRIES = 1000;
246+
export const MAX_MAX_TREE_ENTRIES = 10000;
247+
248+
export const listTreeRequestSchema = z.object({
249+
repo: z
250+
.string()
251+
.describe("The name of the repository to list files from."),
252+
path: z
253+
.string()
254+
.describe("Directory path (relative to repo root). If omitted, the repo root is used.")
255+
.optional()
256+
.default(''),
257+
ref: z
258+
.string()
259+
.describe("Commit SHA, branch or tag name to list files from. If not provided, uses the default branch.")
260+
.optional()
261+
.default('HEAD'),
262+
depth: z
263+
.number()
264+
.int()
265+
.positive()
266+
.max(MAX_TREE_DEPTH)
267+
.describe(`How many directory levels to traverse below \`path\` (min 1, max ${MAX_TREE_DEPTH}, default ${DEFAULT_TREE_DEPTH}).`)
268+
.optional()
269+
.default(DEFAULT_TREE_DEPTH),
270+
includeFiles: z
271+
.boolean()
272+
.describe("Whether to include files in the output (default: true).")
273+
.optional()
274+
.default(true),
275+
includeDirectories: z
276+
.boolean()
277+
.describe("Whether to include directories in the output (default: true).")
278+
.optional()
279+
.default(true),
280+
maxEntries: z
281+
.number()
282+
.int()
283+
.positive()
284+
.max(MAX_MAX_TREE_ENTRIES)
285+
.describe(`Maximum number of entries to return (min 1, max ${MAX_MAX_TREE_ENTRIES}, default ${DEFAULT_MAX_TREE_ENTRIES}).`)
286+
.optional()
287+
.default(DEFAULT_MAX_TREE_ENTRIES),
288+
});
289+
290+
export const listTreeEntrySchema = z.object({
291+
type: z.enum(['tree', 'blob']),
292+
path: z.string(),
293+
name: z.string(),
294+
parentPath: z.string(),
295+
depth: z.number().int().positive(),
296+
});
297+
298+
export const listTreeResponseSchema = z.object({
299+
repo: z.string(),
300+
ref: z.string(),
301+
path: z.string(),
302+
entries: z.array(listTreeEntrySchema),
303+
totalReturned: z.number().int().nonnegative(),
304+
truncated: z.boolean(),
305+
});
306+
219307
export const serviceErrorSchema = z.object({
220308
statusCode: z.number(),
221309
errorCode: z.string(),

packages/mcp/src/types.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ import {
1616
askCodebaseResponseSchema,
1717
languageModelInfoSchema,
1818
listLanguageModelsResponseSchema,
19+
listTreeApiRequestSchema,
20+
listTreeApiResponseSchema,
21+
listTreeRequestSchema,
22+
listTreeEntrySchema,
23+
listTreeResponseSchema,
1924
} from "./schemas.js";
2025
import { z } from "zod";
2126

@@ -44,3 +49,11 @@ export type AskCodebaseResponse = z.infer<typeof askCodebaseResponseSchema>;
4449

4550
export type LanguageModelInfo = z.infer<typeof languageModelInfoSchema>;
4651
export type ListLanguageModelsResponse = z.infer<typeof listLanguageModelsResponseSchema>;
52+
53+
export type ListTreeApiRequest = z.infer<typeof listTreeApiRequestSchema>;
54+
export type ListTreeApiResponse = z.infer<typeof listTreeApiResponseSchema>;
55+
export type ListTreeApiNode = ListTreeApiResponse["tree"];
56+
57+
export type ListTreeRequest = z.input<typeof listTreeRequestSchema>;
58+
export type ListTreeEntry = z.infer<typeof listTreeEntrySchema>;
59+
export type ListTreeResponse = z.infer<typeof listTreeResponseSchema>;

0 commit comments

Comments
 (0)