refactor: 迁移到官方 MCP SDK (#167)

- 添加官方 SDK 依赖 github.com/modelcontextprotocol/go-sdk v0.7.0
- 新增 mcp_server.go 使用官方 SDK 注册 8 个 MCP 工具
- 删除自实现的 streamable_http.go(约 400 行)
- 更新 routes.go 使用 mcp.NewStreamableHTTPHandler
- 优化服务器优雅关闭逻辑(5秒超时 + 警告日志)
- 清理 types.go 中的 JSON-RPC 相关类型

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
zy
2025-09-27 01:44:02 +08:00
committed by GitHub
parent 529ee71144
commit 53ea832773
8 changed files with 270 additions and 459 deletions

View File

@@ -9,21 +9,28 @@ import (
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
) )
// AppServer 应用服务器结构体,封装所有服务和处理器 // AppServer 应用服务器结构体,封装所有服务和处理器
type AppServer struct { type AppServer struct {
xiaohongshuService *XiaohongshuService xiaohongshuService *XiaohongshuService
mcpServer *mcp.Server
router *gin.Engine router *gin.Engine
httpServer *http.Server httpServer *http.Server
} }
// NewAppServer 创建新的应用服务器实例 // NewAppServer 创建新的应用服务器实例
func NewAppServer(xiaohongshuService *XiaohongshuService) *AppServer { func NewAppServer(xiaohongshuService *XiaohongshuService) *AppServer {
return &AppServer{ appServer := &AppServer{
xiaohongshuService: xiaohongshuService, xiaohongshuService: xiaohongshuService,
} }
// 初始化 MCP Server需要在创建 appServer 之后,因为工具注册需要访问 appServer
appServer.mcpServer = InitMCPServer(appServer)
return appServer
} }
// Start 启动服务器 // Start 启动服务器
@@ -51,16 +58,14 @@ func (s *AppServer) Start(port string) error {
logrus.Infof("正在关闭服务器...") logrus.Infof("正在关闭服务器...")
// 优雅关闭 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel() defer cancel()
// 关闭 HTTP 服务器
if err := s.httpServer.Shutdown(ctx); err != nil { if err := s.httpServer.Shutdown(ctx); err != nil {
logrus.Errorf("服务器关闭失败: %v", err) logrus.Warnf("等待连接关闭超时,强制退出: %v", err)
return err } else {
logrus.Infof("服务器已优雅关闭")
} }
logrus.Infof("服务器已关闭")
return nil return nil
} }

5
go.mod
View File

