@gemus/mcp-proxy 0.1.6 → 0.1.8
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 +50 -19
- package/package.json +1 -1
- package/src/__tests__/failFast.test.ts +254 -0
- package/src/__tests__/fixtures/proxy-runtime-loader.mjs +136 -0
- package/src/__tests__/startup.test.ts +558 -0
- package/src/failFast.mjs +266 -0
- package/src/proxy.mjs +127 -22
- package/src/startup.mjs +94 -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
|
|
@@ -27,24 +28,55 @@ server blueprint policy.
|
|
|
27
28
|
- **(c) turn-end + retrieval** — `Stop` hook fires post-flush carrying `transcript_path` → exact
|
|
28
29
|
rollout; imagegen writes `generated_images/<session>/<sanitized-call-id>.png` (historically `ig_*`,
|
|
29
30
|
currently `call_*`) + rollout `saved_path`+base64.
|
|
30
|
-
- **Session cap** —
|
|
31
|
-
|
|
31
|
+
- **Session cap** — the key-only session cap defaults to 10 concurrent sessions per user
|
|
32
|
+
(`MCP_KEY_ONLY_SESSION_CAP`); the proxy MUST `DELETE` its upstream session on close (implemented
|
|
33
|
+
in `cleanup()`) to avoid leaking slots.
|
|
32
34
|
|
|
33
|
-
##
|
|
35
|
+
## Companion setup and migration (Codex desktop)
|
|
34
36
|
|
|
35
|
-
The
|
|
36
|
-
|
|
37
|
-
|
|
37
|
+
The plugin owns the non-secret process contract: exact `@gemus/mcp-proxy@0.1.8`,
|
|
38
|
+
`startup_timeout_sec = 60`, `tool_timeout_sec = 300`, and a default-disabled rollout. The user
|
|
39
|
+
environment owns `GEMUS_KEY` and optional `GEMUS_URL`; the public plugin and generated commands
|
|
40
|
+
never contain their values.
|
|
38
41
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
42
|
+
Environment ownership and lifetime:
|
|
43
|
+
|
|
44
|
+
- **Windows / PowerShell:** persist the key in the Windows user environment and refresh the current
|
|
45
|
+
process.
|
|
46
|
+
- **macOS:** If changing login context, sign out and back in first; rerun the `launchctl setenv`
|
|
47
|
+
setup in the new login session; then fully quit and restart Codex and open a new task.
|
|
48
|
+
- **Linux:** export the key and launch Codex from the same terminal; no universal desktop-session
|
|
49
|
+
inheritance is assumed.
|
|
50
|
+
|
|
51
|
+
Companion and direct modes are mutually exclusive. The canonical new-user and legacy migration flow
|
|
52
|
+
is:
|
|
53
|
+
|
|
54
|
+
1. Set `GEMUS_KEY` in the user environment.
|
|
55
|
+
2. Remove any legacy global server (`codex mcp remove gemus`; not-found is harmless).
|
|
56
|
+
3. Install with `codex plugin marketplace add Gemus-AI/gemus-codex-plugin`, then
|
|
57
|
+
`codex plugin add gemus@gemus`. Existing users refresh the marketplace snapshot and replace the
|
|
58
|
+
installed cache:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
codex plugin marketplace upgrade gemus
|
|
62
|
+
codex plugin remove gemus@gemus
|
|
63
|
+
codex plugin add gemus@gemus
|
|
64
|
+
```
|
|
65
|
+
4. Explicitly enable the plugin-scoped server:
|
|
66
|
+
|
|
67
|
+
```toml
|
|
68
|
+
[plugins."gemus@gemus".mcp_servers.gemus]
|
|
69
|
+
enabled = true
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
5. Restart Codex and open a new task, then use `/hooks` to trust the Stop hook separately.
|
|
73
|
+
|
|
74
|
+
Intentional direct HTTP/OAuth users keep their global direct server and leave the plugin Companion
|
|
75
|
+
disabled:
|
|
76
|
+
|
|
77
|
+
```toml
|
|
78
|
+
[plugins."gemus@gemus".mcp_servers.gemus]
|
|
79
|
+
enabled = false
|
|
48
80
|
```
|
|
49
81
|
|
|
50
82
|
## Local dev
|
|
@@ -52,7 +84,6 @@ codex plugin add gemus@<marketplace>
|
|
|
52
84
|
```bash
|
|
53
85
|
pnpm --filter @gemus/codex-backfill-core build # proxy imports the built core at runtime
|
|
54
86
|
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
87
|
```
|
|
57
88
|
|
|
58
89
|
Env: `GEMUS_KEY` (required), `GEMUS_URL` (default `https://gemus.ai/api/mcp`), `PROXY_LOG` (optional
|
package/package.json
CHANGED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @gemus/mcp-proxy fail-fast (#2068) — single-pass upstream-disconnect correlation.
|
|
3
|
+
*
|
|
4
|
+
* Pure logic; the socket is an injected `realFetch` returning controllable `Response`s. Cases that
|
|
5
|
+
* involve the final-safety timer use fake timers with advanceTimersByTimeAsync (lesson: fake timers
|
|
6
|
+
* + real reader.read() promises need the async advance to flush continuations).
|
|
7
|
+
*/
|
|
8
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
9
|
+
import { parseRequestIds, isCodexOriginated, createSseIdScanner, createFailFast, isJsonRpcResponse } from '../failFast.mjs'
|
|
10
|
+
|
|
11
|
+
const enc = new TextEncoder()
|
|
12
|
+
|
|
13
|
+
/** A response body that emits the given SSE frames then closes. */
|
|
14
|
+
function bodyOf(...frames: string[]): ReadableStream<Uint8Array> {
|
|
15
|
+
return new ReadableStream<Uint8Array>({
|
|
16
|
+
start(c) {
|
|
17
|
+
for (const f of frames) c.enqueue(enc.encode(f))
|
|
18
|
+
c.close()
|
|
19
|
+
},
|
|
20
|
+
})
|
|
21
|
+
}
|
|
22
|
+
/** A response body that never emits and never closes (zombie stream). */
|
|
23
|
+
function neverBody(): ReadableStream<Uint8Array> {
|
|
24
|
+
return new ReadableStream<Uint8Array>({ start() {} })
|
|
25
|
+
}
|
|
26
|
+
function sseRes(body: ReadableStream<Uint8Array> | null, init?: { status?: number; contentType?: string }) {
|
|
27
|
+
const status = init?.status ?? 200
|
|
28
|
+
return new Response(body, { status, headers: { 'content-type': init?.contentType ?? 'text/event-stream' } })
|
|
29
|
+
}
|
|
30
|
+
function resultFrame(id: number | string): string {
|
|
31
|
+
return `event: message\ndata: ${JSON.stringify({ jsonrpc: '2.0', id, result: { ok: true } })}\n\n`
|
|
32
|
+
}
|
|
33
|
+
function post(id: number | string, method = 'tools/call') {
|
|
34
|
+
return { method: 'POST', body: JSON.stringify({ jsonrpc: '2.0', id, method, params: {} }) }
|
|
35
|
+
}
|
|
36
|
+
async function drain(res: Response): Promise<void> {
|
|
37
|
+
const r = res.body!.getReader()
|
|
38
|
+
for (;;) { const { done } = await r.read(); if (done) break }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe('parseRequestIds', () => {
|
|
42
|
+
it('extracts the id from a POST JSON-RPC request', () => {
|
|
43
|
+
expect(parseRequestIds(post(42))).toEqual([42])
|
|
44
|
+
})
|
|
45
|
+
it('extracts all ids from a batch', () => {
|
|
46
|
+
const body = JSON.stringify([
|
|
47
|
+
{ jsonrpc: '2.0', id: 1, method: 'a' },
|
|
48
|
+
{ jsonrpc: '2.0', id: 2, method: 'b' },
|
|
49
|
+
])
|
|
50
|
+
expect(parseRequestIds({ method: 'POST', body })).toEqual([1, 2])
|
|
51
|
+
})
|
|
52
|
+
it('returns [] for GET / DELETE / bodyless requests', () => {
|
|
53
|
+
expect(parseRequestIds({ method: 'GET' })).toEqual([])
|
|
54
|
+
expect(parseRequestIds({ method: 'DELETE' })).toEqual([])
|
|
55
|
+
expect(parseRequestIds({ method: 'POST' })).toEqual([])
|
|
56
|
+
})
|
|
57
|
+
it('returns [] for non-JSON body and for notifications (no id)', () => {
|
|
58
|
+
expect(parseRequestIds({ method: 'POST', body: 'not json' })).toEqual([])
|
|
59
|
+
expect(parseRequestIds({ method: 'POST', body: JSON.stringify({ jsonrpc: '2.0', method: 'x' }) })).toEqual([])
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
describe('isCodexOriginated', () => {
|
|
64
|
+
it.each([
|
|
65
|
+
[42, true],
|
|
66
|
+
['abc', true],
|
|
67
|
+
['gemus-bf-3', false],
|
|
68
|
+
['gemus-ping-9', false],
|
|
69
|
+
[null, false],
|
|
70
|
+
[undefined, false],
|
|
71
|
+
])('id=%s → %s', (id, expected) => {
|
|
72
|
+
expect(isCodexOriginated(id as never)).toBe(expected)
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
describe('isJsonRpcResponse', () => {
|
|
77
|
+
it('is true for a result/error response, false for a request or notification', () => {
|
|
78
|
+
expect(isJsonRpcResponse({ id: 1, result: {} })).toBe(true)
|
|
79
|
+
expect(isJsonRpcResponse({ id: 1, error: {} })).toBe(true)
|
|
80
|
+
expect(isJsonRpcResponse({ id: 1, method: 'elicitation/create', params: {} })).toBe(false) // server→client request
|
|
81
|
+
expect(isJsonRpcResponse({ method: 'notifications/x' })).toBe(false)
|
|
82
|
+
expect(isJsonRpcResponse(null)).toBe(false)
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
describe('createSseIdScanner', () => {
|
|
87
|
+
it('yields the id of a complete RESPONSE frame; ignores comment lines', () => {
|
|
88
|
+
const s = createSseIdScanner()
|
|
89
|
+
expect(s.push(enc.encode(': keepalive\n\n'))).toEqual([])
|
|
90
|
+
expect(s.push(enc.encode(resultFrame(7)))).toEqual([7])
|
|
91
|
+
})
|
|
92
|
+
it('does NOT yield a server→client REQUEST id (has a method) — avoids id-collision premature settle', () => {
|
|
93
|
+
const s = createSseIdScanner()
|
|
94
|
+
const reqFrame = `event: message\ndata: ${JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'elicitation/create', params: {} })}\n\n`
|
|
95
|
+
expect(s.push(enc.encode(reqFrame))).toEqual([])
|
|
96
|
+
})
|
|
97
|
+
it('buffers a half-frame split across chunks until its boundary arrives', () => {
|
|
98
|
+
const s = createSseIdScanner()
|
|
99
|
+
const frame = resultFrame(5)
|
|
100
|
+
const mid = Math.floor(frame.length / 2)
|
|
101
|
+
expect(s.push(enc.encode(frame.slice(0, mid)))).toEqual([])
|
|
102
|
+
expect(s.push(enc.encode(frame.slice(mid)))).toEqual([5])
|
|
103
|
+
})
|
|
104
|
+
it('handles CRLF line endings (normalizes to LF)', () => {
|
|
105
|
+
const s = createSseIdScanner()
|
|
106
|
+
const crlf = `event: message\r\ndata: ${JSON.stringify({ jsonrpc: '2.0', id: 8, result: {} })}\r\n\r\n`
|
|
107
|
+
expect(s.push(enc.encode(crlf))).toEqual([8])
|
|
108
|
+
})
|
|
109
|
+
it('ignores non-JSON data payloads', () => {
|
|
110
|
+
const s = createSseIdScanner()
|
|
111
|
+
expect(s.push(enc.encode('data: not-json\n\n'))).toEqual([])
|
|
112
|
+
})
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
describe('createFailFast.fetch', () => {
|
|
116
|
+
it('injects a JSON-RPC error when the stream closes before the result', async () => {
|
|
117
|
+
const onLost = vi.fn()
|
|
118
|
+
const realFetch = vi.fn(async () => sseRes(bodyOf(': keepalive\n\n'))) // no result frame → lost
|
|
119
|
+
const ff = createFailFast({ onLost, realFetch })
|
|
120
|
+
|
|
121
|
+
const res = await ff.fetch('u', post(42))
|
|
122
|
+
await drain(res)
|
|
123
|
+
|
|
124
|
+
expect(onLost).toHaveBeenCalledTimes(1)
|
|
125
|
+
expect(onLost.mock.calls[0][0]).toBe(42)
|
|
126
|
+
expect(ff.failedIds.has('42')).toBe(true)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('does NOT inject an error when the result crosses the wire before close', async () => {
|
|
130
|
+
const onLost = vi.fn()
|
|
131
|
+
const realFetch = vi.fn(async () => sseRes(bodyOf(resultFrame(42))))
|
|
132
|
+
const ff = createFailFast({ onLost, realFetch })
|
|
133
|
+
|
|
134
|
+
const res = await ff.fetch('u', post(42))
|
|
135
|
+
await drain(res)
|
|
136
|
+
|
|
137
|
+
expect(onLost).not.toHaveBeenCalled()
|
|
138
|
+
expect(ff.failedIds.has('42')).toBe(false)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('fails fast immediately when realFetch throws before any response', async () => {
|
|
142
|
+
const onLost = vi.fn()
|
|
143
|
+
const realFetch = vi.fn(async () => { throw new Error('ECONNRESET') })
|
|
144
|
+
const ff = createFailFast({ onLost, realFetch })
|
|
145
|
+
|
|
146
|
+
await expect(ff.fetch('u', post(7))).rejects.toThrow('ECONNRESET')
|
|
147
|
+
expect(onLost).toHaveBeenCalledWith(7, expect.stringContaining('upstream fetch failed'))
|
|
148
|
+
expect(realFetch).toHaveBeenCalledTimes(1) // must NOT retry — a 2nd fetch could execute the tool
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('fails fast on a non-ok HTTP response', async () => {
|
|
152
|
+
const onLost = vi.fn()
|
|
153
|
+
const realFetch = vi.fn(async () => sseRes(null, { status: 404, contentType: 'application/json' }))
|
|
154
|
+
const ff = createFailFast({ onLost, realFetch })
|
|
155
|
+
|
|
156
|
+
await ff.fetch('u', post(7))
|
|
157
|
+
expect(onLost).toHaveBeenCalledWith(7, 'upstream HTTP 404')
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('passes GET and DELETE through untouched (no body → no monitoring, no onLost)', async () => {
|
|
161
|
+
const onLost = vi.fn()
|
|
162
|
+
const getRes = sseRes(bodyOf())
|
|
163
|
+
const realFetch = vi.fn(async () => getRes)
|
|
164
|
+
const ff = createFailFast({ onLost, realFetch })
|
|
165
|
+
|
|
166
|
+
const g = await ff.fetch('u', { method: 'GET' })
|
|
167
|
+
const d = await ff.fetch('u', { method: 'DELETE' })
|
|
168
|
+
expect(g).toBe(getRes)
|
|
169
|
+
expect(d).toBe(getRes)
|
|
170
|
+
expect(onLost).not.toHaveBeenCalled()
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('does not track internal gemus-bf-* / gemus-ping-* ids', async () => {
|
|
174
|
+
const onLost = vi.fn()
|
|
175
|
+
const realFetch = vi.fn(async () => sseRes(bodyOf(': keepalive\n\n'))) // closes with no result
|
|
176
|
+
const ff = createFailFast({ onLost, realFetch })
|
|
177
|
+
|
|
178
|
+
await drain(await ff.fetch('u', post('gemus-bf-1')))
|
|
179
|
+
await drain(await ff.fetch('u', post('gemus-ping-2')))
|
|
180
|
+
expect(onLost).not.toHaveBeenCalled()
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('degrades to plain passthrough if internal logic throws', async () => {
|
|
184
|
+
const onLost = vi.fn()
|
|
185
|
+
const badRes = { ok: true, status: 200, headers: { get() { throw new Error('boom') } }, body: null } as unknown as Response
|
|
186
|
+
const realFetch = vi.fn(async () => badRes)
|
|
187
|
+
const ff = createFailFast({ onLost, realFetch })
|
|
188
|
+
|
|
189
|
+
const res = await ff.fetch('u', post(1))
|
|
190
|
+
expect(res).toBe(badRes) // returns the response we ALREADY have — no re-fetch
|
|
191
|
+
expect(realFetch).toHaveBeenCalledTimes(1)
|
|
192
|
+
expect(onLost).not.toHaveBeenCalled()
|
|
193
|
+
ff.dispose()
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it('isolates concurrent requests — disconnecting one does not affect the other', async () => {
|
|
197
|
+
const onLost = vi.fn()
|
|
198
|
+
const realFetch = vi
|
|
199
|
+
.fn()
|
|
200
|
+
.mockResolvedValueOnce(sseRes(bodyOf(': keepalive\n\n'))) // id 1 → dies with no result
|
|
201
|
+
.mockResolvedValueOnce(sseRes(bodyOf(resultFrame(2)))) // id 2 → delivers
|
|
202
|
+
const ff = createFailFast({ onLost, realFetch })
|
|
203
|
+
|
|
204
|
+
await drain(await ff.fetch('u', post(1)))
|
|
205
|
+
await drain(await ff.fetch('u', post(2)))
|
|
206
|
+
|
|
207
|
+
expect(onLost).toHaveBeenCalledTimes(1)
|
|
208
|
+
expect(onLost.mock.calls[0][0]).toBe(1)
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
describe('idle safety timeout', () => {
|
|
212
|
+
beforeEach(() => vi.useFakeTimers())
|
|
213
|
+
afterEach(() => vi.useRealTimers())
|
|
214
|
+
|
|
215
|
+
it('injects an error for a zombie connection that never emits a byte', async () => {
|
|
216
|
+
const onLost = vi.fn()
|
|
217
|
+
const realFetch = vi.fn(async () => sseRes(neverBody()))
|
|
218
|
+
const ff = createFailFast({ onLost, realFetch, idleTimeoutMs: 120_000 })
|
|
219
|
+
|
|
220
|
+
await ff.fetch('u', post(99)) // never emits → idle timer is never reset
|
|
221
|
+
expect(onLost).not.toHaveBeenCalled()
|
|
222
|
+
await vi.advanceTimersByTimeAsync(120_000)
|
|
223
|
+
expect(onLost).toHaveBeenCalledWith(99, expect.stringContaining('idle'))
|
|
224
|
+
ff.dispose()
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('does NOT kill a healthy-but-slow stream — heartbeat bytes reset the idle timer', async () => {
|
|
228
|
+
const onLost = vi.fn()
|
|
229
|
+
let ctl!: ReadableStreamDefaultController<Uint8Array>
|
|
230
|
+
const body = new ReadableStream<Uint8Array>({ start(c) { ctl = c } })
|
|
231
|
+
const realFetch = vi.fn(async () => sseRes(body))
|
|
232
|
+
const ff = createFailFast({ onLost, realFetch, idleTimeoutMs: 100 })
|
|
233
|
+
|
|
234
|
+
const res = await ff.fetch('u', post(1))
|
|
235
|
+
const reader = res.body!.getReader()
|
|
236
|
+
|
|
237
|
+
// Keepalive every 60ms (< 100ms idle) for 300ms total — each read touches → never fires.
|
|
238
|
+
for (let i = 0; i < 5; i++) {
|
|
239
|
+
ctl.enqueue(enc.encode(': keepalive\n\n'))
|
|
240
|
+
await reader.read()
|
|
241
|
+
await vi.advanceTimersByTimeAsync(60)
|
|
242
|
+
}
|
|
243
|
+
expect(onLost).not.toHaveBeenCalled() // survived 300ms > idleTimeout because bytes kept flowing
|
|
244
|
+
|
|
245
|
+
// Now the result arrives and the stream closes → still no fail-fast.
|
|
246
|
+
ctl.enqueue(enc.encode(resultFrame(1)))
|
|
247
|
+
await reader.read()
|
|
248
|
+
ctl.close()
|
|
249
|
+
await reader.read()
|
|
250
|
+
expect(onLost).not.toHaveBeenCalled()
|
|
251
|
+
ff.dispose()
|
|
252
|
+
})
|
|
253
|
+
})
|
|
254
|
+
})
|
|
@@ -0,0 +1,136 @@
|
|
|
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
|
+
}
|
|
51
|
+
async send() {}
|
|
52
|
+
}
|
|
53
|
+
`],
|
|
54
|
+
['@modelcontextprotocol/sdk/client/streamableHttp.js', `
|
|
55
|
+
${markerPrelude}
|
|
56
|
+
export class StreamableHTTPClientTransport {
|
|
57
|
+
constructor() { mark('upstream-transport') }
|
|
58
|
+
async start() {}
|
|
59
|
+
async send(message) {
|
|
60
|
+
if (
|
|
61
|
+
process.env.PROXY_TEST_UPSTREAM_INITIALIZE_FAILURE === '1'
|
|
62
|
+
&& message?.method === 'initialize'
|
|
63
|
+
) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
'initialize fetch failed Authorization: Bearer '
|
|
66
|
+
+ process.env.GEMUS_KEY
|
|
67
|
+
+ ' at '
|
|
68
|
+
+ process.env.GEMUS_URL
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
async terminateSession() {}
|
|
73
|
+
async close() {}
|
|
74
|
+
}
|
|
75
|
+
`],
|
|
76
|
+
['node:http', `
|
|
77
|
+
${markerPrelude}
|
|
78
|
+
function hookError() {
|
|
79
|
+
return new Error(
|
|
80
|
+
'hook listen failed Authorization: Bearer '
|
|
81
|
+
+ process.env.GEMUS_KEY
|
|
82
|
+
+ ' at '
|
|
83
|
+
+ process.env.GEMUS_URL
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
const http = {
|
|
87
|
+
createServer() {
|
|
88
|
+
mark('hook-server')
|
|
89
|
+
const listeners = new Map()
|
|
90
|
+
return {
|
|
91
|
+
on(event, handler) { listeners.set(event, handler); return this },
|
|
92
|
+
listen(_port, _host, callback) {
|
|
93
|
+
if (process.env.PROXY_TEST_HOOK_LISTEN_FAILURE === '1') {
|
|
94
|
+
queueMicrotask(() => listeners.get('error')?.(hookError()))
|
|
95
|
+
} else {
|
|
96
|
+
queueMicrotask(callback)
|
|
97
|
+
}
|
|
98
|
+
return this
|
|
99
|
+
},
|
|
100
|
+
address() { return { port: 43114 } },
|
|
101
|
+
unref() {},
|
|
102
|
+
close() {
|
|
103
|
+
mark('hook-server-close')
|
|
104
|
+
if (process.env.PROXY_TEST_HOOK_ERROR_AFTER_CLOSE === '1') {
|
|
105
|
+
queueMicrotask(() => listeners.get('error')?.(hookError()))
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
}
|
|
111
|
+
export default http
|
|
112
|
+
`],
|
|
113
|
+
['./backfill.mjs', `
|
|
114
|
+
${markerPrelude}
|
|
115
|
+
export function createBackfiller() {
|
|
116
|
+
mark('backfiller')
|
|
117
|
+
return {
|
|
118
|
+
observeCall() {},
|
|
119
|
+
observeResult() {},
|
|
120
|
+
async backfillTurn() {},
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
export function injectSteering() { return false }
|
|
124
|
+
`],
|
|
125
|
+
])
|
|
126
|
+
|
|
127
|
+
export async function resolve(specifier, context, nextResolve) {
|
|
128
|
+
const source = modules.get(specifier)
|
|
129
|
+
if (
|
|
130
|
+
source !== undefined
|
|
131
|
+
&& (specifier !== './backfill.mjs' || context.parentURL?.endsWith('/proxy.mjs'))
|
|
132
|
+
) {
|
|
133
|
+
return { url: moduleUrl(source), shortCircuit: true }
|
|
134
|
+
}
|
|
135
|
+
return nextResolve(specifier, context)
|
|
136
|
+
}
|