@context-engine-bridge/context-engine-mcp-bridge 0.0.14 → 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 CHANGED
@@ -1,319 +1,18 @@
1
1
  <!-- Parent: ../AGENTS.md -->
2
- # Context-Engine MCP Bridge
2
+ # MCP Bridge CLI (ctxce)
3
3
 
4
- ## Purpose
4
+ Node package that exposes indexer+memory as a single MCP server (stdio + HTTP). Optional OAuth/PKCE.
5
5
 
6
- TypeScript/Node.js MCP bridge server that acts as a proxy between MCP clients (Claude Code, Cursor, Windsurf) and the Context Engine backend. Provides SSE/HTTP transport, OAuth 2.0 authentication, and transparent path mapping between local and container filesystems.
6
+ ## WHERE TO LOOK
7
7
 
8
- ## Key Responsibilities
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 |
9
15
 
10
- 1. **MCP Protocol Translation**: Converts stdio/HTTP requests from MCP clients to HTTP requests for the Python backend
11
- 2. **Authentication**: Manages OAuth 2.0 flows and session persistence for multi-user environments
12
- 3. **Path Mapping**: Translates container paths (`/work/repo/...`) to local workspace paths for client consumption
13
- 4. **Session Management**: Maintains per-connection session state and default collection/workspace settings
14
- 5. **Tool Routing**: Routes `memory.*` tools to memory server, other tools to indexer server
16
+ ## KNOWN TODO
15
17
 
