thincoder 0.6.0 → 0.7.1

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/src/mcp.mjs CHANGED
@@ -1,13 +1,15 @@
1
1
  /**
2
2
  * mcp.mjs — MCP (Model Context Protocol) client
3
- * 零依赖:stdio transport (spawn + JSON-RPC) + HTTP transport (fetch + SSE)。
4
- * config: { command, args?, name } 或 { url, name, headers? }
3
+ * 零依赖:stdio transport (spawn + JSON-RPC) + HTTP transport (fetch + SSE) + WebSocket transport (global WebSocket)
4
+ * config: { command, args?, name } 或 { url, name, headers? } 或 { wsUrl, name, headers? }
5
5
  */
6
6
 
7
7
  import { spawn } from "node:child_process"
8
8
 
9
9
  const INIT_TIMEOUT_MS = 30_000
10
10
  const CALL_TIMEOUT_MS = 120_000
11
+ // 等 legacy SSE 首个 endpoint 事件的上限:legacy server 连接后立即发,实际几毫秒内到达
12
+ const ENDPOINT_WAIT_MS = 5_000
11
13
 
12
14
  // ---- JSON-RPC helpers ----
13
15
 
@@ -32,6 +34,7 @@ function stdioTransport(command, args) {
32
34
  : spawn(command, args ?? [], spawnOptions)
33
35
 
34
36
  const pending = new Map()
37
+ const decoder = new TextDecoder() // 单一实例:跨 chunk 保留多字节 UTF-8 的中间状态
35
38
  let buffer = ""
36
39
  let stderrTail = "" // 诊断用:server 起不来时给用户一点线索
37
40
  let spawnError = null
@@ -43,7 +46,8 @@ function stdioTransport(command, args) {
43
46
  }
44
47
 
45
48
  child.stdout.on("data", (chunk) => {
46
- buffer += typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk)
49
+ // stream:true:多字节字符跨 chunk 拆分时暂存残片,等下一个 chunk 拼完整
50
+ buffer += typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true })
47
51
  const lines = buffer.split("\n")
48
52
  buffer = lines.pop() ?? ""
49
53
  for (const line of lines) {
@@ -65,6 +69,9 @@ function stdioTransport(command, args) {
65
69
  stderrTail = (stderrTail + chunk.toString()).slice(-2000)
66
70
  })
67
71
 
72
+ // stdin 写错误(EPIPE 等):没有这个监听,error 事件会崩掉整个进程;close 事件统一兜底
73
+ child.stdin.on("error", () => {})
74
+
68
75
  // spawn 失败(命令不存在/EINVAL):没有这个监听,error 事件会崩掉整个进程
