@meldocio/mcp-stdio-proxy 1.0.29 → 1.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +3 -3
- package/bin/cli.js +2 -1
- package/bin/meldoc-mcp-proxy.js +178 -134
- package/lib/cli/commands.js +19 -8
- package/lib/core/auth.js +35 -16
- package/lib/core/oauth-pkce.js +279 -0
- package/lib/http/proxy.js +230 -0
- package/lib/mcp/handlers.js +16 -165
- package/lib/mcp/tools-call.js +16 -18
- package/lib/protocol/tools-schema.js +15 -173
- package/package.json +1 -1
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth 2.1 PKCE Authentication Flow
|
|
3
|
+
*
|
|
4
|
+
* Implements OAuth 2.1 authorization code flow with PKCE for agent authentication.
|
|
5
|
+
* Uses a loopback redirect URI per RFC 8252.
|
|
6
|
+
*
|
|
7
|
+
* Server endpoints:
|
|
8
|
+
* POST /mcp/oauth/register - dynamic client registration
|
|
9
|
+
* GET /mcp/oauth/authorize - authorization page (in browser)
|
|
10
|
+
* POST /mcp/oauth/token - code exchange + refresh token rotation
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const crypto = require('crypto')
|
|
14
|
+
const http = require('http')
|
|
15
|
+
const axios = require('axios')
|
|
16
|
+
const https = require('https')
|
|
17
|
+
const { URL, URLSearchParams } = require('url')
|
|
18
|
+
const { writeCredentials } = require('./credentials')
|
|
19
|
+
const { getApiUrl, getAppUrl } = require('./constants')
|
|
20
|
+
const { exec } = require('child_process')
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Generate a cryptographically random PKCE code verifier (base64url, 43-128 chars).
|
|
24
|
+
* @returns {string}
|
|
25
|
+
*/
|
|
26
|
+
function generateCodeVerifier() {
|
|
27
|
+
return crypto.randomBytes(32).toString('base64url')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Derive code challenge from verifier using S256 method.
|
|
32
|
+
* @param {string} verifier
|
|
33
|
+
* @returns {string} Base64url-encoded SHA-256 hash
|
|
34
|
+
*/
|
|
35
|
+
function generateCodeChallenge(verifier) {
|
|
36
|
+
return crypto.createHash('sha256').update(verifier).digest('base64url')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Start a local HTTP server on a random loopback port to receive the OAuth redirect.
|
|
41
|
+
* @returns {Promise<{server, port, getCode}>}
|
|
42
|
+
*/
|
|
43
|
+
function startCallbackServer() {
|
|
44
|
+
return new Promise((resolve, reject) => {
|
|
45
|
+
let resolveCode
|
|
46
|
+
const codePromise = new Promise((res) => { resolveCode = res })
|
|
47
|
+
|
|
48
|
+
const server = http.createServer((req, res) => {
|
|
49
|
+
const url = new URL(req.url, 'http://127.0.0.1')
|
|
50
|
+
const code = url.searchParams.get('code')
|
|
51
|
+
const state = url.searchParams.get('state')
|
|
52
|
+
const error = url.searchParams.get('error')
|
|
53
|
+
|
|
54
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
|
|
55
|
+
if (error) {
|
|
56
|
+
res.end('<html><body><h2>Authentication failed: ' + error + '</h2><p>You can close this tab.</p></body></html>')
|
|
57
|
+
} else {
|
|
58
|
+
res.end('<html><body><h2>Authentication successful!</h2><p>You can close this tab and return to the terminal.</p></body></html>')
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
resolveCode({ code, state, error })
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
server.listen(0, '127.0.0.1', () => {
|
|
65
|
+
resolve({
|
|
66
|
+
server,
|
|
67
|
+
port: server.address().port,
|
|
68
|
+
getCode: () => codePromise
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
server.on('error', reject)
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Open a URL in the default browser (best-effort, ignores errors).
|
|
78
|
+
* @param {string} url
|
|
79
|
+
*/
|
|
80
|
+
function openBrowser(url) {
|
|
81
|
+
let command
|
|
82
|
+
if (process.platform === 'darwin') {
|
|
83
|
+
command = `open "${url}"`
|
|
84
|
+
} else if (process.platform === 'win32') {
|
|
85
|
+
command = `start "" "${url}"`
|
|
86
|
+
} else {
|
|
87
|
+
command = `xdg-open "${url}"`
|
|
88
|
+
}
|
|
89
|
+
exec(command, () => {}) // Ignore errors
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Register a new OAuth client with the server (dynamic client registration).
|
|
94
|
+
* @param {string} apiBaseUrl
|
|
95
|
+
* @param {string} redirectUri
|
|
96
|
+
* @returns {Promise<string>} client_id
|
|
97
|
+
*/
|
|
98
|
+
async function registerClient(apiBaseUrl, redirectUri) {
|
|
99
|
+
const response = await axios.post(`${apiBaseUrl}/mcp/oauth/register`, {
|
|
100
|
+
redirect_uris: [redirectUri],
|
|
101
|
+
client_name: 'meldoc-mcp-proxy'
|
|
102
|
+
}, {
|
|
103
|
+
timeout: 10000,
|
|
104
|
+
httpsAgent: new https.Agent({ keepAlive: true })
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
if (!response.data.client_id) {
|
|
108
|
+
throw new Error('OAuth registration failed: no client_id in response')
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return response.data.client_id
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Exchange an authorization code for access + refresh tokens.
|
|
116
|
+
* @param {string} apiBaseUrl
|
|
117
|
+
* @param {string} code
|
|
118
|
+
* @param {string} codeVerifier
|
|
119
|
+
* @param {string} clientId
|
|
120
|
+
* @param {string} redirectUri
|
|
121
|
+
* @returns {Promise<{accessToken, refreshToken, expiresAt}>}
|
|
122
|
+
*/
|
|
123
|
+
async function exchangeCode(apiBaseUrl, code, codeVerifier, clientId, redirectUri) {
|
|
124
|
+
const params = new URLSearchParams({
|
|
125
|
+
grant_type: 'authorization_code',
|
|
126
|
+
code,
|
|
127
|
+
code_verifier: codeVerifier,
|
|
128
|
+
client_id: clientId,
|
|
129
|
+
redirect_uri: redirectUri
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
const response = await axios.post(`${apiBaseUrl}/mcp/oauth/token`, params.toString(), {
|
|
133
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
134
|
+
timeout: 10000,
|
|
135
|
+
httpsAgent: new https.Agent({ keepAlive: true })
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
const data = response.data
|
|
139
|
+
if (!data.access_token) {
|
|
140
|
+
throw new Error('Token exchange failed: no access_token in response')
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
accessToken: data.access_token,
|
|
145
|
+
refreshToken: data.refresh_token || null,
|
|
146
|
+
expiresAt: new Date(Date.now() + (data.expires_in || 3600) * 1000).toISOString()
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Refresh tokens using OAuth 2.1 refresh token rotation.
|
|
152
|
+
* The server issues a new refresh token on each use (old token is revoked).
|
|
153
|
+
* @param {string} apiBaseUrl
|
|
154
|
+
* @param {string} refreshToken
|
|
155
|
+
* @param {string} clientId
|
|
156
|
+
* @returns {Promise<{accessToken, refreshToken, expiresAt}>}
|
|
157
|
+
*/
|
|
158
|
+
async function refreshOAuthToken(apiBaseUrl, refreshToken, clientId) {
|
|
159
|
+
const params = new URLSearchParams({
|
|
160
|
+
grant_type: 'refresh_token',
|
|
161
|
+
refresh_token: refreshToken,
|
|
162
|
+
client_id: clientId
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
const response = await axios.post(`${apiBaseUrl}/mcp/oauth/token`, params.toString(), {
|
|
166
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
167
|
+
timeout: 10000,
|
|
168
|
+
httpsAgent: new https.Agent({ keepAlive: true })
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
const data = response.data
|
|
172
|
+
if (!data.access_token) {
|
|
173
|
+
throw new Error('Token refresh failed: no access_token in response')
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
accessToken: data.access_token,
|
|
178
|
+
refreshToken: data.refresh_token || refreshToken, // Server rotates the token
|
|
179
|
+
expiresAt: new Date(Date.now() + (data.expires_in || 3600) * 1000).toISOString()
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Perform interactive OAuth 2.1 PKCE login.
|
|
185
|
+
* Opens browser, starts loopback callback server, exchanges code for tokens.
|
|
186
|
+
* @param {Object} options
|
|
187
|
+
* @param {string} [options.apiBaseUrl]
|
|
188
|
+
* @param {string} [options.appUrl]
|
|
189
|
+
* @param {number} [options.timeout=120000]
|
|
190
|
+
* @returns {Promise<Object>} Credentials object
|
|
191
|
+
*/
|
|
192
|
+
async function pkceLogin(options = {}) {
|
|
193
|
+
const {
|
|
194
|
+
apiBaseUrl = null,
|
|
195
|
+
appUrl = null,
|
|
196
|
+
timeout = 120000
|
|
197
|
+
} = options
|
|
198
|
+
|
|
199
|
+
const apiUrl = apiBaseUrl || getApiUrl()
|
|
200
|
+
const frontendUrl = appUrl || getAppUrl()
|
|
201
|
+
|
|
202
|
+
const { server, port, getCode } = await startCallbackServer()
|
|
203
|
+
const redirectUri = `http://127.0.0.1:${port}/callback`
|
|
204
|
+
|
|
205
|
+
try {
|
|
206
|
+
process.stderr.write('Registering OAuth client...\n')
|
|
207
|
+
const clientId = await registerClient(apiUrl, redirectUri)
|
|
208
|
+
|
|
209
|
+
const codeVerifier = generateCodeVerifier()
|
|
210
|
+
const codeChallenge = generateCodeChallenge(codeVerifier)
|
|
211
|
+
const state = crypto.randomBytes(16).toString('hex')
|
|
212
|
+
|
|
213
|
+
const authParams = new URLSearchParams({
|
|
214
|
+
client_id: clientId,
|
|
215
|
+
redirect_uri: redirectUri,
|
|
216
|
+
code_challenge: codeChallenge,
|
|
217
|
+
code_challenge_method: 'S256',
|
|
218
|
+
state,
|
|
219
|
+
response_type: 'code'
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
const authUrl = `${frontendUrl}/mcp/oauth/authorize?${authParams.toString()}`
|
|
223
|
+
|
|
224
|
+
process.stderr.write('\n╔═══════════════════════════════════════════════════════╗\n')
|
|
225
|
+
process.stderr.write('║ ║\n')
|
|
226
|
+
process.stderr.write('║ 🔐 Meldoc Authentication Required ║\n')
|
|
227
|
+
process.stderr.write('║ ║\n')
|
|
228
|
+
process.stderr.write('╚═══════════════════════════════════════════════════════╝\n\n')
|
|
229
|
+
process.stderr.write(`🌐 Visit: ${authUrl}\n\n`)
|
|
230
|
+
process.stderr.write('🚀 Opening browser automatically...\n\n')
|
|
231
|
+
|
|
232
|
+
openBrowser(authUrl)
|
|
233
|
+
|
|
234
|
+
process.stderr.write('⏳ Waiting for authorization...\n\n')
|
|
235
|
+
|
|
236
|
+
const timeoutPromise = new Promise((_, reject) =>
|
|
237
|
+
setTimeout(() => reject(new Error('Authentication timeout. Please try again.')), timeout)
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
const { code, state: returnedState, error: callbackError } = await Promise.race([
|
|
241
|
+
getCode(),
|
|
242
|
+
timeoutPromise
|
|
243
|
+
])
|
|
244
|
+
|
|
245
|
+
if (callbackError) throw new Error('OAuth error: ' + callbackError)
|
|
246
|
+
if (returnedState !== state) throw new Error('OAuth state mismatch - possible CSRF attack')
|
|
247
|
+
if (!code) throw new Error('No authorization code received')
|
|
248
|
+
|
|
249
|
+
const tokens = await exchangeCode(apiUrl, code, codeVerifier, clientId, redirectUri)
|
|
250
|
+
|
|
251
|
+
const credentials = {
|
|
252
|
+
type: 'user_session',
|
|
253
|
+
auth_method: 'pkce',
|
|
254
|
+
client_id: clientId,
|
|
255
|
+
apiBaseUrl: apiUrl,
|
|
256
|
+
tokens: {
|
|
257
|
+
accessToken: tokens.accessToken,
|
|
258
|
+
accessExpiresAt: tokens.expiresAt,
|
|
259
|
+
refreshToken: tokens.refreshToken
|
|
260
|
+
},
|
|
261
|
+
updatedAt: new Date().toISOString()
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
writeCredentials(credentials)
|
|
265
|
+
|
|
266
|
+
process.stderr.write('\n✅ Successfully authenticated!\n\n')
|
|
267
|
+
|
|
268
|
+
return credentials
|
|
269
|
+
} finally {
|
|
270
|
+
server.close()
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
module.exports = {
|
|
275
|
+
pkceLogin,
|
|
276
|
+
refreshOAuthToken,
|
|
277
|
+
generateCodeVerifier,
|
|
278
|
+
generateCodeChallenge
|
|
279
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Streamable HTTP Proxy for MCP
|
|
3
|
+
*
|
|
4
|
+
* Implements MCP Streamable HTTP transport (spec 2025-03-26).
|
|
5
|
+
* Mirrors the Go implementation in meldoc.cli/cli/internal/mcp/proxy.go
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const https = require('https')
|
|
9
|
+
const http = require('http')
|
|
10
|
+
const { URL } = require('url')
|
|
11
|
+
|
|
12
|
+
const REQUEST_TIMEOUT = 60000
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* StreamableHTTPProxy forwards JSON-RPC requests from stdio to a remote MCP HTTP server.
|
|
16
|
+
*/
|
|
17
|
+
class StreamableHTTPProxy {
|
|
18
|
+
/**
|
|
19
|
+
* @param {string} remoteURL - Full URL of the MCP endpoint (e.g. https://api.meldoc.io/mcp)
|
|
20
|
+
* @param {Function} getToken - Async function that returns the auth token string (or null)
|
|
21
|
+
*/
|
|
22
|
+
constructor(remoteURL, getToken) {
|
|
23
|
+
this.remoteURL = remoteURL
|
|
24
|
+
this.getToken = getToken
|
|
25
|
+
this.sessionId = null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Send the initialize request and store the session ID.
|
|
30
|
+
* @param {*} id - Request ID
|
|
31
|
+
* @param {*} params - Initialize params (may be null)
|
|
32
|
+
* @returns {Promise<Object>} Full JSON-RPC response object (not just result)
|
|
33
|
+
*/
|
|
34
|
+
async initialize(id, params) {
|
|
35
|
+
const body = { jsonrpc: '2.0', id, method: 'initialize' }
|
|
36
|
+
if (params) body.params = params
|
|
37
|
+
|
|
38
|
+
const { response, newSessionId } = await this._doRequest(body, '')
|
|
39
|
+
|
|
40
|
+
if (newSessionId) this.sessionId = newSessionId
|
|
41
|
+
|
|
42
|
+
const text = await this._readBody(response)
|
|
43
|
+
let parsed
|
|
44
|
+
try {
|
|
45
|
+
parsed = JSON.parse(text)
|
|
46
|
+
} catch (e) {
|
|
47
|
+
throw new Error('Failed to parse initialize response: ' + text)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (parsed.error) {
|
|
51
|
+
throw new Error(`Initialize error ${parsed.error.code}: ${parsed.error.message}`)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return parsed
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Forward a JSON-RPC request to the remote server and write all responses to stdout.
|
|
59
|
+
* Handles session expiry (HTTP 404) by re-initializing and retrying once.
|
|
60
|
+
* For SSE responses each event is written as a separate newline-delimited line.
|
|
61
|
+
* @param {Object} request - JSON-RPC request object
|
|
62
|
+
*/
|
|
63
|
+
async forwardAndWrite(request) {
|
|
64
|
+
const body = {
|
|
65
|
+
jsonrpc: request.jsonrpc || '2.0',
|
|
66
|
+
id: request.id,
|
|
67
|
+
method: request.method
|
|
68
|
+
}
|
|
69
|
+
if (request.params !== undefined) body.params = request.params
|
|
70
|
+
|
|
71
|
+
let result
|
|
72
|
+
try {
|
|
73
|
+
result = await this._doRequest(body, this.sessionId || '')
|
|
74
|
+
} catch (err) {
|
|
75
|
+
if (err.statusCode === 404) {
|
|
76
|
+
// Session expired — re-initialize and retry once
|
|
77
|
+
try {
|
|
78
|
+
await this.initialize(0, null)
|
|
79
|
+
} catch (reinitErr) {
|
|
80
|
+
this._writeError(request.id, -32603, 'Session re-initialization failed: ' + reinitErr.message)
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
result = await this._doRequest(body, this.sessionId || '')
|
|
85
|
+
} catch (retryErr) {
|
|
86
|
+
this._writeError(request.id, -32603, retryErr.message)
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
} else if (err.statusCode === 401 || err.statusCode === 403) {
|
|
90
|
+
this._writeError(request.id, -32002, 'Authentication failed: ' + (err.body || 'unauthorized'))
|
|
91
|
+
return
|
|
92
|
+
} else {
|
|
93
|
+
this._writeError(request.id, -32603, err.message)
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const { response, newSessionId } = result
|
|
99
|
+
if (newSessionId) this.sessionId = newSessionId
|
|
100
|
+
|
|
101
|
+
const contentType = response.headers['content-type'] || ''
|
|
102
|
+
if (contentType.includes('text/event-stream')) {
|
|
103
|
+
await this._streamSSE(response)
|
|
104
|
+
} else {
|
|
105
|
+
const text = await this._readBody(response)
|
|
106
|
+
if (text.trim()) process.stdout.write(text.trim() + '\n')
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Perform the raw HTTP POST to the remote server.
|
|
112
|
+
* @param {Object} body - JSON body to send
|
|
113
|
+
* @param {string} sessionId - Session ID header value (empty string to omit)
|
|
114
|
+
* @returns {Promise<{response: IncomingMessage, newSessionId: string}>}
|
|
115
|
+
*/
|
|
116
|
+
async _doRequest(body, sessionId) {
|
|
117
|
+
const token = await this.getToken()
|
|
118
|
+
const data = JSON.stringify(body)
|
|
119
|
+
const url = new URL(this.remoteURL)
|
|
120
|
+
const isHttps = url.protocol === 'https:'
|
|
121
|
+
const port = url.port ? parseInt(url.port) : (isHttps ? 443 : 80)
|
|
122
|
+
|
|
123
|
+
const headers = {
|
|
124
|
+
'Content-Type': 'application/json',
|
|
125
|
+
'Accept': 'application/json, text/event-stream',
|
|
126
|
+
'Content-Length': Buffer.byteLength(data)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (token) headers['Authorization'] = 'Bearer ' + token
|
|
130
|
+
if (sessionId) headers['Mcp-Session-Id'] = sessionId
|
|
131
|
+
|
|
132
|
+
return new Promise((resolve, reject) => {
|
|
133
|
+
const options = {
|
|
134
|
+
hostname: url.hostname,
|
|
135
|
+
port,
|
|
136
|
+
path: url.pathname + (url.search || ''),
|
|
137
|
+
method: 'POST',
|
|
138
|
+
headers,
|
|
139
|
+
timeout: REQUEST_TIMEOUT
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const mod = isHttps ? https : http
|
|
143
|
+
const req = mod.request(options, (res) => {
|
|
144
|
+
const newSessionId = res.headers['mcp-session-id'] || ''
|
|
145
|
+
|
|
146
|
+
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
147
|
+
let errBody = ''
|
|
148
|
+
res.on('data', (chunk) => { errBody += chunk })
|
|
149
|
+
res.on('end', () => {
|
|
150
|
+
const err = new Error(`HTTP ${res.statusCode}: ${errBody.trim()}`)
|
|
151
|
+
err.statusCode = res.statusCode
|
|
152
|
+
err.body = errBody.trim()
|
|
153
|
+
reject(err)
|
|
154
|
+
})
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
resolve({ response: res, newSessionId })
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
req.on('timeout', () => {
|
|
162
|
+
req.destroy()
|
|
163
|
+
const err = new Error('Request timeout')
|
|
164
|
+
err.statusCode = 408
|
|
165
|
+
reject(err)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
req.on('error', reject)
|
|
169
|
+
|
|
170
|
+
req.write(data)
|
|
171
|
+
req.end()
|
|
172
|
+
})
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Stream an SSE response, writing each data event as a line to stdout.
|
|
177
|
+
* @param {IncomingMessage} response
|
|
178
|
+
*/
|
|
179
|
+
_streamSSE(response) {
|
|
180
|
+
return new Promise((resolve, reject) => {
|
|
181
|
+
let buffer = ''
|
|
182
|
+
|
|
183
|
+
response.on('data', (chunk) => {
|
|
184
|
+
buffer += chunk.toString()
|
|
185
|
+
const lines = buffer.split('\n')
|
|
186
|
+
buffer = lines.pop() // Keep incomplete last line
|
|
187
|
+
|
|
188
|
+
for (const line of lines) {
|
|
189
|
+
if (line.startsWith('data: ')) {
|
|
190
|
+
const data = line.slice(6)
|
|
191
|
+
if (data && data !== '[DONE]') process.stdout.write(data + '\n')
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
response.on('end', () => {
|
|
197
|
+
if (buffer.startsWith('data: ')) {
|
|
198
|
+
const data = buffer.slice(6)
|
|
199
|
+
if (data && data !== '[DONE]') process.stdout.write(data + '\n')
|
|
200
|
+
}
|
|
201
|
+
resolve()
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
response.on('error', reject)
|
|
205
|
+
})
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Read full response body as a string.
|
|
210
|
+
* @param {IncomingMessage} response
|
|
211
|
+
* @returns {Promise<string>}
|
|
212
|
+
*/
|
|
213
|
+
_readBody(response) {
|
|
214
|
+
return new Promise((resolve, reject) => {
|
|
215
|
+
let body = ''
|
|
216
|
+
response.on('data', (chunk) => { body += chunk })
|
|
217
|
+
response.on('end', () => resolve(body))
|
|
218
|
+
response.on('error', reject)
|
|
219
|
+
})
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Write a JSON-RPC error to stdout.
|
|
224
|
+
*/
|
|
225
|
+
_writeError(id, code, message) {
|
|
226
|
+
process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id, error: { code, message } }) + '\n')
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
module.exports = { StreamableHTTPProxy }
|
package/lib/mcp/handlers.js
CHANGED
|
@@ -1,81 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* MCP Protocol Method Handlers
|
|
3
3
|
*
|
|
4
|
-
* Handles MCP protocol methods
|
|
5
|
-
*
|
|
4
|
+
* Handles only the minimal set of MCP protocol methods that must be
|
|
5
|
+
* processed locally (notifications and ping).
|
|
6
|
+
* initialize, tools/list and all other methods are forwarded to the server.
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
|
-
const { sendResponse
|
|
9
|
-
const { getToolsList } = require('../protocol/tools-schema');
|
|
10
|
-
const { JSON_RPC_ERROR_CODES } = require('../protocol/error-codes');
|
|
11
|
-
const { LOG_LEVELS, MCP_PROTOCOL_VERSION, SERVER_CAPABILITIES } = require('../core/constants');
|
|
9
|
+
const { sendResponse } = require('../protocol/json-rpc');
|
|
12
10
|
|
|
13
11
|
/**
|
|
14
|
-
*
|
|
15
|
-
*/
|
|
16
|
-
function getLogLevel() {
|
|
17
|
-
const level = (process.env.LOG_LEVEL || 'ERROR').toUpperCase();
|
|
18
|
-
return LOG_LEVELS[level] !== undefined ? LOG_LEVELS[level] : LOG_LEVELS.ERROR;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
const LOG_LEVEL = getLogLevel();
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Log message
|
|
25
|
-
*/
|
|
26
|
-
function log(level, message) {
|
|
27
|
-
if (LOG_LEVEL >= level) {
|
|
28
|
-
const levelName = Object.keys(LOG_LEVELS)[level] || 'UNKNOWN';
|
|
29
|
-
process.stderr.write(`[${levelName}] ${message}\n`);
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Get package info
|
|
35
|
-
*/
|
|
36
|
-
function getPackageInfo() {
|
|
37
|
-
try {
|
|
38
|
-
const pkg = require('../../package.json');
|
|
39
|
-
return { name: pkg.name, version: pkg.version };
|
|
40
|
-
} catch (error) {
|
|
41
|
-
return { name: '@meldocio/mcp-stdio-proxy', version: '1.0.0' };
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Handle MCP initialize method
|
|
47
|
-
* @param {Object} request - JSON-RPC request
|
|
48
|
-
*/
|
|
49
|
-
function handleInitialize(request) {
|
|
50
|
-
const pkg = getPackageInfo();
|
|
51
|
-
|
|
52
|
-
const result = {
|
|
53
|
-
protocolVersion: MCP_PROTOCOL_VERSION,
|
|
54
|
-
capabilities: {
|
|
55
|
-
tools: SERVER_CAPABILITIES.TOOLS ? {} : undefined,
|
|
56
|
-
resources: SERVER_CAPABILITIES.RESOURCES ? {} : undefined,
|
|
57
|
-
prompts: SERVER_CAPABILITIES.PROMPTS ? {} : undefined,
|
|
58
|
-
logging: SERVER_CAPABILITIES.LOGGING ? {} : undefined
|
|
59
|
-
},
|
|
60
|
-
serverInfo: {
|
|
61
|
-
name: pkg.name,
|
|
62
|
-
version: pkg.version
|
|
63
|
-
}
|
|
64
|
-
};
|
|
65
|
-
|
|
66
|
-
// Remove undefined capabilities
|
|
67
|
-
Object.keys(result.capabilities).forEach(key => {
|
|
68
|
-
if (result.capabilities[key] === undefined) {
|
|
69
|
-
delete result.capabilities[key];
|
|
70
|
-
}
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
log(LOG_LEVELS.DEBUG, 'Initialize request received');
|
|
74
|
-
sendResponse(request.id, result);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* Handle MCP ping method (keep-alive)
|
|
12
|
+
* Handle MCP ping method (keep-alive).
|
|
79
13
|
* @param {Object} request - JSON-RPC request
|
|
80
14
|
*/
|
|
81
15
|
function handlePing(request) {
|
|
@@ -83,103 +17,20 @@ function handlePing(request) {
|
|
|
83
17
|
}
|
|
84
18
|
|
|
85
19
|
/**
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
* @
|
|
20
|
+
* Check if a method is a notification that requires no response.
|
|
21
|
+
* @param {string} method
|
|
22
|
+
* @returns {boolean}
|
|
89
23
|
*/
|
|
90
|
-
function
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
* Handle tools/list method
|
|
98
|
-
* Always returns static list locally, never proxies to backend
|
|
99
|
-
* @param {Object} request - JSON-RPC request
|
|
100
|
-
*/
|
|
101
|
-
function handleToolsList(request) {
|
|
102
|
-
try {
|
|
103
|
-
const tools = getToolsList();
|
|
104
|
-
|
|
105
|
-
// Log tool names for debugging
|
|
106
|
-
const toolNames = tools.map(t => t.name).join(', ');
|
|
107
|
-
log(LOG_LEVELS.INFO, `Returning ${tools.length} tools locally: ${toolNames}`);
|
|
108
|
-
|
|
109
|
-
sendResponse(request.id, {
|
|
110
|
-
tools: tools
|
|
111
|
-
});
|
|
112
|
-
} catch (error) {
|
|
113
|
-
// This should never happen, but if it does, send error response
|
|
114
|
-
log(LOG_LEVELS.ERROR, `Unexpected error in handleToolsList: ${error.message || 'Unknown error'}`);
|
|
115
|
-
log(LOG_LEVELS.DEBUG, `Error stack: ${error.stack || 'No stack trace'}`);
|
|
116
|
-
|
|
117
|
-
sendError(request.id, JSON_RPC_ERROR_CODES.INTERNAL_ERROR,
|
|
118
|
-
`Failed to get tools list: ${error.message || 'Unknown error'}`, {
|
|
119
|
-
code: 'INTERNAL_ERROR'
|
|
120
|
-
});
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* Check if a method should be handled locally
|
|
126
|
-
* @param {string} method - Method name
|
|
127
|
-
* @returns {boolean} True if should be handled locally
|
|
128
|
-
*/
|
|
129
|
-
function isLocalMethod(method) {
|
|
130
|
-
const localMethods = [
|
|
131
|
-
'initialize',
|
|
132
|
-
'initialized',
|
|
133
|
-
'notifications/initialized',
|
|
134
|
-
'notifications/cancelled',
|
|
135
|
-
'ping',
|
|
136
|
-
'resources/list',
|
|
137
|
-
'tools/list'
|
|
138
|
-
];
|
|
139
|
-
return localMethods.includes(method);
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* Route request to appropriate local handler
|
|
144
|
-
* @param {Object} request - JSON-RPC request
|
|
145
|
-
* @returns {boolean} True if handled, false if should be proxied
|
|
146
|
-
*/
|
|
147
|
-
function handleLocalMethod(request) {
|
|
148
|
-
const method = request.method;
|
|
149
|
-
|
|
150
|
-
switch (method) {
|
|
151
|
-
case 'initialize':
|
|
152
|
-
handleInitialize(request);
|
|
153
|
-
return true;
|
|
154
|
-
|
|
155
|
-
case 'initialized':
|
|
156
|
-
case 'notifications/initialized':
|
|
157
|
-
case 'notifications/cancelled':
|
|
158
|
-
// Notifications - no response needed
|
|
159
|
-
return true;
|
|
160
|
-
|
|
161
|
-
case 'ping':
|
|
162
|
-
handlePing(request);
|
|
163
|
-
return true;
|
|
164
|
-
|
|
165
|
-
case 'resources/list':
|
|
166
|
-
handleResourcesList(request);
|
|
167
|
-
return true;
|
|
168
|
-
|
|
169
|
-
case 'tools/list':
|
|
170
|
-
handleToolsList(request);
|
|
171
|
-
return true;
|
|
172
|
-
|
|
173
|
-
default:
|
|
174
|
-
return false; // Not a local method
|
|
175
|
-
}
|
|
24
|
+
function isNotification(method) {
|
|
25
|
+
return (
|
|
26
|
+
method === 'initialized' ||
|
|
27
|
+
method === 'notifications/initialized' ||
|
|
28
|
+
method === 'notifications/cancelled' ||
|
|
29
|
+
method === 'notifications/progress'
|
|
30
|
+
);
|
|
176
31
|
}
|
|
177
32
|
|
|
178
33
|
module.exports = {
|
|
179
|
-
handleInitialize,
|
|
180
34
|
handlePing,
|
|
181
|
-
|
|
182
|
-
handleToolsList,
|
|
183
|
-
isLocalMethod,
|
|
184
|
-
handleLocalMethod
|
|
35
|
+
isNotification
|
|
185
36
|
};
|