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
7 changes: 4 additions & 3 deletions src/mcp/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ function textError(prefix: string, err: unknown): McpTextResult {

const recallQueryInput = {
query: z.string().min(1).describe('FTS5 search query (keywords or phrase)'),
projectId: z.string().min(1).describe('Project ID (derived from cwd, e.g. "-Users-foo-my-project")'),
limit: z.number().int().positive().max(50).optional().describe('Max results (default 10, max 50)'),
maxTokens: z.number().int().positive().max(2000).optional().describe(
`Approximate total output token budget (default ${DEFAULT_MAX_TOKENS}). Results are truncated with per-row ellipsis and a trailer so clipping is always visible.`,
Expand Down Expand Up @@ -87,11 +88,11 @@ export function formatMemories(
export function recallQueryHandler(
db: Database,
memoryService: MemoryService,
args: { query: string; limit?: number; maxTokens?: number },
args: { query: string; projectId: string; limit?: number; maxTokens?: number },
): McpTextResult {
const limit = args.limit ?? 10
try {
const memories = db.queryMemories(args.query, limit)
const memories = db.queryMemories(args.query, limit, args.projectId)
const { text, emittedIds } = formatMemories(memories, args.query, args.maxTokens)
// Phase 4c: touch only memories that actually reached the caller.
// Budget-dropped rows are not "surfaced" — bumping their access_count
Expand All @@ -102,7 +103,7 @@ export function recallQueryHandler(
appendRecallTelemetry({
query: args.query,
hitCount: emittedIds.length,
projectId: null,
projectId: args.projectId,
limit,
maxTokens: args.maxTokens ?? null,
})
Expand Down
43 changes: 35 additions & 8 deletions tests/mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ describe('MCP recall_query handler', () => {
})

it('returns empty-result message when no memories match', () => {
const result = recallQueryHandler(db, svc, { query: 'nonexistent' })
const result = recallQueryHandler(db, svc, { query: 'nonexistent', projectId: 'proj-a' })
expect(result.isError).toBeUndefined()
expect(result.content[0].text).toContain('No memories found')
})
Expand All @@ -44,8 +44,9 @@ describe('MCP recall_query handler', () => {
content: 'Use Apache-2.0 license for ccRecall',
type: 'decision',
confidence: 0.9,
projectId: 'proj-a',
})
const result = recallQueryHandler(db, svc, { query: 'Apache' })
const result = recallQueryHandler(db, svc, { query: 'Apache', projectId: 'proj-a' })
expect(result.content[0].text).toContain('[decision]')
expect(result.content[0].text).toContain('Apache-2.0')
expect(result.content[0].text).toContain('conf 0.90')
Expand All @@ -59,9 +60,10 @@ describe('MCP recall_query handler', () => {
content: `test memory ${i} apache`,
type: 'discovery',
confidence: 1,
projectId: 'proj-a',
})
}
const result = recallQueryHandler(db, svc, { query: 'apache', limit: 2 })
const result = recallQueryHandler(db, svc, { query: 'apache', limit: 2, projectId: 'proj-a' })
const lines = result.content[0].text.split('\n').filter(Boolean)
expect(lines.length).toBe(2)
})
Expand All @@ -74,9 +76,10 @@ describe('MCP recall_query handler', () => {
content: `memory ${i} keyword`,
type: 'pattern',
confidence: 1,
projectId: 'proj-a',
})
}
const result = recallQueryHandler(db, svc, { query: 'keyword' })
const result = recallQueryHandler(db, svc, { query: 'keyword', projectId: 'proj-a' })
const lines = result.content[0].text.split('\n').filter(Boolean)
expect(lines.length).toBe(10)
})
Expand All @@ -94,14 +97,16 @@ describe('MCP recall_query handler', () => {
content: 'apache license decision body',
type: 'decision',
confidence: 1,
projectId: 'proj-a',
})
recallQueryHandler(db, svc, { query: 'apache', limit: 5 })
recallQueryHandler(db, svc, { query: 'apache', limit: 5, projectId: 'proj-a' })
const lines = readFileSync(logPath, 'utf8').trim().split('\n')
expect(lines.length).toBe(1)
const row = JSON.parse(lines[0])
expect(row.query).toBe('apache')
expect(row.hitCount).toBe(1)
expect(row.limit).toBe(5)
expect(row.projectId).toBe('proj-a')
} finally {
if (original === undefined) delete process.env.CCRECALL_RECALL_TELEMETRY_PATH
else process.env.CCRECALL_RECALL_TELEMETRY_PATH = original
Expand All @@ -113,7 +118,7 @@ describe('MCP recall_query handler', () => {
const original = process.env.CCRECALL_RECALL_TELEMETRY_PATH
process.env.CCRECALL_RECALL_TELEMETRY_PATH = logPath
try {
recallQueryHandler(db, svc, { query: 'nonexistent-zzzz' })
recallQueryHandler(db, svc, { query: 'nonexistent-zzzz', projectId: 'proj-a' })
const lines = readFileSync(logPath, 'utf8').trim().split('\n')
expect(lines.length).toBe(1)
const row = JSON.parse(lines[0])
Expand All @@ -123,6 +128,28 @@ describe('MCP recall_query handler', () => {
else process.env.CCRECALL_RECALL_TELEMETRY_PATH = original
}
})

it('respects project isolation — only returns current-project memories', () => {
db.saveMemory({
sessionId: null,
messageId: null,
content: 'apache config lives in project alpha',
type: 'decision',
confidence: 1,
projectId: 'proj-a',
})
db.saveMemory({
sessionId: null,
messageId: null,
content: 'apache config lives in project beta',
type: 'decision',
confidence: 1,
projectId: 'proj-b',
})
const result = recallQueryHandler(db, svc, { query: 'apache', projectId: 'proj-a' })
expect(result.content[0].text).toContain('project alpha')
expect(result.content[0].text).not.toContain('project beta')
})
})

describe('formatMemories', () => {
Expand Down Expand Up @@ -242,8 +269,8 @@ describe('MCP recall_save handler', () => {
})

it('persists memory queryable via recallQueryHandler', () => {
recallSaveHandler(db, { content: 'searchable via mcp tool', type: 'discovery' })
const result = recallQueryHandler(db, svc, { query: 'searchable' })
recallSaveHandler(db, { content: 'searchable via mcp tool', type: 'discovery', projectId: 'proj-a' })
const result = recallQueryHandler(db, svc, { query: 'searchable', projectId: 'proj-a' })
expect(result.content[0].text).toContain('searchable via mcp tool')
expect(result.content[0].text).toContain('[discovery]')
})
Expand Down
20 changes: 10 additions & 10 deletions tests/touch-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,44 +38,44 @@ function accessCount(id: number): number {
describe('recallQueryHandler — touch integration', () => {
it('increments access_count for every returned memory', () => {
const id1 = db.saveMemory({
sessionId: null, messageId: null, type: 'decision', content: 'alpha one',
sessionId: null, messageId: null, type: 'decision', content: 'alpha one', projectId: 'proj-a',
})
const id2 = db.saveMemory({
sessionId: null, messageId: null, type: 'decision', content: 'alpha two',
sessionId: null, messageId: null, type: 'decision', content: 'alpha two', projectId: 'proj-a',
})
expect(accessCount(id1)).toBe(0)
expect(accessCount(id2)).toBe(0)

recallQueryHandler(db, svc, { query: 'alpha' })
recallQueryHandler(db, svc, { query: 'alpha', projectId: 'proj-a' })

expect(accessCount(id1)).toBe(1)
expect(accessCount(id2)).toBe(1)
})

it('does not touch memories that were not surfaced', () => {
const surfacedId = db.saveMemory({
sessionId: null, messageId: null, type: 'decision', content: 'beta matched',
sessionId: null, messageId: null, type: 'decision', content: 'beta matched', projectId: 'proj-a',
})
const untouchedId = db.saveMemory({
sessionId: null, messageId: null, type: 'decision', content: 'gamma unmatched',
sessionId: null, messageId: null, type: 'decision', content: 'gamma unmatched', projectId: 'proj-a',
})
recallQueryHandler(db, svc, { query: 'beta' })
recallQueryHandler(db, svc, { query: 'beta', projectId: 'proj-a' })
expect(accessCount(surfacedId)).toBe(1)
expect(accessCount(untouchedId)).toBe(0)
})

it('touches same memory twice on separate calls', () => {
const id = db.saveMemory({
sessionId: null, messageId: null, type: 'decision', content: 'repeated',
sessionId: null, messageId: null, type: 'decision', content: 'repeated', projectId: 'proj-a',
})
recallQueryHandler(db, svc, { query: 'repeated' })
recallQueryHandler(db, svc, { query: 'repeated' })
recallQueryHandler(db, svc, { query: 'repeated', projectId: 'proj-a' })
recallQueryHandler(db, svc, { query: 'repeated', projectId: 'proj-a' })
expect(accessCount(id)).toBe(2)
})

it('noops on empty result set (touch never called)', () => {
// No memories at all — handler should not throw.
expect(() => recallQueryHandler(db, svc, { query: 'nothing' })).not.toThrow()
expect(() => recallQueryHandler(db, svc, { query: 'nothing', projectId: 'proj-a' })).not.toThrow()
})
})

Expand Down