16
- ## Architecture
17
-
18
- ```
19
- ┌────────────────────────────────────────────────────────┐
20
- │ MCP Client (Claude Code, etc.) │
21
- └──────────────┬─────────────────────────────────────────┘
22
- │ stdio or HTTP
23
- ┌──────────────▼─────────────────────────────────────────┐
24
- │ ctx-mcp-bridge (src/mcpServer.js) │
25
- │ ┌─────────────────────────────────────────────────┐ │
26
- │ │ MCP SDK Server (stdio/HTTP transport) │ │
27
- │ │ - tools/list → merge indexer + memory tools │ │
28
- │ │ - tools/call → route to appropriate backend │ │
29
- │ └────────────────┬────────────────────────────────┘ │
30
- │ │ │
31
- │ ┌────────────────▼─────────────┐ ┌─────────────────┐ │
32
- │ │ MCP HTTP Client (indexer) │ │ HTTP Client │ │
33
- │ │ http://localhost:8003/mcp │ │ (memory) │ │
34
- │ └──────────────────────────────┘ └─────────────────┘ │
35
- └────────────────────────────────────────────────────────┘
36
-
37
- ┌──────────────▼─────────────────────────────────────────┐
38
- │ Python Backend (indexer_mcp.py, memory_mcp.py) │
39
- └────────────────────────────────────────────────────────┘
40
- ```
41
-
42
- ## Core Files
43
-
44
- ### src/mcpServer.js (800+ lines)
45
-
46
- Main MCP server implementation. Key functions:
47
-
48
- - `createBridgeServer()`: Initializes MCP server with dual backend clients
49
- - `initializeRemoteClients()`: Connects to indexer and memory servers via StreamableHTTPClientTransport
50
- - `ListToolsRequestSchema` handler: Merges tools from both backends, deduplicates
51
- - `CallToolRequestSchema` handler: Routes tools, injects session ID, handles retries
52
- - `selectClientForTool()`: Routes `memory.*` tools to memory server, others to indexer
53
- - `fetchBridgeCollectionState()`: Queries `/bridge/state` for active collection overrides
54
- - Session retry logic: Detects `"No valid session ID"` errors and reinitializes clients
55
-
56
- **Environment Variables**:
57
- - `CTXCE_INDEXER_URL`: Default `http://localhost:8003/mcp`
58
- - `CTXCE_MEMORY_URL`: Default `http://localhost:8002/mcp` (optional)
59
- - `CTXCE_SESSION_ID`: Explicit session ID (overrides derived session)
60
- - `CTXCE_TOOL_TIMEOUT_MSEC`: Tool call timeout (default 300000 = 5 min)
61
- - `CTXCE_TOOL_RETRY_ATTEMPTS`: Retry count for transient errors (default 2)
62
- - `CTXCE_BRIDGE_STATE_TOKEN`: Auth token for `/bridge/state` endpoint
63
-
64
- ### src/oauthHandler.js (586 lines)
65
-
66
- OAuth 2.0 implementation (RFC9728 Protected Resource Metadata + RFC7591 Dynamic Client Registration):
67
-
68
- - **Endpoints**:
69
- - `GET /.well-known/oauth-authorization-server`: OAuth metadata
70
- - `POST /oauth/register`: Dynamic client registration (auto-approve local clients)
71
- - `GET /oauth/authorize`: Authorization flow (shows login page or auto-approves if session exists)
72
- - `POST /oauth/store-session`: Stores session from login page, returns auth code
73
- - `POST /oauth/token`: Exchanges auth code for bearer token
74
-
75
- - **Security**:
76
- - PKCE code_challenge validation (TODO: currently skipped for local bridge)
77
- - Client ID and redirect URI validation against registered clients
78
- - Origin/Referer header validation (localhost-only for `/oauth/store-session`)
79
- - 10-minute auth code expiry, 24-hour token expiry
80
- - Binds to `127.0.0.1` only (no remote access)
81
-
82
- - **Storage**: In-memory `tokenStore`, `pendingCodes`, `registeredClients` (not persisted across restarts)
83
-
84
- ### src/resultPathMapping.js (507 lines)
85
-
86
- Bidirectional path translation between container and client filesystems:
87
-
88
- **Request Path Mapping** (`maybeRemapToolArgs`):
89
- - Detects `/work/<repo>/...` container paths in tool args
90
- - Extracts repo-relative POSIX path (e.g., `/work/myrepo/src/main.py` → `src/main.py`)
91
- - Handles `path`, `under`, `root`, `subdir`, `path_glob`, `not_glob` parameters
92
- - Recursively processes nested objects and arrays
93
-
94
- **Response Path Mapping** (`maybeRemapToolResult`):
95
- - Parses JSON from `content[].text` or `structuredContent`
96
- - For each hit object:
97
- - Computes `rel_path` from container path
98
- - Joins `rel_path` to workspace root to produce `client_path`
99
- - Prefers existing `host_path` if it exists and is within workspace
100
- - Optionally overrides `hit.path` with `client_path` (default: enabled via `CTXCE_BRIDGE_OVERRIDE_PATH=1`)
101
- - Remaps `related_paths` arrays to absolute local paths
102
-
103
- **Environment Variables**:
104
- - `CTXCE_BRIDGE_MAP_PATHS=1`: Enable/disable path mapping (default: enabled)
105
- - `CTXCE_BRIDGE_OVERRIDE_PATH=1`: Replace `path` field with `client_path` (default: enabled)
106
- - `CTXCE_BRIDGE_CLIENT_PATH_STRICT=0`: Use `workspace_join` always vs preferring `host_path`
107
- - `CTXCE_BRIDGE_PATH_DIAGNOSTICS=0`: Add debug fields `client_path_source`, `client_path_joined`
108
-
109
- ### src/authConfig.js (not shown, ~100 lines)
110
-
111
- Auth entry persistence to `~/.ctxce/auth.json`:
112
-
113
- - `loadAuthEntry(backendUrl)`: Load session for specific backend
114
- - `saveAuthEntry(backendUrl, {sessionId, userId, expiresAt})`: Persist session
115
- - `deleteAuthEntry(backendUrl)`: Remove session
116
- - `loadAnyAuthEntry()`: Find any stored session (fallback)
117
-
118
- ### src/authCli.js (285 lines)
119
-
120
- CLI commands for auth management:
121
-
122
- - `ctxce auth login`: Calls `/auth/login` or `/auth/login/password`, saves session
123
- - `ctxce auth status`: Checks stored session, prints human or JSON output
124
- - `ctxce auth logout`: Deletes stored session
125
-
126
- **Fallback logic**: If `--backend-url` omitted, tries stored sessions, then `CTXCE_UPLOAD_ENDPOINT`, then `http://localhost:8004`
127
-
128
- ### src/cli.js (124 lines)
129
-
130
- Main CLI dispatcher:
131
-
132
- - `ctxce mcp-serve`: Starts stdio MCP bridge (default mode for MCP clients)
133
- - `ctxce mcp-http-serve`: Starts HTTP MCP bridge on port 30810 (used by VS Code extension)
134
- - `ctxce auth <subcmd>`: Delegates to `authCli.js`
135
-
136
- **Flags**:
137
- - `--workspace` / `--path`: Workspace root (default: cwd)
138
- - `--indexer-url`: Override indexer URL
139
- - `--memory-url`: Override memory URL (or omit to disable)
140
- - `--port`: HTTP port (for `mcp-http-serve`)
141
-
142
- ### bin/ctxce.js (2 lines)
143
-
144
- Shebang wrapper: `#!/usr/bin/env node`, imports `runCli()` from `src/cli.js`
145
-
146
- ## Key Features
147
-
148
- ### 1. Tool Routing
149
-
150
- ```javascript
151
- function selectClientForTool(name, indexerClient, memoryClient) {
152
- const lowered = name.toLowerCase();
153
- if (memoryClient && lowered.includes("memory")) {
154
- return memoryClient;
155
- }
156
- return indexerClient;
157
- }
158
- ```
159
-
160
- Tools starting with `memory.` or containing "memory" route to memory server; all others route to indexer.
161
-
162
- ### 2. Session Injection
163
-
164
- Bridge injects `session` parameter into all tool calls:
165
- 1. Explicit `CTXCE_SESSION_ID` env var
166
- 2. Stored auth session from `~/.ctxce/auth.json`
167
- 3. Fallback: `ctxce-<workspace-hash>` (deterministic per workspace)
168
-
169
- ### 3. Retry Logic
170
-
171
- Automatic retry on transient errors (timeouts, network errors, 502/503/504):
172
- - Default: 2 attempts with 200ms delay
173
- - Detects session errors (`"No valid session ID"`) and reinitializes clients
174
- - Configurable via `CTXCE_TOOL_RETRY_ATTEMPTS` and `CTXCE_TOOL_RETRY_DELAY_MSEC`
175
-
176
- ### 4. Collection State Override
177
-
178
- Queries `/bridge/state?collection=...&repo_name=...` on startup to resolve active collection:
179
- - Prefers `serving_collection` from backend over `ctx_config.json`
180
- - Falls back to `default_collection` from `ctx_config.json`
181
- - Logs active collection for transparency
182
-
183
- ### 5. Path Mapping Examples
184
-
185
- **Request**: Client sends `path: "/Users/dev/myrepo/src/main.py"`
186
- → Bridge normalizes to `src/main.py` (repo-relative)
187
- → Backend receives `path: "src/main.py"`
188
-
189
- **Response**: Backend returns `path: "/work/myrepo/src/main.py"`, `container_path: "/work/myrepo/src/main.py"`
190
- → Bridge computes `rel_path: "src/main.py"`, `client_path: "/Users/dev/myrepo/src/main.py"`
191
- → Client receives `path: "/Users/dev/myrepo/src/main.py"` (if `CTXCE_BRIDGE_OVERRIDE_PATH=1`)
192
-
193
- ## Usage Patterns
194
-
195
- ### Stdio Mode (for MCP clients)
196
-
197
- ```bash
198
- ctxce mcp-serve --workspace /path/to/repo
199
- ```
200
-
201
- Add to Claude Desktop `claude_desktop_config.json`:
202
- ```json
203
- {
204
- "mcpServers": {
205
- "context-engine": {
206
- "command": "ctxce",
207
- "args": ["mcp-serve", "--workspace", "/path/to/repo"]
208
- }
209
- }
210
- }
211
- ```
212
-
213
- ### HTTP Mode (for VS Code extension)
214
-
215
- ```bash
216
- ctxce mcp-http-serve --workspace /path/to/repo --port 30810
217
- ```
218
-
219
- Extension uses HTTP transport to avoid stdio buffering issues.
220
-
221
- ### Authentication Flow
222
-
223
- 1. User initiates OAuth via browser: `GET /oauth/authorize?client_id=...&redirect_uri=...&code_challenge=...`
224
- 2. Bridge checks for existing session in `~/.ctxce/auth.json`
225
- 3. If no session, shows login page with backend URL input
226
- 4. User submits credentials, page POSTs to backend `/auth/login`
227
- 5. Backend returns `session_id`, page POSTs to `/oauth/store-session`
228
- 6. Bridge saves session to `~/.ctxce/auth.json`, generates auth code, redirects with code
229
- 7. Client exchanges code for bearer token via `/oauth/token`
230
- 8. Client includes `Authorization: Bearer <token>` in subsequent MCP requests
231
-
232
- ## Testing
233
-
234
- No automated tests currently. Manual testing via:
235
-
236
- 1. Start backend: `docker-compose up -d`
237
- 2. Run bridge: `ctxce mcp-http-serve --workspace /path/to/repo`
238
- 3. Test OAuth flow: Open `http://127.0.0.1:30810/oauth/authorize?client_id=test&redirect_uri=http://localhost/callback&code_challenge=...`
239
- 4. Test MCP tools: Use MCP client (Claude Code) or `curl` to POST tool calls to `/mcp`
240
-
241
- ## Debugging
242
-
243
- Set `CTXCE_DEBUG_LOG=/tmp/ctxce.log` to log all bridge activity:
244
- ```bash
245
- export CTXCE_DEBUG_LOG=/tmp/ctxce.log
246
- ctxce mcp-serve --workspace /path/to/repo
247
- tail -f /tmp/ctxce.log
248
- ```
249
-
250
- Common issues:
251
- - **Session errors**: Check `~/.ctxce/auth.json` has valid `sessionId`
252
- - **Path mapping issues**: Set `CTXCE_BRIDGE_PATH_DIAGNOSTICS=1` to see `client_path_source`
253
- - **Tool routing issues**: Check tool name includes `"memory"` for memory server routing
254
- - **Timeout errors**: Increase `CTXCE_TOOL_TIMEOUT_MSEC` for slow queries
255
-
256
- ## Integration Points
257
-
258
- ### Upstream (MCP Clients)
259
- - **Claude Code**: Uses stdio transport via `claude_desktop_config.json`
260
- - **Cursor**: Supports MCP via settings
261
- - **VS Code Extension**: Uses HTTP transport on port 30810
262
-
263
- ### Downstream (Python Backend)
264
- - **indexer_mcp.py**: Provides 30+ search/graph tools on port 8003
265
- - **memory_mcp.py**: Provides memory storage tools on port 8002
266
- - **upload_service.py**: Auth backend on port 8004 (`/auth/login`, `/bridge/state`)
267
-
268
- ## Security Considerations
269
-
270
- 1. **Local-only binding**: HTTP server binds to `127.0.0.1` only (no remote access)
271
- 2. **Origin validation**: `/oauth/store-session` requires `Origin` or `Referer` header from localhost
272
- 3. **Client validation**: OAuth endpoints validate `client_id` and `redirect_uri` against registered clients
273
- 4. **Session persistence**: `~/.ctxce/auth.json` stores sessions (file permissions: 0600)
274
- 5. **Token expiry**: Auth codes expire in 10 min, bearer tokens in 24 hours
275
- 6. **PKCE**: Code challenge method `S256` supported (validation TODO)
276
-
277
- ## Configuration Files
278
-
279
- ### ~/.ctxce/auth.json
280
-
281
- ```json
282
- {
283
- "http://localhost:8004": {
284
- "sessionId": "sess_abc123",
285
- "userId": "user-456",
286
- "expiresAt": 1704974400
287
- }
288
- }
289
- ```
290
-
291
- ### ctx_config.json (in workspace)
292
-
293
- ```json
294
- {
295
- "default_collection": "myrepo-abc123",
296
- "default_mode": "refrag",
297
- "default_under": "src/",
298
- "repo_name": "myrepo"
299
- }
300
- ```
301
-
302
- Bridge reads this on startup and sends to backend via `set_session_defaults`.
303
-
304
- ## Future Enhancements
305
-
306
- 1. **PKCE validation**: Implement `code_verifier` hash validation in `/oauth/token`
307
- 2. **Persistent storage**: Migrate OAuth state to Redis/SQLite (currently in-memory)
308
- 3. **Token refresh**: Support `refresh_token` grant type for long-lived sessions
309
- 4. **Multi-workspace**: Allow clients to switch workspaces without restarting bridge
310
- 5. **Rate limiting**: Add per-client rate limits for tool calls
311
- 6. **Metrics**: Expose Prometheus metrics for tool call latency, error rates
312
-
313
- ## Related Documentation
314
-
315
- - [MCP SDK Documentation](https://modelcontextprotocol.io/docs)
316
- - [OAuth 2.0 RFC9728](https://www.rfc-editor.org/rfc/rfc9728.html) (Protected Resource Metadata)
317
- - [OAuth 2.0 RFC7591](https://www.rfc-editor.org/rfc/rfc7591.html) (Dynamic Client Registration)
318
- - [Context Engine Python Backend](../scripts/AGENTS.md)
319
- - [VS Code Extension Integration](../README.md#vs-code-extension)
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.14",
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