diff --git a/CLAUDE.md b/CLAUDE.md index 7714d82..5216974 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,10 +70,11 @@ Auth uses timing-safe token comparison. Origin allowlisting supports `"*"` wildc | `remember_context` | Single write tool for chat and admin clients: save durable memory with optional topic, draft flag or explicit branch state, image input, artifact refs, and provenance metadata | | `search_context` | Query → embedding → Firestore vector similarity search (cosine, top-K) with metadata and provenance origin filters | | `fetch_context` | Retrieve one stored memory by document ID after search | +| `list_context` | Enumerate stored memories with filters and pagination | | `deprecate_context` | Soft-delete: mark document as deprecated, record superseding document ID, and record supersession_reason ("changed" sets valid_until, "corrected" does not) | | `consolidate_context` | Merge N related memories into one canonical active memory via LLM; deprecates all sources with `superseded_by` pointing to the merged result. Defaults to WIP queue for a topic; accepts explicit `source_ids` for targeted consolidation | -`remember_context` also accepts optional `valid_from`/`valid_until` (epoch-ms numbers) so a write can carry its temporal validity window from creation. It also accepts optional `origin`, `source_session`, `derived_from`, and `confidence` for provenance tracking (`origin` defaults to `agent_inferred` when omitted). `search_context` accepts optional `filter_origin` to filter search results by provenance origin as a post-filter (no new Firestore index). +`remember_context` also accepts optional `valid_from`/`valid_until` (epoch-ms numbers) so a write can carry its temporal validity window from creation. It also accepts optional `origin`, `source_session`, `derived_from`, and `confidence` for provenance tracking (`origin` defaults to `agent_inferred` when omitted). `search_context` and `list_context` accept optional `filter_origin` to filter memories by provenance origin as a post-filter. ### MCP Prompts @@ -91,13 +92,13 @@ Auth uses timing-safe token comparison. Origin allowlisting supports `"*"` wildc | `errors.ts` | ~9 | `HttpError` exception with `statusCode` field | | `merging.ts` | ~74 | `LlmMergeClient` interface + `GeminiMergeClient` — calls Gemini to merge N memory contents into one | | `runtime.ts` | ~107 | Dependency injection: `createRuntime()` lazily creates and caches Gemini clients, Firestore repo, service | -| `service.ts` | ~468 | `MetaCortexService` — remember/store/search/fetch/deprecate/consolidate flows | +| `service.ts` | ~533 | `MetaCortexService` — remember/store/search/fetch/deprecate/consolidate/list flows | | `observability.ts` | ~150 | Structured tool-event and request-event logging plus Firestore-backed `memory_events` audit trail | | `embeddings.ts` | ~195 | `GeminiEmbeddingClient` + `GeminiMultimodalPreparer` (image→text normalization for retrieval) | | `normalize.ts` | ~8 | Shared text-normalization helper | -| `memoryRepository.ts` | ~228 | Firestore CRUD: `store()`, `search()` (findNearest + cosine), `deprecate()`, `getConsolidationQueue()` | -| `types.ts` | ~175 | Enums (`BRANCH_STATES`, `MEMORY_MODALITIES`, `MCP_TOOL_NAMES`, `PROVENANCE_ORIGINS`) and interfaces | -| `mcpServer.ts` | ~674 | MCP tool registration with Zod schemas, filtered by client's `allowedTools` and `allowedFilterStates`; also registers the `correct_memory` prompt unconditionally | +| `memoryRepository.ts` | ~320 | Firestore CRUD: `store()`, `search()` (findNearest + cosine), `deprecate()`, `getConsolidationQueue()`, `list()` | +| `types.ts` | ~201 | Enums (`BRANCH_STATES`, `MEMORY_MODALITIES`, `MCP_TOOL_NAMES`, `PROVENANCE_ORIGINS`) and interfaces | +| `mcpServer.ts` | ~764 | MCP tool registration with Zod schemas, filtered by client's `allowedTools` and `allowedFilterStates`; also registers the `correct_memory` prompt unconditionally | ### Data Flow diff --git a/README.md b/README.md index 3772b2f..f2e196a 100644 --- a/README.md +++ b/README.md @@ -57,10 +57,10 @@ This project is set up for these workflows: The current MCP surface is intentionally split between: -- a 3-tool client-facing contract for browser-hosted chat clients +- a client-facing contract for browser-hosted chat clients - a smaller admin-only maintenance surface documented later in this README -That means the server currently exposes 5 MCP tools total, but normal browser clients should only see 3 of them. +That means the server currently exposes 6 MCP tools total, but normal browser clients should only see 3 of them by default. ### Client-facing tools @@ -72,6 +72,8 @@ This is the public/browser contract: Vector search over stored memories. Results include stable `id` values and artifact refs when available. - `fetch_context` Fetch one memory by `id` after `remember_context` or `search_context`. +- `list_context` + Enumerate stored memories with cursor pagination and metadata/creation-time/provenance filtering. Returns item summaries and IDs. ## Why `remember_context` Is The Write Tool diff --git a/firestore.indexes.json b/firestore.indexes.json index e514f59..8758dad 100644 --- a/firestore.indexes.json +++ b/firestore.indexes.json @@ -55,6 +55,38 @@ } ] }, + { + "collectionGroup": "memory_vectors", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "metadata.branch_state", + "order": "ASCENDING" + }, + { + "fieldPath": "metadata.created_at", + "order": "DESCENDING" + } + ] + }, + { + "collectionGroup": "memory_vectors", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "metadata.branch_state", + "order": "ASCENDING" + }, + { + "fieldPath": "metadata.module_name", + "order": "ASCENDING" + }, + { + "fieldPath": "metadata.created_at", + "order": "DESCENDING" + } + ] + }, { "collectionGroup": "memory_vectors_eval", "queryScope": "COLLECTION", diff --git a/functions/src/mcpServer.ts b/functions/src/mcpServer.ts index 3038ff8..14638f1 100644 --- a/functions/src/mcpServer.ts +++ b/functions/src/mcpServer.ts @@ -12,6 +12,7 @@ import { buildConsolidatePayload, buildDeprecatePayload, buildFetchPayload, + buildListPayload, buildRememberPayload, buildSearchPayload, MetaCortexService @@ -405,6 +406,92 @@ export function createMetaCortexMcpServer( ); } + if (allowedTools.has("list_context")) { + server.registerTool( + "list_context", + { + title: "List Context", + description: + "Enumerate stored memories without vector search. Supports cursor-based pagination and filtering by topic, state, origin, and creation time. Returns a JSON object with items, next_cursor, and applied_filters. Note: due to origin post-filtering, a page may contain fewer than limit items while next_cursor is non-null.", + inputSchema: { + filter_topic: z + .string() + .optional() + .describe( + "Optional topic or subsystem label to pre-filter, such as auth, billing, or kmp-networking." + ), + filter_state: z + .enum(BRANCH_STATES) + .default(config.defaultFilterState) + .describe("Optional branch state filter. Defaults to active."), + filter_origin: z + .enum(PROVENANCE_ORIGINS) + .optional() + .describe( + "Optional provenance origin filter. Only returns memories whose provenance.origin matches exactly; memories without provenance metadata are excluded when this filter is set." + ), + created_after: z + .number() + .optional() + .describe("Optional epoch-ms timestamp (inclusive). Only returns memories created at or after this time."), + created_before: z + .number() + .optional() + .describe("Optional epoch-ms timestamp (exclusive). Only returns memories created before this time."), + limit: z + .number() + .int() + .min(1) + .max(100) + .optional() + .describe("Max results to return. Defaults to 20."), + cursor: z + .string() + .optional() + .describe("Optional cursor for pagination (a document id). When set, starts after the cursor.") + } + }, + async args => { + const requestedFilterState = args.filter_state ?? config.defaultFilterState; + const normalizedFilterTopic = normalizeOptionalText(args.filter_topic); + const requestSummary = { + filter_topic: normalizedFilterTopic, + filter_state: requestedFilterState, + filter_origin: args.filter_origin, + created_after: args.created_after, + created_before: args.created_before, + limit: args.limit, + cursor: args.cursor + }; + const result = await observeToolCall( + "list_context", + requestSummary, + async () => { + if (!config.allowedFilterStates.includes(requestedFilterState)) { + throw new HttpError( + 403, + `filter_state '${requestedFilterState}' is not allowed for this client` + ); + } + + return service.listContext(args); + }, + listResult => ({ + result_count: listResult.items.length, + result_ids: listResult.items.map(item => item.id), + filter_state: listResult.applied_filters.filter_state, + filter_topic: listResult.applied_filters.filter_topic, + has_next_cursor: listResult.next_cursor !== null + }) + ); + + return { + content: [jsonTextContent(buildListPayload(result))] + }; + } + ); + } + if (allowedTools.has("fetch_context")) { server.registerTool( "fetch_context", @@ -645,6 +732,9 @@ function buildServerInstructions(allowedTools: readonly McpToolName[]): string { allowedToolSet.has("search_context") ? "Use search_context for retrieval and filter_topic to narrow by topic." : undefined, + allowedToolSet.has("list_context") + ? "Use list_context to enumerate memories with filters and pagination." + : undefined, allowedToolSet.has("fetch_context") ? "Use fetch_context with the id returned by remember_context or search_context when you need the full stored record." : undefined, diff --git a/functions/src/memoryRepository.ts b/functions/src/memoryRepository.ts index 6f84191..ea4d360 100644 --- a/functions/src/memoryRepository.ts +++ b/functions/src/memoryRepository.ts @@ -25,6 +25,15 @@ export interface SearchMemoryParams { filterState: string; } +export interface ListMemoryParams { + filterState: string; + filterModule?: string; + createdAfter?: number; + createdBefore?: number; + limit: number; + cursorId?: string; +} + export interface MemoryRepository { store(params: StoreMemoryParams): Promise<{ document: MemoryDocument; created: boolean }>; search(params: SearchMemoryParams): Promise; @@ -35,6 +44,7 @@ export interface MemoryRepository { options?: { supersessionReason?: SupersessionReason; initiator?: "user" | "agent" } ): Promise<{ previousState: BranchState }>; getConsolidationQueue(moduleName?: string): Promise; + list(params: ListMemoryParams): Promise<{ documents: MemoryDocument[] }>; } interface FirestoreMemoryDocument { @@ -232,6 +242,50 @@ export class FirestoreMemoryRepository implements MemoryRepository { }; }); } + + async list(params: ListMemoryParams): Promise<{ documents: MemoryDocument[] }> { + let query = this.firestore + .collection(this.collectionName) + .where("metadata.branch_state", "==", params.filterState); + + if (params.filterModule) { + query = query.where("metadata.module_name", "==", params.filterModule); + } + + if (typeof params.createdAfter === "number") { + query = query.where("metadata.created_at", ">=", params.createdAfter); + } + + if (typeof params.createdBefore === "number") { + query = query.where("metadata.created_at", "<", params.createdBefore); + } + + query = query.orderBy("metadata.created_at", "desc"); + + if (params.cursorId) { + const cursorSnapshot = await this.firestore + .collection(this.collectionName) + .doc(params.cursorId) + .get(); + + if (!cursorSnapshot.exists) { + throw new HttpError(400, "Invalid cursor"); + } + + query = query.startAfter(cursorSnapshot); + } + + query = query.limit(params.limit); + + const snapshot = await query.get(); + + return { + documents: snapshot.docs.map(doc => { + const data = doc.data() as FirestoreMemoryDocument; + return mapFirestoreDocument(doc.id, data); + }) + }; + } } function getDedupeExpiresAt( diff --git a/functions/src/service.ts b/functions/src/service.ts index bcfde3b..ad1a1e1 100644 --- a/functions/src/service.ts +++ b/functions/src/service.ts @@ -9,6 +9,7 @@ import { HttpError } from "./errors.js"; import type { LlmMergeClient } from "./merging.js"; import { normalizeOptionalText } from "./normalize.js"; import type { + BranchState, ConsolidateContextInput, ConsolidateContextResult, ConsolidationQueueInput, @@ -17,6 +18,8 @@ import type { DeprecateContextResult, FetchContextInput, FetchContextResult, + ListContextInput, + ListContextResult, MemoryDocument, MemoryMetadata, RememberContextInput, @@ -159,6 +162,50 @@ export class MetaCortexService { }; } + async listContext(input: ListContextInput): Promise { + const limit = input.limit ?? 20; + if (limit < 1 || limit > 100) { + throw new HttpError(400, "Limit must be between 1 and 100"); + } + + const filterState = input.filter_state ?? this.config.defaultFilterState; + const filterTopic = normalizeOptionalText(input.filter_topic); + + const { documents } = await this.repository.list({ + filterState, + filterModule: filterTopic, + createdAfter: input.created_after, + createdBefore: input.created_before, + limit, + cursorId: input.cursor + }); + + const nextCursor = documents.length === limit ? documents[documents.length - 1].id : null; + + let items = documents; + if (input.filter_origin) { + items = documents.filter( + doc => doc.metadata.provenance?.origin === input.filter_origin + ); + } + + return { + items: items.map(doc => ({ + id: doc.id, + summary: summarizeMemoryContent(doc.content), + metadata: doc.metadata + })), + next_cursor: nextCursor, + applied_filters: { + filter_topic: filterTopic, + filter_state: filterState as BranchState, + filter_origin: input.filter_origin, + created_after: input.created_after, + created_before: input.created_before + } + }; + } + async fetchContext(input: FetchContextInput): Promise { const id = resolveFetchContextId(input); const item = await this.repository.get(id); @@ -306,6 +353,24 @@ export function buildSearchPayload(result: SearchContextResult): Record { + return { + items: result.items.map(item => ({ + id: item.id, + summary: item.summary, + metadata: buildPublicMetadata({ metadata: item.metadata }) + })), + next_cursor: result.next_cursor, + applied_filters: { + filter_topic: result.applied_filters.filter_topic ?? null, + filter_state: result.applied_filters.filter_state, + filter_origin: result.applied_filters.filter_origin ?? null, + created_after: result.applied_filters.created_after ?? null, + created_before: result.applied_filters.created_before ?? null + } + }; +} + export function buildFetchPayload(result: FetchContextResult): Record { return { item: { diff --git a/functions/src/types.ts b/functions/src/types.ts index 41a139e..dcc24f2 100644 --- a/functions/src/types.ts +++ b/functions/src/types.ts @@ -3,7 +3,8 @@ export const MCP_TOOL_NAMES = [ "search_context", "fetch_context", "deprecate_context", - "consolidate_context" + "consolidate_context", + "list_context" ] as const; export const BRANCH_STATES = [ @@ -172,3 +173,29 @@ export interface ConsolidateContextResult { topic: string; source_count: number; } + +export interface ListContextInput { + filter_topic?: string; + filter_state?: BranchState; + filter_origin?: ProvenanceOrigin; + created_after?: number; + created_before?: number; + limit?: number; + cursor?: string; +} + +export interface ListContextResult { + items: { + id: string; + summary: string; + metadata: MemoryMetadata; + }[]; + next_cursor: string | null; + applied_filters: { + filter_topic?: string; + filter_state: BranchState; + filter_origin?: ProvenanceOrigin; + created_after?: number; + created_before?: number; + }; +} diff --git a/functions/test/config.test.ts b/functions/test/config.test.ts index aeec890..24f7d07 100644 --- a/functions/test/config.test.ts +++ b/functions/test/config.test.ts @@ -150,4 +150,23 @@ describe("loadConfig", () => { }) ).toThrowError(MissingConfigurationError); }); + + it("allows list_context in MCP_ALLOWED_TOOLS and client profiles", () => { + const config = loadConfig({ + [geminiApiKeyEnv]: accessCredential("gemini"), + [adminTokenEnv]: accessCredential("admin"), + MCP_ALLOWED_TOOLS: "list_context", + [clientProfilesEnv]: JSON.stringify([ + { + id: "test-client", + [tokenField]: accessCredential("test-client"), + allowedTools: ["list_context"] + } + ]) + }); + + expect(config.defaultClientProfile.allowedTools).toEqual(["list_context"]); + expect(config.clientProfiles[0]?.allowedTools).toEqual(["list_context"]); + }); }); + diff --git a/functions/test/mcp.integration.test.ts b/functions/test/mcp.integration.test.ts index f63a2bb..1b37393 100644 --- a/functions/test/mcp.integration.test.ts +++ b/functions/test/mcp.integration.test.ts @@ -70,6 +70,7 @@ describe("MCP integration", () => { "consolidate_context", "deprecate_context", "fetch_context", + "list_context", "remember_context", "search_context" ]); @@ -1207,6 +1208,167 @@ describe("MCP integration", () => { } ]); }); + + it("supports list_context enumeration and pagination end-to-end", async () => { + const runtime = createTestRuntime({ + defaultClientProfile: { + id: "default", + authToken: "test-access", + allowedOrigins: [], + allowedTools: ["remember_context", "list_context"], + allowedFilterStates: ["active", "deprecated"] + } + }); + const app = createMetaCortexApp({ + getConfig: () => runtime.config, + getObserver: () => runtime.observer, + getRuntime: () => runtime + }); + const baseUrl = await startServer(app, cleanup); + + const client = new Client({ + name: "test-client", + version: "1.0.0" + }); + const transport = new StreamableHTTPClientTransport(new URL(`${baseUrl}/mcp`), { + requestInit: { + headers: { + [authorizationHeaderName]: bearerHeader("test") + } + } + }); + + cleanup.push(async () => { + await client.close(); + }); + + await client.connect(transport); + + const emptyResult = await client.callTool({ + name: "list_context", + arguments: { limit: 10 } + }); + expect(emptyResult.isError).toBeUndefined(); + const emptyPayload = parseJsonTextContent(emptyResult); + expect(emptyPayload.items).toEqual([]); + expect(emptyPayload.next_cursor).toBeNull(); + + for (let i = 1; i <= 3; i++) { + await client.callTool({ + name: "remember_context", + arguments: { + content: `Test memory ${i} content`, + topic: "testing", + branch_state: "active" + } + }); + } + + const listResult = await client.callTool({ + name: "list_context", + arguments: { limit: 2 } + }); + expect(listResult.isError).toBeUndefined(); + const listPayload = parseJsonTextContent(listResult); + expect(listPayload.items.length).toBe(2); + expect(listPayload.next_cursor).not.toBeNull(); + expect(listPayload.applied_filters.filter_state).toBe("active"); + + const listPage2 = await client.callTool({ + name: "list_context", + arguments: { limit: 2, cursor: listPayload.next_cursor } + }); + expect(listPage2.isError).toBeUndefined(); + const page2Payload = parseJsonTextContent(listPage2); + expect(page2Payload.items.length).toBe(1); + expect(page2Payload.next_cursor).toBeNull(); + }); + + it("enforces tool scoping and rejects list_context when absent from allowedTools", async () => { + const runtime = createTestRuntime({ + defaultClientProfile: { + id: "default", + authToken: "test-access", + allowedOrigins: [], + allowedTools: ["remember_context"], + allowedFilterStates: ["active"] + } + }); + const app = createMetaCortexApp({ + getConfig: () => runtime.config, + getObserver: () => runtime.observer, + getRuntime: () => runtime + }); + const baseUrl = await startServer(app, cleanup); + + const client = new Client({ + name: "test-client", + version: "1.0.0" + }); + const transport = new StreamableHTTPClientTransport(new URL(`${baseUrl}/mcp`), { + requestInit: { + headers: { + [authorizationHeaderName]: bearerHeader("test") + } + } + }); + + cleanup.push(async () => { + await client.close(); + }); + + await client.connect(transport); + + const listResult = await client.callTool({ + name: "list_context", + arguments: { limit: 10 } + }); + expect(listResult.isError).toBe(true); + expect(textContent(listResult)).toContain("Tool list_context not found"); + }); + + it("rejects filter_state outside allowedFilterStates for list_context", async () => { + const runtime = createTestRuntime({ + defaultClientProfile: { + id: "default", + authToken: "test-access", + allowedOrigins: [], + allowedTools: ["list_context"], + allowedFilterStates: ["active"] + } + }); + const app = createMetaCortexApp({ + getConfig: () => runtime.config, + getObserver: () => runtime.observer, + getRuntime: () => runtime + }); + const baseUrl = await startServer(app, cleanup); + + const client = new Client({ + name: "test-client", + version: "1.0.0" + }); + const transport = new StreamableHTTPClientTransport(new URL(`${baseUrl}/mcp`), { + requestInit: { + headers: { + [authorizationHeaderName]: bearerHeader("test") + } + } + }); + + cleanup.push(async () => { + await client.close(); + }); + + await client.connect(transport); + + const listResult = await client.callTool({ + name: "list_context", + arguments: { filter_state: "deprecated" } + }); + expect(listResult.isError).toBe(true); + expect(textContent(listResult)).toContain("filter_state 'deprecated' is not allowed"); + }); }); async function startServer( diff --git a/functions/test/service.test.ts b/functions/test/service.test.ts index 2afadce..bf92ba1 100644 --- a/functions/test/service.test.ts +++ b/functions/test/service.test.ts @@ -752,4 +752,155 @@ describe("MetaCortexService", () => { ).rejects.toThrow("Document not found"); }); }); + + describe("listContext()", () => { + it("uses default state (active) and default limit (20)", async () => { + const { service } = createService(); + for (let i = 1; i <= 25; i++) { + await service.storeContext({ + content: `doc ${i}`, + module_name: "general", + branch_state: "active" + }); + } + const result = await service.listContext({}); + expect(result.items.length).toBe(20); + expect(result.next_cursor).not.toBeNull(); + expect(result.applied_filters.filter_state).toBe("active"); + expect(result.applied_filters.filter_topic).toBeUndefined(); + }); + + it("filters by topic (module_name)", async () => { + const { service } = createService(); + await service.storeContext({ + content: "auth memory", + module_name: "auth", + branch_state: "active" + }); + await service.storeContext({ + content: "billing memory", + module_name: "billing", + branch_state: "active" + }); + + const result = await service.listContext({ filter_topic: "auth" }); + expect(result.items.length).toBe(1); + expect(result.items[0].summary).toBe("auth memory"); + expect(result.applied_filters.filter_topic).toBe("auth"); + }); + + it("filters by created range (created_after/created_before)", async () => { + const { service, repository } = createService(); + const timestamps = [1000, 2000, 3000]; + for (let i = 0; i < timestamps.length; i++) { + await repository.store({ + content: `doc ${i}`, + retrievalText: `doc ${i}`, + embedding: [0, 0, 0, 0, 0, 0], + idempotencyKey: `key-${i}`, + metadata: { + module_name: "general", + branch_state: "active", + created_at: timestamps[i], + updated_at: timestamps[i], + modality: "text" + } + }); + } + + const result = await service.listContext({ + created_after: 2000, + created_before: 3000 + }); + expect(result.items.length).toBe(1); + expect(result.items[0].summary).toBe("doc 1"); + }); + + it("excludes provenance-less documents when filter_origin is set", async () => { + const { service, repository } = createService(); + await service.storeContext({ + content: "doc with origin", + module_name: "general", + branch_state: "active", + origin: "user_asserted" + }); + + await repository.store({ + content: "doc without origin", + retrievalText: "doc without origin", + embedding: [0, 0, 0, 0, 0, 0], + idempotencyKey: "key-no-origin", + metadata: { + module_name: "general", + branch_state: "active", + created_at: Date.now(), + updated_at: Date.now(), + modality: "text" + } + }); + + const result = await service.listContext({ + filter_origin: "user_asserted" + }); + expect(result.items.length).toBe(1); + expect(result.items[0].summary).toBe("doc with origin"); + }); + + it("paginates across 2+ pages with cursor", async () => { + const { service } = createService(); + const ids: string[] = []; + for (let i = 1; i <= 5; i++) { + const stored = await service.storeContext({ + content: `item ${i}`, + module_name: "general", + branch_state: "active" + }); + ids.push(stored.id); + } + const page1 = await service.listContext({ limit: 2 }); + expect(page1.items.length).toBe(2); + expect(page1.next_cursor).toBe(page1.items[1].id); + + const page2 = await service.listContext({ limit: 2, cursor: page1.next_cursor! }); + expect(page2.items.length).toBe(2); + expect(page2.next_cursor).toBe(page2.items[1].id); + + const page3 = await service.listContext({ limit: 2, cursor: page2.next_cursor! }); + expect(page3.items.length).toBe(1); + expect(page3.next_cursor).toBeNull(); + }); + + it("returns null next_cursor on final partial page", async () => { + const { service } = createService(); + for (let i = 1; i <= 3; i++) { + await service.storeContext({ + content: `item ${i}`, + module_name: "general", + branch_state: "active" + }); + } + const result = await service.listContext({ limit: 4 }); + expect(result.items.length).toBe(3); + expect(result.next_cursor).toBeNull(); + }); + + it("throws 400 on invalid cursor", async () => { + const { service } = createService(); + await expect( + service.listContext({ cursor: "non-existent-id" }) + ).rejects.toThrow("Invalid cursor"); + }); + + it("throws 400 when limit is less than 1 or greater than 100", async () => { + const { service } = createService(); + await expect( + service.listContext({ limit: 0 }) + ).rejects.toThrow("Limit must be between 1 and 100"); + + await expect( + service.listContext({ limit: 101 }) + ).rejects.toThrow("Limit must be between 1 and 100"); + }); + }); }); + diff --git a/functions/test/support/fakes.ts b/functions/test/support/fakes.ts index 06736a7..db78a56 100644 --- a/functions/test/support/fakes.ts +++ b/functions/test/support/fakes.ts @@ -25,7 +25,9 @@ import { MCP_TOOL_NAMES } from "../../src/types.js"; import { MetaCortexService } from "../../src/service.js"; +import { HttpError } from "../../src/errors.js"; import type { + ListMemoryParams, MemoryRepository, SearchMemoryParams, StoreMemoryParams @@ -222,6 +224,44 @@ export class InMemoryMemoryRepository implements MemoryRepository { .filter(r => (moduleName ? r.metadata.module_name === moduleName : true)) .map(toMemoryDocument); } + + async list(params: ListMemoryParams): Promise<{ documents: MemoryDocument[] }> { + if (params.cursorId) { + const exists = this.records.some(r => r.id === params.cursorId); + if (!exists) { + throw new HttpError(400, "Invalid cursor"); + } + } + + let filtered = this.records + .filter(r => r.metadata.branch_state === params.filterState) + .filter(r => (params.filterModule ? r.metadata.module_name === params.filterModule : true)) + .filter(r => (typeof params.createdAfter === "number" ? r.metadata.created_at >= params.createdAfter : true)) + .filter(r => (typeof params.createdBefore === "number" ? r.metadata.created_at < params.createdBefore : true)) + .map(toMemoryDocument) + .sort((left, right) => right.metadata.created_at - left.metadata.created_at); + + if (params.cursorId) { + const cursorDoc = this.records.find(r => r.id === params.cursorId); + if (cursorDoc) { + const idx = filtered.findIndex(doc => doc.id === params.cursorId); + if (idx !== -1) { + filtered = filtered.slice(idx + 1); + } else { + filtered = filtered.filter(doc => { + if (doc.metadata.created_at < cursorDoc.metadata.created_at) return true; + if (doc.metadata.created_at === cursorDoc.metadata.created_at) { + return doc.id < cursorDoc.id; + } + return false; + }); + } + } + } + + const documents = filtered.slice(0, params.limit); + return { documents }; + } } export class InMemoryToolCallObserver implements ToolCallObserver {