-
-
Notifications
You must be signed in to change notification settings - Fork 2k
Expand file tree
/
Copy pathmcp_server.go
More file actions
487 lines (441 loc) · 19 KB
/
mcp_server.go
File metadata and controls
487 lines (441 loc) · 19 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
package main
import (
"context"
"encoding/base64"
"fmt"
"runtime/debug"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/sirupsen/logrus"
)
// Helper functions for annotation pointers
func boolPtr(b bool) *bool { return &b }
// 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:"话题标签列表(可选参数),如 [美食, 旅行, 生活]"`
ScheduleAt string `json:"schedule_at,omitempty" jsonschema:"定时发布时间(可选),ISO8601格式如 2024-01-20T10:30:00+08:00,支持1小时至14天内。不填则立即发布"`
IsOriginal bool `json:"is_original,omitempty" jsonschema:"是否声明原创(可选),true为声明原创,false或不填则不声明"`
Visibility string `json:"visibility,omitempty" jsonschema:"可见范围(可选),支持: 公开可见(默认)、仅自己可见、仅互关好友可见。不填则默认公开可见"`
Products []string `json:"products,omitempty" jsonschema:"商品关键词列表(可选),用于绑定带货商品。填写商品名称或商品ID,系统会自动搜索并选择第一个匹配结果。需账号已开通商品功能。示例: [面膜, 防晒霜SPF50]"`
}
// PublishVideoArgs 发布视频的参数(仅支持本地单个视频文件)
type PublishVideoArgs struct {
Title string `json:"title" jsonschema:"内容标题(小红书限制:最多20个中文字或英文单词)"`
Content string `json:"content" jsonschema:"正文内容,不包含以#开头的标签内容,所有话题标签都用tags参数来生成和提供即可"`
Video string `json:"video" jsonschema:"本地视频绝对路径(仅支持单个视频文件,如:/Users/user/video.mp4)"`
Tags []string `json:"tags,omitempty" jsonschema:"话题标签列表(可选参数),如 [美食, 旅行, 生活]"`
ScheduleAt string `json:"schedule_at,omitempty" jsonschema:"定时发布时间(可选),ISO8601格式如 2024-01-20T10:30:00+08:00,支持1小时至14天内。不填则立即发布"`
Visibility string `json:"visibility,omitempty" jsonschema:"可见范围(可选),支持: 公开可见(默认)、仅自己可见、仅互关好友可见。不填则默认公开可见"`
Products []string `json:"products,omitempty" jsonschema:"商品关键词列表(可选),用于绑定带货商品。填写商品名称或商品ID,系统会自动搜索并选择第一个匹配结果。需账号已开通商品功能。示例: [面膜, 防晒霜SPF50]"`
}
// SearchFeedsArgs 搜索内容的参数
type SearchFeedsArgs struct {
Keyword string `json:"keyword" jsonschema:"搜索关键词"`
Filters FilterOption `json:"filters,omitempty" jsonschema:"筛选选项"`
}
// FilterOption 筛选选项结构体
type FilterOption struct {
SortBy string `json:"sort_by,omitempty" jsonschema:"排序依据: 综合|最新|最多点赞|最多评论|最多收藏,默认为'综合'"`
NoteType string `json:"note_type,omitempty" jsonschema:"笔记类型: 不限|视频|图文,默认为'不限'"`
PublishTime string `json:"publish_time,omitempty" jsonschema:"发布时间: 不限|一天内|一周内|半年内,默认为'不限'"`
SearchScope string `json:"search_scope,omitempty" jsonschema:"搜索范围: 不限|已看过|未看过|已关注,默认为'不限'"`
Location string `json:"location,omitempty" jsonschema:"位置距离: 不限|同城|附近,默认为'不限'"`
}
// FeedDetailArgs 获取Feed详情的参数
type FeedDetailArgs struct {
FeedID string `json:"feed_id" jsonschema:"小红书笔记ID,从Feed列表获取"`
XsecToken string `json:"xsec_token" jsonschema:"访问令牌,从Feed列表的xsecToken字段获取"`
LoadAllComments bool `json:"load_all_comments,omitempty" jsonschema:"是否加载全部评论。false仅返回前10条一级评论(默认),true滚动加载更多评论"`
Limit int `json:"limit,omitempty" jsonschema:"【仅当load_all_comments为true时生效】限制加载的一级评论数量。例如20表示最多加载20条,默认20"`
ClickMoreReplies bool `json:"click_more_replies,omitempty" jsonschema:"【仅当load_all_comments为true时生效】是否展开二级回复。true展开子评论,false不展开(默认)"`
ReplyLimit int `json:"reply_limit,omitempty" jsonschema:"【仅当click_more_replies为true时生效】跳过回复数过多的评论。例如10表示跳过超过10条回复的,默认10"`
ScrollSpeed string `json:"scroll_speed,omitempty" jsonschema:"【仅当load_all_comments为true时生效】滚动速度slow慢速、normal正常、fast快速"`
}
// 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:"评论内容"`
}
// ReplyCommentArgs 回复评论的参数
type ReplyCommentArgs struct {
FeedID string `json:"feed_id" jsonschema:"小红书笔记ID,从Feed列表获取"`
XsecToken string `json:"xsec_token" jsonschema:"访问令牌,从Feed列表的xsecToken字段获取"`
CommentID string `json:"comment_id,omitempty" jsonschema:"目标评论ID,从评论列表获取"`
UserID string `json:"user_id,omitempty" jsonschema:"目标评论用户ID,从评论列表获取"`
Content string `json:"content" jsonschema:"回复内容"`
}
// LikeFeedArgs 点赞参数
type LikeFeedArgs struct {
FeedID string `json:"feed_id" jsonschema:"小红书笔记ID,从Feed列表获取"`
XsecToken string `json:"xsec_token" jsonschema:"访问令牌,从Feed列表的xsecToken字段获取"`
Unlike bool `json:"unlike,omitempty" jsonschema:"是否取消点赞,true为取消点赞,false或未设置则为点赞"`
}
// FavoriteFeedArgs 收藏参数
type FavoriteFeedArgs struct {
FeedID string `json:"feed_id" jsonschema:"小红书笔记ID,从Feed列表获取"`
XsecToken string `json:"xsec_token" jsonschema:"访问令牌,从Feed列表的xsecToken字段获取"`
Unfavorite bool `json:"unfavorite,omitempty" jsonschema:"是否取消收藏,true为取消收藏,false或未设置则为收藏"`
}
// 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
}
func withPanicRecovery[T any](
toolName string,
handler func(context.Context, *mcp.CallToolRequest, T) (*mcp.CallToolResult, any, error),
) func(context.Context, *mcp.CallToolRequest, T) (*mcp.CallToolResult, any, error) {
return func(ctx context.Context, req *mcp.CallToolRequest, args T) (result *mcp.CallToolResult, resp any, err error) {
defer func() {
if r := recover(); r != nil {
logrus.WithFields(logrus.Fields{
"tool": toolName,
"panic": r,
}).Error("Tool handler panicked")
logrus.Errorf("Stack trace:\n%s", debug.Stack())
result = &mcp.CallToolResult{
Content: []mcp.Content{
&mcp.TextContent{
Text: fmt.Sprintf("工具 %s 执行时发生内部错误: %v\n\n请查看服务端日志获取详细信息。", toolName, r),
},
},
IsError: true,
}
resp = nil
err = nil
}
}()
return handler(ctx, req, args)
}
}
// registerTools 注册所有 MCP 工具
func registerTools(server *mcp.Server, appServer *AppServer) {
// 工具 1: 检查登录状态
mcp.AddTool(server,
&mcp.Tool{
Name: "check_login_status",
Description: "检查小红书登录状态",
Annotations: &mcp.ToolAnnotations{
Title: "Check Login Status",
ReadOnlyHint: true,
},
},
withPanicRecovery("check_login_status", 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 图片和超时时间)",
Annotations: &mcp.ToolAnnotations{
Title: "Get Login QR Code",
ReadOnlyHint: true,
},
},
withPanicRecovery("get_login_qrcode", func(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) {
result := appServer.handleGetLoginQrcode(ctx)
return convertToMCPResult(result), nil, nil
}),
)
// 工具 3: 删除 cookies(登录重置)
mcp.AddTool(server,
&mcp.Tool{
Name: "delete_cookies",
Description: "删除 cookies 文件,重置登录状态。删除后需要重新登录。",
Annotations: &mcp.ToolAnnotations{
Title: "Delete Cookies",
DestructiveHint: boolPtr(true),
},
},
withPanicRecovery("delete_cookies", func(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) {
result := appServer.handleDeleteCookies(ctx)
return convertToMCPResult(result), nil, nil
}),
)
// 工具 4: 发布内容
mcp.AddTool(server,
&mcp.Tool{
Name: "publish_content",
Description: "发布小红书图文内容",
Annotations: &mcp.ToolAnnotations{
Title: "Publish Content",
DestructiveHint: boolPtr(true),
},
},
withPanicRecovery("publish_content", 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),
"schedule_at": args.ScheduleAt,
"is_original": args.IsOriginal,
"visibility": args.Visibility,
"products": convertStringsToInterfaces(args.Products),
}
result := appServer.handlePublishContent(ctx, argsMap)
return convertToMCPResult(result), nil, nil
}),
)
// 工具 5: 获取Feed列表
mcp.AddTool(server,
&mcp.Tool{
Name: "list_feeds",
Description: "获取首页 Feeds 列表",
Annotations: &mcp.ToolAnnotations{
Title: "List Feeds",
ReadOnlyHint: true,
},
},
withPanicRecovery("list_feeds", func(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) {
result := appServer.handleListFeeds(ctx)
return convertToMCPResult(result), nil, nil
}),
)
// 工具 6: 搜索内容
mcp.AddTool(server,
&mcp.Tool{
Name: "search_feeds",
Description: "搜索小红书内容(需要已登录)",
Annotations: &mcp.ToolAnnotations{
Title: "Search Feeds",
ReadOnlyHint: true,
},
},
withPanicRecovery("search_feeds", func(ctx context.Context, req *mcp.CallToolRequest, args SearchFeedsArgs) (*mcp.CallToolResult, any, error) {
result := appServer.handleSearchFeeds(ctx, args)
return convertToMCPResult(result), nil, nil
}),
)
// 工具 7: 获取Feed详情
mcp.AddTool(server,
&mcp.Tool{
Name: "get_feed_detail",
Description: "获取小红书笔记详情,返回笔记内容、图片、作者信息、互动数据(点赞/收藏/分享数)及评论列表。默认返回前10条一级评论,如需更多评论请设置load_all_comments=true",
Annotations: &mcp.ToolAnnotations{
Title: "Get Feed Detail",
ReadOnlyHint: true,
},
},
withPanicRecovery("get_feed_detail", 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,
"load_all_comments": args.LoadAllComments,
}
// 只有当 load_all_comments=true 时,才处理其他参数
if args.LoadAllComments {
argsMap["click_more_replies"] = args.ClickMoreReplies
// 设置评论数量限制,默认20
limit := args.Limit
if limit <= 0 {
limit = 20
}
argsMap["max_comment_items"] = limit
// 设置回复数量阈值,默认10
replyLimit := args.ReplyLimit
if replyLimit <= 0 {
replyLimit = 10
}
argsMap["max_replies_threshold"] = replyLimit
if args.ScrollSpeed != "" {
argsMap["scroll_speed"] = args.ScrollSpeed
}
}
result := appServer.handleGetFeedDetail(ctx, argsMap)
return convertToMCPResult(result), nil, nil
}),
)
// 工具 8: 获取用户主页
mcp.AddTool(server,
&mcp.Tool{
Name: "user_profile",
Description: "获取指定的小红书用户主页,返回用户基本信息,关注、粉丝、获赞量及其笔记内容",
Annotations: &mcp.ToolAnnotations{
Title: "User Profile",
ReadOnlyHint: true,
},
},
withPanicRecovery("user_profile", 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
}),
)
// 工具 9: 发表评论
mcp.AddTool(server,
&mcp.Tool{
Name: "post_comment_to_feed",
Description: "发表评论到小红书笔记",
Annotations: &mcp.ToolAnnotations{
Title: "Post Comment",
DestructiveHint: boolPtr(true),
},
},
withPanicRecovery("post_comment_to_feed", 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
}),
)
// 工具 10: 回复评论
mcp.AddTool(server,
&mcp.Tool{
Name: "reply_comment_in_feed",
Description: "回复小红书笔记下的指定评论",
Annotations: &mcp.ToolAnnotations{
Title: "Reply Comment",
DestructiveHint: boolPtr(true),
},
},
func(ctx context.Context, req *mcp.CallToolRequest, args ReplyCommentArgs) (*mcp.CallToolResult, any, error) {
if args.CommentID == "" && args.UserID == "" {
return &mcp.CallToolResult{
IsError: true,
Content: []mcp.Content{&mcp.TextContent{Text: "缺少 comment_id 或 user_id"}},
}, nil, nil
}
argsMap := map[string]interface{}{
"feed_id": args.FeedID,
"xsec_token": args.XsecToken,
"comment_id": args.CommentID,
"user_id": args.UserID,
"content": args.Content,
}
result := appServer.handleReplyComment(ctx, argsMap)
return convertToMCPResult(result), nil, nil
},
)
// 工具 11: 发布视频(仅本地文件)
mcp.AddTool(server,
&mcp.Tool{
Name: "publish_with_video",
Description: "发布小红书视频内容(仅支持本地单个视频文件)",
Annotations: &mcp.ToolAnnotations{
Title: "Publish Video",
DestructiveHint: boolPtr(true),
},
},
withPanicRecovery("publish_with_video", func(ctx context.Context, req *mcp.CallToolRequest, args PublishVideoArgs) (*mcp.CallToolResult, any, error) {
argsMap := map[string]interface{}{
"title": args.Title,
"content": args.Content,
"video": args.Video,
"tags": convertStringsToInterfaces(args.Tags),
"schedule_at": args.ScheduleAt,
"visibility": args.Visibility,
"products": convertStringsToInterfaces(args.Products),
}
result := appServer.handlePublishVideo(ctx, argsMap)
return convertToMCPResult(result), nil, nil
}),
)
// 工具 12: 点赞笔记
mcp.AddTool(server,
&mcp.Tool{
Name: "like_feed",
Description: "为指定笔记点赞或取消点赞(如已点赞将跳过点赞,如未点赞将跳过取消点赞)",
Annotations: &mcp.ToolAnnotations{
Title: "Like Feed",
DestructiveHint: boolPtr(true),
},
},
withPanicRecovery("like_feed", func(ctx context.Context, req *mcp.CallToolRequest, args LikeFeedArgs) (*mcp.CallToolResult, any, error) {
argsMap := map[string]interface{}{
"feed_id": args.FeedID,
"xsec_token": args.XsecToken,
"unlike": args.Unlike,
}
result := appServer.handleLikeFeed(ctx, argsMap)
return convertToMCPResult(result), nil, nil
}),
)
// 工具 13: 收藏笔记
mcp.AddTool(server,
&mcp.Tool{
Name: "favorite_feed",
Description: "收藏指定笔记或取消收藏(如已收藏将跳过收藏,如未收藏将跳过取消收藏)",
Annotations: &mcp.ToolAnnotations{
Title: "Favorite Feed",
DestructiveHint: boolPtr(true),
},
},
withPanicRecovery("favorite_feed", func(ctx context.Context, req *mcp.CallToolRequest, args FavoriteFeedArgs) (*mcp.CallToolResult, any, error) {
argsMap := map[string]interface{}{
"feed_id": args.FeedID,
"xsec_token": args.XsecToken,
"unfavorite": args.Unfavorite,
}
result := appServer.handleFavoriteFeed(ctx, argsMap)
return convertToMCPResult(result), nil, nil
}),
)
logrus.Infof("Registered %d MCP tools", 13)
}
// 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
}