@context-engine-bridge/context-engine-mcp-bridge 0.0.23 → 0.0.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@context-engine-bridge/context-engine-mcp-bridge",
3
- "version": "0.0.23",
3
+ "version": "0.0.25",
4
4
  "description": "Context Engine MCP bridge (http/stdio proxy combining indexer + memory servers)",
5
5
  "bin": {
6
6
  "ctxce": "bin/ctxce.js",
@@ -29,4 +29,4 @@
29
29
  "engines": {
30
30
  "node": ">=18.0.0"
31
31
  }
32
- }
32
+ }
package/src/cli.js CHANGED
@@ -5,11 +5,18 @@ import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { runMcpServer, runHttpMcpServer } from "./mcpServer.js";
7
7
  import { runAuthCommand } from "./authCli.js";
8
+ import { runConnectCommand } from "./connectCli.js";
8
9
 
9
10
  export async function runCli() {
10
11
  const argv = process.argv.slice(2);
11
12
  const cmd = argv[0];
12
13
 
14
+ if (cmd === "connect") {
15
+ const args = argv.slice(1);
16
+ await runConnectCommand(args);
17
+ return;
18
+ }
19
+
13
20
  if (cmd === "auth") {
14
21
  const sub = argv[1] || "";
15
22
  const args = argv.slice(2);
@@ -134,7 +141,7 @@ export async function runCli() {
134
141
 
135
142
  // eslint-disable-next-line no-console
136
143
  console.error(
137
- `Usage: ${binName} mcp-serve [--workspace <path>] [--indexer-url <url>] [--memory-url <url>] [--collection <name>] | ${binName} mcp-http-serve [--workspace <path>] [--indexer-url <url>] [--memory-url <url>] [--port <port>] [--collection <name>] | ${binName} auth <login|status|logout> [--backend-url <url>] [--token <token>] [--username <name> --password <pass>]`,
144
+ `Usage: ${binName} connect <api-key> [--workspace <path>] [--interval <sec>] [--no-watch] | ${binName} mcp-serve [--workspace <path>] [--indexer-url <url>] [--memory-url <url>] [--collection <name>] | ${binName} mcp-http-serve [--workspace <path>] [--indexer-url <url>] [--memory-url <url>] [--port <port>] [--collection <name>] | ${binName} auth <login|status|logout> [--backend-url <url>] [--token <token>] [--username <name> --password <pass>]`,
138
145
  );
139
146
  process.exit(1);
140
147
  }
@@ -0,0 +1,444 @@
1
+ import process from "node:process";
2
+ import path from "node:path";
3
+ import fs from "node:fs";
4
+ import { execSync, spawn } from "node:child_process";
5
+ import { fileURLToPath } from "node:url";
6
+ import { saveAuthEntry } from "./authConfig.js";
7
+
8
+ const __filename = fileURLToPath(import.meta.url);
9
+ const __dirname = path.dirname(__filename);
10
+
11
+ const SAAS_ENDPOINTS = {
12
+ uploadEndpoint: "https://dev.context-engine.ai/upload",
13
+ authBackendUrl: "https://dev.context-engine.ai",
14
+ mcpIndexerUrl: "https://dev.context-engine.ai/indexer/mcp",
15
+ mcpMemoryUrl: "https://dev.context-engine.ai/memory/mcp",
16
+ };
17
+
18
+ const DEFAULT_WATCH_INTERVAL_MS = 30000;
19
+ const DEFAULT_DEBOUNCE_MS = 2000;
20
+
21
+ function parseConnectArgs(args) {
22
+ let apiKey = "";
23
+ let workspace = process.cwd();
24
+ let skipIndex = false;
25
+ let noWatch = false;
26
+ let watchInterval = DEFAULT_WATCH_INTERVAL_MS;
27
+
28
+ for (let i = 0; i < args.length; i += 1) {
29
+ const a = args[i];
30
+
31
+ if (!a.startsWith("-") && !apiKey) {
32
+ apiKey = a;
33
+ continue;
34
+ }
35
+
36
+ if ((a === "--api-key" || a === "--key" || a === "-k") && i + 1 < args.length) {
37
+ apiKey = args[i + 1];
38
+ i += 1;
39
+ continue;
40
+ }
41
+
42
+ if ((a === "--workspace" || a === "--path" || a === "-w") && i + 1 < args.length) {
43
+ workspace = args[i + 1];
44
+ i += 1;
45
+ continue;
46
+ }
47
+
48
+ if (a === "--skip-index" || a === "--auth-only") {
49
+ skipIndex = true;
50
+ continue;
51
+ }
52
+
53
+ if (a === "--no-watch" || a === "--once") {
54
+ noWatch = true;
55
+ continue;
56
+ }
57
+
58
+ if (a === "--interval" && i + 1 < args.length) {
59
+ const parsed = parseInt(args[i + 1], 10);
60
+ if (!isNaN(parsed) && parsed > 0) {
61
+ watchInterval = parsed * 1000;
62
+ }
63
+ i += 1;
64
+ continue;
65
+ }
66
+ }
67
+
68
+ return { apiKey, workspace, skipIndex, noWatch, watchInterval };
69
+ }
70
+
71
+ async function authenticateWithApiKey(apiKey) {
72
+ const authUrl = `${SAAS_ENDPOINTS.authBackendUrl}/auth/login`;
73
+
74
+ console.error("[ctxce] Authenticating with Context Engine SaaS...");
75
+
76
+ const body = {
77
+ client: "ctxce-cli",
78
+ token: apiKey,
79
+ workspace: process.cwd(),
80
+ };
81
+
82
+ let resp;
83
+ try {
84
+ resp = await fetch(authUrl, {
85
+ method: "POST",
86
+ headers: { "Content-Type": "application/json" },
87
+ body: JSON.stringify(body),
88
+ });
89
+ } catch (err) {
90
+ console.error("[ctxce] Failed to connect to SaaS backend:", String(err));
91
+ return null;
92
+ }
93
+
94
+ if (!resp || !resp.ok) {
95
+ const status = resp ? resp.status : "<no-response>";
96
+ let errorMsg = `HTTP ${status}`;
97
+ try {
98
+ const errorData = await resp.json();
99
+ if (errorData.detail) {
100
+ errorMsg = errorData.detail;
101
+ }
102
+ } catch {
103
+ }
104
+ console.error("[ctxce] Authentication failed:", errorMsg);
105
+ return null;
106
+ }
107
+
108
+ let data;
109
+ try {
110
+ data = await resp.json();
111
+ } catch {
112
+ data = {};
113
+ }
114
+
115
+ const sessionId = data.session_id || data.sessionId || null;
116
+ const userId = data.user_id || data.userId || null;
117
+ const expiresAt = data.expires_at || data.expiresAt || null;
118
+ const orgId = data.org_id || data.orgId || null;
119
+ const orgSlug = data.org_slug || data.orgSlug || null;
120
+
121
+ if (!sessionId) {
122
+ console.error("[ctxce] Authentication response missing session ID.");
123
+ return null;
124
+ }
125
+
126
+ const entry = {
127
+ sessionId,
128
+ userId,
129
+ expiresAt,
130
+ org_id: orgId,
131
+ org_slug: orgSlug,
132
+ apiKey,
133
+ };
134
+
135
+ saveAuthEntry(SAAS_ENDPOINTS.authBackendUrl, entry);
136
+
137
+ console.error("[ctxce] Authenticated successfully!");
138
+ if (userId) {
139
+ console.error(`[ctxce] User: ${userId}`);
140
+ }
141
+ if (orgSlug) {
142
+ console.error(`[ctxce] Organization: ${orgSlug}`);
143
+ }
144
+
145
+ return entry;
146
+ }
147
+
148
+ function findUploadClient() {
149
+ const candidates = [
150
+ path.resolve(process.cwd(), "scripts", "standalone_upload_client.py"),
151
+ path.resolve(process.cwd(), "..", "scripts", "standalone_upload_client.py"),
152
+ path.resolve(__dirname, "..", "..", "scripts", "standalone_upload_client.py"),
153
+ ];
154
+
155
+ for (const candidate of candidates) {
156
+ if (fs.existsSync(candidate)) {
157
+ return candidate;
158
+ }
159
+ }
160
+
161
+ return null;
162
+ }
163
+
164
+ function detectPython() {
165
+ for (const cmd of ["python3", "python"]) {
166
+ try {
167
+ execSync(`${cmd} --version`, { stdio: "ignore" });
168
+ return cmd;
169
+ } catch {
170
+ }
171
+ }
172
+ return null;
173
+ }
174
+
175
+ async function runUploadClient(workspace, sessionId, uploadClient, python) {
176
+ const env = {
177
+ ...process.env,
178
+ REMOTE_UPLOAD_ENDPOINT: SAAS_ENDPOINTS.uploadEndpoint,
179
+ CTXCE_AUTH_BACKEND_URL: SAAS_ENDPOINTS.authBackendUrl,
180
+ CTXCE_SESSION_ID: sessionId,
181
+ HOST_ROOT: workspace,
182
+ CONTAINER_ROOT: "/work",
183
+ };
184
+
185
+ return new Promise((resolve) => {
186
+ const args = [
187
+ uploadClient,
188
+ "--path", workspace,
189
+ "--endpoint", SAAS_ENDPOINTS.uploadEndpoint,
190
+ "--force",
191
+ ];
192
+
193
+ const proc = spawn(python, args, {
194
+ env,
195
+ stdio: ["ignore", "pipe", "pipe"],
196
+ cwd: workspace,
197
+ });
198
+
199
+ proc.stdout.on("data", (data) => {
200
+ const line = data.toString().trim();
201
+ if (line) {
202
+ console.error(`[upload] ${line}`);
203
+ }
204
+ });
205
+
206
+ proc.stderr.on("data", (data) => {
207
+ const line = data.toString().trim();
208
+ if (line) {
209
+ console.error(`[upload] ${line}`);
210
+ }
211
+ });
212
+
213
+ proc.on("close", (code) => {
214
+ resolve(code === 0);
215
+ });
216
+
217
+ proc.on("error", (err) => {
218
+ console.error(`[ctxce] Upload process error: ${err}`);
219
+ resolve(false);
220
+ });
221
+ });
222
+ }
223
+
224
+ async function triggerIndexing(workspace, sessionId) {
225
+ console.error("[ctxce] Starting workspace indexing...");
226
+ console.error(`[ctxce] Workspace: ${workspace}`);
227
+ console.error(`[ctxce] Endpoint: ${SAAS_ENDPOINTS.uploadEndpoint}`);
228
+
229
+ const uploadClient = findUploadClient();
230
+ const python = detectPython();
231
+
232
+ if (!uploadClient || !python) {
233
+ console.error("[ctxce] Python upload client not available.");
234
+ console.error("[ctxce] Install context-engine Python package or use VS Code extension.");
235
+ return false;
236
+ }
237
+
238
+ return await runUploadClient(workspace, sessionId, uploadClient, python);
239
+ }
240
+
241
+ function startWatcher(workspace, sessionId, intervalMs) {
242
+ const uploadClient = findUploadClient();
243
+ const python = detectPython();
244
+
245
+ if (!uploadClient || !python) {
246
+ console.error("[ctxce] Cannot start watcher: Python upload client not available.");
247
+ return null;
248
+ }
249
+
250
+ console.error(`[ctxce] Starting file watcher (sync every ${intervalMs / 1000}s)...`);
251
+ console.error("[ctxce] Press Ctrl+C to stop.");
252
+
253
+ let isRunning = false;
254
+ let pendingSync = false;
255
+ let lastSyncTime = Date.now();
256
+
257
+ const fileHashes = new Map();
258
+
259
+ function getFileHash(filePath) {
260
+ try {
261
+ const stat = fs.statSync(filePath);
262
+ return `${stat.mtime.getTime()}-${stat.size}`;
263
+ } catch {
264
+ return null;
265
+ }
266
+ }
267
+
268
+ function scanDirectory(dir, files = []) {
269
+ try {
270
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
271
+ for (const entry of entries) {
272
+ const fullPath = path.join(dir, entry.name);
273
+
274
+ if (entry.name.startsWith(".") ||
275
+ entry.name === "node_modules" ||
276
+ entry.name === "__pycache__" ||
277
+ entry.name === "venv" ||
278
+ entry.name === ".venv" ||
279
+ entry.name === "dist" ||
280
+ entry.name === "build") {
281
+ continue;
282
+ }
283
+
284
+ if (entry.isDirectory()) {
285
+ scanDirectory(fullPath, files);
286
+ } else if (entry.isFile()) {
287
+ files.push(fullPath);
288
+ }
289
+ }
290
+ } catch {
291
+ }
292
+ return files;
293
+ }
294
+
295
+ function detectChanges() {
296
+ const currentFiles = scanDirectory(workspace);
297
+ let hasChanges = false;
298
+
299
+ const currentPaths = new Set(currentFiles);
300
+
301
+ for (const filePath of currentFiles) {
302
+ const newHash = getFileHash(filePath);
303
+ const oldHash = fileHashes.get(filePath);
304
+
305
+ if (newHash !== oldHash) {
306
+ hasChanges = true;
307
+ fileHashes.set(filePath, newHash);
308
+ }
309
+ }
310
+
311
+ for (const oldPath of fileHashes.keys()) {
312
+ if (!currentPaths.has(oldPath)) {
313
+ hasChanges = true;
314
+ fileHashes.delete(oldPath);
315
+ }
316
+ }
317
+
318
+ return hasChanges;
319
+ }
320
+
321
+ async function doSync() {
322
+ if (isRunning) {
323
+ pendingSync = true;
324
+ return;
325
+ }
326
+
327
+ isRunning = true;
328
+ const now = new Date().toLocaleTimeString();
329
+ console.error(`[ctxce] [${now}] Syncing changes...`);
330
+
331
+ try {
332
+ const success = await runUploadClient(workspace, sessionId, uploadClient, python);
333
+ if (success) {
334
+ console.error(`[ctxce] [${now}] Sync complete.`);
335
+ } else {
336
+ console.error(`[ctxce] [${now}] Sync failed.`);
337
+ }
338
+ } catch (err) {
339
+ console.error(`[ctxce] [${now}] Sync error: ${err}`);
340
+ }
341
+
342
+ isRunning = false;
343
+ lastSyncTime = Date.now();
344
+
345
+ if (pendingSync) {
346
+ pendingSync = false;
347
+ setTimeout(doSync, DEFAULT_DEBOUNCE_MS);
348
+ }
349
+ }
350
+
351
+ scanDirectory(workspace).forEach(f => {
352
+ fileHashes.set(f, getFileHash(f));
353
+ });
354
+
355
+ const intervalId = setInterval(() => {
356
+ if (detectChanges()) {
357
+ doSync();
358
+ }
359
+ }, intervalMs);
360
+
361
+ const cleanup = () => {
362
+ clearInterval(intervalId);
363
+ console.error("\n[ctxce] Watcher stopped.");
364
+ };
365
+
366
+ process.on("SIGINT", () => {
367
+ cleanup();
368
+ process.exit(0);
369
+ });
370
+
371
+ process.on("SIGTERM", () => {
372
+ cleanup();
373
+ process.exit(0);
374
+ });
375
+
376
+ return { intervalId, cleanup };
377
+ }
378
+
379
+ function printSuccess() {
380
+ console.error("");
381
+ console.error("=".repeat(60));
382
+ console.error(" Context Engine connected!");
383
+ console.error("=".repeat(60));
384
+ console.error("");
385
+ console.error("Indexing complete. Watching for file changes...");
386
+ console.error("Press Ctrl+C to stop.");
387
+ console.error("");
388
+ }
389
+
390
+ function printUsage() {
391
+ console.error("Usage: ctxce connect <api-key> [options]");
392
+ console.error("");
393
+ console.error("Connect to Context Engine SaaS, index workspace, and watch for changes.");
394
+ console.error("");
395
+ console.error("Arguments:");
396
+ console.error(" <api-key> Your Context Engine API key");
397
+ console.error("");
398
+ console.error("Options:");
399
+ console.error(" --workspace, -w <path> Workspace path (default: current directory)");
400
+ console.error(" --interval <seconds> Sync interval in seconds (default: 30)");
401
+ console.error(" --no-watch, --once Index once and exit (don't watch for changes)");
402
+ console.error(" --skip-index Only authenticate, skip initial indexing");
403
+ console.error("");
404
+ console.error("Examples:");
405
+ console.error(" ctxce connect sk_abc123xyz");
406
+ console.error(" ctxce connect sk_abc123xyz -w /path/to/repo");
407
+ console.error(" ctxce connect sk_abc123xyz --once");
408
+ console.error("");
409
+ }
410
+
411
+ export async function runConnectCommand(args) {
412
+ const { apiKey, workspace, skipIndex, noWatch, watchInterval } = parseConnectArgs(args || []);
413
+
414
+ if (!apiKey) {
415
+ printUsage();
416
+ process.exit(1);
417
+ }
418
+
419
+ const resolvedWorkspace = path.resolve(workspace);
420
+ if (!fs.existsSync(resolvedWorkspace)) {
421
+ console.error(`[ctxce] Workspace path does not exist: ${resolvedWorkspace}`);
422
+ process.exit(1);
423
+ }
424
+
425
+ const authEntry = await authenticateWithApiKey(apiKey);
426
+ if (!authEntry) {
427
+ process.exit(1);
428
+ }
429
+
430
+ if (!skipIndex) {
431
+ const indexed = await triggerIndexing(resolvedWorkspace, authEntry.sessionId);
432
+ if (!indexed) {
433
+ console.error("[ctxce] Initial indexing failed, but will continue.");
434
+ }
435
+ }
436
+
437
+ if (noWatch) {
438
+ console.error("[ctxce] Done.");
439
+ return;
440
+ }
441
+
442
+ printSuccess();
443
+ startWatcher(resolvedWorkspace, authEntry.sessionId, watchInterval);
444
+ }
package/src/mcpServer.js CHANGED
@@ -282,6 +282,16 @@ function isTransientToolError(error) {
282
282
  return true;
283
283
  }
284
284
 
285
+ // StreamableHTTP transport errors after server restart
286
+ if (
287
+ lower.includes("failed to fetch") ||
288
+ lower.includes("fetch failed") ||
289
+ lower.includes("socket hang up") ||
290
+ lower.includes("aborted")
291
+ ) {
292
+ return true;
293
+ }
294
+
285
295
  if (typeof error.code === "number" && error.code === -32001 && !isSessionError(error)) {
286
296
  return true;
287
297
  }
@@ -298,6 +308,35 @@ function isTransientToolError(error) {
298
308
  return false;
299
309
  }
300
310
  }
311
+
312
+ /**
313
+ * Detect connection-level errors that indicate the underlying transport is dead
314
+ * and the client needs to be fully recreated (not just retried on the same socket).
315
+ */
316
+ function isConnectionDeadError(error) {
317
+ try {
318
+ const msg =
319
+ (error && typeof error.message === "string" && error.message) ||
320
+ (typeof error === "string" ? error : String(error || ""));
321
+ if (!msg) {
322
+ return false;
323
+ }
324
+ const lower = msg.toLowerCase();
325
+ return (
326
+ lower.includes("econnrefused") ||
327
+ lower.includes("econnreset") ||
328
+ lower.includes("socket hang up") ||
329
+ lower.includes("fetch failed") ||
330
+ lower.includes("failed to fetch") ||
331
+ lower.includes("ehostunreach") ||
332
+ lower.includes("enetunreach") ||
333
+ lower.includes("aborted")
334
+ );
335
+ } catch {
336
+ return false;
337
+ }
338
+ }
339
+
301
340
  // MCP stdio server implemented using the official MCP TypeScript SDK.
302
341
  // Acts as a low-level proxy for tools, forwarding tools/list and tools/call
303
342
  // to the remote qdrant-indexer MCP server while adding a local `ping` tool.
@@ -749,10 +788,27 @@ async function createBridgeServer(options) {
749
788
  }
750
789
  remote = await indexerClient.listTools();
751
790
  } catch (err) {
752
- debugLog("[ctxce] Error calling remote tools/list: " + String(err));
753
- const memoryToolsFallback = await listMemoryTools(memoryClient);
754
- const toolsFallback = dedupeTools([...memoryToolsFallback]);
755
- return { tools: toolsFallback };
791
+ // If the transport is dead (server restarted), recreate clients and retry
792
+ // once before falling back to memory-only tools.
793
+ if (isConnectionDeadError(err) || isSessionError(err)) {
794
+ debugLog("[ctxce] tools/list: connection/session error, recreating clients and retrying: " + String(err));
795
+ try {
796
+ await initializeRemoteClients(true);
797
+ if (indexerClient) {
798
+ remote = await indexerClient.listTools();
799
+ }
800
+ } catch (retryErr) {
801
+ debugLog("[ctxce] tools/list: retry after reconnect also failed: " + String(retryErr));
802
+ }
803
+ }
804
+
805
+ // If we still don't have remote tools, fall back to memory-only
806
+ if (!remote) {
807
+ debugLog("[ctxce] Error calling remote tools/list: " + String(err));
808
+ const memoryToolsFallback = await listMemoryTools(memoryClient);
809
+ const toolsFallback = dedupeTools([...memoryToolsFallback]);
810
+ return { tools: toolsFallback };
811
+ }
756
812
  }
