-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathproxy.js
More file actions
237 lines (213 loc) · 7 KB
/
Copy pathproxy.js
File metadata and controls
237 lines (213 loc) · 7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
/**
* Streamable HTTP proxy mode.
*
* Instead of impersonating the MCP server over the REST tool endpoints
* (GET /mcp/tools + POST /mcp/call-tool), this mode is a transparent pipe
* between a stdio MCP client (Claude Desktop, or any stdio-only host) and the
* platform's native MCP endpoint (POST /v1/graphs/{graph_id}/mcp, Streamable
* HTTP). Every JSON-RPC message read from stdin is forwarded verbatim with the
* API key header attached; JSON and SSE responses are relayed back to stdout
* message-for-message. Per-session instructions, live tool lists, and streamed
* progress notifications all pass through untouched — connecting through the
* proxy behaves identically to connecting the URL directly.
*/
import { createInterface } from 'readline'
const JSONRPC_PARSE_ERROR = -32700
const JSONRPC_PROXY_ERROR = -32000
const ERROR_BODY_PREVIEW_CHARS = 300
/**
* Parse a Server-Sent Events stream and deliver each event's JSON payload.
* Handles multi-line `data:` fields per the SSE spec and ignores comment
* (keepalive) lines. `event:`/`id:`/`retry:` fields carry no JSON-RPC content
* on this endpoint and are skipped.
*/
async function relaySSE(body, deliver) {
const reader = body.getReader()
const decoder = new TextDecoder()
let buffer = ''
let dataLines = []
const dispatch = () => {
if (dataLines.length === 0) return
const dataStr = dataLines.join('\n')
dataLines = []
if (!dataStr || dataStr === '[DONE]') return
try {
deliver(JSON.parse(dataStr))
} catch {
console.error('Proxy: skipping unparseable SSE event')
}
}
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split(/\r?\n/)
buffer = lines.pop() || ''
for (const line of lines) {
if (line === '') {
dispatch()
} else if (line.startsWith(':')) {
// Comment / keepalive — ignore
} else if (line.startsWith('data:')) {
dataLines.push(line.slice(5).replace(/^ /, ''))
}
}
}
// Flush a trailing event if the stream ended without a final blank line
if (buffer.startsWith('data:')) {
dataLines.push(buffer.slice(5).replace(/^ /, ''))
}
dispatch()
} finally {
reader.cancel().catch(() => {})
}
}
/**
* Create a proxy instance bound to one MCP endpoint. Exposed separately from
* runProxy so tests can drive individual messages without real stdio.
*/
export function createProxy({ url, apiKey, version, output = process.stdout, fetchImpl = fetch }) {
// Captured from the initialize response and echoed back on subsequent
// requests via the MCP-Protocol-Version header, per Streamable HTTP.
let protocolVersion = null
const deliver = (message) => {
if (message && typeof message === 'object' && message.result?.protocolVersion) {
protocolVersion = message.result.protocolVersion
}
output.write(JSON.stringify(message) + '\n')
}
const buildHeaders = () => {
const headers = {
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
'User-Agent': `robosystems-mcp/${version}`,
'X-MCP-Client': version,
}
if (apiKey) {
headers['X-API-Key'] = apiKey
}
if (protocolVersion) {
headers['MCP-Protocol-Version'] = protocolVersion
}
return headers
}
const handleLine = async (line) => {
const raw = line.trim()
if (!raw) return
let message
try {
message = JSON.parse(raw)
} catch {
deliver({
jsonrpc: '2.0',
id: null,
error: { code: JSONRPC_PARSE_ERROR, message: 'Parse error' },
})
return
}
// Requests carry both a method and an id and expect a reply; notifications
// and client→server responses get forwarded but never answered locally.
const expectsResponse = message.method !== undefined && message.id !== undefined
try {
const response = await fetchImpl(url, {
method: 'POST',
headers: buildHeaders(),
body: raw,
})
// 202 Accepted — notification or response delivered, nothing to relay
if (response.status === 202) return
if (!response.ok) {
const text = await response.text().catch(() => '')
if (expectsResponse) {
const detail = text ? `: ${text.slice(0, ERROR_BODY_PREVIEW_CHARS)}` : ''
deliver({
jsonrpc: '2.0',
id: message.id,
error: {
code: JSONRPC_PROXY_ERROR,
message: `HTTP ${response.status}${detail}`,
},
})
} else {
console.error(`Proxy: HTTP ${response.status} forwarding ${message.method || 'response'}`)
}
return
}
const contentType = response.headers.get('content-type') || ''
if (contentType.includes('text/event-stream')) {
await relaySSE(response.body, deliver)
} else {
const text = await response.text()
if (!text.trim()) return
// Re-serialize to guarantee one message per stdout line
deliver(JSON.parse(text))
}
} catch (error) {
if (expectsResponse) {
deliver({
jsonrpc: '2.0',
id: message.id,
error: {
code: JSONRPC_PROXY_ERROR,
message: `Proxy request failed: ${error.message}`,
},
})
} else {
console.error(`Proxy request failed: ${error.message}`)
}
}
}
return {
handleLine,
getProtocolVersion: () => protocolVersion,
}
}
/**
* Strip credentials from a URL before logging it: query string (`?token=…`
* connector credentials), fragment, and userinfo. MCP hosts capture stderr
* into diagnostic logs that get shared in bug reports — the secret must
* never travel with them.
*/
export function redactUrl(url) {
try {
const parsed = new URL(url)
parsed.username = ''
parsed.password = ''
parsed.hash = ''
const hadQuery = parsed.search !== ''
parsed.search = ''
return parsed.toString() + (hadQuery ? '?<redacted>' : '')
} catch {
return '<invalid url>'
}
}
/**
* Run the proxy over real stdio until the host closes stdin.
*/
export async function runProxy({
url,
apiKey,
version,
input = process.stdin,
output = process.stdout,
fetchImpl = fetch,
}) {
const proxy = createProxy({ url, apiKey, version, output, fetchImpl })
console.error(`RoboSystems MCP proxy v${version}`)
console.error(`Forwarding stdio <-> ${redactUrl(url)}`)
if (!apiKey) {
console.error(
'No ROBOSYSTEMS_API_KEY set — forwarding without an X-API-Key header ' +
'(fine only if the endpoint URL itself carries credentials)'
)
}
const pending = new Set()
const rl = createInterface({ input, terminal: false })
rl.on('line', (line) => {
const task = proxy.handleLine(line).finally(() => pending.delete(task))
pending.add(task)
})
await new Promise((resolve) => rl.on('close', resolve))
await Promise.allSettled([...pending])
}