mirror of
https://github.com/paymog/slack-cli.git
synced 2026-09-18 23:43:49 +08:00
d781728c6c
* feat: add slack-cli, a no-daemon CLI over the slack-mcp-server engine
Turn the forked slack-mcp-server into a CLI so running many agents no
longer means one resident MCP process each. Every command is a
short-lived process that reads the shared on-disk cache.
- rename module to github.com/paymog/slack-cli (go install/homebrew/ldflags)
- internal/toolcall: invoke the upstream tool handlers in-process; the only
mcp-go coupling lives here, so pkg/handler and pkg/provider are reused
byte-for-byte (clean upstream merges, fork-and-extend)
- internal/{cli,cmds,config,credstore,runtime,output}: cobra command tree,
keyring-backed credential profiles, provider bootstrap, result printing
- 21 tools as subcommands (channels, conversations, users, usergroups,
saved, reactions, attachments, cache); write tools keep their env gating
- goreleaser + homebrew release workflow; ships a skills/slack-cli skill
- unit tests for config/credstore/toolcall; MCP server still builds
The MCP server (cmd/slack-mcp-server) is kept intact.
* chore(napkin): record real-workspace verification
* docs: explain how the CLI works (in-process handler invocation, shared cache)
213 lines
5.9 KiB
Go
213 lines
5.9 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/csv"
|
|
"fmt"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/paymog/slack-cli/pkg/test/util"
|
|
"github.com/mark3labs/mcp-go/client"
|
|
"github.com/mark3labs/mcp-go/mcp"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
type testEnv struct {
|
|
mcpClient *client.Client
|
|
ctx context.Context
|
|
}
|
|
|
|
type matchingRule struct {
|
|
csvFieldName string
|
|
csvFieldValueRE string
|
|
}
|
|
|
|
func setupTestEnv(t *testing.T) (*testEnv, func()) {
|
|
t.Helper()
|
|
|
|
sseKey := uuid.New().String()
|
|
require.NotEmpty(t, sseKey, "sseKey must be generated for integration tests")
|
|
|
|
cfg := util.MCPConfig{
|
|
SSEKey: sseKey,
|
|
MessageToolEnabled: true,
|
|
MessageToolMark: true,
|
|
}
|
|
|
|
mcpServer, err := util.SetupMCP(cfg)
|
|
require.NoError(t, err, "Failed to set up MCP server")
|
|
|
|
fwd, err := util.SetupForwarding(context.Background(), "http://"+mcpServer.Host+":"+strconv.Itoa(mcpServer.Port))
|
|
require.NoError(t, err, "Failed to set up ngrok forwarding")
|
|
|
|
sseURL := fmt.Sprintf("%s://%s/sse", fwd.URL.Scheme, fwd.URL.Host)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
|
|
|
mcpClient, err := client.NewSSEMCPClient(sseURL,
|
|
client.WithHeaders(map[string]string{
|
|
"Authorization": "Bearer " + sseKey,
|
|
}),
|
|
)
|
|
require.NoError(t, err, "Failed to create MCP client")
|
|
|
|
err = mcpClient.Start(ctx)
|
|
require.NoError(t, err, "Failed to start MCP client")
|
|
|
|
initReq := mcp.InitializeRequest{}
|
|
initReq.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION
|
|
initReq.Params.ClientInfo = mcp.Implementation{
|
|
Name: "channels-test-client",
|
|
Version: "1.0.0",
|
|
}
|
|
initReq.Params.Capabilities = mcp.ClientCapabilities{}
|
|
|
|
_, err = mcpClient.Initialize(ctx, initReq)
|
|
require.NoError(t, err, "Failed to initialize MCP client")
|
|
|
|
cleanup := func() {
|
|
cancel()
|
|
mcpClient.Close()
|
|
fwd.Shutdown()
|
|
mcpServer.Shutdown()
|
|
}
|
|
|
|
return &testEnv{
|
|
mcpClient: mcpClient,
|
|
ctx: ctx,
|
|
}, cleanup
|
|
}
|
|
|
|
func runChannelTest(t *testing.T, env *testEnv, channelType string, expectedChannels []matchingRule) {
|
|
t.Helper()
|
|
|
|
callReq := mcp.CallToolRequest{}
|
|
callReq.Params.Name = "channels_list"
|
|
callReq.Params.Arguments = map[string]any{
|
|
"channel_types": channelType,
|
|
}
|
|
|
|
result, err := env.mcpClient.CallTool(env.ctx, callReq)
|
|
require.NoError(t, err, "Tool call failed")
|
|
require.NotNil(t, result, "Tool result is nil")
|
|
require.False(t, result.IsError, "Tool returned error")
|
|
|
|
var toolOutput strings.Builder
|
|
for _, content := range result.Content {
|
|
if textContent, ok := content.(mcp.TextContent); ok {
|
|
toolOutput.WriteString(textContent.Text)
|
|
}
|
|
}
|
|
|
|
require.NotEmpty(t, toolOutput.String(), "No tool output captured")
|
|
|
|
reader := csv.NewReader(strings.NewReader(toolOutput.String()))
|
|
rows, err := reader.ReadAll()
|
|
require.NoError(t, err, "Failed to parse CSV")
|
|
require.GreaterOrEqual(t, len(rows), 1, "CSV must have at least a header row")
|
|
|
|
header := rows[0]
|
|
dataRows := rows[1:]
|
|
colIndex := map[string]int{}
|
|
for i, col := range header {
|
|
colIndex[col] = i
|
|
}
|
|
|
|
for _, rule := range expectedChannels {
|
|
idx, ok := colIndex[rule.csvFieldName]
|
|
require.Truef(t, ok, "CSV did not contain column %q, toolOutput: %q", rule.csvFieldName, toolOutput.String())
|
|
|
|
re, err := regexp.Compile(rule.csvFieldValueRE)
|
|
require.NoErrorf(t, err, "Invalid regex %q", rule.csvFieldValueRE)
|
|
|
|
found := false
|
|
for _, row := range dataRows {
|
|
if idx < len(row) && re.MatchString(row[idx]) {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
assert.Truef(t, found, "No row in column %q matched %q; full CSV:\n%s",
|
|
rule.csvFieldName, rule.csvFieldValueRE, toolOutput.String())
|
|
}
|
|
}
|
|
|
|
func TestIntegrationChannelsListQueryFilter(t *testing.T) {
|
|
env, cleanup := setupTestEnv(t)
|
|
defer cleanup()
|
|
|
|
callReq := mcp.CallToolRequest{}
|
|
callReq.Params.Name = "channels_list"
|
|
callReq.Params.Arguments = map[string]any{
|
|
"channel_types": "public_channel",
|
|
"query": "testcase",
|
|
}
|
|
|
|
result, err := env.mcpClient.CallTool(env.ctx, callReq)
|
|
require.NoError(t, err, "Tool call failed")
|
|
require.NotNil(t, result, "Tool result is nil")
|
|
require.False(t, result.IsError, "Tool returned error")
|
|
|
|
var toolOutput strings.Builder
|
|
for _, content := range result.Content {
|
|
if textContent, ok := content.(mcp.TextContent); ok {
|
|
toolOutput.WriteString(textContent.Text)
|
|
}
|
|
}
|
|
|
|
reader := csv.NewReader(strings.NewReader(toolOutput.String()))
|
|
rows, err := reader.ReadAll()
|
|
require.NoError(t, err, "Failed to parse CSV")
|
|
require.GreaterOrEqual(t, len(rows), 1, "CSV must have at least a header row")
|
|
|
|
nameIdx := -1
|
|
for i, col := range rows[0] {
|
|
if col == "Name" {
|
|
nameIdx = i
|
|
break
|
|
}
|
|
}
|
|
require.NotEqualf(t, -1, nameIdx, "CSV did not contain Name column; header: %v", rows[0])
|
|
|
|
dataRows := rows[1:]
|
|
for _, row := range dataRows {
|
|
require.Less(t, nameIdx, len(row), "row shorter than Name column index")
|
|
name := strings.ToLower(row[nameIdx])
|
|
assert.Containsf(t, name, "testcase",
|
|
"Expected all results to match query 'testcase', got: %s", row[nameIdx])
|
|
assert.NotContainsf(t, name, "general",
|
|
"Expected #general to be filtered out, but found: %s", row[nameIdx])
|
|
}
|
|
assert.GreaterOrEqual(t, len(dataRows), 3, "Expected at least testcase-1, testcase-2, testcase-3")
|
|
}
|
|
|
|
func TestIntegrationPublicChannelsList(t *testing.T) {
|
|
env, cleanup := setupTestEnv(t)
|
|
defer cleanup()
|
|
|
|
expectedChannels := []matchingRule{
|
|
{csvFieldName: "Name", csvFieldValueRE: `^#general$`},
|
|
{csvFieldName: "Name", csvFieldValueRE: `^#testcase-1$`},
|
|
{csvFieldName: "Name", csvFieldValueRE: `^#testcase-2$`},
|
|
{csvFieldName: "Name", csvFieldValueRE: `^#testcase-3$`},
|
|
}
|
|
|
|
runChannelTest(t, env, "public_channel", expectedChannels)
|
|
}
|
|
|
|
func TestIntegrationPrivateChannelsList(t *testing.T) {
|
|
env, cleanup := setupTestEnv(t)
|
|
defer cleanup()
|
|
|
|
expectedChannels := []matchingRule{
|
|
{csvFieldName: "Name", csvFieldValueRE: `^#testcase-4$`},
|
|
}
|
|
|
|
runChannelTest(t, env, "private_channel", expectedChannels)
|
|
}
|