单二进制三角色 + Dock 适配器 + Swarm stack 达到可部署态;mgr1 实测订阅经 central-proxy bootstrap,探活 alive=41/52。 Co-authored-by: Cursor <cursoragent@cursor.com>
279 lines
10 KiB
Go
279 lines
10 KiB
Go
// mcphandler.go:MCP Streamable HTTP(2026 无状态)POST /mcp。
|
||
// JSON-RPC 2.0 单请求单响应,无 SSE。tools/call 转发 /v1 同一内核。
|
||
package gateway
|
||
|
||
import (
|
||
"encoding/json"
|
||
"io"
|
||
"net/http"
|
||
"strings"
|
||
)
|
||
|
||
// MCP 协议常量。
|
||
const (
|
||
mcpProtocolVersionDefault = "2026-07-28" // 2026 无状态规范目标版
|
||
mcpServerName = "onesvm-browser-server"
|
||
mcpServerVersion = "0.1.0"
|
||
)
|
||
|
||
// jsonRPCError codes(协议标准)。
|
||
const (
|
||
errParse = -32700
|
||
errInvalidRequest = -32600
|
||
errMethodNotFound = -32601
|
||
errInvalidParams = -32602
|
||
errInternal = -32603
|
||
)
|
||
|
||
// jsonRPCReq JSON-RPC 2.0 请求。
|
||
type jsonRPCReq struct {
|
||
JSONRPC string `json:"jsonrpc"`
|
||
ID json.RawMessage `json:"id"`
|
||
Method string `json:"method"`
|
||
Params json.RawMessage `json:"params"`
|
||
}
|
||
|
||
// jsonRPCResp JSON-RPC 2.0 响应。
|
||
type jsonRPCResp struct {
|
||
JSONRPC string `json:"jsonrpc"`
|
||
ID json.RawMessage `json:"id"`
|
||
Result any `json:"result,omitempty"`
|
||
Error *jsonRPCError `json:"error,omitempty"`
|
||
}
|
||
|
||
// jsonRPCError 错误对象。
|
||
type jsonRPCError struct {
|
||
Code int `json:"code"`
|
||
Message string `json:"message"`
|
||
Data any `json:"data,omitempty"`
|
||
}
|
||
|
||
// handleMCP POST /mcp 单 handler。
|
||
func (s *Server) handleMCP(w http.ResponseWriter, r *http.Request, a *consumerAuth) {
|
||
// 协议头校验:Content-Type 必须 application/json(容忍 charset 后缀)
|
||
ct := r.Header.Get("Content-Type")
|
||
if ct != "" && !strings.HasPrefix(strings.ToLower(strings.TrimSpace(strings.Split(ct, ";")[0])), "application/json") {
|
||
s.mcpWriteErr(w, r, nil, &jsonRPCError{Code: errInvalidRequest, Message: "Content-Type 须为 application/json"})
|
||
return
|
||
}
|
||
// MCP-Protocol-Version:缺省容忍(默认按 2026 版处理)
|
||
_ = r.Header.Get("MCP-Protocol-Version")
|
||
|
||
body, err := io.ReadAll(io.LimitReader(r.Body, 4*1024*1024))
|
||
if err != nil {
|
||
s.mcpWriteErr(w, r, nil, &jsonRPCError{Code: errParse, Message: "请求体读取失败"})
|
||
return
|
||
}
|
||
var req jsonRPCReq
|
||
if err := json.Unmarshal(body, &req); err != nil {
|
||
s.mcpWriteErr(w, r, nil, &jsonRPCError{Code: errParse, Message: "JSON 解析失败: " + err.Error()})
|
||
return
|
||
}
|
||
if req.JSONRPC != "2.0" || req.Method == "" {
|
||
s.mcpWriteErr(w, r, req.ID, &jsonRPCError{Code: errInvalidRequest, Message: "非法 JSON-RPC 2.0 请求"})
|
||
return
|
||
}
|
||
switch req.Method {
|
||
case "initialize":
|
||
s.mcpWrite(w, r, req.ID, s.mcpInitialize())
|
||
case "tools/list":
|
||
s.mcpWrite(w, r, req.ID, s.mcpToolsList())
|
||
case "tools/call":
|
||
s.mcpToolsCall(w, r, &req, a)
|
||
case "ping":
|
||
s.mcpWrite(w, r, req.ID, map[string]any{})
|
||
case "notifications/initialized":
|
||
// 通知:无响应体(2026 无状态;返回 202 Accepted)
|
||
w.WriteHeader(http.StatusAccepted)
|
||
default:
|
||
s.mcpWriteErr(w, r, req.ID, &jsonRPCError{Code: errMethodNotFound, Message: "未知 method: " + req.Method})
|
||
}
|
||
}
|
||
|
||
// mcpWrite JSON-RPC 响应输出(错误对象与 result 二选一)。
|
||
func (s *Server) mcpWrite(w http.ResponseWriter, _ *http.Request, id json.RawMessage, result any, errObj ...*jsonRPCError) {
|
||
resp := jsonRPCResp{JSONRPC: "2.0", ID: id}
|
||
if len(errObj) > 0 && errObj[0] != nil {
|
||
resp.Error = errObj[0]
|
||
} else {
|
||
resp.Result = result
|
||
}
|
||
if resp.ID == nil {
|
||
resp.ID = json.RawMessage("null")
|
||
}
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusOK)
|
||
_ = json.NewEncoder(w).Encode(resp)
|
||
}
|
||
|
||
// mcpWriteErr JSON-RPC error 对象输出(-32600/-32601/-32602/-32700 协议错误走 error 槽)。
|
||
func (s *Server) mcpWriteErr(w http.ResponseWriter, _ *http.Request, id json.RawMessage, errObj *jsonRPCError) {
|
||
resp := jsonRPCResp{JSONRPC: "2.0", ID: id, Error: errObj}
|
||
if resp.ID == nil {
|
||
resp.ID = json.RawMessage("null")
|
||
}
|
||
w.Header().Set("Content-Type", "application/json")
|
||
w.WriteHeader(http.StatusOK)
|
||
_ = json.NewEncoder(w).Encode(resp)
|
||
}
|
||
|
||
// mcpInitialize initialize 响应:serverInfo + capabilities.tools + instructions(合规规则)。
|
||
func (s *Server) mcpInitialize() map[string]any {
|
||
return map[string]any{
|
||
"protocolVersion": mcpProtocolVersionDefault,
|
||
"capabilities": map[string]any{
|
||
"tools": map[string]any{"listChanged": false},
|
||
},
|
||
"serverInfo": map[string]any{
|
||
"name": mcpServerName,
|
||
"version": mcpServerVersion,
|
||
},
|
||
"instructions": mcpInstructions,
|
||
}
|
||
}
|
||
|
||
// mcpInstructions 合规使用规则(mcp-usage §5 五条 + 纪律)。
|
||
const mcpInstructions = "onesvm-browser-server 合规使用规则:\n" +
|
||
"1. 不得将本服务用于任何违反中国法律法规的数据获取。\n" +
|
||
"2. 不得用于绕过登录墙 / 支付墙 / 验证码(本服务默认也不具备此能力)。\n" +
|
||
"3. 不得进行整站搬迁式 crawl(每 host 有频率上限;60 会话封顶本身也是合规阀)。\n" +
|
||
"4. 不得把公开网页中的个人信息汇总成档案再分发;响应中的疑似 PII 默认已被脱敏,不要试图还原。\n" +
|
||
"5. 不得把包含境内个人信息的 query 发给 region=overseas(query 会经境外代理出口)。\n" +
|
||
"其它纪律:普通 key 遵守 robots.txt;denied / blocked 的语义是「没数据」," +
|
||
"不要重试对抗、不要换出口绕过——反复对抗触发审计告警并可能导致 key 被吊销。"
|
||
|
||
// mcpToolDef 工具 schema 描述。
|
||
type mcpToolDef struct {
|
||
Name string `json:"name"`
|
||
Description string `json:"description"`
|
||
InputSchema map[string]any `json:"inputSchema"`
|
||
}
|
||
|
||
// mcpToolsList tools/list:search/read 工具 schema(参数表与 mcp-usage §2 一致)。
|
||
func (s *Server) mcpToolsList() map[string]any {
|
||
tools := []mcpToolDef{
|
||
{
|
||
Name: "search",
|
||
Description: "关键词搜索发现。region 必填(domestic=国内直连,overseas=经代理,当前仅 Bing)。返回 ≤800 字符片段,非全文。",
|
||
InputSchema: map[string]any{
|
||
"type": "object",
|
||
"properties": map[string]any{
|
||
"query": map[string]any{"type": "string", "description": "关键词(必填)"},
|
||
"region": map[string]any{"type": "string", "enum": []string{"domestic", "overseas"}, "description": "出口区域(必填,无默认)"},
|
||
"max_results": map[string]any{"type": "integer", "minimum": 1, "maximum": 20, "default": 5},
|
||
"time_range": map[string]any{"type": "string", "enum": []string{"day", "week", "month", "year"}},
|
||
"lang": map[string]any{"type": "string", "description": "如 zh-CN / en-US"},
|
||
},
|
||
"required": []string{"query", "region"},
|
||
},
|
||
},
|
||
{
|
||
Name: "read",
|
||
Description: "单 URL 精读(fit markdown)。默认 formats=[markdown];links/images 可加;html/screenshot 与 extract 为特权 scope。仅 http/https 80/443。",
|
||
InputSchema: map[string]any{
|
||
"type": "object",
|
||
"properties": map[string]any{
|
||
"url": map[string]any{"type": "string", "description": "目标页 URL(必填)"},
|
||
"formats": map[string]any{"type": "array", "items": map[string]any{"type": "string", "enum": []string{"markdown", "links", "images", "html", "screenshot"}}, "default": []string{"markdown"}},
|
||
"max_chars": map[string]any{"type": "integer", "default": 20000},
|
||
"extract": map[string]any{"type": "object", "description": "特权:{schema, prompt?},JSON Schema 结构化抽取"},
|
||
"region": map[string]any{"type": "string", "enum": []string{"domestic", "overseas"}},
|
||
},
|
||
"required": []string{"url"},
|
||
},
|
||
},
|
||
}
|
||
return map[string]any{"tools": tools}
|
||
}
|
||
|
||
// mcpToolsCall tools/call:转发 /v1 同一内核。
|
||
// 工具结果 content:[{type:"text",text:JSON字符串}];isError 与 HTTP 错误码对齐。
|
||
func (s *Server) mcpToolsCall(w http.ResponseWriter, r *http.Request, req *jsonRPCReq, a *consumerAuth) {
|
||
var params struct {
|
||
Name string `json:"name"`
|
||
Arguments json.RawMessage `json:"arguments"`
|
||
}
|
||
if err := json.Unmarshal(req.Params, ¶ms); err != nil || params.Name == "" {
|
||
s.mcpWriteErr(w, r, req.ID, &jsonRPCError{Code: errInvalidParams, Message: "params.name 必填"})
|
||
return
|
||
}
|
||
var res result
|
||
switch params.Name {
|
||
case "search":
|
||
var in contractSearchInput
|
||
if err := unmarshalInto(params.Arguments, &in); err != nil {
|
||
s.mcpWriteErr(w, r, req.ID, &jsonRPCError{Code: errInvalidParams, Message: "arguments 非法: " + err.Error()})
|
||
return
|
||
}
|
||
rc := runCtx{Intent: IntentSearch, Search: in.search(), Auth: a, W: w, R: r}
|
||
res = s.pipeline(rc)
|
||
case "read":
|
||
var in contractReadInput
|
||
if err := unmarshalInto(params.Arguments, &in); err != nil {
|
||
s.mcpWriteErr(w, r, req.ID, &jsonRPCError{Code: errInvalidParams, Message: "arguments 非法: " + err.Error()})
|
||
return
|
||
}
|
||
rc := runCtx{Intent: IntentRead, Read: in.read(), Auth: a, W: w, R: r}
|
||
res = s.pipeline(rc)
|
||
default:
|
||
s.mcpWriteErr(w, r, req.ID, &jsonRPCError{Code: errInvalidParams, Message: "未知工具: " + params.Name + "(可选 search|read)"})
|
||
return
|
||
}
|
||
s.mcpRespondToolResult(w, r, req.ID, res)
|
||
}
|
||
|
||
// mcpRespondToolResult 把管线结果转 MCP tool result。
|
||
// 前置错误(HTTPStatus!=200):isError=true + error.code 对齐 mcp-usage §3;
|
||
// 信封结果:text=信封 JSON;信封内 error 非空 → isError=true。
|
||
func (s *Server) mcpRespondToolResult(w http.ResponseWriter, r *http.Request, id json.RawMessage, res result) {
|
||
var body []byte
|
||
isError := false
|
||
var structuredErr any
|
||
if res.HTTPStatus != http.StatusOK && res.Body == nil {
|
||
body = res.Body2
|
||
isError = true
|
||
var env struct {
|
||
Error *struct {
|
||
Code string `json:"code"`
|
||
Message string `json:"message"`
|
||
} `json:"error"`
|
||
}
|
||
if json.Unmarshal(body, &env) == nil && env.Error != nil {
|
||
structuredErr = map[string]string{"code": env.Error.Code, "message": env.Error.Message}
|
||
}
|
||
} else {
|
||
body = res.Body
|
||
var env struct {
|
||
OK bool `json:"ok"`
|
||
Error *struct {
|
||
Code string `json:"code"`
|
||
Message string `json:"message"`
|
||
} `json:"error"`
|
||
}
|
||
if json.Unmarshal(body, &env) == nil {
|
||
if env.Error != nil {
|
||
isError = true
|
||
structuredErr = map[string]string{"code": env.Error.Code, "message": env.Error.Message}
|
||
} else if !env.OK {
|
||
isError = true
|
||
structuredErr = map[string]string{"code": "internal", "message": "信封 ok=false 无 error 详情"}
|
||
}
|
||
}
|
||
}
|
||
toolRes := map[string]any{
|
||
"content": []map[string]any{{"type": "text", "text": string(body)}},
|
||
"isError": isError,
|
||
}
|
||
if structuredErr != nil {
|
||
toolRes["error"] = structuredErr
|
||
}
|
||
s.mcpWrite(w, r, id, toolRes)
|
||
}
|
||
|
||
// unmarshalInto JSON 解码辅助。
|
||
func unmarshalInto(raw json.RawMessage, v any) error {
|
||
if len(raw) == 0 {
|
||
raw = json.RawMessage("{}")
|
||
}
|
||
return json.Unmarshal(raw, v)
|
||
}
|