diff --git a/.github/workflows/mcp-ci.yml b/.github/workflows/mcp-ci.yml new file mode 100644 index 0000000..744e707 --- /dev/null +++ b/.github/workflows/mcp-ci.yml @@ -0,0 +1,37 @@ +name: MCP CI + +on: + push: + branches: [main] + paths: + - "mcp/**" + - "go.mod" + - "go.sum" + - ".github/workflows/mcp-ci.yml" + pull_request: + paths: + - "mcp/**" + - "**/*.go" + - "go.mod" + - "go.sum" + - ".github/workflows/mcp-ci.yml" + +jobs: + mcp: + name: mcp package + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.25" + - name: Tidy check + run: | + go mod tidy + git diff --exit-code go.mod go.sum + - name: Vet + run: go vet ./... + - name: Build + run: go build ./... + - name: Test mcp package + run: go test -race -count=1 ./mcp/... diff --git a/README.md b/README.md index c9c85dc..177f013 100644 --- a/README.md +++ b/README.md @@ -316,6 +316,27 @@ if errors.Is(err, x.ErrDMClosed) { /* recipient has DMs closed */ } if errors.Is(err, x.ErrPartialResult) { /* context cancelled mid-scrape */ } ``` +## MCP support + +This package ships an [MCP](https://modelcontextprotocol.io/) tool surface in `./mcp` for use with [`teslashibe/mcptool`](https://github.com/teslashibe/mcptool)-compatible hosts (e.g. [`teslashibe/agent-setup`](https://github.com/teslashibe/agent-setup)). 37 tools cover the full client API: profile fetch (handle/ID/me), follower/following graph, home + latest timelines, tweet fetch + thread, user-tweet feed, simple/user/advanced search, tweet compose (create/reply/quote/delete), engagement (like/unlike/retweet/unretweet/bookmark/unbookmark), social graph writes (follow/unfollow/mute/unmute/block/unblock), DMs (list/read/send/cold-send), lists (metadata/timeline/members), and timeline trend analysis. + +```go +import ( + "github.com/teslashibe/mcptool" + x "github.com/teslashibe/x-go" + xmcp "github.com/teslashibe/x-go/mcp" +) + +client, _ := x.New(x.Cookies{...}) +provider := xmcp.Provider{} +for _, tool := range provider.Tools() { + // register tool with your MCP server, passing client as the + // opaque client argument when invoking +} +``` + +A coverage test in `mcp/mcp_test.go` fails if a new exported method is added to `*Client` without either being wrapped by an MCP tool or being added to `mcp.Excluded` with a reason — keeping the MCP surface in lockstep with the package API is enforced by CI rather than convention. + ## Testing ```bash diff --git a/go.mod b/go.mod index dcc9921..7bdca74 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,14 @@ module github.com/teslashibe/x-go go 1.25.5 + +require github.com/teslashibe/mcptool v0.1.0 + +require ( + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/buger/jsonparser v1.1.1 // indirect + github.com/invopop/jsonschema v0.13.0 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..3778ddc --- /dev/null +++ b/go.sum @@ -0,0 +1,23 @@ +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= +github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/teslashibe/mcptool v0.1.0 h1:GFL4SmtpgTuwq+4/O/xnE1UQbwsSnbNh2mYU1Ipdwgk= +github.com/teslashibe/mcptool v0.1.0/go.mod h1:pEC1/efy4fCDr9NqvX1es+bdud7Q7qe2Uz2BR7ppTgA= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/mcp/actions.go b/mcp/actions.go new file mode 100644 index 0000000..790770d --- /dev/null +++ b/mcp/actions.go @@ -0,0 +1,95 @@ +package mcp + +import ( + "context" + + "github.com/teslashibe/mcptool" + x "github.com/teslashibe/x-go" +) + +// TweetActionInput is the shared typed input for the like/retweet/bookmark +// family of tools, all of which take just a tweet ID. +type TweetActionInput struct { + TweetID string `json:"tweet_id" jsonschema:"description=numeric tweet ID,required"` +} + +func like(ctx context.Context, c *x.Client, in TweetActionInput) (any, error) { + if err := c.Like(ctx, in.TweetID); err != nil { + return nil, err + } + return map[string]any{"ok": true, "tweet_id": in.TweetID}, nil +} + +func unlike(ctx context.Context, c *x.Client, in TweetActionInput) (any, error) { + if err := c.Unlike(ctx, in.TweetID); err != nil { + return nil, err + } + return map[string]any{"ok": true, "tweet_id": in.TweetID}, nil +} + +func retweet(ctx context.Context, c *x.Client, in TweetActionInput) (any, error) { + if err := c.Retweet(ctx, in.TweetID); err != nil { + return nil, err + } + return map[string]any{"ok": true, "tweet_id": in.TweetID}, nil +} + +func unretweet(ctx context.Context, c *x.Client, in TweetActionInput) (any, error) { + if err := c.Unretweet(ctx, in.TweetID); err != nil { + return nil, err + } + return map[string]any{"ok": true, "tweet_id": in.TweetID}, nil +} + +func bookmark(ctx context.Context, c *x.Client, in TweetActionInput) (any, error) { + if err := c.Bookmark(ctx, in.TweetID); err != nil { + return nil, err + } + return map[string]any{"ok": true, "tweet_id": in.TweetID}, nil +} + +func unbookmark(ctx context.Context, c *x.Client, in TweetActionInput) (any, error) { + if err := c.Unbookmark(ctx, in.TweetID); err != nil { + return nil, err + } + return map[string]any{"ok": true, "tweet_id": in.TweetID}, nil +} + +var actionTools = []mcptool.Tool{ + mcptool.Define[*x.Client, TweetActionInput]( + "x_like", + "Like a tweet", + "Like", + like, + ), + mcptool.Define[*x.Client, TweetActionInput]( + "x_unlike", + "Remove a like from a tweet", + "Unlike", + unlike, + ), + mcptool.Define[*x.Client, TweetActionInput]( + "x_retweet", + "Retweet a tweet", + "Retweet", + retweet, + ), + mcptool.Define[*x.Client, TweetActionInput]( + "x_unretweet", + "Undo a retweet", + "Unretweet", + unretweet, + ), + mcptool.Define[*x.Client, TweetActionInput]( + "x_bookmark", + "Bookmark a tweet", + "Bookmark", + bookmark, + ), + mcptool.Define[*x.Client, TweetActionInput]( + "x_unbookmark", + "Remove a tweet from bookmarks", + "Unbookmark", + unbookmark, + ), +} diff --git a/mcp/compose.go b/mcp/compose.go new file mode 100644 index 0000000..d80e7ae --- /dev/null +++ b/mcp/compose.go @@ -0,0 +1,105 @@ +package mcp + +import ( + "context" + + "github.com/teslashibe/mcptool" + x "github.com/teslashibe/x-go" +) + +// CreateTweetInput is the typed input for x_create_tweet. +type CreateTweetInput struct { + Text string `json:"text" jsonschema:"description=tweet body (≤280 chars),required"` + MediaIDs []string `json:"media_ids,omitempty" jsonschema:"description=optional list of pre-uploaded media IDs to attach"` + PossiblySensitive bool `json:"possibly_sensitive,omitempty" jsonschema:"description=mark attached media as possibly sensitive"` +} + +func tweetOptions(mediaIDs []string, possiblySensitive bool) []x.TweetOption { + var opts []x.TweetOption + if len(mediaIDs) > 0 { + opts = append(opts, x.WithMediaIDs(mediaIDs...)) + } + if possiblySensitive { + opts = append(opts, x.WithPossiblySensitive()) + } + return opts +} + +func createTweet(ctx context.Context, c *x.Client, in CreateTweetInput) (any, error) { + tw, err := c.CreateTweet(ctx, in.Text, tweetOptions(in.MediaIDs, in.PossiblySensitive)...) + if err != nil { + return nil, err + } + return map[string]any{"ok": true, "tweet_id": tw.ID, "tweet": tw}, nil +} + +// ReplyInput is the typed input for x_reply. +type ReplyInput struct { + InReplyToID string `json:"in_reply_to_id" jsonschema:"description=tweet ID being replied to,required"` + Text string `json:"text" jsonschema:"description=reply body (≤280 chars),required"` + MediaIDs []string `json:"media_ids,omitempty" jsonschema:"description=optional list of pre-uploaded media IDs to attach"` + PossiblySensitive bool `json:"possibly_sensitive,omitempty" jsonschema:"description=mark attached media as possibly sensitive"` +} + +func reply(ctx context.Context, c *x.Client, in ReplyInput) (any, error) { + tw, err := c.Reply(ctx, in.InReplyToID, in.Text, tweetOptions(in.MediaIDs, in.PossiblySensitive)...) + if err != nil { + return nil, err + } + return map[string]any{"ok": true, "tweet_id": tw.ID, "in_reply_to_id": in.InReplyToID, "tweet": tw}, nil +} + +// QuoteTweetInput is the typed input for x_quote_tweet. +type QuoteTweetInput struct { + QuotedTweetURL string `json:"quoted_tweet_url" jsonschema:"description=full URL of the tweet being quoted (https://x.com//status/),required"` + Text string `json:"text" jsonschema:"description=quote tweet body (≤280 chars),required"` + MediaIDs []string `json:"media_ids,omitempty" jsonschema:"description=optional list of pre-uploaded media IDs to attach"` + PossiblySensitive bool `json:"possibly_sensitive,omitempty" jsonschema:"description=mark attached media as possibly sensitive"` +} + +func quoteTweet(ctx context.Context, c *x.Client, in QuoteTweetInput) (any, error) { + tw, err := c.QuoteTweet(ctx, in.QuotedTweetURL, in.Text, tweetOptions(in.MediaIDs, in.PossiblySensitive)...) + if err != nil { + return nil, err + } + return map[string]any{"ok": true, "tweet_id": tw.ID, "quoted_tweet_url": in.QuotedTweetURL, "tweet": tw}, nil +} + +// DeleteTweetInput is the typed input for x_delete_tweet. +type DeleteTweetInput struct { + TweetID string `json:"tweet_id" jsonschema:"description=ID of the tweet to delete (must be owned by the authenticated user),required"` +} + +func deleteTweet(ctx context.Context, c *x.Client, in DeleteTweetInput) (any, error) { + if err := c.DeleteTweet(ctx, in.TweetID); err != nil { + return nil, err + } + return map[string]any{"ok": true, "tweet_id": in.TweetID}, nil +} + +var composeTools = []mcptool.Tool{ + mcptool.Define[*x.Client, CreateTweetInput]( + "x_create_tweet", + "Publish a new tweet (≤280 chars; optional media attachments)", + "CreateTweet", + createTweet, + ), + mcptool.Define[*x.Client, ReplyInput]( + "x_reply", + "Publish a reply to an existing tweet", + "Reply", + reply, + ), + mcptool.Define[*x.Client, QuoteTweetInput]( + "x_quote_tweet", + "Publish a quote tweet referencing another tweet by URL", + "QuoteTweet", + quoteTweet, + ), + mcptool.Define[*x.Client, DeleteTweetInput]( + "x_delete_tweet", + "Delete a tweet owned by the authenticated user", + "DeleteTweet", + deleteTweet, + ), +} diff --git a/mcp/dm.go b/mcp/dm.go new file mode 100644 index 0000000..08b6953 --- /dev/null +++ b/mcp/dm.go @@ -0,0 +1,87 @@ +package mcp + +import ( + "context" + + "github.com/teslashibe/mcptool" + x "github.com/teslashibe/x-go" +) + +// SendDMInput is the typed input for x_send_dm. +type SendDMInput struct { + ConversationID string `json:"conversation_id" jsonschema:"description=existing DM conversation ID (e.g. '-'),required"` + Text string `json:"text" jsonschema:"description=message body,required"` +} + +func sendDM(ctx context.Context, c *x.Client, in SendDMInput) (any, error) { + msg, err := c.SendDM(ctx, in.ConversationID, in.Text) + if err != nil { + return nil, err + } + return map[string]any{"ok": true, "conversation_id": in.ConversationID, "message_id": msg.ID, "message": msg}, nil +} + +// SendNewDMInput is the typed input for x_send_new_dm. +type SendNewDMInput struct { + RecipientID string `json:"recipient_id" jsonschema:"description=numeric X user ID of the recipient (creates a 1:1 conversation if none exists),required"` + Text string `json:"text" jsonschema:"description=message body,required"` +} + +func sendNewDM(ctx context.Context, c *x.Client, in SendNewDMInput) (any, error) { + msg, err := c.SendNewDM(ctx, in.RecipientID, in.Text) + if err != nil { + return nil, err + } + return map[string]any{"ok": true, "recipient_id": in.RecipientID, "conversation_id": msg.ConversationID, "message_id": msg.ID, "message": msg}, nil +} + +// GetConversationsInput is the typed input for x_get_conversations. +type GetConversationsInput struct{} + +func getConversations(ctx context.Context, c *x.Client, _ GetConversationsInput) (any, error) { + res, err := c.GetConversations(ctx) + if err != nil { + return nil, err + } + return mcptool.PageOf(res.Conversations, res.NextCursor, 0), nil +} + +// GetConversationInput is the typed input for x_get_conversation. +type GetConversationInput struct { + ConversationID string `json:"conversation_id" jsonschema:"description=DM conversation ID to fetch messages from,required"` +} + +func getConversation(ctx context.Context, c *x.Client, in GetConversationInput) (any, error) { + res, err := c.GetConversation(ctx, in.ConversationID) + if err != nil { + return nil, err + } + return mcptool.PageOf(res.Messages, res.NextCursor, 0), nil +} + +var dmTools = []mcptool.Tool{ + mcptool.Define[*x.Client, SendDMInput]( + "x_send_dm", + "Send a direct message in an existing X DM conversation", + "SendDM", + sendDM, + ), + mcptool.Define[*x.Client, SendNewDMInput]( + "x_send_new_dm", + "Send a direct message to a user by ID, creating a new conversation if needed", + "SendNewDM", + sendNewDM, + ), + mcptool.Define[*x.Client, GetConversationsInput]( + "x_get_conversations", + "List the authenticated user's DM conversations", + "GetConversations", + getConversations, + ), + mcptool.Define[*x.Client, GetConversationInput]( + "x_get_conversation", + "Fetch the messages of a specific DM conversation by ID", + "GetConversation", + getConversation, + ), +} diff --git a/mcp/excluded.go b/mcp/excluded.go new file mode 100644 index 0000000..b561b53 --- /dev/null +++ b/mcp/excluded.go @@ -0,0 +1,35 @@ +package mcp + +// Excluded enumerates exported methods on *x.Client that are intentionally +// not exposed via MCP. Each entry must have a non-empty reason. +// +// The coverage test in mcp_test.go fails if any exported method on *Client +// is neither wrapped by a Tool nor present in this map (or vice-versa: if +// an entry here doesn't correspond to a real method). +// +// When the underlying client gains a new method: +// - prefer to add an MCP tool for it (see tweets.go / search.go / etc.) +// - if the method is unsuitable for an agent (internal observability, +// auth-only helper, convenience wrapper around a more general method, +// etc.), add it here with a reason +var Excluded = map[string]string{ + // Internal observability / lifecycle helpers — surfaced via the host + // application's MCP middleware or initialization, not as callable tools. + "RateLimit": "internal observability; surfaced via the host application's MCP middleware, not as a callable tool", + "TransactionInitErr": "internal observability of client bootstrap state; reported at construction, not as a callable tool", + "RefreshQueryIDs": "internal session-bootstrap helper; managed by the client lifecycle, not exposed as an agent-callable tool", + + // Convenience wrappers around the *Page variants — the MCP tool wraps + // the cursor-aware *Page method so a single tool covers both first-page + // and pagination use cases. + "HomeTimeline": "convenience wrapper around HomeTimelinePage; the x_home_timeline tool wraps the cursor-aware variant", + "HomeLatestTimeline": "convenience wrapper around HomeLatestTimelinePage; the x_home_latest_timeline tool wraps the cursor-aware variant", + "UserTweets": "convenience wrapper around UserTweetsPage; the x_get_user_tweets tool wraps the cursor-aware variant", + "SearchTweets": "convenience wrapper around SearchTweetsPage; the x_search_tweets tool wraps the cursor-aware variant", + "SearchUsers": "convenience wrapper around SearchUsersPage; the x_search_users tool wraps the cursor-aware variant", + "AdvancedSearchTweets": "convenience wrapper around AdvancedSearchTweetsPage; the x_advanced_search_tweets tool wraps the cursor-aware variant", + "GetFollowers": "convenience wrapper around GetFollowersPage; the x_get_followers tool wraps the cursor-aware variant", + "GetFollowing": "convenience wrapper around GetFollowingPage; the x_get_following tool wraps the cursor-aware variant", + "GetListTimeline": "convenience wrapper around GetListTimelinePage; the x_get_list_timeline tool wraps the cursor-aware variant", + "GetListMembers": "convenience wrapper around GetListMembersPage; the x_get_list_members tool wraps the cursor-aware variant", +} diff --git a/mcp/lists.go b/mcp/lists.go new file mode 100644 index 0000000..833b275 --- /dev/null +++ b/mcp/lists.go @@ -0,0 +1,76 @@ +package mcp + +import ( + "context" + + "github.com/teslashibe/mcptool" + x "github.com/teslashibe/x-go" +) + +// GetListInput is the typed input for x_get_list. +type GetListInput struct { + ListID string `json:"list_id" jsonschema:"description=numeric X list ID,required"` +} + +func getList(ctx context.Context, c *x.Client, in GetListInput) (any, error) { + return c.GetList(ctx, in.ListID) +} + +// GetListTimelineInput is the typed input for x_get_list_timeline. +type GetListTimelineInput struct { + ListID string `json:"list_id" jsonschema:"description=numeric X list ID,required"` + Count int `json:"count,omitempty" jsonschema:"description=results per page,minimum=1,maximum=200,default=20"` + Cursor string `json:"cursor,omitempty" jsonschema:"description=opaque pagination cursor returned by a previous call (next_cursor)"` +} + +func getListTimeline(ctx context.Context, c *x.Client, in GetListTimelineInput) (any, error) { + res, err := c.GetListTimelinePage(ctx, in.ListID, in.Count, in.Cursor) + if err != nil { + return nil, err + } + limit := in.Count + if limit <= 0 { + limit = 20 + } + return mcptool.PageOf(res.Tweets, res.NextCursor, limit), nil +} + +// GetListMembersInput is the typed input for x_get_list_members. +type GetListMembersInput struct { + ListID string `json:"list_id" jsonschema:"description=numeric X list ID,required"` + Count int `json:"count,omitempty" jsonschema:"description=results per page,minimum=1,maximum=200,default=20"` + Cursor string `json:"cursor,omitempty" jsonschema:"description=opaque pagination cursor returned by a previous call (next_cursor)"` +} + +func getListMembers(ctx context.Context, c *x.Client, in GetListMembersInput) (any, error) { + res, err := c.GetListMembersPage(ctx, in.ListID, in.Count, in.Cursor) + if err != nil { + return nil, err + } + limit := in.Count + if limit <= 0 { + limit = 20 + } + return mcptool.PageOf(res.Users, res.NextCursor, limit), nil +} + +var listTools = []mcptool.Tool{ + mcptool.Define[*x.Client, GetListInput]( + "x_get_list", + "Fetch metadata for an X list by ID", + "GetList", + getList, + ), + mcptool.Define[*x.Client, GetListTimelineInput]( + "x_get_list_timeline", + "Fetch a page of tweets from an X list (cursor-paginated)", + "GetListTimelinePage", + getListTimeline, + ), + mcptool.Define[*x.Client, GetListMembersInput]( + "x_get_list_members", + "Fetch a page of members of an X list (cursor-paginated)", + "GetListMembersPage", + getListMembers, + ), +} diff --git a/mcp/mcp.go b/mcp/mcp.go new file mode 100644 index 0000000..a400cf5 --- /dev/null +++ b/mcp/mcp.go @@ -0,0 +1,63 @@ +// Package mcp exposes the x-go [x.Client] surface as a set of MCP (Model +// Context Protocol) tools that any host application can mount on its own +// MCP server. +// +// All tools wrap exported methods on *x.Client. Each tool is defined via +// [mcptool.Define] so the JSON input schema is reflected from the typed +// input struct — no hand-maintained schemas, no drift. +// +// Usage from a host application: +// +// import ( +// "github.com/teslashibe/mcptool" +// x "github.com/teslashibe/x-go" +// xmcp "github.com/teslashibe/x-go/mcp" +// ) +// +// client, _ := x.New(x.Cookies{...}) +// for _, tool := range (xmcp.Provider{}).Tools() { +// // register tool with your MCP server, passing client as the client +// // argument when invoking +// } +// +// The [Excluded] map documents methods on *Client that are intentionally +// not exposed via MCP, with a one-line reason. The coverage test in +// mcp_test.go fails if a new exported method is added without either being +// wrapped by a tool or appearing in [Excluded]. +package mcp + +import "github.com/teslashibe/mcptool" + +// Provider implements [mcptool.Provider] for x-go. The zero value is ready +// to use. +type Provider struct{} + +// Platform returns "x". +func (Provider) Platform() string { return "x" } + +// Tools returns every x-go MCP tool, in registration order. +func (Provider) Tools() []mcptool.Tool { + out := make([]mcptool.Tool, 0, + len(userTools)+ + len(tweetTools)+ + len(timelineTools)+ + len(searchTools)+ + len(composeTools)+ + len(actionTools)+ + len(socialTools)+ + len(dmTools)+ + len(listTools)+ + len(trendTools), + ) + out = append(out, userTools...) + out = append(out, tweetTools...) + out = append(out, timelineTools...) + out = append(out, searchTools...) + out = append(out, composeTools...) + out = append(out, actionTools...) + out = append(out, socialTools...) + out = append(out, dmTools...) + out = append(out, listTools...) + out = append(out, trendTools...) + return out +} diff --git a/mcp/mcp_test.go b/mcp/mcp_test.go new file mode 100644 index 0000000..878f747 --- /dev/null +++ b/mcp/mcp_test.go @@ -0,0 +1,58 @@ +package mcp_test + +import ( + "reflect" + "strings" + "testing" + + "github.com/teslashibe/mcptool" + x "github.com/teslashibe/x-go" + xmcp "github.com/teslashibe/x-go/mcp" +) + +// TestEveryClientMethodIsWrappedOrExcluded fails when a new exported method +// is added to *x.Client without either being wrapped by an MCP tool or +// being added to xmcp.Excluded with a reason. This is the drift-prevention +// mechanism: keeping the MCP surface in lockstep with the package API is +// enforced by CI rather than convention. +func TestEveryClientMethodIsWrappedOrExcluded(t *testing.T) { + rep := mcptool.Coverage( + reflect.TypeOf(&x.Client{}), + (xmcp.Provider{}).Tools(), + xmcp.Excluded, + ) + if len(rep.Missing) > 0 { + t.Fatalf("methods missing MCP exposure (add a tool or list in excluded.go): %v", rep.Missing) + } + if len(rep.UnknownExclusions) > 0 { + t.Fatalf("excluded.go references methods that don't exist on *Client (rename?): %v", rep.UnknownExclusions) + } + if len(rep.Wrapped)+len(rep.Excluded) == 0 { + t.Fatal("no wrapped or excluded methods detected — coverage helper is mis-configured") + } +} + +// TestToolsValidate verifies every tool has a non-empty name in canonical +// snake_case form, a description within length limits, and a non-nil Invoke +// + InputSchema. +func TestToolsValidate(t *testing.T) { + if err := mcptool.ValidateTools((xmcp.Provider{}).Tools()); err != nil { + t.Fatal(err) + } +} + +// TestPlatformName guards against accidental rebrands. +func TestPlatformName(t *testing.T) { + if got := (xmcp.Provider{}).Platform(); got != "x" { + t.Errorf("Platform() = %q, want x", got) + } +} + +// TestToolsHaveXPrefix encodes the per-platform naming convention. +func TestToolsHaveXPrefix(t *testing.T) { + for _, tool := range (xmcp.Provider{}).Tools() { + if !strings.HasPrefix(tool.Name, "x_") { + t.Errorf("tool %q lacks x_ prefix", tool.Name) + } + } +} diff --git a/mcp/search.go b/mcp/search.go new file mode 100644 index 0000000..4cfe76e --- /dev/null +++ b/mcp/search.go @@ -0,0 +1,141 @@ +package mcp + +import ( + "context" + + "github.com/teslashibe/mcptool" + x "github.com/teslashibe/x-go" +) + +// SearchTweetsInput is the typed input for x_search_tweets. +type SearchTweetsInput struct { + Query string `json:"query" jsonschema:"description=raw X search query (supports operators like from:user since:YYYY-MM-DD),required"` + Count int `json:"count,omitempty" jsonschema:"description=results per page,minimum=1,maximum=200,default=20"` + Cursor string `json:"cursor,omitempty" jsonschema:"description=opaque pagination cursor returned by a previous call (next_cursor)"` + SearchType string `json:"search_type,omitempty" jsonschema:"description=search tab; allowed: Top,Latest,People,Media,Lists,default=Top"` + Since string `json:"since,omitempty" jsonschema:"description=only tweets on or after this date (YYYY-MM-DD)"` + Until string `json:"until,omitempty" jsonschema:"description=only tweets on or before this date (YYYY-MM-DD)"` +} + +func searchTweets(ctx context.Context, c *x.Client, in SearchTweetsInput) (any, error) { + opts := []x.SearchOption{} + if in.SearchType != "" { + opts = append(opts, x.WithSearchType(x.SearchType(in.SearchType))) + } + if in.Since != "" { + opts = append(opts, x.WithSearchSince(in.Since)) + } + if in.Until != "" { + opts = append(opts, x.WithSearchUntil(in.Until)) + } + res, err := c.SearchTweetsPage(ctx, in.Query, in.Count, in.Cursor, opts...) + if err != nil { + return nil, err + } + limit := in.Count + if limit <= 0 { + limit = 20 + } + return mcptool.PageOf(res.Tweets, res.NextCursor, limit), nil +} + +// SearchUsersInput is the typed input for x_search_users. +type SearchUsersInput struct { + Query string `json:"query" jsonschema:"description=keywords or handle to match users on,required"` + Count int `json:"count,omitempty" jsonschema:"description=results per page,minimum=1,maximum=200,default=20"` + Cursor string `json:"cursor,omitempty" jsonschema:"description=opaque pagination cursor returned by a previous call (next_cursor)"` +} + +func searchUsers(ctx context.Context, c *x.Client, in SearchUsersInput) (any, error) { + res, err := c.SearchUsersPage(ctx, in.Query, in.Count, in.Cursor) + if err != nil { + return nil, err + } + limit := in.Count + if limit <= 0 { + limit = 20 + } + return mcptool.PageOf(res.Users, res.NextCursor, limit), nil +} + +// AdvancedSearchTweetsInput is the typed input for x_advanced_search_tweets. +// Mirrors the fields of x.AdvancedSearch and exposes them as a flat schema +// so an agent can build a query without learning X's operator syntax. +type AdvancedSearchTweetsInput struct { + AllWords string `json:"all_words,omitempty" jsonschema:"description=all of these words (space-separated, ANDed)"` + ExactPhrase string `json:"exact_phrase,omitempty" jsonschema:"description=this exact phrase (quoted in the resulting query)"` + AnyWords []string `json:"any_words,omitempty" jsonschema:"description=any of these words (ORed)"` + NoneWords []string `json:"none_words,omitempty" jsonschema:"description=none of these words (excluded)"` + Hashtags []string `json:"hashtags,omitempty" jsonschema:"description=hashtags to match (without the leading #)"` + Language string `json:"language,omitempty" jsonschema:"description=BCP-47 language code (e.g. en, es, ja)"` + From []string `json:"from,omitempty" jsonschema:"description=tweets from these accounts (without @)"` + To []string `json:"to,omitempty" jsonschema:"description=tweets sent in reply to these accounts (without @)"` + Mentioning []string `json:"mentioning,omitempty" jsonschema:"description=tweets mentioning these accounts (without @)"` + Replies string `json:"replies,omitempty" jsonschema:"description=reply filter; allowed: ''(any), 'exclude:replies', 'filter:replies'"` + Links string `json:"links,omitempty" jsonschema:"description=link filter; allowed: ''(any), 'exclude:links', 'filter:links'"` + MinReplies int `json:"min_replies,omitempty" jsonschema:"description=minimum reply count,minimum=0"` + MinLikes int `json:"min_likes,omitempty" jsonschema:"description=minimum like count,minimum=0"` + MinReposts int `json:"min_reposts,omitempty" jsonschema:"description=minimum retweet count,minimum=0"` + Since string `json:"since,omitempty" jsonschema:"description=only tweets on or after this date (YYYY-MM-DD)"` + Until string `json:"until,omitempty" jsonschema:"description=only tweets on or before this date (YYYY-MM-DD)"` + ResultType string `json:"result_type,omitempty" jsonschema:"description=search tab; allowed: Top,Latest,People,Media,Lists,default=Top"` + + Count int `json:"count,omitempty" jsonschema:"description=results per page,minimum=1,maximum=200,default=20"` + Cursor string `json:"cursor,omitempty" jsonschema:"description=opaque pagination cursor returned by a previous call (next_cursor)"` +} + +func advancedSearchTweets(ctx context.Context, c *x.Client, in AdvancedSearchTweetsInput) (any, error) { + resultType := x.SearchType(in.ResultType) + if resultType == "" { + resultType = x.SearchTop + } + search := &x.AdvancedSearch{ + AllWords: in.AllWords, + ExactPhrase: in.ExactPhrase, + AnyWords: in.AnyWords, + NoneWords: in.NoneWords, + Hashtags: in.Hashtags, + Language: in.Language, + From: in.From, + To: in.To, + Mentioning: in.Mentioning, + Replies: x.ReplyFilter(in.Replies), + Links: x.LinkFilter(in.Links), + MinReplies: in.MinReplies, + MinLikes: in.MinLikes, + MinReposts: in.MinReposts, + Since: in.Since, + Until: in.Until, + ResultType: resultType, + } + res, err := c.AdvancedSearchTweetsPage(ctx, search, in.Count, in.Cursor) + if err != nil { + return nil, err + } + limit := in.Count + if limit <= 0 { + limit = 20 + } + return mcptool.PageOf(res.Tweets, res.NextCursor, limit), nil +} + +var searchTools = []mcptool.Tool{ + mcptool.Define[*x.Client, SearchTweetsInput]( + "x_search_tweets", + "Search X for tweets matching a query (cursor-paginated; supports search_type, since, until)", + "SearchTweetsPage", + searchTweets, + ), + mcptool.Define[*x.Client, SearchUsersInput]( + "x_search_users", + "Search X for users matching a query (cursor-paginated)", + "SearchUsersPage", + searchUsers, + ), + mcptool.Define[*x.Client, AdvancedSearchTweetsInput]( + "x_advanced_search_tweets", + "Search X tweets with the full Advanced Search filter set (cursor-paginated)", + "AdvancedSearchTweetsPage", + advancedSearchTweets, + ), +} diff --git a/mcp/social.go b/mcp/social.go new file mode 100644 index 0000000..1332b02 --- /dev/null +++ b/mcp/social.go @@ -0,0 +1,95 @@ +package mcp + +import ( + "context" + + "github.com/teslashibe/mcptool" + x "github.com/teslashibe/x-go" +) + +// UserActionInput is the shared typed input for the follow/mute/block family +// of tools, all of which take just a user ID. +type UserActionInput struct { + UserID string `json:"user_id" jsonschema:"description=numeric X user ID,required"` +} + +func follow(ctx context.Context, c *x.Client, in UserActionInput) (any, error) { + if err := c.Follow(ctx, in.UserID); err != nil { + return nil, err + } + return map[string]any{"ok": true, "user_id": in.UserID}, nil +} + +func unfollow(ctx context.Context, c *x.Client, in UserActionInput) (any, error) { + if err := c.Unfollow(ctx, in.UserID); err != nil { + return nil, err + } + return map[string]any{"ok": true, "user_id": in.UserID}, nil +} + +func mute(ctx context.Context, c *x.Client, in UserActionInput) (any, error) { + if err := c.Mute(ctx, in.UserID); err != nil { + return nil, err + } + return map[string]any{"ok": true, "user_id": in.UserID}, nil +} + +func unmute(ctx context.Context, c *x.Client, in UserActionInput) (any, error) { + if err := c.Unmute(ctx, in.UserID); err != nil { + return nil, err + } + return map[string]any{"ok": true, "user_id": in.UserID}, nil +} + +func block(ctx context.Context, c *x.Client, in UserActionInput) (any, error) { + if err := c.Block(ctx, in.UserID); err != nil { + return nil, err + } + return map[string]any{"ok": true, "user_id": in.UserID}, nil +} + +func unblock(ctx context.Context, c *x.Client, in UserActionInput) (any, error) { + if err := c.Unblock(ctx, in.UserID); err != nil { + return nil, err + } + return map[string]any{"ok": true, "user_id": in.UserID}, nil +} + +var socialTools = []mcptool.Tool{ + mcptool.Define[*x.Client, UserActionInput]( + "x_follow", + "Follow an X user", + "Follow", + follow, + ), + mcptool.Define[*x.Client, UserActionInput]( + "x_unfollow", + "Unfollow an X user", + "Unfollow", + unfollow, + ), + mcptool.Define[*x.Client, UserActionInput]( + "x_mute", + "Mute an X user", + "Mute", + mute, + ), + mcptool.Define[*x.Client, UserActionInput]( + "x_unmute", + "Unmute an X user", + "Unmute", + unmute, + ), + mcptool.Define[*x.Client, UserActionInput]( + "x_block", + "Block an X user", + "Block", + block, + ), + mcptool.Define[*x.Client, UserActionInput]( + "x_unblock", + "Unblock an X user", + "Unblock", + unblock, + ), +} diff --git a/mcp/timeline.go b/mcp/timeline.go new file mode 100644 index 0000000..256b8e1 --- /dev/null +++ b/mcp/timeline.go @@ -0,0 +1,59 @@ +package mcp + +import ( + "context" + + "github.com/teslashibe/mcptool" + x "github.com/teslashibe/x-go" +) + +// HomeTimelineInput is the typed input for x_home_timeline. +type HomeTimelineInput struct { + Count int `json:"count,omitempty" jsonschema:"description=results per page,minimum=1,maximum=200,default=20"` + Cursor string `json:"cursor,omitempty" jsonschema:"description=opaque pagination cursor returned by a previous call (next_cursor)"` +} + +func homeTimeline(ctx context.Context, c *x.Client, in HomeTimelineInput) (any, error) { + res, err := c.HomeTimelinePage(ctx, in.Count, in.Cursor) + if err != nil { + return nil, err + } + limit := in.Count + if limit <= 0 { + limit = 20 + } + return mcptool.PageOf(res.Tweets, res.NextCursor, limit), nil +} + +// HomeLatestTimelineInput is the typed input for x_home_latest_timeline. +type HomeLatestTimelineInput struct { + Count int `json:"count,omitempty" jsonschema:"description=results per page,minimum=1,maximum=200,default=20"` + Cursor string `json:"cursor,omitempty" jsonschema:"description=opaque pagination cursor returned by a previous call (next_cursor)"` +} + +func homeLatestTimeline(ctx context.Context, c *x.Client, in HomeLatestTimelineInput) (any, error) { + res, err := c.HomeLatestTimelinePage(ctx, in.Count, in.Cursor) + if err != nil { + return nil, err + } + limit := in.Count + if limit <= 0 { + limit = 20 + } + return mcptool.PageOf(res.Tweets, res.NextCursor, limit), nil +} + +var timelineTools = []mcptool.Tool{ + mcptool.Define[*x.Client, HomeTimelineInput]( + "x_home_timeline", + "Fetch a page of the algorithmic 'For You' home timeline (cursor-paginated)", + "HomeTimelinePage", + homeTimeline, + ), + mcptool.Define[*x.Client, HomeLatestTimelineInput]( + "x_home_latest_timeline", + "Fetch a page of the reverse-chronological 'Following' home timeline (cursor-paginated)", + "HomeLatestTimelinePage", + homeLatestTimeline, + ), +} diff --git a/mcp/trends.go b/mcp/trends.go new file mode 100644 index 0000000..7cedd5c --- /dev/null +++ b/mcp/trends.go @@ -0,0 +1,39 @@ +package mcp + +import ( + "context" + + "github.com/teslashibe/mcptool" + x "github.com/teslashibe/x-go" +) + +// ScrapeTimelineTrendsInput is the typed input for x_scrape_timeline_trends. +type ScrapeTimelineTrendsInput struct { + UserID string `json:"user_id" jsonschema:"description=numeric X user ID whose tweets to analyse,required"` + MaxTweets int `json:"max_tweets,omitempty" jsonschema:"description=cap on tweets fetched for analysis,minimum=1,maximum=5000,default=200"` + TopN int `json:"top_n,omitempty" jsonschema:"description=number of top keywords/hashtags/mentions to return,minimum=1,maximum=200,default=20"` + StopWords []string `json:"stop_words,omitempty" jsonschema:"description=extra domain-specific stop words to exclude from keyword extraction"` +} + +func scrapeTimelineTrends(ctx context.Context, c *x.Client, in ScrapeTimelineTrendsInput) (any, error) { + var opts []x.TrendOption + if in.MaxTweets > 0 { + opts = append(opts, x.WithTrendMaxTweets(in.MaxTweets)) + } + if in.TopN > 0 { + opts = append(opts, x.WithTrendTopN(in.TopN)) + } + if len(in.StopWords) > 0 { + opts = append(opts, x.WithTrendStopWords(in.StopWords)) + } + return c.ScrapeTimelineTrends(ctx, in.UserID, opts...) +} + +var trendTools = []mcptool.Tool{ + mcptool.Define[*x.Client, ScrapeTimelineTrendsInput]( + "x_scrape_timeline_trends", + "Paginate a user's tweets and produce a TrendReport (keywords, hashtags, peaks, authors)", + "ScrapeTimelineTrends", + scrapeTimelineTrends, + ), +} diff --git a/mcp/tweets.go b/mcp/tweets.go new file mode 100644 index 0000000..43a7adc --- /dev/null +++ b/mcp/tweets.go @@ -0,0 +1,66 @@ +package mcp + +import ( + "context" + + "github.com/teslashibe/mcptool" + x "github.com/teslashibe/x-go" +) + +// GetTweetInput is the typed input for x_get_tweet. +type GetTweetInput struct { + TweetID string `json:"tweet_id" jsonschema:"description=numeric tweet ID,required"` +} + +func getTweet(ctx context.Context, c *x.Client, in GetTweetInput) (any, error) { + return c.GetTweet(ctx, in.TweetID) +} + +// GetTweetDetailInput is the typed input for x_get_tweet_detail. +type GetTweetDetailInput struct { + TweetID string `json:"tweet_id" jsonschema:"description=numeric tweet ID; result includes the tweet plus its reply thread,required"` +} + +func getTweetDetail(ctx context.Context, c *x.Client, in GetTweetDetailInput) (any, error) { + return c.GetTweetDetail(ctx, in.TweetID) +} + +// GetUserTweetsInput is the typed input for x_get_user_tweets. +type GetUserTweetsInput struct { + UserID string `json:"user_id" jsonschema:"description=numeric X user ID whose tweets to fetch,required"` + Count int `json:"count,omitempty" jsonschema:"description=results per page,minimum=1,maximum=200,default=20"` + Cursor string `json:"cursor,omitempty" jsonschema:"description=opaque pagination cursor returned by a previous call (next_cursor)"` +} + +func getUserTweets(ctx context.Context, c *x.Client, in GetUserTweetsInput) (any, error) { + res, err := c.UserTweetsPage(ctx, in.UserID, in.Count, in.Cursor) + if err != nil { + return nil, err + } + limit := in.Count + if limit <= 0 { + limit = 20 + } + return mcptool.PageOf(res.Tweets, res.NextCursor, limit), nil +} + +var tweetTools = []mcptool.Tool{ + mcptool.Define[*x.Client, GetTweetInput]( + "x_get_tweet", + "Fetch a single X tweet by ID", + "GetTweet", + getTweet, + ), + mcptool.Define[*x.Client, GetTweetDetailInput]( + "x_get_tweet_detail", + "Fetch an X tweet with its conversation thread (focal tweet plus replies)", + "GetTweetDetail", + getTweetDetail, + ), + mcptool.Define[*x.Client, GetUserTweetsInput]( + "x_get_user_tweets", + "Fetch a page of tweets authored by an X user (cursor-paginated)", + "UserTweetsPage", + getUserTweets, + ), +} diff --git a/mcp/users.go b/mcp/users.go new file mode 100644 index 0000000..07b97aa --- /dev/null +++ b/mcp/users.go @@ -0,0 +1,104 @@ +package mcp + +import ( + "context" + + "github.com/teslashibe/mcptool" + x "github.com/teslashibe/x-go" +) + +// GetProfileInput is the typed input for x_get_profile. +type GetProfileInput struct { + ScreenName string `json:"screen_name" jsonschema:"description=X handle without the @ (e.g. 'elonmusk'),required"` +} + +func getProfile(ctx context.Context, c *x.Client, in GetProfileInput) (any, error) { + return c.GetProfile(ctx, in.ScreenName) +} + +// GetProfileByIDInput is the typed input for x_get_profile_by_id. +type GetProfileByIDInput struct { + UserID string `json:"user_id" jsonschema:"description=numeric X user ID (rest_id),required"` +} + +func getProfileByID(ctx context.Context, c *x.Client, in GetProfileByIDInput) (any, error) { + return c.GetProfileByID(ctx, in.UserID) +} + +// MeInput is the typed input for x_me. +type MeInput struct{} + +func me(ctx context.Context, c *x.Client, _ MeInput) (any, error) { + return c.Me(ctx) +} + +// GetFollowersInput is the typed input for x_get_followers. +type GetFollowersInput struct { + UserID string `json:"user_id" jsonschema:"description=numeric X user ID whose followers to fetch,required"` + Count int `json:"count,omitempty" jsonschema:"description=results per page,minimum=1,maximum=200,default=20"` + Cursor string `json:"cursor,omitempty" jsonschema:"description=opaque pagination cursor returned by a previous call (next_cursor)"` +} + +func getFollowers(ctx context.Context, c *x.Client, in GetFollowersInput) (any, error) { + res, err := c.GetFollowersPage(ctx, in.UserID, in.Count, in.Cursor) + if err != nil { + return nil, err + } + limit := in.Count + if limit <= 0 { + limit = 20 + } + return mcptool.PageOf(res.Users, res.NextCursor, limit), nil +} + +// GetFollowingInput is the typed input for x_get_following. +type GetFollowingInput struct { + UserID string `json:"user_id" jsonschema:"description=numeric X user ID whose following list to fetch,required"` + Count int `json:"count,omitempty" jsonschema:"description=results per page,minimum=1,maximum=200,default=20"` + Cursor string `json:"cursor,omitempty" jsonschema:"description=opaque pagination cursor returned by a previous call (next_cursor)"` +} + +func getFollowing(ctx context.Context, c *x.Client, in GetFollowingInput) (any, error) { + res, err := c.GetFollowingPage(ctx, in.UserID, in.Count, in.Cursor) + if err != nil { + return nil, err + } + limit := in.Count + if limit <= 0 { + limit = 20 + } + return mcptool.PageOf(res.Users, res.NextCursor, limit), nil +} + +var userTools = []mcptool.Tool{ + mcptool.Define[*x.Client, GetProfileInput]( + "x_get_profile", + "Fetch an X user profile by handle (the @ name)", + "GetProfile", + getProfile, + ), + mcptool.Define[*x.Client, GetProfileByIDInput]( + "x_get_profile_by_id", + "Fetch an X user profile by numeric user ID (rest_id)", + "GetProfileByID", + getProfileByID, + ), + mcptool.Define[*x.Client, MeInput]( + "x_me", + "Return the authenticated X user's own profile", + "Me", + me, + ), + mcptool.Define[*x.Client, GetFollowersInput]( + "x_get_followers", + "Fetch a page of followers of an X user (cursor-paginated)", + "GetFollowersPage", + getFollowers, + ), + mcptool.Define[*x.Client, GetFollowingInput]( + "x_get_following", + "Fetch a page of users an X user is following (cursor-paginated)", + "GetFollowingPage", + getFollowing, + ), +}