@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 +12 -313
- package/package.json +1 -1
- package/src/authConfig.js +1 -1
- package/src/mcpServer.js +175 -44
- package/src/oauthHandler.js +82 -13
- package/src/resultPathMapping.js +2 -0
package/AGENTS.md
CHANGED
|
@@ -1,319 +1,18 @@
|
|
|
1
1
|
<!-- Parent: ../AGENTS.md -->
|
|
2
|
-
#
|
|
2
|
+
# MCP Bridge CLI (ctxce)
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
Node package that exposes indexer+memory as a single MCP server (stdio + HTTP). Optional OAuth/PKCE.
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
## WHERE TO LOOK
|
|
7
7
|
|
|
8
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
package/src/authConfig.js
CHANGED
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
|
|
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
|
|
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
|
|
116
|
+
return 600000;
|
|
121
117
|
}
|
|
122
118
|
return parsed;
|
|
123
119
|
} catch {
|
|
124
|
-
return
|
|
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_")
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 (!
|
|
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",
|
|
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,
|
|
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 ||
|
|
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,
|
|
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
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
package/src/oauthHandler.js
CHANGED
|
@@ -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 >
|
|
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 >
|
|
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();
|