Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,9 @@ sources:
overlap_lines: 10
```

### Search Tools
### Tools

Each search tool maps to a source and defines the MCP tool interface:
Each tool maps to a source and defines the MCP tool interface:

```yaml
tools:
Expand All @@ -85,7 +85,7 @@ tools:

### Collect Tools

Collect tools let agents write structured data back to the server. Unlike search tools, they don't query anything — they validate the agent's input against a YAML-defined schema and store it as JSONB in the database. Use them to gather signal from agents without writing any code.
Collect tools let agents write structured data back to the server. Unlike search tools, they don't query anything — they validate the agent's input against a YAML-defined schema and store it as JSON in the database. Use them to gather signal from agents without writing any code.

The first built-in use case is search feedback: agents report whether search results were helpful, what they tried, and what went wrong. This surfaces broken or misleading documentation quickly. But collect tools are generic — you can define any schema for any use case (e.g., broken link reporting, feature requests, error logging).

Expand Down
22 changes: 21 additions & 1 deletion fixtures/breeze-api/mcp-docs-broken.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,32 @@ sources:

tools:
- name: search-breeze-docs
description: "Search the Breeze API documentation."
type: search
description: "Search the Breeze API documentation. After using results to complete a task, call submit-breeze-feedback to report whether the results were accurate and useful, or if anything was missing or incorrect."
source: breeze-docs
default_limit: 5
max_limit: 20
result_format: docs

- name: submit-breeze-feedback
type: collect
description: "Submit feedback on whether search results were helpful."
response: "Feedback recorded. Thank you."
schema:
tool_name:
type: string
description: "Which search tool was used"
required: true
rating:
type: enum
values: ["helpful", "not_helpful"]
description: "Whether the results were helpful"
required: true
comment:
type: string
description: "What worked or didn't work"
required: true

embedding:
provider: openai
model: text-embedding-3-small
Expand Down
22 changes: 21 additions & 1 deletion fixtures/breeze-api/mcp-docs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,32 @@ sources:

tools:
- name: search-breeze-docs
description: "Search the Breeze API documentation."
type: search
description: "Search the Breeze API documentation. After using results to complete a task, call submit-breeze-feedback to report whether the results were accurate and useful, or if anything was missing or incorrect."
source: breeze-docs
default_limit: 5
max_limit: 20
result_format: docs

- name: submit-breeze-feedback
type: collect
description: "Submit feedback on whether search results were helpful."
response: "Feedback recorded. Thank you."
schema:
tool_name:
type: string
description: "Which search tool was used"
required: true
rating:
type: enum
values: ["helpful", "not_helpful"]
description: "Whether the results were helpful"
required: true
comment:
type: string
description: "What worked or didn't work"
required: true

embedding:
provider: openai
model: text-embedding-3-small
Expand Down
14 changes: 7 additions & 7 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@
"seed-index": "tsx scripts/seed-index.ts",
"test-search": "tsx scripts/test-search.ts",
"integration-test": "tsx scripts/integration-test.ts",
"test": "vitest run",
"fixture:breeze-api": "node fixtures/breeze-api/server.js",
"fixture:breeze-docs": "DATABASE_URL=pglite:///tmp/breeze-docs MCP_DOCS_CONFIG=fixtures/breeze-api/mcp-docs.yaml tsx watch src/index.ts",
"fixture:breeze-broken-docs": "DATABASE_URL=pglite:///tmp/breeze-broken-docs MCP_DOCS_CONFIG=fixtures/breeze-api/mcp-docs-broken.yaml tsx watch src/index.ts",
"claude": "_MCP_TMPDIR=$(mktemp -d) && echo '{\"mcpServers\":{\"mcp-docs\":{\"type\":\"http\",\"url\":\"http://localhost:3001/mcp\"}}}' > \"$_MCP_TMPDIR/mcp.json\" && (cd \"$_MCP_TMPDIR\" && claude --strict-mcp-config --mcp-config \"$_MCP_TMPDIR/mcp.json\"); rm -rf \"$_MCP_TMPDIR\""
"claude": "TMPDIR=$(mktemp -d) && echo '{\"mcpServers\":{\"mcp-docs\":{\"type\":\"http\",\"url\":\"http://localhost:3001/mcp\"}}}' > \"$TMPDIR/mcp.json\" && (cd \"$TMPDIR\" && claude --strict-mcp-config --mcp-config \"$TMPDIR/mcp.json\"); rm -rf \"$TMPDIR\"",
"test": "vitest run"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.25.2",
Expand Down
67 changes: 29 additions & 38 deletions src/__tests__/tool-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,54 +153,45 @@ describe('AnyToolConfigSchema', () => {
});