@@ -6,6 +6,8 @@ require (
github.com/gin-gonic/gin v1.10.1 github.com/gin-gonic/gin v1.10.1
github.com/go-rod/rod v0.116.2 github.com/go-rod/rod v0.116.2
github.com/h2non/filetype v1.1.3 github.com/h2non/filetype v1.1.3
github.com/mattn/go-runewidth v0.0.16
github.com/modelcontextprotocol/go-sdk v0.7.0
github.com/pkg/errors v0.9.1 github.com/pkg/errors v0.9.1
github.com/sirupsen/logrus v1.9.3 github.com/sirupsen/logrus v1.9.3
github.com/stretchr/testify v1.10.0 github.com/stretchr/testify v1.10.0
@@ -25,11 +27,11 @@ require (
github.com/go-playground/validator/v10 v10.20.0 // indirect github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/go-rod/stealth v0.4.9 // indirect github.com/go-rod/stealth v0.4.9 // indirect
github.com/goccy/go-json v0.10.2 // indirect github.com/goccy/go-json v0.10.2 // indirect
github.com/google/jsonschema-go v0.3.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/leodido/go-urn v1.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect
@@ -37,6 +39,7 @@ require (
github.com/rivo/uniseg v0.2.0 // indirect github.com/rivo/uniseg v0.2.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect github.com/ugorji/go/codec v1.2.12 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
github.com/ysmood/fetchup v0.2.3 // indirect github.com/ysmood/fetchup v0.2.3 // indirect
github.com/ysmood/goob v0.4.0 // indirect github.com/ysmood/goob v0.4.0 // indirect
github.com/ysmood/got v0.41.0 // indirect github.com/ysmood/got v0.41.0 // indirect

22
go.sum
View File

@@ -30,9 +30,11 @@ github.com/go-rod/stealth v0.4.9 h1:X2PmQk4DUF2wzw6GOsWjW/glb8K5ebnftbEvLh7MlZ4=
github.com/go-rod/stealth v0.4.9/go.mod h1:eAzyvw8c0iAd5nJJsSWeh0fQ5z94vCIfdi1hUmYDimc= github.com/go-rod/stealth v0.4.9/go.mod h1:eAzyvw8c0iAd5nJJsSWeh0fQ5z94vCIfdi1hUmYDimc=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q=
github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/h2non/filetype v1.1.3 h1:FKkx9QbD7HR/zjK1Ia5XiBsq9zdLi5Kf3zGyFTAFkGg= github.com/h2non/filetype v1.1.3 h1:FKkx9QbD7HR/zjK1Ia5XiBsq9zdLi5Kf3zGyFTAFkGg=
github.com/h2non/filetype v1.1.3/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY= github.com/h2non/filetype v1.1.3/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
@@ -47,6 +49,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/modelcontextprotocol/go-sdk v0.7.0 h1:XEQfn3bDx2cAdSUKty3tYEMll5dtRgBUDX88Q65fai0=
github.com/modelcontextprotocol/go-sdk v0.7.0/go.mod h1:nYtYQroQ2KQiM0/SbyEPUWQ6xs4B95gJjEalc9AQyOs=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -79,16 +83,12 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/xpzouying/headless_browser v0.0.2 h1:sLc4gqUT/5IyTruYIOfCW4aZLinq38hIdUHCHem1KYo=
github.com/xpzouying/headless_browser v0.0.2/go.mod h1:bQTSzGYHIipa1zwToMlOGHcXWDlvw8y33Cx5zzElekc=
github.com/xpzouying/headless_browser v0.1.0 h1:0FyMIzhe/If/VhEdDrs7T1fqm1gOZSCFrmMXI/1JM58=
github.com/xpzouying/headless_browser v0.1.0/go.mod h1:bQTSzGYHIipa1zwToMlOGHcXWDlvw8y33Cx5zzElekc=
github.com/xpzouying/headless_browser v0.2.0 h1:EmuHXDVzx0tAevHJUdETs8iT/eK+QqrLiybvGd1xZDA= github.com/xpzouying/headless_browser v0.2.0 h1:EmuHXDVzx0tAevHJUdETs8iT/eK+QqrLiybvGd1xZDA=
github.com/xpzouying/headless_browser v0.2.0/go.mod h1:bQTSzGYHIipa1zwToMlOGHcXWDlvw8y33Cx5zzElekc= github.com/xpzouying/headless_browser v0.2.0/go.mod h1:bQTSzGYHIipa1zwToMlOGHcXWDlvw8y33Cx5zzElekc=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
github.com/ysmood/fetchup v0.2.3 h1:ulX+SonA0Vma5zUFXtv52Kzip/xe7aj4vqT5AJwQ+ZQ= github.com/ysmood/fetchup v0.2.3 h1:ulX+SonA0Vma5zUFXtv52Kzip/xe7aj4vqT5AJwQ+ZQ=
github.com/ysmood/fetchup v0.2.3/go.mod h1:xhibcRKziSvol0H1/pj33dnKrYyI2ebIvz5cOOkYGns= github.com/ysmood/fetchup v0.2.3/go.mod h1:xhibcRKziSvol0H1/pj33dnKrYyI2ebIvz5cOOkYGns=
github.com/ysmood/fetchup v0.5.2 h1:P9w3OIA7RSNEEFvEmOiTq09IOu42C96PMyZ1MWd8TAs=
github.com/ysmood/fetchup v0.5.2/go.mod h1:yCv8s8itjsCul1LGXJ1Q+8EQnZcVjfbZ4+l1zDm4StE=
github.com/ysmood/goob v0.4.0 h1:HsxXhyLBeGzWXnqVKtmT9qM7EuVs/XOgkX7T6r1o1AQ= github.com/ysmood/goob v0.4.0 h1:HsxXhyLBeGzWXnqVKtmT9qM7EuVs/XOgkX7T6r1o1AQ=
github.com/ysmood/goob v0.4.0/go.mod h1:u6yx7ZhS4Exf2MwciFr6nIM8knHQIE22lFpWHnfql18= github.com/ysmood/goob v0.4.0/go.mod h1:u6yx7ZhS4Exf2MwciFr6nIM8knHQIE22lFpWHnfql18=
github.com/ysmood/gop v0.0.2/go.mod h1:rr5z2z27oGEbyB787hpEcx4ab8cCiPnKxn0SUHt6xzk= github.com/ysmood/gop v0.0.2/go.mod h1:rr5z2z27oGEbyB787hpEcx4ab8cCiPnKxn0SUHt6xzk=
@@ -114,14 +114,12 @@ golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=

View File

@@ -12,9 +12,11 @@ func main() {
var ( var (
headless bool headless bool
binPath string // 浏览器二进制文件路径 binPath string // 浏览器二进制文件路径
port string
) )
flag.BoolVar(&headless, "headless", true, "是否无头模式") flag.BoolVar(&headless, "headless", true, "是否无头模式")
flag.StringVar(&binPath, "bin", "", "浏览器二进制文件路径") flag.StringVar(&binPath, "bin", "", "浏览器二进制文件路径")
flag.StringVar(&port, "port", ":18060", "端口")
flag.Parse() flag.Parse()
if len(binPath) == 0 { if len(binPath) == 0 {
@@ -29,7 +31,7 @@ func main() {
// 创建并启动应用服务器 // 创建并启动应用服务器
appServer := NewAppServer(xiaohongshuService) appServer := NewAppServer(xiaohongshuService)
if err := appServer.Start(":18060"); err != nil { if err := appServer.Start(port); err != nil {
logrus.Fatalf("failed to run server: %v", err) logrus.Fatalf("failed to run server: %v", err)
} }
} }

226
mcp_server.go Normal file
View File

@@ -0,0 +1,226 @@
package main
import (
"context"
"encoding/base64"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/sirupsen/logrus"
)
// MCP 工具参数结构体定义
// PublishContentArgs 发布内容的参数
type PublishContentArgs struct {
Title string `json:"title" jsonschema:"内容标题小红书限制最多20个中文字或英文单词"`
Content string `json:"content" jsonschema:"正文内容,不包含以#开头的标签内容所有话题标签都用tags参数来生成和提供即可"`
Images []string `json:"images" jsonschema:"图片路径列表至少需要1张图片。支持两种方式1. HTTP/HTTPS图片链接自动下载2. 本地图片绝对路径(推荐,如:/Users/user/image.jpg"`
Tags []string `json:"tags,omitempty" jsonschema:"话题标签列表(可选参数),如 [美食, 旅行, 生活]"`
}
// SearchFeedsArgs 搜索内容的参数
type SearchFeedsArgs struct {
Keyword string `json:"keyword" jsonschema:"搜索关键词"`
}
// FeedDetailArgs 获取Feed详情的参数
type FeedDetailArgs struct {
FeedID string `json:"feed_id" jsonschema:"小红书笔记ID从Feed列表获取"`
XsecToken string `json:"xsec_token" jsonschema:"访问令牌从Feed列表的xsecToken字段获取"`
}
// UserProfileArgs 获取用户主页的参数
type UserProfileArgs struct {
UserID string `json:"user_id" jsonschema:"小红书用户ID从Feed列表获取"`
XsecToken string `json:"xsec_token" jsonschema:"访问令牌从Feed列表的xsecToken字段获取"`
}
// PostCommentArgs 发表评论的参数
type PostCommentArgs struct {
FeedID string `json:"feed_id" jsonschema:"小红书笔记ID从Feed列表获取"`
XsecToken string `json:"xsec_token" jsonschema:"访问令牌从Feed列表的xsecToken字段获取"`
Content string `json:"content" jsonschema:"评论内容"`
}
// InitMCPServer 初始化 MCP Server
func InitMCPServer(appServer *AppServer) *mcp.Server {
// 创建 MCP Server
server := mcp.NewServer(
&mcp.Implementation{
Name: "xiaohongshu-mcp",
Version: "2.0.0",
},
nil,
)
// 注册所有工具
registerTools(server, appServer)
logrus.Info("MCP Server initialized with official SDK")
return server
}
// registerTools 注册所有 MCP 工具
func registerTools(server *mcp.Server, appServer *AppServer) {
// 工具 1: 检查登录状态
mcp.AddTool(server,
&mcp.Tool{
Name: "check_login_status",
Description: "检查小红书登录状态",
},
func(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) {
result := appServer.handleCheckLoginStatus(ctx)
return convertToMCPResult(result), nil, nil
},
)
// 工具 2: 获取登录二维码
mcp.AddTool(server,
&mcp.Tool{
Name: "get_login_qrcode",
Description: "获取登录二维码(返回 Base64 图片和超时时间)",
},
func(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) {
result := appServer.handleGetLoginQrcode(ctx)
return convertToMCPResult(result), nil, nil
},
)
// 工具 3: 发布内容
mcp.AddTool(server,
&mcp.Tool{
Name: "publish_content",
Description: "发布小红书图文内容",
},
func(ctx context.Context, req *mcp.CallToolRequest, args PublishContentArgs) (*mcp.CallToolResult, any, error) {
// 转换参数格式到现有的 handler
argsMap := map[string]interface{}{
"title": args.Title,
"content": args.Content,
"images": convertStringsToInterfaces(args.Images),
"tags": convertStringsToInterfaces(args.Tags),
}
result := appServer.handlePublishContent(ctx, argsMap)
return convertToMCPResult(result), nil, nil
},
)
// 工具 4: 获取Feed列表
mcp.AddTool(server,
&mcp.Tool{
Name: "list_feeds",
Description: "获取用户发布的内容列表",
},
func(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) {
result := appServer.handleListFeeds(ctx)
return convertToMCPResult(result), nil, nil
},
)
// 工具 5: 搜索内容
mcp.AddTool(server,
&mcp.Tool{
Name: "search_feeds",
Description: "搜索小红书内容(需要已登录)",
},
func(ctx context.Context, req *mcp.CallToolRequest, args SearchFeedsArgs) (*mcp.CallToolResult, any, error) {
argsMap := map[string]interface{}{
"keyword": args.Keyword,
}
result := appServer.handleSearchFeeds(ctx, argsMap)
return convertToMCPResult(result), nil, nil
},
)
// 工具 6: 获取Feed详情
mcp.AddTool(server,
&mcp.Tool{
Name: "get_feed_detail",
Description: "获取小红书笔记详情,返回笔记内容、图片、作者信息、互动数据(点赞/收藏/分享数)及评论列表",
},
func(ctx context.Context, req *mcp.CallToolRequest, args FeedDetailArgs) (*mcp.CallToolResult, any, error) {
argsMap := map[string]interface{}{
"feed_id": args.FeedID,
"xsec_token": args.XsecToken,
}
result := appServer.handleGetFeedDetail(ctx, argsMap)
return convertToMCPResult(result), nil, nil
},
)
// 工具 7: 获取用户主页
mcp.AddTool(server,
&mcp.Tool{
Name: "user_profile",
Description: "获取小红书用户主页,返回用户基本信息,关注、粉丝、获赞量及其笔记内容",
},
func(ctx context.Context, req *mcp.CallToolRequest, args UserProfileArgs) (*mcp.CallToolResult, any, error) {
argsMap := map[string]interface{}{
"user_id": args.UserID,
"xsec_token": args.XsecToken,
}
result := appServer.handleUserProfile(ctx, argsMap)
return convertToMCPResult(result), nil, nil
},
)
// 工具 8: 发表评论
mcp.AddTool(server,
&mcp.Tool{
Name: "post_comment_to_feed",
Description: "发表评论到小红书笔记",
},
func(ctx context.Context, req *mcp.CallToolRequest, args PostCommentArgs) (*mcp.CallToolResult, any, error) {
argsMap := map[string]interface{}{
"feed_id": args.FeedID,
"xsec_token": args.XsecToken,
"content": args.Content,
}
result := appServer.handlePostComment(ctx, argsMap)
return convertToMCPResult(result), nil, nil
},
)
logrus.Infof("Registered %d MCP tools", 8)
}
// convertToMCPResult 将自定义的 MCPToolResult 转换为官方 SDK 的格式
func convertToMCPResult(result *MCPToolResult) *mcp.CallToolResult {
var contents []mcp.Content
for _, c := range result.Content {
switch c.Type {
case "text":
contents = append(contents, &mcp.TextContent{Text: c.Text})
case "image":
// 解码 base64 字符串为 []byte
imageData, err := base64.StdEncoding.DecodeString(c.Data)
if err != nil {
logrus.WithError(err).Error("Failed to decode base64 image data")
// 如果解码失败,添加错误文本
contents = append(contents, &mcp.TextContent{
Text: "图片数据解码失败: " + err.Error(),
})
} else {
contents = append(contents, &mcp.ImageContent{
Data: imageData,
MIMEType: c.MimeType,
})
}
}
}
return &mcp.CallToolResult{
Content: contents,
IsError: result.IsError,
}
}
// convertStringsToInterfaces 辅助函数:将 []string 转换为 []interface{}
func convertStringsToInterfaces(strs []string) []interface{} {
result := make([]interface{}, len(strs))
for i, s := range strs {
result[i] = s
}
return result
}

View File

@@ -1,7 +1,10 @@
package main package main
import ( import (
"net/http"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/modelcontextprotocol/go-sdk/mcp"
) )
// setupRoutes 设置路由配置 // setupRoutes 设置路由配置
@@ -20,8 +23,15 @@ func setupRoutes(appServer *AppServer) *gin.Engine {
// 健康检查 // 健康检查
router.GET("/health", healthHandler) router.GET("/health", healthHandler)
// MCP 端点 - 使用 Streamable HTTP 协议 // MCP 端点 - 使用官方 SDK 的 Streamable HTTP Handler
mcpHandler := appServer.StreamableHTTPHandler() mcpHandler := mcp.NewStreamableHTTPHandler(
func(r *http.Request) *mcp.Server {
return appServer.mcpServer
},
&mcp.StreamableHTTPOptions{
JSONResponse: true, // 支持 JSON 响应
},
)
router.Any("/mcp", gin.WrapH(mcpHandler)) router.Any("/mcp", gin.WrapH(mcpHandler))
router.Any("/mcp/*path", gin.WrapH(mcpHandler)) router.Any("/mcp/*path", gin.WrapH(mcpHandler))

View File

@@ -1,402 +0,0 @@
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"github.com/sirupsen/logrus"
)
// StreamableHTTPHandler 处理 Streamable HTTP 协议的 MCP 请求
func (s *AppServer) StreamableHTTPHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// 设置 CORS 头
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Accept, Mcp-Session-Id")
// 处理 OPTIONS 请求
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
// 根据方法处理
switch r.Method {
case "GET":
// GET 请求用于建立 SSE 连接(可选功能)
s.handleSSEConnection(w, r)
case "POST":
// POST 请求处理 JSON-RPC
s.handleJSONRPCRequest(w, r)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
}
// handleSSEConnection 处理 SSE 连接(可选,用于服务器推送)
func (s *AppServer) handleSSEConnection(w http.ResponseWriter, r *http.Request) {
// 检查是否支持 SSE
if !strings.Contains(r.Header.Get("Accept"), "text/event-stream") {
http.Error(w, "SSE not requested", http.StatusBadRequest)
return
}
// 设置 SSE 响应头
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
// 发送初始化消息
fmt.Fprintf(w, "event: open\n")
fmt.Fprintf(w, "data: {\"type\":\"connection\",\"status\":\"connected\"}\n\n")
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
// 保持连接打开(实际使用中可以在这里推送通知)
<-r.Context().Done()
}
// handleJSONRPCRequest 处理 JSON-RPC 请求
func (s *AppServer) handleJSONRPCRequest(w http.ResponseWriter, r *http.Request) {
// 读取请求体
body, err := io.ReadAll(r.Body)
if err != nil {
s.sendStreamableError(w, nil, -32700, "Parse error")
return
}
defer r.Body.Close()
// 解析 JSON-RPC 请求
var request JSONRPCRequest
if err := json.Unmarshal(body, &request); err != nil {
s.sendStreamableError(w, nil, -32700, "Parse error")
return
}
logrus.WithField("method", request.Method).Info("Received Streamable HTTP request")
// 检查 Accept 头,判断客户端是否支持 SSE
acceptSSE := strings.Contains(r.Header.Get("Accept"), "text/event-stream")
// 处理请求
response := s.processJSONRPCRequest(&request, r.Context())
// 如果需要 SSE 且是支持流式的方法,使用 SSE 响应
if acceptSSE && s.isStreamableMethod(request.Method) {
s.sendSSEResponse(w, response)
} else {
// 否则使用普通 JSON 响应
s.sendJSONResponse(w, response)
}
}
// processJSONRPCRequest 处理 JSON-RPC 请求并返回响应
func (s *AppServer) processJSONRPCRequest(request *JSONRPCRequest, ctx context.Context) *JSONRPCResponse {
switch request.Method {
case "initialize":
return s.processInitialize(request)
case "initialized":
// 客户端确认初始化完成
return &JSONRPCResponse{
JSONRPC: "2.0",
Result: map[string]interface{}{},
ID: request.ID,
}
case "ping":
// 处理 ping 请求
return &JSONRPCResponse{
JSONRPC: "2.0",
Result: map[string]interface{}{},
ID: request.ID,
}
case "tools/list":
return s.processToolsList(request)
case "tools/call":
return s.processToolCall(ctx, request)
default:
return &JSONRPCResponse{
JSONRPC: "2.0",
Error: &JSONRPCError{
Code: -32601,
Message: "Method not found",
},
ID: request.ID,
}
}
}
// processInitialize 处理初始化请求
func (s *AppServer) processInitialize(request *JSONRPCRequest) *JSONRPCResponse {
result := map[string]interface{}{
"protocolVersion": "2025-03-26", // 使用新的协议版本
"capabilities": map[string]interface{}{
"tools": map[string]interface{}{},
},
"serverInfo": map[string]interface{}{
"name": "xiaohongshu-mcp",
"version": "2.0.0",
},
}
return &JSONRPCResponse{
JSONRPC: "2.0",
Result: result,
ID: request.ID,
}
}
// processToolsList 处理工具列表请求
func (s *AppServer) processToolsList(request *JSONRPCRequest) *JSONRPCResponse {
tools := []map[string]interface{}{
{
"name": "check_login_status",
"description": "检查小红书登录状态",
"inputSchema": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{},
},
},
{
"name": "get_login_qrcode",
"description": "获取登录二维码(返回 Base64 图片和超时时间)",
"inputSchema": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{},
},
},
{
"name": "publish_content",
"description": "发布小红书图文内容",
"inputSchema": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"title": map[string]interface{}{
"type": "string",
"description": "内容标题小红书限制最多20个中文字或英文单词",
},
"content": map[string]interface{}{
"type": "string",
"description": "正文内容,不包含以#开头的标签内容所有话题标签都用tags参数来生成和提供即可",
},
"images": map[string]interface{}{
"type": "array",
"description": "图片路径列表至少需要1张图片。支持两种方式1. HTTP/HTTPS图片链接自动下载2. 本地图片绝对路径(推荐,如:/Users/user/image.jpg",
"items": map[string]interface{}{
"type": "string",
},
"minItems": 1,
},
"tags": map[string]interface{}{
"type": "array",
"description": "话题标签列表(可选),如 [\"美食\", \"旅行\", \"生活\"]",
"items": map[string]interface{}{
"type": "string",
},
},
},
"required": []string{"title", "content", "images"},
},
},
{
"name": "list_feeds",
"description": "获取用户发布的内容列表",
"inputSchema": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{},
},
},
{
"name": "search_feeds",
"description": "搜索小红书内容(需要已登录)",
"inputSchema": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"keyword": map[string]interface{}{
"type": "string",
"description": "搜索关键词",
},
},
"required": []string{"keyword"},
},
},
{
"name": "get_feed_detail",
"description": "获取小红书笔记详情,返回笔记内容、图片、作者信息、互动数据(点赞/收藏/分享数)及评论列表",
"inputSchema": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"feed_id": map[string]interface{}{
"type": "string",
"description": "小红书笔记ID从Feed列表获取",
},
"xsec_token": map[string]interface{}{
"type": "string",
"description": "访问令牌从Feed列表的xsecToken字段获取",
},
},
"required": []string{"feed_id", "xsec_token"},
},
},
{
"name": "user_profile",
"description": "获取小红书用户主页,返回用户基本信息,关注、粉丝、获赞量及其笔记内容",
"inputSchema": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"user_id": map[string]interface{}{
"type": "string",
"description": "小红书用户ID从Feed列表获取",
},
"xsec_token": map[string]interface{}{
"type": "string",
"description": "访问令牌从Feed列表的xsecToken字段获取",
},
},
"required": []string{"user_id", "xsec_token"},
},
},
{
"name": "post_comment_to_feed",
"description": "发表评论到小红书笔记",
"inputSchema": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"feed_id": map[string]interface{}{
"type": "string",
"description": "小红书笔记ID从Feed列表获取",
},
"xsec_token": map[string]interface{}{
"type": "string",
"description": "访问令牌从Feed列表的xsecToken字段获取",
},
"content": map[string]interface{}{
"type": "string",
"description": "评论内容",
},
},
"required": []string{"feed_id", "xsec_token", "content"},
},
},
}
return &JSONRPCResponse{
JSONRPC: "2.0",
Result: map[string]interface{}{
"tools": tools,
},
ID: request.ID,
}
}
// processToolCall 处理工具调用
func (s *AppServer) processToolCall(ctx context.Context, request *JSONRPCRequest) *JSONRPCResponse {
// 解析参数
params, ok := request.Params.(map[string]interface{})
if !ok {
return &JSONRPCResponse{
JSONRPC: "2.0",
Error: &JSONRPCError{
Code: -32602,
Message: "Invalid params",
},
ID: request.ID,
}
}
toolName, _ := params["name"].(string)
toolArgs, _ := params["arguments"].(map[string]interface{})
var result *MCPToolResult
switch toolName {
case "check_login_status":
result = s.handleCheckLoginStatus(ctx)
case "get_login_qrcode":
result = s.handleGetLoginQrcode(ctx)
case "publish_content":
result = s.handlePublishContent(ctx, toolArgs)
case "list_feeds":
result = s.handleListFeeds(ctx)
case "search_feeds":
result = s.handleSearchFeeds(ctx, toolArgs)
case "get_feed_detail":
result = s.handleGetFeedDetail(ctx, toolArgs)
case "user_profile":
result = s.handleUserProfile(ctx, toolArgs)
case "post_comment_to_feed":
result = s.handlePostComment(ctx, toolArgs)
default:
return &JSONRPCResponse{
JSONRPC: "2.0",
Error: &JSONRPCError{
Code: -32602,
Message: fmt.Sprintf("Unknown tool: %s", toolName),
},
ID: request.ID,
}
}
return &JSONRPCResponse{
JSONRPC: "2.0",
Result: result,
ID: request.ID,
}
}
// isStreamableMethod 判断方法是否支持流式响应
func (s *AppServer) isStreamableMethod(_ string) bool {
// 目前我们的方法都不需要流式响应
// 未来可以在这里添加支持流式的方法
return false
}
// sendJSONResponse 发送普通 JSON 响应
func (s *AppServer) sendJSONResponse(w http.ResponseWriter, response *JSONRPCResponse) {
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(response); err != nil {
logrus.WithError(err).Error("Failed to encode response")
}
}
// sendSSEResponse 发送 SSE 响应
func (s *AppServer) sendSSEResponse(w http.ResponseWriter, response *JSONRPCResponse) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
// 将响应转换为 JSON
data, err := json.Marshal(response)
if err != nil {
logrus.WithError(err).Error("Failed to marshal SSE response")
return
}
// 发送 SSE 格式的响应
fmt.Fprintf(w, "data: %s\n\n", string(data))
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
}
// sendStreamableError 发送错误响应
func (s *AppServer) sendStreamableError(w http.ResponseWriter, id interface{}, code int, message string) {
response := &JSONRPCResponse{
JSONRPC: "2.0",
Error: &JSONRPCError{
Code: code,
Message: message,
},
ID: id,
}
s.sendJSONResponse(w, response)
}

