9501751792
三轮审查修复(60+ 项),相对远端 main(b536672)的关键变更:
- 安全: 数据面 SSRF 拨号防护(防 DNS rebinding)/上游凭据剥离/登录防枚举
与锁定态统一/可信代理(X-Forwarded-For)限流加固/会话版本失效机制/
撤销即时传播/弱密钥拒绝启动/脱敏字节级重写(保签名契约)
- 业务逻辑: 裸 body 上传 panic/bootstrap 审计管线卡死/定价通配符优先级/
全局工具可见性/调度器停机补跑/TOTP 挑战令牌消费顺序/熔断探针语义/
>4MB 响应 token 计量/管理员重置密码作废会话 等
- 前端: 新 logo(语枢 AI 网关主题)/Provider 凭据异常警示/删除入口/
后端错误消息透传/localStorage 敏感数据收敛
- 部署: CREDENTIAL_MASTER_KEY 持久化与弱值拒绝/Provider DELETE 接口/
nginx 安全头/worker 内存限制
- 新增迁移 000029(key_hash 索引)/000030(usage_daily 币种维度)
382 lines
12 KiB
Go
382 lines
12 KiB
Go
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
|
|
}
|