@context-engine-bridge/context-engine-mcp-bridge 0.0.13 → 0.0.15

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/AGENTS.md ADDED
@@ -0,0 +1,18 @@
1
+ <!-- Parent: ../AGENTS.md -->
2
+ # MCP Bridge CLI (ctxce)
3
+
4
+ Node package that exposes indexer+memory as a single MCP server (stdio + HTTP). Optional OAuth/PKCE.
5
+
6
+ ## WHERE TO LOOK
7
+
8
+ | Task | Location | Notes |
9
+ |------|----------|-------|
10
+ | CLI entry | `ctx-mcp-bridge/bin/ctxce.js` | wrapper -> src/cli |
11
+ | CLI logic | `ctx-mcp-bridge/src/cli.js` | command parsing |
12
+ | MCP proxy | `ctx-mcp-bridge/src/mcpServer.js` | forwards tools |
13
+ | OAuth/PKCE | `ctx-mcp-bridge/src/oauthHandler.js` | auth flow |
14
+ | Path mapping | `ctx-mcp-bridge/src/resultPathMapping.js` | local <-> remote |
15
+
16
+ ## KNOWN TODO
17
+
18
+ - PKCE verifier validation is still TODO in `ctx-mcp-bridge/src/oauthHandler.js`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@context-engine-bridge/context-engine-mcp-bridge",
3
- "version": "0.0.13",
3
+ "version": "0.0.15",
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/authConfig.js CHANGED
@@ -11,7 +11,7 @@ function getConfigPath() {
11
11
  return path.join(dir, CONFIG_BASENAME);
12
12
  }
13
13
 
