Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 6 additions & 15 deletions src/bin/cmd-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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;
}

Expand Down
224 changes: 213 additions & 11 deletions src/clients/mcp-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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(
{
Expand Down Expand Up @@ -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,
Expand All @@ -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(
Expand Down Expand Up @@ -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);
Expand Down
Loading