package workbench import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "strconv" "strings" "sync" "time" ) // mcpProtocolVersion is the Model Context Protocol version this client speaks. // Streamable HTTP (2025-06-18) is the current stable transport; the server may // negotiate an older one and we accept whatever it replies with. const mcpProtocolVersion = "2025-06-18" // MCPTool is one tool discovered from an MCP server via tools/list. type MCPTool struct { Name string `json:"name"` Description string `json:"description"` InputSchema json.RawMessage `json:"inputSchema"` } // MCPToolResult is the outcome of a tools/call. type MCPToolResult struct { Content string IsError bool } // mcpServerState caches the negotiated session and discovered tool list for one // server, keyed by server ID. initialize is expensive (two round trips) and // stateless HTTP servers expect it per session, so we keep the session warm for // cacheTTL and only re-handshake once it lapses. type mcpServerState struct { initAt time.Time sessionID string tools []MCPTool toolsAt time.Time } // MCPClient is a minimal Model Context Protocol client over the streamable HTTP // (and legacy SSE) transports. It speaks just enough of the protocol for the // resource marketplace: initialize + notifications/initialized, tools/list for // discovery, and tools/call for execution. type MCPClient struct { client *http.Client cacheTTL time.Duration mu sync.Mutex states map[string]*mcpServerState } func NewMCPClient(allowPrivate bool, cacheTTL time.Duration) *MCPClient { if cacheTTL <= 0 { cacheTTL = 60 * time.Second } return &MCPClient{ client: &http.Client{ Timeout: 15 * time.Second, Transport: &http.Transport{ DialContext: safeToolDial(allowPrivate), ForceAttemptHTTP2: true, TLSHandshakeTimeout: 5 * time.Second, ResponseHeaderTimeout: 10 * time.Second, MaxIdleConns: 64, MaxIdleConnsPerHost: 16, IdleConnTimeout: 90 * time.Second, }, CheckRedirect: func(*http.Request, []*http.Request) error { return errors.New("MCP 服务器不允许重定向") }, }, cacheTTL: cacheTTL, states: make(map[string]*mcpServerState), } } // DiscoverTools returns the tools an MCP server advertises, reusing a cached // list for cacheTTL. headers are the already-decrypted request headers (e.g. // Authorization) for this server. func (c *MCPClient) DiscoverTools(ctx context.Context, server MCPServer, headers map[string]string) ([]MCPTool, error) { state, err := c.ensureInitialized(ctx, server, headers) if err != nil { return nil, err } // 读缓存与写入共用同一把锁,避免 -race 下 tools/toolsAt 的无锁读。 c.mu.Lock() if state.tools != nil && time.Since(state.toolsAt) < c.cacheTTL { snapshot := state.tools c.mu.Unlock() return snapshot, nil } c.mu.Unlock() result, err := c.call(ctx, server, headers, "tools/list", map[string]any{}) if err != nil { return nil, err } var list struct { Tools []MCPTool `json:"tools"` } if err = json.Unmarshal(result, &list); err != nil { return nil, fmt.Errorf("MCP tools/list 响应无效: %w", err) } for i := range list.Tools { if list.Tools[i].Name == "" { return nil, errors.New("MCP 服务器返回了没有名称的工具") } if len(list.Tools[i].InputSchema) == 0 { list.Tools[i].InputSchema = json.RawMessage(`{}`) } } c.mu.Lock() state.tools = list.Tools state.toolsAt = time.Now() c.mu.Unlock() return list.Tools, nil } // CallTool invokes one tool on an MCP server and returns the concatenated text // content. An isError result is surfaced as an error so callers treat it as a // failed tool round rather than a successful empty answer. func (c *MCPClient) CallTool(ctx context.Context, server MCPServer, headers map[string]string, name string, args map[string]any) (MCPToolResult, error) { state, err := c.ensureInitialized(ctx, server, headers) if err != nil { return MCPToolResult{}, err } // The runtime exposes tools under the collision-proof prefix // (mcp__{serverCode}__{toolName}); strip it before the wire call since the // remote server only knows the unprefixed tool name. if _, resolved, ok := resolveMCPTool(name); ok { name = resolved } params := map[string]any{"name": name, "arguments": args} result, err := c.call(ctx, server, headers, "tools/call", params) if err != nil { return MCPToolResult{}, err } var called struct { Content []struct { Type string `json:"type"` Text string `json:"text"` } `json:"content"` IsError bool `json:"isError"` } if err = json.Unmarshal(result, &called); err != nil { return MCPToolResult{}, fmt.Errorf("MCP tools/call 响应无效: %w", err) } text := strings.Builder{} for _, block := range called.Content { if block.Type == "text" { if text.Len() > 0 { text.WriteString("\n") } text.WriteString(block.Text) } } _ = state // keep state warm check semantics obvious if called.IsError { return MCPToolResult{}, errors.New("MCP 工具执行失败: " + text.String()) } return MCPToolResult{Content: text.String()}, nil } // cacheKey 以服务器 revision 参与缓存键:管理端编辑 endpoint/请求头后 // revision 递增,旧会话与旧工具列表立即失效,不会把过期会话发往新端点。 func cacheKey(server MCPServer) string { return server.ID + ":" + strconv.FormatInt(server.Revision, 10) } // ensureInitialized performs the MCP initialize handshake for a server if its // session has lapsed (or no cached tools exist yet), then acknowledges with // notifications/initialized. The handshake is guarded by the per-server cache // so a burst of calls does not re-initialize every request. func (c *MCPClient) ensureInitialized(ctx context.Context, server MCPServer, headers map[string]string) (*mcpServerState, error) { c.mu.Lock() state, ok := c.states[cacheKey(server)] if ok && time.Since(state.initAt) < c.cacheTTL { c.mu.Unlock() return state, nil } c.mu.Unlock() result, headersOut, err := c.handshake(ctx, server, headers) if err != nil { return nil, err } sessionID := headersOut.Get("Mcp-Session-Id") if server.Transport != "" && server.Transport != "streamable-http" && server.Transport != "sse" { return nil, fmt.Errorf("不支持的 MCP 传输方式 %s", server.Transport) } _ = result // negotiated protocol version is accepted as-is c.mu.Lock() state = &mcpServerState{initAt: time.Now(), sessionID: sessionID} c.states[cacheKey(server)] = state c.mu.Unlock() // Best-effort acknowledgment; servers that require it will reject later // calls and we will surface that error naturally. c.sendNotification(ctx, server, headers, sessionID) return state, nil } func (c *MCPClient) handshake(ctx context.Context, server MCPServer, headers map[string]string) (json.RawMessage, http.Header, error) { params := map[string]any{ "protocolVersion": mcpProtocolVersion, "capabilities": map[string]any{}, "clientInfo": map[string]any{"name": "LLMGuardX语枢", "version": "0.10.0"}, } payload, _ := json.Marshal(mcpRequest{JSONRPC: "2.0", ID: 1, Method: "initialize", Params: params}) request, err := http.NewRequestWithContext(ctx, http.MethodPost, server.EndpointURL, bytes.NewReader(payload)) if err != nil { return nil, nil, err } c.prepare(request, headers, "") response, err := c.client.Do(request) if err != nil { return nil, nil, fmt.Errorf("MCP 连接失败: %w", err) } defer response.Body.Close() result, err := readMCPBody(response) if err != nil { return nil, nil, err } return result, response.Header, nil } func (c *MCPClient) sendNotification(ctx context.Context, server MCPServer, headers map[string]string, sessionID string) { payload, _ := json.Marshal(mcpRequest{JSONRPC: "2.0", ID: nil, Method: "notifications/initialized"}) request, err := http.NewRequestWithContext(ctx, http.MethodPost, server.EndpointURL, bytes.NewReader(payload)) if err != nil { return } c.prepare(request, headers, sessionID) response, err := c.client.Do(request) if err != nil { return } _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 1<<10)) response.Body.Close() } func (c *MCPClient) call(ctx context.Context, server MCPServer, headers map[string]string, method string, params any) (json.RawMessage, error) { c.mu.Lock() sessionID := "" if state, ok := c.states[cacheKey(server)]; ok { sessionID = state.sessionID } c.mu.Unlock() payload, _ := json.Marshal(mcpRequest{JSONRPC: "2.0", ID: 1, Method: method, Params: params}) request, err := http.NewRequestWithContext(ctx, http.MethodPost, server.EndpointURL, bytes.NewReader(payload)) if err != nil { return nil, err } c.prepare(request, headers, sessionID) response, err := c.client.Do(request) if err != nil { return nil, fmt.Errorf("MCP 调用失败: %w", err) } defer response.Body.Close() return readMCPBody(response) } func (c *MCPClient) prepare(request *http.Request, headers map[string]string, sessionID string) { request.Header.Set("Content-Type", "application/json") request.Header.Set("Accept", "application/json, text/event-stream") for key, value := range headers { request.Header.Set(key, value) } if sessionID != "" { request.Header.Set("Mcp-Session-Id", sessionID) } } type mcpRequest struct { JSONRPC string `json:"jsonrpc"` ID any `json:"id"` Method string `json:"method"` Params any `json:"params,omitempty"` } type mcpError struct { Code int `json:"code"` Message string `json:"message"` } type mcpResponse struct { JSONRPC string `json:"jsonrpc"` ID any `json:"id"` Result json.RawMessage `json:"result"` Error *mcpError `json:"error"` } // readMCPBody reads and parses a single JSON-RPC response. Streamable HTTP // servers return application/json; legacy SSE servers stream data frames, from // which the first complete JSON object is extracted. func readMCPBody(response *http.Response) (json.RawMessage, error) { code := response.StatusCode raw, err := io.ReadAll(io.LimitReader(response.Body, (1<<20)+1)) if err != nil { return nil, err } if len(raw) > 1<<20 { return nil, errors.New("MCP 响应超过 1 MiB") } if code < 200 || code >= 300 { return nil, fmt.Errorf("MCP 服务器返回 HTTP %d", code) } var body []byte if strings.HasPrefix(response.Header.Get("Content-Type"), "text/event-stream") { body, err = extractSSEJSON(raw) if err != nil { return nil, err } } else { body = raw } var resp mcpResponse if err = json.Unmarshal(body, &resp); err != nil { return nil, fmt.Errorf("MCP 响应不是有效 JSON-RPC: %w", err) } if resp.Error != nil { return nil, fmt.Errorf("MCP 服务器错误 (%d): %s", resp.Error.Code, resp.Error.Message) } if len(resp.Result) == 0 { return nil, errors.New("MCP 服务器返回空结果") } return resp.Result, nil } // extractSSEJSON concatenates the first data frame's payload into one JSON // document. MCP streamable-HTTP servers emit a single frame per request, so // only the first frame boundary is consumed and the rest is ignored. func extractSSEJSON(raw []byte) ([]byte, error) { var data strings.Builder for _, line := range strings.Split(string(raw), "\n") { trimmed := strings.TrimSpace(line) switch { case strings.HasPrefix(trimmed, "data:"): value := strings.TrimSpace(strings.TrimPrefix(trimmed, "data:")) if value == "" { continue } if data.Len() > 0 { data.WriteString("\n") } data.WriteString(value) case trimmed == "" && data.Len() > 0: if json.Valid([]byte(data.String())) { return []byte(data.String()), nil } data.Reset() } } if data.Len() > 0 && json.Valid([]byte(data.String())) { return []byte(data.String()), nil } return nil, errors.New("MCP 服务器未返回有效的 SSE 数据帧") } // toolCallPrefix namespaces MCP tools inside a shared tool list so different // servers cannot collide. Format: mcp__{serverCode}__{toolName}. const toolCallPrefix = "mcp__" func mcpToolName(serverCode, tool string) string { return toolCallPrefix + serverCode + "__" + tool } // resolveMCPTool splits a prefixed tool name back into its MCP server code and // tool name. Returns ok=false for names that are not MCP-prefixed. func resolveMCPTool(prefixed string) (serverCode, tool string, ok bool) { if !strings.HasPrefix(prefixed, toolCallPrefix) { return "", "", false } rest := strings.TrimPrefix(prefixed, toolCallPrefix) parts := strings.SplitN(rest, "__", 2) if len(parts) != 2 || parts[0] == "" || parts[1] == "" { return "", "", false } return parts[0], parts[1], true }