@context-engine-bridge/context-engine-mcp-bridge 0.0.27 → 0.0.29

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.
@@ -0,0 +1,6 @@
1
+ {
2
+ "timestamp": "2026-02-20T02:56:43.457Z",
3
+ "backgroundTasks": [],
4
+ "sessionStartTimestamp": "2026-02-20T02:54:38.180Z",
5
+ "sessionId": "391fa45d902019af"
6
+ }
package/AGENTS.md ADDED
@@ -0,0 +1,69 @@
1
+ <!-- Parent: ../AGENTS.md -->
2
+ <!-- Generated: 2026-02-19 | Updated: 2026-02-19 -->
3
+
4
+ # ctx-mcp-bridge
5
+
6
+ ## Purpose
7
+
8
+ The MCP bridge (`ctxce` CLI) is a Model Context Protocol server that aggregates the Context Engine indexer and memory servers into a single unified MCP server. It supports both stdio and HTTP transport modes, making it compatible with MCP clients like Claude Code, Windsurf, Augment, and others. The bridge is primarily launched by the VS Code extension but can run standalone.
9
+
10
+ ## Key Files
11
+
12
+ | File | Description |
13
+ |------|-------------|
14
+ | `bin/ctxce.js` | CLI entry point and executable (chmod +x 755) |
15
+ | `src/cli.js` | Command routing and argument parsing for `mcp-serve`, `mcp-http-serve`, `auth`, `connect` |
16
+ | `src/mcpServer.js` | MCP server implementation with stdio/HTTP transport, tool deduping, and auth handling |
17
+ | `src/authCli.js` | Auth command handlers: `login`, `logout`, `status` with token and password flows |
18
+ | `src/authConfig.js` | Session storage and management in `~/.ctxce/auth.json` |
19
+ | `src/oauthHandler.js` | OAuth protocol support for remote deployments |
20
+ | `src/uploader.js` | Standalone code uploader integration |
21
+ | `src/connectCli.js` | Connection validation and setup helpers |
22
+ | `src/resultPathMapping.js` | Path remapping for tool results (container/host paths) |
23
+ | `package.json` | Node.js package manifest (requires Node >= 18) |
24
+
25
+ ## Subdirectories
26
+
27
+ | Directory | Purpose |
28
+ |-----------|---------|
29
+ | `bin/` | Executable CLI entry point |
30
+ | `docs/` | Debugging guides and documentation |
31
+ | `src/` | Core MCP server and auth logic (see `src/AGENTS.md`) |
32
+
33
+ ## For AI Agents
34
+
35
+ ### Working In This Directory
36
+
37
+ This is a Node.js MCP bridge package. Changes to MCP routing, tool forwarding, or auth handling require updates to `src/cli.js` and `src/mcpServer.js`. The bridge proxies requests between MCP clients and remote indexer/memory HTTP servers, so test both stdio and HTTP modes.
38
+
39
+ ### Testing Requirements
40
+
41
+ - Run with `npm start` or `node bin/ctxce.js --help`
42
+ - Test MCP stdio mode: `ctxce mcp-serve --workspace /tmp/test`
43
+ - Test MCP HTTP mode: `ctxce mcp-http-serve --workspace /tmp/test --port 30810`
44
+ - Test auth commands: `ctxce auth login --backend-url http://localhost:8004 --token TEST_TOKEN`
45
+ - Verify auth state: `ctxce auth status --backend-url http://localhost:8004 --json`
46
+ - E2E tests: `npm run test:e2e`
47
+
48
+ ### Common Patterns
49
+
50
+ - Environment variables: `CTXCE_INDEXER_URL`, `CTXCE_MEMORY_URL`, `CTXCE_HTTP_PORT`, `CTXCE_AUTH_*`
51
+ - Auth sessions stored in `~/.ctxce/auth.json` keyed by backend URL
52
+ - MCP tools are deduplicated and forwarded from indexer and memory servers
53
+ - Path remapping (host paths <-> container paths) handled transparently
54
+ - All MCP requests are logged to stderr or `CTXCE_DEBUG_LOG` if set
55
+
56
+ ## Dependencies
57
+
58
+ ### Internal
59
+ - Context Engine indexer (HTTP endpoint at `CTXCE_INDEXER_URL` or `http://localhost:8003/mcp`)
60
+ - Context Engine memory server (HTTP endpoint at `CTXCE_MEMORY_URL` or `http://localhost:8002/mcp`)
61
+ - Auth backend (optional, at `CTXCE_AUTH_BACKEND_URL` or `http://localhost:8004`)
62
+
63
+ ### External
64
+ - `@modelcontextprotocol/sdk` (^1.24.3) – MCP protocol implementation
65
+ - `zod` (^3.25.0) – Runtime type validation
66
+ - `tar` (^7.5.9) – Archive support for uploads
67
+ - `ignore` (^7.0.5) – .gitignore-style file filtering
68
+
69
+ <!-- MANUAL: Any manually added notes below this line are preserved on regeneration -->
package/bin/AGENTS.md ADDED
@@ -0,0 +1,34 @@
1
+ <!-- Parent: ../AGENTS.md -->
2
+ <!-- Generated: 2026-02-19 | Updated: 2026-02-19 -->
3
+
4
+ # bin
5
+
6
+ ## Purpose
7
+
8
+ Executable CLI entry point for the `ctxce` command (also aliased as `ctxce-bridge`). This directory contains the shebang-wrapped Node.js script that is installed globally or via npm when the package is installed.
9
+
10
+ ## Key Files
11
+
12
+ | File | Description |
13
+ |------|-------------|
14
+ | `ctxce.js` | CLI executable entry point (Node.js script, chmod +x 755) |
15
+
16
+ ## For AI Agents
17
+
18
+ ### Working In This Directory
19
+
20
+ Do not modify `ctxce.js` directly unless changing the CLI bootstrap. The actual CLI logic is in `src/cli.js`. The executable must have a shebang (`#!/usr/bin/env node`) and be marked executable on Unix systems.
21
+
22
+ ### Testing Requirements
23
+
24
+ - Verify executable permission: `ls -l bin/ctxce.js` should show `-rwxr-xr-x`
25
+ - Test global install: `npm install -g` and run `ctxce --help`
26
+ - Test npx mode: `npx @context-engine-bridge/context-engine-mcp-bridge ctxce --help`
27
+ - Postinstall script auto-fixes permissions on non-Windows systems
28
+
29
+ ## Dependencies
30
+
31
+ ### Internal
32
+ - `src/cli.js` – Main CLI router and handler
33
+
34
+ <!-- MANUAL: Any manually added notes below this line are preserved on regeneration -->
package/docs/AGENTS.md ADDED
@@ -0,0 +1,22 @@
1
+ <!-- Parent: ../AGENTS.md -->
2
+ <!-- Generated: 2026-02-19 | Updated: 2026-02-19 -->
3
+
4
+ # docs
5
+
6
+ ## Purpose
7
+
8
+ Debugging guides and developer documentation for the MCP bridge. Currently contains minimal documentation; most usage is covered in the main `README.md`.
9
+
10
+ ## Key Files
11
+
12
+ | File | Description |
13
+ |------|-------------|
14
+ | `debugging.md` | Debug logging and troubleshooting tips |
15
+
16
+ ## For AI Agents
17
+
18
+ ### Working In This Directory
19
+
20
+ This directory is for developer guides, not API documentation (which belongs in the main README). When adding debugging tips or advanced usage patterns, place them here.
21
+
22
+ <!-- MANUAL: Any manually added notes below this line are preserved on regeneration -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@context-engine-bridge/context-engine-mcp-bridge",
3
- "version": "0.0.27",
3
+ "version": "0.0.29",
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/AGENTS.md ADDED
@@ -0,0 +1,59 @@
1
+ <!-- Parent: ../AGENTS.md -->
2
+ <!-- Generated: 2026-02-19 | Updated: 2026-02-19 -->
3
+
4
+ # src
5
+
6
+ ## Purpose
7
+
8
+ Core MCP server implementation, auth handling, and CLI routing for the `ctxce` bridge. This module aggregates the Context Engine indexer and memory servers, manages authentication sessions, and handles both stdio and HTTP transport modes.
9
+
10
+ ## Key Files
11
+
12
+ | File | Description |
13
+ |------|-------------|
14
+ | `cli.js` | Main command router for `mcp-serve`, `mcp-http-serve`, `auth`, `connect` subcommands; parses CLI flags and environment variables |
15
+ | `mcpServer.js` | MCP server implementation; proxies requests to indexer/memory servers, dedupes tools, handles auth, supports stdio and HTTP transports |
16
+ | `authCli.js` | Auth command handlers for `login`, `logout`, `status` with token and password flows; manages session lifecycle |
17
+ | `authConfig.js` | Persistent auth state in `~/.ctxce/auth.json`; session loading, saving, TTL handling, OAuth support |
18
+ | `oauthHandler.js` | OAuth protocol implementation for token refresh and remote auth flows |
19
+ | `uploader.js` | Integration with the standalone code uploader (tar archive support, progress tracking) |
20
+ | `connectCli.js` | Connection validation, workspace discovery, and setup helpers |
21
+ | `resultPathMapping.js` | Path remapping for tool results (host paths <-> container paths); handles path translation for Docker environments |
22
+
23
+ ## For AI Agents
24
+
25
+ ### Working In This Directory
26
+
27
+ This is the core business logic. Changes to MCP command handling, tool routing, or auth flows affect both the CLI and the VS Code extension. The server proxies all tool requests to remote indexer/memory servers and dedupes tool lists to prevent duplicates. Auth is optional but handles session TTL, token refresh, and fallback to dev tokens.
28
+
29
+ ### Testing Requirements
30
+
31
+ - Test MCP stdio transport: `node src/cli.js mcp-serve --workspace /tmp/test`
32
+ - Test MCP HTTP transport: `node src/cli.js mcp-http-serve --workspace /tmp/test --port 30810`
33
+ - Test auth login: `node src/cli.js auth login --backend-url http://localhost:8004 --token TOKEN`
34
+ - Test auth status: `node src/cli.js auth status --backend-url http://localhost:8004 --json`
35
+ - Verify tool deduping: Check that tools from indexer and memory are merged without duplicates
36
+ - Test path remapping: Verify that container paths are correctly mapped for Docker environments
37
+ - Run E2E tests: `npm run test:e2e`, `npm run test:e2e:auth`, `npm run test:e2e:happy`, `npm run test:e2e:edge`
38
+
39
+ ### Common Patterns
40
+
41
+ - Environment variables guide server initialization: `CTXCE_INDEXER_URL`, `CTXCE_MEMORY_URL`, `CTXCE_HTTP_PORT`, `CTXCE_AUTH_BACKEND_URL`, `CTXCE_AUTH_ENABLED`, `CTXCE_DEBUG_LOG`
42
+ - Sessions are stored per backend URL in `~/.ctxce/auth.json` with TTL tracking
43
+ - All MCP tools from indexer and memory are merged and deduplicated before listing
44
+ - Path remapping is applied to tool arguments and results for Docker host/container path translation
45
+ - Debug logging goes to stderr and optionally to a file (set `CTXCE_DEBUG_LOG` env var)
46
+ - HTTP transport uses Node.js `createServer` with MCP's `StreamableHTTPServerTransport`
47
+
48
+ ## Dependencies
49
+
50
+ ### Internal
51
+ - `bin/ctxce.js` – CLI entry point that imports and runs these modules
52
+
53
+ ### External
54
+ - `@modelcontextprotocol/sdk` – MCP protocol (Server, StdioServerTransport, StreamableHTTPServerTransport, Client, StreamableHTTPClientTransport)
55
+ - `zod` – Runtime type validation (used in config parsing)
56
+ - `tar` – Archive handling for uploader
57
+ - `ignore` – File filtering for .gitignore patterns
58
+
59
+ <!-- MANUAL: Any manually added notes below this line are preserved on regeneration -->
package/src/connectCli.js CHANGED
@@ -2,7 +2,7 @@ import process from "node:process";
2
2
  import path from "node:path";
