Skip to content
5 changes: 5 additions & 0 deletions src/mcp/tool-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
2 changes: 2 additions & 0 deletions src/mcp/tools/semantic-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown> {
Expand All @@ -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') {
Expand Down
72 changes: 72 additions & 0 deletions tests/unit/mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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/'] }),
);
});
});
Loading