@context-engine-bridge/context-engine-mcp-bridge 0.0.5 → 0.0.7

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/README.md ADDED
@@ -0,0 +1,212 @@
1
+ # Context Engine MCP Bridge
2
+
3
+ `@context-engine-bridge/context-engine-mcp-bridge` provides the `ctxce` CLI, a
4
+ Model Context Protocol (MCP) bridge that speaks to the Context Engine indexer
5
+ and memory servers and exposes them as a single MCP server.
6
+
7
+ It is primarily used by the VS Code **Context Engine Uploader** extension,
8
+ available on the Marketplace:
9
+
10
+ - <https://marketplace.visualstudio.com/items?itemName=context-engine.context-engine-uploader>
11
+
12
+ The bridge can also be run standalone (e.g. from a terminal, or wired into
13
+ other MCP clients) as long as the Context Engine stack is running.
14
+
15
+ ## Prerequisites
16
+
17
+ - Node.js **>= 18** (see `engines` in `package.json`).
18
+ - A running Context Engine stack (e.g. via `docker-compose.dev-remote.yml`) with:
19
+ - MCP indexer HTTP endpoint (default: `http://localhost:8003/mcp`).
20
+ - MCP memory HTTP endpoint (optional, default: `http://localhost:8002/mcp`).
21
+ - For optional auth:
22
+ - The upload/auth services must be configured with `CTXCE_AUTH_ENABLED=1` and
23
+ a reachable auth backend URL (e.g. `http://localhost:8004`).
24
+
25
+ ## Installation
26
+
27
+ You can install the package globally, or run it via `npx`.
28
+
29
+ ### Global install
30
+
31
+ ```bash
32
+ npm install -g @context-engine-bridge/context-engine-mcp-bridge
33
+ ```
34
+
35
+ This installs the `ctxce` (and `ctxce-bridge`) CLI in your PATH.
36
+
37
+ ### Using npx (no global install)
38
+
39
+ ```bash
40
+ npx @context-engine-bridge/context-engine-mcp-bridge ctxce --help
41
+ ```
42
+
43
+ The examples below assume `ctxce` is available on your PATH; if you use `npx`,
44
+ just prefix commands with `npx @context-engine-bridge/context-engine-mcp-bridge`.
45
+
46
+ ## CLI overview
47
+
48
+ The main entrypoint is:
49
+
50
+ ```bash
51
+ ctxce <command> [...args]
52
+ ```
53
+
54
+ Supported commands (from `src/cli.js`):
55
+
56
+ - `ctxce mcp-serve` – stdio MCP bridge (for stdio-based MCP clients).
57
+ - `ctxce mcp-http-serve` – HTTP MCP bridge (for HTTP-based MCP clients).
58
+ - `ctxce auth <subcmd>` – auth helper commands (`login`, `status`, `logout`).
59
+
60
+ ### Environment variables
61
+
62
+ These environment variables are respected by the bridge:
63
+
64
+ - `CTXCE_INDEXER_URL` – MCP indexer URL (default: `http://localhost:8003/mcp`).
65
+ - `CTXCE_MEMORY_URL` – MCP memory URL, or empty/omitted to disable memory
66
+ (default: `http://localhost:8002/mcp`).
67
+ - `CTXCE_HTTP_PORT` – port for `mcp-http-serve` (default: `30810`).
68
+
69
+ For auth (optional, shared with the upload/auth backend):
70
+
71
+ - `CTXCE_AUTH_ENABLED` – whether auth is enabled in the backend.
72
+ - `CTXCE_AUTH_BACKEND_URL` – auth backend URL (e.g. `http://localhost:8004`).
73
+ - `CTXCE_AUTH_TOKEN` – dev/shared token for `ctxce auth login`.
74
+ - `CTXCE_AUTH_SESSION_TTL_SECONDS` – session TTL / sliding expiry (seconds).
75
+
76
+ The CLI also stores auth sessions in `~/.ctxce/auth.json`, keyed by backend URL.
77
+
78
+ ## Running the MCP bridge (stdio)
79
+
80
+ The stdio bridge is suitable for MCP clients that speak stdio directly (for
81
+ example, certain editors or tools that expect an MCP server on stdin/stdout).
82
+
83
+ ```bash
84
+ ctxce mcp-serve \
85
+ --workspace /path/to/your/workspace \
86
+ --indexer-url http://localhost:8003/mcp \
87
+ --memory-url http://localhost:8002/mcp
88
+ ```
89
+
90
+ Flags:
91
+
92
+ - `--workspace` / `--path` – workspace root (default: current working directory).
93
+ - `--indexer-url` – override indexer URL (default: `CTXCE_INDEXER_URL` or
94
+ `http://localhost:8003/mcp`).
95
+ - `--memory-url` – override memory URL (default: `CTXCE_MEMORY_URL` or
96
+ disabled when empty).
97
+
98
+ ## Running the MCP bridge (HTTP)
99
+
100
+ The HTTP bridge exposes the MCP server via an HTTP endpoint (default
101
+ `http://127.0.0.1:30810/mcp`) and is what the VS Code extension uses in its
102
+ `http` transport mode.
103
+
104
+ ```bash
105
+ ctxce mcp-http-serve \
106
+ --workspace /path/to/your/workspace \
107
+ --indexer-url http://localhost:8003/mcp \
108
+ --memory-url http://localhost:8002/mcp \
109
+ --port 30810
110
+ ```
111
+
112
+ Flags:
113
+
114
+ - `--workspace` / `--path` – workspace root (default: current working directory).
115
+ - `--indexer-url` – MCP indexer URL.
116
+ - `--memory-url` – MCP memory URL (or omit/empty to disable memory).
117
+ - `--port` – HTTP port for the bridge (default: `CTXCE_HTTP_PORT`
118
+ or `30810`).
119
+
120
+ Once running, you can point an MCP client at:
121
+
122
+ ```text
123
+ http://127.0.0.1:<port>/mcp
124
+ ```
125
+
126
+ ## Auth helper commands (`ctxce auth ...`)
127
+
128
+ These commands are used both by the VS Code extension and standalone flows to
129
+ log in and manage auth sessions for the backend.
130
+
131
+ ### Login (token)
132
+
133
+ ```bash
134
+ ctxce auth login \
135
+ --backend-url http://localhost:8004 \
136
+ --token $CTXCE_AUTH_SHARED_TOKEN
137
+ ```
138
+
139
+ This hits the backend `/auth/login` endpoint and stores a session entry in
140
+ `~/.ctxce/auth.json` under the given backend URL.
141
+
142
+ ### Login (username/password)
143
+
144
+ ```bash
145
+ ctxce auth login \
146
+ --backend-url http://localhost:8004 \
147
+ --username your-user \
148
+ --password your-password
149
+ ```
150
+
151
+ This calls `/auth/login/password` and persists the returned session the same
152
+ way as the token flow.
153
+
154
+ ### Status
155
+
156
+ Human-readable status:
157
+
158
+ ```bash
159
+ ctxce auth status --backend-url http://localhost:8004
160
+ ```
161
+
162
+ Machine-readable status (used by the VS Code extension):
163
+
164
+ ```bash
165
+ ctxce auth status --backend-url http://localhost:8004 --json
166
+ ```
167
+
168
+ The `--json` variant prints a single JSON object to stdout, for example:
169
+
170
+ ```json
171
+ {
172
+ "backendUrl": "http://localhost:8004",
173
+ "state": "ok", // "ok" | "missing" | "expired" | "missing_backend"
174
+ "sessionId": "...",
175
+ "userId": "user-123",
176
+ "expiresAt": 0 // 0 or a Unix timestamp
177
+ }
178
+ ```
179
+
180
+ Exit codes:
181
+
182
+ - `0` – `state: "ok"` (valid session present).
183
+ - `1` – `state: "missing"` or `"missing_backend"`.
184
+ - `2` – `state: "expired"`.
185
+
186
+ ### Logout
187
+
188
+ ```bash
189
+ ctxce auth logout --backend-url http://localhost:8004
190
+ ```
191
+
192
+ Removes the stored auth entry for the given backend URL from
193
+ `~/.ctxce/auth.json`.
194
+
195
+ ## Relationship to the VS Code extension
196
+
197
+ The VS Code **Context Engine Uploader** extension is the recommended way to use
198
+ this bridge for day-to-day development. It:
199
+
200
+ - Launches the standalone upload client to push code into the remote stack.
201
+ - Starts/stops the MCP HTTP bridge (`ctxce mcp-http-serve`) for the active
202
+ workspace when `autoStartMcpBridge` is enabled.
203
+ - Uses `ctxce auth status --json` and `ctxce auth login` under the hood to
204
+ manage user sessions via UI prompts.
205
+
206
+ This package README is aimed at advanced users who want to:
207
+
208
+ - Run the MCP bridge outside of VS Code.
209
+ - Integrate the Context Engine MCP servers with other MCP-compatible clients.
210
+
211
+ You can safely mix both approaches: the extension and the standalone bridge
212
+ share the same auth/session storage in `~/.ctxce/auth.json`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@context-engine-bridge/context-engine-mcp-bridge",
3
- "version": "0.0.5",
3
+ "version": "0.0.7",
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/authCli.js ADDED
@@ -0,0 +1,284 @@
1
+ import process from "node:process";
2
+ import { loadAuthEntry, saveAuthEntry, deleteAuthEntry, loadAnyAuthEntry } from "./authConfig.js";
3
+
4
+ function parseAuthArgs(args) {
5
+ let backendUrl = process.env.CTXCE_AUTH_BACKEND_URL || "";
6
+ let token = process.env.CTXCE_AUTH_TOKEN || "";
7
+ let username = process.env.CTXCE_AUTH_USERNAME || "";
8
+ let password = process.env.CTXCE_AUTH_PASSWORD || "";
9
+ let outputJson = false;
10
+ for (let i = 0; i < args.length; i += 1) {
11
+ const a = args[i];
12
+ if ((a === "--backend-url" || a === "--auth-url") && i + 1 < args.length) {
13
+ backendUrl = args[i + 1];
14
+ i += 1;
15
+ continue;
16
+ }
17
+ if ((a === "--token" || a === "--api-key") && i + 1 < args.length) {
18
+ token = args[i + 1];
19
+ i += 1;
20
+ continue;
21
+ }
22
+ if (a === "--username" || a === "--user") {
23
+ const hasNext = i + 1 < args.length;
24
+ const next = hasNext ? String(args[i + 1]) : "";
25
+ if (hasNext && !next.startsWith("-")) {
26
+ username = args[i + 1];
27
+ i += 1;
28
+ } else {
29
+ console.error("[ctxce] Missing value for --username/--user; expected a username.");
30
+ process.exit(1);
31
+ }
32
+ continue;
33
+ }
34
+ if ((a === "--password" || a === "--pass") && i + 1 < args.length) {
35
+ password = args[i + 1];
36
+ i += 1;
37
+ continue;
38
+ }
39
+ if (a === "--json" || a === "-j") {
40
+ outputJson = true;
41
+ continue;
42
+ }
43
+ }
44
+ return { backendUrl, token, username, password, outputJson };
45
+ }
46
+
47
+ function getBackendUrl(backendUrl) {
48
+ return (backendUrl || process.env.CTXCE_AUTH_BACKEND_URL || "").trim();
49
+ }
50
+
51
+ function getDefaultUploadBackend() {
52
+ // Default to upload service when nothing else is configured
53
+ return (process.env.CTXCE_UPLOAD_ENDPOINT || process.env.UPLOAD_ENDPOINT || "http://localhost:8004").trim();
54
+ }
55
+
56
+ function requireBackendUrl(backendUrl) {
57
+ const url = getBackendUrl(backendUrl);
58
+ if (!url) {
59
+ console.error("[ctxce] Auth backend URL not configured. Set CTXCE_AUTH_BACKEND_URL or use --backend-url.");
60
+ process.exit(1);
61
+ }
62
+ return url;
63
+ }
64
+
65
+ function outputJsonStatus(url, state, entry, rawExpires) {
66
+ const expiresAt = typeof rawExpires === "number"
67
+ ? rawExpires
68
+ : entry && typeof entry.expiresAt === "number"
69
+ ? entry.expiresAt
70
+ : null;
71
+ console.log(JSON.stringify({
72
+ backendUrl: url,
73
+ state,
74
+ sessionId: entry && entry.sessionId ? entry.sessionId : null,
75
+ userId: entry && entry.userId ? entry.userId : null,
76
+ expiresAt,
77
+ }));
78
+ }
79
+
80
+ async function doLogin(args) {
81
+ const { backendUrl, token, username, password } = parseAuthArgs(args);
82
+ let url = getBackendUrl(backendUrl);
83
+ if (!url) {
84
+ // Fallback: use any stored auth entry when no backend is provided
85
+ const any = loadAnyAuthEntry();
86
+ if (any && any.backendUrl) {
87
+ url = any.backendUrl;
88
+ console.error("[ctxce] Using stored backend for login:", url);
89
+ }
90
+ }
91
+ if (!url) {
92
+ // Final fallback: default upload endpoint (extension's upload endpoint or localhost:8004)
93
+ url = getDefaultUploadBackend();
94
+ if (url) {
95
+ console.error("[ctxce] Using default upload backend for login:", url);
96
+ }
97
+ }
98
+ if (!url) {
99
+ console.error("[ctxce] Auth backend URL not configured. Set CTXCE_AUTH_BACKEND_URL or use --backend-url.");
100
+ process.exit(1);
101
+ }
102
+ const trimmedUser = (username || "").trim();
103
+ const usePassword = trimmedUser && (password || "").length > 0;
104
+
105
+ let body;
106
+ let target;
107
+ if (usePassword) {
108
+ body = {
109
+ username: trimmedUser,
110
+ password,
111
+ workspace: process.cwd(),
112
+ };
113
+ target = url.replace(/\/+$/, "") + "/auth/login/password";
114
+ } else {
115
+ body = {
116
+ client: "ctxce",
117
+ workspace: process.cwd(),
118
+ };
119
+ if (token) {
120
+ body.token = token;
121
+ }
122
+ target = url.replace(/\/+$/, "") + "/auth/login";
123
+ }
124
+ let resp;
125
+ try {
126
+ resp = await fetch(target, {
127
+ method: "POST",
128
+ headers: { "Content-Type": "application/json" },
129
+ body: JSON.stringify(body),
130
+ });
131
+ } catch (err) {
132
+ console.error("[ctxce] Auth login request failed:", String(err));
133
+ process.exit(1);
134
+ }
135
+ if (!resp || !resp.ok) {
136
+ console.error("[ctxce] Auth login failed with status", resp ? resp.status : "<no-response>");
137
+ process.exit(1);
138
+ }
139
+ let data;
140
+ try {
141
+ data = await resp.json();
142
+ } catch (err) {
143
+ data = {};
144
+ }
145
+ const sessionId = data.session_id || data.sessionId || null;
146
+ const userId = data.user_id || data.userId || null;
147
+ const expiresAt = data.expires_at || data.expiresAt || null;
148
+ if (!sessionId) {
149
+ console.error("[ctxce] Auth login response missing session id.");
150
+ process.exit(1);
151
+ }
152
+ saveAuthEntry(url, { sessionId, userId, expiresAt });
153
+ console.error("[ctxce] Auth login successful for", url);
154
+ }
155
+
156
+ async function doStatus(args) {
157
+ const { backendUrl, outputJson } = parseAuthArgs(args);
158
+ let url = getBackendUrl(backendUrl);
159
+ let usedFallback = false;
160
+ if (!url) {
161
+ // Fallback: use any stored auth entry when no backend is provided
162
+ const any = loadAnyAuthEntry();
163
+ if (any && any.backendUrl) {
164
+ url = any.backendUrl;
165
+ usedFallback = true;
166
+ }
167
+ }
168
+ if (!url) {
169
+ // Final fallback: default upload endpoint
170
+ url = getDefaultUploadBackend();
171
+ if (url) {
172
+ usedFallback = true;
173
+ console.error("[ctxce] Using default upload backend for status:", url);
174
+ }
175
+ }
176
+ if (!url) {
177
+ if (outputJson) {
178
+ outputJsonStatus("", "missing_backend", null, null);
179
+ process.exit(1);
180
+ }
181
+ console.error("[ctxce] Auth backend URL not configured and no stored sessions found. Set CTXCE_AUTH_BACKEND_URL or use --backend-url.");
182
+ process.exit(1);
183
+ }
184
+ let entry;
185
+ try {
186
+ entry = loadAuthEntry(url);
187
+ } catch (err) {
188
+ entry = null;
189
+ }
190
+ const nowSecs = Math.floor(Date.now() / 1000);
191
+ const rawExpires = entry && typeof entry.expiresAt === "number" ? entry.expiresAt : null;
192
+ const hasSession = !!(entry && typeof entry.sessionId === "string" && entry.sessionId);
193
+ const expired = !!(rawExpires && rawExpires > 0 && rawExpires < nowSecs);
194
+
195
+ if (!entry || !hasSession) {
196
+ if (outputJson) {
197
+ outputJsonStatus(url, "missing", null, rawExpires);
198
+ process.exit(1);
199
+ }
200
+ if (usedFallback) {
201
+ console.error("[ctxce] Not logged in for stored backend", url);
202
+ } else {
203
+ console.error("[ctxce] Not logged in for", url);
204
+ }
205
+ process.exit(1);
206
+ }
207
+
208
+ if (expired) {
209
+ if (outputJson) {
210
+ outputJsonStatus(url, "expired", entry, rawExpires);
211
+ process.exit(2);
212
+ }
213
+ if (usedFallback) {
214
+ console.error("[ctxce] Stored auth session appears expired for stored backend", url);
215
+ } else {
216
+ console.error("[ctxce] Stored auth session appears expired for", url);
217
+ }
218
+ if (rawExpires) {
219
+ console.error("[ctxce] Session expired at", rawExpires);
220
+ }
221
+ process.exit(2);
222
+ }
223
+
224
+ if (outputJson) {
225
+ outputJsonStatus(url, "ok", entry, rawExpires);
226
+ return;
227
+ }
228
+ if (usedFallback) {
229
+ console.error("[ctxce] Using stored backend for status:", url);
230
+ }
231
+ console.error("[ctxce] Logged in to", url, "as", entry.userId || "<unknown>");
232
+ if (rawExpires) {
233
+ console.error("[ctxce] Session expires at", rawExpires);
234
+ }
235
+ }
236
+
237
+ async function doLogout(args) {
238
+ const { backendUrl } = parseAuthArgs(args);
239
+ let url = getBackendUrl(backendUrl);
240
+ if (!url) {
241
+ // Fallback: use any stored auth entry when no backend is provided
242
+ const any = loadAnyAuthEntry();
243
+ if (any && any.backendUrl) {
244
+ url = any.backendUrl;
245
+ console.error("[ctxce] Using stored backend for logout:", url);
246
+ }
247
+ }
248
+ if (!url) {
249
+ // Final fallback: default upload endpoint
250
+ url = getDefaultUploadBackend();
251
+ if (url) {
252
+ console.error("[ctxce] Using default upload backend for logout:", url);
253
+ }
254
+ }
255
+ if (!url) {
256
+ console.error("[ctxce] Auth backend URL not configured and no stored sessions found. Set CTXCE_AUTH_BACKEND_URL or use --backend-url.");
257
+ process.exit(1);
258
+ }
259
+ const entry = loadAuthEntry(url);
260
+ if (!entry) {
261
+ console.error("[ctxce] No stored auth session for", url);
262
+ return;
263
+ }
264
+ deleteAuthEntry(url);
265
+ console.error("[ctxce] Logged out from", url);
266
+ }
267
+
268
+ export async function runAuthCommand(subcommand, args) {
269
+ const sub = (subcommand || "").toLowerCase();
270
+ if (sub === "login") {
271
+ await doLogin(args || []);
272
+ return;
273
+ }
274
+ if (sub === "status") {
275
+ await doStatus(args || []);
276
+ return;
277
+ }
278
+ if (sub === "logout") {
279
+ await doLogout(args || []);
280
+ return;
281
+ }
282
+ console.error("Usage: ctxce auth <login|status|logout> [--backend-url <url>] [--token <token>]");
283
+ process.exit(1);
284
+ }
@@ -0,0 +1,84 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ const CONFIG_DIR_NAME = ".ctxce";
6
+ const CONFIG_BASENAME = "auth.json";
7
+
8
+ function getConfigPath() {
9
+ const home = os.homedir() || process.cwd();
10
+ const dir = path.join(home, CONFIG_DIR_NAME);
11
+ return path.join(dir, CONFIG_BASENAME);
12
+ }
13
+
14
+ function readConfig() {
15
+ try {
16
+ const cfgPath = getConfigPath();
17
+ const raw = fs.readFileSync(cfgPath, "utf8");
18
+ const parsed = JSON.parse(raw);
19
+ if (parsed && typeof parsed === "object") {
20
+ return parsed;
21
+ }
22
+ } catch (err) {
23
+ }
24
+ return {};
25
+ }
26
+
27
+ function writeConfig(data) {
28
+ try {
29
+ const cfgPath = getConfigPath();
30
+ const dir = path.dirname(cfgPath);
31
+ if (!fs.existsSync(dir)) {
32
+ fs.mkdirSync(dir, { recursive: true });
33
+ }
34
+ fs.writeFileSync(cfgPath, JSON.stringify(data, null, 2), "utf8");
35
+ } catch (err) {
36
+ }
37
+ }
38
+
39
+ export function loadAuthEntry(backendUrl) {
40
+ if (!backendUrl) {
41
+ return null;
42
+ }
43
+ const all = readConfig();
44
+ const key = String(backendUrl);
45
+ const entry = all[key];
46
+ if (!entry || typeof entry !== "object") {
47
+ return null;
48
+ }
49
+ return entry;
50
+ }
51
+
52
+ export function saveAuthEntry(backendUrl, entry) {
53
+ if (!backendUrl || !entry || typeof entry !== "object") {
54
+ return;
55
+ }
56
+ const all = readConfig();
57
+ const key = String(backendUrl);
58
+ all[key] = entry;
59
+ writeConfig(all);
60
+ }
61
+
62
+ export function deleteAuthEntry(backendUrl) {
63
+ if (!backendUrl) {
64
+ return;
65
+ }
66
+ const all = readConfig();
67
+ const key = String(backendUrl);
68
+ if (Object.prototype.hasOwnProperty.call(all, key)) {
69
+ delete all[key];
70
+ writeConfig(all);
71
+ }
72
+ }
73
+
74
+ export function loadAnyAuthEntry() {
75
+ const all = readConfig();
76
+ const keys = Object.keys(all);
77
+ for (const key of keys) {
78
+ const entry = all[key];
79
+ if (entry && typeof entry === "object") {
80
+ return { backendUrl: key, entry };
81
+ }
82
+ }
83
+ return null;
84
+ }
package/src/cli.js CHANGED
@@ -4,11 +4,19 @@ import process from "node:process";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { runMcpServer, runHttpMcpServer } from "./mcpServer.js";
7
+ import { runAuthCommand } from "./authCli.js";
7
8
 
