Commit 86ed10c7 authored by 郭阔's avatar 郭阔

Add a publish-only MCP mode for shared deployments

parent 3fe25414
...@@ -10,6 +10,30 @@ ...@@ -10,6 +10,30 @@
MCP for 小红书 / xiaohongshu.com。让你的 AI 助手直接访问小红书数据。 MCP for 小红书 / xiaohongshu.com。让你的 AI 助手直接访问小红书数据。
## 内网图文发布模式
`-publish-only` 只注册图文发布、主站登录状态、当前账号主页和搜索四个 MCP 工具。主页用于确认已发布笔记及链接,搜索兼容旧版 Skill。该模式要求配置 `AUTH_TOKEN`,客户端使用 `Authorization: Bearer <令牌>`。默认启动模式保留完整工具集。
支持 Windows x64 和 Linux x64。安装 Go 1.27.3 或兼容版本后,从本仓库构建:
```sh
# Linux x64
go build -o xhs-mcp .
go build -o xhs-login ./cmd/login
```
```powershell
# Windows x64
go build -o xhs-mcp.exe .
go build -o xhs-login.exe ./cmd/login
```
在同一工作目录运行登录工具和服务。管理员可在带桌面环境的部署机运行登录工具并扫码。无桌面的 Linux 服务器可先在回环地址临时启动完整模式,通过 SSH 隧道调用 `get_login_qrcode`,扫码成功后关闭临时服务,再启动图文发布模式。登录文件路径可用 `COOKIES_PATH` 固定。Linux 所需 Chromium 系统库见 [Dockerfile](./Dockerfile)。
设置 `AUTH_TOKEN` 后运行 `./xhs-mcp -publish-only -port 127.0.0.1:18060`,Windows 对应 `./xhs-mcp.exe -publish-only -port 127.0.0.1:18060`。通过 `/health` 验证服务已启动,再用 `check_login_status` 核对账号。
现有 Skill 仍连接编辑电脑的本机地址。供多人集中调用前,需要为 Skill 增加远程地址和令牌配置,并通过受控素材上传或受信任的素材域传递图片。服务接口支持读取部署机文件和下载图片,配置远程接入时应在入口限制素材来源。
### 🚀 快速开始:选择最适合你的版本 ### 🚀 快速开始:选择最适合你的版本
> [!IMPORTANT] > [!IMPORTANT]
......
...@@ -20,13 +20,19 @@ type AppServer struct { ...@@ -20,13 +20,19 @@ type AppServer struct {
router *gin.Engine router *gin.Engine
httpServer *http.Server httpServer *http.Server
authToken string authToken string
publishOnly bool
} }
// NewAppServer 创建新的应用服务器实例 // NewAppServer 创建新的应用服务器实例
func NewAppServer(xiaohongshuService *XiaohongshuService, authToken string) *AppServer { func NewAppServer(xiaohongshuService *XiaohongshuService, authToken string) *AppServer {
return newAppServer(xiaohongshuService, authToken, false)
}
func newAppServer(xiaohongshuService *XiaohongshuService, authToken string, publishOnly bool) *AppServer {
appServer := &AppServer{ appServer := &AppServer{
xiaohongshuService: xiaohongshuService, xiaohongshuService: xiaohongshuService,
authToken: authToken, authToken: authToken,
publishOnly: publishOnly,
} }
// 初始化 MCP Server(需要在创建 appServer 之后,因为工具注册需要访问 appServer) // 初始化 MCP Server(需要在创建 appServer 之后,因为工具注册需要访问 appServer)
......
...@@ -15,6 +15,7 @@ var version = "dev" ...@@ -15,6 +15,7 @@ var version = "dev"
func main() { func main() {
var ( var (
publishOnly bool
headless bool headless bool
port string port string
token string token string
...@@ -22,11 +23,16 @@ func main() { ...@@ -22,11 +23,16 @@ func main() {
flag.BoolVar(&headless, "headless", true, "是否无头模式") flag.BoolVar(&headless, "headless", true, "是否无头模式")
flag.StringVar(&port, "port", ":18060", "端口") flag.StringVar(&port, "port", ":18060", "端口")
flag.StringVar(&token, "token", "", "鉴权 Token,留空则读取 AUTH_TOKEN") flag.StringVar(&token, "token", "", "鉴权 Token,留空则读取 AUTH_TOKEN")
flag.BoolVar(&publishOnly, "publish-only", false, "仅提供图文发布、登录状态和笔记查询")
flag.Parse() flag.Parse()
if token == "" { if token == "" {
token = os.Getenv("AUTH_TOKEN") token = os.Getenv("AUTH_TOKEN")
} }
if publishOnly && token == "" {
logrus.Fatal("图文发布模式需要配置 AUTH_TOKEN")
}
logrus.Infof("xiaohongshu-mcp version: %s", version) logrus.Infof("xiaohongshu-mcp version: %s", version)
// 只用内置浏览器。启动时就备好,缺它直接退出,不拖到第一个请求才失败。 // 只用内置浏览器。启动时就备好,缺它直接退出,不拖到第一个请求才失败。
...@@ -47,7 +53,7 @@ func main() { ...@@ -47,7 +53,7 @@ func main() {
xiaohongshuService := NewXiaohongshuService() xiaohongshuService := NewXiaohongshuService()
// 创建并启动应用服务器 // 创建并启动应用服务器
appServer := NewAppServer(xiaohongshuService, token) appServer := newAppServer(xiaohongshuService, token, publishOnly)
if err := appServer.Start(port); 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)
} }
......
...@@ -195,39 +195,7 @@ func registerTools(server *mcp.Server, appServer *AppServer) { ...@@ -195,39 +195,7 @@ func registerTools(server *mcp.Server, appServer *AppServer) {
}), }),
) )
// 工具 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: 发布内容
publishContentSchema, err := jsonschema.For[PublishContentArgs](nil) publishContentSchema, err := jsonschema.For[PublishContentArgs](nil)
if err != nil { if err != nil {
panic(fmt.Sprintf("publish_content 参数 schema 生成失败: %v", err)) panic(fmt.Sprintf("publish_content 参数 schema 生成失败: %v", err))
...@@ -263,34 +231,87 @@ func registerTools(server *mcp.Server, appServer *AppServer) { ...@@ -263,34 +231,87 @@ func registerTools(server *mcp.Server, appServer *AppServer) {
}), }),
) )
// 工具 5: 获取Feed列表 // 搜索内容
mcp.AddTool(server, mcp.AddTool(server,
&mcp.Tool{ &mcp.Tool{
Name: "list_feeds", Name: "search_feeds",
Description: "获取首页 Feeds 列表", Description: "搜索小红书内容(需要已登录)",
Annotations: &mcp.ToolAnnotations{ Annotations: &mcp.ToolAnnotations{
Title: "List Feeds", Title: "Search Feeds",
ReadOnlyHint: true, ReadOnlyHint: true,
}, },
}, },
withPanicRecovery("list_feeds", func(ctx context.Context, req *mcp.CallToolRequest, _ any) (*mcp.CallToolResult, any, error) { withPanicRecovery("search_feeds", func(ctx context.Context, req *mcp.CallToolRequest, args SearchFeedsArgs) (*mcp.CallToolResult, any, error) {
result := appServer.handleListFeeds(ctx) result := appServer.handleSearchFeeds(ctx, args)
return convertToMCPResult(result), nil, nil return convertToMCPResult(result), nil, nil
}), }),
) )
// 工具 6: 搜索内容 // 获取我的主页
mcp.AddTool(server, mcp.AddTool(server,
&mcp.Tool{ &mcp.Tool{
Name: "search_feeds", Name: "get_my_profile",
Description: "搜索小红书内容(需要已登录)", Description: "获取当前登录用户的主页,返回用户基本信息,关注、粉丝、获赞量,以及指定 tab 下的内容。tab 可选 note(自己发的笔记,默认)、fav(自己收藏的)、liked(自己点赞的)",
Annotations: &mcp.ToolAnnotations{ Annotations: &mcp.ToolAnnotations{
Title: "Search Feeds", Title: "Get My Profile",
ReadOnlyHint: true, ReadOnlyHint: true,
}, },
}, },
withPanicRecovery("search_feeds", func(ctx context.Context, req *mcp.CallToolRequest, args SearchFeedsArgs) (*mcp.CallToolResult, any, error) { withPanicRecovery("get_my_profile", func(ctx context.Context, req *mcp.CallToolRequest, args MyProfileArgs) (*mcp.CallToolResult, any, error) {
result := appServer.handleSearchFeeds(ctx, args) result := appServer.handleGetMyProfile(ctx, args.Tab)
return convertToMCPResult(result), nil, nil
}),
)
if appServer.publishOnly {
logrus.Info("Registered 4 publish-only MCP tools")
return
}
// 工具 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
}),
)
// 工具 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 return convertToMCPResult(result), nil, nil
}), }),
) )
...@@ -480,22 +501,6 @@ func registerTools(server *mcp.Server, appServer *AppServer) { ...@@ -480,22 +501,6 @@ func registerTools(server *mcp.Server, appServer *AppServer) {
}), }),
) )
// 工具 14: 获取我的主页
mcp.AddTool(server,
&mcp.Tool{
Name: "get_my_profile",
Description: "获取当前登录用户的主页,返回用户基本信息,关注、粉丝、获赞量,以及指定 tab 下的内容。tab 可选 note(自己发的笔记,默认)、fav(自己收藏的)、liked(自己点赞的)",
Annotations: &mcp.ToolAnnotations{
Title: "Get My Profile",
ReadOnlyHint: true,
},
},
withPanicRecovery("get_my_profile", func(ctx context.Context, req *mcp.CallToolRequest, args MyProfileArgs) (*mcp.CallToolResult, any, error) {
result := appServer.handleGetMyProfile(ctx, args.Tab)
return convertToMCPResult(result), nil, nil
}),
)
// 工具 15: 通知未读数 // 工具 15: 通知未读数
mcp.AddTool(server, mcp.AddTool(server,
&mcp.Tool{ &mcp.Tool{
......
...@@ -46,22 +46,26 @@ func setupRoutes(appServer *AppServer) *gin.Engine { ...@@ -46,22 +46,26 @@ func setupRoutes(appServer *AppServer) *gin.Engine {
// API 路由组 // API 路由组
api := protected.Group("/api/v1") api := protected.Group("/api/v1")
{
api.GET("/login/status", appServer.checkLoginStatusHandler) api.GET("/login/status", appServer.checkLoginStatusHandler)
api.POST("/publish", appServer.publishHandler)
api.GET("/feeds/search", appServer.searchFeedsHandler)
api.POST("/feeds/search", appServer.searchFeedsHandler)
api.GET("/user/me", appServer.myProfileHandler)
if appServer.publishOnly {
return router
}
{
api.GET("/login/qrcode", appServer.getLoginQrcodeHandler) api.GET("/login/qrcode", appServer.getLoginQrcodeHandler)
api.DELETE("/login/cookies", appServer.deleteCookiesHandler) api.DELETE("/login/cookies", appServer.deleteCookiesHandler)
api.POST("/publish", appServer.publishHandler)
api.POST("/publish_video", appServer.publishVideoHandler) api.POST("/publish_video", appServer.publishVideoHandler)
api.GET("/feeds/list", appServer.listFeedsHandler) api.GET("/feeds/list", appServer.listFeedsHandler)
api.GET("/feeds/search", appServer.searchFeedsHandler)
api.POST("/feeds/search", appServer.searchFeedsHandler)
api.POST("/feeds/detail", appServer.getFeedDetailHandler) api.POST("/feeds/detail", appServer.getFeedDetailHandler)
api.POST("/user/profile", appServer.userProfileHandler) api.POST("/user/profile", appServer.userProfileHandler)
api.POST("/feeds/comment", appServer.postCommentHandler) api.POST("/feeds/comment", appServer.postCommentHandler)
api.POST("/feeds/comment/reply", appServer.replyCommentHandler) api.POST("/feeds/comment/reply", appServer.replyCommentHandler)
api.POST("/feeds/like", appServer.likeFeedHandler) api.POST("/feeds/like", appServer.likeFeedHandler)
api.POST("/feeds/favorite", appServer.favoriteFeedHandler) api.POST("/feeds/favorite", appServer.favoriteFeedHandler)
api.GET("/user/me", appServer.myProfileHandler)
api.GET("/notifications/unread", appServer.getUnreadCountHandler) api.GET("/notifications/unread", appServer.getUnreadCountHandler)
api.GET("/notifications/list", appServer.listNotificationsHandler) api.GET("/notifications/list", appServer.listNotificationsHandler)
api.POST("/notifications/list", appServer.listNotificationsHandler) api.POST("/notifications/list", appServer.listNotificationsHandler)
......
...@@ -205,3 +205,49 @@ func TestMCPRejectsNullJSONKey(t *testing.T) { ...@@ -205,3 +205,49 @@ func TestMCPRejectsNullJSONKey(t *testing.T) {
assert.Equal(t, http.StatusBadRequest, recorder.Code, "含 NUL 的 JSON 键应被拒绝") assert.Equal(t, http.StatusBadRequest, recorder.Code, "含 NUL 的 JSON 键应被拒绝")
} }
func TestPublishOnlySurface(t *testing.T) {
router := setupRoutes(newAppServer(NewXiaohongshuService(), "test-token", true))
listTools := func(token string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, "/mcp",
strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"tools/list"}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json, text/event-stream")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
return resp
}
require.Equal(t, http.StatusUnauthorized, listTools("").Code)
resp := listTools("test-token")
require.Equal(t, http.StatusOK, resp.Code)
var result struct {
Result struct {
Tools []struct {
Name string `json:"name"`
} `json:"tools"`
} `json:"result"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&result))
names := make([]string, 0, len(result.Result.Tools))
for _, tool := range result.Result.Tools {
names = append(names, tool.Name)
}
assert.ElementsMatch(t, []string{"check_login_status", "publish_content", "get_my_profile", "search_feeds"}, names)
for _, route := range []struct{ method, path string }{
{http.MethodGet, "/api/v1/login/qrcode"},
{http.MethodDelete, "/api/v1/login/cookies"},
{http.MethodPost, "/api/v1/publish_video"},
{http.MethodPost, "/api/v1/feeds/comment"},
{http.MethodGet, "/api/v1/notifications/list"},
} {
req := httptest.NewRequest(route.method, route.path, nil)
req.Header.Set("Authorization", "Bearer test-token")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
assert.Equal(t, http.StatusNotFound, resp.Code, route.path)
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment