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 substring patterns (e.g. "db/" or ["db/", "src/"])',
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 The schema description says "substring patterns" but the underlying backends (prepare.ts, filters.ts, keyword.ts) also support glob syntax (*, **, ?, […]). A caller reading only the schema would not know to try "src/**/*.ts" — and those glob patterns do work end-to-end through applyFilters/globMatch. Mentioning glob support here keeps the schema accurate and prevents confusion.

Suggested change
description:
'Restrict results to files matching one or more substring patterns (e.g. "db/" or ["db/", "src/"])',
description:
'Restrict results to files matching one or more glob or substring patterns (e.g. "db/", "src/**/*.ts", or ["db/", "src/"])',

Fix in Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch — applied the suggestion verbatim in c35a237. Confirmed by reading src/domain/search/search/filters.ts that applyFilters does branch on /[*?[\]]/.test(p) and routes glob patterns through globMatch (which handles *, **, ?, and char classes), so the schema description now accurately reflects what the backends support.

},
...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