@gemus/mcp-proxy 0.1.7 → 0.1.9
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 +84 -19
- package/package.json +3 -2
- package/src/__tests__/failFast.test.ts +108 -6
- package/src/__tests__/fixtures/proxy-runtime-loader.mjs +216 -0
- package/src/__tests__/proxyMessages.test.ts +81 -0
- package/src/__tests__/startup.test.ts +752 -0
- package/src/__tests__/upstreamHttp.real.test.ts +37 -0
- package/src/__tests__/upstreamHttp.test.ts +151 -0
- package/src/failFast.mjs +23 -10
- package/src/proxy.mjs +122 -31
- package/src/proxyMessages.mjs +38 -0
- package/src/startup.mjs +110 -0
- package/src/upstreamHttp.mjs +69 -0
package/README.md
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
# @gemus/mcp-proxy (Issues #1751, #1756, #1917)
|
|
2
2
|
|
|
3
3
|
Local stdio↔HTTP MCP proxy that gives **Codex desktop (direct-connect / Mode B)** users canvas
|
|
4
|
-
image write-back.
|
|
5
|
-
|
|
6
|
-
(Codex does no OAuth for stdio servers). Full design:
|
|
4
|
+
image write-back. The Codex plugin declares it as a **default-disabled, command-based** MCP server
|
|
5
|
+
so Codex can spawn it locally after explicit enablement; the proxy owns upstream auth to the remote
|
|
6
|
+
Gemus `/api/mcp` (Codex does no OAuth for stdio servers). Full design:
|
|
7
|
+
`docs/plans/1751-codex-desktop-companion.md`
|
|
7
8
|
and `docs/architecture/agent architecture/codex-desktop-companion.md`.
|
|
8
9
|
|
|
9
10
|
## Status: implemented
|
|
@@ -14,6 +15,40 @@ then delivers an unambiguous image to its planned node or conservatively creates
|
|
|
14
15
|
`image-upload`. MCP initialization advertises the shared Codex capability contract used by the
|
|
15
16
|
server blueprint policy.
|
|
16
17
|
|
|
18
|
+
## Upstream connection reliability (#2328)
|
|
19
|
+
|
|
20
|
+
The proxy uses one owned Undici `Agent` for all upstream MCP traffic. By default its TCP
|
|
21
|
+
address-family racing policy sets `autoSelectFamily: true` with a
|
|
22
|
+
`PROXY_CONNECT_ATTEMPT_TIMEOUT_MS=1000` per-address-family attempt window. An operator may set a
|
|
23
|
+
positive numeric override; invalid, empty, and non-positive values fall back to `1000`, while
|
|
24
|
+
values below `10` ms clamp to `10` ms. The transport's request method, body, and auth headers are
|
|
25
|
+
preserved; the proxy adds only that owned dispatcher.
|
|
26
|
+
|
|
27
|
+
This is a connection-attempt policy, not an MCP retry policy. For a post-start request, a rejected
|
|
28
|
+
`POST` before response headers produces one request only: the affected JSON-RPC id receives `-32000`
|
|
29
|
+
with `Gemus upstream connection failed before a response was received`. The raw network reason is
|
|
30
|
+
never returned to Codex. During `initialize`, the same classification writes one sanitized fatal
|
|
31
|
+
diagnostic and exits non-zero. Once an SSE response has started, a body that ends before its result
|
|
32
|
+
remains the distinct #2068 failure:
|
|
33
|
+
`Gemus upstream stream disconnected before the tool result was delivered`.
|
|
34
|
+
|
|
35
|
+
On graceful or fatal shutdown, the proxy bounds session `DELETE`, then transport close, before it
|
|
36
|
+
closes the owned Agent. Each stage has a 2-second bound; an Agent that does not close in its bound
|
|
37
|
+
has its retained per-origin dispatchers force-destroyed before the Agent is destroyed. Repeated
|
|
38
|
+
shutdown signals share the same cleanup. See the [transport reliability
|
|
39
|
+
runbook](../../docs/operations/mcp-transport-reliability.md) for the network boundaries and the
|
|
40
|
+
[Companion architecture](../../docs/architecture/agent%20architecture/codex-desktop-companion.md)
|
|
41
|
+
for the request path.
|
|
42
|
+
|
|
43
|
+
## Release gate (#2330)
|
|
44
|
+
|
|
45
|
+
This package is prepared as `@gemus/mcp-proxy@0.1.9`; it is not published by this change. Release
|
|
46
|
+
Issue [#2330](https://github.com/Gemus-AI/Gemus/issues/2330) owns the post-merge gate: publish and
|
|
47
|
+
verify the npm package, then in a follow-up change pin the plugin to exact `0.1.9`, forward
|
|
48
|
+
`PROXY_CONNECT_ATTEMPT_TIMEOUT_MS`, bump and sync the plugin, refresh the installed Codex plugin
|
|
49
|
+
cache, and run fresh ESA and direct-domain smoke tests. Until that gate completes, the plugin
|
|
50
|
+
contract stays byte-for-byte at exact `@gemus/mcp-proxy@0.1.8`.
|
|
51
|
+
|
|
17
52
|
## What was validated end-to-end (2026-07-06, codex-cli 0.142.2 → local dev `/api/mcp`)
|
|
18
53
|
|
|
19
54
|
- **Transparent passthrough** — initialize + tools/list relay all **12 gemus tools** (incl.
|
|
@@ -27,24 +62,55 @@ server blueprint policy.
|
|
|
27
62
|
- **(c) turn-end + retrieval** — `Stop` hook fires post-flush carrying `transcript_path` → exact
|
|
28
63
|
rollout; imagegen writes `generated_images/<session>/<sanitized-call-id>.png` (historically `ig_*`,
|
|
29
64
|
currently `call_*`) + rollout `saved_path`+base64.
|
|
30
|
-
- **Session cap** —
|
|
31
|
-
|
|
65
|
+
- **Session cap** — the key-only session cap defaults to 10 concurrent sessions per user
|
|
66
|
+
(`MCP_KEY_ONLY_SESSION_CAP`); the proxy MUST `DELETE` its upstream session on close (implemented
|
|
67
|
+
in `cleanup()`) to avoid leaking slots.
|
|
32
68
|
|
|
33
|
-
##
|
|
69
|
+
## Companion setup and migration (Codex desktop)
|
|
34
70
|
|
|
35
|
-
The
|
|
36
|
-
|
|
37
|
-
|
|
71
|
+
The plugin owns the non-secret process contract: exact `@gemus/mcp-proxy@0.1.8`,
|
|
72
|
+
`startup_timeout_sec = 60`, `tool_timeout_sec = 300`, and a default-disabled rollout. The user
|
|
73
|
+
environment owns `GEMUS_KEY` and optional `GEMUS_URL`; the public plugin and generated commands
|
|
74
|
+
never contain their values.
|
|
38
75
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
76
|
+
Environment ownership and lifetime:
|
|
77
|
+
|
|
78
|
+
- **Windows / PowerShell:** persist the key in the Windows user environment and refresh the current
|
|
79
|
+
process.
|
|
80
|
+
- **macOS:** If changing login context, sign out and back in first; rerun the `launchctl setenv`
|
|
81
|
+
setup in the new login session; then fully quit and restart Codex and open a new task.
|
|
82
|
+
- **Linux:** export the key and launch Codex from the same terminal; no universal desktop-session
|
|
83
|
+
inheritance is assumed.
|
|
84
|
+
|
|
85
|
+
Companion and direct modes are mutually exclusive. The canonical new-user and legacy migration flow
|
|
86
|
+
is:
|
|
87
|
+
|
|
88
|
+
1. Set `GEMUS_KEY` in the user environment.
|
|
89
|
+
2. Remove any legacy global server (`codex mcp remove gemus`; not-found is harmless).
|
|
90
|
+
3. Install with `codex plugin marketplace add Gemus-AI/gemus-codex-plugin`, then
|
|
91
|
+
`codex plugin add gemus@gemus`. Existing users refresh the marketplace snapshot and replace the
|
|
92
|
+
installed cache:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
codex plugin marketplace upgrade gemus
|
|
96
|
+
codex plugin remove gemus@gemus
|
|
97
|
+
codex plugin add gemus@gemus
|
|
98
|
+
```
|
|
99
|
+
4. Explicitly enable the plugin-scoped server:
|
|
100
|
+
|
|
101
|
+
```toml
|
|
102
|
+
[plugins."gemus@gemus".mcp_servers.gemus]
|
|
103
|
+
enabled = true
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
5. Restart Codex and open a new task, then use `/hooks` to trust the Stop hook separately.
|
|
107
|
+
|
|
108
|
+
Intentional direct HTTP/OAuth users keep their global direct server and leave the plugin Companion
|
|
109
|
+
disabled:
|
|
110
|
+
|
|
111
|
+
```toml
|
|
112
|
+
[plugins."gemus@gemus".mcp_servers.gemus]
|
|
113
|
+
enabled = false
|
|
48
114
|
```
|
|
49
115
|
|
|
50
116
|
## Local dev
|
|
@@ -52,7 +118,6 @@ codex plugin add gemus@<marketplace>
|
|
|
52
118
|
```bash
|
|
53
119
|
pnpm --filter @gemus/codex-backfill-core build # proxy imports the built core at runtime
|
|
54
120
|
GEMUS_KEY=mak_... GEMUS_URL=http://localhost:3000/api/mcp PROXY_LOG=/tmp/proxy.log node src/proxy.mjs
|
|
55
|
-
# or register against dev: codex mcp add gemus --env GEMUS_KEY=mak_... GEMUS_URL=http://localhost:3000/api/mcp -- node <abs>/src/proxy.mjs
|
|
56
121
|
```
|
|
57
122
|
|
|
58
123
|
Env: `GEMUS_KEY` (required), `GEMUS_URL` (default `https://gemus.ai/api/mcp`), `PROXY_LOG` (optional
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gemus/mcp-proxy",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
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,10 +25,11 @@
|
|
|
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
35
|
"src/",
|
|
@@ -122,7 +122,7 @@ describe('createFailFast.fetch', () => {
|
|
|
122
122
|
await drain(res)
|
|
123
123
|
|
|
124
124
|
expect(onLost).toHaveBeenCalledTimes(1)
|
|
125
|
-
expect(onLost
|
|
125
|
+
expect(onLost).toHaveBeenCalledWith(42, { kind: 'stream' })
|
|
126
126
|
expect(ff.failedIds.has('42')).toBe(true)
|
|
127
127
|
})
|
|
128
128
|
|
|
@@ -140,21 +140,90 @@ describe('createFailFast.fetch', () => {
|
|
|
140
140
|
|
|
141
141
|
it('fails fast immediately when realFetch throws before any response', async () => {
|
|
142
142
|
const onLost = vi.fn()
|
|
143
|
-
const realFetch = vi.fn(async () => {
|
|
143
|
+
const realFetch = vi.fn(async () => {
|
|
144
|
+
throw new Error('socket reset', { cause: Object.assign(new Error('inner socket failure'), { code: 'ECONNRESET' }) })
|
|
145
|
+
})
|
|
144
146
|
const ff = createFailFast({ onLost, realFetch })
|
|
145
147
|
|
|
146
|
-
await expect(ff.fetch('u', post(7))).rejects.toThrow('
|
|
147
|
-
expect(onLost).toHaveBeenCalledWith(7,
|
|
148
|
+
await expect(ff.fetch('u', post(7))).rejects.toThrow('socket reset')
|
|
149
|
+
expect(onLost).toHaveBeenCalledWith(7, { kind: 'connection', code: 'ECONNRESET' })
|
|
148
150
|
expect(realFetch).toHaveBeenCalledTimes(1) // must NOT retry — a 2nd fetch could execute the tool
|
|
149
151
|
})
|
|
150
152
|
|
|
153
|
+
it('allowlists a nested connection code without exposing hostile raw diagnostics or retrying', async () => {
|
|
154
|
+
const hostile =
|
|
155
|
+
'Authorization: Bearer arbitrary-secret at '
|
|
156
|
+
+ 'https://alice:password@example.test/api/mcp?token=query-secret '
|
|
157
|
+
+ 'via 203.0.113.1,2001:db8::1'
|
|
158
|
+
const onLost = vi.fn()
|
|
159
|
+
const log = vi.fn()
|
|
160
|
+
const rejection = new Error(`outer ${hostile}`, {
|
|
161
|
+
cause: new Error(`middle ${hostile}`, {
|
|
162
|
+
cause: Object.assign(new Error(`inner ${hostile}`), {
|
|
163
|
+
code: 'UND_ERR_CONNECT_TIMEOUT',
|
|
164
|
+
}),
|
|
165
|
+
}),
|
|
166
|
+
})
|
|
167
|
+
const realFetch = vi.fn(async () => { throw rejection })
|
|
168
|
+
const ff = createFailFast({ onLost, log, realFetch })
|
|
169
|
+
|
|
170
|
+
await expect(ff.fetch('u', post(71))).rejects.toBe(rejection)
|
|
171
|
+
|
|
172
|
+
expect(realFetch).toHaveBeenCalledTimes(1)
|
|
173
|
+
expect(onLost).toHaveBeenCalledTimes(1)
|
|
174
|
+
expect(onLost).toHaveBeenCalledWith(71, {
|
|
175
|
+
kind: 'connection',
|
|
176
|
+
code: 'UND_ERR_CONNECT_TIMEOUT',
|
|
177
|
+
})
|
|
178
|
+
const diagnostics = JSON.stringify(log.mock.calls)
|
|
179
|
+
expect(diagnostics).not.toMatch(
|
|
180
|
+
/arbitrary-secret|alice|password|query-secret|203\.0\.113\.1|2001:db8/,
|
|
181
|
+
)
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('classifies an established SSE reader error before its result as a stream failure', async () => {
|
|
185
|
+
const readerError = new Error('established stream failed')
|
|
186
|
+
let pulls = 0
|
|
187
|
+
const body = new ReadableStream<Uint8Array>({
|
|
188
|
+
pull(controller) {
|
|
189
|
+
if (pulls++ === 0) {
|
|
190
|
+
controller.enqueue(enc.encode(': established\n\n'))
|
|
191
|
+
} else {
|
|
192
|
+
controller.error(readerError)
|
|
193
|
+
}
|
|
194
|
+
},
|
|
195
|
+
})
|
|
196
|
+
const onLost = vi.fn()
|
|
197
|
+
const realFetch = vi.fn(async () => sseRes(body))
|
|
198
|
+
const ff = createFailFast({ onLost, realFetch })
|
|
199
|
+
|
|
200
|
+
const res = await ff.fetch('u', post(72))
|
|
201
|
+
await expect(drain(res)).rejects.toBe(readerError)
|
|
202
|
+
|
|
203
|
+
expect(onLost).toHaveBeenCalledTimes(1)
|
|
204
|
+
expect(onLost).toHaveBeenCalledWith(72, { kind: 'stream' })
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it('classifies downstream cancellation before its result as a stream failure', async () => {
|
|
208
|
+
const onLost = vi.fn()
|
|
209
|
+
const realFetch = vi.fn(async () => sseRes(neverBody()))
|
|
210
|
+
const ff = createFailFast({ onLost, realFetch })
|
|
211
|
+
const res = await ff.fetch('u', post(73))
|
|
212
|
+
const reader = res.body!.getReader()
|
|
213
|
+
|
|
214
|
+
await reader.cancel('downstream closed')
|
|
215
|
+
|
|
216
|
+
expect(onLost).toHaveBeenCalledTimes(1)
|
|
217
|
+
expect(onLost).toHaveBeenCalledWith(73, { kind: 'stream' })
|
|
218
|
+
})
|
|
219
|
+
|
|
151
220
|
it('fails fast on a non-ok HTTP response', async () => {
|
|
152
221
|
const onLost = vi.fn()
|
|
153
222
|
const realFetch = vi.fn(async () => sseRes(null, { status: 404, contentType: 'application/json' }))
|
|
154
223
|
const ff = createFailFast({ onLost, realFetch })
|
|
155
224
|
|
|
156
225
|
await ff.fetch('u', post(7))
|
|
157
|
-
expect(onLost).toHaveBeenCalledWith(7, '
|
|
226
|
+
expect(onLost).toHaveBeenCalledWith(7, { kind: 'http', status: 404 })
|
|
158
227
|
})
|
|
159
228
|
|
|
160
229
|
it('passes GET and DELETE through untouched (no body → no monitoring, no onLost)', async () => {
|
|
@@ -208,6 +277,39 @@ describe('createFailFast.fetch', () => {
|
|
|
208
277
|
expect(onLost.mock.calls[0][0]).toBe(1)
|
|
209
278
|
})
|
|
210
279
|
|
|
280
|
+
it('does not settle a healthy concurrent request when another connection rejects before response', async () => {
|
|
281
|
+
let healthyController!: ReadableStreamDefaultController<Uint8Array>
|
|
282
|
+
const healthyBody = new ReadableStream<Uint8Array>({
|
|
283
|
+
start(controller) { healthyController = controller },
|
|
284
|
+
})
|
|
285
|
+
const connectionError = Object.assign(new Error('connection rejected'), {
|
|
286
|
+
code: 'ECONNREFUSED',
|
|
287
|
+
})
|
|
288
|
+
const onLost = vi.fn()
|
|
289
|
+
const realFetch = vi.fn(async (_url: unknown, init: { body?: unknown }) => {
|
|
290
|
+
const [{ id }] = [JSON.parse(String(init.body))]
|
|
291
|
+
if (id === 74) throw connectionError
|
|
292
|
+
return sseRes(healthyBody)
|
|
293
|
+
})
|
|
294
|
+
const ff = createFailFast({ onLost, realFetch })
|
|
295
|
+
|
|
296
|
+
const rejecting = ff.fetch('u', post(74))
|
|
297
|
+
const healthy = ff.fetch('u', post(75))
|
|
298
|
+
await expect(rejecting).rejects.toBe(connectionError)
|
|
299
|
+
const healthyResponse = await healthy
|
|
300
|
+
|
|
301
|
+
expect(onLost).toHaveBeenCalledTimes(1)
|
|
302
|
+
expect(onLost).toHaveBeenCalledWith(74, {
|
|
303
|
+
kind: 'connection',
|
|
304
|
+
code: 'ECONNREFUSED',
|
|
305
|
+
})
|
|
306
|
+
healthyController.enqueue(enc.encode(resultFrame(75)))
|
|
307
|
+
healthyController.close()
|
|
308
|
+
await drain(healthyResponse)
|
|
309
|
+
expect(onLost).toHaveBeenCalledTimes(1)
|
|
310
|
+
expect(ff.failedIds.has('75')).toBe(false)
|
|
311
|
+
})
|
|
312
|
+
|
|
211
313
|
describe('idle safety timeout', () => {
|
|
212
314
|
beforeEach(() => vi.useFakeTimers())
|
|
213
315
|
afterEach(() => vi.useRealTimers())
|
|
@@ -220,7 +322,7 @@ describe('createFailFast.fetch', () => {
|
|
|
220
322
|
await ff.fetch('u', post(99)) // never emits → idle timer is never reset
|
|
221
323
|
expect(onLost).not.toHaveBeenCalled()
|
|
222
324
|
await vi.advanceTimersByTimeAsync(120_000)
|
|
223
|
-
expect(onLost).toHaveBeenCalledWith(99,
|
|
325
|
+
expect(onLost).toHaveBeenCalledWith(99, { kind: 'idle' })
|
|
224
326
|
ff.dispose()
|
|
225
327
|
})
|
|
226
328
|
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
function moduleUrl(source) {
|
|
2
|
+
return `data:text/javascript,${encodeURIComponent(source)}`
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
const markerPrelude = `
|
|
6
|
+
import fs from 'node:fs'
|
|
7
|
+
function mark(name) {
|
|
8
|
+
const file = process.env.PROXY_TEST_CONSTRUCTION_LOG
|
|
9
|
+
if (file) fs.appendFileSync(file, name + '\\n')
|
|
10
|
+
}
|
|
11
|
+
`
|
|
12
|
+
|
|
13
|
+
const modules = new Map([
|
|
14
|
+
['@gemus/codex-backfill-core', `
|
|
15
|
+
export const CLIENT_CAPABILITIES = []
|
|
16
|
+
export const CLIENT_CAPABILITIES_HEADER = 'X-Gemus-Client-Capabilities'
|
|
17
|
+
export function serializeClientCapabilities(value) { return JSON.stringify(value) }
|
|
18
|
+
export function unwrapMcpContent(value) { return value }
|
|
19
|
+
`],
|
|
20
|
+
['@modelcontextprotocol/sdk/server/stdio.js', `
|
|
21
|
+
${markerPrelude}
|
|
22
|
+
export class StdioServerTransport {
|
|
23
|
+
constructor() { mark('stdio-transport') }
|
|
24
|
+
async start() {
|
|
25
|
+
if (process.env.PROXY_TEST_STDIO_START_FAILURE === '1') {
|
|
26
|
+
this.onclose?.()
|
|
27
|
+
throw new Error(
|
|
28
|
+
'stdio failed Authorization: Bearer '
|
|
29
|
+
+ process.env.GEMUS_KEY
|
|
30
|
+
+ ' at '
|
|
31
|
+
+ process.env.GEMUS_URL
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
if (process.env.PROXY_TEST_FORWARD_STDIN === '1') {
|
|
35
|
+
let input = ''
|
|
36
|
+
process.stdin.setEncoding('utf8')
|
|
37
|
+
process.stdin.on('data', (chunk) => {
|
|
38
|
+
input += chunk
|
|
39
|
+
for (;;) {
|
|
40
|
+
const newline = input.indexOf('\\n')
|
|
41
|
+
if (newline < 0) break
|
|
42
|
+
const line = input.slice(0, newline).trim()
|
|
43
|
+
input = input.slice(newline + 1)
|
|
44
|
+
if (line) this.onmessage?.(JSON.parse(line))
|
|
45
|
+
}
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
process.stdin.resume()
|
|
49
|
+
process.stdin.once('end', () => this.onclose?.())
|
|
50
|
+
if (process.env.PROXY_TEST_SIGNAL_AFTER_START === 'SIGTERM') {
|
|
51
|
+
setTimeout(() => {
|
|
52
|
+
if (process.platform === 'win32') {
|
|
53
|
+
process.emit('SIGTERM')
|
|
54
|
+
} else {
|
|
55
|
+
process.kill(process.pid, 'SIGTERM')
|
|
56
|
+
}
|
|
57
|
+
}, 0)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
async send(message) {
|
|
61
|
+
if (process.env.PROXY_TEST_STDIO_CAPTURE_SEND === '1') {
|
|
62
|
+
process.stdout.write(JSON.stringify(message) + '\\n')
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
`],
|
|
67
|
+
['@modelcontextprotocol/sdk/client/streamableHttp.js', `
|
|
68
|
+
${markerPrelude}
|
|
69
|
+
export class StreamableHTTPClientTransport {
|
|
70
|
+
constructor(url, options) {
|
|
71
|
+
mark('upstream-transport')
|
|
72
|
+
this.url = url
|
|
73
|
+
this.fetch = options.fetch
|
|
74
|
+
}
|
|
75
|
+
async start() {}
|
|
76
|
+
async send(message) {
|
|
77
|
+
if (
|
|
78
|
+
process.env.PROXY_TEST_UPSTREAM_INITIALIZE_FAILURE === '1'
|
|
79
|
+
&& message?.method === 'initialize'
|
|
80
|
+
) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
'initialize fetch failed Authorization: Bearer '
|
|
83
|
+
+ process.env.GEMUS_KEY
|
|
84
|
+
+ ' at '
|
|
85
|
+
+ process.env.GEMUS_URL
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
if (
|
|
89
|
+
process.env.PROXY_TEST_UPSTREAM_INITIALIZE_FETCH_FAILURE === '1'
|
|
90
|
+
&& message?.method === 'initialize'
|
|
91
|
+
) {
|
|
92
|
+
await this.fetch(this.url, {
|
|
93
|
+
method: 'POST',
|
|
94
|
+
headers: { Authorization: 'Bearer ' + process.env.GEMUS_KEY },
|
|
95
|
+
body: JSON.stringify(message),
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
if (
|
|
99
|
+
process.env.PROXY_TEST_LATE_RESULT === '1'
|
|
100
|
+
&& message?.method === 'tools/call'
|
|
101
|
+
) {
|
|
102
|
+
await this.fetch(this.url, {
|
|
103
|
+
method: 'POST',
|
|
104
|
+
body: JSON.stringify(message),
|
|
105
|
+
})
|
|
106
|
+
setTimeout(() => {
|
|
107
|
+
this.onmessage?.({
|
|
108
|
+
jsonrpc: '2.0',
|
|
109
|
+
id: message.id,
|
|
110
|
+
result: { content: [{ type: 'text', text: 'late-real-result' }] },
|
|
111
|
+
})
|
|
112
|
+
}, 80)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
async terminateSession() {
|
|
116
|
+
mark('upstream-terminate-session')
|
|
117
|
+
if (process.env.PROXY_TEST_TERMINATE_SESSION_HANG === '1') {
|
|
118
|
+
return await new Promise(() => {})
|
|
119
|
+
}
|
|
120
|
+
if (process.env.PROXY_TEST_TERMINATE_SESSION_REJECT === '1') {
|
|
121
|
+
throw new Error('terminate session rejected')
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
async close() { mark('upstream-transport-close') }
|
|
125
|
+
}
|
|
126
|
+
`],
|
|
127
|
+
['undici', `
|
|
128
|
+
${markerPrelude}
|
|
129
|
+
export class Agent {
|
|
130
|
+
constructor() { mark('upstream-http-agent') }
|
|
131
|
+
async close() { mark('upstream-http-shutdown') }
|
|
132
|
+
destroy() { mark('upstream-http-destroy') }
|
|
133
|
+
}
|
|
134
|
+
export class Pool {
|
|
135
|
+
destroy() { mark('upstream-origin-destroy') }
|
|
136
|
+
}
|
|
137
|
+
export async function fetch() {
|
|
138
|
+
mark('undici-fetch')
|
|
139
|
+
if (process.env.PROXY_TEST_LATE_RESULT === '1') {
|
|
140
|
+
return new Response(
|
|
141
|
+
new ReadableStream({ start() {} }),
|
|
142
|
+
{ headers: { 'content-type': 'text/event-stream' } },
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
const error = new Error(
|
|
146
|
+
'connect failed Authorization: Bearer '
|
|
147
|
+
+ process.env.GEMUS_KEY
|
|
148
|
+
+ ' at '
|
|
149
|
+
+ process.env.GEMUS_URL
|
|
150
|
+
+ ' via 203.0.113.1,2001:db8::1'
|
|
151
|
+
)
|
|
152
|
+
error.cause = { code: 'UND_ERR_CONNECT_TIMEOUT' }
|
|
153
|
+
throw error
|
|
154
|
+
}
|
|
155
|
+
`],
|
|
156
|
+
['node:http', `
|
|
157
|
+
${markerPrelude}
|
|
158
|
+
function hookError() {
|
|
159
|
+
return new Error(
|
|
160
|
+
'hook listen failed Authorization: Bearer '
|
|
161
|
+
+ process.env.GEMUS_KEY
|
|
162
|
+
+ ' at '
|
|
163
|
+
+ process.env.GEMUS_URL
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
const http = {
|
|
167
|
+
createServer() {
|
|
168
|
+
mark('hook-server')
|
|
169
|
+
const listeners = new Map()
|
|
170
|
+
return {
|
|
171
|
+
on(event, handler) { listeners.set(event, handler); return this },
|
|
172
|
+
listen(_port, _host, callback) {
|
|
173
|
+
if (process.env.PROXY_TEST_HOOK_LISTEN_FAILURE === '1') {
|
|
174
|
+
queueMicrotask(() => listeners.get('error')?.(hookError()))
|
|
175
|
+
} else {
|
|
176
|
+
queueMicrotask(callback)
|
|
177
|
+
}
|
|
178
|
+
return this
|
|
179
|
+
},
|
|
180
|
+
address() { return { port: 43114 } },
|
|
181
|
+
unref() {},
|
|
182
|
+
close() {
|
|
183
|
+
mark('hook-server-close')
|
|
184
|
+
if (process.env.PROXY_TEST_HOOK_ERROR_AFTER_CLOSE === '1') {
|
|
185
|
+
queueMicrotask(() => listeners.get('error')?.(hookError()))
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
}
|
|
191
|
+
export default http
|
|
192
|
+
`],
|
|
193
|
+
['./backfill.mjs', `
|
|
194
|
+
${markerPrelude}
|
|
195
|
+
export function createBackfiller() {
|
|
196
|
+
mark('backfiller')
|
|
197
|
+
return {
|
|
198
|
+
observeCall() { mark('backfiller-observe-call') },
|
|
199
|
+
observeResult() { mark('backfiller-observe-result') },
|
|
200
|
+
async backfillTurn() {},
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
export function injectSteering() { return false }
|
|
204
|
+
`],
|
|
205
|
+
])
|
|
206
|
+
|
|
207
|
+
export async function resolve(specifier, context, nextResolve) {
|
|
208
|
+
const source = modules.get(specifier)
|
|
209
|
+
if (
|
|
210
|
+
source !== undefined
|
|
211
|
+
&& (specifier !== './backfill.mjs' || context.parentURL?.endsWith('/proxy.mjs'))
|
|
212
|
+
) {
|
|
213
|
+
return { url: moduleUrl(source), shortCircuit: true }
|
|
214
|
+
}
|
|
215
|
+
return nextResolve(specifier, context)
|
|
216
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { createProxyFailure, publicMessageForFailure } from '../proxyMessages.mjs'
|
|
3
|
+
|
|
4
|
+
describe('publicMessageForFailure', () => {
|
|
5
|
+
it.each([
|
|
6
|
+
[
|
|
7
|
+
{ kind: 'connection', code: 'UND_ERR_CONNECT_TIMEOUT' },
|
|
8
|
+
'Gemus upstream connection failed before a response was received',
|
|
9
|
+
],
|
|
10
|
+
[
|
|
11
|
+
{ kind: 'http', status: 503 },
|
|
12
|
+
'Gemus upstream returned an HTTP error before the tool result was delivered',
|
|
13
|
+
],
|
|
14
|
+
[
|
|
15
|
+
{ kind: 'stream' },
|
|
16
|
+
'Gemus upstream stream disconnected before the tool result was delivered',
|
|
17
|
+
],
|
|
18
|
+
[
|
|
19
|
+
{ kind: 'idle' },
|
|
20
|
+
'Gemus upstream stream became unresponsive before the tool result was delivered',
|
|
21
|
+
],
|
|
22
|
+
])('maps $kind to its delivery-stage message', (failure, expected) => {
|
|
23
|
+
expect(publicMessageForFailure(failure)).toBe(expected)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('fails closed for an unknown failure kind', () => {
|
|
27
|
+
expect(publicMessageForFailure({ kind: 'hostile-secret-kind' })).toBe(
|
|
28
|
+
'Gemus upstream failed before the tool result was delivered',
|
|
29
|
+
)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it.each(['toString', 'constructor', '__proto__'])(
|
|
33
|
+
'fails closed for the Object.prototype property name %s',
|
|
34
|
+
(kind) => {
|
|
35
|
+
expect(publicMessageForFailure({ kind })).toBe(
|
|
36
|
+
'Gemus upstream failed before the tool result was delivered',
|
|
37
|
+
)
|
|
38
|
+
},
|
|
39
|
+
)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
describe('createProxyFailure', () => {
|
|
43
|
+
it('preserves the original JSON-RPC id in the public error envelope', () => {
|
|
44
|
+
expect(createProxyFailure('request-17', { kind: 'stream' }).response).toEqual({
|
|
45
|
+
jsonrpc: '2.0',
|
|
46
|
+
id: 'request-17',
|
|
47
|
+
error: {
|
|
48
|
+
code: -32000,
|
|
49
|
+
message: 'Gemus upstream stream disconnected before the tool result was delivered',
|
|
50
|
+
},
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it.each([
|
|
55
|
+
[
|
|
56
|
+
{
|
|
57
|
+
kind: 'connection',
|
|
58
|
+
code: 'UND_ERR_CONNECT_TIMEOUT',
|
|
59
|
+
reason: 'Authorization: Bearer secret at https://alice:password@example.test/api/mcp?token=secret',
|
|
60
|
+
addresses: ['203.0.113.1', '2001:db8::1'],
|
|
61
|
+
},
|
|
62
|
+
{ id: 41, kind: 'connection', code: 'UND_ERR_CONNECT_TIMEOUT' },
|
|
63
|
+
],
|
|
64
|
+
[
|
|
65
|
+
{
|
|
66
|
+
kind: 'http',
|
|
67
|
+
status: 503,
|
|
68
|
+
reason: 'https://alice:password@example.test/api/mcp?token=secret',
|
|
69
|
+
},
|
|
70
|
+
{ id: 41, kind: 'http', status: 503 },
|
|
71
|
+
],
|
|
72
|
+
])('keeps only safe diagnostic fields for $kind failures', (failure, expected) => {
|
|
73
|
+
const result = createProxyFailure(41, failure)
|
|
74
|
+
const serialized = JSON.stringify(result)
|
|
75
|
+
|
|
76
|
+
expect(result.diagnostic).toEqual(expected)
|
|
77
|
+
expect(serialized).not.toMatch(
|
|
78
|
+
/Authorization|Bearer|secret|alice|password|example\.test|203\.0\.113\.1|2001:db8/,
|
|
79
|
+
)
|
|
80
|
+
})
|
|
81
|
+
})
|