Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
4981a79
Support dynamic index list in MultiIndexRunner
richhankins Feb 2, 2026
427a1bc
Add list_indexes tool to MCP server
richhankins Feb 2, 2026
6447099
Add delete_index tool to MCP server
richhankins Feb 2, 2026
0592040
Add delete_index Tool
richhankins Feb 2, 2026
2ea4cdf
Add index_repo tool to MCP server
richhankins Feb 2, 2026
35f06b1
Allow MCP server to start with zero indexes
richhankins Feb 2, 2026
94d91f1
Fix index_repo conditional and remove stale enum from MCP tools
richhankins Feb 6, 2026
4316cd4
Implement LayeredStore class combining local and remote indexes
richhankins Feb 6, 2026
e0fc0f7
Restore enum in Fixed Mode
richhankins Feb 6, 2026
975991f
Add --agent-managed CLI Flag
richhankins Feb 6, 2026
bbed4fc
Fix agentManaged config calculation in cmd-mcp.ts
richhankins Feb 6, 2026
b781515
Replace agent-managed with discovery mode
richhankins Feb 6, 2026
44d7569
Replace Agent-Managed with Discovery Mode
richhankins Feb 6, 2026
dcf2386
Add support for -i flags in Discovery mode with ReadOnlyLayeredStore
richhankins Feb 6, 2026
81b4dbf
Merge origin/main into dynamic-mcp-tools
richhankins Feb 6, 2026
c932344
Fix type errors from main branch merge
richhankins Feb 6, 2026
ba57527
Fix PR review comments: allow empty indexes, add error handling, fix …
richhankins Feb 6, 2026
2c37861
Fix refreshIndexList() to respect fixed mode allowlist
richhankins Feb 6, 2026
c9eed14
Make fixed mode's index list completely static
richhankins Feb 6, 2026
c9c4ea1
fix: restore clientUserAgent in DirectContext.import()
richhankins Feb 7, 2026
5c35f78
fix: prune stale clientCache entries in refreshIndexList()
richhankins Feb 7, 2026
3220fcf
fix: restore fail-fast behavior for non-discovery mode (issues #1, #7)
richhankins Feb 7, 2026
733ad36
test: add unit tests for list_indexes and discovery vs fixed mode
richhankins Feb 7, 2026
9c79c29
fix: restore clientUserAgent in AugmentLanguageModel constructor
richhankins Feb 7, 2026
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
54 changes: 42 additions & 12 deletions src/bin/cmd-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--discovery is currently only honored when --index is also provided; ctxc mcp stdio --discovery (without -i) falls through to the “No flags” branch and sets discovery = false, so discovery mode never activates.

Severity: high

Other Locations
  • src/bin/cmd-mcp.ts:98

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

// 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();

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This branch sets up discovery mode without requiring any indexes, but MultiIndexRunner.create() still throws when the store has zero valid indexes, so ctxc mcp ... can still fail to start on a fresh install. If discovery mode is intended to allow “start empty and index later”, consider aligning runner/server startup behavior with that expectation.

Severity: high

Other Locations
  • src/bin/cmd-mcp.ts:106

Fix This in Augment

🤖 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)
Expand All @@ -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)")
Expand All @@ -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) {
Expand All @@ -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
Expand All @@ -112,6 +141,7 @@ const httpCommand = new Command("http")
store,
indexNames,
searchOnly: options.searchOnly,
discovery,
port: parseInt(options.port, 10),
host: options.host,
cors,
Expand Down
210 changes: 210 additions & 0 deletions src/clients/mcp-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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"));

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test mocks store.list() to reject before calling createMCPServer(), but createMCPServer()/MultiIndexRunner.create() calls store.list() during startup, so the server will likely fail to construct and the test won’t actually exercise the list_indexes error handling path.

Severity: medium

Fix This in Augment

🤖 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();
});
});
}
);

Loading