@gemus/mcp-proxy 0.1.8 → 0.1.10
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 +69 -15
- package/package.json +5 -3
- package/src/failFast.mjs +23 -10
- package/src/proxy.mjs +29 -404
- package/src/proxyMessages.mjs +38 -0
- package/src/relay.mjs +440 -0
- package/src/setup/cli.mjs +130 -0
- package/src/setup/codex.mjs +110 -0
- package/src/setup/config.mjs +530 -0
- package/src/setup/configFile.mjs +155 -0
- package/src/setup/environment.mjs +79 -0
- package/src/setup/secret.mjs +91 -0
- package/src/startup.mjs +16 -0
- package/src/upstreamHttp.mjs +69 -0
- package/src/__tests__/backfill.test.ts +0 -394
- package/src/__tests__/failFast.test.ts +0 -254
- package/src/__tests__/fixtures/proxy-runtime-loader.mjs +0 -136
- package/src/__tests__/mcpHeaders.test.ts +0 -32
- package/src/__tests__/route.integration.test.ts +0 -68
- package/src/__tests__/startup.test.ts +0 -558
package/README.md
CHANGED
|
@@ -9,11 +9,49 @@ and `docs/architecture/agent architecture/codex-desktop-companion.md`.
|
|
|
9
9
|
|
|
10
10
|
## Status: implemented
|
|
11
11
|
|
|
12
|
-
`src/proxy.mjs` is the
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
`
|
|
16
|
-
|
|
12
|
+
`src/proxy.mjs` is the executable router: `setup` loads the isolated installer, while no arguments
|
|
13
|
+
load `src/relay.mjs`. The relay is the production passthrough, execute-tap, and deterministic
|
|
14
|
+
image-backfill companion. A trusted Stop hook triggers `generated_images`/rollout discovery;
|
|
15
|
+
`ExecuteClaimTracker` then delivers an unambiguous image to its planned node or conservatively
|
|
16
|
+
creates an orphan `image-upload`. MCP initialization advertises the shared Codex capability
|
|
17
|
+
contract used by the server blueprint policy.
|
|
18
|
+
|
|
19
|
+
## Upstream connection reliability (#2328)
|
|
20
|
+
|
|
21
|
+
The proxy uses one owned Undici `Agent` for all upstream MCP traffic. By default its TCP
|
|
22
|
+
address-family racing policy sets `autoSelectFamily: true` with a
|
|
23
|
+
`PROXY_CONNECT_ATTEMPT_TIMEOUT_MS=1000` per-address-family attempt window. An operator may set a
|
|
24
|
+
positive numeric override; invalid, empty, and non-positive values fall back to `1000`, while
|
|
25
|
+
values below `10` ms clamp to `10` ms. The transport's request method, body, and auth headers are
|
|
26
|
+
preserved; the proxy adds only that owned dispatcher.
|
|
27
|
+
|
|
28
|
+
This is a connection-attempt policy, not an MCP retry policy. For a post-start request, a rejected
|
|
29
|
+
`POST` before response headers produces one request only: the affected JSON-RPC id receives `-32000`
|
|
30
|
+
with `Gemus upstream connection failed before a response was received`. The raw network reason is
|
|
31
|
+
never returned to Codex. During `initialize`, the same classification writes one sanitized fatal
|
|
32
|
+
diagnostic and exits non-zero. Once an SSE response has started, a body that ends before its result
|
|
33
|
+
remains the distinct #2068 failure:
|
|
34
|
+
`Gemus upstream stream disconnected before the tool result was delivered`.
|
|
35
|
+
|
|
36
|
+
On graceful or fatal shutdown, the proxy bounds session `DELETE`, then transport close, before it
|
|
37
|
+
closes the owned Agent. Each stage has a 2-second bound; an Agent that does not close in its bound
|
|
38
|
+
has its retained per-origin dispatchers force-destroyed before the Agent is destroyed. Repeated
|
|
39
|
+
shutdown signals share the same cleanup. See the [transport reliability
|
|
40
|
+
runbook](../../docs/operations/mcp-transport-reliability.md) for the network boundaries and the
|
|
41
|
+
[Companion architecture](../../docs/architecture/agent%20architecture/codex-desktop-companion.md)
|
|
42
|
+
for the request path.
|
|
43
|
+
|
|
44
|
+
## Release gate (#2330)
|
|
45
|
+
|
|
46
|
+
`@gemus/mcp-proxy@0.1.9` was published and verified on 2026-07-28. Release Issue
|
|
47
|
+
[#2330](https://github.com/Gemus-AI/Gemus/issues/2330) rolls the plugin to exact `0.1.9`, forwards
|
|
48
|
+
`PROXY_CONNECT_ATTEMPT_TIMEOUT_MS`, and bumps the plugin contract to 0.1.12. After that change
|
|
49
|
+
merges, the remaining gate is to verify the public mirror, refresh the installed Codex plugin
|
|
50
|
+
cache, and run repeated fresh-process ESA and, when available, isolated direct-domain smoke tests.
|
|
51
|
+
|
|
52
|
+
That public `0.1.9` package is immutable and transport-only: it does not contain the one-command
|
|
53
|
+
setup added by #2324. The setup release therefore advances to `@gemus/mcp-proxy@0.1.10`, with
|
|
54
|
+
plugin contract `0.1.13`; the historical #2328/#2330 release remains `0.1.9`/`0.1.12`.
|
|
17
55
|
|
|
18
56
|
## What was validated end-to-end (2026-07-06, codex-cli 0.142.2 → local dev `/api/mcp`)
|
|
19
57
|
|
|
@@ -34,24 +72,40 @@ server blueprint policy.
|
|
|
34
72
|
|
|
35
73
|
## Companion setup and migration (Codex desktop)
|
|
36
74
|
|
|
37
|
-
The plugin owns the non-secret process contract: exact `@gemus/mcp-proxy@0.1.
|
|
75
|
+
The plugin owns the non-secret process contract: exact `@gemus/mcp-proxy@0.1.10`,
|
|
38
76
|
`startup_timeout_sec = 60`, `tool_timeout_sec = 300`, and a default-disabled rollout. The user
|
|
39
|
-
environment owns `GEMUS_KEY
|
|
40
|
-
never contain their
|
|
77
|
+
environment owns `GEMUS_KEY`, optional `GEMUS_URL`, and optional
|
|
78
|
+
`PROXY_CONNECT_ATTEMPT_TIMEOUT_MS`; the public plugin and generated commands never contain their
|
|
79
|
+
values.
|
|
80
|
+
|
|
81
|
+
For the production Gemus service, use this Windows/macOS setup command; it is safe to rerun:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
npx -y @gemus/mcp-proxy@0.1.10 setup
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
It securely prompts for the key, reconciles the marketplace/plugin, migrates legacy Gemus config,
|
|
88
|
+
enables the Companion, and preserves a backup when it changes `config.toml`. Fully quit and restart
|
|
89
|
+
Codex when it finishes, then open a new task and run `/hooks` to trust the Gemus Stop hook.
|
|
90
|
+
|
|
91
|
+
The public setup command, shell history, and setup diagnostics remain key-free. On macOS, the
|
|
92
|
+
short-lived `launchctl setenv` child necessarily receives the key in its argv.
|
|
41
93
|
|
|
42
94
|
Environment ownership and lifetime:
|
|
43
95
|
|
|
44
|
-
- **Windows / PowerShell:** persist the key in the Windows user environment
|
|
45
|
-
|
|
96
|
+
- **Windows / PowerShell:** persist the key in the Windows user environment scope. Fully quit and
|
|
97
|
+
restart Codex so it receives the updated environment.
|
|
46
98
|
- **macOS:** If changing login context, sign out and back in first; rerun the `launchctl setenv`
|
|
47
|
-
setup in the new login session;
|
|
99
|
+
setup in the new login session; the value lasts for the current login session only. Then fully quit
|
|
100
|
+
and restart Codex and open a new task.
|
|
48
101
|
- **Linux:** export the key and launch Codex from the same terminal; no universal desktop-session
|
|
49
102
|
inheritance is assumed.
|
|
50
103
|
|
|
51
|
-
Companion and direct modes are mutually exclusive. The
|
|
52
|
-
is
|
|
104
|
+
Companion and direct modes are mutually exclusive. The following advanced manual fallback is for
|
|
105
|
+
Linux, self-hosted `GEMUS_URL`, or troubleshooting when the production one-command setup is not
|
|
106
|
+
applicable:
|
|
53
107
|
|
|
54
|
-
1. Set `GEMUS_KEY` in the user environment.
|
|
108
|
+
1. Set `GEMUS_KEY` in the user environment (and optional `GEMUS_URL` for self-hosted/development).
|
|
55
109
|
2. Remove any legacy global server (`codex mcp remove gemus`; not-found is harmless).
|
|
56
110
|
3. Install with `codex plugin marketplace add Gemus-AI/gemus-codex-plugin`, then
|
|
57
111
|
`codex plugin add gemus@gemus`. Existing users refresh the marketplace snapshot and replace the
|
|
@@ -69,7 +123,7 @@ is:
|
|
|
69
123
|
enabled = true
|
|
70
124
|
```
|
|
71
125
|
|
|
72
|
-
5.
|
|
126
|
+
5. Fully quit and restart Codex, open a new task, then trust the Gemus Stop hook with `/hooks`.
|
|
73
127
|
|
|
74
128
|
Intentional direct HTTP/OAuth users keep their global direct server and leave the plugin Companion
|
|
75
129
|
disabled:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gemus/mcp-proxy",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.10",
|
|
4
4
|
"description": "Local stdio<->HTTP MCP proxy for Codex desktop: transparent passthrough + execute tap + imagegen backfill (Issue #1751, P1).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -25,13 +25,15 @@
|
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
27
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
28
|
+
"undici": "^6.28.0",
|
|
28
29
|
"@gemus/codex-backfill-core": "0.1.3"
|
|
29
30
|
},
|
|
30
31
|
"engines": {
|
|
31
|
-
"node": ">=18"
|
|
32
|
+
"node": ">=18.17"
|
|
32
33
|
},
|
|
33
34
|
"files": [
|
|
34
|
-
"src
|
|
35
|
+
"src/*.mjs",
|
|
36
|
+
"src/setup/*.mjs",
|
|
35
37
|
"README.md"
|
|
36
38
|
],
|
|
37
39
|
"license": "MIT",
|
package/src/failFast.mjs
CHANGED
|
@@ -93,11 +93,24 @@ export function createSseIdScanner() {
|
|
|
93
93
|
}
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
/** Extract an allowlisted network error code without exposing the raw error text. */
|
|
97
|
+
function failureCode(error) {
|
|
98
|
+
let current = error
|
|
99
|
+
for (let depth = 0; depth < 8 && current && typeof current === 'object'; depth++) {
|
|
100
|
+
if (typeof current.code === 'string') {
|
|
101
|
+
const code = current.code.toUpperCase()
|
|
102
|
+
if (/^[A-Z0-9_]{1,64}$/.test(code)) return code
|
|
103
|
+
}
|
|
104
|
+
current = current.cause
|
|
105
|
+
}
|
|
106
|
+
return 'UNKNOWN'
|
|
107
|
+
}
|
|
108
|
+
|
|
96
109
|
/**
|
|
97
110
|
* Build the fail-fast fetch wrapper + settle/dispose controls.
|
|
98
111
|
*
|
|
99
112
|
* @param {object} opts
|
|
100
|
-
* @param {(id:any,
|
|
113
|
+
* @param {(id:any, failure:{kind:string, code?:string, status?:number})=>void} opts.onLost called once per lost id (must be idempotent-safe on caller side)
|
|
101
114
|
* @param {number} [opts.idleTimeoutMs] IDLE backstop reset on every upstream byte (incl. server
|
|
102
115
|
* heartbeats every ~12s), so it trips ONLY on a genuinely silent/zombie connection — never on a
|
|
103
116
|
* healthy-but-slow tool. Must be < Codex 300s; default 120_000.
|
|
@@ -117,7 +130,7 @@ export function createFailFast(opts) {
|
|
|
117
130
|
const failedIds = new Set()
|
|
118
131
|
|
|
119
132
|
function armTimer(entry, id) {
|
|
120
|
-
entry.timer = setTimeout(() => lose(id, '
|
|
133
|
+
entry.timer = setTimeout(() => lose(id, { kind: 'idle' }), idleTimeoutMs)
|
|
121
134
|
if (typeof entry.timer.unref === 'function') entry.timer.unref()
|
|
122
135
|
}
|
|
123
136
|
|
|
@@ -150,15 +163,15 @@ export function createFailFast(opts) {
|
|
|
150
163
|
failedIds.delete(String(id))
|
|
151
164
|
}
|
|
152
165
|
|
|
153
|
-
function lose(id,
|
|
166
|
+
function lose(id, failure) {
|
|
154
167
|
const key = String(id)
|
|
155
168
|
if (!inFlight.has(key)) return // already settled or already lost
|
|
156
169
|
settle(id)
|
|
157
170
|
failedIds.add(key)
|
|
158
171
|
// Bound the set: disconnect-case entries never see a late result to forget(), so cap FIFO.
|
|
159
172
|
if (failedIds.size > FAILED_IDS_CAP) failedIds.delete(failedIds.values().next().value)
|
|
160
|
-
log('FAILFAST', { id,
|
|
161
|
-
onLost(id,
|
|
173
|
+
log('FAILFAST', { id, ...failure })
|
|
174
|
+
onLost(id, failure)
|
|
162
175
|
}
|
|
163
176
|
|
|
164
177
|
function observeAndForward(body, codexIds) {
|
|
@@ -168,7 +181,7 @@ export function createFailFast(opts) {
|
|
|
168
181
|
const onEnd = () => {
|
|
169
182
|
if (ended) return
|
|
170
183
|
ended = true
|
|
171
|
-
for (const id of codexIds) lose(id, '
|
|
184
|
+
for (const id of codexIds) lose(id, { kind: 'stream' })
|
|
172
185
|
}
|
|
173
186
|
return new ReadableStream({
|
|
174
187
|
async pull(controller) {
|
|
@@ -218,7 +231,7 @@ export function createFailFast(opts) {
|
|
|
218
231
|
codexIds = parseRequestIds(init).filter(isCodexOriginated)
|
|
219
232
|
for (const id of codexIds) register(id)
|
|
220
233
|
} catch (e) {
|
|
221
|
-
log('FAILFAST-WRAP-ERR',
|
|
234
|
+
log('FAILFAST-WRAP-ERR', { code: failureCode(e) })
|
|
222
235
|
codexIds = []
|
|
223
236
|
}
|
|
224
237
|
|
|
@@ -229,7 +242,7 @@ export function createFailFast(opts) {
|
|
|
229
242
|
res = await realFetch(url, init)
|
|
230
243
|
} catch (e) {
|
|
231
244
|
// Connection failed before any response (DNS / TLS / RST) — fail fast, don't wait 270s.
|
|
232
|
-
for (const id of codexIds) lose(id,
|
|
245
|
+
for (const id of codexIds) lose(id, { kind: 'connection', code: failureCode(e) })
|
|
233
246
|
throw e // preserve the SDK's existing onerror/logging path (no re-fetch)
|
|
234
247
|
}
|
|
235
248
|
|
|
@@ -241,7 +254,7 @@ export function createFailFast(opts) {
|
|
|
241
254
|
if (!res.ok) {
|
|
242
255
|
// Non-SSE HTTP error (404 session-gone / 403 / 429 / …). send() throws before onmessage,
|
|
243
256
|
// and that rejection is swallowed by upstream.send().catch — so nothing ever settles the id.
|
|
244
|
-
for (const id of codexIds) lose(id,
|
|
257
|
+
for (const id of codexIds) lose(id, { kind: 'http', status: res.status })
|
|
245
258
|
return res
|
|
246
259
|
}
|
|
247
260
|
const ct = res.headers.get('content-type') || ''
|
|
@@ -252,7 +265,7 @@ export function createFailFast(opts) {
|
|
|
252
265
|
headers: res.headers,
|
|
253
266
|
})
|
|
254
267
|
} catch (e) {
|
|
255
|
-
log('FAILFAST-WRAP-ERR',
|
|
268
|
+
log('FAILFAST-WRAP-ERR', { code: failureCode(e) })
|
|
256
269
|
return res
|
|
257
270
|
}
|
|
258
271
|
}
|