From 8cdabe6315cf8fe7fb7b5d06df95599c48226f22 Mon Sep 17 00:00:00 2001 From: Ousama Ben Younes Date: Wed, 1 Apr 2026 06:28:29 +0000 Subject: [PATCH 1/3] fix: declare inputSchema properties for search and timeline MCP tools (#1384 #1413) Both tools had properties:{} which prevents MCP clients from exposing params to the LLM, causing every call to send {} and get a 500 error ("Either query or filters required for search"). - search: declare query, limit, project, type, obs_type, dateStart, dateEnd, offset, orderBy - timeline: declare anchor, query, depth_before, depth_after, project - Add 3 schema regression tests (static source validation) Closes #1384 Closes #1413 Co-Authored-By: Claude --- src/servers/mcp-server.ts | 20 ++++++++- tests/servers/mcp-tool-schemas.test.ts | 56 ++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 2 deletions(-) create mode 100644 tests/servers/mcp-tool-schemas.test.ts diff --git a/src/servers/mcp-server.ts b/src/servers/mcp-server.ts index 70fbf336..4a0a5bfe 100644 --- a/src/servers/mcp-server.ts +++ b/src/servers/mcp-server.ts @@ -188,7 +188,17 @@ NEVER fetch full details without filtering first. 10x token savings.`, description: 'Step 1: Search memory. Returns index with IDs. Params: query, limit, project, type, obs_type, dateStart, dateEnd, offset, orderBy', inputSchema: { type: 'object', - properties: {}, + properties: { + query: { type: 'string', description: 'Search query' }, + limit: { type: 'number', description: 'Max results (default 20)' }, + project: { type: 'string', description: 'Filter by project name' }, + type: { type: 'string', description: 'Filter by observation type' }, + obs_type: { type: 'string', description: 'Filter by obs_type field' }, + dateStart: { type: 'string', description: 'Start date filter (ISO)' }, + dateEnd: { type: 'string', description: 'End date filter (ISO)' }, + offset: { type: 'number', description: 'Pagination offset' }, + orderBy: { type: 'string', description: 'Sort order: date_desc or date_asc' } + }, additionalProperties: true }, handler: async (args: any) => { @@ -201,7 +211,13 @@ NEVER fetch full details without filtering first. 10x token savings.`, description: 'Step 2: Get context around results. Params: anchor (observation ID) OR query (finds anchor automatically), depth_before, depth_after, project', inputSchema: { type: 'object', - properties: {}, + properties: { + anchor: { type: 'number', description: 'Observation ID to center the timeline around' }, + query: { type: 'string', description: 'Query to find anchor automatically' }, + depth_before: { type: 'number', description: 'Items before anchor (default 3)' }, + depth_after: { type: 'number', description: 'Items after anchor (default 3)' }, + project: { type: 'string', description: 'Filter by project name' } + }, additionalProperties: true }, handler: async (args: any) => { diff --git a/tests/servers/mcp-tool-schemas.test.ts b/tests/servers/mcp-tool-schemas.test.ts new file mode 100644 index 00000000..ca7a1a68 --- /dev/null +++ b/tests/servers/mcp-tool-schemas.test.ts @@ -0,0 +1,56 @@ +/** + * Tests for MCP tool inputSchema declarations (fix for #1384 / #1413) + * + * Validates that search and timeline tools declare their parameters explicitly + * so MCP clients (Claude Code) can expose them to the LLM. + */ +import { describe, it, expect } from 'bun:test'; + +// Import the tools array directly — static schema validation, no server needed +import '../../src/servers/mcp-server.js'; + +// Re-import via dynamic require to access the tools array +const mcpServerPath = new URL('../../src/servers/mcp-server.ts', import.meta.url).pathname; + +describe('MCP tool inputSchema declarations', () => { + let tools: any[]; + + // Load tools by reading the source and extracting the exported structure + // We test the schema shape directly from the source constants + it('search tool declares query parameter', async () => { + const src = await Bun.file(mcpServerPath).text(); + + // Verify search properties are declared (not empty) + expect(src).toContain("name: 'search'"); + // Check query is declared in properties after the search tool definition + const searchSection = src.slice(src.indexOf("name: 'search'"), src.indexOf("name: 'timeline'")); + expect(searchSection).toContain("query:"); + expect(searchSection).toContain("limit:"); + expect(searchSection).toContain("project:"); + expect(searchSection).toContain("orderBy:"); + expect(searchSection).not.toContain("properties: {}"); + }); + + it('timeline tool declares anchor and query parameters', async () => { + const src = await Bun.file(mcpServerPath).text(); + + const timelineSection = src.slice( + src.indexOf("name: 'timeline'"), + src.indexOf("name: 'get_observations'") + ); + expect(timelineSection).toContain("anchor:"); + expect(timelineSection).toContain("query:"); + expect(timelineSection).toContain("depth_before:"); + expect(timelineSection).toContain("depth_after:"); + expect(timelineSection).toContain("project:"); + expect(timelineSection).not.toContain("properties: {}"); + }); + + it('get_observations still declares ids (regression check)', async () => { + const src = await Bun.file(mcpServerPath).text(); + + const getObsSection = src.slice(src.indexOf("name: 'get_observations'")); + expect(getObsSection).toContain("ids:"); + expect(getObsSection).toContain("required:"); + }); +}); From 64062ac7618a9cb16a03a287becaa9bd6a7c318a Mon Sep 17 00:00:00 2001 From: Ousama Ben Younes Date: Tue, 7 Apr 2026 17:30:59 +0000 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20address=20CodeRabbit=20review=20?= =?UTF-8?q?=E2=80=94=20remove=20side-effectful=20test=20import=20and=20nor?= =?UTF-8?q?malize=20timeline=20depth=20params?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tests/servers/mcp-tool-schemas.test.ts: remove `import '../../src/servers/mcp-server.js'` which triggered server startup side effects; test only needs to read the TS source as text - src/services/worker/SearchManager.ts: add Number() coercion for depth_before/depth_after in timeline(), getContextTimeline(), getTimelineByQuery() — HTTP query strings deliver these as strings, coercion ensures they are always numbers before being passed to filterByDepth() and getTimelineAround*() Co-Authored-By: Claude Sonnet 4.6 --- src/services/worker/SearchManager.ts | 50 ++++++++++++++------------ tests/servers/mcp-tool-schemas.test.ts | 5 +-- 2 files changed, 29 insertions(+), 26 deletions(-) diff --git a/src/services/worker/SearchManager.ts b/src/services/worker/SearchManager.ts index a72ce0d2..5efb0f70 100644 --- a/src/services/worker/SearchManager.ts +++ b/src/services/worker/SearchManager.ts @@ -395,7 +395,9 @@ export class SearchManager { * Tool handler: timeline */ async timeline(args: any): Promise { - const { anchor, query, depth_before = 10, depth_after = 10, project } = args; + const { anchor, query, depth_before, depth_after, project } = args; + const depthBefore = Number(depth_before) || 10; + const depthAfter = Number(depth_after) || 10; const cwd = process.cwd(); // Validate: must provide either anchor or query, not both @@ -464,7 +466,7 @@ export class SearchManager { anchorId = topResult.id; anchorEpoch = topResult.created_at_epoch; logger.debug('SEARCH', 'Query mode: Using observation as timeline anchor', { observationId: topResult.id }); - timelineData = this.sessionStore.getTimelineAroundObservation(topResult.id, topResult.created_at_epoch, depth_before, depth_after, project); + timelineData = this.sessionStore.getTimelineAroundObservation(topResult.id, topResult.created_at_epoch, depthBefore, depthAfter, project); } // MODE 2: Anchor-based timeline else if (typeof anchor === 'number') { @@ -481,7 +483,7 @@ export class SearchManager { } anchorId = anchor; anchorEpoch = obs.created_at_epoch; - timelineData = this.sessionStore.getTimelineAroundObservation(anchor, anchorEpoch, depth_before, depth_after, project); + timelineData = this.sessionStore.getTimelineAroundObservation(anchor, anchorEpoch, depthBefore, depthAfter, project); } else if (typeof anchor === 'string') { // Session ID or ISO timestamp if (anchor.startsWith('S') || anchor.startsWith('#S')) { @@ -499,7 +501,7 @@ export class SearchManager { } anchorEpoch = sessions[0].created_at_epoch; anchorId = `S${sessionNum}`; - timelineData = this.sessionStore.getTimelineAroundTimestamp(anchorEpoch, depth_before, depth_after, project); + timelineData = this.sessionStore.getTimelineAroundTimestamp(anchorEpoch, depthBefore, depthAfter, project); } else { // ISO timestamp const date = new Date(anchor); @@ -514,7 +516,7 @@ export class SearchManager { } anchorEpoch = date.getTime(); anchorId = anchor; - timelineData = this.sessionStore.getTimelineAroundTimestamp(anchorEpoch, depth_before, depth_after, project); + timelineData = this.sessionStore.getTimelineAroundTimestamp(anchorEpoch, depthBefore, depthAfter, project); } } else { return { @@ -533,15 +535,15 @@ export class SearchManager { ...(timelineData.prompts || []).map((prompt: any) => ({ type: 'prompt' as const, data: prompt, epoch: prompt.created_at_epoch })) ]; items.sort((a, b) => a.epoch - b.epoch); - const filteredItems = this.timelineService.filterByDepth(items, anchorId, anchorEpoch, depth_before, depth_after); + const filteredItems = this.timelineService.filterByDepth(items, anchorId, anchorEpoch, depthBefore, depthAfter); if (!filteredItems || filteredItems.length === 0) { return { content: [{ type: 'text' as const, text: query - ? `Found observation matching "${query}", but no timeline context available (${depth_before} records before, ${depth_after} records after).` - : `No context found around anchor (${depth_before} records before, ${depth_after} records after)` + ? `Found observation matching "${query}", but no timeline context available (${depthBefore} records before, ${depthAfter} records after).` + : `No context found around anchor (${depthBefore} records before, ${depthAfter} records after)` }] }; } @@ -559,7 +561,7 @@ export class SearchManager { lines.push(`# Timeline around anchor: ${anchorId}`); } - lines.push(`**Window:** ${depth_before} records before -> ${depth_after} records after | **Items:** ${filteredItems?.length ?? 0}`); + lines.push(`**Window:** ${depthBefore} records before -> ${depthAfter} records after | **Items:** ${filteredItems?.length ?? 0}`); lines.push(''); @@ -1443,7 +1445,9 @@ export class SearchManager { * Tool handler: get_context_timeline */ async getContextTimeline(args: any): Promise { - const { anchor, depth_before = 10, depth_after = 10, project } = args; + const { anchor, depth_before, depth_after, project } = args; + const depthBefore = Number(depth_before) || 10; + const depthAfter = Number(depth_after) || 10; const cwd = process.cwd(); let anchorEpoch: number; let anchorId: string | number = anchor; @@ -1463,7 +1467,7 @@ export class SearchManager { }; } anchorEpoch = obs.created_at_epoch; - timelineData = this.sessionStore.getTimelineAroundObservation(anchor, anchorEpoch, depth_before, depth_after, project); + timelineData = this.sessionStore.getTimelineAroundObservation(anchor, anchorEpoch, depthBefore, depthAfter, project); } else if (typeof anchor === 'string') { // Session ID or ISO timestamp if (anchor.startsWith('S') || anchor.startsWith('#S')) { @@ -1481,7 +1485,7 @@ export class SearchManager { } anchorEpoch = sessions[0].created_at_epoch; anchorId = `S${sessionNum}`; - timelineData = this.sessionStore.getTimelineAroundTimestamp(anchorEpoch, depth_before, depth_after, project); + timelineData = this.sessionStore.getTimelineAroundTimestamp(anchorEpoch, depthBefore, depthAfter, project); } else { // ISO timestamp const date = new Date(anchor); @@ -1495,7 +1499,7 @@ export class SearchManager { }; } anchorEpoch = date.getTime(); // Keep as milliseconds - timelineData = this.sessionStore.getTimelineAroundTimestamp(anchorEpoch, depth_before, depth_after, project); + timelineData = this.sessionStore.getTimelineAroundTimestamp(anchorEpoch, depthBefore, depthAfter, project); } } else { return { @@ -1514,14 +1518,14 @@ export class SearchManager { ...timelineData.prompts.map(prompt => ({ type: 'prompt' as const, data: prompt, epoch: prompt.created_at_epoch })) ]; items.sort((a, b) => a.epoch - b.epoch); - const filteredItems = this.timelineService.filterByDepth(items, anchorId, anchorEpoch, depth_before, depth_after); + const filteredItems = this.timelineService.filterByDepth(items, anchorId, anchorEpoch, depthBefore, depthAfter); if (!filteredItems || filteredItems.length === 0) { const anchorDate = new Date(anchorEpoch).toLocaleString(); return { content: [{ type: 'text' as const, - text: `No context found around ${anchorDate} (${depth_before} records before, ${depth_after} records after)` + text: `No context found around ${anchorDate} (${depthBefore} records before, ${depthAfter} records after)` }] }; } @@ -1531,7 +1535,7 @@ export class SearchManager { // Header lines.push(`# Timeline around anchor: ${anchorId}`); - lines.push(`**Window:** ${depth_before} records before -> ${depth_after} records after | **Items:** ${filteredItems?.length ?? 0}`); + lines.push(`**Window:** ${depthBefore} records before -> ${depthAfter} records after | **Items:** ${filteredItems?.length ?? 0}`); lines.push(''); @@ -1655,7 +1659,9 @@ export class SearchManager { * Tool handler: get_timeline_by_query */ async getTimelineByQuery(args: any): Promise { - const { query, mode = 'auto', depth_before = 10, depth_after = 10, limit = 5, project } = args; + const { query, mode = 'auto', depth_before, depth_after, limit = 5, project } = args; + const depthBefore = Number(depth_before) || 10; + const depthAfter = Number(depth_after) || 10; const cwd = process.cwd(); // Step 1: Search for observations @@ -1736,8 +1742,8 @@ export class SearchManager { const timelineData = this.sessionStore.getTimelineAroundObservation( topResult.id, topResult.created_at_epoch, - depth_before, - depth_after, + depthBefore, + depthAfter, project ); @@ -1748,13 +1754,13 @@ export class SearchManager { ...(timelineData.prompts || []).map(prompt => ({ type: 'prompt' as const, data: prompt, epoch: prompt.created_at_epoch })) ]; items.sort((a, b) => a.epoch - b.epoch); - const filteredItems = this.timelineService.filterByDepth(items, topResult.id, 0, depth_before, depth_after); + const filteredItems = this.timelineService.filterByDepth(items, topResult.id, 0, depthBefore, depthAfter); if (!filteredItems || filteredItems.length === 0) { return { content: [{ type: 'text' as const, - text: `Found observation #${topResult.id} matching "${query}", but no timeline context available (${depth_before} records before, ${depth_after} records after).` + text: `Found observation #${topResult.id} matching "${query}", but no timeline context available (${depthBefore} records before, ${depthAfter} records after).` }] }; } @@ -1765,7 +1771,7 @@ export class SearchManager { // Header lines.push(`# Timeline for query: "${query}"`); lines.push(`**Anchor:** Observation #${topResult.id} - ${topResult.title || 'Untitled'}`); - lines.push(`**Window:** ${depth_before} records before -> ${depth_after} records after | **Items:** ${filteredItems?.length ?? 0}`); + lines.push(`**Window:** ${depthBefore} records before -> ${depthAfter} records after | **Items:** ${filteredItems?.length ?? 0}`); lines.push(''); diff --git a/tests/servers/mcp-tool-schemas.test.ts b/tests/servers/mcp-tool-schemas.test.ts index ca7a1a68..1db787b0 100644 --- a/tests/servers/mcp-tool-schemas.test.ts +++ b/tests/servers/mcp-tool-schemas.test.ts @@ -6,10 +6,7 @@ */ import { describe, it, expect } from 'bun:test'; -// Import the tools array directly — static schema validation, no server needed -import '../../src/servers/mcp-server.js'; - -// Re-import via dynamic require to access the tools array +// Static schema validation — reads source as text, no server startup needed const mcpServerPath = new URL('../../src/servers/mcp-server.ts', import.meta.url).pathname; describe('MCP tool inputSchema declarations', () => { From 53f98fad67f7cdc16b2ebc24e6b3edfa1b3049a6 Mon Sep 17 00:00:00 2001 From: Ousama Ben Younes Date: Tue, 7 Apr 2026 17:40:15 +0000 Subject: [PATCH 3/3] fix: use null-check instead of falsy-OR for depth defaults to preserve 0 Number(x) || 10 converts 0 to 10 since 0 is falsy, making it impossible to request zero context depth (anchor only). Replace with explicit null check in timeline(), getContextTimeline(), getTimelineByQuery(). Co-Authored-By: Claude Sonnet 4.6 --- src/services/worker/SearchManager.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/services/worker/SearchManager.ts b/src/services/worker/SearchManager.ts index 5efb0f70..337b2df9 100644 --- a/src/services/worker/SearchManager.ts +++ b/src/services/worker/SearchManager.ts @@ -396,8 +396,8 @@ export class SearchManager { */ async timeline(args: any): Promise { const { anchor, query, depth_before, depth_after, project } = args; - const depthBefore = Number(depth_before) || 10; - const depthAfter = Number(depth_after) || 10; + const depthBefore = depth_before != null ? Number(depth_before) : 10; + const depthAfter = depth_after != null ? Number(depth_after) : 10; const cwd = process.cwd(); // Validate: must provide either anchor or query, not both @@ -1446,8 +1446,8 @@ export class SearchManager { */ async getContextTimeline(args: any): Promise { const { anchor, depth_before, depth_after, project } = args; - const depthBefore = Number(depth_before) || 10; - const depthAfter = Number(depth_after) || 10; + const depthBefore = depth_before != null ? Number(depth_before) : 10; + const depthAfter = depth_after != null ? Number(depth_after) : 10; const cwd = process.cwd(); let anchorEpoch: number; let anchorId: string | number = anchor; @@ -1660,8 +1660,8 @@ export class SearchManager { */ async getTimelineByQuery(args: any): Promise { const { query, mode = 'auto', depth_before, depth_after, limit = 5, project } = args; - const depthBefore = Number(depth_before) || 10; - const depthAfter = Number(depth_after) || 10; + const depthBefore = depth_before != null ? Number(depth_before) : 10; + const depthAfter = depth_after != null ? Number(depth_after) : 10; const cwd = process.cwd(); // Step 1: Search for observations