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
16 changes: 16 additions & 0 deletions docs/docs/features/mcp-server.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,22 @@ Parameters:
| `ref` | no | Commit SHA, branch or tag name to fetch the source code for. If not provided, uses the default branch. |


### `list_tree`

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`).

Parameters:
| Name | Required | Description |
|:---------------------|:---------|:--------------------------------------------------------------------------------------------------------------|
| `repo` | yes | The name of the repository to list files from. |
| `path` | no | Directory path (relative to repo root). If omitted, the repo root is used. |
| `ref` | no | Commit SHA, branch or tag name to list files from. If not provided, uses the default branch. |
| `depth` | no | Number of directory levels to traverse below `path` (min 1, max 10, default: 1). |
| `includeFiles` | no | Whether to include file entries in the output (default: true). |
| `includeDirectories` | no | Whether to include directory entries in the output (default: true). |
| `maxEntries` | no | Maximum number of entries to return before truncating (min 1, max 10000, default: 1000). |


### `list_commits`

Get a list of commits for a given repository.
Expand Down
5 changes: 4 additions & 1 deletion packages/mcp/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- 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)

## [1.0.15] - 2026-02-02

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

### Added
- Initial release
- Initial release
19 changes: 19 additions & 0 deletions packages/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,25 @@ Reads the source code for a given file.
| `ref` | no | Commit SHA, branch or tag name to fetch the source code for. If not provided, uses the default branch. |
</details>

### list_tree

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`).

<details>
<summary>Parameters</summary>

| Name | Required | Description |
|:---------------------|:---------|:--------------------------------------------------------------------------------------------------------------|
| `repo` | yes | The name of the repository to list files from. |
| `path` | no | Directory path (relative to repo root). If omitted, the repo root is used. |
| `ref` | no | Commit SHA, branch or tag name to list files from. If not provided, uses the default branch. |
| `depth` | no | Number of directory levels to traverse below `path` (min 1, max 10, default: 1). |
| `includeFiles` | no | Whether to include file entries in the output (default: true). |
| `includeDirectories` | no | Whether to include directory entries in the output (default: true). |
| `maxEntries` | no | Maximum number of entries to return before truncating (min 1, max 10000, default: 1000). |

</details>

### list_commits

Get a list of commits for a given repository.
Expand Down
24 changes: 22 additions & 2 deletions packages/mcp/src/client.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { env } from './env.js';
import { listReposResponseSchema, searchResponseSchema, fileSourceResponseSchema, listCommitsResponseSchema, askCodebaseResponseSchema, listLanguageModelsResponseSchema } from './schemas.js';
import { AskCodebaseRequest, AskCodebaseResponse, FileSourceRequest, ListReposQueryParams, SearchRequest, ListCommitsQueryParamsSchema, ListLanguageModelsResponse } from './types.js';
import { listReposResponseSchema, searchResponseSchema, fileSourceResponseSchema, listCommitsResponseSchema, askCodebaseResponseSchema, listLanguageModelsResponseSchema, listTreeApiResponseSchema } from './schemas.js';
import { AskCodebaseRequest, AskCodebaseResponse, FileSourceRequest, ListReposQueryParams, SearchRequest, ListCommitsQueryParamsSchema, ListLanguageModelsResponse, ListTreeApiRequest, ListTreeApiResponse } from './types.js';
import { isServiceError, ServiceErrorException } from './utils.js';
import { z } from 'zod';

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

/**
* Fetches a repository tree (or subtree union) from the Sourcebot tree API.
*
* @param request - Repository name, revision, and path selectors for the tree query
* @returns A tree response rooted at `tree` containing nested `tree`/`blob` nodes
*/
export const listTree = async (request: ListTreeApiRequest): Promise<ListTreeApiResponse> => {
const response = await fetch(`${env.SOURCEBOT_HOST}/api/tree`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Sourcebot-Client-Source': 'mcp',
...(env.SOURCEBOT_API_KEY ? { 'X-Sourcebot-Api-Key': env.SOURCEBOT_API_KEY } : {})
},
body: JSON.stringify(request),
});

return parseResponse(response, listTreeApiResponseSchema);
}

