httpMcp.ts 4.8 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091
  1. import { randomUUID } from 'node:crypto'
  2. import type { McpTool, McpToolResult } from './stdioMcp.js'
  3. export interface PluginClient {
  4. init(signal?: AbortSignal): Promise<void>
  5. listTools(signal?: AbortSignal): Promise<McpTool[]>
  6. callTool(name: string, args: unknown, signal?: AbortSignal): Promise<McpToolResult>
  7. dispose(): void
  8. waitClosed(): Promise<void>
  9. }
  10. /** Streamable HTTP MCP, including JSON and SSE responses. Never follows a
  11. * redirect with the user's headers, and never advertises client-side execution. */
  12. export class HttpMcp implements PluginClient {
  13. private controller = new AbortController()
  14. private session = ''
  15. private version = '2025-06-18'
  16. constructor(readonly url: string, readonly headers: Record<string, string> = {}, readonly fetchImpl: typeof fetch = fetch) {}
  17. async rpc(method: string, params: unknown, signal?: AbortSignal, notification = false): Promise<any> {
  18. const id = randomUUID()
  19. const response = await this.fetchImpl(this.url, {
  20. method: 'POST', redirect: 'error',
  21. headers: { ...this.headers, 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream', 'MCP-Protocol-Version': this.version, ...(this.session ? { 'Mcp-Session-Id': this.session } : {}) },
  22. body: JSON.stringify({ jsonrpc: '2.0', ...(notification ? {} : { id }), method, params }),
  23. signal: AbortSignal.any([this.controller.signal, AbortSignal.timeout(method === 'tools/call' ? 120_000 : 15_000), ...(signal ? [signal] : [])]),
  24. })
  25. if (!response.ok) { await response.body?.cancel(); throw new Error(`MCP HTTP ${response.status}`) }
  26. const session = response.headers.get('mcp-session-id')
  27. if (session) this.session = session
  28. if (notification || response.status === 204) { await response.body?.cancel(); return {} }
  29. const reader = response.body?.getReader()
  30. if (!reader) throw new Error('MCP 响应为空')
  31. const decoder = new TextDecoder(); let body = '', bytes = 0
  32. const sse = response.headers.get('content-type')?.includes('text/event-stream')
  33. const unpack = (frame: any) => {
  34. if (frame?.id !== id) return undefined
  35. if (frame.error) throw new Error('MCP 服务拒绝了请求')
  36. if (!frame.result || typeof frame.result !== 'object') throw new Error('MCP 响应格式无效')
  37. return frame.result
  38. }
  39. try {
  40. for (;;) {
  41. const part = await reader.read()
  42. if (part.done) break
  43. bytes += part.value.byteLength
  44. if (bytes > 8 * 1024 * 1024) throw new Error('MCP 响应超过 8 MiB')
  45. body += decoder.decode(part.value, { stream: true })
  46. if (sse) {
  47. body = body.replace(/\r\n/g, '\n')
  48. let end: number
  49. while ((end = body.indexOf('\n\n')) >= 0) {
  50. const event = body.slice(0, end); body = body.slice(end + 2)
  51. const data = event.split('\n').filter(line => line.startsWith('data:')).map(line => line.slice(5).trimStart()).join('\n')
  52. if (!data) continue
  53. const result = unpack(JSON.parse(data))
  54. if (result !== undefined) return result
  55. }
  56. }
  57. }
  58. const result = !sse ? unpack(JSON.parse(body + decoder.decode())) : undefined
  59. if (result === undefined) throw new Error('MCP 未返回对应请求的结果')
  60. return result
  61. } finally { await reader.cancel().catch(() => {}) }
  62. }
  63. async init(signal?: AbortSignal) {
  64. const result = await this.rpc('initialize', { protocolVersion: this.version, capabilities: {}, clientInfo: { name: 'yaoyao-bot-plugins', version: '1' } }, signal)
  65. if (!['2024-11-05', '2025-03-26', '2025-06-18', '2025-11-25'].includes(result.protocolVersion)) throw new Error('MCP 协议版本不支持')
  66. this.version = result.protocolVersion
  67. await this.rpc('notifications/initialized', {}, signal, true)
  68. }
  69. async listTools(signal?: AbortSignal): Promise<McpTool[]> {
  70. const tools: McpTool[] = [], cursors = new Set<string>(); let cursor: string | undefined
  71. for (let page = 0; page < 20; page++) {
  72. const result = await this.rpc('tools/list', cursor ? { cursor } : {}, signal)
  73. if (!Array.isArray(result.tools)) throw new Error('MCP 工具目录无效')
  74. tools.push(...result.tools)
  75. if (tools.length > 500) throw new Error('MCP 工具数量超过 500')
  76. if (!result.nextCursor) return tools
  77. if (typeof result.nextCursor !== 'string' || cursors.has(result.nextCursor)) throw new Error('MCP 工具分页无效')
  78. cursor = result.nextCursor; cursors.add(cursor!)
  79. }
  80. throw new Error('MCP 工具分页超出限制')
  81. }
  82. async callTool(name: string, args: unknown, signal?: AbortSignal): Promise<McpToolResult> {
  83. const value = await this.rpc('tools/call', { name, arguments: args }, signal)
  84. if (!Array.isArray(value.content)) throw new Error('MCP 工具返回格式无效')
  85. return value
  86. }
  87. dispose() { this.controller.abort() }
  88. async waitClosed() {}
  89. }