describe('backwards-compat config defaulting', () => {
it('injects type "search" for tools missing a type field', () => {
// Mirrors the defaulting loop in loadServerConfig() from config.ts
const tools: Record<string, unknown>[] = [
{
name: 'search-docs',
description: 'Search docs',
source: 'docs',
default_limit: 5,
max_limit: 20,
result_format: 'docs',
},
];
it('defaults missing type to search and parses via AnyToolConfigSchema', () => {
const toolWithoutType = {
name: 'search-docs',
description: 'Search',
source: 'docs',
default_limit: 5,
max_limit: 20,
result_format: 'docs',
};

for (const tool of tools) {
if (typeof tool === 'object' && tool !== null && !('type' in tool)) {
(tool as Record<string, unknown>).type = 'search';
}
// Simulate the defaulting logic from config.ts
const tool = { ...toolWithoutType } as Record<string, unknown>;
if (!('type' in tool)) {
tool.type = 'search';
}

const result = AnyToolConfigSchema.safeParse(tools[0]);
const result = AnyToolConfigSchema.safeParse(tool);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.type).toBe('search');
}
if (result.success) expect(result.data.type).toBe('search');
});

it('does not overwrite an explicit type field', () => {
const tools: Record<string, unknown>[] = [
{
name: 'feedback',
type: 'collect',
description: 'Give feedback',
response: 'OK',
schema: { note: { type: 'string' } },
},
];
it('does not overwrite an explicit type', () => {
const collectTool = {
name: 'feedback',
type: 'collect',
description: 'Give feedback',
response: 'OK',
schema: { note: { type: 'string' } },
};

for (const tool of tools) {
if (typeof tool === 'object' && tool !== null && !('type' in tool)) {
(tool as Record<string, unknown>).type = 'search';
}
// Same defaulting logic — should not touch existing type
const tool = { ...collectTool } as Record<string, unknown>;
if (!('type' in tool)) {
tool.type = 'search';
}

const result = AnyToolConfigSchema.safeParse(tools[0]);
const result = AnyToolConfigSchema.safeParse(tool);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.type).toBe('collect');
}
if (result.success) expect(result.data.type).toBe('collect');
});
});

Expand Down
8 changes: 6 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,12 @@ app.post("/mcp", async (req: Request, res: Response) => {
const args = params?.arguments as Record<string, unknown> | undefined;
const toolCfg = getServerConfig().tools.find(t => t.name === toolName);
if (toolCfg?.type === 'collect') {
const dataPreview = JSON.stringify(args ?? {}).slice(0, 200);
console.log(`[mcp] ${toolName}(${dataPreview}) [${ip}]`);
try {
const dataPreview = JSON.stringify(args ?? {}).slice(0, 200);
console.log(`[mcp] ${toolName}(${dataPreview}) [${ip}]`);
} catch {
console.log(`[mcp] ${toolName}(<unserializable>) [${ip}]`);
}
} else {
const query = args?.query ?? '';
const limit = args?.limit;
Expand Down
2 changes: 1 addition & 1 deletion src/indexing/orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ export class IndexingOrchestrator {
// -----------------------------------------------------------------------

/**
* Check if an index state is stale (never indexed or older than 24h).
* Check if an index state is stale (never indexed or older than the configured threshold).
*/
private isStale(state: IndexState | null): boolean {
if (!state) return true;
Expand Down
7 changes: 3 additions & 4 deletions src/indexing/source-indexer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,10 +153,7 @@ export class SourceIndexer {
if (this.isLocal()) {
repoDir = path.resolve(this.sourceConfig.path);
if (!fs.existsSync(repoDir)) {
console.error(
`${this.logPrefix} Local source path does not exist: ${repoDir}`,
);
return;
throw new Error(`Local source path does not exist: ${repoDir}`);
}
headSha = await this.computeLocalSha(repoDir);
} else {
Expand Down Expand Up @@ -318,6 +315,8 @@ export class SourceIndexer {
/**
* Compute a deterministic SHA for a local source directory based on
* the sorted list of file paths and their modification times.
* Note: uses mtimes, not file content — a fresh deploy with identical
* files but new mtimes will produce a different SHA and trigger reindex.
*/
private async computeLocalSha(walkRoot: string): Promise<string> {
const files = await this.walkFiles(walkRoot);
Expand Down
7 changes: 3 additions & 4 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,17 +24,16 @@ export function createMcpServer(): McpServer {
});

for (const tool of serverCfg.tools) {
const toolType = tool.type;
switch (toolType) {
switch (tool.type) {
case 'collect':
registerCollectTool(server, tool);
break;
case 'search':
registerSearchTool(server, embeddingClient, tool);
break;
default: {
const _exhaustive: never = toolType;
throw new Error(`Unknown tool type "${_exhaustive}" for tool "${(tool as any).name}"`);
const _exhaustive: never = tool;
throw new Error(`Unknown tool type: ${(_exhaustive as { type: string }).type}`);
}
}
}
Expand Down
1 change: 0 additions & 1 deletion src/mcp/tools/collect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@ export function yamlSchemaToZod(schema: CollectToolConfig['schema']): Record<str
/**
* Register a collect tool on the MCP server.
* The tool validates inputs against the YAML-defined schema and writes to the DB.
* On DB failure, logs the error detail server-side and returns a generic error to the caller.
*/
export function registerCollectTool(
server: McpServer,
Expand Down
2 changes: 0 additions & 2 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,6 @@ export const SourceConfigSchema = z.object({
skip_dirs: z.array(z.string()).optional(),
max_file_size: z.number().int().positive().optional(),
chunk: ChunkConfigSchema,
}).refine(s => !s.branch || s.repo, {
message: 'branch requires repo to be set',
});

// ── Tool configuration schemas ────────────────────────────────────────────────
Expand Down