@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 +212 -0
- package/package.json +1 -1
- package/src/authCli.js +284 -0
- package/src/authConfig.js +84 -0
- package/src/cli.js +9 -1
- package/src/mcpServer.js +80 -16
- package/src/resultPathMapping.js +266 -0
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
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
|
|
285
|
-
|
|
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
|
-
//
|
|
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
|
+
}
|