8
9
  export async function runCli() {
9
10
  const argv = process.argv.slice(2);
10
11
  const cmd = argv[0];
11
12
 
13
+ if (cmd === "auth") {
14
+ const sub = argv[1] || "";
15
+ const args = argv.slice(2);
16
+ await runAuthCommand(sub, args);
17
+ return;
18
+ }
19
+
12
20
  if (cmd === "mcp-http-serve") {
13
21
  const args = argv.slice(1);
14
22
  let workspace = process.cwd();
@@ -109,7 +117,7 @@ export async function runCli() {
109
117
 
110
118
  // eslint-disable-next-line no-console
111
119
  console.error(
112
- `Usage: ${binName} mcp-serve [--workspace <path>] [--indexer-url <url>] [--memory-url <url>] | ${binName} mcp-http-serve [--workspace <path>] [--indexer-url <url>] [--memory-url <url>] [--port <port>]`,
120
+ `Usage: ${binName} mcp-serve [--workspace <path>] [--indexer-url <url>] [--memory-url <url>] | ${binName} mcp-http-serve [--workspace <path>] [--indexer-url <url>] [--memory-url <url>] [--port <port>] | ${binName} auth <login|status|logout> [--backend-url <url>] [--token <token>] [--username <name> --password <pass>]`,
113
121
  );
114
122
  process.exit(1);
115
123
  }
package/src/mcpServer.js CHANGED
@@ -1,3 +1,17 @@
1
+ import process from "node:process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { execSync } from "node:child_process";
5
+ import { createServer } from "node:http";
6
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
7
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
8
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
9
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
10
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
11
+ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
12
+ import { loadAnyAuthEntry, loadAuthEntry } from "./authConfig.js";
13
+ import { maybeRemapToolResult } from "./resultPathMapping.js";
14
+
1
15
  function debugLog(message) {
2
16
  try {
3
17
  const text = typeof message === "string" ? message : String(message);
@@ -236,18 +250,6 @@ function isTransientToolError(error) {
236
250
  // Acts as a low-level proxy for tools, forwarding tools/list and tools/call
237
251
  // to the remote qdrant-indexer MCP server while adding a local `ping` tool.
238
252
 
239
- import process from "node:process";
240
- import fs from "node:fs";
241
- import path from "node:path";
242
- import { execSync } from "node:child_process";
243
- import { createServer } from "node:http";
244
- import { Server } from "@modelcontextprotocol/sdk/server/index.js";
245
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
246
- import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
247
- import { Client } from "@modelcontextprotocol/sdk/client/index.js";
248
- import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
249
- import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
250
-
251
253
  async function createBridgeServer(options) {
252
254
  const workspace = options.workspace || process.cwd();
253
255
  const indexerUrl = options.indexerUrl;
@@ -281,8 +283,61 @@ async function createBridgeServer(options) {
281
283
  // future this can be made user-aware (e.g. from auth), but for now we
282
284
  // keep it deterministic per workspace to help the indexer reuse
283
285
  // session-scoped defaults.
284
- const sessionId =
285
- process.env.CTXCE_SESSION_ID || `ctxce-${Buffer.from(workspace).toString("hex").slice(0, 24)}`;
286
+ const explicitSession = process.env.CTXCE_SESSION_ID || "";
287
+ const authBackendUrl = process.env.CTXCE_AUTH_BACKEND_URL || "";
288
+ let sessionId = explicitSession;
289
+
290
+ function resolveSessionId() {
291
+ const explicit = process.env.CTXCE_SESSION_ID || "";
292
+ if (explicit) {
293
+ return explicit;
294
+ }
295
+ let backendToUse = authBackendUrl;
296
+ let entry = null;
297
+ if (backendToUse) {
298
+ try {
299
+ entry = loadAuthEntry(backendToUse);
300
+ } catch {
301
+ entry = null;
302
+ }
303
+ }
304
+ if (!entry) {
305
+ try {
306
+ const any = loadAnyAuthEntry();
307
+ if (any && any.entry) {
308
+ backendToUse = any.backendUrl;
309
+ entry = any.entry;
310
+ }
311
+ } catch {
312
+ entry = null;
313
+ }
314
+ }
315
+ if (entry) {
316
+ let expired = false;
317
+ const rawExpires = entry.expiresAt;
318
+ if (typeof rawExpires === "number" && Number.isFinite(rawExpires) && rawExpires > 0) {
319
+ const nowSecs = Math.floor(Date.now() / 1000);
320
+ if (rawExpires < nowSecs) {
321
+ expired = true;
322
+ }
323
+ }
324
+ if (!expired && typeof entry.sessionId === "string" && entry.sessionId) {
325
+ return entry.sessionId;
326
+ }
327
+ if (expired) {
328
+ debugLog("[ctxce] Stored auth session appears expired; please run `ctxce auth login` again.");
329
+ }
330
+ }
331
+ return "";
332
+ }
333
+
334
+ if (!sessionId) {
335
+ sessionId = resolveSessionId();
336
+ }
337
+
338
+ if (!sessionId) {
339
+ sessionId = `ctxce-${Buffer.from(workspace).toString("hex").slice(0, 24)}`;
340
+ }
286
341
 
287
342
  // Best-effort: inform the indexer of default collection and session.
288
343
  // If this fails we still proceed, falling back to per-call injection.
@@ -430,7 +485,16 @@ async function createBridgeServer(options) {
430
485
 
431
486
  debugLog(`[ctxce] tools/call: ${name || "<no-name>"}`);
432
487
 
433
- // Attach session id so the target server can apply per-session defaults.
488
+ // Refresh session before each call; re-init clients if session changes.
489
+ const freshSession = resolveSessionId() || sessionId;
490
+ if (freshSession && freshSession !== sessionId) {
491
+ sessionId = freshSession;
492
+ try {
493
+ await initializeRemoteClients(true);
494
+ } catch (err) {
495
+ debugLog("[ctxce] Failed to reinitialize clients after session refresh: " + String(err));
496
+ }
497
+ }
434
498
  if (sessionId && (args === undefined || args === null || typeof args === "object")) {
435
499
  const obj = args && typeof args === "object" ? { ...args } : {};
436
500
  if (!Object.prototype.hasOwnProperty.call(obj, "session")) {
@@ -478,7 +542,7 @@ async function createBridgeServer(options) {
478
542
  undefined,
479
543
  { timeout: timeoutMs },
480
544
  );
481
- return result;
545
+ return maybeRemapToolResult(name, result, workspace);
482
546
  } catch (err) {
483
547
  lastError = err;
484
548
 
@@ -0,0 +1,266 @@
1
+ import process from "node:process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+
5
+ function envTruthy(value, defaultVal = false) {
6
+ try {
7
+ if (value === undefined || value === null) {
8
+ return defaultVal;
9
+ }
10
+ const s = String(value).trim().toLowerCase();
11
+ if (!s) {
12
+ return defaultVal;
13
+ }
14
+ return s === "1" || s === "true" || s === "yes" || s === "on";
15
+ } catch {
16
+ return defaultVal;
17
+ }
18
+ }
19
+
20
+ function _posixToNative(rel) {
21
+ try {
22
+ if (!rel) {
23
+ return "";
24
+ }
25
+ return String(rel).split("/").join(path.sep);
26
+ } catch {
27
+ return rel;
28
+ }
29
+ }
30
+
31
+ function computeWorkspaceRelativePath(containerPath, hostPath) {
32
+ try {
33
+ const cont = typeof containerPath === "string" ? containerPath.trim() : "";
34
+ if (cont.startsWith("/work/")) {
35
+ const rest = cont.slice("/work/".length);
36
+ const parts = rest.split("/").filter(Boolean);
37
+ if (parts.length >= 2) {
38
+ return parts.slice(1).join("/");
39
+ }
40
+ if (parts.length === 1) {
41
+ return parts[0];
42
+ }
43
+ }
44
+ } catch {
45
+ }
46
+ try {
47
+ const hp = typeof hostPath === "string" ? hostPath.trim() : "";
48
+ if (!hp) {
49
+ return "";
50
+ }
51
+ // If we don't have a container path, at least try to return a basename.
52
+ return path.posix.basename(hp.replace(/\\/g, "/"));
53
+ } catch {
54
+ return "";
55
+ }
56
+ }
57
+
58
+ function remapHitPaths(hit, workspaceRoot) {
59
+ if (!hit || typeof hit !== "object") {
60
+ return hit;
61
+ }
62
+ const hostPath = typeof hit.host_path === "string" ? hit.host_path : "";
63
+ const containerPath = typeof hit.container_path === "string" ? hit.container_path : "";
64
+ const relPath = computeWorkspaceRelativePath(containerPath, hostPath);
65
+ const out = { ...hit };
66
+ if (relPath) {
67
+ out.rel_path = relPath;
68
+ }
69
+ if (workspaceRoot && relPath) {
70
+ try {
71
+ const relNative = _posixToNative(relPath);
72
+ const candidate = path.join(workspaceRoot, relNative);
73
+ const diagnostics = envTruthy(process.env.CTXCE_BRIDGE_PATH_DIAGNOSTICS, false);
74
+ const strictClientPath = envTruthy(process.env.CTXCE_BRIDGE_CLIENT_PATH_STRICT, false);
75
+ if (strictClientPath) {
76
+ out.client_path = candidate;
77
+ if (diagnostics) {
78
+ out.client_path_joined = candidate;
79
+ out.client_path_source = "workspace_join";
80
+ }
81
+ } else {
82
+ // Prefer a host_path that is within the current bridge workspace.
83
+ // This keeps provenance (host_path) intact while providing a user-local
84
+ // absolute path even when the bridge workspace is a parent directory.
85
+ const hp = typeof hostPath === "string" ? hostPath : "";
86
+ const hpNorm = hp ? hp.replace(/\\/g, path.sep) : "";
87
+ if (
88
+ hpNorm &&
89
+ hpNorm.startsWith(workspaceRoot) &&
90
+ (!fs.existsSync(candidate) || fs.existsSync(hpNorm))
91
+ ) {
92
+ out.client_path = hpNorm;
93
+ if (diagnostics) {
94
+ out.client_path_joined = candidate;
95
+ out.client_path_source = "host_path";
96
+ }
97
+ } else {
98
+ out.client_path = candidate;
99
+ if (diagnostics) {
100
+ out.client_path_joined = candidate;
101
+ out.client_path_source = "workspace_join";
102
+ }
103
+ }
104
+ }
105
+ } catch {
106
+ // ignore
107
+ }
108
+ }
109
+ const overridePath = envTruthy(process.env.CTXCE_BRIDGE_OVERRIDE_PATH, true);
110
+ if (overridePath && relPath) {
111
+ out.path = relPath;
112
+ }
113
+ return out;
114
+ }
115
+
116
+ function remapStringPath(p) {
117
+ try {
118
+ const s = typeof p === "string" ? p : "";
119
+ if (!s) {
120
+ return p;
121
+ }
122
+ if (s.startsWith("/work/")) {
123
+ const rest = s.slice("/work/".length);
124
+ const parts = rest.split("/").filter(Boolean);
125
+ if (parts.length >= 2) {
126
+ const rel = parts.slice(1).join("/");
127
+ const override = envTruthy(process.env.CTXCE_BRIDGE_OVERRIDE_PATH, true);
128
+ if (override) {
129
+ return rel;
130
+ }
131
+ return p;
132
+ }
133
+ }
134
+ return p;
135
+ } catch {
136
+ return p;
137
+ }
138
+ }
139
+
140
+ function maybeParseToolJson(result) {
141
+ try {
142
+ if (
143
+ result &&
144
+ typeof result === "object" &&
145
+ result.structuredContent &&
146
+ typeof result.structuredContent === "object"
147
+ ) {
148
+ return { mode: "structured", value: result.structuredContent };
149
+ }
150
+ } catch {
151
+ }
152
+ try {
153
+ const content = result && result.content;
154
+ if (!Array.isArray(content)) {
155
+ return null;
156
+ }
157
+ const first = content.find(
158
+ (c) => c && c.type === "text" && typeof c.text === "string",
159
+ );
160
+ if (!first) {
161
+ return null;
162
+ }
163
+ const txt = String(first.text || "").trim();
164
+ if (!txt || !(txt.startsWith("{") || txt.startsWith("["))) {
165
+ return null;
166
+ }
167
+ return { mode: "text", value: JSON.parse(txt) };
168
+ } catch {
169
+ return null;
170
+ }
171
+ }
172
+
173
+ function applyPathMappingToPayload(payload, workspaceRoot) {
174
+ if (!payload || typeof payload !== "object") {
175
+ return payload;
176
+ }
177
+ const out = Array.isArray(payload) ? payload.slice() : { ...payload };
178
+
179
+ const mapHitsArray = (arr) => {
180
+ if (!Array.isArray(arr)) {
181
+ return arr;
182
+ }
183
+ return arr.map((h) => remapHitPaths(h, workspaceRoot));
184
+ };
185
+
186
+ // Common result shapes across tools
187
+ if (Array.isArray(out.results)) {
188
+ out.results = mapHitsArray(out.results);
189
+ }
190
+ if (Array.isArray(out.citations)) {
191
+ out.citations = mapHitsArray(out.citations);
192
+ }
193
+ if (Array.isArray(out.related_paths)) {
194
+ out.related_paths = out.related_paths.map((p) => remapStringPath(p));
195
+ }
196
+
197
+ // context_search: {results:[{source:"code"|"memory", ...}]}
198
+ if (Array.isArray(out.results)) {
199
+ out.results = out.results.map((r) => {
200
+ if (!r || typeof r !== "object") {
201
+ return r;
202
+ }
203
+ // Only code results have path-like fields
204
+ return remapHitPaths(r, workspaceRoot);
205
+ });
206
+ }
207
+
208
+ // Some tools nest under {result:{...}}
209
+ if (out.result && typeof out.result === "object") {
210
+ out.result = applyPathMappingToPayload(out.result, workspaceRoot);
211
+ }
212
+
213
+ return out;
214
+ }
215
+
216
+ export function maybeRemapToolResult(name, result, workspaceRoot) {
217
+ try {
218
+ if (!name || !result || !workspaceRoot) {
219
+ return result;
220
+ }
221
+ const enabled = envTruthy(process.env.CTXCE_BRIDGE_MAP_PATHS, true);
222
+ if (!enabled) {
223
+ return result;
224
+ }
225
+ const lower = String(name).toLowerCase();
226
+ const shouldMap = (
227
+ lower === "repo_search" ||
228
+ lower === "context_search" ||
229
+ lower === "context_answer" ||
230
+ lower.endsWith("search_tests_for") ||
231
+ lower.endsWith("search_config_for") ||
232
+ lower.endsWith("search_callers_for") ||
233
+ lower.endsWith("search_importers_for")
234
+ );
235
+ if (!shouldMap) {
236
+ return result;
237
+ }
238
+
239
+ const parsed = maybeParseToolJson(result);
240
+ if (!parsed) {
241
+ return result;
242
+ }
243
+
244
+ const mapped = applyPathMappingToPayload(parsed.value, workspaceRoot);
245
+ if (parsed.mode === "structured") {
246
+ return { ...result, structuredContent: mapped };
247
+ }
248
+
249
+ // Replace text payload for clients that only read `content[].text`
250
+ try {
251
+ const content = Array.isArray(result.content) ? result.content.slice() : [];
252
+ const idx = content.findIndex(
253
+ (c) => c && c.type === "text" && typeof c.text === "string",
254
+ );
255
+ if (idx >= 0) {
256
+ content[idx] = { ...content[idx], text: JSON.stringify(mapped) };
257
+ return { ...result, content };
258
+ }
259
+ } catch {
260
+ // ignore
261
+ }
262
+ return result;
263
+ } catch {
264
+ return result;
265
+ }
266
+ }