/**
* Asks a natural language question about the codebase using the Sourcebot AI agent.
* This is a blocking call that runs the full agent loop and returns when complete.
Expand Down
156 changes: 153 additions & 3 deletions packages/mcp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import _dedent from "dedent";
import escapeStringRegexp from 'escape-string-regexp';
import { z } from 'zod';
import { askCodebase, getFileSource, listCommits, listLanguageModels, listRepos, search } from './client.js';
import { askCodebase, getFileSource, listCommits, listLanguageModels, listRepos, listTree, search } from './client.js';
import { env, numberSchema } from './env.js';
import { askCodebaseRequestSchema, fileSourceRequestSchema, listCommitsQueryParamsSchema, listReposQueryParamsSchema } from './schemas.js';
import { AskCodebaseRequest, FileSourceRequest, ListCommitsQueryParamsSchema, ListReposQueryParams, TextContent } from './types.js';
import { askCodebaseRequestSchema, DEFAULT_MAX_TREE_ENTRIES, DEFAULT_TREE_DEPTH, fileSourceRequestSchema, listCommitsQueryParamsSchema, listReposQueryParamsSchema, listTreeRequestSchema, MAX_MAX_TREE_ENTRIES, MAX_TREE_DEPTH } from './schemas.js';
import { AskCodebaseRequest, FileSourceRequest, ListCommitsQueryParamsSchema, ListReposQueryParams, ListTreeEntry, ListTreeRequest, TextContent } from './types.js';
import { buildTreeNodeIndex, joinTreePath, normalizeTreePath, sortTreeEntries } from './utils.js';

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

Expand Down Expand Up @@ -238,6 +239,155 @@ server.tool(
}
);

server.tool(
Copy link
Contributor

@brendan-kellam brendan-kellam Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This approach makes sense for the apis that we expose, and I'm happy to ship this. For context,/api/tree is designed around the file tree viewer in the code browser, and is why we accept a list of paths (representing open directories) and return a tree spanning the union.

An alternative, potentially simpler, approach I was going to suggest is that we could use ls-tree with -r to recurse from a specified path. It does not accept a max depth unfortunately, so we would need to truncate the output to the specified depth. Perf is ok (because of #791), even on large repos like UnrealEngine:

> UnrealEngine git:(release) time git ls-tree -r HEAD . > /dev/null
git ls-tree -r HEAD . > /dev/null  0.10s user 0.02s system 94% cpu 0.125 total

Regardless of the approach, we will want to move this api surface into the server s.t., we can also add a list_tree tool to the Ask agent. I'll leave that for a follow-up PR that I can open later this week.

"list_tree",
dedent`
Lists files and directories from a repository path. This can be used as a repo tree tool or directory listing tool.
Returns a flat list of entries with path metadata and depth relative to the requested path.
`,
listTreeRequestSchema.shape,
async ({
repo,
path = '',
ref = 'HEAD',
depth = DEFAULT_TREE_DEPTH,
includeFiles = true,
includeDirectories = true,
maxEntries = DEFAULT_MAX_TREE_ENTRIES,
}: ListTreeRequest) => {
const normalizedPath = normalizeTreePath(path);
const normalizedDepth = Math.min(depth, MAX_TREE_DEPTH);
const normalizedMaxEntries = Math.min(maxEntries, MAX_MAX_TREE_ENTRIES);

if (!includeFiles && !includeDirectories) {
return {
content: [{
type: "text",
text: JSON.stringify({
repo,
ref,
path: normalizedPath,
entries: [] as ListTreeEntry[],
totalReturned: 0,
truncated: false,
}),
}],
};
}

// BFS frontier of directories still to expand. Each item stores a repo-relative
// directory path plus the current depth from the requested root `path`.
const queue: Array<{ path: string; depth: number }> = [{ path: normalizedPath, depth: 0 }];

// Tracks directory paths that have already been enqueued.
// With the current single-root traversal duplicates are uncommon, but this
// prevents duplicate expansion if we later support overlapping multi-root
// inputs (e.g. ["src", "src/lib"]) or receive overlapping tree data.
const queuedPaths = new Set<string>([normalizedPath]);

const seenEntries = new Set<string>();
const entries: ListTreeEntry[] = [];
let truncated = false;

// Traverse breadth-first by depth, batching all directories at the same
// depth into a single /api/tree request per iteration.
while (queue.length > 0 && !truncated) {
const currentDepth = queue[0]!.depth;
const currentLevelPaths: string[] = [];

// Drain only the current depth level so we can issue one API call
// for all sibling directories before moving deeper.
while (queue.length > 0 && queue[0]!.depth === currentDepth) {
const next = queue.shift()!;
currentLevelPaths.push(next.path);
}

// Ask Sourcebot for a tree spanning all requested paths at this level.
const treeResponse = await listTree({
repoName: repo,
revisionName: ref,
paths: currentLevelPaths.filter(Boolean),
});
const treeNodeIndex = buildTreeNodeIndex(treeResponse.tree);

for (const currentPath of currentLevelPaths) {
const currentNode = currentPath === '' ? treeResponse.tree : treeNodeIndex.get(currentPath);
if (!currentNode || currentNode.type !== 'tree') {
// Skip paths that are missing from the response or resolve to a
// file node. We only iterate children of directories.
continue;
}

for (const child of currentNode.children) {
if (child.type !== 'tree' && child.type !== 'blob') {
// Skip non-standard git object types (e.g. unexpected entries)
// since this tool only exposes directories and files.
continue;
}

const childPath = joinTreePath(currentPath, child.name);
const childDepth = currentDepth + 1;

// Queue child directories for the next depth level only if
// they are within the requested depth bound.
if (child.type === 'tree' && childDepth < normalizedDepth && !queuedPaths.has(childPath)) {
queue.push({ path: childPath, depth: childDepth });
queuedPaths.add(childPath);
}

if ((child.type === 'blob' && !includeFiles) || (child.type === 'tree' && !includeDirectories)) {
// Skip entries filtered out by caller preferences
// (`includeFiles` / `includeDirectories`).
continue;
}

const key = `${child.type}:${childPath}`;
if (seenEntries.has(key)) {
// Skip duplicates when multiple requested paths overlap and
// surface the same child entry.
continue;
}
seenEntries.add(key);

// Stop collecting once the entry budget is exhausted.
if (entries.length >= normalizedMaxEntries) {
truncated = true;
break;
}

entries.push({
type: child.type,
path: childPath,
name: child.name,
parentPath: currentPath,
depth: childDepth,
});
}

if (truncated) {
break;
}
}
}

const sortedEntries = sortTreeEntries(entries);

return {
content: [{
type: "text",
text: JSON.stringify({
repo,
ref,
path: normalizedPath,
entries: sortedEntries,
totalReturned: sortedEntries.length,
truncated,
}),
}]
};
}
);

server.tool(
"list_language_models",
dedent`Lists the available language models configured on the Sourcebot instance. Use this to discover which models can be specified when calling ask_codebase.`,
Expand Down
88 changes: 88 additions & 0 deletions packages/mcp/src/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,94 @@ export const fileSourceResponseSchema = z.object({
externalWebUrl: z.string().optional(),
});

type TreeNode = {
type: string;
path: string;
name: string;
children: TreeNode[];
};

const treeNodeSchema: z.ZodType<TreeNode> = z.lazy(() => z.object({
type: z.string(),
path: z.string(),
name: z.string(),
children: z.array(treeNodeSchema),
}));

export const listTreeApiRequestSchema = z.object({
repoName: z.string(),
revisionName: z.string(),
paths: z.array(z.string()),
});

export const listTreeApiResponseSchema = z.object({
tree: treeNodeSchema,
});

export const DEFAULT_TREE_DEPTH = 1;
export const MAX_TREE_DEPTH = 10;
export const DEFAULT_MAX_TREE_ENTRIES = 1000;
export const MAX_MAX_TREE_ENTRIES = 10000;

export const listTreeRequestSchema = z.object({
repo: z
.string()
.describe("The name of the repository to list files from."),
path: z
.string()
.describe("Directory path (relative to repo root). If omitted, the repo root is used.")
.optional()
.default(''),
ref: z
.string()
.describe("Commit SHA, branch or tag name to list files from. If not provided, uses the default branch.")
.optional()
.default('HEAD'),
depth: z
.number()
.int()
.positive()
.max(MAX_TREE_DEPTH)
.describe(`How many directory levels to traverse below \`path\` (min 1, max ${MAX_TREE_DEPTH}, default ${DEFAULT_TREE_DEPTH}).`)
.optional()
.default(DEFAULT_TREE_DEPTH),
includeFiles: z
.boolean()
.describe("Whether to include files in the output (default: true).")
.optional()
.default(true),
includeDirectories: z
.boolean()
.describe("Whether to include directories in the output (default: true).")
.optional()
.default(true),
maxEntries: z
.number()
.int()
.positive()
.max(MAX_MAX_TREE_ENTRIES)
.describe(`Maximum number of entries to return (min 1, max ${MAX_MAX_TREE_ENTRIES}, default ${DEFAULT_MAX_TREE_ENTRIES}).`)
.optional()
.default(DEFAULT_MAX_TREE_ENTRIES),
});

export const listTreeEntrySchema = z.object({
type: z.enum(['tree', 'blob']),
path: z.string(),
name: z.string(),
parentPath: z.string(),
depth: z.number().int().positive(),
});

export const listTreeResponseSchema = z.object({
repo: z.string(),
ref: z.string(),
path: z.string(),
entries: z.array(listTreeEntrySchema),
totalReturned: z.number().int().nonnegative(),
truncated: z.boolean(),
});

export const serviceErrorSchema = z.object({
statusCode: z.number(),
errorCode: z.string(),
Expand Down
13 changes: 13 additions & 0 deletions packages/mcp/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ import {
askCodebaseResponseSchema,
languageModelInfoSchema,
listLanguageModelsResponseSchema,
listTreeApiRequestSchema,
listTreeApiResponseSchema,
listTreeRequestSchema,
listTreeEntrySchema,
listTreeResponseSchema,
} from "./schemas.js";
import { z } from "zod";

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

export type LanguageModelInfo = z.infer<typeof languageModelInfoSchema>;
export type ListLanguageModelsResponse = z.infer<typeof listLanguageModelsResponseSchema>;

export type ListTreeApiRequest = z.infer<typeof listTreeApiRequestSchema>;
export type ListTreeApiResponse = z.infer<typeof listTreeApiResponseSchema>;
export type ListTreeApiNode = ListTreeApiResponse["tree"];

export type ListTreeRequest = z.input<typeof listTreeRequestSchema>;
export type ListTreeEntry = z.infer<typeof listTreeEntrySchema>;
export type ListTreeResponse = z.infer<typeof listTreeResponseSchema>;
Loading
Loading