Skip to content

Commit fc404b7

Browse files
feat: add temporal query parameters to MCP tools (#625)
* feat: add temporal filtering to search and repository APIs Signed-off-by: Wayne Sun <gsun@redhat.com> Co-authored-by: Brendan Kellam <bshizzle1234@gmail.com>
1 parent bbabfe4 commit fc404b7

File tree

20 files changed

+1493
-125
lines changed

20 files changed

+1493
-125
lines changed

CHANGELOG.md

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

1010
### Added
1111
- Added ask sidebar to homepage. [#721](https://github.com/sourcebot-dev/sourcebot/pull/721)
12+
- Added endpoint for searching commit history for a git repository. [#625](https://github.com/sourcebot-dev/sourcebot/pull/625)
1213

1314
## [4.10.17] - 2026-01-23
1415

packages/backend/src/repoIndexManager.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import * as Sentry from '@sentry/node';
22
import { PrismaClient, Repo, RepoIndexingJobStatus, RepoIndexingJobType } from "@sourcebot/db";
33
import { createLogger, Logger } from "@sourcebot/shared";
4-
import { env, RepoIndexingJobMetadata, repoIndexingJobMetadataSchema, RepoMetadata, repoMetadataSchema } from '@sourcebot/shared';
4+
import { env, RepoIndexingJobMetadata, repoIndexingJobMetadataSchema, RepoMetadata, repoMetadataSchema, getRepoPath } from '@sourcebot/shared';
55
import { existsSync } from 'fs';
66
import { readdir, rm } from 'fs/promises';
77
import { Job, Queue, ReservedJob, Worker } from "groupmq";
@@ -12,7 +12,7 @@ import { cloneRepository, fetchRepository, getBranches, getCommitHashForRefName,
1212
import { captureEvent } from './posthog.js';
1313
import { PromClient } from './promClient.js';
1414
import { RepoWithConnections, Settings } from "./types.js";
15-
import { getAuthCredentialsForRepo, getRepoPath, getShardPrefix, groupmqLifecycleExceptionWrapper, measure, setIntervalAsync } from './utils.js';
15+
import { getAuthCredentialsForRepo, getShardPrefix, groupmqLifecycleExceptionWrapper, measure, setIntervalAsync } from './utils.js';
1616
import { indexGitRepository } from './zoekt.js';
1717

1818
const LOG_TAG = 'repo-index-manager';

packages/backend/src/utils.ts

Lines changed: 0 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -53,25 +53,6 @@ export const arraysEqualShallow = <T>(a?: readonly T[], b?: readonly T[]) => {
5353
return true;
5454
}
5555

56-
// @note: this function is duplicated in `packages/web/src/features/fileTree/actions.ts`.
57-
// @todo: we should move this to a shared package.
58-
export const getRepoPath = (repo: Repo): { path: string, isReadOnly: boolean } => {
59-
// If we are dealing with a local repository, then use that as the path.
60-
// Mark as read-only since we aren't guaranteed to have write access to the local filesystem.
61-
const cloneUrl = new URL(repo.cloneUrl);
62-
if (repo.external_codeHostType === 'genericGitHost' && cloneUrl.protocol === 'file:') {
63-
return {
64-
path: cloneUrl.pathname,
65-
isReadOnly: true,
66-
}
67-
}
68-
69-
return {
70-
path: path.join(REPOS_CACHE_DIR, repo.id.toString()),
71-
isReadOnly: false,
72-
}
73-
}
74-
7556
export const getShardPrefix = (orgId: number, repoId: number) => {
7657
return `${orgId}_${repoId}`;
7758
}

packages/backend/src/zoekt.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { Repo } from "@sourcebot/db";
2-
import { createLogger, env } from "@sourcebot/shared";
2+
import { createLogger, env, getRepoPath } from "@sourcebot/shared";
33
import { exec } from "child_process";
44
import { INDEX_CACHE_DIR } from "./constants.js";
55
import { Settings } from "./types.js";
6-
import { getRepoPath, getShardPrefix } from "./utils.js";
6+
import { getShardPrefix } from "./utils.js";
77

88
const logger = createLogger('zoekt');
99

packages/mcp/CHANGELOG.md

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

88
## [Unreleased]
99

10+
### Added
11+
- Added `search_commits` tool to search a repos commit history. [#625](https://github.com/sourcebot-dev/sourcebot/pull/625)
12+
- Added `gitRevision` parameter to the `search_code` tool to allow for searching on different branches. [#625](https://github.com/sourcebot-dev/sourcebot/pull/625)
13+
1014
## [1.0.12] - 2026-01-13
1115

1216
### Fixed

packages/mcp/README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,24 @@ Fetches the source code for a given file.
208208
| `repoId` | yes | The Sourcebot repository ID. |
209209
</details>
210210

211+
### search_commits
212+
213+
Searches for commits in a specific repository based on actual commit time.
214+
215+
<details>
216+
<summary>Parameters</summary>
217+
218+
| Name | Required | Description |
219+
|:-----------|:---------|:-----------------------------------------------------------------------------------------------|
220+
| `repoId` | yes | Repository identifier: either numeric database ID (e.g., 123) or full repository name (e.g., "github.com/owner/repo") as returned by `list_repos`. |
221+
| `query` | no | Search query to filter commits by message (case-insensitive). |
222+
| `since` | no | Show commits after this date (by commit time). Supports ISO 8601 or relative formats. |
223+
| `until` | no | Show commits before this date (by commit time). Supports ISO 8601 or relative formats. |
224+
| `author` | no | Filter by author name or email (supports partial matches). |
225+
| `maxCount` | no | Maximum number of commits to return (default: 50). |
226+
227+
</details>
228+
211229

212230
## Supported Code Hosts
213231
Sourcebot supports the following code hosts:

packages/mcp/src/client.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { env } from './env.js';
2-
import { listRepositoriesResponseSchema, searchResponseSchema, fileSourceResponseSchema } from './schemas.js';
3-
import { FileSourceRequest, FileSourceResponse, ListRepositoriesResponse, SearchRequest, SearchResponse, ServiceError } from './types.js';
2+
import { listRepositoriesResponseSchema, searchResponseSchema, fileSourceResponseSchema, searchCommitsResponseSchema } from './schemas.js';
3+
import { FileSourceRequest, FileSourceResponse, ListRepositoriesResponse, SearchRequest, SearchResponse, ServiceError, SearchCommitsRequest, SearchCommitsResponse } from './types.js';
44
import { isServiceError } from './utils.js';
55

66
export const search = async (request: SearchRequest): Promise<SearchResponse | ServiceError> => {
@@ -52,3 +52,21 @@ export const getFileSource = async (request: FileSourceRequest): Promise<FileSou
5252

5353
return fileSourceResponseSchema.parse(result);
5454
}
55+
56+
export const searchCommits = async (request: SearchCommitsRequest): Promise<SearchCommitsResponse | ServiceError> => {
57+
const result = await fetch(`${env.SOURCEBOT_HOST}/api/commits`, {
58+
method: 'POST',
59+
headers: {
60+
'Content-Type': 'application/json',
61+
'X-Org-Domain': '~',
62+
...(env.SOURCEBOT_API_KEY ? { 'X-Sourcebot-Api-Key': env.SOURCEBOT_API_KEY } : {})
63+
},
64+
body: JSON.stringify(request)
65+
}).then(response => response.json());
66+
67+
if (isServiceError(result)) {
68+
return result;
69+
}
70+
71+
return searchCommitsResponseSchema.parse(result);
72+
}

packages/mcp/src/index.ts

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
55
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
66
import escapeStringRegexp from 'escape-string-regexp';
77
import { z } from 'zod';
8-
import { listRepos, search, getFileSource } from './client.js';
8+
import { getFileSource, listRepos, search, searchCommits } from './client.js';
99
import { env, numberSchema } from './env.js';
1010
import { listReposRequestSchema } from './schemas.js';
1111
import { TextContent } from './types.js';
@@ -49,6 +49,10 @@ server.tool(
4949
.boolean()
5050
.describe(`Whether to include the code snippets in the response (default: false). If false, only the file's URL, repository, and language will be returned. Set to false to get a more concise response.`)
5151
.optional(),
52+
gitRevision: z
53+
.string()
54+
.describe(`The git revision to search in (e.g., 'main', 'HEAD', 'v1.0.0', 'a1b2c3d'). If not provided, defaults to the default branch (usually 'main' or 'master').`)
55+
.optional(),
5256
maxTokens: numberSchema
5357
.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.`)
5458
.transform((val) => (val < env.DEFAULT_MINIMUM_TOKENS ? env.DEFAULT_MINIMUM_TOKENS : val))
@@ -61,6 +65,7 @@ server.tool(
6165
maxTokens = env.DEFAULT_MINIMUM_TOKENS,
6266
includeCodeSnippets = false,
6367
caseSensitive = false,
68+
gitRevision,
6469
}) => {
6570
if (repoIds.length > 0) {
6671
query += ` ( repo:${repoIds.map(id => escapeStringRegexp(id)).join(' or repo:')} )`;
@@ -70,13 +75,17 @@ server.tool(
7075
query += ` ( lang:${languages.join(' or lang:')} )`;
7176
}
7277

78+
if (gitRevision) {
79+
query += ` ( rev:${gitRevision} )`;
80+
}
81+
7382
const response = await search({
7483
query,
7584
matches: env.DEFAULT_MATCHES,
7685
contextLines: env.DEFAULT_CONTEXT_LINES,
7786
isRegexEnabled: true,
7887
isCaseSensitivityEnabled: caseSensitive,
79-
source: 'mcp'
88+
source: 'mcp',
8089
});
8190

8291
if (isServiceError(response)) {
@@ -162,9 +171,43 @@ server.tool(
162171
}
163172
);
164173

174+
server.tool(
175+
"search_commits",
176+
`Searches for commits in a specific repository based on actual commit time. If you receive an error that indicates that you're not authenticated, please inform the user to set the SOURCEBOT_API_KEY environment variable.`,
177+
{
178+
repoId: z.string().describe(`The repository to search commits in. This is the Sourcebot compatible repository ID as returned by 'list_repos'.`),
179+
query: z.string().describe(`Search query to filter commits by message content (case-insensitive).`).optional(),
180+
since: z.string().describe(`Show commits more recent than this date. Filters by actual commit time. Supports ISO 8601 (e.g., '2024-01-01') or relative formats (e.g., '30 days ago', 'last week').`).optional(),
181+
until: z.string().describe(`Show commits older than this date. Filters by actual commit time. Supports ISO 8601 (e.g., '2024-12-31') or relative formats (e.g., 'yesterday').`).optional(),
182+
author: z.string().describe(`Filter commits by author name or email (supports partial matches and patterns).`).optional(),
183+
maxCount: z.number().int().positive().default(50).describe(`Maximum number of commits to return (default: 50).`),
184+
},
185+
async ({ repoId, query, since, until, author, maxCount }) => {
186+
const result = await searchCommits({
187+
repository: repoId,
188+
query,
189+
since,
190+
until,
191+
author,
192+
maxCount,
193+
});
194+
195+
if (isServiceError(result)) {
196+
return {
197+
content: [{ type: "text", text: `Error: ${result.message}` }],
198+
isError: true,
199+
};
200+
}
201+
202+
return {
203+
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
204+
};
205+
}
206+
);
207+
165208
server.tool(
166209
"list_repos",
167-
"Lists repositories in the organization with optional filtering and pagination. If you receive an error that indicates that you're not authenticated, please inform the user to set the SOURCEBOT_API_KEY environment variable.",
210+
`Lists repositories in the organization with optional filtering and pagination. If you receive an error that indicates that you're not authenticated, please inform the user to set the SOURCEBOT_API_KEY environment variable.`,
168211
listReposRequestSchema.shape,
169212
async ({ query, pageNumber = 1, limit = 50 }: {
170213
query?: string;

packages/mcp/src/schemas.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// @NOTE : Please keep this file in sync with @sourcebot/web/src/features/search/schemas.ts
1+
// @NOTE : Please keep this file in sync with @sourcebot/web/src/features/search/types.ts
22
// At some point, we should move these to a shared package...
33
import { z } from "zod";
44

@@ -193,3 +193,22 @@ export const serviceErrorSchema = z.object({
193193
errorCode: z.string(),
194194
message: z.string(),
195195
});
196+
197+
export const searchCommitsRequestSchema = z.object({
198+
repository: z.string(),
199+
query: z.string().optional(),
200+
since: z.string().optional(),
201+
until: z.string().optional(),
202+
author: z.string().optional(),
203+
maxCount: z.number().int().positive().max(500).optional(),
204+
});
205+
206+
export const searchCommitsResponseSchema = z.array(z.object({
207+
hash: z.string(),
208+
date: z.string(),
209+
message: z.string(),
210+
refs: z.string(),
211+
body: z.string(),
212+
author_name: z.string(),
213+
author_email: z.string(),
214+
}));

packages/mcp/src/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import {
1010
fileSourceRequestSchema,
1111
symbolSchema,
1212
serviceErrorSchema,
13+
searchCommitsRequestSchema,
14+
searchCommitsResponseSchema,
1315
} from "./schemas.js";
1416
import { z } from "zod";
1517

@@ -29,3 +31,6 @@ export type FileSourceResponse = z.infer<typeof fileSourceResponseSchema>;
2931
export type TextContent = { type: "text", text: string };
3032

3133
export type ServiceError = z.infer<typeof serviceErrorSchema>;
34+
35+
export type SearchCommitsRequest = z.infer<typeof searchCommitsRequestSchema>;
36+
export type SearchCommitsResponse = z.infer<typeof searchCommitsResponseSchema>;

0 commit comments

Comments
 (0)