Skip to content

Commit dcf2386

Browse files
committed
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
1 parent 44d7569 commit dcf2386

File tree

4 files changed

+302
-18
lines changed

4 files changed

+302
-18
lines changed

src/bin/cmd-mcp.ts

Lines changed: 31 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { FilesystemStore } from "../stores/filesystem.js";
77
import { runMCPServer } from "../clients/mcp-server.js";
88
import { parseIndexSpecs } from "../stores/index-spec.js";
99
import { CompositeStoreReader } from "../stores/composite.js";
10+
import { ReadOnlyLayeredStore } from "../stores/read-only-layered-store.js";
1011

1112
// stdio subcommand (stdio-based MCP server for local clients like Claude Desktop)
1213
const stdioCommand = new Command("stdio")
@@ -20,28 +21,34 @@ const stdioCommand = new Command("stdio")
2021
.action(async (options) => {
2122
try {
2223
const indexSpecs: string[] | undefined = options.index;
23-
const discovery = options.discovery || !indexSpecs || indexSpecs.length === 0;
24+
const discoveryFlag = options.discovery;
2425

2526
let store;
2627
let indexNames: string[] | undefined;
28+
let discovery: boolean;
2729

28-
if (indexSpecs && indexSpecs.length > 0) {
29-
// Parse index specs
30+
if (discoveryFlag && indexSpecs && indexSpecs.length > 0) {
31+
// Discovery mode WITH remote indexes: merge local + remote
3032
const specs = parseIndexSpecs(indexSpecs);
31-
indexNames = specs.map((s) => s.displayName);
32-
33+
const remoteStore = await CompositeStoreReader.fromSpecs(specs);
34+
const localStore = new FilesystemStore();
35+
store = new ReadOnlyLayeredStore(localStore, remoteStore);
36+
indexNames = undefined; // Discovery mode: no fixed list
37+
discovery = true;
38+
} else if (indexSpecs && indexSpecs.length > 0) {
3339
// Fixed mode: use read-only CompositeStoreReader
40+
const specs = parseIndexSpecs(indexSpecs);
41+
indexNames = specs.map((s) => s.displayName);
3442
store = await CompositeStoreReader.fromSpecs(specs);
43+
discovery = false;
3544
} else {
36-
// No --index: use FilesystemStore (discovery mode)
45+
// Discovery mode only: use FilesystemStore
3746
store = new FilesystemStore();
38-
// Discovery mode: server can start with zero indexes
39-
// Use list_indexes to see available indexes, manage via CLI
4047
indexNames = undefined;
48+
discovery = true;
4149
}
4250

4351
// Start MCP server (writes to stdout, reads from stdin)
44-
// discovery: true when no -i flags (discovery mode), false when -i flags provided (fixed mode)
4552
await runMCPServer({
4653
store,
4754
indexNames,
@@ -75,24 +82,31 @@ const httpCommand = new Command("http")
7582
.action(async (options) => {
7683
try {
7784
const indexSpecs: string[] | undefined = options.index;
78-
const discovery = options.discovery || !indexSpecs || indexSpecs.length === 0;
85+
const discoveryFlag = options.discovery;
7986

8087
let store;
8188
let indexNames: string[] | undefined;
89+
let discovery: boolean;
8290

83-
if (indexSpecs && indexSpecs.length > 0) {
84-
// Parse index specs
91+
if (discoveryFlag && indexSpecs && indexSpecs.length > 0) {
92+
// Discovery mode WITH remote indexes: merge local + remote
8593
const specs = parseIndexSpecs(indexSpecs);
86-
indexNames = specs.map((s) => s.displayName);
87-
94+
const remoteStore = await CompositeStoreReader.fromSpecs(specs);
95+
const localStore = new FilesystemStore();
96+
store = new ReadOnlyLayeredStore(localStore, remoteStore);
97+
indexNames = undefined; // Discovery mode: no fixed list
98+
discovery = true;
99+
} else if (indexSpecs && indexSpecs.length > 0) {
88100
// Fixed mode: use read-only CompositeStoreReader
101+
const specs = parseIndexSpecs(indexSpecs);
102+
indexNames = specs.map((s) => s.displayName);
89103
store = await CompositeStoreReader.fromSpecs(specs);
104+
discovery = false;
90105
} else {
91-
// No --index: use FilesystemStore (discovery mode)
106+
// Discovery mode only: use FilesystemStore
92107
store = new FilesystemStore();
93-
// Discovery mode: server can start with zero indexes
94-
// Use list_indexes to see available indexes, manage via CLI
95108
indexNames = undefined;
109+
discovery = true;
96110
}
97111

98112
// Parse CORS option
@@ -108,7 +122,6 @@ const httpCommand = new Command("http")
108122
const apiKey = options.apiKey ?? process.env.MCP_API_KEY;
109123

110124
// Start HTTP server
111-
// discovery: true when no -i flags (discovery mode), false when -i flags provided (fixed mode)
112125
const { runMCPHttpServer } = await import("../clients/mcp-http-server.js");
113126
const server = await runMCPHttpServer({
114127
store,

src/stores/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export type { MemoryStoreConfig } from "./memory.js";
1010
export { S3Store } from "./s3.js";
1111
export type { S3StoreConfig } from "./s3.js";
1212
export { CompositeStoreReader } from "./composite.js";
13+
export { ReadOnlyLayeredStore } from "./read-only-layered-store.js";
1314
export { parseIndexSpec, parseIndexSpecs } from "./index-spec.js";
1415
export type { IndexSpec } from "./index-spec.js";
1516

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
import { describe, it, expect, beforeEach } from "vitest";
2+
import { ReadOnlyLayeredStore } from "./read-only-layered-store.js";
3+
import { MemoryStore } from "./memory.js";
4+
import type { IndexState, IndexStateSearchOnly } from "../core/types.js";
5+
6+
function createMockState(): { full: IndexState; search: IndexStateSearchOnly } {
7+
const source = {
8+
type: "github" as const,
9+
config: { owner: "test-owner", repo: "test-repo" },
10+
syncedAt: new Date().toISOString(),
11+
};
12+
return {
13+
full: {
14+
version: 1,
15+
contextState: {
16+
mode: "full" as const,
17+
checkpointId: "test-checkpoint-123",
18+
addedBlobs: ["blob-1"],
19+
deletedBlobs: [],
20+
blobs: [["blob-1", "src/file1.ts"]],
21+
},
22+
source,
23+
},
24+
search: {
25+
version: 1,
26+
contextState: {
27+
mode: "search-only" as const,
28+
checkpointId: "test-checkpoint-123",
29+
addedBlobs: ["blob-1"],
30+
deletedBlobs: [],
31+
},
32+
source,
33+
},
34+
};
35+
}
36+
37+
describe("ReadOnlyLayeredStore", () => {
38+
let primary: MemoryStore;
39+
let remote: MemoryStore;
40+
let layered: ReadOnlyLayeredStore;
41+
42+
beforeEach(() => {
43+
primary = new MemoryStore();
44+
remote = new MemoryStore();
45+
layered = new ReadOnlyLayeredStore(primary, remote);
46+
});
47+
48+
describe("list", () => {
49+
it("returns merged and deduplicated list from both stores", async () => {
50+
const { full, search } = createMockState();
51+
52+
// Add to primary
53+
await primary.save("local-a", full, search);
54+
await primary.save("local-b", full, search);
55+
56+
// Add to remote
57+
await remote.save("remote-a", full, search);
58+
await remote.save("remote-b", full, search);
59+
60+
const list = await layered.list();
61+
62+
expect(list).toContain("local-a");
63+
expect(list).toContain("local-b");
64+
expect(list).toContain("remote-a");
65+
expect(list).toContain("remote-b");
66+
expect(list.length).toBe(4);
67+
});
68+
69+
it("deduplicates when same index exists in both stores", async () => {
70+
const { full, search } = createMockState();
71+
72+
// Add same index to both
73+
await primary.save("shared", full, search);
74+
await remote.save("shared", full, search);
75+
76+
const list = await layered.list();
77+
78+
expect(list).toContain("shared");
79+
expect(list.length).toBe(1);
80+
});
81+
82+
it("returns sorted list", async () => {
83+
const { full, search } = createMockState();
84+
85+
await primary.save("zebra", full, search);
86+
await primary.save("apple", full, search);
87+
await remote.save("mango", full, search);
88+
89+
const list = await layered.list();
90+
91+
expect(list).toEqual(["apple", "mango", "zebra"]);
92+
});
93+
94+
it("returns empty list when both stores are empty", async () => {
95+
const list = await layered.list();
96+
expect(list).toEqual([]);
97+
});
98+
});
99+
100+
describe("loadSearch", () => {
101+
it("returns from primary if exists", async () => {
102+
const { full, search } = createMockState();
103+
await primary.save("test", full, search);
104+
105+
const result = await layered.loadSearch("test");
106+
107+
expect(result).toEqual(search);
108+
});
109+
110+
it("falls back to remote if not in primary", async () => {
111+
const { full, search } = createMockState();
112+
await remote.save("test", full, search);
113+
114+
const result = await layered.loadSearch("test");
115+
116+
expect(result).toEqual(search);
117+
});
118+
119+
it("prefers primary over remote when both exist", async () => {
120+
const { full, search } = createMockState();
121+
const primarySearch = {
122+
...search,
123+
contextState: { ...search.contextState, checkpointId: "primary" },
124+
};
125+
const remoteSearch = {
126+
...search,
127+
contextState: { ...search.contextState, checkpointId: "remote" },
128+
};
129+
130+
await primary.save("test", full, primarySearch);
131+
await remote.save("test", full, remoteSearch);
132+
133+
const result = await layered.loadSearch("test");
134+
135+
expect(result?.contextState.checkpointId).toBe("primary");
136+
});
137+
138+
it("returns null if not found in either store", async () => {
139+
const result = await layered.loadSearch("nonexistent");
140+
expect(result).toBeNull();
141+
});
142+
});
143+
144+
describe("loadState", () => {
145+
it("returns from primary if exists", async () => {
146+
const { full, search } = createMockState();
147+
await primary.save("test", full, search);
148+
149+
const result = await layered.loadState("test");
150+
151+
expect(result).toEqual(full);
152+
});
153+
154+
it("falls back to remote if not in primary", async () => {
155+
const { full, search } = createMockState();
156+
await remote.save("test", full, search);
157+
158+
const result = await layered.loadState("test");
159+
160+
expect(result).toEqual(full);
161+
});
162+
163+
it("prefers primary over remote when both exist", async () => {
164+
const { full, search } = createMockState();
165+
const primaryFull = {
166+
...full,
167+
contextState: { ...full.contextState, checkpointId: "primary" },
168+
};
169+
const remoteFull = {
170+
...full,
171+
contextState: { ...full.contextState, checkpointId: "remote" },
172+
};
173+
174+
await primary.save("test", primaryFull, search);
175+
await remote.save("test", remoteFull, search);
176+
177+
const result = await layered.loadState("test");
178+
179+
expect(result?.contextState.checkpointId).toBe("primary");
180+
});
181+
182+
it("returns null if not found in either store", async () => {
183+
const result = await layered.loadState("nonexistent");
184+
expect(result).toBeNull();
185+
});
186+
});
187+
});
188+
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/**
2+
* Read-only layered store that merges a primary store with a remote store.
3+
*
4+
* Used in discovery mode with remote indexes:
5+
* - Primary store: FilesystemStore (local indexes)
6+
* - Remote store: CompositeStoreReader (remote indexes)
7+
*
8+
* Behavior:
9+
* - list(): Merge both lists, deduplicated, sorted
10+
* - loadState(name): Try primary first, then remote
11+
* - loadSearch(name): Try primary first, then remote
12+
* - No save/delete methods (read-only)
13+
*
14+
* @module stores/read-only-layered-store
15+
*/
16+
17+
import type { IndexStoreReader } from "./types.js";
18+
import type { IndexState, IndexStateSearchOnly } from "../core/types.js";
19+
20+
/**
21+
* Read-only layered store that combines a primary and remote store.
22+
*
23+
* Useful for discovery mode where users can:
24+
* - Manage local indexes via CLI (stored in FilesystemStore)
25+
* - Reference remote indexes via -i flags (stored in CompositeStoreReader)
26+
*
27+
* @example
28+
* ```typescript
29+
* const primary = new FilesystemStore();
30+
* const remote = await CompositeStoreReader.fromSpecs([
31+
* { type: "s3", value: "s3://bucket/shared-index", displayName: "shared" }
32+
* ]);
33+
* const layered = new ReadOnlyLayeredStore(primary, remote);
34+
*
35+
* // Lists both local and remote indexes
36+
* const allIndexes = await layered.list();
37+
*
38+
* // Tries primary first, then remote
39+
* const state = await layered.loadSearch("my-index");
40+
* ```
41+
*/
42+
export class ReadOnlyLayeredStore implements IndexStoreReader {
43+
constructor(
44+
private primary: IndexStoreReader,
45+
private remote: IndexStoreReader
46+
) {}
47+
48+
async loadState(key: string): Promise<IndexState | null> {
49+
// Try primary first
50+
const primaryState = await this.primary.loadState(key);
51+
if (primaryState !== null) {
52+
return primaryState;
53+
}
54+
// Fall back to remote
55+
return this.remote.loadState(key);
56+
}
57+
58+
async loadSearch(key: string): Promise<IndexStateSearchOnly | null> {
59+
// Try primary first
60+
const primarySearch = await this.primary.loadSearch(key);
61+
if (primarySearch !== null) {
62+
return primarySearch;
63+
}
64+
// Fall back to remote
65+
return this.remote.loadSearch(key);
66+
}
67+
68+
async list(): Promise<string[]> {
69+
// Get both lists
70+
const [primaryList, remoteList] = await Promise.all([
71+
this.primary.list(),
72+
this.remote.list(),
73+
]);
74+
75+
// Merge and deduplicate
76+
const merged = new Set([...primaryList, ...remoteList]);
77+
78+
// Return sorted array
79+
return Array.from(merged).sort();
80+
}
81+
}
82+

0 commit comments

Comments
 (0)