From 53ea83277381da7189318982a03a074a2a48958a Mon Sep 17 00:00:00 2001 From: zy Date: Sat, 27 Sep 2025 01:44:02 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E8=BF=81=E7=A7=BB=E5=88=B0?= =?UTF-8?q?=E5=AE=98=E6=96=B9=20MCP=20SDK=20(#167)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加官方 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 --- app_server.go | 19 ++- go.mod | 5 +- go.sum | 22 ++- main.go | 4 +- mcp_server.go | 226 +++++++++++++++++++++++++ routes.go | 14 +- streamable_http.go | 402 --------------------------------------------- types.go | 37 +---- 8 files changed, 270 insertions(+), 459 deletions(-) create mode 100644 mcp_server.go delete mode 100644 streamable_http.go diff --git a/app_server.go b/app_server.go index 83d2e72..29d531a 100644 --- a/app_server.go +++ b/app_server.go @@ -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 } diff --git a/go.mod b/go.mod index 662121f..d1bc3ba 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index f431496..25fc399 100644 --- a/go.sum +++ b/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= diff --git a/main.go b/main.go index 789ca46..31bd04d 100644 --- a/main.go +++ b/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) } } diff --git a/mcp_server.go b/mcp_server.go new file mode 100644 index 0000000..b407bd4 --- /dev/null +++ b/mcp_server.go @@ -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 +} diff --git a/routes.go b/routes.go index 3985942..ea6b9d9 100644 --- a/routes.go +++ b/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)) diff --git a/streamable_http.go b/streamable_http.go deleted file mode 100644 index e16ea43..0000000 --- a/streamable_http.go +++ /dev/null @@ -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) -} diff --git a/types.go b/types.go index 3adca49..afd5f07 100644 --- a/types.go +++ b/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"`