Merge pull request #1555 from ousamabenyounes/fix/issue-1384-mcp-inputschema

fix: declare inputSchema properties for search and timeline MCP tools (#1384 #1413)
This commit is contained in:
Alex Newman
2026-04-14 18:41:54 -07:00
committed by GitHub
3 changed files with 99 additions and 24 deletions
+18 -2
View File
@@ -284,7 +284,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', description: 'Step 1: Search memory. Returns index with IDs. Params: query, limit, project, type, obs_type, dateStart, dateEnd, offset, orderBy',
inputSchema: { inputSchema: {
type: 'object', 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 additionalProperties: true
}, },
handler: async (args: any) => { handler: async (args: any) => {
@@ -297,7 +307,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', description: 'Step 2: Get context around results. Params: anchor (observation ID) OR query (finds anchor automatically), depth_before, depth_after, project',
inputSchema: { inputSchema: {
type: 'object', 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 additionalProperties: true
}, },
handler: async (args: any) => { handler: async (args: any) => {
+28 -22
View File
@@ -395,7 +395,9 @@ export class SearchManager {
* Tool handler: timeline * Tool handler: timeline
*/ */
async timeline(args: any): Promise<any> { async timeline(args: any): Promise<any> {
const { anchor, query, depth_before = 10, depth_after = 10, project } = args; const { anchor, query, depth_before, depth_after, project } = args;
const depthBefore = depth_before != null ? Number(depth_before) : 10;
const depthAfter = depth_after != null ? Number(depth_after) : 10;
const cwd = process.cwd(); const cwd = process.cwd();
// Validate: must provide either anchor or query, not both // Validate: must provide either anchor or query, not both
@@ -464,7 +466,7 @@ export class SearchManager {
anchorId = topResult.id; anchorId = topResult.id;
anchorEpoch = topResult.created_at_epoch; anchorEpoch = topResult.created_at_epoch;
logger.debug('SEARCH', 'Query mode: Using observation as timeline anchor', { observationId: topResult.id }); 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 // MODE 2: Anchor-based timeline
else if (typeof anchor === 'number') { else if (typeof anchor === 'number') {
@@ -481,7 +483,7 @@ export class SearchManager {
} }
anchorId = anchor; anchorId = anchor;
anchorEpoch = obs.created_at_epoch; 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') { } else if (typeof anchor === 'string') {
// Session ID or ISO timestamp // Session ID or ISO timestamp
if (anchor.startsWith('S') || anchor.startsWith('#S')) { if (anchor.startsWith('S') || anchor.startsWith('#S')) {
@@ -499,7 +501,7 @@ export class SearchManager {
} }
anchorEpoch = sessions[0].created_at_epoch; anchorEpoch = sessions[0].created_at_epoch;
anchorId = `S${sessionNum}`; anchorId = `S${sessionNum}`;
timelineData = this.sessionStore.getTimelineAroundTimestamp(anchorEpoch, depth_before, depth_after, project); timelineData = this.sessionStore.getTimelineAroundTimestamp(anchorEpoch, depthBefore, depthAfter, project);
} else { } else {
// ISO timestamp // ISO timestamp
const date = new Date(anchor); const date = new Date(anchor);
@@ -514,7 +516,7 @@ export class SearchManager {
} }
anchorEpoch = date.getTime(); anchorEpoch = date.getTime();
anchorId = anchor; anchorId = anchor;
timelineData = this.sessionStore.getTimelineAroundTimestamp(anchorEpoch, depth_before, depth_after, project); timelineData = this.sessionStore.getTimelineAroundTimestamp(anchorEpoch, depthBefore, depthAfter, project);
} }
} else { } else {
return { return {
@@ -533,15 +535,15 @@ export class SearchManager {
...(timelineData.prompts || []).map((prompt: any) => ({ type: 'prompt' as const, data: prompt, epoch: prompt.created_at_epoch })) ...(timelineData.prompts || []).map((prompt: any) => ({ type: 'prompt' as const, data: prompt, epoch: prompt.created_at_epoch }))
]; ];
items.sort((a, b) => a.epoch - b.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) { if (!filteredItems || filteredItems.length === 0) {
return { return {
content: [{ content: [{
type: 'text' as const, type: 'text' as const,
text: query text: query
? `Found observation matching "${query}", but no timeline context available (${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 (${depth_before} records before, ${depth_after} 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(`# 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(''); lines.push('');
@@ -1443,7 +1445,9 @@ export class SearchManager {
* Tool handler: get_context_timeline * Tool handler: get_context_timeline
*/ */
async getContextTimeline(args: any): Promise<any> { async getContextTimeline(args: any): Promise<any> {
const { anchor, depth_before = 10, depth_after = 10, project } = args; const { anchor, depth_before, depth_after, project } = args;
const depthBefore = depth_before != null ? Number(depth_before) : 10;
const depthAfter = depth_after != null ? Number(depth_after) : 10;
const cwd = process.cwd(); const cwd = process.cwd();
let anchorEpoch: number; let anchorEpoch: number;
let anchorId: string | number = anchor; let anchorId: string | number = anchor;
@@ -1463,7 +1467,7 @@ export class SearchManager {
}; };
} }
anchorEpoch = obs.created_at_epoch; 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') { } else if (typeof anchor === 'string') {
// Session ID or ISO timestamp // Session ID or ISO timestamp
if (anchor.startsWith('S') || anchor.startsWith('#S')) { if (anchor.startsWith('S') || anchor.startsWith('#S')) {
@@ -1481,7 +1485,7 @@ export class SearchManager {
} }
anchorEpoch = sessions[0].created_at_epoch; anchorEpoch = sessions[0].created_at_epoch;
anchorId = `S${sessionNum}`; anchorId = `S${sessionNum}`;
timelineData = this.sessionStore.getTimelineAroundTimestamp(anchorEpoch, depth_before, depth_after, project); timelineData = this.sessionStore.getTimelineAroundTimestamp(anchorEpoch, depthBefore, depthAfter, project);
} else { } else {
// ISO timestamp // ISO timestamp
const date = new Date(anchor); const date = new Date(anchor);
@@ -1495,7 +1499,7 @@ export class SearchManager {
}; };
} }
anchorEpoch = date.getTime(); // Keep as milliseconds 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 { } else {
return { return {
@@ -1514,14 +1518,14 @@ export class SearchManager {
...timelineData.prompts.map(prompt => ({ type: 'prompt' as const, data: prompt, epoch: prompt.created_at_epoch })) ...timelineData.prompts.map(prompt => ({ type: 'prompt' as const, data: prompt, epoch: prompt.created_at_epoch }))
]; ];
items.sort((a, b) => a.epoch - b.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) { if (!filteredItems || filteredItems.length === 0) {
const anchorDate = new Date(anchorEpoch).toLocaleString(); const anchorDate = new Date(anchorEpoch).toLocaleString();
return { return {
content: [{ content: [{
type: 'text' as const, 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 // Header
lines.push(`# Timeline around anchor: ${anchorId}`); 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(''); lines.push('');
@@ -1655,7 +1659,9 @@ export class SearchManager {
* Tool handler: get_timeline_by_query * Tool handler: get_timeline_by_query
*/ */
async getTimelineByQuery(args: any): Promise<any> { async getTimelineByQuery(args: any): Promise<any> {
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 = depth_before != null ? Number(depth_before) : 10;
const depthAfter = depth_after != null ? Number(depth_after) : 10;
const cwd = process.cwd(); const cwd = process.cwd();
// Step 1: Search for observations // Step 1: Search for observations
@@ -1736,8 +1742,8 @@ export class SearchManager {
const timelineData = this.sessionStore.getTimelineAroundObservation( const timelineData = this.sessionStore.getTimelineAroundObservation(
topResult.id, topResult.id,
topResult.created_at_epoch, topResult.created_at_epoch,
depth_before, depthBefore,
depth_after, depthAfter,
project project
); );
@@ -1748,13 +1754,13 @@ export class SearchManager {
...(timelineData.prompts || []).map(prompt => ({ type: 'prompt' as const, data: prompt, epoch: prompt.created_at_epoch })) ...(timelineData.prompts || []).map(prompt => ({ type: 'prompt' as const, data: prompt, epoch: prompt.created_at_epoch }))
]; ];
items.sort((a, b) => a.epoch - b.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) { if (!filteredItems || filteredItems.length === 0) {
return { return {
content: [{ content: [{
type: 'text' as const, 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 // Header
lines.push(`# Timeline for query: "${query}"`); lines.push(`# Timeline for query: "${query}"`);
lines.push(`**Anchor:** Observation #${topResult.id} - ${topResult.title || 'Untitled'}`); 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(''); lines.push('');
+53
View File
@@ -0,0 +1,53 @@
/**
* 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';
// 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', () => {
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:");
});
});