@context-engine-bridge/context-engine-mcp-bridge 0.0.55 → 0.0.56

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/mcpServer.js +91 -99
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@context-engine-bridge/context-engine-mcp-bridge",
3
- "version": "0.0.55",
3
+ "version": "0.0.56",
4
4
  "description": "Context Engine MCP bridge (http/stdio proxy combining indexer + memory servers)",
5
5
  "bin": {
6
6
  "ctxce": "bin/ctxce.js",
package/src/mcpServer.js CHANGED
@@ -47,6 +47,11 @@ function _lspCircuitRecordFailure() {
47
47
  }
48
48
  }
49
49
 
50
+ function _errorMessage(error) {
51
+ return (error && typeof error.message === "string" && error.message) ||
52
+ (typeof error === "string" ? error : String(error || ""));
53
+ }
54
+
50
55
  function _isValidPort(v) {
51
56
  const p = Number.parseInt(String(v), 10);
52
57
  return Number.isFinite(p) && p >= 1024 && p <= 65535;
@@ -97,6 +102,11 @@ function _readLspConnection(workspace) {
97
102
  return result;
98
103
  }
99
104
 
105
+ const _ALLOWED_LSP_OPS = new Set([
106
+ "diagnostics", "definition", "references", "hover",
107
+ "document_symbols", "workspace_symbols", "code_actions", "diagnostics_recent",
108
+ ]);
109
+
100
110
  const _LSP_ENRICHABLE_TOOLS = new Set([
101
111
  "batch_search", "batch_symbol_graph", "batch_graph_query",
102
112
  "repo_search", "repo_search_compat", "symbol_graph", "search",
@@ -137,7 +147,7 @@ function _extractPaths(obj, paths, workspace, depth = 0) {
137
147
  }
138
148
  }
139
149
 
140
- function _callLspHandler(port, secret, operation, params) {
150
+ function _callLspHandler(port, secret, operation, params, timeoutMs = 3000) {
141
151
  const postData = JSON.stringify(params);
142
152
  return new Promise((resolve) => {
143
153
  let settled = false;
@@ -152,7 +162,7 @@ function _callLspHandler(port, secret, operation, params) {
152
162
  "Content-Length": Buffer.byteLength(postData),
153
163
  ...(secret ? { "x-lsp-handler-token": secret } : {}),
154
164
  },
155
- timeout: 3000,
165
+ timeout: timeoutMs,
156
166
  }, (res) => {
157
167
  const chunks = [];
158
168
  let totalBytes = 0;
@@ -317,9 +327,7 @@ function selectClientForTool(name, indexerClient, memoryClient) {
317
327
 
318
328
  function isSessionError(error) {
319
329
  try {
320
- const msg =
321
- (error && typeof error.message === "string" && error.message) ||
322
- (typeof error === "string" ? error : String(error || ""));
330
+ const msg = _errorMessage(error);
323
331
  if (!msg) {
324
332
  return false;
325
333
  }
@@ -341,9 +349,7 @@ function isSessionError(error) {
341
349
  */
342
350
  function isAuthRejectionError(error) {
343
351
  try {
344
- const msg =
345
- (error && typeof error.message === "string" && error.message) ||
346
- (typeof error === "string" ? error : String(error || ""));
352
+ const msg = _errorMessage(error);
347
353
  if (!msg) {
348
354
  return false;
349
355
  }
@@ -362,9 +368,7 @@ function isAuthRejectionError(error) {
362
368
  * Includes the backend URL and a hint to sign in via VS Code command palette.
363
369
  */
364
370
  function formatAuthRejectionError(originalError, backendUrl) {
365
- const originalMsg =
366
- (originalError && typeof originalError.message === "string" && originalError.message) ||
367
- (typeof originalError === "string" ? originalError : String(originalError || "Unknown auth error"));
371
+ const originalMsg = _errorMessage(originalError) || "Unknown auth error";
368
372
 
369
373
  const serverInfo = backendUrl ? ` (server: ${backendUrl})` : "";
370
374
  const hint = "Run 'Context Engine: Sign In' from the VS Code command palette to authenticate.";
@@ -421,9 +425,7 @@ function getBridgeRetryDelayMs() {
421
425
 
422
426
  function isTransientToolError(error) {
423
427
  try {
424
- const msg =
425
- (error && typeof error.message === "string" && error.message) ||
426
- (typeof error === "string" ? error : String(error || ""));
428
+ const msg = _errorMessage(error);
427
429
  if (!msg) {
428
430
  return false;
429
431
  }
@@ -463,7 +465,6 @@ function isTransientToolError(error) {
463
465
  return true;
464
466
  }
465
467
 
466
- // StreamableHTTP transport errors after server restart
467
468
  if (
468
469
  lower.includes("failed to fetch") ||
469
470
  lower.includes("fetch failed") ||
@@ -490,15 +491,9 @@ function isTransientToolError(error) {
490
491
  }
491
492
  }
492
493
 
493
- /**
494
- * Detect connection-level errors that indicate the underlying transport is dead
495
- * and the client needs to be fully recreated (not just retried on the same socket).
496
- */
497
494
  function isConnectionDeadError(error) {
498
495
  try {
499
- const msg =
500
- (error && typeof error.message === "string" && error.message) ||
501
- (typeof error === "string" ? error : String(error || ""));
496
+ const msg = _errorMessage(error);
502
497
  if (!msg) {
503
498
  return false;
504
499
  }
@@ -781,7 +776,6 @@ async function createBridgeServer(options) {
781
776
  expiresAt > 0 &&
782
777
  expiresAt < Math.floor(Date.now() / 1000)
783
778
  ) {
784
- // Allow 5-minute grace period for clock skew; beyond that, reject
785
779
  const expiredSecs = Math.floor(Date.now() / 1000) - expiresAt;
786
780
  if (expiredSecs > 300) {
787
781
  debugLog(`[ctxce] Session expired ${expiredSecs}s ago (beyond 5min grace), triggering re-auth.`);
@@ -930,10 +924,6 @@ async function createBridgeServer(options) {
930
924
  }
931
925
  }
932
926
 
933
- // Build requestInit with Authorization header for nginx auth_request passthrough.
934
- // When the bridge connects to a remote endpoint (e.g. https://dev.example.com/indexer/mcp),
935
- // nginx's auth_request subrequest checks the Authorization header. Without it, the
936
- // connection is rejected with 401.
937
927
  const transportOpts = {};
938
928
  if (sessionId && !sessionId.startsWith("ctxce-")) {
939
929
  transportOpts.requestInit = {
@@ -943,7 +933,6 @@ async function createBridgeServer(options) {
943
933
  };
944
934
  debugLog(`[ctxce] Transport auth: injecting Authorization header (session ${sessionId.slice(0, 8)}...)`);
945
935
  } else {
946
- // Check for API key in environment as fallback
947
936
  const envApiKey = (process.env.CTXCE_API_KEY || process.env.CTXCE_AUTH_TOKEN || "").trim();
948
937
  if (envApiKey) {
949
938
  transportOpts.requestInit = {
@@ -1040,8 +1029,6 @@ async function createBridgeServer(options) {
1040
1029
  }
1041
1030
  remote = await indexerClient.listTools();
1042
1031
  } catch (err) {
1043
- // If the transport is dead (server restarted), recreate clients and retry
1044
- // once before falling back to memory-only tools.
1045
1032
  if (isConnectionDeadError(err) || isSessionError(err)) {
1046
1033
  debugLog("[ctxce] tools/list: connection/session error, recreating clients and retrying: " + String(err));
1047
1034
  try {
@@ -1054,7 +1041,6 @@ async function createBridgeServer(options) {
1054
1041
  }
1055
1042
  }
1056
1043
 
1057
- // If we still don't have remote tools, fall back to memory-only
1058
1044
  if (!remote) {
1059
1045
  debugLog("[ctxce] Error calling remote tools/list: " + String(err));
1060
1046
  const memoryToolsFallback = await listMemoryTools(memoryClient);
@@ -1244,66 +1230,29 @@ async function createBridgeServer(options) {
1244
1230
  return { content: [{ type: "text", text: JSON.stringify({ ok: false, error: "LSP proxy temporarily unavailable (circuit breaker open after repeated failures). Will retry automatically." }) }] };
1245
1231
  }
1246
1232
  const lspConn = _readLspConnection(workspace);
1247
- const lspPort = lspConn ? lspConn.port : null;
1248
- const lspSecret = lspConn ? lspConn.secret : "";
1249
- if (!lspPort) {
1233
+ if (!lspConn) {
1250
1234
  return { content: [{ type: "text", text: JSON.stringify({ ok: false, error: "LSP proxy not available. Ensure VS Code extension has lsp.enabled=true." }) }] };
1251
1235
  }
1252
- const ALLOWED_LSP_OPS = new Set(['diagnostics','definition','references','hover','document_symbols','workspace_symbols','code_actions','diagnostics_recent']);
1253
1236
  const operation = name.toLowerCase().replace(/^lsp_/, "");
1254
- if (!ALLOWED_LSP_OPS.has(operation)) {
1237
+ if (!_ALLOWED_LSP_OPS.has(operation)) {
1255
1238
  return { content: [{ type: "text", text: JSON.stringify({ ok: false, error: "Unknown LSP operation" }) }] };
1256
1239
  }
1257
- try {
1258
- const lspArgs = args || {};
1259
- const postData = JSON.stringify(lspArgs);
1260
- const result = await new Promise((resolve, reject) => {
1261
- const req = http.request({
1262
- hostname: "127.0.0.1",
1263
- port: parseInt(lspPort, 10),
1264
- path: `/lsp/${operation}`,
1265
- method: "POST",
1266
- headers: Object.assign({ "Content-Type": "application/json", "Content-Length": Buffer.byteLength(postData) }, lspSecret ? { "x-lsp-handler-token": lspSecret } : {}),
1267
- timeout: LSP_DIRECT_TIMEOUT_MS,
1268
- }, (res) => {
1269
- const chunks = [];
1270
- let totalBytes = 0;
1271
- let exceeded = false;
1272
- const MAX_RESP = 5 * 1024 * 1024;
1273
- res.on("error", () => { if (!exceeded) resolve({ ok: false, error: "LSP response stream error" }); });
1274
- res.on("data", chunk => {
1275
- if (exceeded) return;
1276
- const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
1277
- totalBytes += buf.length;
1278
- if (totalBytes > MAX_RESP) { exceeded = true; req.destroy(); resolve({ ok: false, error: "Response too large" }); return; }
1279
- chunks.push(buf);
1280
- });
1281
- res.on("end", () => {
1282
- if (!exceeded) { try { resolve(JSON.parse(Buffer.concat(chunks).toString("utf8"))); } catch { resolve({ ok: false, error: "Invalid response from LSP handler" }); } }
1283
- });
1284
- });
1285
- req.on("error", reject);
1286
- req.on("timeout", () => { req.destroy(); reject(new Error("LSP request timeout")); });
1287
- req.write(postData);
1288
- req.end();
1289
- });
1240
+ const result = await _callLspHandler(lspConn.port, lspConn.secret, operation, args || {}, LSP_DIRECT_TIMEOUT_MS);
1241
+ if (result) {
1290
1242
  _lspCircuitRecordSuccess();
1291
1243
  return { content: [{ type: "text", text: JSON.stringify(result) }] };
1292
- } catch (err) {
1293
- _lspCircuitRecordFailure();
1294
- debugLog("[ctxce] LSP proxy error: " + String(err));
1295
- const safeMsg = (err && err.code) ? `LSP proxy error: ${err.code}` : "LSP proxy error: request failed";
1296
- return { content: [{ type: "text", text: JSON.stringify({ ok: false, error: safeMsg }) }] };
1297
1244
  }
1245
+ _lspCircuitRecordFailure();
1246
+ debugLog("[ctxce] LSP proxy error: request returned null");
1247
+ return { content: [{ type: "text", text: JSON.stringify({ ok: false, error: "LSP proxy error: request failed" }) }] };
1298
1248
  }
1299
1249
 
1300
1250
  await initializeRemoteClients(false);
1301
1251
 
1302
- const MAX_FANOUT_COLLECTIONS = 5;
1303
1252
  const primaryCollection = args && typeof args.collection === "string" ? args.collection : "";
1304
1253
  const primaryLower = primaryCollection.toLowerCase();
1305
1254
  const additionalCollections = _FANOUT_TOOLS.has(name)
1306
- ? _loadSessionCollections(workspace).filter(c => c.toLowerCase() !== primaryLower).slice(0, MAX_FANOUT_COLLECTIONS)
1255
+ ? _loadSessionCollections(workspace, defaultCollection).filter(c => c.toLowerCase() !== primaryLower).slice(0, MAX_FANOUT_COLLECTIONS)
1307
1256
  : [];
1308
1257
  const shouldFanOut = additionalCollections.length > 0 && indexerClient;
1309
1258
 
@@ -1339,13 +1288,14 @@ async function createBridgeServer(options) {
1339
1288
  );
1340
1289
 
1341
1290
  if (shouldFanOut) {
1342
- const fanOutTimeoutMs = Math.min(timeoutMs, 10000);
1291
+ const fanOutCallTimeoutMs = Math.min(timeoutMs, 10000);
1292
+ const fanOutDeadlineMs = Math.round(fanOutCallTimeoutMs * 0.8);
1343
1293
  const fanOutPromises = additionalCollections.map(col => {
1344
1294
  const colArgs = { ...args, collection: col };
1345
1295
  return targetClient.callTool(
1346
1296
  { name, arguments: colArgs },
1347
1297
  undefined,
1348
- { timeout: fanOutTimeoutMs },
1298
+ { timeout: fanOutCallTimeoutMs },
1349
1299
  ).catch(err => {
1350
1300
  debugLog(`[ctxce] Fan-out to ${col} failed: ${String(err)}`);
1351
1301
  return null;
@@ -1353,7 +1303,7 @@ async function createBridgeServer(options) {
1353
1303
  });
1354
1304
 
1355
1305
  let fanOutTimer;
1356
- const fanOutDeadline = new Promise(resolve => { fanOutTimer = setTimeout(() => resolve(null), fanOutTimeoutMs); });
1306
+ const fanOutDeadline = new Promise(resolve => { fanOutTimer = setTimeout(() => resolve(null), fanOutDeadlineMs); });
1357
1307
  const fanOutResults = await Promise.race([
1358
1308
  Promise.all(fanOutPromises),
1359
1309
  fanOutDeadline.then(() => fanOutPromises.map(() => null)),
@@ -1410,9 +1360,6 @@ async function createBridgeServer(options) {
1410
1360
  } catch (err) {
1411
1361
  lastError = err;
1412
1362
 
1413
- // Connection-level error (ECONNREFUSED, ECONNRESET, etc.) means the
1414
- // transport is dead (e.g. server restarted). Recreate clients so the
1415
- // next attempt uses a fresh connection.
1416
1363
  if (isConnectionDeadError(err) && !connectionRetried) {
1417
1364
  debugLog(
1418
1365
  "[ctxce] tools/call: connection dead (server may have restarted); recreating clients and retrying: " +
@@ -1752,7 +1699,6 @@ export async function runHttpMcpServer(options) {
1752
1699
  return;
1753
1700
  }
1754
1701
 
1755
- // Health check endpoint (used by VS Code extension health ping)
1756
1702
  if (parsedUrl.pathname === "/health") {
1757
1703
  res.writeHead(200, { "Content-Type": "application/json" });
1758
1704
  res.end(JSON.stringify({ status: "ok", sessions: sessions.size }));
@@ -1805,6 +1751,8 @@ export async function runHttpMcpServer(options) {
1805
1751
  process.on("SIGINT", () => shutdown("SIGINT"));
1806
1752
  }
1807
1753
 
1754
+ const MAX_FANOUT_COLLECTIONS = 5;
1755
+
1808
1756
  const _FANOUT_TOOLS = new Set([
1809
1757
  "search", "repo_search", "code_search", "context_search",
1810
1758
  "symbol_graph", "pattern_search",
@@ -1817,29 +1765,73 @@ let _sessionCollectionsCache = null;
1817
1765
  let _sessionCollectionsMtime = 0;
1818
1766
  let _sessionCollectionsCacheKey = "";
1819
1767
 
1820
- function _loadSessionCollections(workspacePath) {
1821
- if (!workspacePath) return [];
1768
+ function _findWorkspaceDirByCollection(collection) {
1769
+ if (!collection) return null;
1770
+ const wsRoot = path.join(os.homedir(), ".ctxce", "workspaces");
1822
1771
  try {
1823
- const wsDir = _computeWorkspaceDir(workspacePath);
1824
- const filePath = path.join(wsDir, "session_collections.json");
1825
- let mtime = 0;
1826
- try {
1827
- mtime = fs.statSync(filePath).mtimeMs;
1828
- } catch (_) {
1829
- _sessionCollectionsCache = null;
1830
- _sessionCollectionsMtime = 0;
1831
- _sessionCollectionsCacheKey = "";
1832
- return [];
1833
- }
1834
- if (_sessionCollectionsCache && mtime === _sessionCollectionsMtime && _sessionCollectionsCacheKey === workspacePath) {
1835
- return _sessionCollectionsCache;
1772
+ const realWsRoot = fs.realpathSync(wsRoot);
1773
+ const dirs = fs.readdirSync(wsRoot).slice(0, MAX_WS_SCAN);
1774
+ for (const dir of dirs) {
1775
+ if (dir === "." || dir === ".." || dir.includes("/") || dir.includes("\0")) continue;
1776
+ const dirPath = path.join(wsRoot, dir);
1777
+ try {
1778
+ const realDir = fs.realpathSync(dirPath);
1779
+ if (realDir !== realWsRoot && !realDir.startsWith(realWsRoot + path.sep)) continue;
1780
+ if (!fs.lstatSync(realDir).isDirectory()) continue;
1781
+ } catch (_) { continue; }
1782
+ try {
1783
+ const cfg = JSON.parse(fs.readFileSync(path.join(realDir, "ctx_config.json"), "utf8"));
1784
+ if (cfg && cfg.default_collection === collection) {
1785
+ return realDir;
1786
+ }
1787
+ } catch (_) {}
1836
1788
  }
1789
+ } catch (_) {}
1790
+ return null;
1791
+ }
1792
+
1793
+ function _loadSessionCollections(workspacePath, fallbackCollection) {
1794
+ if (!workspacePath && !fallbackCollection) return [];
1795
+
1796
+ let wsDir = workspacePath ? _computeWorkspaceDir(workspacePath) : null;
1797
+ let filePath = wsDir ? path.join(wsDir, "session_collections.json") : null;
1798
+ let mtime = 0;
1799
+
1800
+ if (filePath) {
1801
+ try { mtime = fs.statSync(filePath).mtimeMs; } catch (_) { mtime = 0; }
1802
+ }
1803
+
1804
+ if (!mtime && fallbackCollection) {
1805
+ const altDir = _findWorkspaceDirByCollection(fallbackCollection);
1806
+ if (altDir) {
1807
+ wsDir = altDir;
1808
+ filePath = path.join(altDir, "session_collections.json");
1809
+ try { mtime = fs.statSync(filePath).mtimeMs; } catch (_) { mtime = 0; }
1810
+ if (mtime) {
1811
+ debugLog("[ctxce] Found session_collections via collection lookup: " + altDir);
1812
+ }
1813
+ }
1814
+ }
1815
+
1816
+ if (!mtime || !filePath) {
1817
+ _sessionCollectionsCache = null;
1818
+ _sessionCollectionsMtime = 0;
1819
+ _sessionCollectionsCacheKey = "";
1820
+ return [];
1821
+ }
1822
+
1823
+ const cacheKey = filePath;
1824
+ if (_sessionCollectionsCache && mtime === _sessionCollectionsMtime && _sessionCollectionsCacheKey === cacheKey) {
1825
+ return _sessionCollectionsCache;
1826
+ }
1827
+
1828
+ try {
1837
1829
  const raw = fs.readFileSync(filePath, "utf8");
1838
1830
  const parsed = JSON.parse(raw);
1839
1831
  if (Array.isArray(parsed)) {
1840
1832
  _sessionCollectionsCache = parsed.filter(c => c && typeof c === "string" && c.length <= 128 && /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(c));
1841
1833
  _sessionCollectionsMtime = mtime;
1842
- _sessionCollectionsCacheKey = workspacePath;
1834
+ _sessionCollectionsCacheKey = cacheKey;
1843
1835
  return _sessionCollectionsCache;
1844
1836
  }
1845
1837
  return [];