@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
|
@@ -1,136 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest'
|
|
2
|
-
import { CLIENT_CAPABILITIES } from '@gemus/codex-backfill-core'
|
|
3
|
-
import { buildProxyUpstreamHeaders } from '../mcpHeaders.mjs'
|
|
4
|
-
|
|
5
|
-
describe('buildProxyUpstreamHeaders', () => {
|
|
6
|
-
it('advertises only the capabilities the proxy can actually fulfil', () => {
|
|
7
|
-
expect(buildProxyUpstreamHeaders('mak_test123')).toEqual({
|
|
8
|
-
Authorization: 'Bearer mak_test123',
|
|
9
|
-
'X-Gemus-Client-Capabilities': 'agent-authored-prompts,codex-imagen',
|
|
10
|
-
})
|
|
11
|
-
})
|
|
12
|
-
|
|
13
|
-
it('never advertises codex-instant-delivery — the proxy has no ImageScheduler to renew the lease (#2049)', () => {
|
|
14
|
-
// 该能力门控服务端的 AWAITING_CLIENT execute 分支,要求客户端有 scheduler 每 60s renewLease。
|
|
15
|
-
// 只有 bridge 有(且 probe 门控,见 bridge/src/cli.ts)。proxy 没有 —— 发了它就等于承诺一个
|
|
16
|
-
// 永不到来的续租 → 5min 后 CLIENT_LEASE_EXPIRED → 图 orphan 成孤立 image-upload 节点。
|
|
17
|
-
// absent → 服务端回退经典两阶段 client-delivered 协议,proxy 完全支持(backfill.mjs)。
|
|
18
|
-
const header = buildProxyUpstreamHeaders('mak_test123')['X-Gemus-Client-Capabilities']
|
|
19
|
-
expect(header).not.toContain('codex-instant-delivery')
|
|
20
|
-
})
|
|
21
|
-
|
|
22
|
-
it('stays a strict subset of the shared allowlist — a new capability must not auto-inherit (#2049)', () => {
|
|
23
|
-
// 回归门:本 bug 的成因正是 proxy 发「整个 CLIENT_CAPABILITIES 数组」—— #1969 往数组里加了
|
|
24
|
-
// codex-instant-delivery,proxy 就自动继承了一个它做不到的承诺,而 #1990 把红掉的断言改成
|
|
25
|
-
// 迎合它。此断言让「加能力」与「proxy 声称支持它」变成两个独立决策,不再自动传染。
|
|
26
|
-
const advertised = buildProxyUpstreamHeaders('mak_test123')['X-Gemus-Client-Capabilities'].split(',')
|
|
27
|
-
for (const capability of advertised) {
|
|
28
|
-
expect(CLIENT_CAPABILITIES).toContain(capability)
|
|
29
|
-
}
|
|
30
|
-
expect(advertised.length).toBeLessThan(CLIENT_CAPABILITIES.length)
|
|
31
|
-
})
|
|
32
|
-
})
|
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest'
|
|
2
|
-
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
|
3
|
-
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Opt-in integration test against a REAL running gemus `/api/mcp` route (#1751). Validates the Mode-B
|
|
7
|
-
* **key-only** contract the proxy + core delivery rely on: Bearer `mak_` auth with **no**
|
|
8
|
-
* `X-Gemus-Session-Id` header, and addressing by an explicit real `workflowId`.
|
|
9
|
-
*
|
|
10
|
-
* Excluded from the default runner (`*.integration.test.ts`) and gated on env so it never runs in CI or
|
|
11
|
-
* by accident. To run against local dev:
|
|
12
|
-
* GEMUS_IT_KEY=mak_... GEMUS_IT_WORKFLOW=<realWorkflowId> \
|
|
13
|
-
* pnpm exec vitest run packages/gemus-mcp-proxy/src/__tests__/route.integration.test.ts
|
|
14
|
-
* (GEMUS_IT_URL defaults to http://localhost:3000/api/mcp.) All calls here are read-only (no mutation).
|
|
15
|
-
*
|
|
16
|
-
* connect-per-call invariant: every case opens + closes its own client (fresh connectionNonce, #1744).
|
|
17
|
-
*/
|
|
18
|
-
const KEY = process.env.GEMUS_IT_KEY
|
|
19
|
-
const URL_ = process.env.GEMUS_IT_URL || 'http://localhost:3000/api/mcp'
|
|
20
|
-
const WORKFLOW = process.env.GEMUS_IT_WORKFLOW
|
|
21
|
-
|
|
22
|
-
/** Key-only client: Bearer only, deliberately NO X-Gemus-Session-Id (the Mode-B contract). */
|
|
23
|
-
async function connectKeyOnly() {
|
|
24
|
-
const client = new Client({ name: 'gemus-proxy-it', version: '0.1.0' })
|
|
25
|
-
const transport = new StreamableHTTPClientTransport(new URL(URL_), {
|
|
26
|
-
requestInit: { headers: { Authorization: `Bearer ${KEY}` } },
|
|
27
|
-
})
|
|
28
|
-
await client.connect(transport)
|
|
29
|
-
return client
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
describe.skipIf(!KEY)('key-only /api/mcp route (real, opt-in)', () => {
|
|
33
|
-
it('relays the full tool surface incl. execute/batch_execute (no session header)', async () => {
|
|
34
|
-
const client = await connectKeyOnly()
|
|
35
|
-
try {
|
|
36
|
-
const { tools } = await client.listTools()
|
|
37
|
-
const names = tools.map((t) => t.name)
|
|
38
|
-
expect(names).toEqual(expect.arrayContaining(['execute', 'batch_execute', 'canvas_read', 'canvas_edit']))
|
|
39
|
-
} finally {
|
|
40
|
-
await client.close()
|
|
41
|
-
}
|
|
42
|
-
})
|
|
43
|
-
|
|
44
|
-
it('serves resources (skills / knowledge) through the key-only connection', async () => {
|
|
45
|
-
const client = await connectKeyOnly()
|
|
46
|
-
try {
|
|
47
|
-
const { resources } = await client.listResources()
|
|
48
|
-
// The route exposes skill:// / knowledge:// resources; a tool-only passthrough would drop these.
|
|
49
|
-
expect(Array.isArray(resources)).toBe(true)
|
|
50
|
-
} finally {
|
|
51
|
-
await client.close()
|
|
52
|
-
}
|
|
53
|
-
})
|
|
54
|
-
|
|
55
|
-
it.skipIf(!WORKFLOW)('addresses a real workflow by explicit workflowId (key-only, no activeContext)', async () => {
|
|
56
|
-
const client = await connectKeyOnly()
|
|
57
|
-
try {
|
|
58
|
-
// canvas_read is read-only; success proves key-only addressing by explicit workflowId works
|
|
59
|
-
// without a session header (what deliverImageToNode relies on in Mode B).
|
|
60
|
-
const res = (await client.callTool({ name: 'canvas_read', arguments: { workflowId: WORKFLOW } })) as {
|
|
61
|
-
isError?: boolean
|
|
62
|
-
}
|
|
63
|
-
expect(res.isError).not.toBe(true)
|
|
64
|
-
} finally {
|
|
65
|
-
await client.close()
|
|
66
|
-
}
|
|
67
|
-
})
|
|
68
|
-
})
|