69
76
  child.on("error", (error) => {
70
77
  spawnError = error
@@ -83,13 +90,21 @@ function stdioTransport(command, args) {
83
90
  if (closed) return Promise.reject(new Error("MCP connection closed"))
84
91
  const id = rpcId()
85
92
  const promise = new Promise((resolve) => pending.set(id, resolve))
86
- child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n")
93
+ try {
94
+ child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n")
95
+ } catch (error) {
96
+ pending.delete(id)
97
+ return Promise.resolve({ id: null, error: { code: -32000, message: `stdin write failed: ${error.message}` } })
98
+ }
87
99
  return withTimeout(promise, CALL_TIMEOUT_MS).finally(() => pending.delete(id))
88
100
  }
89
101
 
90
102
  // notification:无 id,不期待响应(协议要求)
91
103
  const notify = (method, params) => {
92
- if (!closed) child.stdin.write(JSON.stringify({ jsonrpc: "2.0", method, params }) + "\n")
104
+ if (closed) return
105
+ try {
106
+ child.stdin.write(JSON.stringify({ jsonrpc: "2.0", method, params }) + "\n")
107
+ } catch { /* 忽略:close 事件会兜底 */ }
93
108
  }
94
109
 
95
110
  return { send, notify, close: () => { if (!closed) child.kill() } }
@@ -103,6 +118,12 @@ function httpTransport(baseURL, extraHeaders = {}) {
103
118
  let closed = false
104
119
  let eventSource = null
105
120
  let abortController = null
121
+ // legacy SSE (2024-11-05):POST 地址由 server 的 endpoint 事件告知;
122
+ // Streamable HTTP (2025-03-26):POST 到配置的 URL 本身
123
+ let postUrl = url
124
+ // 收到 endpoint 事件才置真:legacy SSE (2024-11-05) 模式,POST 只回 202,响应经 SSE 流推回;
125
+ // 否则按 Streamable HTTP (2025-03-26):响应就在 POST 自身(即使 server 同时支持 GET 推送)
126
+ let legacySSE = false
106
127
 
107
128
  const headers = () => {
108
129
  const h = { "Content-Type": "application/json", Accept: "text/event-stream, application/json", ...extraHeaders }
@@ -112,7 +133,7 @@ function httpTransport(baseURL, extraHeaders = {}) {
112
133
 
113
134
  const pending = new Map()
114
135
 
115
- // SSE 解析器:从 response body 逐行读,处理 data: / event: / id: / 空行(dispatch)
136
+ // SSE 解析器:从 response body 逐行读,处理 data: / event: / 空行(dispatch)
116
137
  async function* parseSSE(response) {
117
138
  const reader = response.body.getReader()
118
139
  const decoder = new TextDecoder()
@@ -144,24 +165,34 @@ function httpTransport(baseURL, extraHeaders = {}) {
144
165
  }
145
166
  }
146
167
 
147
- // 打开 SSE 长连接(用于接收服务端推送)
168
+ // 打开 SSE 长连接(legacy SSE transport,用于接收服务端推送)
169
+ // 按 2024-11-05 规范:GET 配置的 URL 本身,server 的第一个 endpoint 事件告知 POST 地址
148
170
  async function openSSE() {
149
171
  if (closed) return
150
172
  abortController?.abort()
151
173
  abortController = new AbortController()
152
- const resp = await fetch(url + "/sse", {
174
+ const resp = await fetch(url, {
153
175
  method: "GET",
154
- headers: { Accept: "text/event-stream" },
176
+ headers: { Accept: "text/event-stream", ...extraHeaders },
155
177
  signal: abortController.signal,
156
178
  })
157
179
  if (!resp.ok) throw new Error(`SSE connect failed: HTTP ${resp.status}`)
158
180
  eventSource = parseSSE(resp)
181
+ let endpointReady
182
+ const gotEndpoint = new Promise((resolve) => { endpointReady = resolve })
159
183
 
160
184
  // 后台消费 SSE 事件并分发到 pending
161
185
  ;(async () => {
162
186
  try {
163
- for await (const { data } of eventSource) {
187
+ for await (const { event, data } of eventSource) {
164
188
  if (closed) break
189
+ if (event === "endpoint") {
190
+ // data 是相对/绝对 URI,拼到配置的 URL 上作为 POST 地址
191
+ postUrl = new URL(data.trim(), url).href
192
+ legacySSE = true
193
+ endpointReady()
194
+ continue
195
+ }
165
196
  try {
166
197
  const msg = JSON.parse(data)
167
198
  const resolver = pending.get(msg.id)
@@ -175,9 +206,18 @@ function httpTransport(baseURL, extraHeaders = {}) {
175
206
  } catch (error) {
176
207
  if (!closed) {
177
208
  for (const [, resolve] of pending) resolve({ id: null, error: { code: -32000, message: `SSE error: ${error.message}` } })
209
+ pending.clear()
178
210
  }
179
211
  }
180
212
  })()
213
+
214
+ // 等 endpoint 事件再发请求(2024-11-05 要求拿到 POST 地址后才能 POST);
215
+ // 超时说明不是 legacy server——Streamable HTTP 的 GET 流只推服务端消息,响应走 POST 自身
216
+ const wait = new Promise((resolve) => {
217
+ const t = setTimeout(resolve, ENDPOINT_WAIT_MS)
218
+ t.unref?.()
219
+ })
220
+ await Promise.race([gotEndpoint, wait])
181
221
  }
182
222
 
183
223
  // POST JSON-RPC 请求,同时监听响应
@@ -185,20 +225,28 @@ function httpTransport(baseURL, extraHeaders = {}) {
185
225
  const id = rpcId()
186
226
  const body = JSON.stringify({ jsonrpc: "2.0", id, method, params })
187
227
 
188
- // 如果有活跃的 SSE 连接,服务器会通过 SSE 推回响应
189
- if (eventSource) {
228
+ // legacy SSE:POST 只回 202,服务器经 SSE 流推回响应
229
+ if (legacySSE) {
190
230
  return new Promise((resolve) => {
191
231
  pending.set(id, resolve)
192
- fetch(url + "/messages", { method: "POST", headers: headers(), body, signal: AbortSignal.timeout(CALL_TIMEOUT_MS) })
232
+ fetch(postUrl, { method: "POST", headers: headers(), body, signal: AbortSignal.timeout(CALL_TIMEOUT_MS) })
233
+ .then((resp) => {
234
+ // 2024-11-05:POST 期望 202 Accepted;其他错误码说明请求没送达
235
+ if (!resp.ok) {
236
+ pending.delete(id)
237
+ resolve({ id, error: { code: -32000, message: `POST failed: HTTP ${resp.status}` } })
238
+ }
239
+ })
193
240
  .catch((e) => {
194
241
  pending.delete(id)
195
242
  resolve({ id, error: { code: -32000, message: `POST failed: ${e.message}` } })
196
243
  })
197
- })
244
+ // 兜底清理:响应超时(send 外层 withTimeout 先赢)时 pending 不留尸
245
+ }).finally(() => pending.delete(id))
198
246
  }
199
247
 
200
- // 没有 SSE:纯 HTTP POST,响应就是 JSON-RPC
201
- const resp = await fetch(url + "/messages", {
248
+ // Streamable HTTP(无 SSE,或 GET 流只推服务端消息):响应就在 POST 自身
249
+ const resp = await fetch(postUrl, {
202
250
  method: "POST",
203
251
  headers: headers(),
204
252
  body,
@@ -231,7 +279,7 @@ function httpTransport(baseURL, extraHeaders = {}) {
231
279
 
232
280
  // notification:无 id,不期待响应(协议要求)
233
281
  const notify = (method, params) => {
234
- fetch(url + "/messages", {
282
+ fetch(postUrl, {
235
283
  method: "POST",
236
284
  headers: headers(),
237
285
  body: JSON.stringify({ jsonrpc: "2.0", method, params }),
@@ -242,6 +290,15 @@ function httpTransport(baseURL, extraHeaders = {}) {
242
290
  const close = () => {
243
291
  closed = true
244
292
  abortController?.abort()
293
+ // Streamable HTTP 规范:有 session 时发 DELETE 让 server 释放会话(尽力而为)
294
+ if (sessionId) {
295
+ fetch(postUrl, {
296
+ method: "DELETE",
297
+ headers: { "Mcp-Session-Id": sessionId, ...extraHeaders },
298
+ signal: AbortSignal.timeout(5_000),
299
+ }).catch(() => {})
300
+ sessionId = null
301
+ }
245
302
  for (const [, resolve] of pending) resolve({ id: null, error: { code: -32000, message: "Connection closed" } })
246
303
  pending.clear()
247
304
  }
@@ -249,12 +306,102 @@ function httpTransport(baseURL, extraHeaders = {}) {
249
306
  return { send, notify, close, openSSE, url, headers: extraHeaders }
250
307
  }
251
308
 
309
+ // ---- WebSocket transport ----
310
+
311
+ function wsTransport(wsUrl, extraHeaders = {}) {
312
+ const pending = new Map()
313
+ let closed = false
314
+ let ws = null
315
+
316
+ const failAll = (message) => {
317
+ for (const [, resolve] of pending) resolve({ id: null, error: { code: -32000, message } })
318
+ pending.clear()
319
+ }
320
+
321
+ const connect = () => {
322
+ if (closed) throw new Error("MCP WebSocket connection closed")
323
+ // WebSocket API 不支持自定义 header,token 走 query param
324
+ // (不能把 "Bearer ..." 当子协议传——含空格,违反 Sec-WebSocket-Protocol token 规则会抛 SyntaxError)
325
+ ws = new WebSocket(withAuthToken(wsUrl, extraHeaders.Authorization))
326
+
327
+ return new Promise((resolve, reject) => {
328
+ const timeout = setTimeout(() => {
329
+ ws.close()
330
+ reject(new Error(`WebSocket connect timeout: ${wsUrl}`))
331
+ }, INIT_TIMEOUT_MS)
332
+
333
+ ws.addEventListener("open", () => {
334
+ clearTimeout(timeout)
335
+ resolve()
336
+ })
337
+
338
+ ws.addEventListener("message", (event) => {
339
+ try {
340
+ const msg = JSON.parse(event.data.toString())
341
+ const resolver = pending.get(msg.id)
342
+ if (resolver) {
343
+ pending.delete(msg.id)
344
+ resolver(msg)
345
+ }
346
+ // 没有 resolver 的是通知,忽略
347
+ } catch { /* 非 JSON,忽略 */ }
348
+ })
349
+
350
+ ws.addEventListener("error", (event) => {
351
+ clearTimeout(timeout)
352
+ closed = true
353
+ const errMsg = event.message || "WebSocket error"
354
+ if (pending.size > 0) {
355
+ failAll(errMsg)
356
+ } else {
357
+ reject(new Error(errMsg))
358
+ }
359
+ })
360
+
361
+ ws.addEventListener("close", () => {
362
+ clearTimeout(timeout)
363
+ closed = true
364
+ failAll("WebSocket closed")
365
+ })
366
+ })
367
+ }
368
+
369
+ const send = (method, params) => {
370
+ if (closed) return Promise.reject(new Error("MCP WebSocket connection closed"))
371
+ const id = rpcId()
372
+ const promise = new Promise((resolve) => pending.set(id, resolve))
373
+ try {
374
+ ws.send(JSON.stringify({ jsonrpc: "2.0", id, method, params }))
375
+ } catch (error) {
376
+ // 非 OPEN 状态 send 会同步抛;close 事件随后统一兜底
377
+ pending.delete(id)
378
+ return Promise.resolve({ id: null, error: { code: -32000, message: `ws send failed: ${error.message}` } })
379
+ }
380
+ return withTimeout(promise, CALL_TIMEOUT_MS).finally(() => pending.delete(id))
381
+ }
382
+
383
+ const notify = (method, params) => {
384
+ if (!closed && ws?.readyState === WebSocket.OPEN) {
385
+ ws.send(JSON.stringify({ jsonrpc: "2.0", method, params }))
386
+ }
387
+ }
388
+
389
+ const close = () => {
390
+ closed = true
391
+ failAll("Connection closed")
392
+ try { ws?.close() } catch { /* 忽略 */ }
393
+ }
394
+
395
+ return { send, notify, close, connect }
396
+ }
397
+
252
398
  // ---- MCP lifecycle ----
253
399
 
254
400
  function buildTools(mcpTools, transport, config) {
255
401
  const prefix = config.name ? `${config.name}_` : "mcp_"
256
402
  return mcpTools.map((t) => ({
257
- name: prefix + sanitizeToolName(t.name),
403
+ // 组合名整体 sanitize + 截断:prefix 也要计入 64 字符上限
404
+ name: sanitizeToolName(prefix + t.name),
258
405
  description: t.description ?? `MCP tool: ${t.name}`,
259
406
  parameters: t.inputSchema ?? { type: "object", properties: {} },
260
407
  readonly: false,
@@ -294,15 +441,32 @@ async function doInitialize(transport, name) {
294
441
  * http: { name, url, headers? }
295
442
  */
296
443
  export async function connectMcpServer(config) {
444
+ if (config.wsUrl) {
445
+ const transport = wsTransport(config.wsUrl, config.headers ?? {})
446
+ try {
447
+ await transport.connect()
448
+ const mcpTools = await doInitialize(transport, config.name ?? config.wsUrl)
449
+ return buildTools(mcpTools, transport, config)
450
+ } catch (error) {
451
+ transport.close()
452
+ throw error
453
+ }
454
+ }
455
+
297
456
  if (config.url) {
298
457
  const transport = httpTransport(config.url, config.headers ?? {})
299
458
  try {
300
459
  await transport.openSSE()
301
460
  } catch {
302
- // 不支持 GET /sse 的 server(纯 Streamable HTTP POST):降级为无 SSE 模式
461
+ // 不支持 GET 的 server(纯 Streamable HTTP POST):降级为无 SSE 模式
462
+ }
463
+ try {
464
+ const mcpTools = await doInitialize(transport, config.name ?? config.url)
465
+ return buildTools(mcpTools, transport, config)
466
+ } catch (error) {
467
+ transport.close()
468
+ throw error
303
469
  }
304
- const mcpTools = await doInitialize(transport, config.name ?? config.url)
305
- return buildTools(mcpTools, transport, config)
306
470
  }
307
471
 
308
472
  if (config.command) {
@@ -316,7 +480,7 @@ export async function connectMcpServer(config) {
316
480
  }
317
481
  }
318
482
 
319
- throw new Error(`MCP server "${config.name}": needs either 'command' (stdio) or 'url' (http)`)
483
+ throw new Error(`MCP server "${config.name}": needs either 'wsUrl' (websocket), 'command' (stdio), or 'url' (http)`)
320
484
  }
321
485
 
322
486
  export function closeAllMcp(agent) {
@@ -339,13 +503,22 @@ export function removeMcpTools(agent, serverName) {
339
503
 
340
504
  // ---- helpers ----
341
505
 
506
+ /** 把 Authorization header 转成 ?token= query param(WebSocket 无法自定义 header) */
507
+ function withAuthToken(wsUrl, authorization) {
508
+ if (!authorization) return wsUrl
509
+ const token = authorization.replace(/^Bearer\s+/i, "")
510
+ const u = new URL(wsUrl)
511
+ u.searchParams.set("token", token)
512
+ return u.href
513
+ }
514
+
342
515
  function sanitizeToolName(name) {
343
516
  return name.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64)
344
517
  }
345
518
 
346
- /** cmd.exe 参数加引号(含空格/引号时) */
519
+ /** cmd.exe 参数加引号(含空格/引号时)。cmd 不认 \" 转义——内层引号必须翻倍 */
347
520
  function quoteArg(s) {
348
- return /[\s"]/.test(s) ? `"${s.replace(/"/g, '\\"')}"` : s
521
+ return /[\s"]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s
349
522
  }
350
523
 
351
524
  function withTimeout(promise, ms) {