757
813
 
758
814
  try {
@@ -821,6 +877,7 @@ async function createBridgeServer(options) {
821
877
  const maxAttempts = getBridgeRetryAttempts();
822
878
  const retryDelayMs = getBridgeRetryDelayMs();
823
879
  let sessionRetried = false;
880
+ let connectionRetried = false;
824
881
  let lastError;
825
882
 
826
883
  for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
@@ -846,6 +903,19 @@ async function createBridgeServer(options) {
846
903
  } catch (err) {
847
904
  lastError = err;
848
905
 
906
+ // Connection-level error (ECONNREFUSED, ECONNRESET, etc.) means the
907
+ // transport is dead (e.g. server restarted). Recreate clients so the
908
+ // next attempt uses a fresh connection.
909
+ if (isConnectionDeadError(err) && !connectionRetried) {
910
+ debugLog(
911
+ "[ctxce] tools/call: connection dead (server may have restarted); recreating clients and retrying: " +
912
+ String(err),
913
+ );
914
+ await initializeRemoteClients(true);
915
+ connectionRetried = true;
916
+ continue;
917
+ }
918
+
849
919
  if (isSessionError(err) && !sessionRetried) {
850
920
  debugLog(
851
921
  "[ctxce] tools/call: detected remote MCP session error; reinitializing clients and retrying once: " +