Skip to content

Commit 590e38b

Browse files
BrainSlugs83Copilot
andcommitted
feat: add VECTOR_MEMORY_DATA_DIR env var + MCP integration tests
- Add VECTOR_MEMORY_DATA_DIR env var to vector-memory-server.js and index.js for overriding the data directory (default: ~/.copilot/) - Add test-integration.js with 7 end-to-end tests that spawn the full MCP STDIO proxy, perform the JSON-RPC handshake, and exercise all tools - Tests use temp directory via VECTOR_MEMORY_DATA_DIR + random port to avoid touching real data or conflicting with running servers - Add npm run test:integration script Refs #5 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent e6b21c0 commit 590e38b

File tree

4 files changed

+265
-3
lines changed

4 files changed

+265
-3
lines changed

index.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { request } from "http";
1111
import { userInfo } from "os";
1212

1313
const __dirname = dirname(fileURLToPath(import.meta.url));
14-
const COPILOT_DIR = join(homedir(), ".copilot");
14+
const COPILOT_DIR = process.env.VECTOR_MEMORY_DATA_DIR || join(homedir(), ".copilot");
1515
const EXPECTED_USER = userInfo().username;
1616
const PKG = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf-8"));
1717

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,8 @@
5353
"scripts": {
5454
"lint": "eslint index.js vector-memory-server.js embed-worker.js embed-pool.js lib.js",
5555
"test": "node --test test.js",
56-
"test:coverage": "node --test --experimental-test-coverage --test-coverage-lines=100 --test-coverage-branches=100 --test-coverage-functions=100 --test-coverage-exclude=test.js test.js",
56+
"test:integration": "node --test test-integration.js",
57+
"test:coverage": "node --test --experimental-test-coverage --test-coverage-lines=100 --test-coverage-branches=100 --test-coverage-functions=100 --test-coverage-exclude=test.js --test-coverage-exclude=test-integration.js test.js",
5758
"check": "npm run lint && npm run test:coverage"
5859
},
5960
"dependencies": {

test-integration.js

Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
/**
2+
* Integration tests for the MCP STDIO proxy + HTTP server pipeline.
3+
*
4+
* Spawns index.js (the proxy), which spawns vector-memory-server.js (the
5+
* HTTP server) on first tool call. All data goes to a temp directory via
6+
* VECTOR_MEMORY_DATA_DIR, leaving the real ~/.copilot/ untouched.
7+
*
8+
* Refs #5
9+
*/
10+
11+
import { describe, it, before, after } from "node:test";
12+
import assert from "node:assert/strict";
13+
import { spawn } from "node:child_process";
14+
import { mkdtempSync, rmSync, existsSync } from "node:fs";
15+
import { join } from "node:path";
16+
import { tmpdir } from "node:os";
17+
import { fileURLToPath } from "node:url";
18+
import { dirname } from "node:path";
19+
20+
const __dirname = dirname(fileURLToPath(import.meta.url));
21+
const INDEX_JS = join(__dirname, "index.js");
22+
23+
// --- MCP JSON-RPC helpers ---
24+
25+
let msgId = 0;
26+
27+
function jsonrpc(method, params = {}) {
28+
return JSON.stringify({ jsonrpc: "2.0", id: ++msgId, method, params });
29+
}
30+
31+
function notification(method, params = {}) {
32+
return JSON.stringify({ jsonrpc: "2.0", method, params });
33+
}
34+
35+
/**
36+
* Spawns the MCP proxy, performs the initialize handshake, and returns
37+
* a helper object for sending tool calls and reading responses.
38+
*/
39+
function createMcpClient(env = {}) {
40+
const child = spawn(process.execPath, [INDEX_JS], {
41+
stdio: ["pipe", "pipe", "pipe"],
42+
env: { ...process.env, ...env },
43+
windowsHide: true,
44+
});
45+
46+
let buffer = "";
47+
const pending = new Map();
48+
49+
child.stdout.on("data", (chunk) => {
50+
buffer += chunk.toString();
51+
// MCP messages are newline-delimited JSON
52+
let nl;
53+
while ((nl = buffer.indexOf("\n")) !== -1) {
54+
const line = buffer.slice(0, nl).trim();
55+
buffer = buffer.slice(nl + 1);
56+
if (!line) continue;
57+
try {
58+
const msg = JSON.parse(line);
59+
if (msg.id != null && pending.has(msg.id)) {
60+
pending.get(msg.id)(msg);
61+
pending.delete(msg.id);
62+
}
63+
} catch { /* ignore non-JSON lines */ }
64+
}
65+
});
66+
67+
function send(text) {
68+
child.stdin.write(text + "\n");
69+
}
70+
71+
function request(method, params = {}, timeoutMs = 120_000) {
72+
const id = ++msgId;
73+
return new Promise((resolve, reject) => {
74+
const timer = setTimeout(() => {
75+
pending.delete(id);
76+
reject(new Error(`MCP request "${method}" (id=${id}) timed out after ${timeoutMs}ms`));
77+
}, timeoutMs);
78+
79+
pending.set(id, (msg) => {
80+
clearTimeout(timer);
81+
resolve(msg);
82+
});
83+
84+
send(JSON.stringify({ jsonrpc: "2.0", id, method, params }));
85+
});
86+
}
87+
88+
async function initialize() {
89+
const resp = await request("initialize", {
90+
protocolVersion: "2024-11-05",
91+
capabilities: {},
92+
clientInfo: { name: "integration-test", version: "0.1" },
93+
});
94+
// Send initialized notification (required by MCP spec)
95+
send(notification("notifications/initialized"));
96+
return resp;
97+
}
98+
99+
async function callTool(name, args = {}, timeoutMs = 120_000) {
100+
return request("tools/call", { name, arguments: args }, timeoutMs);
101+
}
102+
103+
async function listTools() {
104+
return request("tools/list");
105+
}
106+
107+
function kill() {
108+
try { child.stdin.end(); } catch {}
109+
try { child.kill(); } catch {}
110+
}
111+
112+
return { initialize, callTool, listTools, request, kill, child };
113+
}
114+
115+
// --- Integration tests ---
116+
117+
describe("MCP Integration (end-to-end)", { timeout: 180_000 }, () => {
118+
let tmpDir;
119+
let client;
120+
let testPort;
121+
122+
before(async () => {
123+
// Create isolated temp data directory
124+
tmpDir = mkdtempSync(join(tmpdir(), "vector-memory-test-"));
125+
126+
// Use a random high port to avoid conflicting with a running server
127+
testPort = 40000 + Math.floor(Math.random() * 20000);
128+
129+
client = createMcpClient({
130+
VECTOR_MEMORY_DATA_DIR: tmpDir,
131+
VECTOR_MEMORY_PORT: String(testPort),
132+
VECTOR_MEMORY_IDLE_TIMEOUT: "0", // disable idle shutdown
133+
});
134+
135+
// Perform MCP handshake
136+
const initResp = await client.initialize();
137+
assert.ok(initResp.result, "initialize should return a result");
138+
assert.ok(initResp.result.protocolVersion, "should include protocol version");
139+
});
140+
141+
after(async () => {
142+
if (client) client.kill();
143+
144+
// Give child processes a moment to exit
145+
await new Promise(r => setTimeout(r, 1000));
146+
147+
// Kill any server on our test port
148+
try {
149+
const { execSync } = await import("node:child_process");
150+
const out = execSync("netstat -ano", { encoding: "utf-8", windowsHide: true });
151+
for (const line of out.split("\n")) {
152+
if (line.includes(`:${testPort}`) && line.includes("LISTENING")) {
153+
const pid = parseInt(line.trim().split(/\s+/).pop());
154+
if (!isNaN(pid) && pid > 0) {
155+
try { process.kill(pid); } catch {}
156+
}
157+
}
158+
}
159+
} catch {}
160+
161+
// Clean up temp directory
162+
if (tmpDir && existsSync(tmpDir)) {
163+
rmSync(tmpDir, { recursive: true, force: true });
164+
}
165+
});
166+
167+
it("tools/list returns vector_search and vector_reindex", async () => {
168+
const resp = await client.listTools();
169+
assert.ok(resp.result, "should have result");
170+
const tools = resp.result.tools;
171+
assert.ok(Array.isArray(tools), "tools should be an array");
172+
173+
const names = tools.map(t => t.name).sort();
174+
assert.deepEqual(names, ["vector_reindex", "vector_search"]);
175+
176+
// vector_search should have query and limit params
177+
const searchTool = tools.find(t => t.name === "vector_search");
178+
assert.ok(searchTool.inputSchema.properties.query, "vector_search should have query param");
179+
assert.ok(searchTool.inputSchema.properties.limit, "vector_search should have limit param");
180+
181+
// vector_reindex should have no required params
182+
const reindexTool = tools.find(t => t.name === "vector_reindex");
183+
assert.ok(reindexTool, "vector_reindex should exist");
184+
});
185+
186+
it("vector_search with valid query returns results (empty DB = no results)", async () => {
187+
const resp = await client.callTool("vector_search", { query: "test query", limit: 5 });
188+
assert.ok(resp.result, "should have result");
189+
assert.ok(Array.isArray(resp.result.content), "should have content array");
190+
assert.equal(resp.result.content[0].type, "text");
191+
const text = resp.result.content[0].text;
192+
// Empty temp DB → "No results found." or worker error (acceptable on first run)
193+
assert.ok(
194+
text.includes("No results") ||
195+
text.includes("score:") ||
196+
text.includes("unavailable") ||
197+
text.includes("Error"),
198+
`Unexpected response: ${text.slice(0, 300)}`
199+
);
200+
});
201+
202+
it("vector_search with missing query returns validation error", async () => {
203+
const resp = await client.callTool("vector_search", {});
204+
// MCP SDK returns validation errors as result with isError: true
205+
assert.ok(resp.result || resp.error, "should have result or error");
206+
if (resp.result) {
207+
assert.ok(resp.result.isError, "should be flagged as error");
208+
assert.ok(resp.result.content[0].text.includes("invalid") ||
209+
resp.result.content[0].text.includes("Invalid") ||
210+
resp.result.content[0].text.includes("required"),
211+
`Expected validation error, got: ${resp.result.content[0].text.slice(0, 200)}`);
212+
}
213+
});
214+
215+
it("vector_search with invalid limit type returns validation error", async () => {
216+
const resp = await client.callTool("vector_search", { query: "test", limit: "not a number" });
217+
assert.ok(resp.result || resp.error, "should have result or error");
218+
if (resp.result) {
219+
assert.ok(resp.result.isError, "should be flagged as error");
220+
assert.ok(resp.result.content[0].text.includes("invalid") ||
221+
resp.result.content[0].text.includes("Invalid") ||
222+
resp.result.content[0].text.includes("number"),
223+
`Expected type validation error, got: ${resp.result.content[0].text.slice(0, 200)}`);
224+
}
225+
});
226+
227+
it("vector_reindex returns count or session store message", async () => {
228+
const resp = await client.callTool("vector_reindex", {});
229+
assert.ok(resp.result, "should have result");
230+
assert.ok(Array.isArray(resp.result.content), "should have content array");
231+
const text = resp.result.content[0].text;
232+
assert.ok(
233+
text.includes("Reindexed") ||
234+
text.includes("Session store") ||
235+
text.includes("not found") ||
236+
text.includes("unavailable") ||
237+
text.includes("Error"),
238+
`Expected reindex result or error message, got: ${text.slice(0, 300)}`
239+
);
240+
});
241+
242+
it("calling unknown tool returns error", async () => {
243+
const resp = await client.callTool("nonexistent_tool", {});
244+
// MCP SDK returns unknown tool as either error or result with isError
245+
assert.ok(
246+
resp.error || (resp.result && resp.result.isError),
247+
`Expected error for unknown tool, got: ${JSON.stringify(resp.result || resp.error).slice(0, 200)}`
248+
);
249+
});
250+
251+
it("PID file is created in temp data dir", async () => {
252+
// The server should have written its PID file to our temp dir
253+
const pidFile = join(tmpDir, "vector-memory.pid");
254+
// Give the server a moment if it's still starting
255+
for (let i = 0; i < 10; i++) {
256+
if (existsSync(pidFile)) break;
257+
await new Promise(r => setTimeout(r, 500));
258+
}
259+
assert.ok(existsSync(pidFile), "PID file should exist in temp data dir (not ~/.copilot/)");
260+
});
261+
});

vector-memory-server.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { createEmbedPool } from "./embed-pool.js";
1414
const __dirname = dirname(fileURLToPath(import.meta.url));
1515
const PKG = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf-8"));
1616
const SERVER_USER = userInfo().username;
17-
const COPILOT_DIR = join(homedir(), ".copilot");
17+
const COPILOT_DIR = process.env.VECTOR_MEMORY_DATA_DIR || join(homedir(), ".copilot");
1818
const SESSION_STORE_PATH = join(COPILOT_DIR, "session-store.db");
1919
const VECTOR_INDEX_PATH = join(COPILOT_DIR, "vector-index.db");
2020
const INDEX_INTERVAL_MS = 15 * 60 * 1000;

0 commit comments

Comments
 (0)