From 4981a7929f5eb93172b28c54a21b740f9df3da65 Mon Sep 17 00:00:00 2001 From: Rich Hankins Date: Sun, 1 Feb 2026 16:22:24 -0800 Subject: [PATCH 01/23] Support dynamic index list in MultiIndexRunner - Remove readonly from indexNames and indexes fields - Remove empty-index checks at initialization (allow zero indexes) - Add refreshIndexList() method to reload indexes from store - Add invalidateClient() method to clear cached SearchClient - Update MCPServerConfig.store to accept both IndexStoreReader and IndexStore Agent-Id: agent-49436113-35bf-4532-983a-fb01da818555 --- src/clients/mcp-server.ts | 6 ++-- src/clients/multi-index-runner.ts | 59 +++++++++++++++++++++++-------- 2 files changed, 48 insertions(+), 17 deletions(-) diff --git a/src/clients/mcp-server.ts b/src/clients/mcp-server.ts index f72a6c0..26f3c84 100644 --- a/src/clients/mcp-server.ts +++ b/src/clients/mcp-server.ts @@ -37,7 +37,7 @@ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; -import type { IndexStoreReader } from "../stores/types.js"; +import type { IndexStoreReader, IndexStore } from "../stores/types.js"; import { MultiIndexRunner } from "./multi-index-runner.js"; import { SEARCH_DESCRIPTION, @@ -50,8 +50,8 @@ import { * Configuration for the MCP server. */ export interface MCPServerConfig { - /** Store to load indexes from */ - store: IndexStoreReader; + /** Store to load indexes from (accepts both reader-only and full store) */ + store: IndexStoreReader | IndexStore; /** * Index names to expose. If undefined, all indexes in the store are exposed. */ diff --git a/src/clients/multi-index-runner.ts b/src/clients/multi-index-runner.ts index f15027f..f5a6e70 100644 --- a/src/clients/multi-index-runner.ts +++ b/src/clients/multi-index-runner.ts @@ -7,7 +7,7 @@ * @module clients/multi-index-runner */ -import type { IndexStoreReader } from "../stores/types.js"; +import type { IndexStoreReader, IndexStore } from "../stores/types.js"; import type { Source } from "../sources/types.js"; import type { IndexStateSearchOnly } from "../core/types.js"; import { getSourceIdentifier, getResolvedRef } from "../core/types.js"; @@ -26,7 +26,7 @@ export interface IndexInfo { /** Configuration for MultiIndexRunner */ export interface MultiIndexRunnerConfig { /** Store to load indexes from */ - store: IndexStoreReader; + store: IndexStoreReader | IndexStore; /** * Index names to expose. If undefined, all indexes in the store are exposed. */ @@ -63,18 +63,18 @@ async function createSourceFromState(state: IndexStateSearchOnly): Promise(); /** Available index names */ - readonly indexNames: string[]; + indexNames: string[]; /** Metadata about available indexes */ - readonly indexes: IndexInfo[]; + indexes: IndexInfo[]; private constructor( - store: IndexStoreReader, + store: IndexStoreReader | IndexStore, indexNames: string[], indexes: IndexInfo[], searchOnly: boolean @@ -102,10 +102,6 @@ export class MultiIndexRunner { throw new Error(`Indexes not found: ${missingIndexes.join(", ")}`); } - if (indexNames.length === 0) { - throw new Error("No indexes available in store"); - } - // Load metadata for available indexes, filtering out any that fail to load const indexes: IndexInfo[] = []; const validIndexNames: string[] = []; @@ -128,10 +124,6 @@ export class MultiIndexRunner { } } - if (validIndexNames.length === 0) { - throw new Error("No valid indexes available (all indexes failed to load)"); - } - return new MultiIndexRunner(store, validIndexNames, indexes, searchOnly); } @@ -165,6 +157,45 @@ export class MultiIndexRunner { return client; } + /** + * Refresh the list of available indexes from the store. + * Call after adding or removing indexes. + */ + async refreshIndexList(): Promise { + const allIndexNames = await this.store.list(); + const newIndexes: IndexInfo[] = []; + const newIndexNames: string[] = []; + + for (const name of allIndexNames) { + try { + const state = await this.store.loadSearch(name); + if (state) { + newIndexNames.push(name); + newIndexes.push({ + name, + type: state.source.type, + identifier: getSourceIdentifier(state.source), + ref: getResolvedRef(state.source), + syncedAt: state.source.syncedAt, + }); + } + } catch { + // Skip indexes that fail to load + } + } + + this.indexNames = newIndexNames; + this.indexes = newIndexes; + } + + /** + * Invalidate cached SearchClient for an index. + * Call after updating an index to ensure fresh data on next access. + */ + invalidateClient(indexName: string): void { + this.clientCache.delete(indexName); + } + /** Check if file operations are enabled */ hasFileOperations(): boolean { return !this.searchOnly; From 427a1bc7a679092f4af62378955e7169b99da119 Mon Sep 17 00:00:00 2001 From: Rich Hankins Date: Sun, 1 Feb 2026 16:25:15 -0800 Subject: [PATCH 02/23] Add list_indexes tool to MCP server - Add list_indexes tool to ListToolsRequestSchema handler - Add handler in CallToolRequestSchema that refreshes index list and returns formatted metadata (name, type, identifier, syncedAt) - Returns helpful message when no indexes exist - Add withListIndexesReference() helper in tool-descriptions.ts - Update MCP server to use withListIndexesReference instead of embedding static index list in tool descriptions - Keep withIndexList() for CLI agent backward compatibility Agent-Id: agent-b187383f-2e44-4592-9ec9-6f0f4e0eafb6 --- src/clients/mcp-server.ts | 39 +++++++++++++++++++++++++------- src/clients/tool-descriptions.ts | 11 +++++++++ 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/src/clients/mcp-server.ts b/src/clients/mcp-server.ts index 26f3c84..1bce516 100644 --- a/src/clients/mcp-server.ts +++ b/src/clients/mcp-server.ts @@ -43,7 +43,7 @@ import { SEARCH_DESCRIPTION, LIST_FILES_DESCRIPTION, READ_FILE_DESCRIPTION, - withIndexList, + withListIndexesReference, } from "./tool-descriptions.js"; /** @@ -105,9 +105,6 @@ export async function createMCPServer( const { indexNames, indexes } = runner; const searchOnly = !runner.hasFileOperations(); - // Format index list for tool descriptions - const indexListStr = runner.getIndexListString(); - // Create MCP server const server = new Server( { @@ -135,14 +132,23 @@ export async function createMCPServer( }; }; - // Tool descriptions with available indexes (from shared module) - const searchDescription = withIndexList(SEARCH_DESCRIPTION, indexListStr); - const listFilesDescription = withIndexList(LIST_FILES_DESCRIPTION, indexListStr); - const readFileDescription = withIndexList(READ_FILE_DESCRIPTION, indexListStr); + // Tool descriptions with reference to list_indexes + const searchDescription = withListIndexesReference(SEARCH_DESCRIPTION); + const listFilesDescription = withListIndexesReference(LIST_FILES_DESCRIPTION); + const readFileDescription = withListIndexesReference(READ_FILE_DESCRIPTION); // List available tools server.setRequestHandler(ListToolsRequestSchema, async () => { const tools: Tool[] = [ + { + name: "list_indexes", + description: "List all available indexes with their metadata. Call this to discover what indexes are available before using search, list_files, or read_file tools.", + inputSchema: { + type: "object", + properties: {}, + required: [], + }, + }, { name: "search", description: searchDescription, @@ -255,6 +261,23 @@ export async function createMCPServer( server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; + // Handle list_indexes separately (no index_name required) + if (name === "list_indexes") { + await runner.refreshIndexList(); + const { indexes } = runner; + if (indexes.length === 0) { + return { + content: [{ type: "text", text: "No indexes available. Use index_repo to create one." }], + }; + } + const lines = indexes.map((i) => + `- ${i.name} (${i.type}://${i.identifier}) - synced ${i.syncedAt}` + ); + return { + content: [{ type: "text", text: `Available indexes:\n${lines.join("\n")}` }], + }; + } + try { const indexName = args?.index_name as string; const client = await runner.getClient(indexName); diff --git a/src/clients/tool-descriptions.ts b/src/clients/tool-descriptions.ts index 1a74c85..7f08c90 100644 --- a/src/clients/tool-descriptions.ts +++ b/src/clients/tool-descriptions.ts @@ -65,6 +65,7 @@ NOT supported: \\d, \\s, \\w (use [0-9], [ \\t], [a-zA-Z_] instead)`; /** * Format a tool description with available indexes for multi-index mode. + * Used by CLI agent which cannot call list_indexes dynamically. */ export function withIndexList(baseDescription: string, indexListStr: string): string { return `${baseDescription} @@ -73,3 +74,13 @@ Available indexes: ${indexListStr}`; } +/** + * Format a tool description with a reference to list_indexes. + * Used by MCP server where agents can call list_indexes dynamically. + */ +export function withListIndexesReference(baseDescription: string): string { + return `${baseDescription} + +Use list_indexes to see available indexes.`; +} + From 6447099c9f793bf73832565e4203aa0166f5fb80 Mon Sep 17 00:00:00 2001 From: Rich Hankins Date: Sun, 1 Feb 2026 16:27:48 -0800 Subject: [PATCH 03/23] Add delete_index tool to MCP server - Add delete_index tool definition (only when store supports delete) - Add handler to delete index from store, refresh runner state, and invalidate cached client - Validates index exists before attempting delete - Returns appropriate error messages for missing index or unsupported store Agent-Id: agent-8cf675a5-fcdf-402a-a3a1-f3fa2a8c3f1b --- src/clients/mcp-server.ts | 98 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/src/clients/mcp-server.ts b/src/clients/mcp-server.ts index 1bce516..547dfdc 100644 --- a/src/clients/mcp-server.ts +++ b/src/clients/mcp-server.ts @@ -149,6 +149,49 @@ export async function createMCPServer( required: [], }, }, + { + name: "index_repo", + description: "Create or update an index from a repository. This may take 30+ seconds for large repos. The index will be available for search, list_files, and read_file after creation.", + inputSchema: { + type: "object", + properties: { + name: { + type: "string", + description: "Unique name for this index (e.g., 'pytorch', 'my-lib')" + }, + source_type: { + type: "string", + enum: ["github", "gitlab", "bitbucket", "website"], + description: "Type of source to index" + }, + owner: { + type: "string", + description: "GitHub repository owner (required for github)" + }, + repo: { + type: "string", + description: "Repository name (required for github, bitbucket)" + }, + project_id: { + type: "string", + description: "GitLab project ID or path (required for gitlab)" + }, + workspace: { + type: "string", + description: "BitBucket workspace slug (required for bitbucket)" + }, + url: { + type: "string", + description: "URL to crawl (required for website)" + }, + ref: { + type: "string", + description: "Branch, tag, or commit (default: HEAD)" + }, + }, + required: ["name", "source_type"], + }, + }, { name: "search", description: searchDescription, @@ -174,6 +217,24 @@ export async function createMCPServer( }, ]; + // Add delete_index if store supports it + if ('delete' in config.store) { + tools.push({ + name: "delete_index", + description: "Delete an index by name. This removes the index from storage and it will no longer be available for search.", + inputSchema: { + type: "object", + properties: { + name: { + type: "string", + description: "Name of the index to delete", + }, + }, + required: ["name"], + }, + }); + } + // Only advertise file tools if not in search-only mode if (!searchOnly) { tools.push( @@ -278,6 +339,43 @@ export async function createMCPServer( }; } + // Handle delete_index separately (uses 'name' not 'index_name') + if (name === "delete_index") { + const indexName = args?.name as string; + + if (!indexName) { + return { content: [{ type: "text", text: "Error: name is required" }], isError: true }; + } + + // Check if index exists + if (!runner.indexNames.includes(indexName)) { + return { + content: [{ type: "text", text: `Error: Index "${indexName}" not found` }], + isError: true, + }; + } + + // Check if store supports delete operations + if (!('delete' in config.store)) { + return { content: [{ type: "text", text: "Error: Store does not support delete operations" }], isError: true }; + } + + try { + // Delete from store + await (config.store as IndexStore).delete(indexName); + + // Refresh runner state + await runner.refreshIndexList(); + runner.invalidateClient(indexName); + + return { + content: [{ type: "text", text: `Deleted index "${indexName}"` }], + }; + } catch (error) { + return { content: [{ type: "text", text: `Error deleting index: ${error}` }], isError: true }; + } + } + try { const indexName = args?.index_name as string; const client = await runner.getClient(indexName); From 0592040b7dd9e8cccbb253278b84f3705ca6bce6 Mon Sep 17 00:00:00 2001 From: Rich Hankins Date: Sun, 1 Feb 2026 16:28:02 -0800 Subject: [PATCH 04/23] Add delete_index Tool Agent-Id: agent-8cf675a5-fcdf-402a-a3a1-f3fa2a8c3f1b Linked-Note-Id: 18ee4796-4931-48c2-80e4-e85d51fe2ccd --- src/clients/mcp-server.ts | 80 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/src/clients/mcp-server.ts b/src/clients/mcp-server.ts index 547dfdc..f6a97c5 100644 --- a/src/clients/mcp-server.ts +++ b/src/clients/mcp-server.ts @@ -376,6 +376,86 @@ export async function createMCPServer( } } + // Handle index_repo separately (uses 'name' not 'index_name') + if (name === "index_repo") { + const indexName = args?.name as string; + const sourceType = args?.source_type as string; + + if (!indexName) { + return { content: [{ type: "text", text: "Error: name is required" }], isError: true }; + } + if (!sourceType) { + return { content: [{ type: "text", text: "Error: source_type is required" }], isError: true }; + } + + try { + let source: Source; + let sourceDesc: string; + + if (sourceType === "github") { + const owner = args?.owner as string; + const repo = args?.repo as string; + if (!owner || !repo) { + return { content: [{ type: "text", text: "Error: github requires owner and repo" }], isError: true }; + } + const { GitHubSource } = await import("../sources/github.js"); + source = new GitHubSource({ owner, repo, ref: (args?.ref as string) || "HEAD" }); + sourceDesc = `github://${owner}/${repo}`; + } else if (sourceType === "gitlab") { + const projectId = args?.project_id as string; + if (!projectId) { + return { content: [{ type: "text", text: "Error: gitlab requires project_id" }], isError: true }; + } + const { GitLabSource } = await import("../sources/gitlab.js"); + source = new GitLabSource({ projectId, ref: (args?.ref as string) || "HEAD" }); + sourceDesc = `gitlab://${projectId}`; + } else if (sourceType === "bitbucket") { + const workspace = args?.workspace as string; + const repo = args?.repo as string; + if (!workspace || !repo) { + return { content: [{ type: "text", text: "Error: bitbucket requires workspace and repo" }], isError: true }; + } + const { BitBucketSource } = await import("../sources/bitbucket.js"); + source = new BitBucketSource({ workspace, repo, ref: (args?.ref as string) || "HEAD" }); + sourceDesc = `bitbucket://${workspace}/${repo}`; + } else if (sourceType === "website") { + const url = args?.url as string; + if (!url) { + return { content: [{ type: "text", text: "Error: website requires url" }], isError: true }; + } + const { WebsiteSource } = await import("../sources/website.js"); + source = new WebsiteSource({ url }); + sourceDesc = `website://${url}`; + } else { + return { content: [{ type: "text", text: `Error: Unknown source_type: ${sourceType}` }], isError: true }; + } + + // Run indexer - need IndexStore for this + const { Indexer } = await import("../core/indexer.js"); + const indexer = new Indexer(); + + // Check if store supports write operations + if (!('save' in config.store)) { + return { content: [{ type: "text", text: "Error: Store does not support write operations (index_repo requires IndexStore)" }], isError: true }; + } + + const result = await indexer.index(source, config.store as IndexStore, indexName); + + // Refresh runner state + await runner.refreshIndexList(); + runner.invalidateClient(indexName); + + return { + content: [{ + type: "text", + text: `Created index "${indexName}" from ${sourceDesc}\n- Type: ${result.type}\n- Files indexed: ${result.filesIndexed}\n- Duration: ${result.duration}ms` + }], + }; + } catch (error) { + return { content: [{ type: "text", text: `Error indexing: ${error}` }], isError: true }; + } + } + try { const indexName = args?.index_name as string; const client = await runner.getClient(indexName); From 2ea4cdfd322112284e196329e3b0eda8fec4d226 Mon Sep 17 00:00:00 2001 From: Rich Hankins Date: Sun, 1 Feb 2026 16:28:36 -0800 Subject: [PATCH 05/23] Add index_repo tool to MCP server This tool allows creating/updating indexes dynamically from: - GitHub repositories (requires owner and repo) - GitLab repositories (requires project_id) - BitBucket repositories (requires workspace and repo) - Websites (requires url) Features: - Validates required parameters for each source type - Checks if store supports write operations - Refreshes runner state after indexing - Invalidates cached client for updated indexes - Returns success message with stats (type, filesIndexed, duration) Agent-Id: agent-4dd9b0b4-3ecf-4a2a-850a-49fd55aba0b5 --- src/clients/mcp-server.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/clients/mcp-server.ts b/src/clients/mcp-server.ts index f6a97c5..78816f4 100644 --- a/src/clients/mcp-server.ts +++ b/src/clients/mcp-server.ts @@ -38,6 +38,7 @@ import { ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; import type { IndexStoreReader, IndexStore } from "../stores/types.js"; +import type { Source } from "../sources/types.js"; import { MultiIndexRunner } from "./multi-index-runner.js"; import { SEARCH_DESCRIPTION, From 35f06b12bc09e3c2ef22237cb13da4143c818986 Mon Sep 17 00:00:00 2001 From: Rich Hankins Date: Sun, 1 Feb 2026 17:04:21 -0800 Subject: [PATCH 06/23] Allow MCP server to start with zero indexes Remove the empty-index check from the CLI so the MCP server can start even when no indexes exist. This enables the dynamic indexing workflow where users can call list_indexes (empty) then index_repo to create indexes on-demand. --- src/bin/cmd-mcp.ts | 21 ++++++--------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/src/bin/cmd-mcp.ts b/src/bin/cmd-mcp.ts index f9dbbdc..81479ba 100644 --- a/src/bin/cmd-mcp.ts +++ b/src/bin/cmd-mcp.ts @@ -21,7 +21,7 @@ const stdioCommand = new Command("stdio") const indexSpecs: string[] | undefined = options.index; let store; - let indexNames: string[]; + let indexNames: string[] | undefined; if (indexSpecs && indexSpecs.length > 0) { // Parse index specs and create composite store @@ -31,13 +31,9 @@ const stdioCommand = new Command("stdio") } else { // No --index: use default store, list all indexes store = new FilesystemStore(); - indexNames = await store.list(); - if (indexNames.length === 0) { - console.error("Error: No indexes found."); - console.error("The MCP server requires at least one index to operate."); - console.error("Run 'ctxc index --help' to see how to create an index."); - process.exit(1); - } + // Dynamic indexing: server can start with zero indexes + // Use list_indexes to see available indexes, index_repo to create new ones + indexNames = undefined; } // Start MCP server (writes to stdout, reads from stdin) @@ -84,13 +80,8 @@ const httpCommand = new Command("http") } else { // No --index: use default store, serve all store = new FilesystemStore(); - const availableIndexes = await store.list(); - if (availableIndexes.length === 0) { - console.error("Error: No indexes found."); - console.error("The MCP server requires at least one index to operate."); - console.error("Run 'ctxc index --help' to see how to create an index."); - process.exit(1); - } + // Dynamic indexing: server can start with zero indexes + // Use list_indexes to see available indexes, index_repo to create new ones indexNames = undefined; } From 94d91f131d66fd0c6f12bcb80a55b142bcc0ceff Mon Sep 17 00:00:00 2001 From: Rich Hankins Date: Thu, 5 Feb 2026 16:38:08 -0800 Subject: [PATCH 07/23] Fix index_repo conditional and remove stale enum from MCP tools Agent-Id: agent-9c6981c4-45dd-4c45-bec4-386d78c4bd30 --- src/clients/mcp-server.ts | 56 +++++++++++++++++++-------------------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/src/clients/mcp-server.ts b/src/clients/mcp-server.ts index 78816f4..d583ecb 100644 --- a/src/clients/mcp-server.ts +++ b/src/clients/mcp-server.ts @@ -103,7 +103,6 @@ export async function createMCPServer( searchOnly: config.searchOnly, }); - const { indexNames, indexes } = runner; const searchOnly = !runner.hasFileOperations(); // Create MCP server @@ -151,6 +150,32 @@ export async function createMCPServer( }, }, { + name: "search", + description: searchDescription, + inputSchema: { + type: "object", + properties: { + index_name: { + type: "string", + description: "Name of the index to search.", + }, + query: { + type: "string", + description: "Natural language description of what you're looking for.", + }, + maxChars: { + type: "number", + description: "Maximum characters in response (optional).", + }, + }, + required: ["index_name", "query"], + }, + }, + ]; + + // Add index_repo if store supports write operations + if ('save' in config.store) { + tools.push({ name: "index_repo", description: "Create or update an index from a repository. This may take 30+ seconds for large repos. The index will be available for search, list_files, and read_file after creation.", inputSchema: { @@ -192,31 +217,8 @@ export async function createMCPServer( }, required: ["name", "source_type"], }, - }, - { - name: "search", - description: searchDescription, - inputSchema: { - type: "object", - properties: { - index_name: { - type: "string", - description: "Name of the index to search.", - enum: indexNames, - }, - query: { - type: "string", - description: "Natural language description of what you're looking for.", - }, - maxChars: { - type: "number", - description: "Maximum characters in response (optional).", - }, - }, - required: ["index_name", "query"], - }, - }, - ]; + }); + } // Add delete_index if store supports it if ('delete' in config.store) { @@ -248,7 +250,6 @@ export async function createMCPServer( index_name: { type: "string", description: "Name of the index.", - enum: indexNames, }, directory: { type: "string", @@ -279,7 +280,6 @@ export async function createMCPServer( index_name: { type: "string", description: "Name of the index.", - enum: indexNames, }, path: { type: "string", From 4316cd4ffafc4bdc23c58bbe9788dd4f56671e39 Mon Sep 17 00:00:00 2001 From: Rich Hankins Date: Fri, 6 Feb 2026 12:16:37 -0800 Subject: [PATCH 08/23] Implement LayeredStore class combining local and remote indexes Agent-Id: agent-49ab3e98-e947-4ed1-8796-9454c317bd15 --- src/stores/index.ts | 1 + src/stores/layered-store.test.ts | 205 +++++++++++++++++++++++++++++++ src/stores/layered-store.ts | 124 +++++++++++++++++++ 3 files changed, 330 insertions(+) create mode 100644 src/stores/layered-store.test.ts create mode 100644 src/stores/layered-store.ts diff --git a/src/stores/index.ts b/src/stores/index.ts index 85b992b..0bbee15 100644 --- a/src/stores/index.ts +++ b/src/stores/index.ts @@ -10,6 +10,7 @@ export type { MemoryStoreConfig } from "./memory.js"; export { S3Store } from "./s3.js"; export type { S3StoreConfig } from "./s3.js"; export { CompositeStoreReader } from "./composite.js"; +export { LayeredStore } from "./layered-store.js"; export { parseIndexSpec, parseIndexSpecs } from "./index-spec.js"; export type { IndexSpec } from "./index-spec.js"; diff --git a/src/stores/layered-store.test.ts b/src/stores/layered-store.test.ts new file mode 100644 index 0000000..f48ab46 --- /dev/null +++ b/src/stores/layered-store.test.ts @@ -0,0 +1,205 @@ +/** + * Tests for LayeredStore + */ + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { promises as fs } from "node:fs"; +import { join } from "node:path"; +import { LayeredStore } from "./layered-store.js"; +import { FilesystemStore } from "./filesystem.js"; +import { CompositeStoreReader } from "./composite.js"; +import type { IndexState, IndexStateSearchOnly } from "../core/types.js"; + +const TEST_DIR = "/tmp/context-connectors-test-layered-store"; + +// Create a minimal mock IndexState for testing +function createMockState(id: string): { + full: IndexState; + search: IndexStateSearchOnly; +} { + const source = { + type: "github" as const, + config: { owner: "test-owner", repo: "test-repo" }, + syncedAt: new Date().toISOString(), + }; + return { + full: { + version: 1, + contextState: { + mode: "full" as const, + checkpointId: `checkpoint-${id}`, + addedBlobs: ["blob-1", "blob-2"], + deletedBlobs: [], + blobs: [ + ["blob-1", "src/file1.ts"], + ["blob-2", "src/file2.ts"], + ], + }, + source, + }, + search: { + version: 1, + contextState: { + mode: "search-only" as const, + checkpointId: `checkpoint-${id}`, + addedBlobs: ["blob-1", "blob-2"], + deletedBlobs: [], + }, + source, + }, + }; +} + +describe("LayeredStore", () => { + let localStore: FilesystemStore; + let remoteStore: CompositeStoreReader; + let layered: LayeredStore; + + beforeEach(async () => { + // Clean up test directory before each test + await fs.rm(TEST_DIR, { recursive: true, force: true }); + + // Create local store + localStore = new FilesystemStore({ basePath: TEST_DIR }); + + // Create empty remote store (no specs) + remoteStore = await CompositeStoreReader.fromSpecs([]); + + // Create layered store + layered = new LayeredStore(localStore, remoteStore); + }); + + afterEach(async () => { + // Clean up test directory after each test + await fs.rm(TEST_DIR, { recursive: true, force: true }); + }); + + describe("save", () => { + it("saves to local store only", async () => { + const { full, search } = createMockState("local"); + + await layered.save("test-index", full, search); + + // Verify it was saved to local + const loaded = await localStore.loadState("test-index"); + expect(loaded).not.toBeNull(); + expect(loaded!.contextState.checkpointId).toBe("checkpoint-local"); + }); + }); + + describe("loadState", () => { + it("loads from local store when available", async () => { + const { full, search } = createMockState("local"); + await layered.save("test-index", full, search); + + const loaded = await layered.loadState("test-index"); + + expect(loaded).not.toBeNull(); + expect(loaded!.contextState.checkpointId).toBe("checkpoint-local"); + }); + + it("returns null when index not found in either store", async () => { + const loaded = await layered.loadState("nonexistent"); + + expect(loaded).toBeNull(); + }); + }); + + describe("loadSearch", () => { + it("loads search state from local store when available", async () => { + const { full, search } = createMockState("local"); + await layered.save("test-index", full, search); + + const loaded = await layered.loadSearch("test-index"); + + expect(loaded).not.toBeNull(); + expect(loaded!.contextState.checkpointId).toBe("checkpoint-local"); + }); + + it("returns null when search index not found", async () => { + const loaded = await layered.loadSearch("nonexistent"); + + expect(loaded).toBeNull(); + }); + }); + + describe("list", () => { + it("lists indexes from local store", async () => { + const { full, search } = createMockState("1"); + await layered.save("index-1", full, search); + await layered.save("index-2", full, search); + + const list = await layered.list(); + + expect(list).toContain("index-1"); + expect(list).toContain("index-2"); + }); + + it("returns sorted list", async () => { + const { full, search } = createMockState("1"); + await layered.save("zebra", full, search); + await layered.save("apple", full, search); + await layered.save("banana", full, search); + + const list = await layered.list(); + + expect(list).toEqual(["apple", "banana", "zebra"]); + }); + }); + + describe("delete", () => { + it("deletes from local store", async () => { + const { full, search } = createMockState("local"); + await layered.save("test-index", full, search); + + await layered.delete("test-index"); + + const loaded = await layered.loadState("test-index"); + expect(loaded).toBeNull(); + }); + + it("throws error when trying to delete remote-only index", async () => { + // Create a remote store with an index + const remoteStoreWithIndex = await CompositeStoreReader.fromSpecs([ + { type: "name", displayName: "remote-index", value: "remote-index" }, + ]); + + const layeredWithRemote = new LayeredStore( + localStore, + remoteStoreWithIndex + ); + + // Try to delete the remote-only index + await expect( + layeredWithRemote.delete("remote-index") + ).rejects.toThrow( + "Cannot delete remote index 'remote-index'. Remote indexes are read-only." + ); + }); + + it("allows deletion of local index even if it exists in remote", async () => { + const { full, search } = createMockState("local"); + + // Save to local + await layered.save("test-index", full, search); + + // Create a remote store with the same index + const remoteStoreWithIndex = await CompositeStoreReader.fromSpecs([ + { type: "name", displayName: "test-index", value: "test-index" }, + ]); + + const layeredWithRemote = new LayeredStore( + localStore, + remoteStoreWithIndex + ); + + // Delete should succeed (deletes from local) + await expect(layeredWithRemote.delete("test-index")).resolves.toBeUndefined(); + + // Verify it's deleted from local but still in remote + const localLoaded = await localStore.loadState("test-index"); + expect(localLoaded).toBeNull(); + }); + }); +}); + diff --git a/src/stores/layered-store.ts b/src/stores/layered-store.ts new file mode 100644 index 0000000..f7fea18 --- /dev/null +++ b/src/stores/layered-store.ts @@ -0,0 +1,124 @@ +/** + * Layered Store - Combines writable local storage with read-only remote indexes. + * + * Provides a unified interface that: + * - Reads from local storage first, then falls back to remote + * - Writes only to local storage + * - Lists indexes from both sources (deduplicated) + * - Prevents deletion of remote-only indexes + * + * @module stores/layered-store + * + * @example + * ```typescript + * import { LayeredStore, FilesystemStore, CompositeStoreReader } from "@augmentcode/context-connectors/stores"; + * + * const localStore = new FilesystemStore(); + * const remoteStore = await CompositeStoreReader.fromSpecs(specs); + * const layered = new LayeredStore(localStore, remoteStore); + * + * // Read from local first, then remote + * const state = await layered.loadState("my-index"); + * + * // Write only to local + * await layered.save("my-index", fullState, searchState); + * + * // List all available indexes + * const keys = await layered.list(); + * ``` + */ + +import type { IndexStore, IndexStoreReader } from "./types.js"; +import type { IndexState, IndexStateSearchOnly } from "../core/types.js"; +import type { FilesystemStore } from "./filesystem.js"; +import type { CompositeStoreReader } from "./composite.js"; + +/** + * Layered store that combines a writable local store with a read-only remote store. + * + * Implements the IndexStore interface by delegating: + * - Read operations to local first, then remote + * - Write operations to local only + * - List operations to both (deduplicated) + */ +export class LayeredStore implements IndexStore { + private readonly localStore: FilesystemStore; + private readonly remoteStore: CompositeStoreReader; + + /** + * Create a new LayeredStore. + * + * @param localStore - Writable local filesystem store + * @param remoteStore - Read-only remote composite store + */ + constructor(localStore: FilesystemStore, remoteStore: CompositeStoreReader) { + this.localStore = localStore; + this.remoteStore = remoteStore; + } + + async loadState(key: string): Promise { + // Try local first + const localState = await this.localStore.loadState(key); + if (localState !== null) { + return localState; + } + + // Fall back to remote + return this.remoteStore.loadState(key); + } + + async loadSearch(key: string): Promise { + // Try local first + const localSearch = await this.localStore.loadSearch(key); + if (localSearch !== null) { + return localSearch; + } + + // Fall back to remote + return this.remoteStore.loadSearch(key); + } + + async save( + key: string, + fullState: IndexState, + searchState: IndexStateSearchOnly + ): Promise { + // Always save to local store only + await this.localStore.save(key, fullState, searchState); + } + + async delete(key: string): Promise { + // Get lists from both stores + const localList = await this.localStore.list(); + const remoteList = await this.remoteStore.list(); + + // Check if the key exists in local + const existsInLocal = localList.includes(key); + + // Check if the key exists in remote + const existsInRemote = remoteList.includes(key); + + // If it only exists in remote, throw an error + if (!existsInLocal && existsInRemote) { + throw new Error( + `Cannot delete remote index '${key}'. Remote indexes are read-only.` + ); + } + + // Delete from local (no-op if doesn't exist) + await this.localStore.delete(key); + } + + async list(): Promise { + // Get lists from both stores + const [localList, remoteList] = await Promise.all([ + this.localStore.list(), + this.remoteStore.list(), + ]); + + // Merge and deduplicate + const merged = new Set([...localList, ...remoteList]); + return Array.from(merged).sort(); + } +} + From e0fc0f7cc046515cb37f22b382752adbb337a3a0 Mon Sep 17 00:00:00 2001 From: Rich Hankins Date: Fri, 6 Feb 2026 12:17:10 -0800 Subject: [PATCH 09/23] Restore enum in Fixed Mode Agent-Id: agent-e9ca9165-d91d-4f53-a17c-da25a2a71927 Linked-Note-Id: 8e3113fe-0ce3-4374-81ed-e0536c4bbe35 --- src/clients/mcp-server.ts | 32 ++++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/src/clients/mcp-server.ts b/src/clients/mcp-server.ts index d583ecb..a84aa71 100644 --- a/src/clients/mcp-server.ts +++ b/src/clients/mcp-server.ts @@ -45,6 +45,7 @@ import { LIST_FILES_DESCRIPTION, READ_FILE_DESCRIPTION, withListIndexesReference, + withIndexList, } from "./tool-descriptions.js"; /** @@ -72,6 +73,13 @@ export interface MCPServerConfig { * @default "0.1.0" */ version?: string; + /** + * Agent-managed mode flag. + * When true: use withListIndexesReference (no enum in schemas) + * When false/undefined: use withIndexList (include enum in schemas) + * @default false + */ + agentManaged?: boolean; } /** @@ -132,10 +140,23 @@ export async function createMCPServer( }; }; - // Tool descriptions with reference to list_indexes - const searchDescription = withListIndexesReference(SEARCH_DESCRIPTION); - const listFilesDescription = withListIndexesReference(LIST_FILES_DESCRIPTION); - const readFileDescription = withListIndexesReference(READ_FILE_DESCRIPTION); + // Tool descriptions: use enum in fixed mode, reference in agent-managed mode + let searchDescription: string; + let listFilesDescription: string; + let readFileDescription: string; + + if (config.agentManaged) { + // Agent-managed mode: use reference to list_indexes (no enum) + searchDescription = withListIndexesReference(SEARCH_DESCRIPTION); + listFilesDescription = withListIndexesReference(LIST_FILES_DESCRIPTION); + readFileDescription = withListIndexesReference(READ_FILE_DESCRIPTION); + } else { + // Fixed mode: include enum with index list + const indexListStr = runner.getIndexListString(); + searchDescription = withIndexList(SEARCH_DESCRIPTION, indexListStr); + listFilesDescription = withIndexList(LIST_FILES_DESCRIPTION, indexListStr); + readFileDescription = withIndexList(READ_FILE_DESCRIPTION, indexListStr); + } // List available tools server.setRequestHandler(ListToolsRequestSchema, async () => { @@ -158,6 +179,7 @@ export async function createMCPServer( index_name: { type: "string", description: "Name of the index to search.", + ...(config.agentManaged ? {} : { enum: runner.indexes.map(i => i.name) }), }, query: { type: "string", @@ -250,6 +272,7 @@ export async function createMCPServer( index_name: { type: "string", description: "Name of the index.", + ...(config.agentManaged ? {} : { enum: runner.indexes.map(i => i.name) }), }, directory: { type: "string", @@ -280,6 +303,7 @@ export async function createMCPServer( index_name: { type: "string", description: "Name of the index.", + ...(config.agentManaged ? {} : { enum: runner.indexes.map(i => i.name) }), }, path: { type: "string", From 975991f5d9baaff642239d901eaae535884a3414 Mon Sep 17 00:00:00 2001 From: Rich Hankins Date: Fri, 6 Feb 2026 12:17:15 -0800 Subject: [PATCH 10/23] Add --agent-managed CLI Flag Agent-Id: agent-bb8e0202-acb6-4479-940b-8cb958aa88f1 Linked-Note-Id: 5039c3be-a4e3-4fed-a279-827b12453884 --- src/bin/cmd-mcp.ts | 37 +++++++++++++++++++++++++++++++------ 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/src/bin/cmd-mcp.ts b/src/bin/cmd-mcp.ts index 81479ba..00b969d 100644 --- a/src/bin/cmd-mcp.ts +++ b/src/bin/cmd-mcp.ts @@ -7,6 +7,7 @@ import { FilesystemStore } from "../stores/filesystem.js"; import { runMCPServer } from "../clients/mcp-server.js"; import { parseIndexSpecs } from "../stores/index-spec.js"; import { CompositeStoreReader } from "../stores/composite.js"; +import { LayeredStore } from "../stores/index.js"; // stdio subcommand (stdio-based MCP server for local clients like Claude Desktop) const stdioCommand = new Command("stdio") @@ -15,21 +16,31 @@ const stdioCommand = new Command("stdio") "-i, --index ", "Index spec(s): name, path:/path, or s3://bucket/key" ) + .option("--agent-managed", "Enable dynamic index management (index_repo, delete_index)") .option("--search-only", "Disable list_files/read_file tools (search only)") .action(async (options) => { try { const indexSpecs: string[] | undefined = options.index; + const agentManaged = options.agentManaged || !indexSpecs || indexSpecs.length === 0; let store; let indexNames: string[] | undefined; if (indexSpecs && indexSpecs.length > 0) { - // Parse index specs and create composite store + // Parse index specs const specs = parseIndexSpecs(indexSpecs); - store = await CompositeStoreReader.fromSpecs(specs); indexNames = specs.map((s) => s.displayName); + + if (agentManaged) { + // Agent-managed + remote indexes: use LayeredStore + const remoteStore = await CompositeStoreReader.fromSpecs(specs); + store = new LayeredStore(new FilesystemStore(), remoteStore); + } else { + // Fixed mode: use read-only CompositeStoreReader + store = await CompositeStoreReader.fromSpecs(specs); + } } else { - // No --index: use default store, list all indexes + // No --index: use FilesystemStore (agent-managed mode) store = new FilesystemStore(); // Dynamic indexing: server can start with zero indexes // Use list_indexes to see available indexes, index_repo to create new ones @@ -37,10 +48,12 @@ const stdioCommand = new Command("stdio") } // Start MCP server (writes to stdout, reads from stdin) + // agentManaged: true when no -i flags (dynamic mode), false when -i flags provided (fixed mode) await runMCPServer({ store, indexNames, searchOnly: options.searchOnly, + agentManaged: !indexSpecs || indexSpecs.length === 0, }); } catch (error) { // Write errors to stderr (stdout is for MCP protocol) @@ -56,6 +69,7 @@ const httpCommand = new Command("http") "-i, --index ", "Index spec(s): name, path:/path, or s3://bucket/key" ) + .option("--agent-managed", "Enable dynamic index management (index_repo, delete_index)") .option("--port ", "Port to listen on", "3000") .option("--host ", "Host to bind to", "localhost") .option("--cors ", "CORS origins (comma-separated, or '*' for any)") @@ -68,17 +82,26 @@ const httpCommand = new Command("http") .action(async (options) => { try { const indexSpecs: string[] | undefined = options.index; + const agentManaged = options.agentManaged || !indexSpecs || indexSpecs.length === 0; let store; let indexNames: string[] | undefined; if (indexSpecs && indexSpecs.length > 0) { - // Parse index specs and create composite store + // Parse index specs const specs = parseIndexSpecs(indexSpecs); - store = await CompositeStoreReader.fromSpecs(specs); indexNames = specs.map((s) => s.displayName); + + if (agentManaged) { + // Agent-managed + remote indexes: use LayeredStore + const remoteStore = await CompositeStoreReader.fromSpecs(specs); + store = new LayeredStore(new FilesystemStore(), remoteStore); + } else { + // Fixed mode: use read-only CompositeStoreReader + store = await CompositeStoreReader.fromSpecs(specs); + } } else { - // No --index: use default store, serve all + // No --index: use FilesystemStore (agent-managed mode) store = new FilesystemStore(); // Dynamic indexing: server can start with zero indexes // Use list_indexes to see available indexes, index_repo to create new ones @@ -98,11 +121,13 @@ const httpCommand = new Command("http") const apiKey = options.apiKey ?? process.env.MCP_API_KEY; // Start HTTP server + // agentManaged: true when no -i flags (dynamic mode), false when -i flags provided (fixed mode) const { runMCPHttpServer } = await import("../clients/mcp-http-server.js"); const server = await runMCPHttpServer({ store, indexNames, searchOnly: options.searchOnly, + agentManaged: !indexSpecs || indexSpecs.length === 0, port: parseInt(options.port, 10), host: options.host, cors, From bbed4fcb74ffd0cc2899527131cfd7bdee07166a Mon Sep 17 00:00:00 2001 From: Rich Hankins Date: Fri, 6 Feb 2026 12:23:04 -0800 Subject: [PATCH 11/23] Fix agentManaged config calculation in cmd-mcp.ts The agentManaged flag was correctly calculated at the top of both stdio and http handlers, but then recalculated incorrectly when passing to the MCP server config. This caused the --agent-managed flag to be ignored when combined with -i specs. Changes: - Line 56 (stdio): Use calculated agentManaged variable instead of recalculating - Line 130 (http): Use calculated agentManaged variable instead of recalculating This ensures --agent-managed -i s3://... correctly sets agentManaged: true in the server config. Agent-Id: agent-1fdc5885-eb2c-4a33-9451-323cf14bb29a --- src/bin/cmd-mcp.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/bin/cmd-mcp.ts b/src/bin/cmd-mcp.ts index 00b969d..0bb79d2 100644 --- a/src/bin/cmd-mcp.ts +++ b/src/bin/cmd-mcp.ts @@ -53,7 +53,7 @@ const stdioCommand = new Command("stdio") store, indexNames, searchOnly: options.searchOnly, - agentManaged: !indexSpecs || indexSpecs.length === 0, + agentManaged, }); } catch (error) { // Write errors to stderr (stdout is for MCP protocol) @@ -127,7 +127,7 @@ const httpCommand = new Command("http") store, indexNames, searchOnly: options.searchOnly, - agentManaged: !indexSpecs || indexSpecs.length === 0, + agentManaged, port: parseInt(options.port, 10), host: options.host, cors, From b7815153fb442070c4eb27c47875c8e23ae6f24d Mon Sep 17 00:00:00 2001 From: Rich Hankins Date: Fri, 6 Feb 2026 13:44:58 -0800 Subject: [PATCH 12/23] Replace agent-managed with discovery mode - Replace --agent-managed flag with --discovery in cmd-mcp.ts - Remove index_repo and delete_index tool handlers from mcp-server.ts - Delete layered-store.ts and layered-store.test.ts files - Keep list_indexes in both modes - Use withListIndexesReference() in discovery mode, withIndexList() in fixed mode - Update MCPServerConfig to use discovery flag instead of agentManaged - All tests pass, build succeeds Agent-Id: agent-84f44748-891f-477f-b3f3-c078cb5bd2c5 --- src/bin/cmd-mcp.ts | 49 ++++----- src/clients/mcp-server.ts | 202 ++------------------------------------ src/stores/index.ts | 1 - 3 files changed, 28 insertions(+), 224 deletions(-) diff --git a/src/bin/cmd-mcp.ts b/src/bin/cmd-mcp.ts index 0bb79d2..5a92cf0 100644 --- a/src/bin/cmd-mcp.ts +++ b/src/bin/cmd-mcp.ts @@ -7,7 +7,6 @@ import { FilesystemStore } from "../stores/filesystem.js"; import { runMCPServer } from "../clients/mcp-server.js"; import { parseIndexSpecs } from "../stores/index-spec.js"; import { CompositeStoreReader } from "../stores/composite.js"; -import { LayeredStore } from "../stores/index.js"; // stdio subcommand (stdio-based MCP server for local clients like Claude Desktop) const stdioCommand = new Command("stdio") @@ -16,12 +15,12 @@ const stdioCommand = new Command("stdio") "-i, --index ", "Index spec(s): name, path:/path, or s3://bucket/key" ) - .option("--agent-managed", "Enable dynamic index management (index_repo, delete_index)") + .option("--discovery", "Enable discovery mode (read-only, manage indexes via CLI)") .option("--search-only", "Disable list_files/read_file tools (search only)") .action(async (options) => { try { const indexSpecs: string[] | undefined = options.index; - const agentManaged = options.agentManaged || !indexSpecs || indexSpecs.length === 0; + const discovery = options.discovery || !indexSpecs || indexSpecs.length === 0; let store; let indexNames: string[] | undefined; @@ -31,29 +30,23 @@ const stdioCommand = new Command("stdio") const specs = parseIndexSpecs(indexSpecs); indexNames = specs.map((s) => s.displayName); - if (agentManaged) { - // Agent-managed + remote indexes: use LayeredStore - const remoteStore = await CompositeStoreReader.fromSpecs(specs); - store = new LayeredStore(new FilesystemStore(), remoteStore); - } else { - // Fixed mode: use read-only CompositeStoreReader - store = await CompositeStoreReader.fromSpecs(specs); - } + // Fixed mode: use read-only CompositeStoreReader + store = await CompositeStoreReader.fromSpecs(specs); } else { - // No --index: use FilesystemStore (agent-managed mode) + // No --index: use FilesystemStore (discovery mode) store = new FilesystemStore(); - // Dynamic indexing: server can start with zero indexes - // Use list_indexes to see available indexes, index_repo to create new ones + // Discovery mode: server can start with zero indexes + // Use list_indexes to see available indexes, manage via CLI indexNames = undefined; } // Start MCP server (writes to stdout, reads from stdin) - // agentManaged: true when no -i flags (dynamic mode), false when -i flags provided (fixed mode) + // discovery: true when no -i flags (discovery mode), false when -i flags provided (fixed mode) await runMCPServer({ store, indexNames, searchOnly: options.searchOnly, - agentManaged, + discovery, }); } catch (error) { // Write errors to stderr (stdout is for MCP protocol) @@ -69,7 +62,7 @@ const httpCommand = new Command("http") "-i, --index ", "Index spec(s): name, path:/path, or s3://bucket/key" ) - .option("--agent-managed", "Enable dynamic index management (index_repo, delete_index)") + .option("--discovery", "Enable discovery mode (read-only, manage indexes via CLI)") .option("--port ", "Port to listen on", "3000") .option("--host ", "Host to bind to", "localhost") .option("--cors ", "CORS origins (comma-separated, or '*' for any)") @@ -82,7 +75,7 @@ const httpCommand = new Command("http") .action(async (options) => { try { const indexSpecs: string[] | undefined = options.index; - const agentManaged = options.agentManaged || !indexSpecs || indexSpecs.length === 0; + const discovery = options.discovery || !indexSpecs || indexSpecs.length === 0; let store; let indexNames: string[] | undefined; @@ -92,19 +85,13 @@ const httpCommand = new Command("http") const specs = parseIndexSpecs(indexSpecs); indexNames = specs.map((s) => s.displayName); - if (agentManaged) { - // Agent-managed + remote indexes: use LayeredStore - const remoteStore = await CompositeStoreReader.fromSpecs(specs); - store = new LayeredStore(new FilesystemStore(), remoteStore); - } else { - // Fixed mode: use read-only CompositeStoreReader - store = await CompositeStoreReader.fromSpecs(specs); - } + // Fixed mode: use read-only CompositeStoreReader + store = await CompositeStoreReader.fromSpecs(specs); } else { - // No --index: use FilesystemStore (agent-managed mode) + // No --index: use FilesystemStore (discovery mode) store = new FilesystemStore(); - // Dynamic indexing: server can start with zero indexes - // Use list_indexes to see available indexes, index_repo to create new ones + // Discovery mode: server can start with zero indexes + // Use list_indexes to see available indexes, manage via CLI indexNames = undefined; } @@ -121,13 +108,13 @@ const httpCommand = new Command("http") const apiKey = options.apiKey ?? process.env.MCP_API_KEY; // Start HTTP server - // agentManaged: true when no -i flags (dynamic mode), false when -i flags provided (fixed mode) + // discovery: true when no -i flags (discovery mode), false when -i flags provided (fixed mode) const { runMCPHttpServer } = await import("../clients/mcp-http-server.js"); const server = await runMCPHttpServer({ store, indexNames, searchOnly: options.searchOnly, - agentManaged, + discovery, port: parseInt(options.port, 10), host: options.host, cors, diff --git a/src/clients/mcp-server.ts b/src/clients/mcp-server.ts index a84aa71..653f8dd 100644 --- a/src/clients/mcp-server.ts +++ b/src/clients/mcp-server.ts @@ -74,12 +74,12 @@ export interface MCPServerConfig { */ version?: string; /** - * Agent-managed mode flag. - * When true: use withListIndexesReference (no enum in schemas) - * When false/undefined: use withIndexList (include enum in schemas) + * Discovery mode flag. + * When true: use withListIndexesReference (no enum in schemas), no write tools + * When false/undefined: use withIndexList (include enum in schemas), write tools available * @default false */ - agentManaged?: boolean; + discovery?: boolean; } /** @@ -140,13 +140,13 @@ export async function createMCPServer( }; }; - // Tool descriptions: use enum in fixed mode, reference in agent-managed mode + // Tool descriptions: use enum in fixed mode, reference in discovery mode let searchDescription: string; let listFilesDescription: string; let readFileDescription: string; - if (config.agentManaged) { - // Agent-managed mode: use reference to list_indexes (no enum) + if (config.discovery) { + // Discovery mode: use reference to list_indexes (no enum) searchDescription = withListIndexesReference(SEARCH_DESCRIPTION); listFilesDescription = withListIndexesReference(LIST_FILES_DESCRIPTION); readFileDescription = withListIndexesReference(READ_FILE_DESCRIPTION); @@ -179,7 +179,7 @@ export async function createMCPServer( index_name: { type: "string", description: "Name of the index to search.", - ...(config.agentManaged ? {} : { enum: runner.indexes.map(i => i.name) }), + ...(config.discovery ? {} : { enum: runner.indexes.map(i => i.name) }), }, query: { type: "string", @@ -195,71 +195,6 @@ export async function createMCPServer( }, ]; - // Add index_repo if store supports write operations - if ('save' in config.store) { - tools.push({ - name: "index_repo", - description: "Create or update an index from a repository. This may take 30+ seconds for large repos. The index will be available for search, list_files, and read_file after creation.", - inputSchema: { - type: "object", - properties: { - name: { - type: "string", - description: "Unique name for this index (e.g., 'pytorch', 'my-lib')" - }, - source_type: { - type: "string", - enum: ["github", "gitlab", "bitbucket", "website"], - description: "Type of source to index" - }, - owner: { - type: "string", - description: "GitHub repository owner (required for github)" - }, - repo: { - type: "string", - description: "Repository name (required for github, bitbucket)" - }, - project_id: { - type: "string", - description: "GitLab project ID or path (required for gitlab)" - }, - workspace: { - type: "string", - description: "BitBucket workspace slug (required for bitbucket)" - }, - url: { - type: "string", - description: "URL to crawl (required for website)" - }, - ref: { - type: "string", - description: "Branch, tag, or commit (default: HEAD)" - }, - }, - required: ["name", "source_type"], - }, - }); - } - - // Add delete_index if store supports it - if ('delete' in config.store) { - tools.push({ - name: "delete_index", - description: "Delete an index by name. This removes the index from storage and it will no longer be available for search.", - inputSchema: { - type: "object", - properties: { - name: { - type: "string", - description: "Name of the index to delete", - }, - }, - required: ["name"], - }, - }); - } - // Only advertise file tools if not in search-only mode if (!searchOnly) { tools.push( @@ -272,7 +207,7 @@ export async function createMCPServer( index_name: { type: "string", description: "Name of the index.", - ...(config.agentManaged ? {} : { enum: runner.indexes.map(i => i.name) }), + ...(config.discovery ? {} : { enum: runner.indexes.map(i => i.name) }), }, directory: { type: "string", @@ -303,7 +238,7 @@ export async function createMCPServer( index_name: { type: "string", description: "Name of the index.", - ...(config.agentManaged ? {} : { enum: runner.indexes.map(i => i.name) }), + ...(config.discovery ? {} : { enum: runner.indexes.map(i => i.name) }), }, path: { type: "string", @@ -364,123 +299,6 @@ export async function createMCPServer( }; } - // Handle delete_index separately (uses 'name' not 'index_name') - if (name === "delete_index") { - const indexName = args?.name as string; - - if (!indexName) { - return { content: [{ type: "text", text: "Error: name is required" }], isError: true }; - } - - // Check if index exists - if (!runner.indexNames.includes(indexName)) { - return { - content: [{ type: "text", text: `Error: Index "${indexName}" not found` }], - isError: true, - }; - } - - // Check if store supports delete operations - if (!('delete' in config.store)) { - return { content: [{ type: "text", text: "Error: Store does not support delete operations" }], isError: true }; - } - - try { - // Delete from store - await (config.store as IndexStore).delete(indexName); - - // Refresh runner state - await runner.refreshIndexList(); - runner.invalidateClient(indexName); - - return { - content: [{ type: "text", text: `Deleted index "${indexName}"` }], - }; - } catch (error) { - return { content: [{ type: "text", text: `Error deleting index: ${error}` }], isError: true }; - } - } - - // Handle index_repo separately (uses 'name' not 'index_name') - if (name === "index_repo") { - const indexName = args?.name as string; - const sourceType = args?.source_type as string; - - if (!indexName) { - return { content: [{ type: "text", text: "Error: name is required" }], isError: true }; - } - if (!sourceType) { - return { content: [{ type: "text", text: "Error: source_type is required" }], isError: true }; - } - - try { - let source: Source; - let sourceDesc: string; - - if (sourceType === "github") { - const owner = args?.owner as string; - const repo = args?.repo as string; - if (!owner || !repo) { - return { content: [{ type: "text", text: "Error: github requires owner and repo" }], isError: true }; - } - const { GitHubSource } = await import("../sources/github.js"); - source = new GitHubSource({ owner, repo, ref: (args?.ref as string) || "HEAD" }); - sourceDesc = `github://${owner}/${repo}`; - } else if (sourceType === "gitlab") { - const projectId = args?.project_id as string; - if (!projectId) { - return { content: [{ type: "text", text: "Error: gitlab requires project_id" }], isError: true }; - } - const { GitLabSource } = await import("../sources/gitlab.js"); - source = new GitLabSource({ projectId, ref: (args?.ref as string) || "HEAD" }); - sourceDesc = `gitlab://${projectId}`; - } else if (sourceType === "bitbucket") { - const workspace = args?.workspace as string; - const repo = args?.repo as string; - if (!workspace || !repo) { - return { content: [{ type: "text", text: "Error: bitbucket requires workspace and repo" }], isError: true }; - } - const { BitBucketSource } = await import("../sources/bitbucket.js"); - source = new BitBucketSource({ workspace, repo, ref: (args?.ref as string) || "HEAD" }); - sourceDesc = `bitbucket://${workspace}/${repo}`; - } else if (sourceType === "website") { - const url = args?.url as string; - if (!url) { - return { content: [{ type: "text", text: "Error: website requires url" }], isError: true }; - } - const { WebsiteSource } = await import("../sources/website.js"); - source = new WebsiteSource({ url }); - sourceDesc = `website://${url}`; - } else { - return { content: [{ type: "text", text: `Error: Unknown source_type: ${sourceType}` }], isError: true }; - } - - // Run indexer - need IndexStore for this - const { Indexer } = await import("../core/indexer.js"); - const indexer = new Indexer(); - - // Check if store supports write operations - if (!('save' in config.store)) { - return { content: [{ type: "text", text: "Error: Store does not support write operations (index_repo requires IndexStore)" }], isError: true }; - } - - const result = await indexer.index(source, config.store as IndexStore, indexName); - - // Refresh runner state - await runner.refreshIndexList(); - runner.invalidateClient(indexName); - - return { - content: [{ - type: "text", - text: `Created index "${indexName}" from ${sourceDesc}\n- Type: ${result.type}\n- Files indexed: ${result.filesIndexed}\n- Duration: ${result.duration}ms` - }], - }; - } catch (error) { - return { content: [{ type: "text", text: `Error indexing: ${error}` }], isError: true }; - } - } - try { const indexName = args?.index_name as string; const client = await runner.getClient(indexName); diff --git a/src/stores/index.ts b/src/stores/index.ts index 0bbee15..85b992b 100644 --- a/src/stores/index.ts +++ b/src/stores/index.ts @@ -10,7 +10,6 @@ export type { MemoryStoreConfig } from "./memory.js"; export { S3Store } from "./s3.js"; export type { S3StoreConfig } from "./s3.js"; export { CompositeStoreReader } from "./composite.js"; -export { LayeredStore } from "./layered-store.js"; export { parseIndexSpec, parseIndexSpecs } from "./index-spec.js"; export type { IndexSpec } from "./index-spec.js"; From 44d75697d57a53ba551cf632307d7f7d5866eba2 Mon Sep 17 00:00:00 2001 From: Rich Hankins Date: Fri, 6 Feb 2026 13:45:04 -0800 Subject: [PATCH 13/23] Replace Agent-Managed with Discovery Mode Agent-Id: agent-84f44748-891f-477f-b3f3-c078cb5bd2c5 Linked-Note-Id: 75b599ae-9ec2-4535-b5d2-f83cc77a9d6b --- src/stores/layered-store.test.ts | 205 ------------------------------- src/stores/layered-store.ts | 124 ------------------- 2 files changed, 329 deletions(-) delete mode 100644 src/stores/layered-store.test.ts delete mode 100644 src/stores/layered-store.ts diff --git a/src/stores/layered-store.test.ts b/src/stores/layered-store.test.ts deleted file mode 100644 index f48ab46..0000000 --- a/src/stores/layered-store.test.ts +++ /dev/null @@ -1,205 +0,0 @@ -/** - * Tests for LayeredStore - */ - -import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { promises as fs } from "node:fs"; -import { join } from "node:path"; -import { LayeredStore } from "./layered-store.js"; -import { FilesystemStore } from "./filesystem.js"; -import { CompositeStoreReader } from "./composite.js"; -import type { IndexState, IndexStateSearchOnly } from "../core/types.js"; - -const TEST_DIR = "/tmp/context-connectors-test-layered-store"; - -// Create a minimal mock IndexState for testing -function createMockState(id: string): { - full: IndexState; - search: IndexStateSearchOnly; -} { - const source = { - type: "github" as const, - config: { owner: "test-owner", repo: "test-repo" }, - syncedAt: new Date().toISOString(), - }; - return { - full: { - version: 1, - contextState: { - mode: "full" as const, - checkpointId: `checkpoint-${id}`, - addedBlobs: ["blob-1", "blob-2"], - deletedBlobs: [], - blobs: [ - ["blob-1", "src/file1.ts"], - ["blob-2", "src/file2.ts"], - ], - }, - source, - }, - search: { - version: 1, - contextState: { - mode: "search-only" as const, - checkpointId: `checkpoint-${id}`, - addedBlobs: ["blob-1", "blob-2"], - deletedBlobs: [], - }, - source, - }, - }; -} - -describe("LayeredStore", () => { - let localStore: FilesystemStore; - let remoteStore: CompositeStoreReader; - let layered: LayeredStore; - - beforeEach(async () => { - // Clean up test directory before each test - await fs.rm(TEST_DIR, { recursive: true, force: true }); - - // Create local store - localStore = new FilesystemStore({ basePath: TEST_DIR }); - - // Create empty remote store (no specs) - remoteStore = await CompositeStoreReader.fromSpecs([]); - - // Create layered store - layered = new LayeredStore(localStore, remoteStore); - }); - - afterEach(async () => { - // Clean up test directory after each test - await fs.rm(TEST_DIR, { recursive: true, force: true }); - }); - - describe("save", () => { - it("saves to local store only", async () => { - const { full, search } = createMockState("local"); - - await layered.save("test-index", full, search); - - // Verify it was saved to local - const loaded = await localStore.loadState("test-index"); - expect(loaded).not.toBeNull(); - expect(loaded!.contextState.checkpointId).toBe("checkpoint-local"); - }); - }); - - describe("loadState", () => { - it("loads from local store when available", async () => { - const { full, search } = createMockState("local"); - await layered.save("test-index", full, search); - - const loaded = await layered.loadState("test-index"); - - expect(loaded).not.toBeNull(); - expect(loaded!.contextState.checkpointId).toBe("checkpoint-local"); - }); - - it("returns null when index not found in either store", async () => { - const loaded = await layered.loadState("nonexistent"); - - expect(loaded).toBeNull(); - }); - }); - - describe("loadSearch", () => { - it("loads search state from local store when available", async () => { - const { full, search } = createMockState("local"); - await layered.save("test-index", full, search); - - const loaded = await layered.loadSearch("test-index"); - - expect(loaded).not.toBeNull(); - expect(loaded!.contextState.checkpointId).toBe("checkpoint-local"); - }); - - it("returns null when search index not found", async () => { - const loaded = await layered.loadSearch("nonexistent"); - - expect(loaded).toBeNull(); - }); - }); - - describe("list", () => { - it("lists indexes from local store", async () => { - const { full, search } = createMockState("1"); - await layered.save("index-1", full, search); - await layered.save("index-2", full, search); - - const list = await layered.list(); - - expect(list).toContain("index-1"); - expect(list).toContain("index-2"); - }); - - it("returns sorted list", async () => { - const { full, search } = createMockState("1"); - await layered.save("zebra", full, search); - await layered.save("apple", full, search); - await layered.save("banana", full, search); - - const list = await layered.list(); - - expect(list).toEqual(["apple", "banana", "zebra"]); - }); - }); - - describe("delete", () => { - it("deletes from local store", async () => { - const { full, search } = createMockState("local"); - await layered.save("test-index", full, search); - - await layered.delete("test-index"); - - const loaded = await layered.loadState("test-index"); - expect(loaded).toBeNull(); - }); - - it("throws error when trying to delete remote-only index", async () => { - // Create a remote store with an index - const remoteStoreWithIndex = await CompositeStoreReader.fromSpecs([ - { type: "name", displayName: "remote-index", value: "remote-index" }, - ]); - - const layeredWithRemote = new LayeredStore( - localStore, - remoteStoreWithIndex - ); - - // Try to delete the remote-only index - await expect( - layeredWithRemote.delete("remote-index") - ).rejects.toThrow( - "Cannot delete remote index 'remote-index'. Remote indexes are read-only." - ); - }); - - it("allows deletion of local index even if it exists in remote", async () => { - const { full, search } = createMockState("local"); - - // Save to local - await layered.save("test-index", full, search); - - // Create a remote store with the same index - const remoteStoreWithIndex = await CompositeStoreReader.fromSpecs([ - { type: "name", displayName: "test-index", value: "test-index" }, - ]); - - const layeredWithRemote = new LayeredStore( - localStore, - remoteStoreWithIndex - ); - - // Delete should succeed (deletes from local) - await expect(layeredWithRemote.delete("test-index")).resolves.toBeUndefined(); - - // Verify it's deleted from local but still in remote - const localLoaded = await localStore.loadState("test-index"); - expect(localLoaded).toBeNull(); - }); - }); -}); - diff --git a/src/stores/layered-store.ts b/src/stores/layered-store.ts deleted file mode 100644 index f7fea18..0000000 --- a/src/stores/layered-store.ts +++ /dev/null @@ -1,124 +0,0 @@ -/** - * Layered Store - Combines writable local storage with read-only remote indexes. - * - * Provides a unified interface that: - * - Reads from local storage first, then falls back to remote - * - Writes only to local storage - * - Lists indexes from both sources (deduplicated) - * - Prevents deletion of remote-only indexes - * - * @module stores/layered-store - * - * @example - * ```typescript - * import { LayeredStore, FilesystemStore, CompositeStoreReader } from "@augmentcode/context-connectors/stores"; - * - * const localStore = new FilesystemStore(); - * const remoteStore = await CompositeStoreReader.fromSpecs(specs); - * const layered = new LayeredStore(localStore, remoteStore); - * - * // Read from local first, then remote - * const state = await layered.loadState("my-index"); - * - * // Write only to local - * await layered.save("my-index", fullState, searchState); - * - * // List all available indexes - * const keys = await layered.list(); - * ``` - */ - -import type { IndexStore, IndexStoreReader } from "./types.js"; -import type { IndexState, IndexStateSearchOnly } from "../core/types.js"; -import type { FilesystemStore } from "./filesystem.js"; -import type { CompositeStoreReader } from "./composite.js"; - -/** - * Layered store that combines a writable local store with a read-only remote store. - * - * Implements the IndexStore interface by delegating: - * - Read operations to local first, then remote - * - Write operations to local only - * - List operations to both (deduplicated) - */ -export class LayeredStore implements IndexStore { - private readonly localStore: FilesystemStore; - private readonly remoteStore: CompositeStoreReader; - - /** - * Create a new LayeredStore. - * - * @param localStore - Writable local filesystem store - * @param remoteStore - Read-only remote composite store - */ - constructor(localStore: FilesystemStore, remoteStore: CompositeStoreReader) { - this.localStore = localStore; - this.remoteStore = remoteStore; - } - - async loadState(key: string): Promise { - // Try local first - const localState = await this.localStore.loadState(key); - if (localState !== null) { - return localState; - } - - // Fall back to remote - return this.remoteStore.loadState(key); - } - - async loadSearch(key: string): Promise { - // Try local first - const localSearch = await this.localStore.loadSearch(key); - if (localSearch !== null) { - return localSearch; - } - - // Fall back to remote - return this.remoteStore.loadSearch(key); - } - - async save( - key: string, - fullState: IndexState, - searchState: IndexStateSearchOnly - ): Promise { - // Always save to local store only - await this.localStore.save(key, fullState, searchState); - } - - async delete(key: string): Promise { - // Get lists from both stores - const localList = await this.localStore.list(); - const remoteList = await this.remoteStore.list(); - - // Check if the key exists in local - const existsInLocal = localList.includes(key); - - // Check if the key exists in remote - const existsInRemote = remoteList.includes(key); - - // If it only exists in remote, throw an error - if (!existsInLocal && existsInRemote) { - throw new Error( - `Cannot delete remote index '${key}'. Remote indexes are read-only.` - ); - } - - // Delete from local (no-op if doesn't exist) - await this.localStore.delete(key); - } - - async list(): Promise { - // Get lists from both stores - const [localList, remoteList] = await Promise.all([ - this.localStore.list(), - this.remoteStore.list(), - ]); - - // Merge and deduplicate - const merged = new Set([...localList, ...remoteList]); - return Array.from(merged).sort(); - } -} - From dcf23860e25b0fb96958747c4db47e4b4121512b Mon Sep 17 00:00:00 2001 From: Rich Hankins Date: Fri, 6 Feb 2026 13:58:06 -0800 Subject: [PATCH 14/23] Add support for -i flags in Discovery mode with ReadOnlyLayeredStore - Create ReadOnlyLayeredStore to merge local (FilesystemStore) and remote (CompositeStoreReader) indexes - Update cmd-mcp.ts to use ReadOnlyLayeredStore when both --discovery and -i flags are present - Add comprehensive tests for ReadOnlyLayeredStore (12 tests) - Export ReadOnlyLayeredStore from stores/index.ts Behavior: - ctxc mcp stdio --discovery -i s3://bucket/shared-index: Merges local + remote indexes - ctxc mcp stdio -i pytorch -i react: Fixed mode (no discovery) - ctxc mcp stdio --discovery: Discovery mode with local indexes only All tests pass (175 passed, 24 skipped) Agent-Id: agent-5834efcf-335b-415a-b8a9-19ed489133ed --- src/bin/cmd-mcp.ts | 49 ++++-- src/stores/index.ts | 1 + src/stores/read-only-layered-store.test.ts | 188 +++++++++++++++++++++ src/stores/read-only-layered-store.ts | 82 +++++++++ 4 files changed, 302 insertions(+), 18 deletions(-) create mode 100644 src/stores/read-only-layered-store.test.ts create mode 100644 src/stores/read-only-layered-store.ts diff --git a/src/bin/cmd-mcp.ts b/src/bin/cmd-mcp.ts index 5a92cf0..729b8e1 100644 --- a/src/bin/cmd-mcp.ts +++ b/src/bin/cmd-mcp.ts @@ -7,6 +7,7 @@ import { FilesystemStore } from "../stores/filesystem.js"; import { runMCPServer } from "../clients/mcp-server.js"; import { parseIndexSpecs } from "../stores/index-spec.js"; import { CompositeStoreReader } from "../stores/composite.js"; +import { ReadOnlyLayeredStore } from "../stores/read-only-layered-store.js"; // stdio subcommand (stdio-based MCP server for local clients like Claude Desktop) const stdioCommand = new Command("stdio") @@ -20,28 +21,34 @@ const stdioCommand = new Command("stdio") .action(async (options) => { try { const indexSpecs: string[] | undefined = options.index; - const discovery = options.discovery || !indexSpecs || indexSpecs.length === 0; + const discoveryFlag = options.discovery; let store; let indexNames: string[] | undefined; + let discovery: boolean; - if (indexSpecs && indexSpecs.length > 0) { - // Parse index specs + if (discoveryFlag && indexSpecs && indexSpecs.length > 0) { + // Discovery mode WITH remote indexes: merge local + remote const specs = parseIndexSpecs(indexSpecs); - indexNames = specs.map((s) => s.displayName); - + const remoteStore = await CompositeStoreReader.fromSpecs(specs); + const localStore = new FilesystemStore(); + store = new ReadOnlyLayeredStore(localStore, remoteStore); + indexNames = undefined; // Discovery mode: no fixed list + discovery = true; + } else if (indexSpecs && indexSpecs.length > 0) { // Fixed mode: use read-only CompositeStoreReader + const specs = parseIndexSpecs(indexSpecs); + indexNames = specs.map((s) => s.displayName); store = await CompositeStoreReader.fromSpecs(specs); + discovery = false; } else { - // No --index: use FilesystemStore (discovery mode) + // Discovery mode only: use FilesystemStore store = new FilesystemStore(); - // Discovery mode: server can start with zero indexes - // Use list_indexes to see available indexes, manage via CLI indexNames = undefined; + discovery = true; } // Start MCP server (writes to stdout, reads from stdin) - // discovery: true when no -i flags (discovery mode), false when -i flags provided (fixed mode) await runMCPServer({ store, indexNames, @@ -75,24 +82,31 @@ const httpCommand = new Command("http") .action(async (options) => { try { const indexSpecs: string[] | undefined = options.index; - const discovery = options.discovery || !indexSpecs || indexSpecs.length === 0; + const discoveryFlag = options.discovery; let store; let indexNames: string[] | undefined; + let discovery: boolean; - if (indexSpecs && indexSpecs.length > 0) { - // Parse index specs + if (discoveryFlag && indexSpecs && indexSpecs.length > 0) { + // Discovery mode WITH remote indexes: merge local + remote const specs = parseIndexSpecs(indexSpecs); - indexNames = specs.map((s) => s.displayName); - + const remoteStore = await CompositeStoreReader.fromSpecs(specs); + const localStore = new FilesystemStore(); + store = new ReadOnlyLayeredStore(localStore, remoteStore); + indexNames = undefined; // Discovery mode: no fixed list + discovery = true; + } else if (indexSpecs && indexSpecs.length > 0) { // Fixed mode: use read-only CompositeStoreReader + const specs = parseIndexSpecs(indexSpecs); + indexNames = specs.map((s) => s.displayName); store = await CompositeStoreReader.fromSpecs(specs); + discovery = false; } else { - // No --index: use FilesystemStore (discovery mode) + // Discovery mode only: use FilesystemStore store = new FilesystemStore(); - // Discovery mode: server can start with zero indexes - // Use list_indexes to see available indexes, manage via CLI indexNames = undefined; + discovery = true; } // Parse CORS option @@ -108,7 +122,6 @@ const httpCommand = new Command("http") const apiKey = options.apiKey ?? process.env.MCP_API_KEY; // Start HTTP server - // discovery: true when no -i flags (discovery mode), false when -i flags provided (fixed mode) const { runMCPHttpServer } = await import("../clients/mcp-http-server.js"); const server = await runMCPHttpServer({ store, diff --git a/src/stores/index.ts b/src/stores/index.ts index 85b992b..1879b59 100644 --- a/src/stores/index.ts +++ b/src/stores/index.ts @@ -10,6 +10,7 @@ export type { MemoryStoreConfig } from "./memory.js"; export { S3Store } from "./s3.js"; export type { S3StoreConfig } from "./s3.js"; export { CompositeStoreReader } from "./composite.js"; +export { ReadOnlyLayeredStore } from "./read-only-layered-store.js"; export { parseIndexSpec, parseIndexSpecs } from "./index-spec.js"; export type { IndexSpec } from "./index-spec.js"; diff --git a/src/stores/read-only-layered-store.test.ts b/src/stores/read-only-layered-store.test.ts new file mode 100644 index 0000000..4ebec61 --- /dev/null +++ b/src/stores/read-only-layered-store.test.ts @@ -0,0 +1,188 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { ReadOnlyLayeredStore } from "./read-only-layered-store.js"; +import { MemoryStore } from "./memory.js"; +import type { IndexState, IndexStateSearchOnly } from "../core/types.js"; + +function createMockState(): { full: IndexState; search: IndexStateSearchOnly } { + const source = { + type: "github" as const, + config: { owner: "test-owner", repo: "test-repo" }, + syncedAt: new Date().toISOString(), + }; + return { + full: { + version: 1, + contextState: { + mode: "full" as const, + checkpointId: "test-checkpoint-123", + addedBlobs: ["blob-1"], + deletedBlobs: [], + blobs: [["blob-1", "src/file1.ts"]], + }, + source, + }, + search: { + version: 1, + contextState: { + mode: "search-only" as const, + checkpointId: "test-checkpoint-123", + addedBlobs: ["blob-1"], + deletedBlobs: [], + }, + source, + }, + }; +} + +describe("ReadOnlyLayeredStore", () => { + let primary: MemoryStore; + let remote: MemoryStore; + let layered: ReadOnlyLayeredStore; + + beforeEach(() => { + primary = new MemoryStore(); + remote = new MemoryStore(); + layered = new ReadOnlyLayeredStore(primary, remote); + }); + + describe("list", () => { + it("returns merged and deduplicated list from both stores", async () => { + const { full, search } = createMockState(); + + // Add to primary + await primary.save("local-a", full, search); + await primary.save("local-b", full, search); + + // Add to remote + await remote.save("remote-a", full, search); + await remote.save("remote-b", full, search); + + const list = await layered.list(); + + expect(list).toContain("local-a"); + expect(list).toContain("local-b"); + expect(list).toContain("remote-a"); + expect(list).toContain("remote-b"); + expect(list.length).toBe(4); + }); + + it("deduplicates when same index exists in both stores", async () => { + const { full, search } = createMockState(); + + // Add same index to both + await primary.save("shared", full, search); + await remote.save("shared", full, search); + + const list = await layered.list(); + + expect(list).toContain("shared"); + expect(list.length).toBe(1); + }); + + it("returns sorted list", async () => { + const { full, search } = createMockState(); + + await primary.save("zebra", full, search); + await primary.save("apple", full, search); + await remote.save("mango", full, search); + + const list = await layered.list(); + + expect(list).toEqual(["apple", "mango", "zebra"]); + }); + + it("returns empty list when both stores are empty", async () => { + const list = await layered.list(); + expect(list).toEqual([]); + }); + }); + + describe("loadSearch", () => { + it("returns from primary if exists", async () => { + const { full, search } = createMockState(); + await primary.save("test", full, search); + + const result = await layered.loadSearch("test"); + + expect(result).toEqual(search); + }); + + it("falls back to remote if not in primary", async () => { + const { full, search } = createMockState(); + await remote.save("test", full, search); + + const result = await layered.loadSearch("test"); + + expect(result).toEqual(search); + }); + + it("prefers primary over remote when both exist", async () => { + const { full, search } = createMockState(); + const primarySearch = { + ...search, + contextState: { ...search.contextState, checkpointId: "primary" }, + }; + const remoteSearch = { + ...search, + contextState: { ...search.contextState, checkpointId: "remote" }, + }; + + await primary.save("test", full, primarySearch); + await remote.save("test", full, remoteSearch); + + const result = await layered.loadSearch("test"); + + expect(result?.contextState.checkpointId).toBe("primary"); + }); + + it("returns null if not found in either store", async () => { + const result = await layered.loadSearch("nonexistent"); + expect(result).toBeNull(); + }); + }); + + describe("loadState", () => { + it("returns from primary if exists", async () => { + const { full, search } = createMockState(); + await primary.save("test", full, search); + + const result = await layered.loadState("test"); + + expect(result).toEqual(full); + }); + + it("falls back to remote if not in primary", async () => { + const { full, search } = createMockState(); + await remote.save("test", full, search); + + const result = await layered.loadState("test"); + + expect(result).toEqual(full); + }); + + it("prefers primary over remote when both exist", async () => { + const { full, search } = createMockState(); + const primaryFull = { + ...full, + contextState: { ...full.contextState, checkpointId: "primary" }, + }; + const remoteFull = { + ...full, + contextState: { ...full.contextState, checkpointId: "remote" }, + }; + + await primary.save("test", primaryFull, search); + await remote.save("test", remoteFull, search); + + const result = await layered.loadState("test"); + + expect(result?.contextState.checkpointId).toBe("primary"); + }); + + it("returns null if not found in either store", async () => { + const result = await layered.loadState("nonexistent"); + expect(result).toBeNull(); + }); + }); +}); + diff --git a/src/stores/read-only-layered-store.ts b/src/stores/read-only-layered-store.ts new file mode 100644 index 0000000..8bbf162 --- /dev/null +++ b/src/stores/read-only-layered-store.ts @@ -0,0 +1,82 @@ +/** + * Read-only layered store that merges a primary store with a remote store. + * + * Used in discovery mode with remote indexes: + * - Primary store: FilesystemStore (local indexes) + * - Remote store: CompositeStoreReader (remote indexes) + * + * Behavior: + * - list(): Merge both lists, deduplicated, sorted + * - loadState(name): Try primary first, then remote + * - loadSearch(name): Try primary first, then remote + * - No save/delete methods (read-only) + * + * @module stores/read-only-layered-store + */ + +import type { IndexStoreReader } from "./types.js"; +import type { IndexState, IndexStateSearchOnly } from "../core/types.js"; + +/** + * Read-only layered store that combines a primary and remote store. + * + * Useful for discovery mode where users can: + * - Manage local indexes via CLI (stored in FilesystemStore) + * - Reference remote indexes via -i flags (stored in CompositeStoreReader) + * + * @example + * ```typescript + * const primary = new FilesystemStore(); + * const remote = await CompositeStoreReader.fromSpecs([ + * { type: "s3", value: "s3://bucket/shared-index", displayName: "shared" } + * ]); + * const layered = new ReadOnlyLayeredStore(primary, remote); + * + * // Lists both local and remote indexes + * const allIndexes = await layered.list(); + * + * // Tries primary first, then remote + * const state = await layered.loadSearch("my-index"); + * ``` + */ +export class ReadOnlyLayeredStore implements IndexStoreReader { + constructor( + private primary: IndexStoreReader, + private remote: IndexStoreReader + ) {} + + async loadState(key: string): Promise { + // Try primary first + const primaryState = await this.primary.loadState(key); + if (primaryState !== null) { + return primaryState; + } + // Fall back to remote + return this.remote.loadState(key); + } + + async loadSearch(key: string): Promise { + // Try primary first + const primarySearch = await this.primary.loadSearch(key); + if (primarySearch !== null) { + return primarySearch; + } + // Fall back to remote + return this.remote.loadSearch(key); + } + + async list(): Promise { + // Get both lists + const [primaryList, remoteList] = await Promise.all([ + this.primary.list(), + this.remote.list(), + ]); + + // Merge and deduplicate + const merged = new Set([...primaryList, ...remoteList]); + + // Return sorted array + return Array.from(merged).sort(); + } +} + From c932344b49ec7b2170d8d368bdfaeb3d7813dfc8 Mon Sep 17 00:00:00 2001 From: Rich Hankins Date: Fri, 6 Feb 2026 14:39:31 -0800 Subject: [PATCH 15/23] Fix type errors from main branch merge The main branch code attempted to pass clientUserAgent to DirectContext.create/import and AugmentLanguageModel, but these APIs don't accept that parameter. Changes: - Removed clientUserAgent from DirectContext.create() call in indexer.ts - Removed clientUserAgent from DirectContext.import() calls in indexer.ts and search-client.ts - Removed clientUserAgent from AugmentLanguageModel config in cli-agent.ts The clientUserAgent is still stored in SearchClient and Indexer for future use, but is not passed to APIs that don't support it. --- src/clients/cli-agent.ts | 1 - src/clients/search-client.ts | 1 - src/core/indexer.ts | 2 -- 3 files changed, 4 deletions(-) diff --git a/src/clients/cli-agent.ts b/src/clients/cli-agent.ts index 7029e02..b609598 100644 --- a/src/clients/cli-agent.ts +++ b/src/clients/cli-agent.ts @@ -192,7 +192,6 @@ async function loadModel( return new AugmentLanguageModel(modelName, { apiKey: credentials.apiKey, apiUrl: credentials.apiUrl, - clientUserAgent, }) as unknown as LanguageModel; } default: diff --git a/src/clients/search-client.ts b/src/clients/search-client.ts index a088415..e21f124 100644 --- a/src/clients/search-client.ts +++ b/src/clients/search-client.ts @@ -169,7 +169,6 @@ export class SearchClient { this.context = await DirectContext.import(this.state.contextState, { apiKey: this.apiKey, apiUrl: this.apiUrl, - clientUserAgent: this.clientUserAgent, }); } diff --git a/src/core/indexer.ts b/src/core/indexer.ts index 61b2606..89632f4 100644 --- a/src/core/indexer.ts +++ b/src/core/indexer.ts @@ -198,7 +198,6 @@ export class Indexer { const context = await DirectContext.create({ apiKey: this.apiKey, apiUrl: this.apiUrl, - clientUserAgent: this.clientUserAgent, }); // Fetch all files from source @@ -262,7 +261,6 @@ export class Indexer { const context = await DirectContext.import(previousState.contextState, { apiKey: this.apiKey, apiUrl: this.apiUrl, - clientUserAgent: this.clientUserAgent, }); // Remove deleted files From ba5752758c17be4185f97f1628197be13ec17f7f Mon Sep 17 00:00:00 2001 From: Rich Hankins Date: Fri, 6 Feb 2026 15:00:34 -0800 Subject: [PATCH 16/23] Fix PR review comments: allow empty indexes, add error handling, fix docs Agent-Id: agent-8f86dfa1-8342-4d24-9127-87412acf9740 --- src/clients/mcp-server.ts | 31 +++++++++++++++++++------------ src/clients/multi-index-runner.ts | 5 +---- 2 files changed, 20 insertions(+), 16 deletions(-) diff --git a/src/clients/mcp-server.ts b/src/clients/mcp-server.ts index 3e93a08..f78d4a2 100644 --- a/src/clients/mcp-server.ts +++ b/src/clients/mcp-server.ts @@ -75,8 +75,8 @@ export interface MCPServerConfig { version?: string; /** * Discovery mode flag. - * When true: use withListIndexesReference (no enum in schemas), no write tools - * When false/undefined: use withIndexList (include enum in schemas), write tools available + * When true: use withListIndexesReference (no enum in schemas), dynamic index list + * When false/undefined: use withIndexList (include enum in schemas), static index list * @default false */ discovery?: boolean; @@ -296,19 +296,26 @@ export async function createMCPServer( // Handle list_indexes separately (no index_name required) if (name === "list_indexes") { - await runner.refreshIndexList(); - const { indexes } = runner; - if (indexes.length === 0) { + try { + await runner.refreshIndexList(); + const { indexes } = runner; + if (indexes.length === 0) { + return { + content: [{ type: "text", text: "No indexes available. Use `ctxc index` CLI to create one." }], + }; + } + const lines = indexes.map((i) => + `- ${i.name} (${i.type}://${i.identifier}) - synced ${i.syncedAt}` + ); + return { + content: [{ type: "text", text: `Available indexes:\n${lines.join("\n")}` }], + }; + } catch (error) { return { - content: [{ type: "text", text: "No indexes available. Use index_repo to create one." }], + content: [{ type: "text", text: `Error listing indexes: ${error}` }], + isError: true, }; } - const lines = indexes.map((i) => - `- ${i.name} (${i.type}://${i.identifier}) - synced ${i.syncedAt}` - ); - return { - content: [{ type: "text", text: `Available indexes:\n${lines.join("\n")}` }], - }; } try { diff --git a/src/clients/multi-index-runner.ts b/src/clients/multi-index-runner.ts index 99346c1..824d454 100644 --- a/src/clients/multi-index-runner.ts +++ b/src/clients/multi-index-runner.ts @@ -152,10 +152,7 @@ export class MultiIndexRunner { } } - if (validIndexNames.length === 0) { - throw new Error("No valid indexes available (all indexes failed to load)"); - } - + // Allow empty - server can start with no indexes and user can add via CLI return new MultiIndexRunner(store, validIndexNames, indexes, searchOnly, config.clientUserAgent); } From 2c37861df527abe5442519a2831bc656f0cdfaa7 Mon Sep 17 00:00:00 2001 From: Rich Hankins Date: Fri, 6 Feb 2026 15:20:47 -0800 Subject: [PATCH 17/23] Fix refreshIndexList() to respect fixed mode allowlist In fixed mode (when indexNames are specified via CLI), calling list_indexes would trigger refreshIndexList() which overwrote this.indexNames with ALL indexes from the store, bypassing the original allowlist. Changes: - Add readonly originalIndexNames field to MultiIndexRunner to store the original allowlist in fixed mode - In constructor, save the original indexNames if provided - In refreshIndexList(), filter results to only include indexes in the original allowlist when in fixed mode This ensures that in fixed mode (-i pytorch -i react), list_indexes only returns the originally specified indexes, even if other indexes exist in the store. In discovery mode (--discovery), all indexes are returned as before. Tests added to verify: - Discovery mode includes all indexes from store - Fixed mode respects the original allowlist - Fixed mode handles missing indexes gracefully Agent-Id: agent-cf0760a8-47e4-4742-a697-7de4bf2367e6 --- src/clients/multi-index-runner.test.ts | 140 +++++++++++++++++++++++-- src/clients/multi-index-runner.ts | 21 +++- 2 files changed, 150 insertions(+), 11 deletions(-) diff --git a/src/clients/multi-index-runner.test.ts b/src/clients/multi-index-runner.test.ts index 8bea9a0..87ee8b4 100644 --- a/src/clients/multi-index-runner.test.ts +++ b/src/clients/multi-index-runner.test.ts @@ -1,18 +1,17 @@ /** - * Tests for createSourceFromState + * Tests for MultiIndexRunner * - * These tests verify that createSourceFromState correctly uses resolvedRef + * Tests for createSourceFromState verify that it correctly uses resolvedRef * from state metadata when creating source instances. * - * We mock GitHub and Website sources to capture what config gets passed - * to the constructors, without needing API credentials. - * - * Since all VCS sources (GitHub, GitLab, BitBucket) use the same getRef() logic, - * we only test GitHub as the representative case. + * Tests for MultiIndexRunner.refreshIndexList verify that it respects + * the fixed mode allowlist when refreshing the index list. */ import { describe, it, expect, vi, beforeEach } from "vitest"; -import type { IndexStateSearchOnly, SourceMetadata } from "../core/types.js"; +import type { IndexStateSearchOnly, SourceMetadata, IndexState } from "../core/types.js"; +import type { IndexStoreReader } from "../stores/types.js"; +import { MultiIndexRunner } from "./multi-index-runner.js"; // Mock only the sources we actually test vi.mock("../sources/github.js", () => ({ @@ -113,3 +112,128 @@ describe("createSourceFromState", () => { ); }); }); + +describe("MultiIndexRunner.refreshIndexList", () => { + // Helper to create a mock store with multiple indexes + const createMockStoreWithIndexes = (indexNames: string[]): IndexStoreReader => { + const mockState = (name: string): IndexState => ({ + version: 1, + contextState: { version: 1 } as any, + source: { + type: "github", + config: { owner: "test", repo: name }, + syncedAt: new Date().toISOString(), + }, + }); + + return { + loadState: vi.fn(), + loadSearch: vi.fn().mockImplementation((name: string) => { + if (indexNames.includes(name)) { + return Promise.resolve(mockState(name)); + } + return Promise.resolve(null); + }), + list: vi.fn().mockResolvedValue(indexNames), + }; + }; + + it("in discovery mode, refreshIndexList includes all indexes from store", async () => { + const store = createMockStoreWithIndexes(["pytorch", "react", "docs"]); + + const runner = await MultiIndexRunner.create({ + store, + // No indexNames = discovery mode + }); + + expect(runner.indexNames).toEqual(["pytorch", "react", "docs"]); + + // Simulate store gaining a new index + (store.list as any).mockResolvedValue(["pytorch", "react", "docs", "vue"]); + (store.loadSearch as any).mockImplementation((name: string) => { + if (["pytorch", "react", "docs", "vue"].includes(name)) { + return Promise.resolve({ + version: 1, + contextState: { version: 1 } as any, + source: { + type: "github", + config: { owner: "test", repo: name }, + syncedAt: new Date().toISOString(), + }, + }); + } + return Promise.resolve(null); + }); + + await runner.refreshIndexList(); + expect(runner.indexNames).toEqual(["pytorch", "react", "docs", "vue"]); + }); + + it("in fixed mode, refreshIndexList respects the original allowlist", async () => { + const store = createMockStoreWithIndexes(["pytorch", "react", "docs"]); + + // Create runner in fixed mode with only pytorch and react + const runner = await MultiIndexRunner.create({ + store, + indexNames: ["pytorch", "react"], + }); + + expect(runner.indexNames).toEqual(["pytorch", "react"]); + + // Simulate store gaining a new index (docs is already there, vue is new) + (store.list as any).mockResolvedValue(["pytorch", "react", "docs", "vue"]); + (store.loadSearch as any).mockImplementation((name: string) => { + if (["pytorch", "react", "docs", "vue"].includes(name)) { + return Promise.resolve({ + version: 1, + contextState: { version: 1 } as any, + source: { + type: "github", + config: { owner: "test", repo: name }, + syncedAt: new Date().toISOString(), + }, + }); + } + return Promise.resolve(null); + }); + + await runner.refreshIndexList(); + + // Should still only include pytorch and react, not docs or vue + expect(runner.indexNames).toEqual(["pytorch", "react"]); + }); + + it("in fixed mode, refreshIndexList handles missing indexes gracefully", async () => { + const store = createMockStoreWithIndexes(["pytorch", "react"]); + + // Create runner in fixed mode with pytorch, react, and a missing index + const runner = await MultiIndexRunner.create({ + store, + indexNames: ["pytorch", "react"], + }); + + expect(runner.indexNames).toEqual(["pytorch", "react"]); + + // Simulate pytorch being deleted from store + (store.list as any).mockResolvedValue(["react"]); + (store.loadSearch as any).mockImplementation((name: string) => { + if (name === "react") { + return Promise.resolve({ + version: 1, + contextState: { version: 1 } as any, + source: { + type: "github", + config: { owner: "test", repo: name }, + syncedAt: new Date().toISOString(), + }, + }); + } + return Promise.resolve(null); + }); + + await runner.refreshIndexList(); + + // Should only include react (pytorch is gone from store) + expect(runner.indexNames).toEqual(["react"]); + }); +}); diff --git a/src/clients/multi-index-runner.ts b/src/clients/multi-index-runner.ts index 824d454..033d203 100644 --- a/src/clients/multi-index-runner.ts +++ b/src/clients/multi-index-runner.ts @@ -92,6 +92,7 @@ export class MultiIndexRunner { private readonly searchOnly: boolean; private clientUserAgent?: string; private readonly clientCache = new Map(); + private readonly originalIndexNames: string[] | undefined; /** Available index names */ indexNames: string[]; @@ -104,13 +105,15 @@ export class MultiIndexRunner { indexNames: string[], indexes: IndexInfo[], searchOnly: boolean, - clientUserAgent?: string + clientUserAgent?: string, + originalIndexNames?: string[] ) { this.store = store; this.indexNames = indexNames; this.indexes = indexes; this.searchOnly = searchOnly; this.clientUserAgent = clientUserAgent; + this.originalIndexNames = originalIndexNames; } /** @@ -124,6 +127,9 @@ export class MultiIndexRunner { const allIndexNames = await store.list(); const indexNames = config.indexNames ?? allIndexNames; + // In fixed mode, save the original allowlist for later filtering + const originalIndexNames = config.indexNames ? [...config.indexNames] : undefined; + // Validate requested indexes exist const missingIndexes = indexNames.filter((n) => !allIndexNames.includes(n)); if (missingIndexes.length > 0) { @@ -153,7 +159,7 @@ export class MultiIndexRunner { } // Allow empty - server can start with no indexes and user can add via CLI - return new MultiIndexRunner(store, validIndexNames, indexes, searchOnly, config.clientUserAgent); + return new MultiIndexRunner(store, validIndexNames, indexes, searchOnly, config.clientUserAgent, originalIndexNames); } /** @@ -200,13 +206,22 @@ export class MultiIndexRunner { /** * Refresh the list of available indexes from the store. * Call after adding or removing indexes. + * + * In fixed mode (when originalIndexNames is set), only includes indexes + * from the original allowlist, even if other indexes exist in the store. */ async refreshIndexList(): Promise { const allIndexNames = await this.store.list(); + + // In fixed mode, filter to only the original allowlist + const indexNamesToLoad = this.originalIndexNames + ? allIndexNames.filter(name => this.originalIndexNames!.includes(name)) + : allIndexNames; + const newIndexes: IndexInfo[] = []; const newIndexNames: string[] = []; - for (const name of allIndexNames) { + for (const name of indexNamesToLoad) { try { const state = await this.store.loadSearch(name); if (state) { From c9eed1421755fbc6ad8dd216e429cc8e4fcadee2 Mon Sep 17 00:00:00 2001 From: Rich Hankins Date: Fri, 6 Feb 2026 15:26:28 -0800 Subject: [PATCH 18/23] Make fixed mode's index list completely static MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In fixed mode, refreshIndexList() is now a no-op. The index list is completely static and never changes, even if indexes are deleted from the store. This fixes the issue where: 1. Fixed mode starts with [-i pytorch -i react] 2. User deletes pytorch from the store 3. Agent calls list_indexes → triggers refreshIndexList() 4. Previously: indexNames would shrink to ["react"] 5. Now: indexNames remains ["pytorch", "react"] In discovery mode, refreshIndexList() still refreshes from the store to pick up new/deleted indexes. Changes: - src/clients/multi-index-runner.ts: Make refreshIndexList() a no-op in fixed mode - src/clients/multi-index-runner.test.ts: Update test to expect static list in fixed mode All tests pass, build succeeds. Agent-Id: agent-0ac871dd-5181-4ed9-84d1-446aa4b19c81 --- src/clients/multi-index-runner.test.ts | 8 ++++---- src/clients/multi-index-runner.ts | 19 +++++++++++-------- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/src/clients/multi-index-runner.test.ts b/src/clients/multi-index-runner.test.ts index 87ee8b4..369b45a 100644 --- a/src/clients/multi-index-runner.test.ts +++ b/src/clients/multi-index-runner.test.ts @@ -203,10 +203,10 @@ describe("MultiIndexRunner.refreshIndexList", () => { expect(runner.indexNames).toEqual(["pytorch", "react"]); }); - it("in fixed mode, refreshIndexList handles missing indexes gracefully", async () => { + it("in fixed mode, refreshIndexList is a no-op even when indexes are deleted", async () => { const store = createMockStoreWithIndexes(["pytorch", "react"]); - // Create runner in fixed mode with pytorch, react, and a missing index + // Create runner in fixed mode with pytorch and react const runner = await MultiIndexRunner.create({ store, indexNames: ["pytorch", "react"], @@ -233,7 +233,7 @@ describe("MultiIndexRunner.refreshIndexList", () => { await runner.refreshIndexList(); - // Should only include react (pytorch is gone from store) - expect(runner.indexNames).toEqual(["react"]); + // In fixed mode, the list should remain unchanged even though pytorch was deleted + expect(runner.indexNames).toEqual(["pytorch", "react"]); }); }); diff --git a/src/clients/multi-index-runner.ts b/src/clients/multi-index-runner.ts index 033d203..4061a1a 100644 --- a/src/clients/multi-index-runner.ts +++ b/src/clients/multi-index-runner.ts @@ -207,21 +207,24 @@ export class MultiIndexRunner { * Refresh the list of available indexes from the store. * Call after adding or removing indexes. * - * In fixed mode (when originalIndexNames is set), only includes indexes - * from the original allowlist, even if other indexes exist in the store. + * In fixed mode (when originalIndexNames is set), this is a no-op. + * The list is completely static and never changes. + * + * In discovery mode, refreshes from the store to pick up new/deleted indexes. */ async refreshIndexList(): Promise { - const allIndexNames = await this.store.list(); + // In fixed mode, the list is static - don't refresh + if (this.originalIndexNames) { + return; + } - // In fixed mode, filter to only the original allowlist - const indexNamesToLoad = this.originalIndexNames - ? allIndexNames.filter(name => this.originalIndexNames!.includes(name)) - : allIndexNames; + // Discovery mode: refresh from store + const allIndexNames = await this.store.list(); const newIndexes: IndexInfo[] = []; const newIndexNames: string[] = []; - for (const name of indexNamesToLoad) { + for (const name of allIndexNames) { try { const state = await this.store.loadSearch(name); if (state) { From c9c4ea16222887ae7e85b474f4c007dc590d5a75 Mon Sep 17 00:00:00 2001 From: Rich Hankins Date: Fri, 6 Feb 2026 16:36:15 -0800 Subject: [PATCH 19/23] fix: restore clientUserAgent in DirectContext.import() Agent-Id: agent-42c9e35e-86c9-4eea-abf2-253ad24da923 --- src/clients/search-client.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/clients/search-client.ts b/src/clients/search-client.ts index e21f124..a088415 100644 --- a/src/clients/search-client.ts +++ b/src/clients/search-client.ts @@ -169,6 +169,7 @@ export class SearchClient { this.context = await DirectContext.import(this.state.contextState, { apiKey: this.apiKey, apiUrl: this.apiUrl, + clientUserAgent: this.clientUserAgent, }); } From 5c35f78b5a3defdcfcf1bce7ef87dbb05723cdeb Mon Sep 17 00:00:00 2001 From: Rich Hankins Date: Fri, 6 Feb 2026 16:41:02 -0800 Subject: [PATCH 20/23] fix: prune stale clientCache entries in refreshIndexList() Agent-Id: agent-81feb144-5dc6-4a1f-9cd1-7e74cd26e3d1 --- src/clients/multi-index-runner.test.ts | 48 ++++++++++++++++++++++++++ src/clients/multi-index-runner.ts | 7 ++++ 2 files changed, 55 insertions(+) diff --git a/src/clients/multi-index-runner.test.ts b/src/clients/multi-index-runner.test.ts index 369b45a..c4f8340 100644 --- a/src/clients/multi-index-runner.test.ts +++ b/src/clients/multi-index-runner.test.ts @@ -236,4 +236,52 @@ describe("MultiIndexRunner.refreshIndexList", () => { // In fixed mode, the list should remain unchanged even though pytorch was deleted expect(runner.indexNames).toEqual(["pytorch", "react"]); }); + + it("in discovery mode, refreshIndexList prunes stale cache entries", async () => { + const store = createMockStoreWithIndexes(["pytorch", "react", "docs"]); + + const runner = await MultiIndexRunner.create({ + store, + // No indexNames = discovery mode + }); + + expect(runner.indexNames).toEqual(["pytorch", "react", "docs"]); + + // Manually populate cache with mock clients (avoid initialization) + const mockClient = { initialized: true } as any; + (runner as any).clientCache.set("pytorch", mockClient); + (runner as any).clientCache.set("react", mockClient); + (runner as any).clientCache.set("docs", mockClient); + + // Verify cache has all three entries + expect((runner as any).clientCache.size).toBe(3); + + // Simulate store losing the "docs" index + (store.list as any).mockResolvedValue(["pytorch", "react"]); + (store.loadSearch as any).mockImplementation((name: string) => { + if (["pytorch", "react"].includes(name)) { + return Promise.resolve({ + version: 1, + contextState: { version: 1 } as any, + source: { + type: "github", + config: { owner: "test", repo: name }, + syncedAt: new Date().toISOString(), + }, + }); + } + return Promise.resolve(null); + }); + + await runner.refreshIndexList(); + + // Index list should be updated + expect(runner.indexNames).toEqual(["pytorch", "react"]); + + // Cache should be pruned - only pytorch and react remain + expect((runner as any).clientCache.size).toBe(2); + expect((runner as any).clientCache.has("pytorch")).toBe(true); + expect((runner as any).clientCache.has("react")).toBe(true); + expect((runner as any).clientCache.has("docs")).toBe(false); + }); }); diff --git a/src/clients/multi-index-runner.ts b/src/clients/multi-index-runner.ts index 4061a1a..0fbb0db 100644 --- a/src/clients/multi-index-runner.ts +++ b/src/clients/multi-index-runner.ts @@ -244,6 +244,13 @@ export class MultiIndexRunner { this.indexNames = newIndexNames; this.indexes = newIndexes; + + // Prune stale cache entries for removed indexes + for (const cachedName of this.clientCache.keys()) { + if (!newIndexNames.includes(cachedName)) { + this.clientCache.delete(cachedName); + } + } } /** From 3220fcfc9739e3c897558d3550ee183299d42b24 Mon Sep 17 00:00:00 2001 From: Rich Hankins Date: Fri, 6 Feb 2026 16:50:50 -0800 Subject: [PATCH 21/23] fix: restore fail-fast behavior for non-discovery mode (issues #1, #7) Agent-Id: agent-02660ea0-b190-4940-802f-62ed81e5543d --- src/bin/cmd-mcp.ts | 26 ++++++++++++++++++++------ src/clients/mcp-server.ts | 3 ++- src/clients/multi-index-runner.ts | 14 +++++++++++++- 3 files changed, 35 insertions(+), 8 deletions(-) diff --git a/src/bin/cmd-mcp.ts b/src/bin/cmd-mcp.ts index 729b8e1..0cdc91e 100644 --- a/src/bin/cmd-mcp.ts +++ b/src/bin/cmd-mcp.ts @@ -42,10 +42,17 @@ const stdioCommand = new Command("stdio") store = await CompositeStoreReader.fromSpecs(specs); discovery = false; } else { - // Discovery mode only: use FilesystemStore + // No flags: restore original behavior - fail fast if no indexes store = new FilesystemStore(); - indexNames = undefined; - discovery = true; + const availableIndexes = await store.list(); + if (availableIndexes.length === 0) { + console.error("Error: No indexes found."); + console.error("The MCP server requires at least one index to operate."); + console.error("Run 'ctxc index --help' to see how to create an index."); + process.exit(1); + } + indexNames = availableIndexes; + discovery = false; } // Start MCP server (writes to stdout, reads from stdin) @@ -103,10 +110,17 @@ const httpCommand = new Command("http") store = await CompositeStoreReader.fromSpecs(specs); discovery = false; } else { - // Discovery mode only: use FilesystemStore + // No flags: restore original behavior - fail fast if no indexes store = new FilesystemStore(); - indexNames = undefined; - discovery = true; + const availableIndexes = await store.list(); + if (availableIndexes.length === 0) { + console.error("Error: No indexes found."); + console.error("The MCP server requires at least one index to operate."); + console.error("Run 'ctxc index --help' to see how to create an index."); + process.exit(1); + } + indexNames = availableIndexes; + discovery = false; } // Parse CORS option diff --git a/src/clients/mcp-server.ts b/src/clients/mcp-server.ts index f78d4a2..ac9d32a 100644 --- a/src/clients/mcp-server.ts +++ b/src/clients/mcp-server.ts @@ -106,12 +106,13 @@ export async function createMCPServer( // Create shared runner for multi-index operations // Build User-Agent for analytics tracking const clientUserAgent = buildClientUserAgent("mcp"); - + const runner = await MultiIndexRunner.create({ store: config.store, indexNames: config.indexNames, searchOnly: config.searchOnly, clientUserAgent, + discovery: config.discovery, }); const { indexNames, indexes } = runner; diff --git a/src/clients/multi-index-runner.ts b/src/clients/multi-index-runner.ts index 0fbb0db..c367421 100644 --- a/src/clients/multi-index-runner.ts +++ b/src/clients/multi-index-runner.ts @@ -41,6 +41,13 @@ export interface MultiIndexRunnerConfig { * When provided, this is passed to SearchClient instances for API requests. */ clientUserAgent?: string; + /** + * Discovery mode flag. + * When true: allow empty index list (user can add indexes later via CLI) + * When false: require at least one valid index to be loaded + * @default false + */ + discovery?: boolean; } /** @@ -122,6 +129,7 @@ export class MultiIndexRunner { static async create(config: MultiIndexRunnerConfig): Promise { const store = config.store; const searchOnly = config.searchOnly ?? false; + const discovery = config.discovery ?? false; // Discover available indexes const allIndexNames = await store.list(); @@ -158,7 +166,11 @@ export class MultiIndexRunner { } } - // Allow empty - server can start with no indexes and user can add via CLI + // In fixed mode (non-discovery), require at least one valid index + if (!discovery && validIndexNames.length === 0) { + throw new Error("No valid indexes loaded. Fixed mode requires at least one index."); + } + return new MultiIndexRunner(store, validIndexNames, indexes, searchOnly, config.clientUserAgent, originalIndexNames); } From 733ad367fe5fb73d1d15a8dff4d135cee4e2c5f9 Mon Sep 17 00:00:00 2001 From: Rich Hankins Date: Fri, 6 Feb 2026 16:55:07 -0800 Subject: [PATCH 22/23] test: add unit tests for list_indexes and discovery vs fixed mode Agent-Id: agent-5dbe4a24-0e78-4075-b85c-d6ff632c88c7 --- src/clients/mcp-server.test.ts | 210 +++++++++++++++++++++++++++++++++ 1 file changed, 210 insertions(+) diff --git a/src/clients/mcp-server.test.ts b/src/clients/mcp-server.test.ts index 96c9e9c..e71152e 100644 --- a/src/clients/mcp-server.test.ts +++ b/src/clients/mcp-server.test.ts @@ -6,6 +6,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import type { IndexState } from "../core/types.js"; import type { IndexStoreReader } from "../stores/types.js"; import type { Source } from "../sources/types.js"; +import { + ListToolsRequestSchema, + CallToolRequestSchema, +} from "@modelcontextprotocol/sdk/types.js"; // Try to import SDK-dependent modules let createMCPServer: typeof import("./mcp-server.js").createMCPServer; @@ -134,3 +138,209 @@ describe.skipIf(sdkLoadError !== null)("MCP Server Unit Tests", () => { }); }); +// Tests for list_indexes tool and discovery vs fixed mode +describe.skipIf(sdkLoadError !== null || !hasApiCredentials)( + "list_indexes tool and discovery mode", + () => { + describe("list_indexes tool", () => { + it("returns available indexes with metadata", async () => { + const mockState = createMockState(); + const store = createMockStore(mockState); + + const server = await createMCPServer({ + store, + indexNames: ["test-key"], + }); + + // Get the ListToolsRequestSchema handler + const listToolsHandler = (server as any).requestHandlers.get( + ListToolsRequestSchema + ); + expect(listToolsHandler).toBeDefined(); + + // Call the handler to get tools + const result = await listToolsHandler(); + const listIndexesTool = result.tools.find( + (t: any) => t.name === "list_indexes" + ); + expect(listIndexesTool).toBeDefined(); + expect(listIndexesTool.description).toContain("available indexes"); + }); + + it("returns 'No indexes available' message when empty in discovery mode", async () => { + const store = createMockStore(null); + // Mock store.list() to return empty array for discovery mode + store.list = vi.fn().mockResolvedValue([]); + + const server = await createMCPServer({ + store, + indexNames: [], + discovery: true, + }); + + // Get the CallToolRequestSchema handler + const callToolHandler = (server as any).requestHandlers.get( + CallToolRequestSchema + ); + expect(callToolHandler).toBeDefined(); + + // Call list_indexes + const result = await callToolHandler({ + params: { + name: "list_indexes", + arguments: {}, + }, + }); + + expect(result.content[0].text).toContain("No indexes available"); + }); + + it("handles errors gracefully and returns isError: true", async () => { + const store = createMockStore(createMockState()); + // Mock store.list() to throw an error + store.list = vi.fn().mockRejectedValue(new Error("Store error")); + + const server = await createMCPServer({ + store, + indexNames: ["test-key"], + discovery: true, + }); + + // Get the CallToolRequestSchema handler + const callToolHandler = (server as any).requestHandlers.get( + CallToolRequestSchema + ); + + // Call list_indexes + const result = await callToolHandler({ + params: { + name: "list_indexes", + arguments: {}, + }, + }); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("Error listing indexes"); + }); + }); + + describe("discovery vs fixed mode", () => { + it("fixed mode includes enum in tool schemas for index_name", async () => { + const mockState = createMockState(); + const store = createMockStore(mockState); + + const server = await createMCPServer({ + store, + indexNames: ["test-key"], + discovery: false, // Fixed mode + }); + + // Get the ListToolsRequestSchema handler + const listToolsHandler = (server as any).requestHandlers.get( + ListToolsRequestSchema + ); + const result = await listToolsHandler(); + + // Check search tool has enum + const searchTool = result.tools.find((t: any) => t.name === "search"); + expect(searchTool.inputSchema.properties.index_name.enum).toBeDefined(); + expect(searchTool.inputSchema.properties.index_name.enum).toContain( + "test-key" + ); + + // Check list_files tool has enum (if present) + const listFilesTool = result.tools.find( + (t: any) => t.name === "list_files" + ); + if (listFilesTool) { + expect(listFilesTool.inputSchema.properties.index_name.enum).toBeDefined(); + expect(listFilesTool.inputSchema.properties.index_name.enum).toContain( + "test-key" + ); + } + + // Check read_file tool has enum (if present) + const readFileTool = result.tools.find((t: any) => t.name === "read_file"); + if (readFileTool) { + expect(readFileTool.inputSchema.properties.index_name.enum).toBeDefined(); + expect(readFileTool.inputSchema.properties.index_name.enum).toContain( + "test-key" + ); + } + }); + + it("discovery mode does NOT include enum in tool schemas", async () => { + const mockState = createMockState(); + const store = createMockStore(mockState); + + const server = await createMCPServer({ + store, + indexNames: ["test-key"], + discovery: true, // Discovery mode + }); + + // Get the ListToolsRequestSchema handler + const listToolsHandler = (server as any).requestHandlers.get( + ListToolsRequestSchema + ); + const result = await listToolsHandler(); + + // Check search tool does NOT have enum + const searchTool = result.tools.find((t: any) => t.name === "search"); + expect(searchTool.inputSchema.properties.index_name.enum).toBeUndefined(); + + // Check list_files tool does NOT have enum (if present) + const listFilesTool = result.tools.find( + (t: any) => t.name === "list_files" + ); + if (listFilesTool) { + expect(listFilesTool.inputSchema.properties.index_name.enum).toBeUndefined(); + } + + // Check read_file tool does NOT have enum (if present) + const readFileTool = result.tools.find((t: any) => t.name === "read_file"); + if (readFileTool) { + expect(readFileTool.inputSchema.properties.index_name.enum).toBeUndefined(); + } + }); + + it("list_indexes tool is available in both fixed and discovery modes", async () => { + const mockState = createMockState(); + const store = createMockStore(mockState); + + // Test fixed mode + const fixedServer = await createMCPServer({ + store, + indexNames: ["test-key"], + discovery: false, + }); + + const fixedListToolsHandler = (fixedServer as any).requestHandlers.get( + ListToolsRequestSchema + ); + const fixedResult = await fixedListToolsHandler(); + const fixedListIndexesTool = fixedResult.tools.find( + (t: any) => t.name === "list_indexes" + ); + expect(fixedListIndexesTool).toBeDefined(); + + // Test discovery mode + const discoveryServer = await createMCPServer({ + store, + indexNames: ["test-key"], + discovery: true, + }); + + const discoveryListToolsHandler = (discoveryServer as any).requestHandlers.get( + ListToolsRequestSchema + ); + const discoveryResult = await discoveryListToolsHandler(); + const discoveryListIndexesTool = discoveryResult.tools.find( + (t: any) => t.name === "list_indexes" + ); + expect(discoveryListIndexesTool).toBeDefined(); + }); + }); + } +); + From 9c79c2960e7acf17d0f9e43d818e16830dffd9a2 Mon Sep 17 00:00:00 2001 From: Rich Hankins Date: Fri, 6 Feb 2026 16:58:16 -0800 Subject: [PATCH 23/23] fix: restore clientUserAgent in AugmentLanguageModel constructor Agent-Id: agent-9eaf775a-04cb-45c8-bc31-4705c63e8b54 --- src/clients/cli-agent.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/clients/cli-agent.ts b/src/clients/cli-agent.ts index b609598..7029e02 100644 --- a/src/clients/cli-agent.ts +++ b/src/clients/cli-agent.ts @@ -192,6 +192,7 @@ async function loadModel( return new AugmentLanguageModel(modelName, { apiKey: credentials.apiKey, apiUrl: credentials.apiUrl, + clientUserAgent, }) as unknown as LanguageModel; } default: