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:
@@ -9,21 +9,28 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// AppServer 应用服务器结构体,封装所有服务和处理器
|
||||
type AppServer struct {
|
||||
xiaohongshuService *XiaohongshuService
|
||||
mcpServer *mcp.Server
|
||||
router *gin.Engine
|
||||
httpServer *http.Server
|
||||
}
|
||||
|
||||
// NewAppServer 创建新的应用服务器实例
|
||||
func NewAppServer(xiaohongshuService *XiaohongshuService) *AppServer {
|
||||
return &AppServer{
|
||||
appServer := &AppServer{
|
||||
xiaohongshuService: xiaohongshuService,
|
||||
}
|
||||
|
||||
// 初始化 MCP Server(需要在创建 appServer 之后,因为工具注册需要访问 appServer)
|
||||
appServer.mcpServer = InitMCPServer(appServer)
|
||||
|
||||
return appServer
|
||||
}
|
||||
|
||||
// Start 启动服务器
|
||||
@@ -51,16 +58,14 @@ func (s *AppServer) Start(port string) error {
|
||||
|
||||
logrus.Infof("正在关闭服务器...")
|
||||
|
||||
// 优雅关闭
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// 关闭 HTTP 服务器
|
||||
if err := s.httpServer.Shutdown(ctx); err != nil {
|
||||
logrus.Errorf("服务器关闭失败: %v", err)
|
||||
return err
|
||||
logrus.Warnf("等待连接关闭超时,强制退出: %v", err)
|
||||
} else {
|
||||
logrus.Infof("服务器已优雅关闭")
|
||||
}
|
||||
|
||||
logrus.Infof("服务器已关闭")
|
||||
return nil
|
||||
}
|
||||
|
||||
5
go.mod
5
go.mod
@@ -6,6 +6,8 @@ require (
|
||||
github.com/gin-gonic/gin v1.10.1
|
||||
github.com/go-rod/rod v0.116.2
|
||||
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/sirupsen/logrus v1.9.3
|
||||
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-rod/stealth v0.4.9 // 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/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // 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/reflect2 v1.0.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/twitchyliquid64/golang-asm v0.15.1 // 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/goob v0.4.0 // indirect
|
||||
github.com/ysmood/got v0.41.0 // indirect
|
||||
|
||||
22
go.sum
22
go.sum
@@ -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/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
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.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
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/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/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY=
|
||||
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-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
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-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
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/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
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/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/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/go.mod h1:u6yx7ZhS4Exf2MwciFr6nIM8knHQIE22lFpWHnfql18=
|
||||
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.5.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/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/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
||||
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/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
|
||||
4
main.go
4
main.go
@@ -12,9 +12,11 @@ func main() {
|
||||
var (
|
||||
headless bool
|
||||
binPath string // 浏览器二进制文件路径
|
||||
port string
|
||||
)
|
||||
flag.BoolVar(&headless, "headless", true, "是否无头模式")
|
||||
flag.StringVar(&binPath, "bin", "", "浏览器二进制文件路径")
|
||||
flag.StringVar(&port, "port", ":18060", "端口")
|
||||
flag.Parse()
|
||||
|
||||
if len(binPath) == 0 {
|
||||
@@ -29,7 +31,7 @@ func main() {
|
||||
|
||||
// 创建并启动应用服务器
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
226
mcp_server.go
Normal file
226
mcp_server.go
Normal 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
|
||||
}
|
||||
14
routes.go
14
routes.go
@@ -1,7 +1,10 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
// setupRoutes 设置路由配置
|
||||
@@ -20,8 +23,15 @@ func setupRoutes(appServer *AppServer) *gin.Engine {
|
||||
// 健康检查
|
||||
router.GET("/health", healthHandler)
|
||||
|
||||
// MCP 端点 - 使用 Streamable HTTP 协议
|
||||
mcpHandler := appServer.StreamableHTTPHandler()
|
||||
// MCP 端点 - 使用官方 SDK 的 Streamable HTTP Handler
|
||||
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/*path", gin.WrapH(mcpHandler))
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
37
types.go
37
types.go
@@ -16,46 +16,15 @@ type SuccessResponse struct {
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// JSON-RPC 相关类型
|
||||
// MCP 相关类型(用于内部转换)
|
||||
|
||||
// JSONRPCRequest JSON-RPC 请求
|
||||
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 工具结果
|
||||
// MCPToolResult MCP 工具结果(内部使用)
|
||||
type MCPToolResult struct {
|
||||
Content []MCPContent `json:"content"`
|
||||
IsError bool `json:"isError,omitempty"`
|
||||
}
|
||||
|
||||
// MCPContent MCP 内容
|
||||
// MCPContent MCP 内容(内部使用)
|
||||
type MCPContent struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
|
||||
Reference in New Issue
Block a user