@calibress/curl-mcp-bridge 0.1.0
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 +79 -0
- package/bin/curl-mcp-bridge.js +312 -0
- package/package.json +28 -0
package/README.md
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# curl-mcp-bridge
|
|
2
|
+
|
|
3
|
+
A local MCP `stdio` bridge that proxies MCP JSON-RPC to a Calibress hosted `/mcp` HTTP endpoint.
|
|
4
|
+
|
|
5
|
+
This lets any MCP client that supports `stdio` (Claude Desktop, Roo, etc.) talk to the hosted multi-tenant MCP gateway.
|
|
6
|
+
|
|
7
|
+
## Requirements
|
|
8
|
+
|
|
9
|
+
- Node.js >= 18
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
Set these env vars:
|
|
14
|
+
|
|
15
|
+
- `CALIBRESS_ENDPOINT` (required) — Hosted MCP URL (e.g. `https://enterprise-plan.dev.calibress.com/mcp`)
|
|
16
|
+
- `CALIBRESS_API_KEY` (required) — Tenant/project API key (Bearer). Prefer project-scoped keys so “project selection” is implicit.
|
|
17
|
+
|
|
18
|
+
Then run:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
CALIBRESS_ENDPOINT="https://enterprise-plan.dev.calibress.com/mcp" \
|
|
22
|
+
CALIBRESS_API_KEY="cmcp_..." \
|
|
23
|
+
node ./bin/curl-mcp-bridge.js
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
CLI overrides are also available (`--endpoint`, `--api-key`, `--session-id`).
|
|
27
|
+
|
|
28
|
+
The bridge reads MCP messages from stdin and writes MCP responses to stdout (1 JSON object per line).
|
|
29
|
+
|
|
30
|
+
## Run via npx (npm)
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
CALIBRESS_ENDPOINT="https://enterprise-plan.dev.calibress.com/mcp" \
|
|
34
|
+
CALIBRESS_API_KEY="cmcp_..." \
|
|
35
|
+
npx -y @calibress/curl-mcp-bridge
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
If you need to run an unpublished build, you can still run from GitHub:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
CALIBRESS_ENDPOINT="https://enterprise-plan.dev.calibress.com/mcp" \
|
|
42
|
+
CALIBRESS_API_KEY="cmcp_..." \
|
|
43
|
+
npx -y github:calibress/curl-mcp-bridge
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## MCP client configuration
|
|
47
|
+
|
|
48
|
+
Example (Roo / Claude Desktop style):
|
|
49
|
+
|
|
50
|
+
```json
|
|
51
|
+
{
|
|
52
|
+
"curl-mcp-enterprise-plan": {
|
|
53
|
+
"transport": "stdio",
|
|
54
|
+
"command": "node",
|
|
55
|
+
"args": ["/ABS/PATH/TO/curl-mcp-bridge/bin/curl-mcp-bridge.js"],
|
|
56
|
+
"env": {
|
|
57
|
+
"CALIBRESS_ENDPOINT": "https://enterprise-plan.dev.calibress.com/mcp",
|
|
58
|
+
"CALIBRESS_API_KEY": "cmcp_REPLACE_ME"
|
|
59
|
+
},
|
|
60
|
+
"alwaysAllow": ["curl_request", "mcp_version"]
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Session handling
|
|
66
|
+
|
|
67
|
+
- The hosted MCP endpoint issues `Mcp-Session-Id` on `initialize`.
|
|
68
|
+
- The bridge caches that session id in-process and automatically sends it on subsequent requests.
|
|
69
|
+
- If a client sends a request before `initialize`, the bridge will bootstrap a session and retry once.
|
|
70
|
+
|
|
71
|
+
## Extensibility
|
|
72
|
+
|
|
73
|
+
The bridge forwards all JSON-RPC methods as-is.
|
|
74
|
+
|
|
75
|
+
When the hosted MCP gateway adds new tools (e.g. `list_connections`), existing clients automatically see them via `tools/list` without changing the bridge.
|
|
76
|
+
|
|
77
|
+
## Notes
|
|
78
|
+
|
|
79
|
+
- To publish, bump `package.json` version and run `npm publish` (public access is configured).
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import process from 'node:process'
|
|
4
|
+
import readline from 'node:readline'
|
|
5
|
+
import { createRequire } from 'node:module'
|
|
6
|
+
|
|
7
|
+
const require = createRequire(import.meta.url)
|
|
8
|
+
const { name: PACKAGE_NAME, version: PACKAGE_VERSION } = require('../package.json')
|
|
9
|
+
|
|
10
|
+
function parseArgs(argv) {
|
|
11
|
+
const args = {
|
|
12
|
+
endpoint: undefined,
|
|
13
|
+
apiKey: undefined,
|
|
14
|
+
sessionId: undefined,
|
|
15
|
+
verbose: false,
|
|
16
|
+
help: false,
|
|
17
|
+
version: false,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
21
|
+
const current = argv[i]
|
|
22
|
+
|
|
23
|
+
if (current === '--help' || current === '-h') {
|
|
24
|
+
args.help = true
|
|
25
|
+
continue
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (current === '--version' || current === '-v') {
|
|
29
|
+
args.version = true
|
|
30
|
+
continue
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (current === '--verbose') {
|
|
34
|
+
args.verbose = true
|
|
35
|
+
continue
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const [key, valueFromEquals] = current.split('=', 2)
|
|
39
|
+
const valueFromNext = argv[i + 1]
|
|
40
|
+
|
|
41
|
+
if (key === '--endpoint') {
|
|
42
|
+
args.endpoint = valueFromEquals ?? valueFromNext
|
|
43
|
+
if (valueFromEquals === undefined) i += 1
|
|
44
|
+
continue
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (key === '--api-key') {
|
|
48
|
+
args.apiKey = valueFromEquals ?? valueFromNext
|
|
49
|
+
if (valueFromEquals === undefined) i += 1
|
|
50
|
+
continue
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (key === '--session-id') {
|
|
54
|
+
args.sessionId = valueFromEquals ?? valueFromNext
|
|
55
|
+
if (valueFromEquals === undefined) i += 1
|
|
56
|
+
continue
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return args
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function printHelp() {
|
|
64
|
+
process.stderr.write(`${PACKAGE_NAME} ${PACKAGE_VERSION}\n\n`)
|
|
65
|
+
process.stderr.write(`Usage:\n`)
|
|
66
|
+
process.stderr.write(` CALIBRESS_ENDPOINT=... CALIBRESS_API_KEY=... curl-mcp-bridge\n\n`)
|
|
67
|
+
process.stderr.write(`Options:\n`)
|
|
68
|
+
process.stderr.write(` --endpoint <url> Override CALIBRESS_ENDPOINT\n`)
|
|
69
|
+
process.stderr.write(` --api-key <key> Override CALIBRESS_API_KEY\n`)
|
|
70
|
+
process.stderr.write(` --session-id <id> Force Mcp-Session-Id (debug)\n`)
|
|
71
|
+
process.stderr.write(` --verbose Log non-sensitive diagnostics to stderr\n`)
|
|
72
|
+
process.stderr.write(` --help Show help\n`)
|
|
73
|
+
process.stderr.write(` --version Show version\n`)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function jsonRpcError(id, code, message) {
|
|
77
|
+
return {
|
|
78
|
+
jsonrpc: '2.0',
|
|
79
|
+
id: id ?? null,
|
|
80
|
+
error: {
|
|
81
|
+
code,
|
|
82
|
+
message,
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function normalizeContentItem(item) {
|
|
88
|
+
if (!item || typeof item !== 'object') return item
|
|
89
|
+
if (item.type !== 'json') return item
|
|
90
|
+
|
|
91
|
+
const value = Object.prototype.hasOwnProperty.call(item, 'data')
|
|
92
|
+
? item.data
|
|
93
|
+
: Object.prototype.hasOwnProperty.call(item, 'json')
|
|
94
|
+
? item.json
|
|
95
|
+
: item
|
|
96
|
+
|
|
97
|
+
const text = typeof value === 'string' ? value : JSON.stringify(value, null, 2)
|
|
98
|
+
return { type: 'text', text }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function normalizeMcpPayload(payload) {
|
|
102
|
+
const normalizeOne = (message) => {
|
|
103
|
+
if (!message || typeof message !== 'object') return message
|
|
104
|
+
if (!message.result || typeof message.result !== 'object') return message
|
|
105
|
+
if (!Array.isArray(message.result.content)) return message
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
...message,
|
|
109
|
+
result: {
|
|
110
|
+
...message.result,
|
|
111
|
+
content: message.result.content.map(normalizeContentItem),
|
|
112
|
+
},
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return Array.isArray(payload) ? payload.map(normalizeOne) : normalizeOne(payload)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function safeStringify(value) {
|
|
120
|
+
return JSON.stringify(value)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function logVerbose(enabled, message) {
|
|
124
|
+
if (!enabled) return
|
|
125
|
+
process.stderr.write(`[curl-mcp-bridge] ${message}\n`)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const args = parseArgs(process.argv.slice(2))
|
|
129
|
+
|
|
130
|
+
if (args.help) {
|
|
131
|
+
printHelp()
|
|
132
|
+
process.exit(0)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (args.version) {
|
|
136
|
+
process.stdout.write(`${PACKAGE_VERSION}\n`)
|
|
137
|
+
process.exit(0)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const endpoint =
|
|
141
|
+
args.endpoint ??
|
|
142
|
+
process.env.CALIBRESS_ENDPOINT ??
|
|
143
|
+
process.env.CALIBRESS_MCP_ENDPOINT
|
|
144
|
+
|
|
145
|
+
const apiKey =
|
|
146
|
+
args.apiKey ??
|
|
147
|
+
process.env.CALIBRESS_API_KEY ??
|
|
148
|
+
process.env.CALIBRESS_MCP_API_KEY
|
|
149
|
+
|
|
150
|
+
if (!endpoint) {
|
|
151
|
+
process.stderr.write('Missing CALIBRESS_ENDPOINT\n')
|
|
152
|
+
process.exit(1)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (!apiKey) {
|
|
156
|
+
process.stderr.write('Missing CALIBRESS_API_KEY\n')
|
|
157
|
+
process.exit(1)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
let sessionId =
|
|
161
|
+
args.sessionId ??
|
|
162
|
+
process.env.CALIBRESS_MCP_SESSION_ID ??
|
|
163
|
+
undefined
|
|
164
|
+
|
|
165
|
+
async function postJson(body, extraHeaders) {
|
|
166
|
+
const headers = {
|
|
167
|
+
Authorization: `Bearer ${apiKey}`,
|
|
168
|
+
'Content-Type': 'application/json',
|
|
169
|
+
Accept: 'application/json, text/event-stream',
|
|
170
|
+
...extraHeaders,
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const response = await fetch(endpoint, {
|
|
174
|
+
method: 'POST',
|
|
175
|
+
headers,
|
|
176
|
+
body: safeStringify(body),
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
const nextSessionId =
|
|
180
|
+
response.headers.get('mcp-session-id') ??
|
|
181
|
+
response.headers.get('Mcp-Session-Id')
|
|
182
|
+
|
|
183
|
+
if (nextSessionId) sessionId = nextSessionId
|
|
184
|
+
|
|
185
|
+
const text = await response.text()
|
|
186
|
+
if (!text) return null
|
|
187
|
+
|
|
188
|
+
try {
|
|
189
|
+
const parsed = JSON.parse(text)
|
|
190
|
+
return normalizeMcpPayload(parsed)
|
|
191
|
+
} catch {
|
|
192
|
+
const contentType = response.headers.get('content-type') ?? 'unknown'
|
|
193
|
+
const ngrokError = response.headers.get('ngrok-error-code')
|
|
194
|
+
const snippet = text.trim().slice(0, 160).replaceAll(/\s+/g, ' ')
|
|
195
|
+
const details = [
|
|
196
|
+
`HTTP ${response.status}`,
|
|
197
|
+
`content-type=${contentType}`,
|
|
198
|
+
ngrokError ? `ngrok-error-code=${ngrokError}` : null,
|
|
199
|
+
]
|
|
200
|
+
.filter(Boolean)
|
|
201
|
+
.join(', ')
|
|
202
|
+
|
|
203
|
+
return jsonRpcError(
|
|
204
|
+
body?.id ?? null,
|
|
205
|
+
-32000,
|
|
206
|
+
`Upstream returned non-JSON response (${details}). Body: ${snippet}`
|
|
207
|
+
)
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function shouldBootstrapFromError(payload) {
|
|
212
|
+
const message = payload?.error?.message
|
|
213
|
+
if (typeof message !== 'string') return false
|
|
214
|
+
|
|
215
|
+
if (message.includes('Mcp-Session-Id') && message.includes('required')) return true
|
|
216
|
+
if (message.toLowerCase().includes('server not initialized')) return true
|
|
217
|
+
|
|
218
|
+
return false
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function bootstrapSession() {
|
|
222
|
+
const initRequest = {
|
|
223
|
+
jsonrpc: '2.0',
|
|
224
|
+
id: 'bridge-init',
|
|
225
|
+
method: 'initialize',
|
|
226
|
+
params: {
|
|
227
|
+
protocolVersion: '2024-11-05',
|
|
228
|
+
capabilities: {},
|
|
229
|
+
clientInfo: {
|
|
230
|
+
name: PACKAGE_NAME,
|
|
231
|
+
version: PACKAGE_VERSION,
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const payload = await postJson(initRequest, {})
|
|
237
|
+
|
|
238
|
+
if (!sessionId) {
|
|
239
|
+
const message = payload?.error?.message
|
|
240
|
+
throw new Error(
|
|
241
|
+
typeof message === 'string'
|
|
242
|
+
? `Failed to establish session: ${message}`
|
|
243
|
+
: 'Failed to establish session'
|
|
244
|
+
)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return payload
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function proxyMessage(message) {
|
|
251
|
+
const headers = {}
|
|
252
|
+
|
|
253
|
+
if (sessionId) {
|
|
254
|
+
headers['Mcp-Session-Id'] = sessionId
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const payload = await postJson(message, headers)
|
|
258
|
+
|
|
259
|
+
if (payload && shouldBootstrapFromError(payload)) {
|
|
260
|
+
logVerbose(args.verbose, 'Bootstrapping MCP session and retrying request')
|
|
261
|
+
await bootstrapSession()
|
|
262
|
+
|
|
263
|
+
const retryHeaders = {}
|
|
264
|
+
if (sessionId) retryHeaders['Mcp-Session-Id'] = sessionId
|
|
265
|
+
|
|
266
|
+
return postJson(message, retryHeaders)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return payload
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function writeMessage(message) {
|
|
273
|
+
if (message === null || message === undefined) return
|
|
274
|
+
process.stdout.write(`${safeStringify(message)}\n`)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function main() {
|
|
278
|
+
const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity })
|
|
279
|
+
for await (const line of rl) {
|
|
280
|
+
const trimmed = line.trim()
|
|
281
|
+
if (!trimmed) continue
|
|
282
|
+
|
|
283
|
+
let parsed
|
|
284
|
+
try {
|
|
285
|
+
parsed = JSON.parse(trimmed)
|
|
286
|
+
} catch {
|
|
287
|
+
writeMessage(jsonRpcError(null, -32700, 'Parse error'))
|
|
288
|
+
continue
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
try {
|
|
292
|
+
const response = await proxyMessage(parsed)
|
|
293
|
+
writeMessage(response)
|
|
294
|
+
} catch (error) {
|
|
295
|
+
const message =
|
|
296
|
+
error instanceof Error && typeof error.message === 'string'
|
|
297
|
+
? error.message
|
|
298
|
+
: 'Upstream request failed'
|
|
299
|
+
|
|
300
|
+
writeMessage(jsonRpcError(parsed?.id ?? null, -32000, message))
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
main().catch((error) => {
|
|
306
|
+
const message =
|
|
307
|
+
error instanceof Error && typeof error.message === 'string'
|
|
308
|
+
? error.message
|
|
309
|
+
: 'Bridge failed'
|
|
310
|
+
process.stderr.write(`[curl-mcp-bridge] ${message}\n`)
|
|
311
|
+
process.exit(1)
|
|
312
|
+
})
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@calibress/curl-mcp-bridge",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Local MCP stdio bridge that proxies to Calibress hosted /mcp over HTTP",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"mcp",
|
|
8
|
+
"stdio",
|
|
9
|
+
"bridge",
|
|
10
|
+
"calibress"
|
|
11
|
+
],
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/calibress/curl-mcp-bridge.git"
|
|
15
|
+
},
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public"
|
|
18
|
+
},
|
|
19
|
+
"bin": {
|
|
20
|
+
"curl-mcp-bridge": "bin/curl-mcp-bridge.js"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"start": "node ./bin/curl-mcp-bridge.js"
|
|
24
|
+
},
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=18"
|
|
27
|
+
}
|
|
28
|
+
}
|