@context-engine-bridge/context-engine-mcp-bridge 0.0.54 → 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.
- package/package.json +1 -1
- package/src/mcpServer.js +93 -100
package/package.json
CHANGED
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:
|
|
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,65 +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
|
-
|
|
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 (!
|
|
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
|
-
|
|
1258
|
-
|
|
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
|
|
1303
|
-
const
|
|
1252
|
+
const primaryCollection = args && typeof args.collection === "string" ? args.collection : "";
|
|
1253
|
+
const primaryLower = primaryCollection.toLowerCase();
|
|
1304
1254
|
const additionalCollections = _FANOUT_TOOLS.has(name)
|
|
1305
|
-
? _loadSessionCollections(workspace).filter(c => c !==
|
|
1255
|
+
? _loadSessionCollections(workspace, defaultCollection).filter(c => c.toLowerCase() !== primaryLower).slice(0, MAX_FANOUT_COLLECTIONS)
|
|
1306
1256
|
: [];
|
|
1307
1257
|
const shouldFanOut = additionalCollections.length > 0 && indexerClient;
|
|
1308
1258
|
|
|
@@ -1338,13 +1288,14 @@ async function createBridgeServer(options) {
|
|
|
1338
1288
|
);
|
|
1339
1289
|
|
|
1340
1290
|
if (shouldFanOut) {
|
|
1341
|
-
const
|
|
1291
|
+
const fanOutCallTimeoutMs = Math.min(timeoutMs, 10000);
|
|
1292
|
+
const fanOutDeadlineMs = Math.round(fanOutCallTimeoutMs * 0.8);
|
|
1342
1293
|
const fanOutPromises = additionalCollections.map(col => {
|
|
1343
1294
|
const colArgs = { ...args, collection: col };
|
|
1344
1295
|
return targetClient.callTool(
|
|
1345
1296
|
{ name, arguments: colArgs },
|
|
1346
1297
|
undefined,
|
|
1347
|
-
{ timeout:
|
|
1298
|
+
{ timeout: fanOutCallTimeoutMs },
|
|
1348
1299
|
).catch(err => {
|
|
1349
1300
|
debugLog(`[ctxce] Fan-out to ${col} failed: ${String(err)}`);
|
|
1350
1301
|
return null;
|
|
@@ -1352,7 +1303,7 @@ async function createBridgeServer(options) {
|
|
|
1352
1303
|
});
|
|
1353
1304
|
|
|
1354
1305
|
let fanOutTimer;
|
|
1355
|
-
const fanOutDeadline = new Promise(resolve => { fanOutTimer = setTimeout(() => resolve(null),
|
|
1306
|
+
const fanOutDeadline = new Promise(resolve => { fanOutTimer = setTimeout(() => resolve(null), fanOutDeadlineMs); });
|
|
1356
1307
|
const fanOutResults = await Promise.race([
|
|
1357
1308
|
Promise.all(fanOutPromises),
|
|
1358
1309
|
fanOutDeadline.then(() => fanOutPromises.map(() => null)),
|
|
@@ -1409,9 +1360,6 @@ async function createBridgeServer(options) {
|
|
|
1409
1360
|
} catch (err) {
|
|
1410
1361
|
lastError = err;
|
|
1411
1362
|
|
|
1412
|
-
// Connection-level error (ECONNREFUSED, ECONNRESET, etc.) means the
|
|
1413
|
-
// transport is dead (e.g. server restarted). Recreate clients so the
|
|
1414
|
-
// next attempt uses a fresh connection.
|
|
1415
1363
|
if (isConnectionDeadError(err) && !connectionRetried) {
|
|
1416
1364
|
debugLog(
|
|
1417
1365
|
"[ctxce] tools/call: connection dead (server may have restarted); recreating clients and retrying: " +
|
|
@@ -1751,7 +1699,6 @@ export async function runHttpMcpServer(options) {
|
|
|
1751
1699
|
return;
|
|
1752
1700
|
}
|
|
1753
1701
|
|
|
1754
|
-
// Health check endpoint (used by VS Code extension health ping)
|
|
1755
1702
|
if (parsedUrl.pathname === "/health") {
|
|
1756
1703
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1757
1704
|
res.end(JSON.stringify({ status: "ok", sessions: sessions.size }));
|
|
@@ -1804,6 +1751,8 @@ export async function runHttpMcpServer(options) {
|
|
|
1804
1751
|
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
1805
1752
|
}
|
|
1806
1753
|
|
|
1754
|
+
const MAX_FANOUT_COLLECTIONS = 5;
|
|
1755
|
+
|
|
1807
1756
|
const _FANOUT_TOOLS = new Set([
|
|
1808
1757
|
"search", "repo_search", "code_search", "context_search",
|
|
1809
1758
|
"symbol_graph", "pattern_search",
|
|
@@ -1816,29 +1765,73 @@ let _sessionCollectionsCache = null;
|
|
|
1816
1765
|
let _sessionCollectionsMtime = 0;
|
|
1817
1766
|
let _sessionCollectionsCacheKey = "";
|
|
1818
1767
|
|
|
1819
|
-
function
|
|
1820
|
-
if (!
|
|
1768
|
+
function _findWorkspaceDirByCollection(collection) {
|
|
1769
|
+
if (!collection) return null;
|
|
1770
|
+
const wsRoot = path.join(os.homedir(), ".ctxce", "workspaces");
|
|
1821
1771
|
try {
|
|
1822
|
-
const
|
|
1823
|
-
const
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
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 (_) {}
|
|
1835
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 {
|
|
1836
1829
|
const raw = fs.readFileSync(filePath, "utf8");
|
|
1837
1830
|
const parsed = JSON.parse(raw);
|
|
1838
1831
|
if (Array.isArray(parsed)) {
|
|
1839
1832
|
_sessionCollectionsCache = parsed.filter(c => c && typeof c === "string" && c.length <= 128 && /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(c));
|
|
1840
1833
|
_sessionCollectionsMtime = mtime;
|
|
1841
|
-
_sessionCollectionsCacheKey =
|
|
1834
|
+
_sessionCollectionsCacheKey = cacheKey;
|
|
1842
1835
|
return _sessionCollectionsCache;
|
|
1843
1836
|
}
|
|
1844
1837
|
return [];
|