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; } diff --git a/src/clients/mcp-server.ts b/src/clients/mcp-server.ts index f72a6c0..78816f4 100644 --- a/src/clients/mcp-server.ts +++ b/src/clients/mcp-server.ts @@ -37,21 +37,22 @@ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; -import type { IndexStoreReader } from "../stores/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, LIST_FILES_DESCRIPTION, READ_FILE_DESCRIPTION, - withIndexList, + withListIndexesReference, } from "./tool-descriptions.js"; /** * 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. */ @@ -105,9 +106,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 +133,66 @@ 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: "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, @@ -168,6 +218,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( @@ -255,6 +323,140 @@ 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")}` }], + }; + } + + // 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/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; 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.`; +} +