@context-engine-bridge/context-engine-mcp-bridge 0.0.4 → 0.0.6
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 +206 -0
- package/src/authConfig.js +84 -0
- package/src/cli.js +9 -1
- package/src/mcpServer.js +290 -64
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,206 @@
|
|
|
1
|
+
import process from "node:process";
|
|
2
|
+
import { loadAuthEntry, saveAuthEntry, deleteAuthEntry } 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") && i + 1 < args.length) {
|
|
23
|
+
username = args[i + 1];
|
|
24
|
+
i += 1;
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if ((a === "--password" || a === "--pass") && i + 1 < args.length) {
|
|
28
|
+
password = args[i + 1];
|
|
29
|
+
i += 1;
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (a === "--json" || a === "-j") {
|
|
33
|
+
outputJson = true;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return { backendUrl, token, username, password, outputJson };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function getBackendUrl(backendUrl) {
|
|
41
|
+
return (backendUrl || process.env.CTXCE_AUTH_BACKEND_URL || "").trim();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function requireBackendUrl(backendUrl) {
|
|
45
|
+
const url = getBackendUrl(backendUrl);
|
|
46
|
+
if (!url) {
|
|
47
|
+
console.error("[ctxce] Auth backend URL not configured. Set CTXCE_AUTH_BACKEND_URL or use --backend-url.");
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
return url;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function outputJsonStatus(url, state, entry, rawExpires) {
|
|
54
|
+
const expiresAt = typeof rawExpires === "number"
|
|
55
|
+
? rawExpires
|
|
56
|
+
: entry && typeof entry.expiresAt === "number"
|
|
57
|
+
? entry.expiresAt
|
|
58
|
+
: null;
|
|
59
|
+
console.log(JSON.stringify({
|
|
60
|
+
backendUrl: url,
|
|
61
|
+
state,
|
|
62
|
+
sessionId: entry && entry.sessionId ? entry.sessionId : null,
|
|
63
|
+
userId: entry && entry.userId ? entry.userId : null,
|
|
64
|
+
expiresAt,
|
|
65
|
+
}));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function doLogin(args) {
|
|
69
|
+
const { backendUrl, token, username, password } = parseAuthArgs(args);
|
|
70
|
+
const url = requireBackendUrl(backendUrl);
|
|
71
|
+
const trimmedUser = (username || "").trim();
|
|
72
|
+
const usePassword = trimmedUser && (password || "").length > 0;
|
|
73
|
+
|
|
74
|
+
let body;
|
|
75
|
+
let target;
|
|
76
|
+
if (usePassword) {
|
|
77
|
+
body = {
|
|
78
|
+
username: trimmedUser,
|
|
79
|
+
password,
|
|
80
|
+
workspace: process.cwd(),
|
|
81
|
+
};
|
|
82
|
+
target = url.replace(/\/+$/, "") + "/auth/login/password";
|
|
83
|
+
} else {
|
|
84
|
+
body = {
|
|
85
|
+
client: "ctxce",
|
|
86
|
+
workspace: process.cwd(),
|
|
87
|
+
};
|
|
88
|
+
if (token) {
|
|
89
|
+
body.token = token;
|
|
90
|
+
}
|
|
91
|
+
target = url.replace(/\/+$/, "") + "/auth/login";
|
|
92
|
+
}
|
|
93
|
+
let resp;
|
|
94
|
+
try {
|
|
95
|
+
resp = await fetch(target, {
|
|
96
|
+
method: "POST",
|
|
97
|
+
headers: { "Content-Type": "application/json" },
|
|
98
|
+
body: JSON.stringify(body),
|
|
99
|
+
});
|
|
100
|
+
} catch (err) {
|
|
101
|
+
console.error("[ctxce] Auth login request failed:", String(err));
|
|
102
|
+
process.exit(1);
|
|
103
|
+
}
|
|
104
|
+
if (!resp || !resp.ok) {
|
|
105
|
+
console.error("[ctxce] Auth login failed with status", resp ? resp.status : "<no-response>");
|
|
106
|
+
process.exit(1);
|
|
107
|
+
}
|
|
108
|
+
let data;
|
|
109
|
+
try {
|
|
110
|
+
data = await resp.json();
|
|
111
|
+
} catch (err) {
|
|
112
|
+
data = {};
|
|
113
|
+
}
|
|
114
|
+
const sessionId = data.session_id || data.sessionId || null;
|
|
115
|
+
const userId = data.user_id || data.userId || null;
|
|
116
|
+
const expiresAt = data.expires_at || data.expiresAt || null;
|
|
117
|
+
if (!sessionId) {
|
|
118
|
+
console.error("[ctxce] Auth login response missing session id.");
|
|
119
|
+
process.exit(1);
|
|
120
|
+
}
|
|
121
|
+
saveAuthEntry(url, { sessionId, userId, expiresAt });
|
|
122
|
+
console.error("[ctxce] Auth login successful for", url);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function doStatus(args) {
|
|
126
|
+
const { backendUrl, outputJson } = parseAuthArgs(args);
|
|
127
|
+
const url = getBackendUrl(backendUrl);
|
|
128
|
+
if (!url) {
|
|
129
|
+
if (outputJson) {
|
|
130
|
+
outputJsonStatus("", "missing_backend", null, null);
|
|
131
|
+
process.exit(1);
|
|
132
|
+
}
|
|
133
|
+
console.error("[ctxce] Auth backend URL not configured. Set CTXCE_AUTH_BACKEND_URL or use --backend-url.");
|
|
134
|
+
process.exit(1);
|
|
135
|
+
}
|
|
136
|
+
let entry;
|
|
137
|
+
try {
|
|
138
|
+
entry = loadAuthEntry(url);
|
|
139
|
+
} catch (err) {
|
|
140
|
+
entry = null;
|
|
141
|
+
}
|
|
142
|
+
const nowSecs = Math.floor(Date.now() / 1000);
|
|
143
|
+
const rawExpires = entry && typeof entry.expiresAt === "number" ? entry.expiresAt : null;
|
|
144
|
+
const hasSession = !!(entry && typeof entry.sessionId === "string" && entry.sessionId);
|
|
145
|
+
const expired = !!(rawExpires && rawExpires > 0 && rawExpires < nowSecs);
|
|
146
|
+
|
|
147
|
+
if (!entry || !hasSession) {
|
|
148
|
+
if (outputJson) {
|
|
149
|
+
outputJsonStatus(url, "missing", null, rawExpires);
|
|
150
|
+
process.exit(1);
|
|
151
|
+
}
|
|
152
|
+
console.error("[ctxce] Not logged in for", url);
|
|
153
|
+
process.exit(1);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (expired) {
|
|
157
|
+
if (outputJson) {
|
|
158
|
+
outputJsonStatus(url, "expired", entry, rawExpires);
|
|
159
|
+
process.exit(2);
|
|
160
|
+
}
|
|
161
|
+
console.error("[ctxce] Stored auth session appears expired for", url);
|
|
162
|
+
if (rawExpires) {
|
|
163
|
+
console.error("[ctxce] Session expired at", rawExpires);
|
|
164
|
+
}
|
|
165
|
+
process.exit(2);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (outputJson) {
|
|
169
|
+
outputJsonStatus(url, "ok", entry, rawExpires);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
console.error("[ctxce] Logged in to", url, "as", entry.userId || "<unknown>");
|
|
173
|
+
if (rawExpires) {
|
|
174
|
+
console.error("[ctxce] Session expires at", rawExpires);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function doLogout(args) {
|
|
179
|
+
const { backendUrl } = parseAuthArgs(args);
|
|
180
|
+
const url = requireBackendUrl(backendUrl);
|
|
181
|
+
const entry = loadAuthEntry(url);
|
|
182
|
+
if (!entry) {
|
|
183
|
+
console.error("[ctxce] No stored auth session for", url);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
deleteAuthEntry(url);
|
|
187
|
+
console.error("[ctxce] Logged out from", url);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export async function runAuthCommand(subcommand, args) {
|
|
191
|
+
const sub = (subcommand || "").toLowerCase();
|
|
192
|
+
if (sub === "login") {
|
|
193
|
+
await doLogin(args || []);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
if (sub === "status") {
|
|
197
|
+
await doStatus(args || []);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (sub === "logout") {
|
|
201
|
+
await doLogout(args || []);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
console.error("Usage: ctxce auth <login|status|logout> [--backend-url <url>] [--token <token>]");
|
|
205
|
+
process.exit(1);
|
|
206
|
+
}
|
|
@@ -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
|
@@ -120,6 +120,118 @@ function selectClientForTool(name, indexerClient, memoryClient) {
|
|
|
120
120
|
}
|
|
121
121
|
return indexerClient;
|
|
122
122
|
}
|
|
123
|
+
|
|
124
|
+
function isSessionError(error) {
|
|
125
|
+
try {
|
|
126
|
+
const msg =
|
|
127
|
+
(error && typeof error.message === "string" && error.message) ||
|
|
128
|
+
(typeof error === "string" ? error : String(error || ""));
|
|
129
|
+
if (!msg) {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
return (
|
|
133
|
+
msg.includes("No valid session ID") ||
|
|
134
|
+
msg.includes("Mcp-Session-Id header is required") ||
|
|
135
|
+
msg.includes("Server not initialized") ||
|
|
136
|
+
msg.includes("Session not found")
|
|
137
|
+
);
|
|
138
|
+
} catch {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function getBridgeRetryAttempts() {
|
|
144
|
+
try {
|
|
145
|
+
const raw = process.env.CTXCE_TOOL_RETRY_ATTEMPTS;
|
|
146
|
+
if (!raw) {
|
|
147
|
+
return 2;
|
|
148
|
+
}
|
|
149
|
+
const parsed = Number.parseInt(String(raw), 10);
|
|
150
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
151
|
+
return 1;
|
|
152
|
+
}
|
|
153
|
+
return parsed;
|
|
154
|
+
} catch {
|
|
155
|
+
return 2;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function getBridgeRetryDelayMs() {
|
|
160
|
+
try {
|
|
161
|
+
const raw = process.env.CTXCE_TOOL_RETRY_DELAY_MSEC;
|
|
162
|
+
if (!raw) {
|
|
163
|
+
return 200;
|
|
164
|
+
}
|
|
165
|
+
const parsed = Number.parseInt(String(raw), 10);
|
|
166
|
+
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
167
|
+
return 0;
|
|
168
|
+
}
|
|
169
|
+
return parsed;
|
|
170
|
+
} catch {
|
|
171
|
+
return 200;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function isTransientToolError(error) {
|
|
176
|
+
try {
|
|
177
|
+
const msg =
|
|
178
|
+
(error && typeof error.message === "string" && error.message) ||
|
|
179
|
+
(typeof error === "string" ? error : String(error || ""));
|
|
180
|
+
if (!msg) {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
const lower = msg.toLowerCase();
|
|
184
|
+
|
|
185
|
+
if (
|
|
186
|
+
lower.includes("timed out") ||
|
|
187
|
+
lower.includes("timeout") ||
|
|
188
|
+
lower.includes("time-out")
|
|
189
|
+
) {
|
|
190
|
+
return true;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (
|
|
194
|
+
lower.includes("econnreset") ||
|
|
195
|
+
lower.includes("econnrefused") ||
|
|
196
|
+
lower.includes("etimedout") ||
|
|
197
|
+
lower.includes("enotfound") ||
|
|
198
|
+
lower.includes("ehostunreach") ||
|
|
199
|
+
lower.includes("enetunreach")
|
|
200
|
+
) {
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (
|
|
205
|
+
lower.includes("bad gateway") ||
|
|
206
|
+
lower.includes("gateway timeout") ||
|
|
207
|
+
lower.includes("service unavailable") ||
|
|
208
|
+
lower.includes(" 502 ") ||
|
|
209
|
+
lower.includes(" 503 ") ||
|
|
210
|
+
lower.includes(" 504 ")
|
|
211
|
+
) {
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (lower.includes("network error")) {
|
|
216
|
+
return true;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (typeof error.code === "number" && error.code === -32001 && !isSessionError(error)) {
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
if (
|
|
223
|
+
typeof error.code === "string" &&
|
|
224
|
+
error.code.toLowerCase &&
|
|
225
|
+
error.code.toLowerCase().includes("timeout")
|
|
226
|
+
) {
|
|
227
|
+
return true;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return false;
|
|
231
|
+
} catch {
|
|
232
|
+
return false;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
123
235
|
// MCP stdio server implemented using the official MCP TypeScript SDK.
|
|
124
236
|
// Acts as a low-level proxy for tools, forwarding tools/list and tools/call
|
|
125
237
|
// to the remote qdrant-indexer MCP server while adding a local `ping` tool.
|
|
@@ -135,6 +247,7 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/
|
|
|
135
247
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
136
248
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
137
249
|
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
250
|
+
import { loadAnyAuthEntry, loadAuthEntry } from "./authConfig.js";
|
|
138
251
|
|
|
139
252
|
async function createBridgeServer(options) {
|
|
140
253
|
const workspace = options.workspace || process.cwd();
|
|
@@ -162,59 +275,61 @@ async function createBridgeServer(options) {
|
|
|
162
275
|
);
|
|
163
276
|
}
|
|
164
277
|
|
|
165
|
-
|
|
166
|
-
const indexerTransport = new StreamableHTTPClientTransport(indexerUrl);
|
|
167
|
-
const indexerClient = new Client(
|
|
168
|
-
{
|
|
169
|
-
name: "ctx-context-engine-bridge-http-client",
|
|
170
|
-
version: "0.0.1",
|
|
171
|
-
},
|
|
172
|
-
{
|
|
173
|
-
capabilities: {
|
|
174
|
-
tools: {},
|
|
175
|
-
resources: {},
|
|
176
|
-
prompts: {},
|
|
177
|
-
},
|
|
178
|
-
},
|
|
179
|
-
);
|
|
180
|
-
|
|
181
|
-
try {
|
|
182
|
-
await indexerClient.connect(indexerTransport);
|
|
183
|
-
} catch (err) {
|
|
184
|
-
debugLog("[ctxce] Failed to connect MCP HTTP client to indexer: " + String(err));
|
|
185
|
-
}
|
|
186
|
-
|
|
278
|
+
let indexerClient = null;
|
|
187
279
|
let memoryClient = null;
|
|
188
|
-
if (memoryUrl) {
|
|
189
|
-
try {
|
|
190
|
-
const memoryTransport = new StreamableHTTPClientTransport(memoryUrl);
|
|
191
|
-
memoryClient = new Client(
|
|
192
|
-
{
|
|
193
|
-
name: "ctx-context-engine-bridge-memory-client",
|
|
194
|
-
version: "0.0.1",
|
|
195
|
-
},
|
|
196
|
-
{
|
|
197
|
-
capabilities: {
|
|
198
|
-
tools: {},
|
|
199
|
-
resources: {},
|
|
200
|
-
prompts: {},
|
|
201
|
-
},
|
|
202
|
-
},
|
|
203
|
-
);
|
|
204
|
-
await memoryClient.connect(memoryTransport);
|
|
205
|
-
debugLog(`[ctxce] Connected memory MCP client: ${memoryUrl}`);
|
|
206
|
-
} catch (err) {
|
|
207
|
-
debugLog("[ctxce] Failed to connect memory MCP client: " + String(err));
|
|
208
|
-
memoryClient = null;
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
280
|
|
|
212
281
|
// Derive a simple session identifier for this bridge process. In the
|
|
213
282
|
// future this can be made user-aware (e.g. from auth), but for now we
|
|
214
283
|
// keep it deterministic per workspace to help the indexer reuse
|
|
215
284
|
// session-scoped defaults.
|
|
216
|
-
const
|
|
217
|
-
|
|
285
|
+
const explicitSession = process.env.CTXCE_SESSION_ID || "";
|
|
286
|
+
const authBackendUrl = process.env.CTXCE_AUTH_BACKEND_URL || "";
|
|
287
|
+
let sessionId = explicitSession;
|
|
288
|
+
|
|
289
|
+
if (!sessionId) {
|
|
290
|
+
let backendToUse = authBackendUrl;
|
|
291
|
+
let entry = null;
|
|
292
|
+
|
|
293
|
+
if (backendToUse) {
|
|
294
|
+
try {
|
|
295
|
+
entry = loadAuthEntry(backendToUse);
|
|
296
|
+
} catch (err) {
|
|
297
|
+
entry = null;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (!entry) {
|
|
302
|
+
try {
|
|
303
|
+
const any = loadAnyAuthEntry();
|
|
304
|
+
if (any && any.entry) {
|
|
305
|
+
backendToUse = any.backendUrl;
|
|
306
|
+
entry = any.entry;
|
|
307
|
+
}
|
|
308
|
+
} catch (err) {
|
|
309
|
+
entry = null;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (entry) {
|
|
314
|
+
let expired = false;
|
|
315
|
+
const rawExpires = entry.expiresAt;
|
|
316
|
+
if (typeof rawExpires === "number" && Number.isFinite(rawExpires) && rawExpires > 0) {
|
|
317
|
+
const nowSecs = Math.floor(Date.now() / 1000);
|
|
318
|
+
if (rawExpires < nowSecs) {
|
|
319
|
+
expired = true;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
if (!expired && typeof entry.sessionId === "string" && entry.sessionId) {
|
|
323
|
+
sessionId = entry.sessionId;
|
|
324
|
+
} else if (expired) {
|
|
325
|
+
debugLog("[ctxce] Stored auth session appears expired; please run `ctxce auth login` again.");
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (!sessionId) {
|
|
331
|
+
sessionId = `ctxce-${Buffer.from(workspace).toString("hex").slice(0, 24)}`;
|
|
332
|
+
}
|
|
218
333
|
|
|
219
334
|
// Best-effort: inform the indexer of default collection and session.
|
|
220
335
|
// If this fails we still proceed, falling back to per-call injection.
|
|
@@ -229,13 +344,81 @@ async function createBridgeServer(options) {
|
|
|
229
344
|
defaultsPayload.under = defaultUnder;
|
|
230
345
|
}
|
|
231
346
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
347
|
+
async function initializeRemoteClients(forceRecreate = false) {
|
|
348
|
+
if (!forceRecreate && indexerClient) {
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (forceRecreate) {
|
|
353
|
+
try {
|
|
354
|
+
debugLog("[ctxce] Reinitializing remote MCP clients after session error.");
|
|
355
|
+
} catch {
|
|
356
|
+
// ignore logging failures
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
let nextIndexerClient = null;
|
|
361
|
+
try {
|
|
362
|
+
const indexerTransport = new StreamableHTTPClientTransport(indexerUrl);
|
|
363
|
+
const client = new Client(
|
|
364
|
+
{
|
|
365
|
+
name: "ctx-context-engine-bridge-http-client",
|
|
366
|
+
version: "0.0.1",
|
|
367
|
+
},
|
|
368
|
+
{
|
|
369
|
+
capabilities: {
|
|
370
|
+
tools: {},
|
|
371
|
+
resources: {},
|
|
372
|
+
prompts: {},
|
|
373
|
+
},
|
|
374
|
+
},
|
|
375
|
+
);
|
|
376
|
+
await client.connect(indexerTransport);
|
|
377
|
+
nextIndexerClient = client;
|
|
378
|
+
} catch (err) {
|
|
379
|
+
debugLog("[ctxce] Failed to connect MCP HTTP client to indexer: " + String(err));
|
|
380
|
+
nextIndexerClient = null;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
let nextMemoryClient = null;
|
|
384
|
+
if (memoryUrl) {
|
|
385
|
+
try {
|
|
386
|
+
const memoryTransport = new StreamableHTTPClientTransport(memoryUrl);
|
|
387
|
+
const client = new Client(
|
|
388
|
+
{
|
|
389
|
+
name: "ctx-context-engine-bridge-memory-client",
|
|
390
|
+
version: "0.0.1",
|
|
391
|
+
},
|
|
392
|
+
{
|
|
393
|
+
capabilities: {
|
|
394
|
+
tools: {},
|
|
395
|
+
resources: {},
|
|
396
|
+
prompts: {},
|
|
397
|
+
},
|
|
398
|
+
},
|
|
399
|
+
);
|
|
400
|
+
await client.connect(memoryTransport);
|
|
401
|
+
debugLog(`[ctxce] Connected memory MCP client: ${memoryUrl}`);
|
|
402
|
+
nextMemoryClient = client;
|
|
403
|
+
} catch (err) {
|
|
404
|
+
debugLog("[ctxce] Failed to connect memory MCP client: " + String(err));
|
|
405
|
+
nextMemoryClient = null;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
indexerClient = nextIndexerClient;
|
|
410
|
+
memoryClient = nextMemoryClient;
|
|
411
|
+
|
|
412
|
+
if (Object.keys(defaultsPayload).length > 1 && indexerClient) {
|
|
413
|
+
await sendSessionDefaults(indexerClient, defaultsPayload, "indexer");
|
|
414
|
+
if (memoryClient) {
|
|
415
|
+
await sendSessionDefaults(memoryClient, defaultsPayload, "memory");
|
|
416
|
+
}
|
|
236
417
|
}
|
|
237
418
|
}
|
|
238
419
|
|
|
420
|
+
await initializeRemoteClients(false);
|
|
421
|
+
|
|
239
422
|
const server = new Server( // TODO: marked as depreciated
|
|
240
423
|
{
|
|
241
424
|
name: "ctx-context-engine-bridge",
|
|
@@ -253,6 +436,10 @@ async function createBridgeServer(options) {
|
|
|
253
436
|
let remote;
|
|
254
437
|
try {
|
|
255
438
|
debugLog("[ctxce] tools/list: fetching tools from indexer");
|
|
439
|
+
await initializeRemoteClients(false);
|
|
440
|
+
if (!indexerClient) {
|
|
441
|
+
throw new Error("Indexer MCP client not initialized");
|
|
442
|
+
}
|
|
256
443
|
remote = await withTimeout(
|
|
257
444
|
indexerClient.listTools(),
|
|
258
445
|
10000,
|
|
@@ -311,21 +498,60 @@ async function createBridgeServer(options) {
|
|
|
311
498
|
return indexerResult;
|
|
312
499
|
}
|
|
313
500
|
|
|
314
|
-
|
|
315
|
-
if (!targetClient) {
|
|
316
|
-
throw new Error(`Tool ${name} not available on any configured MCP server`);
|
|
317
|
-
}
|
|
501
|
+
await initializeRemoteClients(false);
|
|
318
502
|
|
|
319
503
|
const timeoutMs = getBridgeToolTimeoutMs();
|
|
320
|
-
const
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
504
|
+
const maxAttempts = getBridgeRetryAttempts();
|
|
505
|
+
const retryDelayMs = getBridgeRetryDelayMs();
|
|
506
|
+
let sessionRetried = false;
|
|
507
|
+
let lastError;
|
|
508
|
+
|
|
509
|
+
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
|
510
|
+
if (attempt > 0 && retryDelayMs > 0) {
|
|
511
|
+
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const targetClient = selectClientForTool(name, indexerClient, memoryClient);
|
|
515
|
+
if (!targetClient) {
|
|
516
|
+
throw new Error(`Tool ${name} not available on any configured MCP server`);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
try {
|
|
520
|
+
const result = await targetClient.callTool(
|
|
521
|
+
{
|
|
522
|
+
name,
|
|
523
|
+
arguments: args,
|
|
524
|
+
},
|
|
525
|
+
undefined,
|
|
526
|
+
{ timeout: timeoutMs },
|
|
527
|
+
);
|
|
528
|
+
return result;
|
|
529
|
+
} catch (err) {
|
|
530
|
+
lastError = err;
|
|
531
|
+
|
|
532
|
+
if (isSessionError(err) && !sessionRetried) {
|
|
533
|
+
debugLog(
|
|
534
|
+
"[ctxce] tools/call: detected remote MCP session error; reinitializing clients and retrying once: " +
|
|
535
|
+
String(err),
|
|
536
|
+
);
|
|
537
|
+
await initializeRemoteClients(true);
|
|
538
|
+
sessionRetried = true;
|
|
539
|
+
continue;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
if (!isTransientToolError(err) || attempt === maxAttempts - 1) {
|
|
543
|
+
throw err;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
debugLog(
|
|
547
|
+
`[ctxce] tools/call: transient error (attempt ${attempt + 1}/${maxAttempts}), retrying: ` +
|
|
548
|
+
String(err),
|
|
549
|
+
);
|
|
550
|
+
// Loop will retry
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
throw lastError || new Error("Unknown MCP tools/call error");
|
|
329
555
|
});
|
|
330
556
|
|
|
331
557
|
return server;
|