14
- function readConfig() {
14
+ export function readConfig() {
15
15
  try {
16
16
  const cfgPath = getConfigPath();
17
17
  const raw = fs.readFileSync(cfgPath, "utf8");
package/src/mcpServer.js CHANGED
@@ -9,7 +9,7 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/
9
9
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
10
10
  import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
11
11
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
12
- import { loadAnyAuthEntry, loadAuthEntry } from "./authConfig.js";
12
+ import { loadAnyAuthEntry, loadAuthEntry, readConfig, saveAuthEntry } from "./authConfig.js";
13
13
  import { maybeRemapToolArgs, maybeRemapToolResult } from "./resultPathMapping.js";
14
14
  import * as oauthHandler from "./oauthHandler.js";
15
15
 
@@ -63,11 +63,7 @@ async function listMemoryTools(client) {
63
63
  return [];
64
64
  }
65
65
  try {
66
- const remote = await withTimeout(
67
- client.listTools(),
68
- 5000,
69
- "memory tools/list",
70
- );
66
+ const remote = await client.listTools();
71
67
  return Array.isArray(remote?.tools) ? remote.tools.slice() : [];
72
68
  } catch (err) {
73
69
  debugLog("[ctxce] Error calling memory tools/list: " + String(err));
@@ -113,15 +109,15 @@ function getBridgeToolTimeoutMs() {
113
109
  try {
114
110
  const raw = process.env.CTXCE_TOOL_TIMEOUT_MSEC;
115
111
  if (!raw) {
116
- return 300000;
112
+ return 600000; // 10 minutes default for remote operations
117
113
  }
118
114
  const parsed = Number.parseInt(String(raw), 10);
119
115
  if (!Number.isFinite(parsed) || parsed <= 0) {
120
- return 300000;
116
+ return 600000;
121
117
  }
122
118
  return parsed;
123
119
  } catch {
124
- return 300000;
120
+ return 600000;
125
121
  }
126
122
  }
127
123
 
@@ -130,7 +126,7 @@ function selectClientForTool(name, indexerClient, memoryClient) {
130
126
  return indexerClient;
131
127
  }
132
128
  const lowered = name.toLowerCase();
133
- if (memoryClient && (lowered.startsWith("memory.") || lowered.startsWith("mcp_memory_") || lowered.includes("memory"))) {
129
+ if (memoryClient && (lowered.startsWith("memory.") || lowered.startsWith("mcp_memory_"))) {
134
130
  return memoryClient;
135
131
  }
136
132
  return indexerClient;
@@ -155,11 +151,34 @@ function isSessionError(error) {
155
151
  }
156
152
  }
157
153
 
154
+ /**
155
+ * Detect actual backend auth rejection (from mcp_auth.py ValidationError).
156
+ * These indicate the session is truly invalid on the backend, not just
157
+ * an MCP SDK transport issue that can be fixed by reinit.
158
+ */
159
+ function isAuthRejectionError(error) {
160
+ try {
161
+ const msg =
162
+ (error && typeof error.message === "string" && error.message) ||
163
+ (typeof error === "string" ? error : String(error || ""));
164
+ if (!msg) {
165
+ return false;
166
+ }
167
+ return (
168
+ msg.includes("Invalid or expired session") ||
169
+ msg.includes("Missing session for authorized operation") ||
170
+ msg.includes("Not authenticated")
171
+ );
172
+ } catch {
173
+ return false;
174
+ }
175
+ }
176
+
158
177
  function getBridgeRetryAttempts() {
159
178
  try {
160
179
  const raw = process.env.CTXCE_TOOL_RETRY_ATTEMPTS;
161
180
  if (!raw) {
162
- return 2;
181
+ return 3; // 3 attempts for better reliability on remote
163
182
  }
164
183
  const parsed = Number.parseInt(String(raw), 10);
165
184
  if (!Number.isFinite(parsed) || parsed <= 0) {
@@ -167,7 +186,7 @@ function getBridgeRetryAttempts() {
167
186
  }
168
187
  return parsed;
169
188
  } catch {
170
- return 2;
189
+ return 3;
171
190
  }
172
191
  }
173
192
 
@@ -175,7 +194,7 @@ function getBridgeRetryDelayMs() {
175
194
  try {
176
195
  const raw = process.env.CTXCE_TOOL_RETRY_DELAY_MSEC;
177
196
  if (!raw) {
178
- return 200;
197
+ return 1000; // 1 second delay between retries for remote
179
198
  }
180
199
  const parsed = Number.parseInt(String(raw), 10);
181
200
  if (!Number.isFinite(parsed) || parsed < 0) {
@@ -183,7 +202,7 @@ function getBridgeRetryDelayMs() {
183
202
  }
184
203
  return parsed;
185
204
  } catch {
186
- return 200;
205
+ return 1000;
187
206
  }
188
207
  }
189
208
 
@@ -255,6 +274,15 @@ const ADMIN_SESSION_COOKIE_NAME = "ctxce_session";
255
274
  const SLUGGED_REPO_RE = /.+-[0-9a-f]{16}(?:_old)?$/i;
256
275
  const BRIDGE_STATE_TOKEN = (process.env.CTXCE_BRIDGE_STATE_TOKEN || "").trim();
257
276
 
277
+ function getHostname(candidate) {
278
+ try {
279
+ const url = new URL(candidate);
280
+ return url.hostname;
281
+ } catch {
282
+ return "";
283
+ }
284
+ }
285
+
258
286
  function normalizeBackendUrl(candidate) {
259
287
  const trimmed = (candidate || "").trim();
260
288
  if (!trimmed) {
@@ -271,11 +299,35 @@ function normalizeBackendUrl(candidate) {
271
299
  return trimmed.replace(/\/+$/, "");
272
300
  }
273
301
 
274
- function resolveAuthBackendContext() {
302
+ function resolveAuthBackendContext(indexerUrl, memoryUrl) {
275
303
  const envBackend = normalizeBackendUrl(process.env.CTXCE_AUTH_BACKEND_URL || "");
276
304
  if (envBackend) {
277
305
  return { backendUrl: envBackend, source: "CTXCE_AUTH_BACKEND_URL" };
278
306
  }
307
+
308
+ // If no env override, try to find a saved session.
309
+ // We prefer one that matches the host of indexer or memory URL if they look like backend roots.
310
+ const targetHosts = new Set();
311
+ const ih = getHostname(indexerUrl);
312
+ if (ih) {
313
+ targetHosts.add(ih);
314
+ }
315
+ const mh = getHostname(memoryUrl);
316
+ if (mh) {
317
+ targetHosts.add(mh);
318
+ }
319
+
320
+ if (targetHosts.size > 0) {
321
+ const all = readConfig();
322
+ const backends = Object.keys(all);
323
+ for (const backendUrl of backends) {
324
+ const bh = getHostname(backendUrl);
325
+ if (bh && targetHosts.has(bh)) {
326
+ return { backendUrl, source: "auth_entry_host_match" };
327
+ }
328
+ }
329
+ }
330
+
279
331
  try {
280
332
  const any = loadAnyAuthEntry();
281
333
  const stored = normalizeBackendUrl(any?.backendUrl || "");
@@ -288,32 +340,22 @@ function resolveAuthBackendContext() {
288
340
  return { backendUrl: "", source: "" };
289
341
  }
290
342
 
291
- const {
292
- backendUrl: AUTH_BACKEND_URL,
293
- source: AUTH_BACKEND_SOURCE,
294
- } = resolveAuthBackendContext();
295
- const UPLOAD_SERVICE_URL = AUTH_BACKEND_URL;
296
- const UPLOAD_AUTH_BACKEND = AUTH_BACKEND_URL;
297
-
298
- if (UPLOAD_SERVICE_URL) {
299
- debugLog(`[ctxce] Upload/auth backend resolved from ${AUTH_BACKEND_SOURCE}: ${UPLOAD_SERVICE_URL}`);
300
- } else {
301
- debugLog("[ctxce] No auth backend detected; bridge/state overrides disabled.");
302
- }
303
-
304
343
  async function fetchBridgeCollectionState({
305
344
  workspace,
306
345
  collection,
307
346
  sessionId,
308
347
  repoName,
309
348
  bridgeStateToken,
349
+ backendHint,
350
+ uploadServiceUrl,
310
351
  }) {
311
352
  try {
312
- if (!UPLOAD_SERVICE_URL) {
353
+ if (!uploadServiceUrl) {
313
354
  debugLog("[ctxce] Skipping bridge/state fetch: no upload endpoint configured.");
314
355
  return null;
315
356
  }
316
- const url = new URL("/bridge/state", UPLOAD_SERVICE_URL);
357
+ const url = new URL("/bridge/state", uploadServiceUrl);
358
+ // ...
317
359
  if (collection && collection.trim()) {
318
360
  url.searchParams.set("collection", collection.trim());
319
361
  } else if (workspace && workspace.trim()) {
@@ -341,8 +383,18 @@ async function fetchBridgeCollectionState({
341
383
  if (!resp.ok) {
342
384
  if (resp.status === 401 || resp.status === 403) {
343
385
  debugLog(
344
- `[ctxce] /bridge/state responded ${resp.status}; missing or invalid token/session, falling back to ctx_config defaults.`,
386
+ `[ctxce] /bridge/state responded ${resp.status}; missing or invalid token/session, marking local session as expired.`,
345
387
  );
388
+ if (backendHint) {
389
+ try {
390
+ const entry = loadAuthEntry(backendHint);
391
+ if (entry) {
392
+ saveAuthEntry(backendHint, { ...entry, expiresAt: 1 });
393
+ }
394
+ } catch {
395
+ // ignore failures
396
+ }
397
+ }
346
398
  return null;
347
399
  }
348
400
  throw new Error(`bridge/state responded ${resp.status}`);
@@ -389,9 +441,23 @@ async function createBridgeServer(options) {
389
441
  // future this can be made user-aware (e.g. from auth), but for now we
390
442
  // keep it deterministic per workspace to help the indexer reuse
391
443
  // session-scoped defaults.
444
+ const {
445
+ backendUrl: authBackendUrl,
446
+ source: authBackendSource,
447
+ } = resolveAuthBackendContext(indexerUrl, memoryUrl);
448
+
449
+ const uploadServiceUrl = authBackendUrl;
450
+ const uploadAuthBackend = authBackendUrl;
451
+
452
+ if (uploadServiceUrl) {
453
+ debugLog(`[ctxce] Upload/auth backend resolved from ${authBackendSource}: ${uploadServiceUrl}`);
454
+ } else {
455
+ debugLog("[ctxce] No auth backend detected; bridge/state overrides disabled.");
456
+ }
457
+
392
458
  const explicitSession = process.env.CTXCE_SESSION_ID || "";
393
459
  const authBackendEnv = (process.env.CTXCE_AUTH_BACKEND_URL || "").trim();
394
- let backendHint = authBackendEnv || UPLOAD_AUTH_BACKEND || "";
460
+ let backendHint = authBackendEnv || uploadAuthBackend || "";
395
461
  let sessionId = explicitSession;
396
462
 
397
463
  function sessionFromEntry(entry) {
@@ -446,7 +512,7 @@ async function createBridgeServer(options) {
446
512
  if (explicit) {
447
513
  return explicit;
448
514
  }
449
- return findSavedSession([backendHint, UPLOAD_AUTH_BACKEND, authBackendEnv]);
515
+ return findSavedSession([backendHint, uploadAuthBackend, authBackendEnv]);
450
516
  }
451
517
 
452
518
  if (!sessionId) {
@@ -473,6 +539,8 @@ async function createBridgeServer(options) {
473
539
  sessionId,
474
540
  repoName,
475
541
  bridgeStateToken: BRIDGE_STATE_TOKEN,
542
+ backendHint,
543
+ uploadServiceUrl,
476
544
  });
477
545
  if (state) {
478
546
  const serving = state.serving_collection || state.active_collection;
@@ -502,10 +570,23 @@ async function createBridgeServer(options) {
502
570
  }
503
571
 
504
572
  if (forceRecreate) {
505
- try {
506
- debugLog("[ctxce] Reinitializing remote MCP clients after session error.");
507
- } catch {
508
- // ignore logging failures
573
+ debugLog("[ctxce] Reinitializing remote MCP clients after session error.");
574
+
575
+ if (indexerClient) {
576
+ try {
577
+ await indexerClient.close();
578
+ } catch {
579
+ // ignore close errors
580
+ }
581
+ indexerClient = null;
582
+ }
583
+ if (memoryClient) {
584
+ try {
585
+ await memoryClient.close();
586
+ } catch {
587
+ // ignore close errors
588
+ }
589
+ memoryClient = null;
509
590
  }
510
591
  }
511
592
 
@@ -592,11 +673,7 @@ async function createBridgeServer(options) {
592
673
  if (!indexerClient) {
593
674
  throw new Error("Indexer MCP client not initialized");
594
675
  }
595
- remote = await withTimeout(
596
- indexerClient.listTools(),
597
- 10000,
598
- "indexer tools/list",
599
- );
676
+ remote = await indexerClient.listTools();
600
677
  } catch (err) {
601
678
  debugLog("[ctxce] Error calling remote tools/list: " + String(err));
602
679
  const memoryToolsFallback = await listMemoryTools(memoryClient);
@@ -695,20 +772,38 @@ async function createBridgeServer(options) {
695
772
  if (isSessionError(err) && !sessionRetried) {
696
773
  debugLog(
697
774
  "[ctxce] tools/call: detected remote MCP session error; reinitializing clients and retrying once: " +
698
- String(err),
775
+ String(err),
699
776
  );
700
777
  await initializeRemoteClients(true);
701
778
  sessionRetried = true;
702
779
  continue;
703
780
  }
704
781
 
782
+ // Backend auth rejection (mcp_auth.py ValidationError) - expire local auth
783
+ if (isAuthRejectionError(err)) {
784
+ debugLog(
785
+ "[ctxce] tools/call: backend auth rejection; marking local session as expired: " +
786
+ String(err),
787
+ );
788
+ if (backendHint) {
789
+ try {
790
+ const entry = loadAuthEntry(backendHint);
791
+ if (entry) {
792
+ saveAuthEntry(backendHint, { ...entry, expiresAt: 1 });
793
+ }
794
+ } catch {
795
+ // ignore failures
796
+ }
797
+ }
798
+ }
799
+
705
800
  if (!isTransientToolError(err) || attempt === maxAttempts - 1) {
706
801
  throw err;
707
802
  }
708
803
 
709
804
  debugLog(
710
805
  `[ctxce] tools/call: transient error (attempt ${attempt + 1}/${maxAttempts}), retrying: ` +
711
- String(err),
806
+ String(err),
712
807
  );
713
808
  // Loop will retry
714
809
  }
@@ -830,11 +925,28 @@ export async function runHttpMcpServer(options) {
830
925
  return;
831
926
  }
832
927
 
928
+ const MAX_BODY_SIZE = 10 * 1024 * 1024; // 10MB
833
929
  let body = "";
930
+ let bodyLimitExceeded = false;
834
931
  req.on("data", (chunk) => {
932
+ if (bodyLimitExceeded) return;
835
933
  body += chunk;
934
+ if (body.length > MAX_BODY_SIZE) {
935
+ bodyLimitExceeded = true;
936
+ req.destroy();
937
+ res.statusCode = 413;
938
+ res.setHeader("Content-Type", "application/json");
939
+ res.end(
940
+ JSON.stringify({
941
+ jsonrpc: "2.0",
942
+ error: { code: -32000, message: "Request body too large" },
943
+ id: null,
944
+ }),
945
+ );
946
+ }
836
947
  });
837
948
  req.on("end", async () => {
949
+ if (bodyLimitExceeded) return;
838
950
  let parsed;
839
951
  try {
840
952
  parsed = body ? JSON.parse(body) : {};
@@ -902,6 +1014,25 @@ export async function runHttpMcpServer(options) {
902
1014
  httpServer.listen(port, '127.0.0.1', () => {
903
1015
  debugLog(`[ctxce] HTTP MCP bridge listening on 127.0.0.1:${port}`);
904
1016
  });
1017
+
1018
+ let shuttingDown = false;
1019
+ const shutdown = (signal) => {
1020
+ if (shuttingDown) return;
1021
+ shuttingDown = true;
1022
+ debugLog(`[ctxce] Received ${signal}; closing HTTP server (waiting for in-flight requests).`);
1023
+ httpServer.close(() => {
1024
+ debugLog("[ctxce] HTTP server closed.");
1025
+ process.exit(0);
1026
+ });
1027
+ const SHUTDOWN_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes for long MCP calls
1028
+ setTimeout(() => {
1029
+ debugLog("[ctxce] Forcing exit after shutdown timeout.");
1030
+ process.exit(1);
1031
+ }, SHUTDOWN_TIMEOUT_MS).unref();
1032
+ };
1033
+
1034
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
1035
+ process.on("SIGINT", () => shutdown("SIGINT"));
905
1036
  }
906
1037
 
907
1038
  function loadConfig(startDir) {
@@ -16,24 +16,97 @@ const pendingCodes = new Map();
16
16
  // Maps client_id to client info
17
17
  const registeredClients = new Map();
18
18
 
19
+ // ============================================================================
20
+ // Storage Limits and Cleanup Configuration
21
+ // ============================================================================
22
+
23
+ const MAX_TOKEN_STORE_SIZE = 10000;
24
+ const MAX_PENDING_CODES_SIZE = 1000;
25
+ const MAX_REGISTERED_CLIENTS_SIZE = 1000;
26
+ const TOKEN_EXPIRY_MS = 86400000; // 24 hours
27
+ const CODE_EXPIRY_MS = 600000; // 10 minutes
28
+ const CLIENT_EXPIRY_MS = 7 * 86400000; // 7 days
29
+ const CLEANUP_INTERVAL_MS = 300000; // 5 minutes
30
+
31
+ // Cleanup interval reference (for cleanup on shutdown if needed)
32
+ let cleanupIntervalId = null;
33
+
19
34
  // ============================================================================
20
35
  // OAuth Utilities
21
36
  // ============================================================================
22
37
 
23
- /**
24
- * Clean up expired tokens from tokenStore
25
- * Called periodically to prevent unbounded memory growth
26
- */
27
38
  function cleanupExpiredTokens() {
28
39
  const now = Date.now();
29
- const expiryMs = 86400000; // 24 hours
30
40
  for (const [token, data] of tokenStore.entries()) {
31
- if (now - data.createdAt > expiryMs) {
41
+ if (now - data.createdAt > TOKEN_EXPIRY_MS) {
32
42
  tokenStore.delete(token);
33
43
  }
34
44
  }
35
45
  }
36
46
 
47
+ function cleanupExpiredCodes() {
48
+ const now = Date.now();
49
+ for (const [code, data] of pendingCodes.entries()) {
50
+ if (now - data.createdAt > CODE_EXPIRY_MS) {
51
+ pendingCodes.delete(code);
52
+ }
53
+ }
54
+ }
55
+
56
+ function cleanupExpiredClients() {
57
+ const now = Date.now();
58
+ for (const [clientId, data] of registeredClients.entries()) {
59
+ if (now - data.createdAt > CLIENT_EXPIRY_MS) {
60
+ registeredClients.delete(clientId);
61
+ }
62
+ }
63
+ }
64
+
65
+ function enforceStorageLimits() {
66
+ if (tokenStore.size > MAX_TOKEN_STORE_SIZE) {
67
+ const entries = [...tokenStore.entries()].sort((a, b) => a[1].createdAt - b[1].createdAt);
68
+ const toRemove = entries.slice(0, tokenStore.size - MAX_TOKEN_STORE_SIZE);
69
+ for (const [key] of toRemove) {
70
+ tokenStore.delete(key);
71
+ }
72
+ }
73
+ if (pendingCodes.size > MAX_PENDING_CODES_SIZE) {
74
+ const entries = [...pendingCodes.entries()].sort((a, b) => a[1].createdAt - b[1].createdAt);
75
+ const toRemove = entries.slice(0, pendingCodes.size - MAX_PENDING_CODES_SIZE);
76
+ for (const [key] of toRemove) {
77
+ pendingCodes.delete(key);
78
+ }
79
+ }
80
+ if (registeredClients.size > MAX_REGISTERED_CLIENTS_SIZE) {
81
+ const entries = [...registeredClients.entries()].sort((a, b) => a[1].createdAt - b[1].createdAt);
82
+ const toRemove = entries.slice(0, registeredClients.size - MAX_REGISTERED_CLIENTS_SIZE);
83
+ for (const [key] of toRemove) {
84
+ registeredClients.delete(key);
85
+ }
86
+ }
87
+ }
88
+
89
+ function runPeriodicCleanup() {
90
+ cleanupExpiredTokens();
91
+ cleanupExpiredCodes();
92
+ cleanupExpiredClients();
93
+ enforceStorageLimits();
94
+ }
95
+
96
+ export function startCleanupInterval() {
97
+ if (!cleanupIntervalId) {
98
+ cleanupIntervalId = setInterval(runPeriodicCleanup, CLEANUP_INTERVAL_MS);
99
+ cleanupIntervalId.unref?.();
100
+ }
101
+ }
102
+
103
+ export function stopCleanupInterval() {
104
+ if (cleanupIntervalId) {
105
+ clearInterval(cleanupIntervalId);
106
+ cleanupIntervalId = null;
107
+ }
108
+ }
109
+
37
110
  function generateToken() {
38
111
  return randomBytes(32).toString("hex");
39
112
  }
@@ -545,20 +618,14 @@ export function handleOAuthToken(req, res) {
545
618
  });
546
619
  }
547
620
 
548
- /**
549
- * Validate Bearer token and return session info
550
- * @param {string} token - Bearer token
551
- * @returns {{sessionId: string, backendUrl: string} | null}
552
- */
553
621
  export function validateBearerToken(token) {
554
622
  const tokenData = tokenStore.get(token);
555
623
  if (!tokenData) {
556
624
  return null;
557
625
  }
558
626
 
559
- // Check token age (24 hour expiry)
560
627
  const tokenAge = Date.now() - tokenData.createdAt;
561
- if (tokenAge > 86400000) {
628
+ if (tokenAge > TOKEN_EXPIRY_MS) {
562
629
  tokenStore.delete(token);
563
630
  return null;
564
631
  }
@@ -583,3 +650,5 @@ export function isOAuthEndpoint(pathname) {
583
650
  pathname === "/oauth/token"
584
651
  );
585
652
  }
653
+
654
+ startCleanupInterval();
@@ -326,6 +326,8 @@ function remapHitPaths(hit, workspaceRoot) {
326
326
  out.path = finalRelPath;
327
327
  }
328
328
  }
329
+ // Strip internal container_path before returning to client.
330
+ delete out.container_path;
329
331
  return out;
330
332
  }
331
333