diff --git a/src/mcp/tool-registry.ts b/src/mcp/tool-registry.ts index d5d9dac7f..84d6aaff4 100644 --- a/src/mcp/tool-registry.ts +++ b/src/mcp/tool-registry.ts @@ -322,6 +322,11 @@ const BASE_TOOLS: ToolSchema[] = [ description: 'Search mode: hybrid (BM25 + semantic, default), semantic (embeddings only), keyword (BM25 only)', }, + file_pattern: { + oneOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }], + description: + 'Restrict results to files matching one or more glob or substring patterns (e.g. "db/", "src/**/*.ts", or ["db/", "src/"])', + }, ...PAGINATION_PROPS, }, required: ['query'], diff --git a/src/mcp/tools/semantic-search.ts b/src/mcp/tools/semantic-search.ts index 6c6a8afc6..be2a79b6c 100644 --- a/src/mcp/tools/semantic-search.ts +++ b/src/mcp/tools/semantic-search.ts @@ -9,6 +9,7 @@ interface SemanticSearchArgs { limit?: number; offset?: number; min_score?: number; + file_pattern?: string | string[]; } export async function handler(args: SemanticSearchArgs, ctx: McpToolContext): Promise { @@ -17,6 +18,7 @@ export async function handler(args: SemanticSearchArgs, ctx: McpToolContext): Pr limit: Math.min(args.limit ?? MCP_DEFAULTS.semantic_search ?? 100, ctx.MCP_MAX_LIMIT), offset: effectiveOffset(args), minScore: args.min_score, + filePattern: args.file_pattern, }; if (mode === 'keyword') { diff --git a/tests/unit/mcp.test.ts b/tests/unit/mcp.test.ts index d5990b80c..c22dfc31d 100644 --- a/tests/unit/mcp.test.ts +++ b/tests/unit/mcp.test.ts @@ -161,6 +161,7 @@ describe('TOOLS', () => { expect(ss.inputSchema.required).toContain('query'); expect(ss.inputSchema.properties).toHaveProperty('limit'); expect(ss.inputSchema.properties).toHaveProperty('min_score'); + expect(ss.inputSchema.properties).toHaveProperty('file_pattern'); }); it('export_graph requires format parameter with enum', () => { @@ -1233,4 +1234,75 @@ describe('startMCPServer handler dispatch', () => { kind: 'function', }); }); + + it('dispatches semantic_search and forwards file_pattern as filePattern', async () => { + const handlers = {}; + + vi.doMock('@modelcontextprotocol/sdk/server/index.js', () => ({ + Server: class MockServer { + setRequestHandler(name, handler) { + handlers[name] = handler; + } + async connect() {} + }, + })); + vi.doMock('@modelcontextprotocol/sdk/server/stdio.js', () => ({ + StdioServerTransport: class MockTransport {}, + })); + vi.doMock('@modelcontextprotocol/sdk/types.js', () => ({ + ListToolsRequestSchema: 'tools/list', + CallToolRequestSchema: 'tools/call', + })); + + const hybridSearchMock = vi.fn(async () => ({ results: [] })); + const ftsSearchMock = vi.fn(() => ({ results: [] })); + const searchDataMock = vi.fn(async () => ({ results: [] })); + vi.doMock('../../src/domain/search/index.js', () => ({ + hybridSearchData: hybridSearchMock, + ftsSearchData: ftsSearchMock, + searchData: searchDataMock, + })); + + const { startMCPServer } = await import('../../src/mcp/index.js'); + await startMCPServer('/tmp/test.db'); + + // hybrid (default): forwards filePattern as array + await handlers['tools/call']({ + params: { + name: 'semantic_search', + arguments: { query: 'GUC variable', file_pattern: ['db/'], limit: 5 }, + }, + }); + expect(hybridSearchMock).toHaveBeenCalledWith( + 'GUC variable', + '/tmp/test.db', + expect.objectContaining({ filePattern: ['db/'], limit: 5 }), + ); + + // semantic mode: forwards filePattern as string + await handlers['tools/call']({ + params: { + name: 'semantic_search', + arguments: { query: 'q', mode: 'semantic', file_pattern: 'src/mcp/' }, + }, + }); + expect(searchDataMock).toHaveBeenCalledWith( + 'q', + '/tmp/test.db', + expect.objectContaining({ filePattern: 'src/mcp/' }), + ); + + // keyword mode: forwards filePattern + await handlers['tools/call']({ + params: { + name: 'semantic_search', + arguments: { query: 'q', mode: 'keyword', file_pattern: ['tests/'] }, + }, + }); + expect(ftsSearchMock).toHaveBeenCalledWith( + 'q', + '/tmp/test.db', + expect.objectContaining({ filePattern: ['tests/'] }), + ); + }); });