-
Notifications
You must be signed in to change notification settings - Fork 0
Add dynamic MCP tools: list_indexes, index_repo, delete_index #15
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
4981a79
427a1bc
6447099
0592040
2ea4cdf
35f06b1
94d91f1
4316cd4
e0fc0f7
975991f
bbed4fc
b781515
44d7569
dcf2386
81b4dbf
c932344
ba57527
2c37861
c9eed14
c9c4ea1
5c35f78
3220fcf
733ad36
9c79c29
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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") | ||
|
|
@@ -15,36 +16,51 @@ const stdioCommand = new Command("stdio") | |
| "-i, --index <specs...>", | ||
| "Index spec(s): name, path:/path, or s3://bucket/key" | ||
| ) | ||
| .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 discoveryFlag = options.discovery; | ||
|
|
||
| let store; | ||
| let indexNames: string[]; | ||
| let indexNames: string[] | undefined; | ||
| let discovery: boolean; | ||
|
|
||
| if (indexSpecs && indexSpecs.length > 0) { | ||
| // Parse index specs and create composite store | ||
| if (discoveryFlag && indexSpecs && indexSpecs.length > 0) { | ||
| // Discovery mode WITH remote indexes: merge local + remote | ||
| const specs = parseIndexSpecs(indexSpecs); | ||
| 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); | ||
| store = await CompositeStoreReader.fromSpecs(specs); | ||
| indexNames = specs.map((s) => s.displayName); | ||
| store = await CompositeStoreReader.fromSpecs(specs); | ||
| discovery = false; | ||
| } else { | ||
| // No --index: use default store, list all indexes | ||
| // No flags: restore original behavior - fail fast if no indexes | ||
| store = new FilesystemStore(); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This branch sets up discovery mode without requiring any indexes, but Severity: high Other Locations
🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage. |
||
| indexNames = await store.list(); | ||
| if (indexNames.length === 0) { | ||
| 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) | ||
| await runMCPServer({ | ||
| store, | ||
| indexNames, | ||
| searchOnly: options.searchOnly, | ||
| discovery, | ||
| }); | ||
| } catch (error) { | ||
| // Write errors to stderr (stdout is for MCP protocol) | ||
|
|
@@ -60,6 +76,7 @@ const httpCommand = new Command("http") | |
| "-i, --index <specs...>", | ||
| "Index spec(s): name, path:/path, or s3://bucket/key" | ||
| ) | ||
| .option("--discovery", "Enable discovery mode (read-only, manage indexes via CLI)") | ||
| .option("--port <number>", "Port to listen on", "3000") | ||
| .option("--host <host>", "Host to bind to", "localhost") | ||
| .option("--cors <origins>", "CORS origins (comma-separated, or '*' for any)") | ||
|
|
@@ -72,17 +89,28 @@ const httpCommand = new Command("http") | |
| .action(async (options) => { | ||
| try { | ||
| const indexSpecs: string[] | undefined = options.index; | ||
| const discoveryFlag = options.discovery; | ||
|
|
||
| let store; | ||
| let indexNames: string[] | undefined; | ||
| let discovery: boolean; | ||
|
|
||
| if (indexSpecs && indexSpecs.length > 0) { | ||
| // Parse index specs and create composite store | ||
| if (discoveryFlag && indexSpecs && indexSpecs.length > 0) { | ||
| // Discovery mode WITH remote indexes: merge local + remote | ||
| const specs = parseIndexSpecs(indexSpecs); | ||
| 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); | ||
| store = await CompositeStoreReader.fromSpecs(specs); | ||
| indexNames = specs.map((s) => s.displayName); | ||
| store = await CompositeStoreReader.fromSpecs(specs); | ||
| discovery = false; | ||
| } else { | ||
| // No --index: use default store, serve all | ||
| // No flags: restore original behavior - fail fast if no indexes | ||
| store = new FilesystemStore(); | ||
| const availableIndexes = await store.list(); | ||
| if (availableIndexes.length === 0) { | ||
|
|
@@ -91,7 +119,8 @@ const httpCommand = new Command("http") | |
| console.error("Run 'ctxc index --help' to see how to create an index."); | ||
| process.exit(1); | ||
| } | ||
| indexNames = undefined; | ||
| indexNames = availableIndexes; | ||
| discovery = false; | ||
| } | ||
|
|
||
| // Parse CORS option | ||
|
|
@@ -112,6 +141,7 @@ const httpCommand = new Command("http") | |
| store, | ||
| indexNames, | ||
| searchOnly: options.searchOnly, | ||
| discovery, | ||
| port: parseInt(options.port, 10), | ||
| host: options.host, | ||
| cors, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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")); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This test mocks Severity: medium 🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage. |
||
|
|
||
| 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(); | ||
| }); | ||
| }); | ||
| } | ||
| ); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
--discoveryis currently only honored when--indexis also provided;ctxc mcp stdio --discovery(without-i) falls through to the “No flags” branch and setsdiscovery = false, so discovery mode never activates.Severity: high
Other Locations
src/bin/cmd-mcp.ts:98🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.