Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .github/workflows/mcp-ci.yml
Original file line number Diff line number Diff line change
@@ -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/...
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -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
)
23 changes: 23 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
95 changes: 95 additions & 0 deletions mcp/actions.go
Original file line number Diff line number Diff line change
@@ -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,
),
}
105 changes: 105 additions & 0 deletions mcp/compose.go
Original file line number Diff line number Diff line change
@@ -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/<user>/status/<id>),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,
),
}
87 changes: 87 additions & 0 deletions mcp/dm.go
Original file line number Diff line number Diff line change
@@ -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. '<id1>-<id2>'),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,
),
}
Loading
Loading