diff --git a/.gitignore b/.gitignore index 42837a0..d9ea418 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ *.out # Build artifacts +slack slack-cli slack_* diff --git a/README.md b/README.md index 4c823da..910e15c 100644 --- a/README.md +++ b/README.md @@ -37,12 +37,51 @@ Add this to your prompt (e.g. `AGENTS.md`): ```markdown - You can send messages to a Slack user by using the `slack send-message ""` command. - The message supports Markdown formatting which will be automatically converted to Slack's Mrkdwn format. +- For AI assistants supporting MCP (Model Context Protocol), you can use `slack mcp-server` to enable tool-based Slack integration. ``` ## Usage +### Direct CLI Usage + ```bash Usage: slack configure - configure Slack token (reads from stdin) slack send-message - send a message to a user + slack mcp-server - start MCP server (Model Context Protocol) +``` + +**Example:** +```bash +slack send-message alex_collins@intuit.com "I love this tool! It makes Slack integration so easy." ``` + +### MCP Server Mode + +The MCP (Model Context Protocol) server allows AI assistants and other tools to interact with Slack through a standardized JSON-RPC protocol over stdio. This enables seamless integration with AI coding assistants and other automation tools. + +**Setup:** + +1. First, configure your Slack token (stored securely in the system keyring): + ```bash + echo "xoxb-your-slack-token" | slack configure + ``` + +2. Add the MCP server configuration to your MCP client: + ```json + { + "mcpServers": { + "slack": { + "command": "slack", + "args": ["mcp-server"] + } + } + } + ``` + +The server exposes a `send_message` tool that accepts: +- `identifier` - Slack channel ID (e.g., 'C1234567890') or user email address (e.g., 'user@example.com') +- `message` - The message to send (supports Markdown formatting) + +**Example usage from an AI assistant:** +> "Slack alex_collins@intuit.com to say how much you like this tool." diff --git a/go.mod b/go.mod index 9aeec00..386d706 100644 --- a/go.mod +++ b/go.mod @@ -3,15 +3,25 @@ module github.com/kitproj/slack-cli go 1.24.4 require ( + github.com/mark3labs/mcp-go v0.42.0 github.com/slack-go/slack v0.17.3 github.com/zalando/go-keyring v0.2.6 + golang.org/x/term v0.36.0 ) require ( al.essio.dev/pkg/shellescape v1.5.1 // indirect + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/buger/jsonparser v1.1.1 // indirect github.com/danieljoos/wincred v1.2.2 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect + github.com/invopop/jsonschema v0.13.0 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/spf13/cast v1.7.1 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect golang.org/x/sys v0.37.0 // indirect - golang.org/x/term v0.36.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 7d77889..2508c9e 100644 --- a/go.sum +++ b/go.sum @@ -1,32 +1,61 @@ al.essio.dev/pkg/shellescape v1.5.1 h1:86HrALUujYS/h+GtqoB26SBEdkWfmMI6FubjXlsXyho= al.essio.dev/pkg/shellescape v1.5.1/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= +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/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0= github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8= 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/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +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/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mark3labs/mcp-go v0.42.0 h1:gk/8nYJh8t3yroCAOBhNbYsM9TCKvkM13I5t5Hfu6Ls= +github.com/mark3labs/mcp-go v0.42.0/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw= 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/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/slack-go/slack v0.17.3 h1:zV5qO3Q+WJAQ/XwbGfNFrRMaJ5T/naqaonyPV/1TP4g= github.com/slack-go/slack v0.17.3/go.mod h1:X+UqOufi3LYQHDnMG1vxf0J8asC6+WllXrVrhl8/Prk= +github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= +github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +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= +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/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s= github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI= -golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= -golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= +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/main.go b/main.go index 652c214..c806951 100644 --- a/main.go +++ b/main.go @@ -30,6 +30,7 @@ func main() { fmt.Fprintln(w) fmt.Fprintln(w, " slack configure - configure Slack token (reads from stdin)") fmt.Fprintln(w, " slack send-message - send a message to a user") + fmt.Fprintln(w, " slack mcp-server - start MCP server (Model Context Protocol)") fmt.Fprintln(w) } flag.Parse() @@ -49,6 +50,8 @@ func run(ctx context.Context, args []string) error { switch args[0] { case "configure": return configureToken(ctx) + case "mcp-server": + return runMCPServer(ctx) case "send-message": if len(args) < 3 { return fmt.Errorf("usage: slack send-message ") diff --git a/mcp.go b/mcp.go new file mode 100644 index 0000000..de62dc2 --- /dev/null +++ b/mcp.go @@ -0,0 +1,67 @@ +package main + +import ( + "context" + "fmt" + "net/http" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + "github.com/slack-go/slack" +) + +// runMCPServer starts the MCP server that communicates over stdio using the mcp-go library +func runMCPServer(ctx context.Context) error { + token := getToken() + if token == "" { + return fmt.Errorf("Slack token must be set (use 'slack configure' or set SLACK_TOKEN env var)") + } + + // disable HTTP/2 support as it causes issues with some proxies + http.DefaultTransport.(*http.Transport).ForceAttemptHTTP2 = false + api := slack.New(token) + + // Create a new MCP server + s := server.NewMCPServer( + "slack-cli-mcp-server", + "0.1.0", + server.WithToolCapabilities(true), + ) + + // Define the send_message tool + sendMessageTool := mcp.NewTool("send_message", + mcp.WithDescription("Send a message to a Slack channel or user. You can specify either a channel ID or a user's email address. The message supports Markdown formatting which will be automatically converted to Slack's Mrkdwn format."), + mcp.WithString("identifier", + mcp.Required(), + mcp.Description("The Slack channel ID (e.g., 'C1234567890') or user email address (e.g., 'user@example.com')"), + ), + mcp.WithString("message", + mcp.Required(), + mcp.Description("The message to send. Supports Markdown formatting."), + ), + ) + + // Add the tool handler + s.AddTool(sendMessageTool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + identifier, err := request.RequireString("identifier") + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Missing or invalid 'identifier' argument: %v", err)), nil + } + + message, err := request.RequireString("message") + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Missing or invalid 'message' argument: %v", err)), nil + } + + // Send the message using the existing sendMessage function + err = sendMessage(ctx, api, identifier, message) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("Error: %v", err)), nil + } + + return mcp.NewToolResultText(fmt.Sprintf("Message sent successfully to %s", identifier)), nil + }) + + // Start the stdio server + return server.ServeStdio(s) +} diff --git a/mcp_test.go b/mcp_test.go new file mode 100644 index 0000000..f2a9bcf --- /dev/null +++ b/mcp_test.go @@ -0,0 +1,52 @@ +package main + +import ( + "context" + "os" + "strings" + "testing" +) + +func TestRun_MCPServer(t *testing.T) { + // Set SLACK_TOKEN env var to get past token check + oldToken := os.Getenv("SLACK_TOKEN") + os.Setenv("SLACK_TOKEN", "test-token") + defer func() { + if oldToken == "" { + os.Unsetenv("SLACK_TOKEN") + } else { + os.Setenv("SLACK_TOKEN", oldToken) + } + }() + + // Test that mcp-server sub-command is recognized (won't actually run the server in this test) + // We would need to mock stdin/stdout to fully test this + args := []string{"mcp-server"} + + // We can't easily test the full server without mocking stdin/stdout + // but we can verify the command is recognized and doesn't return "unknown sub-command" + _ = args + // This test just verifies the test setup works +} + +func TestRun_MCPServerMissingToken(t *testing.T) { + // Unset SLACK_TOKEN env var + oldToken := os.Getenv("SLACK_TOKEN") + os.Unsetenv("SLACK_TOKEN") + defer func() { + if oldToken != "" { + os.Setenv("SLACK_TOKEN", oldToken) + } + }() + + ctx := context.Background() + err := run(ctx, []string{"mcp-server"}) + + if err == nil { + t.Error("Expected error for missing token, got nil") + } + + if !strings.Contains(err.Error(), "Slack token must be set") { + t.Errorf("Expected 'Slack token must be set' error, got: %v", err) + } +}