Files
HomeAgent/internal/plugins/mcp/sse.go
JianFeeeee 51eb98e0ae fix(mcp+adapter): 流式 tool_calls 解析修复 + MCP transport 超时保护
openai adapter transform_stream_chunk:
- OpenAI 流式分片是嵌套格式 function.{name,arguments},原样透传后
  json.Unmarshal 到扁平 ToolCall{name,arguments} 时 name 恒为空,
  accumulateStream flushToolCall 因 acc.name=="" 静默丢弃整个工具调用
  (流式路径自上线起 tool_calls 全部丢失的根因)
- 现在正确解包 function.name → name,function.arguments → raw_arguments
  (保留原始 JSON 字符串分片,由 accumulateStream 按 index 拼接)
- 不按 name 过滤分片:OpenAI 流式续传块 name 为空但携带 arguments 分片

mcp stdio/sse transport:
- stdio Send() 无超时:server 进程卡死时插件加载永久阻塞
- sse http.Client 无超时:远程 server 网络抖动/无响应时永久阻塞,
  导致 webui 等后续插件全部无法启动(生产实例偶发启动卡死根因)
- stdio 加 60s 请求超时;sse client 加 30s 整体 + 10s 拨号超时
2026-08-25 18:59:00 +08:00

116 lines
3.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package mcp
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"strings"
"time"
)
// SSETransport 通过 HTTP POST 进行 JSON-RPC 通信。
// 兼容两种服务端响应application/json 直连响应,以及 Streamable HTTP
// 的异步响应HTTP 202 + text/event-stream 的 SSE data 帧)。
type SSETransport struct {
url string
client *http.Client
}
func NewSSETransport(url string) *SSETransport {
// 必须带超时:远程 MCP server 网络抖动/无响应时,
// 无超时的 client 会让插件加载永久阻塞webui 等后续插件全部起不来)
return &SSETransport{
url: url,
client: &http.Client{
Timeout: 30 * time.Second,
Transport: &http.Transport{
DialContext: (&net.Dialer{
Timeout: 10 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
},
},
}
}
// parseResponseBody 根据 Content-Type 解析 JSON-RPC 响应
func parseResponseBody(contentType string, body []byte) (*rpcResponse, error) {
if strings.Contains(contentType, "text/event-stream") {
// SSE 流:逐行提取 data: 帧
var lastJSON []byte
for _, line := range strings.Split(string(body), "\n") {
line = strings.TrimRight(line, "\r")
if strings.HasPrefix(line, "data:") {
data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
if data == "" || data == "[DONE]" {
continue
}
var frame map[string]json.RawMessage
if err := json.Unmarshal([]byte(data), &frame); err == nil {
if _, isResp := frame["id"]; isResp || frame["result"] != nil || frame["error"] != nil {
lastJSON = []byte(data)
}
}
}
}
if len(lastJSON) == 0 {
return nil, fmt.Errorf("SSE 流中未找到 JSON-RPC 响应帧: %s", truncate(string(body), 300))
}
body = lastJSON
}
var rpcResp rpcResponse
if err := json.Unmarshal(body, &rpcResp); err != nil {
return nil, fmt.Errorf("unmarshal response: %w, body=%s", err, truncate(string(body), 300))
}
return &rpcResp, nil
}
func truncate(s string, n int) string {
if len(s) > n {
return s[:n] + "..."
}
return s
}
func (t *SSETransport) Send(req *rpcRequest) (*rpcResponse, error) {
data, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("marshal: %w", err)
}
httpReq, err := http.NewRequest("POST", t.url, bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("http request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Accept", "application/json, text/event-stream")
resp, err := t.client.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("http post: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read body: %w", err)
}
ct := resp.Header.Get("Content-Type")
// 非 2xx尝试提取错误信息
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("http status %d: %s", resp.StatusCode, truncate(string(body), 300))
}
return parseResponseBody(ct, body)
}
func (t *SSETransport) Close() error {
t.client.CloseIdleConnections()
return nil
}