3
3
  import fs from "node:fs";
4
4
  import { saveAuthEntry } from "./authConfig.js";
5
- import { indexWorkspace } from "./uploader.js";
5
+ import { indexWorkspace, loadGitignore, isCodeFile } from "./uploader.js";
6
6
 
7
7
  const SAAS_ENDPOINTS = {
8
8
  uploadEndpoint: "https://dev.context-engine.ai/upload",
@@ -160,12 +160,35 @@ async function triggerIndexing(workspace, sessionId, authEntry) {
160
160
  return result.success;
161
161
  }
162
162
 
163
- function startWatcher(workspace, sessionId, authEntry, intervalMs) {
163
+ function startWatcher(workspace, initialSessionId, authEntry, intervalMs) {
164
164
  console.error(`[ctxce] Starting file watcher (sync every ${intervalMs / 1000}s)...`);
165
165
  console.error("[ctxce] Press Ctrl+C to stop.");
166
166
 
167
167
  let isRunning = false;
168
168
  let pendingSync = false;
169
+ let sessionId = initialSessionId;
170
+
171
+ async function refreshSessionIfNeeded() {
172
+ // If the auth entry has an expiry and we're within 5 minutes of it,
173
+ // re-authenticate using the stored API key.
174
+ if (!authEntry || !authEntry.apiKey) return;
175
+ const expiresAt = authEntry.expiresAt;
176
+ if (typeof expiresAt !== "number" || !Number.isFinite(expiresAt) || expiresAt <= 0) return;
177
+ const nowSecs = Math.floor(Date.now() / 1000);
178
+ const remainingSecs = expiresAt - nowSecs;
179
+ if (remainingSecs > 300) return; // still valid for > 5 min
180
+ console.error("[ctxce] Session approaching expiry, refreshing...");
181
+ try {
182
+ const refreshed = await authenticateWithApiKey(authEntry.apiKey);
183
+ if (refreshed && refreshed.sessionId) {
184
+ sessionId = refreshed.sessionId;
185
+ authEntry = refreshed;
186
+ console.error("[ctxce] Session refreshed successfully.");
187
+ }
188
+ } catch (err) {
189
+ console.error(`[ctxce] Session refresh failed: ${err}`);
190
+ }
191
+ }
169
192
 
170
193
  const fileHashes = new Map();
171
194
 
@@ -178,26 +201,29 @@ function startWatcher(workspace, sessionId, authEntry, intervalMs) {
178
201
  }
179
202
  }
180
203
 
204
+ // Use gitignore from uploader.js so the watcher ignores the same files as
205
+ // the bundle creator -- prevents redundant uploads for generated/ignored files.
206
+ const _ig = loadGitignore(workspace);
207
+
181
208
  function scanDirectory(dir, files = []) {
182
209
  try {
183
210
  const entries = fs.readdirSync(dir, { withFileTypes: true });
184
211
  for (const entry of entries) {
212
+ if (entry.isSymbolicLink()) continue;
185
213
  const fullPath = path.join(dir, entry.name);
186
-
187
- if (entry.name.startsWith(".") ||
188
- entry.name === "node_modules" ||
189
- entry.name === "__pycache__" ||
190
- entry.name === "venv" ||
191
- entry.name === ".venv" ||
192
- entry.name === "dist" ||
193
- entry.name === "build") {
194
- continue;
195
- }
214
+ // Normalize to forward slashes for the `ignore` library (expects POSIX paths)
215
+ const relPath = path.relative(workspace, fullPath).split(path.sep).join('/');
196
216
 
217
+ // Use the same ignore rules as the bundle creator
218
+ // Directories need a trailing slash for gitignore pattern matching
197
219
  if (entry.isDirectory()) {
220
+ if (_ig.ignores(relPath + '/')) continue;
198
221
  scanDirectory(fullPath, files);
199
222
  } else if (entry.isFile()) {
200
- files.push(fullPath);
223
+ if (_ig.ignores(relPath)) continue;
224
+ if (isCodeFile(fullPath)) {
225
+ files.push(fullPath);
226
+ }
201
227
  }
202
228
  }
203
229
  } catch {
@@ -242,6 +268,9 @@ function startWatcher(workspace, sessionId, authEntry, intervalMs) {
242
268
  console.error(`[ctxce] [${now}] Syncing changes...`);
243
269
 
244
270
  try {
271
+ // Refresh session before upload if approaching expiry
272
+ await refreshSessionIfNeeded();
273
+
245
274
  const result = await indexWorkspace(
246
275
  workspace,
247
276
  SAAS_ENDPOINTS.uploadEndpoint,
package/src/mcpServer.js CHANGED
@@ -72,40 +72,6 @@ async function listMemoryTools(client) {
72
72
  }
73
73
  }
74
74
 
75
- function withTimeout(promise, ms, label) {
76
- return new Promise((resolve, reject) => {
77
- let settled = false;
78
- const timer = setTimeout(() => {
79
- if (settled) {
80
- return;
81
- }
82
- settled = true;
83
- const errorMessage =
84
- label != null
85
- ? `[ctxce] Timeout after ${ms}ms in ${label}`
86
- : `[ctxce] Timeout after ${ms}ms`;
87
- reject(new Error(errorMessage));
88
- }, ms);
89
- promise
90
- .then((value) => {
91
- if (settled) {
92
- return;
93
- }
94
- settled = true;
95
- clearTimeout(timer);
96
- resolve(value);
97
- })
98
- .catch((err) => {
99
- if (settled) {
100
- return;
101
- }
102
- settled = true;
103
- clearTimeout(timer);
104
- reject(err);
105
- });
106
- });
107
- }
108
-
109
75
  function getBridgeToolTimeoutMs() {
110
76
  try {
111
77
  const raw = process.env.CTXCE_TOOL_TIMEOUT_MSEC;
@@ -535,7 +501,13 @@ async function createBridgeServer(options) {
535
501
  expiresAt > 0 &&
536
502
  expiresAt < Math.floor(Date.now() / 1000)
537
503
  ) {
538
- debugLog("[ctxce] Stored auth session has local expiry in the past; attempting to use it anyway (server will validate).");
504
+ // Allow 5-minute grace period for clock skew; beyond that, reject
505
+ const expiredSecs = Math.floor(Date.now() / 1000) - expiresAt;
506
+ if (expiredSecs > 300) {
507
+ debugLog(`[ctxce] Session expired ${expiredSecs}s ago (beyond 5min grace), triggering re-auth.`);
508
+ return "";
509
+ }
510
+ debugLog("[ctxce] Session expired but within 5min grace period; using it (server will validate).");
539
511
  return entry.sessionId;
540
512
  }
541
513
  return entry.sessionId;
@@ -705,7 +677,7 @@ async function createBridgeServer(options) {
705
677
 
706
678
  let nextIndexerClient = null;
707
679
  try {
708
- const indexerTransport = new StreamableHTTPClientTransport(indexerUrl, transportOpts);
680
+ const indexerTransport = new StreamableHTTPClientTransport(new URL(indexerUrl), transportOpts);
709
681
  const client = new Client(
710
682
  {
711
683
  name: "ctx-context-engine-bridge-http-client",
@@ -729,7 +701,7 @@ async function createBridgeServer(options) {
729
701
  let nextMemoryClient = null;
730
702
  if (memoryUrl) {
731
703
  try {
732
- const memoryTransport = new StreamableHTTPClientTransport(memoryUrl, transportOpts);
704
+ const memoryTransport = new StreamableHTTPClientTransport(new URL(memoryUrl), transportOpts);
733
705
  const client = new Client(
734
706
  {
735
707
  name: "ctx-context-engine-bridge-memory-client",
@@ -860,6 +832,9 @@ async function createBridgeServer(options) {
860
832
  args = maybeRemapToolArgs(name, args, workspace);
861
833
 
862
834
  if (name === "set_session_defaults") {
835
+ if (!indexerClient) {
836
+ throw new Error("Indexer client not connected");
837
+ }
863
838
  const indexerResult = await indexerClient.callTool({ name, arguments: args });
864
839
  if (memoryClient) {
865
840
  try {
package/src/uploader.js CHANGED
@@ -36,7 +36,7 @@ const DEFAULT_IGNORES = [
36
36
 
37
37
  const MAX_FILE_SIZE = 10 * 1024 * 1024;
38
38
 
39
- function loadGitignore(workspacePath) {
39
+ export function loadGitignore(workspacePath) {
40
40
  const ig = ignore();
41
41
  ig.add(DEFAULT_IGNORES);
42
42
 
@@ -52,7 +52,7 @@ function loadGitignore(workspacePath) {
52
52
  return ig;
53
53
  }
54
54
 
55
- function isCodeFile(filePath) {
55
+ export function isCodeFile(filePath) {
56
56
  const ext = path.extname(filePath).toLowerCase();
57
57
  if (CODE_EXTS.has(ext)) return true;
58
58
 
@@ -129,33 +129,76 @@ export async function createBundle(workspacePath, options = {}) {
129
129
  log(`[uploader] Found ${files.length} code files`);
130
130
 
131
131
  const bundleId = createHash("sha256").update(Date.now().toString() + Math.random().toString()).digest("hex").slice(0, 16);
132
+ const createdAt = new Date().toISOString();
133
+
134
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ctxce-"));
135
+ const bundleDir = path.join(tmpDir, bundleId);
136
+ const metadataDir = path.join(bundleDir, ".metadata");
137
+ const filesDir = path.join(bundleDir, "files");
138
+
139
+ fs.mkdirSync(metadataDir, { recursive: true });
140
+ fs.mkdirSync(filesDir, { recursive: true });
141
+
142
+ const operations = [];
143
+ const fileHashes = {};
144
+ let totalSize = 0;
145
+
146
+ for (const file of files) {
147
+ const destPath = path.join(filesDir, file.path);
148
+ fs.mkdirSync(path.dirname(destPath), { recursive: true });
149
+ fs.copyFileSync(file.fullPath, destPath);
150
+
151
+ const hash = computeFileHash(file.fullPath);
152
+ fileHashes[file.path] = hash;
153
+ totalSize += file.size;
154
+
155
+ operations.push({
156
+ op: "upsert",
157
+ path: file.path,
158
+ size: file.size,
159
+ hash,
160
+ });
161
+ }
132
162
 
133
163
  const manifest = {
164
+ version: "1.0",
134
165
  bundle_id: bundleId,
135
166
  workspace_path: workspacePath,
136
- logical_repo_id: computeLogicalRepoId(workspacePath),
137
- sequence_number: Date.now(),
138
- file_count: files.length,
139
- files: files.map(f => ({
140
- path: f.path,
141
- size: f.size,
142
- hash: computeFileHash(f.fullPath),
143
- })),
144
- created_at: new Date().toISOString(),
167
+ collection_name: computeLogicalRepoId(workspacePath),
168
+ created_at: createdAt,
169
+ sequence_number: null,
170
+ parent_sequence: null,
171
+ operations: {
172
+ created: files.length,
173
+ updated: 0,
174
+ deleted: 0,
175
+ moved: 0,
176
+ },
177
+ total_files: files.length,
178
+ total_size_bytes: totalSize,
179
+ compression: "gzip",
180
+ encoding: "utf-8",
145
181
  };
146
182
 
147
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ctxce-"));
148
- const bundlePath = path.join(tmpDir, `bundle-${bundleId}.tar.gz`);
183
+ fs.writeFileSync(path.join(bundleDir, "manifest.json"), JSON.stringify(manifest, null, 2));
184
+ fs.writeFileSync(path.join(metadataDir, "operations.json"), JSON.stringify({ operations }, null, 2));
185
+ fs.writeFileSync(path.join(metadataDir, "hashes.json"), JSON.stringify({
186
+ workspace_path: workspacePath,
187
+ updated_at: createdAt,
188
+ file_hashes: fileHashes,
189
+ }, null, 2));
190
+
191
+ const bundlePath = path.join(tmpDir, `${bundleId}.tar.gz`);
149
192
 
150
193
  try {
151
194
  await tarCreate(
152
195
  {
153
196
  gzip: true,
154
197
  file: bundlePath,
155
- cwd: workspacePath,
198
+ cwd: tmpDir,
156
199
  portable: true,
157
200
  },
158
- files.map(f => f.path)
201
+ [bundleId]
159
202
  );
160
203
 
161
204
  const bundleSize = fs.statSync(bundlePath).size;
@@ -181,57 +224,80 @@ export async function createBundle(workspacePath, options = {}) {
181
224
  export async function uploadBundle(bundlePath, manifest, uploadEndpoint, sessionId, options = {}) {
182
225
  const { log = console.error, orgId, orgSlug } = options;
183
226
 
184
- const bundleData = fs.readFileSync(bundlePath);
227
+ const bundleSize = fs.statSync(bundlePath).size;
185
228
  const boundary = `----ctxce${Date.now()}${Math.random().toString(36).slice(2)}`;
186
229
 
187
- const parts = [];
188
-
189
- parts.push(
190
- `--${boundary}\r\n`,
191
- `Content-Disposition: form-data; name="bundle"; filename="bundle.tar.gz"\r\n`,
192
- `Content-Type: application/gzip\r\n\r\n`,
193
- );
194
- parts.push(bundleData);
195
- parts.push(`\r\n`);
196
-
230
+ // Build form fields (small metadata -- kept in memory)
231
+ const logicalRepoId = computeLogicalRepoId(manifest.workspace_path);
197
232
  const fields = {
198
233
  workspace_path: manifest.workspace_path,
199
- collection_name: manifest.logical_repo_id,
200
- sequence_number: String(manifest.sequence_number),
234
+ collection_name: manifest.collection_name || logicalRepoId,
201
235
  force: "true",
202
236
  source_path: manifest.workspace_path,
203
- logical_repo_id: manifest.logical_repo_id,
237
+ logical_repo_id: logicalRepoId,
204
238
  session: sessionId,
205
239
  };
206
-
207
240
  if (orgId) fields.org_id = orgId;
208
241
  if (orgSlug) fields.org_slug = orgSlug;
209
242
 
243
+ // Build multipart preamble (file header) and epilogue (fields + close)
244
+ const filePreamble = Buffer.from(
245
+ `--${boundary}\r\n` +
246
+ `Content-Disposition: form-data; name="bundle"; filename="bundle.tar.gz"\r\n` +
247
+ `Content-Type: application/gzip\r\n\r\n`
248
+ );
249
+ const fileEpilogue = Buffer.from(`\r\n`);
250
+
251
+ let fieldsPart = '';
210
252
  for (const [key, value] of Object.entries(fields)) {
211
253
  if (value) {
212
- parts.push(
213
- `--${boundary}\r\n`,
214
- `Content-Disposition: form-data; name="${key}"\r\n\r\n`,
215
- `${value}\r\n`,
216
- );
254
+ fieldsPart += `--${boundary}\r\n` +
255
+ `Content-Disposition: form-data; name="${key}"\r\n\r\n` +
256
+ `${value}\r\n`;
217
257
  }
218
258
  }
219
-
220
- parts.push(`--${boundary}--\r\n`);
221
-
222
- const bodyParts = parts.map(p => typeof p === "string" ? Buffer.from(p) : p);
223
- const body = Buffer.concat(bodyParts);
259
+ fieldsPart += `--${boundary}--\r\n`;
260
+ const fieldsBuffer = Buffer.from(fieldsPart);
261
+
262
+ // Stream the bundle file instead of loading it entirely into memory.
263
+ // This prevents OOM for large repositories (hundreds of MB bundles).
264
+ const totalLength = filePreamble.length + bundleSize + fileEpilogue.length + fieldsBuffer.length;
265
+
266
+ const { Readable } = await import('node:stream');
267
+ const bodyStream = new Readable({
268
+ read() {
269
+ // Push preamble
270
+ this.push(filePreamble);
271
+ // Push file data in chunks via sync read to keep it simple
272
+ const CHUNK = 256 * 1024; // 256KB chunks
273
+ const fd = fs.openSync(bundlePath, 'r');
274
+ try {
275
+ const buf = Buffer.allocUnsafe(CHUNK);
276
+ let bytesRead;
277
+ while ((bytesRead = fs.readSync(fd, buf, 0, CHUNK)) > 0) {
278
+ this.push(bytesRead === CHUNK ? buf : buf.subarray(0, bytesRead));
279
+ }
280
+ } finally {
281
+ fs.closeSync(fd);
282
+ }
283
+ // Push epilogue + fields + close
284
+ this.push(fileEpilogue);
285
+ this.push(fieldsBuffer);
286
+ this.push(null); // EOF
287
+ }
288
+ });
224
289
 
225
290
  const url = `${uploadEndpoint}/api/v1/delta/upload`;
226
- log(`[uploader] Uploading to ${url}...`);
291
+ log(`[uploader] Uploading to ${url} (${(bundleSize / 1024).toFixed(0)}KB bundle, streaming)...`);
227
292
 
228
293
  const resp = await fetch(url, {
229
294
  method: "POST",
230
295
  headers: {
231
296
  "Content-Type": `multipart/form-data; boundary=${boundary}`,
232
- "Content-Length": String(body.length),
297
+ "Content-Length": String(totalLength),
233
298
  },
234
- body,
299
+ body: bodyStream,
300
+ duplex: "half", // required for streaming request bodies in Node.js fetch
235
301
  });
236
302
 
237
303
  let result;
@@ -242,7 +308,11 @@ export async function uploadBundle(bundlePath, manifest, uploadEndpoint, session
242
308
  }
243
309
 
244
310
  if (!resp.ok || !result.success) {
245
- const errorMsg = result.error?.message || result.detail || `HTTP ${resp.status}`;
311
+ let errorMsg = result.error?.message || result.detail || result.error;
312
+ if (typeof errorMsg === "object") {
313
+ errorMsg = JSON.stringify(errorMsg);
314
+ }
315
+ errorMsg = errorMsg || `HTTP ${resp.status}`;
246
316
  log(`[uploader] Upload failed: ${errorMsg}`);
247
317
  return { success: false, error: errorMsg };
248
318
  }
@@ -251,8 +321,23 @@ export async function uploadBundle(bundlePath, manifest, uploadEndpoint, session
251
321
  return { success: true, result };
252
322
  }
253
323
 
324
+ let _indexInFlight = false;
254
325
  export async function indexWorkspace(workspacePath, uploadEndpoint, sessionId, options = {}) {
255
326
  const { log = console.error, orgId, orgSlug } = options;
327
+ if (_indexInFlight) {
328
+ log("[uploader] indexWorkspace already in progress, skipping concurrent call");
329
+ return { success: false, error: "already_in_progress" };
330
+ }
331
+ _indexInFlight = true;
332
+ try {
333
+ return await _indexWorkspaceInner(workspacePath, uploadEndpoint, sessionId, options);
334
+ } finally {
335
+ _indexInFlight = false;
336
+ }
337
+ }
338
+
339
+ async function _indexWorkspaceInner(workspacePath, uploadEndpoint, sessionId, options = {}) {
340
+ const { log = console.error, orgId, orgSlug } = options;
256
341
 
257
342
  log(`[uploader] Scanning workspace: ${workspacePath}`);
258
343
 
@@ -1,11 +0,0 @@
1
- {
2
- "enableAllProjectMcpServers": true,
3
- "enabledMcpjsonServers": [
4
- "context-engine"
5
- ],
6
- "permissions": {
7
- "allow": [
8
- "mcp__context-engine__repo_search"
9
- ]
10
- }
11
- }