View File

@@ -16,46 +16,15 @@ type SuccessResponse struct {
Message string `json:"message,omitempty"` Message string `json:"message,omitempty"`
} }
// JSON-RPC 相关类型 // MCP 相关类型(用于内部转换)
// JSONRPCRequest JSON-RPC 请求 // MCPToolResult MCP 工具结果(内部使用)
type JSONRPCRequest struct {
JSONRPC string `json:"jsonrpc"`
Method string `json:"method"`
Params any `json:"params,omitempty"`
ID any `json:"id"`
}
// JSONRPCResponse JSON-RPC 响应
type JSONRPCResponse struct {
JSONRPC string `json:"jsonrpc"`
Result any `json:"result,omitempty"`
Error *JSONRPCError `json:"error,omitempty"`
ID any `json:"id"`
}
// JSONRPCError JSON-RPC 错误
type JSONRPCError struct {
Code int `json:"code"`
Message string `json:"message"`
Data any `json:"data,omitempty"`
}
// MCP 相关类型
// MCPToolCall MCP 工具调用
type MCPToolCall struct {
Name string `json:"name"`
Arguments map[string]interface{} `json:"arguments"`
}
// MCPToolResult MCP 工具结果
type MCPToolResult struct { type MCPToolResult struct {
Content []MCPContent `json:"content"` Content []MCPContent `json:"content"`
IsError bool `json:"isError,omitempty"` IsError bool `json:"isError,omitempty"`
} }
// MCPContent MCP 内容 // MCPContent MCP 内容(内部使用)
type MCPContent struct { type MCPContent struct {
Type string `json:"type"` Type string `json:"type"`
Text string `json:"text"` Text string `json:"text"`