Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 365
feat: add temporal query parameters to MCP tools#625
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
ba4c8a5a220d4fb5d0efd4be7cf473889f1b4ce3144eaa36cFile filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,6 @@ | ||
| import { env } from './env.js'; | ||
| import { listRepositoriesResponseSchema, searchResponseSchema, fileSourceResponseSchema } from './schemas.js'; | ||
| import { FileSourceRequest, FileSourceResponse, ListRepositoriesResponse, SearchRequest, SearchResponse, ServiceError } from './types.js'; | ||
| import { listRepositoriesResponseSchema, searchResponseSchema, fileSourceResponseSchema, searchCommitsResponseSchema } from './schemas.js'; | ||
| import { FileSourceRequest, FileSourceResponse, ListRepositoriesResponse, SearchRequest, SearchResponse, ServiceError, SearchCommitsRequest, SearchCommitsResponse } from './types.js'; | ||
| import { isServiceError } from './utils.js'; | ||
| export const search = async (request: SearchRequest): Promise<SearchResponse | ServiceError> => { | ||
| @@ -52,3 +52,21 @@ export const getFileSource = async (request: FileSourceRequest): Promise<FileSou | ||
| return fileSourceResponseSchema.parse(result); | ||
| } | ||
| export const searchCommits = async (request: SearchCommitsRequest): Promise<SearchCommitsResponse | ServiceError> => { | ||
| const result = await fetch(`${env.SOURCEBOT_HOST}/api/commits`, { | ||
| method: 'POST', | ||
| headers: { | ||
| 'Content-Type': 'application/json', | ||
| 'X-Org-Domain': '~', | ||
| ...(env.SOURCEBOT_API_KEY ? { 'X-Sourcebot-Api-Key': env.SOURCEBOT_API_KEY } : {}) | ||
| }, | ||
| body: JSON.stringify(request) | ||
| }).then(response => response.json()); | ||
brendan-kellam marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| if (isServiceError(result)) { | ||
| return result; | ||
| } | ||
| return searchCommitsResponseSchema.parse(result); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -5,7 +5,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; | ||
| import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; | ||
| import escapeStringRegexp from 'escape-string-regexp'; | ||
| import { z } from 'zod'; | ||
| import { listRepos, search, getFileSource } from './client.js'; | ||
| import { getFileSource, listRepos, search, searchCommits } from './client.js'; | ||
| import { env, numberSchema } from './env.js'; | ||
| import { listReposRequestSchema } from './schemas.js'; | ||
| import { TextContent } from './types.js'; | ||
| @@ -49,6 +49,10 @@ server.tool( | ||
| .boolean() | ||
| .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.`) | ||
| .optional(), | ||
| gitRevision: z | ||
| .string() | ||
| .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').`) | ||
| .optional(), | ||
| maxTokens: numberSchema | ||
| .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.`) | ||
| .transform((val) => (val < env.DEFAULT_MINIMUM_TOKENS ? env.DEFAULT_MINIMUM_TOKENS : val)) | ||
| @@ -61,6 +65,7 @@ server.tool( | ||
| maxTokens = env.DEFAULT_MINIMUM_TOKENS, | ||
| includeCodeSnippets = false, | ||
| caseSensitive = false, | ||
| gitRevision, | ||
| }) => { | ||
| if (repoIds.length > 0) { | ||
| query += ` ( repo:${repoIds.map(id => escapeStringRegexp(id)).join(' or repo:')} )`; | ||
| @@ -70,13 +75,17 @@ server.tool( | ||
| query += ` ( lang:${languages.join(' or lang:')} )`; | ||
| } | ||
| if (gitRevision) { | ||
| query += ` ( rev:${gitRevision} )`; | ||
| } | ||
brendan-kellam marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| const response = await search({ | ||
| query, | ||
| matches: env.DEFAULT_MATCHES, | ||
| contextLines: env.DEFAULT_CONTEXT_LINES, | ||
| isRegexEnabled: true, | ||
| isCaseSensitivityEnabled: caseSensitive, | ||
| source: 'mcp' | ||
| source: 'mcp', | ||
| }); | ||
| if (isServiceError(response)) { | ||
| @@ -162,9 +171,43 @@ server.tool( | ||
| } | ||
| ); | ||
| server.tool( | ||
| "search_commits", | ||
| `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.`, | ||
| { | ||
| repoId: z.string().describe(`The repository to search commits in. This is the Sourcebot compatible repository ID as returned by 'list_repos'.`), | ||
| query: z.string().describe(`Search query to filter commits by message content (case-insensitive).`).optional(), | ||
| 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(), | ||
| 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(), | ||
| author: z.string().describe(`Filter commits by author name or email (supports partial matches and patterns).`).optional(), | ||
| maxCount: z.number().int().positive().default(50).describe(`Maximum number of commits to return (default: 50).`), | ||
| }, | ||
| async ({ repoId, query, since, until, author, maxCount }) => { | ||
| const result = await searchCommits({ | ||
| repository: repoId, | ||
| query, | ||
| since, | ||
| until, | ||
| author, | ||
| maxCount, | ||
| }); | ||
| if (isServiceError(result)) { | ||
| return { | ||
| content: [{ type: "text", text: `Error: ${result.message}` }], | ||
| isError: true, | ||
| }; | ||
| } | ||
| return { | ||
| content: [{ type: "text", text: JSON.stringify(result, null, 2) }], | ||
| }; | ||
| } | ||
| ); | ||
| server.tool( | ||
| "list_repos", | ||
| "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.", | ||
| `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.`, | ||
| listReposRequestSchema.shape, | ||
| async ({ query, pageNumber = 1, limit = 50 }: { | ||
| query?: string; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,9 +1,12 @@ | ||
| import { indexSchema } from "@sourcebot/schemas/v3/index.schema"; | ||
| import { SourcebotConfig } from "@sourcebot/schemas/v3/index.type"; | ||
| import { createEnv } from "@t3-oss/env-core"; | ||
| import { Ajv } from "ajv"; | ||
| import { readFile } from 'fs/promises'; | ||
| import stripJsonComments from "strip-json-comments"; | ||
| import { z } from "zod"; | ||
| import { loadConfig } from "./utils.js"; | ||
| import { tenancyModeSchema } from "./types.js"; | ||
| import { SourcebotConfig } from "@sourcebot/schemas/v3/index.type"; | ||
| import { getTokenFromConfig } from "./crypto.js"; | ||
| import { tenancyModeSchema } from "./types.js"; | ||
| // Booleans are specified as 'true' or 'false' strings. | ||
| const booleanSchema = z.enum(["true", "false"]); | ||
| @@ -13,6 +16,10 @@ const booleanSchema = z.enum(["true", "false"]); | ||
| // @see: https://zod.dev/?id=coercion-for-primitives | ||
| const numberSchema = z.coerce.number(); | ||
| const ajv = new Ajv({ | ||
| validateFormats: false, | ||
| }); | ||
| export const resolveEnvironmentVariableOverridesFromConfig = async (config: SourcebotConfig): Promise<Record<string, string>> => { | ||
| if (!config.environmentOverrides) { | ||
| return {}; | ||
| @@ -45,6 +52,66 @@ export const resolveEnvironmentVariableOverridesFromConfig = async (config: Sour | ||
| return resolved; | ||
| } | ||
| export const isRemotePath = (path: string) => { | ||
| return path.startsWith('https://') || path.startsWith('http://'); | ||
| } | ||
| export const loadConfig = async (configPath?: string): Promise<SourcebotConfig> => { | ||
| if (!configPath) { | ||
| throw new Error('CONFIG_PATH is required but not provided'); | ||
| } | ||
| const configContent = await (async () => { | ||
| if (isRemotePath(configPath)) { | ||
| const response = await fetch(configPath); | ||
| if (!response.ok) { | ||
| throw new Error(`Failed to fetch config file ${configPath}: ${response.statusText}`); | ||
| } | ||
| return response.text(); | ||
| } else { | ||
| // Retry logic for handling race conditions with mounted volumes | ||
| const maxAttempts = 5; | ||
| const retryDelayMs = 2000; | ||
| let lastError: Error | null = null; | ||
| for (let attempt = 1; attempt <= maxAttempts; attempt++) { | ||
| try { | ||
| return await readFile(configPath, { | ||
| encoding: 'utf-8', | ||
| }); | ||
| } catch (error) { | ||
| lastError = error as Error; | ||
| // Only retry on ENOENT errors (file not found) | ||
| if ((error as NodeJS.ErrnoException)?.code !== 'ENOENT') { | ||
| throw error; // Throw immediately for non-ENOENT errors | ||
| } | ||
| // Log warning before retry (except on the last attempt) | ||
| if (attempt < maxAttempts) { | ||
| console.warn(`Config file not found, retrying in 2s... (Attempt ${attempt}/${maxAttempts})`); | ||
| await new Promise(resolve => setTimeout(resolve, retryDelayMs)); | ||
| } | ||
| } | ||
| } | ||
| // If we've exhausted all retries, throw the last ENOENT error | ||
| if (lastError) { | ||
| throw lastError; | ||
| } | ||
| throw new Error('Failed to load config after all retry attempts'); | ||
| } | ||
brendan-kellam marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| })(); | ||
| const config = JSON.parse(stripJsonComments(configContent)) as SourcebotConfig; | ||
| const isValidConfig = ajv.validate(indexSchema, config); | ||
| if (!isValidConfig) { | ||
| throw new Error(`Config file '${configPath}' is invalid: ${ajv.errorsText(ajv.errors)}`); | ||
| } | ||
| return config; | ||
| } | ||
| // Merge process.env with environment variables resolved from config.json | ||
| const runtimeEnv = await (async () => { | ||
| const configPath = process.env.CONFIG_PATH; | ||
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.