onesvm-browser-server/server/internal/gateway/mcphandler.go
chii eb972dfa93 feat: 落地 browser-server 控制面并打通 mgr1 海外订阅
单二进制三角色 + Dock 适配器 + Swarm stack 达到可部署态;mgr1 实测订阅经 central-proxy bootstrap,探活 alive=41/52。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 15:05:12 +08:00

279 lines
10 KiB
Go
Raw Permalink 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.

// 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, &params); 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)
}