diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 00000000..7487d58c
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,26 @@
+version: 2
+
+updates:
+ - package-ecosystem: "gomod"
+ directory: "/"
+ schedule:
+ interval: "weekly"
+ open-pull-requests-limit: 3
+ groups:
+ go-dependencies:
+ patterns:
+ - "*"
+ commit-message:
+ prefix: "deps(go)"
+
+ - package-ecosystem: "github-actions"
+ directory: "/"
+ schedule:
+ interval: "weekly"
+ open-pull-requests-limit: 2
+ groups:
+ github-actions:
+ patterns:
+ - "*"
+ commit-message:
+ prefix: "deps(gha)"
diff --git a/.github/workflows/integration-tests.yaml b/.github/workflows/integration-tests.yaml
index 895fc6d0..ab899b03 100644
--- a/.github/workflows/integration-tests.yaml
+++ b/.github/workflows/integration-tests.yaml
@@ -15,10 +15,10 @@ jobs:
steps:
- name: Checkout
- uses: actions/checkout@v4
+ uses: actions/checkout@v6
- name: Set up Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@v6
with:
go-version-file: "go.mod"
diff --git a/.github/workflows/release-image.yaml b/.github/workflows/release-image.yaml
index a9d5b7b0..3d8acd48 100644
--- a/.github/workflows/release-image.yaml
+++ b/.github/workflows/release-image.yaml
@@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@v6
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml
index ed558934..d9771c21 100644
--- a/.github/workflows/release.yaml
+++ b/.github/workflows/release.yaml
@@ -23,9 +23,9 @@ jobs:
runs-on: macos-latest
steps:
- name: Checkout
- uses: actions/checkout@v4
+ uses: actions/checkout@v6
- - uses: actions/setup-go@v5
+ - uses: actions/setup-go@v6
with:
go-version: ${{ env.GO_VERSION }}
diff --git a/.github/workflows/security-trivy.yaml b/.github/workflows/security-trivy.yaml
new file mode 100644
index 00000000..d4002c08
--- /dev/null
+++ b/.github/workflows/security-trivy.yaml
@@ -0,0 +1,31 @@
+name: Security (Trivy)
+
+on:
+ push:
+ branches:
+ - master
+ pull_request:
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ trivy-fs:
+ name: Trivy filesystem scan
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6.0.1
+
+ - name: Run Trivy (Go dependencies)
+ uses: aquasecurity/trivy-action@0.33.1
+ with:
+ scan-type: fs
+ scan-ref: .
+ scanners: vuln
+ vuln-type: library
+ severity: CRITICAL,HIGH
+ format: table
+ ignore-unfixed: true
+ exit-code: 1
diff --git a/.github/workflows/unit-tests.yaml b/.github/workflows/unit-tests.yaml
index f2a7f815..0433d1b2 100644
--- a/.github/workflows/unit-tests.yaml
+++ b/.github/workflows/unit-tests.yaml
@@ -15,10 +15,10 @@ jobs:
steps:
- name: Checkout
- uses: actions/checkout@v4
+ uses: actions/checkout@v6
- name: Set up Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@v6
with:
go-version-file: "go.mod"
diff --git a/Dockerfile b/Dockerfile
index 9d65f601..becf5a85 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -27,7 +27,8 @@ WORKDIR /app/mcp-server
EXPOSE 3001
-CMD ["mcp-server", "--transport", "sse"]
+ENTRYPOINT ["mcp-server"]
+CMD ["--transport", "sse"]
FROM alpine:3.22 AS production
@@ -39,4 +40,5 @@ WORKDIR /app
EXPOSE 3001
-CMD ["mcp-server", "--transport", "sse"]
+ENTRYPOINT ["mcp-server"]
+CMD ["--transport", "sse"]
diff --git a/README.md b/README.md
index 98eab967..d29962ba 100644
--- a/README.md
+++ b/README.md
@@ -188,6 +188,7 @@ See [Authentication Setup](docs/01-authentication-setup.md) for extracting brows
| `SLACK_MCP_USERS_CACHE` | No | `~/Library/Caches/slack-mcp-server/users_cache.json` (macOS)
`~/.cache/slack-mcp-server/users_cache.json` (Linux)
`%LocalAppData%/slack-mcp-server/users_cache.json` (Windows) | Path to the users cache file. Used to cache Slack user information to avoid repeated API calls on startup. |
| `SLACK_MCP_CHANNELS_CACHE` | No | `~/Library/Caches/slack-mcp-server/channels_cache_v2.json` (macOS)
`~/.cache/slack-mcp-server/channels_cache_v2.json` (Linux)
`%LocalAppData%/slack-mcp-server/channels_cache_v2.json` (Windows) | Path to the channels cache file. Used to cache Slack channel information to avoid repeated API calls on startup. |
| `SLACK_MCP_LOG_LEVEL` | No | `info` | Log-level for stdout or stderr. Valid values are: `debug`, `info`, `warn`, `error`, `panic` and `fatal` |
+| `SLACK_MCP_GOVSLACK` | No | `nil` | Set to `true` to enable [GovSlack](https://slack.com/solutions/govslack) mode. Routes API calls to `slack-gov.com` endpoints instead of `slack.com` for FedRAMP-compliant government workspaces. |
*You need one of: `xoxp` (user), `xoxb` (bot), or both `xoxc`/`xoxd` tokens for legacy mode authentication.
**For OAuth mode, set `SLACK_MCP_OAUTH_ENABLED=true` and provide Client ID, Secret, and Redirect URI instead.
diff --git a/cmd/slack-mcp-server/main.go b/cmd/slack-mcp-server/main.go
index 51cbbebe..7a5bf560 100644
--- a/cmd/slack-mcp-server/main.go
+++ b/cmd/slack-mcp-server/main.go
@@ -9,6 +9,7 @@ import (
"strconv"
"strings"
"sync"
+ "time"
"github.com/korotovsky/slack-mcp-server/pkg/handler"
"github.com/korotovsky/slack-mcp-server/pkg/oauth"
@@ -102,6 +103,12 @@ func main() {
switch transport {
case "stdio":
+ for {
+ if ready, _ := p.IsReady(); ready {
+ break
+ }
+ time.Sleep(100 * time.Millisecond)
+ }
if err := s.ServeStdio(); err != nil {
logger.Fatal("Server error",
zap.String("context", "console"),
diff --git a/docs/03-configuration-and-usage.md b/docs/03-configuration-and-usage.md
index 530ec470..1550fdbf 100644
--- a/docs/03-configuration-and-usage.md
+++ b/docs/03-configuration-and-usage.md
@@ -128,7 +128,6 @@ Open your `claude_desktop_config.json` and add the mcp server to the list of `mc
"-e",
"SLACK_MCP_XOXP_TOKEN",
"ghcr.io/korotovsky/slack-mcp-server",
- "mcp-server",
"--transport",
"stdio"
],
@@ -155,7 +154,6 @@ Open your `claude_desktop_config.json` and add the mcp server to the list of `mc
"-e",
"SLACK_MCP_XOXD_TOKEN",
"ghcr.io/korotovsky/slack-mcp-server",
- "mcp-server",
"--transport",
"stdio"
],
@@ -245,7 +243,7 @@ docker pull ghcr.io/korotovsky/slack-mcp-server:latest
docker run -i --rm \
-e SLACK_MCP_XOXC_TOKEN \
-e SLACK_MCP_XOXD_TOKEN \
- slack-mcp-server mcp-server --transport stdio
+ ghcr.io/korotovsky/slack-mcp-server:latest --transport stdio
```
Or, the docker-compose way:
diff --git a/go.mod b/go.mod
index 0ac5c40f..822319c4 100644
--- a/go.mod
+++ b/go.mod
@@ -5,42 +5,46 @@ go 1.24.4
require (
github.com/gocarina/gocsv v0.0.0-20240520201108-78e41c74b4b1
github.com/google/uuid v1.6.0
- github.com/mark3labs/mcp-go v0.40.0
+ github.com/mark3labs/mcp-go v0.43.2
github.com/mattn/go-isatty v0.0.20
- github.com/openai/openai-go v1.11.0
- github.com/refraction-networking/utls v1.8.0
+ github.com/openai/openai-go v1.12.0
+ github.com/refraction-networking/utls v1.8.2
github.com/rusq/slack v0.9.6-0.20250408103104-dd80d1b6337f
- github.com/rusq/slackauth v0.6.1
- github.com/rusq/slackdump/v3 v3.1.6
+ github.com/rusq/slackauth v0.7.1
+ github.com/rusq/slackdump/v3 v3.1.11
github.com/rusq/tagops v0.1.1
github.com/slack-go/slack v0.17.3
- github.com/stretchr/testify v1.10.0
+ github.com/stretchr/testify v1.11.1
github.com/takara2314/slack-go-util v0.3.0
- go.uber.org/zap v1.27.0
- golang.ngrok.com/ngrok/v2 v2.0.0
- golang.org/x/net v0.40.0
- golang.org/x/sync v0.14.0
- golang.org/x/time v0.12.0
+ go.uber.org/zap v1.27.1
+ golang.ngrok.com/ngrok/v2 v2.1.1
+ golang.org/x/net v0.47.0
+ golang.org/x/sync v0.18.0
+ golang.org/x/time v0.14.0
)
require (
github.com/MercuryEngineering/CookieMonster v0.0.0-20180304172713-1584578b3403 // indirect
- github.com/andybalholm/brotli v1.0.6 // indirect
+ github.com/andybalholm/brotli v1.1.1 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
+ github.com/caiguanhao/readqr v1.0.0 // indirect
github.com/catppuccin/go v0.3.0 // indirect
- github.com/charmbracelet/bubbles v0.21.0 // indirect
- github.com/charmbracelet/bubbletea v1.3.5 // indirect
- github.com/charmbracelet/colorprofile v0.3.1 // indirect
- github.com/charmbracelet/huh v0.7.0 // indirect
- github.com/charmbracelet/huh/spinner v0.0.0-20250519092748-d6f1597485e0 // indirect
+ github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect
+ github.com/charmbracelet/bubbletea v1.3.10 // indirect
+ github.com/charmbracelet/colorprofile v0.3.3 // indirect
+ github.com/charmbracelet/huh v0.8.0 // indirect
+ github.com/charmbracelet/huh/spinner v0.0.0-20250714122654-40d2b68703eb // indirect
github.com/charmbracelet/lipgloss v1.1.0 // indirect
- github.com/charmbracelet/x/ansi v0.9.2 // indirect
- github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
- github.com/charmbracelet/x/exp/strings v0.0.0-20250520193441-8304e91a28cb // indirect
- github.com/charmbracelet/x/term v0.2.1 // indirect
+ github.com/charmbracelet/x/ansi v0.11.1 // indirect
+ github.com/charmbracelet/x/cellbuf v0.0.14 // indirect
+ github.com/charmbracelet/x/exp/strings v0.0.0-20251118172736-77d017256798 // indirect
+ github.com/charmbracelet/x/term v0.2.2 // indirect
+ github.com/clipperhouse/displaywidth v0.6.0 // indirect
+ github.com/clipperhouse/stringish v0.1.1 // indirect
+ github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/deckarep/golang-set/v2 v2.8.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
@@ -51,17 +55,15 @@ require (
github.com/go-stack/stack v1.8.1 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
- github.com/inconshreveable/log15 v3.0.0-testing.5+incompatible // indirect
- github.com/inconshreveable/log15/v3 v3.0.0-testing.5 // indirect
github.com/invopop/jsonschema v0.13.0 // indirect
github.com/joho/godotenv v1.5.1 // indirect
github.com/jpillora/backoff v1.0.0 // indirect
- github.com/klauspost/compress v1.17.4 // indirect
- github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
+ github.com/klauspost/compress v1.18.0 // indirect
+ github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
- github.com/mattn/go-runewidth v0.0.16 // indirect
+ github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
@@ -82,16 +84,17 @@ require (
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
github.com/ysmood/fetchup v0.3.0 // indirect
github.com/ysmood/goob v0.4.0 // indirect
- github.com/ysmood/got v0.40.0 // indirect
+ github.com/ysmood/got v0.41.0 // indirect
github.com/ysmood/gson v0.7.3 // indirect
github.com/ysmood/leakless v0.9.0 // indirect
- github.com/yuin/goldmark v1.7.12 // indirect
+ github.com/yuin/goldmark v1.7.13 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.ngrok.com/muxado/v2 v2.0.1 // indirect
- golang.org/x/crypto v0.38.0 // indirect
- golang.org/x/sys v0.33.0 // indirect
- golang.org/x/term v0.32.0 // indirect
- golang.org/x/text v0.25.0 // indirect
- google.golang.org/protobuf v1.35.1 // indirect
+ golang.org/x/crypto v0.45.0 // indirect
+ golang.org/x/sys v0.38.0 // indirect
+ golang.org/x/term v0.37.0 // indirect
+ golang.org/x/text v0.31.0 // indirect
+ golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f // indirect
+ google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
diff --git a/go.sum b/go.sum
index 2cd59b41..8aec04f1 100644
--- a/go.sum
+++ b/go.sum
@@ -2,50 +2,58 @@ github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/MercuryEngineering/CookieMonster v0.0.0-20180304172713-1584578b3403 h1:EtZwYyLbkEcIt+B//6sujwRCnHuTEK3qiSypAX5aJeM=
github.com/MercuryEngineering/CookieMonster v0.0.0-20180304172713-1584578b3403/go.mod h1:mM6WvakkX2m+NgMiPCfFFjwfH4KzENC07zeGEqq9U7s=
-github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI=
-github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
+github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
+github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
-github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
-github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
+github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
+github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
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/caiguanhao/readqr v1.0.0 h1:axynewywpUyqZxFjKPtEbr97PzSOMrJsfn9bKkp+22w=
+github.com/caiguanhao/readqr v1.0.0/go.mod h1:oaAqEl5Zt0XzeIJf7nCEzJFz4is8rfE+Vgiw8b07vMM=
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
-github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
-github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
-github.com/charmbracelet/bubbletea v1.3.5 h1:JAMNLTbqMOhSwoELIr0qyP4VidFq72/6E9j7HHmRKQc=
-github.com/charmbracelet/bubbletea v1.3.5/go.mod h1:TkCnmH+aBd4LrXhXcqrKiYwRs7qyQx5rBgH5fVY3v54=
-github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40=
-github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0=
-github.com/charmbracelet/huh v0.7.0 h1:W8S1uyGETgj9Tuda3/JdVkc3x7DBLZYPZc4c+/rnRdc=
-github.com/charmbracelet/huh v0.7.0/go.mod h1:UGC3DZHlgOKHvHC07a5vHag41zzhpPFj34U92sOmyuk=
-github.com/charmbracelet/huh/spinner v0.0.0-20250519092748-d6f1597485e0 h1:CiQY7CVtEigidVu1vzLxqdW3Tg2DB66R/2OaM3E2rbI=
-github.com/charmbracelet/huh/spinner v0.0.0-20250519092748-d6f1597485e0/go.mod h1:D/ml7UtSMq/cwoJiHJ78KFzGrx4m01ALekBSHImKiu4=
+github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws=
+github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw=
+github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
+github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
+github.com/charmbracelet/colorprofile v0.3.3 h1:DjJzJtLP6/NZ8p7Cgjno0CKGr7wwRJGxWUwh2IyhfAI=
+github.com/charmbracelet/colorprofile v0.3.3/go.mod h1:nB1FugsAbzq284eJcjfah2nhdSLppN2NqvfotkfRYP4=
+github.com/charmbracelet/huh v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxDvY=
+github.com/charmbracelet/huh v0.8.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=
+github.com/charmbracelet/huh/spinner v0.0.0-20250714122654-40d2b68703eb h1:foK5EYUrChM3+7lK6qCEH43p/3oljGMtWtRq+tv3As4=
+github.com/charmbracelet/huh/spinner v0.0.0-20250714122654-40d2b68703eb/go.mod h1:imftm8y+Db+rZ4Jcb6A7qJ0eOX78s9m84n8cdipC+R0=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
-github.com/charmbracelet/x/ansi v0.9.2 h1:92AGsQmNTRMzuzHEYfCdjQeUzTrgE1vfO5/7fEVoXdY=
-github.com/charmbracelet/x/ansi v0.9.2/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
-github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
-github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
+github.com/charmbracelet/x/ansi v0.11.1 h1:iXAC8SyMQDJgtcz9Jnw+HU8WMEctHzoTAETIeA3JXMk=
+github.com/charmbracelet/x/ansi v0.11.1/go.mod h1:M49wjzpIujwPceJ+t5w3qh2i87+HRtHohgb5iTyepL0=
+github.com/charmbracelet/x/cellbuf v0.0.14 h1:iUEMryGyFTelKW3THW4+FfPgi4fkmKnnaLOXuc+/Kj4=
+github.com/charmbracelet/x/cellbuf v0.0.14/go.mod h1:P447lJl49ywBbil/KjCk2HexGh4tEY9LH0/1QrZZ9rA=
github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U=
github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
-github.com/charmbracelet/x/exp/strings v0.0.0-20250520193441-8304e91a28cb h1:JFEeU2KTS+W0dkZVbLeEgXI+PLBRZGomQKeWDpQD+V0=
-github.com/charmbracelet/x/exp/strings v0.0.0-20250520193441-8304e91a28cb/go.mod h1:Rgw3/F+xlcUc5XygUtimVSxAqCOsqyvJjqF5UHRvc5k=
-github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
-github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
+github.com/charmbracelet/x/exp/strings v0.0.0-20251118172736-77d017256798 h1:g0RVaqkUdTikWLqrBdk2ZvJ9oTQOS0HZlYjYE8Tu7yg=
+github.com/charmbracelet/x/exp/strings v0.0.0-20251118172736-77d017256798/go.mod h1:/ehtMPNh9K4odGFkqYJKpIYyePhdp1hLBRvyY4bWkH8=
+github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
+github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI=
github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4=
+github.com/clipperhouse/displaywidth v0.6.0 h1:k32vueaksef9WIKCNcoqRNyKbyvkvkysNYnAWz2fN4s=
+github.com/clipperhouse/displaywidth v0.6.0/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
+github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
+github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
+github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
+github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -82,10 +90,6 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE=
github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ=
-github.com/inconshreveable/log15 v3.0.0-testing.5+incompatible h1:VryeOTiaZfAzwx8xBcID1KlJCeoWSIpsNbSk+/D2LNk=
-github.com/inconshreveable/log15 v3.0.0-testing.5+incompatible/go.mod h1:cOaXtrgN4ScfRrD9Bre7U1thNq5RtJ8ZoP4iXVGRj6o=
-github.com/inconshreveable/log15/v3 v3.0.0-testing.5 h1:h4e0f3kjgg+RJBlKOabrohjHe47D3bbAB9BgMrc3DYA=
-github.com/inconshreveable/log15/v3 v3.0.0-testing.5/go.mod h1:3GQg1SVrLoWGfRv/kAZMsdyU5cp8eFc1P3cw+Wwku94=
github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
@@ -93,26 +97,26 @@ github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwA
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA=
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
-github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
-github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
+github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
+github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
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/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
-github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
+github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
+github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
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.40.0 h1:M0oqK412OHBKut9JwXSsj4KanSmEKpzoW8TcxoPOkAU=
-github.com/mark3labs/mcp-go v0.40.0/go.mod h1:T7tUa2jO6MavG+3P25Oy/jR7iCeJPHImCZHRymCn39g=
+github.com/mark3labs/mcp-go v0.43.2 h1:21PUSlWWiSbUPQwXIJ5WKlETixpFpq+WBpbMGDSVy/I=
+github.com/mark3labs/mcp-go v0.43.2/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
-github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
-github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
+github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc=
github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg=
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
@@ -123,15 +127,14 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
-github.com/openai/openai-go v1.11.0 h1:ztH+W0ug5Kh9+/EErHa8KAmhwixkzjK57rXyE+ZnSCk=
-github.com/openai/openai-go v1.11.0/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y=
+github.com/openai/openai-go v1.12.0 h1:NBQCnXzqOTv5wsgNC36PrFEiskGfO5wccfCWDo9S1U0=
+github.com/openai/openai-go v1.12.0/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y=
github.com/playwright-community/playwright-go v0.5200.0 h1:z/5LGuX2tBrg3ug1HupMXLjIG93f1d2MWdDsNhkMQ9c=
github.com/playwright-community/playwright-go v0.5200.0/go.mod h1:UnnyQZaqUOO5ywAZu60+N4EiWReUqX1MQBBA3Oofvf8=
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/refraction-networking/utls v1.8.0 h1:L38krhiTAyj9EeiQQa2sg+hYb4qwLCqdMcpZrRfbONE=
-github.com/refraction-networking/utls v1.8.0/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
-github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
+github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
@@ -142,10 +145,10 @@ github.com/rusq/fsadapter v1.1.0 h1:/tuzrPNGr4Tx2f8fPK+WudSRBLDvjjDaqVvto1yrVdk=
github.com/rusq/fsadapter v1.1.0/go.mod h1:aSH7MYrWvAGiFkz1qGPE8OknkplFfQSj66leC0eSqYg=
github.com/rusq/slack v0.9.6-0.20250408103104-dd80d1b6337f h1:w4klfw1A3iZv5qWg1YHcRF2bJuRDV7aOpsF6sLLSs0A=
github.com/rusq/slack v0.9.6-0.20250408103104-dd80d1b6337f/go.mod h1:gULX17QqyNX4BF001nHKlSe0uKYI+MAKiDQ7oi80BYI=
-github.com/rusq/slackauth v0.6.1 h1:s09G3WHSA1yz6H9dHT+Yo6DCZF34ClY31tQz849B++Q=
-github.com/rusq/slackauth v0.6.1/go.mod h1:wAtNCbeKH0pnaZnqJjG5RKY3e5BF9F2L/YTzhOjBIb0=
-github.com/rusq/slackdump/v3 v3.1.6 h1:t6hi49jSDWpiXqyna8OlEd2I2zkLBgi9XZGr+xDl5ik=
-github.com/rusq/slackdump/v3 v3.1.6/go.mod h1:c9AiEEkmLWIbQJuxDIK+K9H5g6kdfc06Eqk6DmLWWps=
+github.com/rusq/slackauth v0.7.1 h1:D4peflZtHSyQFh5pLeBI8n0f12enuA9D25mA0KaHo8o=
+github.com/rusq/slackauth v0.7.1/go.mod h1:UOqfnUaJeygO9rYShAhsLxAZjbbEBNaLZpsdw03W3R0=
+github.com/rusq/slackdump/v3 v3.1.11 h1:gFMi7asrlBP67lyXHN95uZ9InpU+DTjfAY3Pebyd90c=
+github.com/rusq/slackdump/v3 v3.1.11/go.mod h1:Kt2VO0In8WBAQP7y6fhxScPgAGOM8UQkl8qt37C0pEw=
github.com/rusq/tagops v0.1.1 h1:R5MHPR822lSg3LFr0RS3DFS0CapRiqtuHVD5NlOMOvY=
github.com/rusq/tagops v0.1.1/go.mod h1:mUJ5WoHxrSv9wreCrHQkAeMevt5aXFadlOdLM6UsoHc=
github.com/slack-go/slack v0.17.3 h1:zV5qO3Q+WJAQ/XwbGfNFrRMaJ5T/naqaonyPV/1TP4g=
@@ -155,8 +158,8 @@ github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cA
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-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/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/takara2314/slack-go-util v0.3.0 h1:vA4WV5liJkZ9JMa2dVN+Rj6u8EW2jRiupOGkn52SMrg=
github.com/takara2314/slack-go-util v0.3.0/go.mod h1:zAMjTWVT2/cDkJtbFm+AtNg5dp+l0FpknJZs8q95NWs=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
@@ -173,6 +176,8 @@ github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
+github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
+github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
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/ysmood/fetchup v0.3.0 h1:UhYz9xnLEVn2ukSuK3KCgcznWpHMdrmbsPpllcylyu8=
@@ -181,8 +186,8 @@ github.com/ysmood/goob v0.4.0 h1:HsxXhyLBeGzWXnqVKtmT9qM7EuVs/XOgkX7T6r1o1AQ=
github.com/ysmood/goob v0.4.0/go.mod h1:u6yx7ZhS4Exf2MwciFr6nIM8knHQIE22lFpWHnfql18=
github.com/ysmood/gop v0.2.0 h1:+tFrG0TWPxT6p9ZaZs+VY+opCvHU8/3Fk6BaNv6kqKg=
github.com/ysmood/gop v0.2.0/go.mod h1:rr5z2z27oGEbyB787hpEcx4ab8cCiPnKxn0SUHt6xzk=
-github.com/ysmood/got v0.40.0 h1:ZQk1B55zIvS7zflRrkGfPDrPG3d7+JOza1ZkNxcc74Q=
-github.com/ysmood/got v0.40.0/go.mod h1:W7DdpuX6skL3NszLmAsC5hT7JAhuLZhByVzHTq874Qg=
+github.com/ysmood/got v0.41.0 h1:XiFH311ltTSGyxjeKcNvy7dzbJjjTzn6DBgK313JHBs=
+github.com/ysmood/got v0.41.0/go.mod h1:W7DdpuX6skL3NszLmAsC5hT7JAhuLZhByVzHTq874Qg=
github.com/ysmood/gotrace v0.6.0 h1:SyI1d4jclswLhg7SWTL6os3L1WOKeNn/ZtzVQF8QmdY=
github.com/ysmood/gotrace v0.6.0/go.mod h1:TzhIG7nHDry5//eYZDYcTzuJLYQIkykJzCRIo4/dzQM=
github.com/ysmood/gson v0.7.3 h1:QFkWbTH8MxyUTKPkVWAENJhxqdBa4lYTQWqZCiLG6kE=
@@ -190,27 +195,27 @@ github.com/ysmood/gson v0.7.3/go.mod h1:3Kzs5zDl21g5F/BlLTNcuAGAYLKt2lV5G8D1zF3R
github.com/ysmood/leakless v0.9.0 h1:qxCG5VirSBvmi3uynXFkcnLMzkphdh3xx5FtrORwDCU=
github.com/ysmood/leakless v0.9.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY8q0JvMQ=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
-github.com/yuin/goldmark v1.7.12 h1:YwGP/rrea2/CnCtUHgjuolG/PnMxdQtPMO5PvaE2/nY=
-github.com/yuin/goldmark v1.7.12/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
+github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
+github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
-go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
-go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
+go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
+go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.ngrok.com/muxado/v2 v2.0.1 h1:jM9i6Pom6GGmnPrHKNR6OJRrUoHFkSZlJ3/S0zqdVpY=
golang.ngrok.com/muxado/v2 v2.0.1/go.mod h1:wzxJYX4xiAtmwumzL+QsukVwFRXmPNv86vB8RPpOxyM=
-golang.ngrok.com/ngrok/v2 v2.0.0 h1:eUEF7ULph6hUdOVR9r7oue2UhT2vvDoLAo0q//N6vJo=
-golang.ngrok.com/ngrok/v2 v2.0.0/go.mod h1:nppMCtZ44/KeGrDHOV0c4bRyMGdHCEBo2Rvjdv/1Uio=
+golang.ngrok.com/ngrok/v2 v2.1.1 h1:HhBEBiTx8Rsf1txH3909ky0XS5xCBYWQWABiX1iuSBc=
+golang.ngrok.com/ngrok/v2 v2.1.1/go.mod h1:0tZJGx2wKb8HO1IR3hzToPwwI7ggE4nl88/AFACgy2A=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
-golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
-golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
-golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI=
-golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ=
+golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
+golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
+golang.org/x/exp v0.0.0-20250808145144-a408d31f581a h1:Y+7uR/b1Mw2iSXZ3G//1haIiSElDQZ8KWh0h+sZPG90=
+golang.org/x/exp v0.0.0-20250808145144-a408d31f581a/go.mod h1:rT6SFzZ7oxADUDx58pcaKFTcZ+inxAa9fTrYx/uVYwg=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -218,13 +223,13 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
-golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
-golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
+golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
+golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
-golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
+golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
+golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -235,32 +240,34 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
-golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
+golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
-golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
-golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
+golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
+golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
-golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
-golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
-golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
-golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
+golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
+golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
+golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
+golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA=
-google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
+golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f h1:GGU+dLjvlC3qDwqYgL6UgRmHXhOOgns0bZu2Ty5mm6U=
+golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
+google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
diff --git a/pkg/handler/channels.go b/pkg/handler/channels.go
index 312ac70c..2a3e0d6c 100644
--- a/pkg/handler/channels.go
+++ b/pkg/handler/channels.go
@@ -27,8 +27,8 @@ type Channel struct {
}
type ChannelsHandler struct {
- apiProvider *provider.ApiProvider // Legacy mode
- tokenStorage oauth.TokenStorage // OAuth mode
+ apiProvider *provider.ApiProvider // Legacy mode
+ tokenStorage oauth.TokenStorage // OAuth mode
oauthEnabled bool
validTypes map[string]bool
logger *zap.Logger
@@ -76,7 +76,12 @@ func (ch *ChannelsHandler) getSlackClient(ctx context.Context) (*slack.Client, e
}
// Use token directly from context (already validated by middleware)
- return slack.New(userCtx.AccessToken), nil
+ // Set API URL from auth.test response to support external tokens and GovSlack
+ opts := []slack.Option{}
+ if userCtx.URL != "" {
+ opts = append(opts, slack.OptionAPIURL(userCtx.URL+"api/"))
+ }
+ return slack.New(userCtx.AccessToken, opts...), nil
}
func (ch *ChannelsHandler) ChannelsResource(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
@@ -399,12 +404,53 @@ func (ch *ChannelsHandler) channelsHandlerOAuth(ctx context.Context, request mcp
}
for _, c := range channels {
+ name := "#" + c.Name
+ memberCount := c.NumMembers
+
+ // Handle DM channels - they have empty names but have a User field
+ if c.IsIM && c.User != "" {
+ // Fetch user info to get their display name
+ user, err := client.GetUserInfo(c.User)
+ if err != nil {
+ ch.logger.Debug("Failed to get user info for DM", zap.String("userID", c.User), zap.Error(err))
+ name = "@" + c.User // Fallback to user ID
+ } else if user != nil {
+ // Priority: RealName (full name) → DisplayName → Name (username)
+ // This provides more consistent formatting across users
+ if user.RealName != "" {
+ name = "@" + user.RealName
+ } else if user.Profile.DisplayName != "" {
+ name = "@" + user.Profile.DisplayName
+ } else {
+ name = "@" + user.Name
+ }
+ }
+ memberCount = 2 // 1:1 DMs always have exactly 2 members
+ } else if c.IsMpIM {
+ // Group DMs - use the purpose or a placeholder
+ if c.Purpose.Value != "" {
+ name = c.Purpose.Value
+ } else {
+ name = "Group DM"
+ }
+ // Get actual member count for group DMs
+ members, _, err := client.GetUsersInConversation(&slack.GetUsersInConversationParameters{
+ ChannelID: c.ID,
+ Limit: 1000,
+ })
+ if err != nil {
+ ch.logger.Debug("Failed to get members for MPIM", zap.String("channelID", c.ID), zap.Error(err))
+ } else {
+ memberCount = len(members)
+ }
+ }
+
allChannels = append(allChannels, Channel{
ID: c.ID,
- Name: "#" + c.Name,
+ Name: name,
Topic: c.Topic.Value,
Purpose: c.Purpose.Value,
- MemberCount: c.NumMembers,
+ MemberCount: memberCount,
})
}
}
@@ -427,4 +473,3 @@ func (ch *ChannelsHandler) channelsHandlerOAuth(ctx context.Context, request mcp
ch.logger.Debug("Returning channels", zap.Int("count", len(allChannels)))
return mcp.NewToolResultText(string(csvBytes)), nil
}
-
diff --git a/pkg/handler/channels_test.go b/pkg/handler/channels_test.go
index 65a6e079..2e8286c5 100644
--- a/pkg/handler/channels_test.go
+++ b/pkg/handler/channels_test.go
@@ -3,60 +3,36 @@ package handler
import (
"context"
"encoding/csv"
- "encoding/json"
"fmt"
- "os"
"regexp"
"strconv"
"strings"
"testing"
+ "time"
"github.com/google/uuid"
"github.com/korotovsky/slack-mcp-server/pkg/test/util"
- "github.com/openai/openai-go/packages/param"
+ "github.com/mark3labs/mcp-go/client"
+ "github.com/mark3labs/mcp-go/mcp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
-
- "github.com/openai/openai-go"
- "github.com/openai/openai-go/option"
- "github.com/openai/openai-go/responses"
)
-type channelsListToolArgs struct {
- ChannelTypes ChannelTypes `json:"channel_types"`
- Cursor string `json:"cursor"`
- Limit int `json:"limit"`
- Sort string `json:"sort,omitempty"`
+type testEnv struct {
+ mcpClient *client.Client
+ ctx context.Context
}
-type ChannelTypes []string
-
-func (c *ChannelTypes) UnmarshalJSON(data []byte) error {
- var raw string
- if err := json.Unmarshal(data, &raw); err != nil {
- return err
- }
- parts := strings.Split(raw, ",")
- allowed := map[string]bool{
- "public_channel": true,
- "private_channel": true,
- "im": true,
- "mpim": true,
- }
- for _, ch := range parts {
- if !allowed[ch] {
- return fmt.Errorf("invalid channel type %q", ch)
- }
- }
- *c = parts
- return nil
+type matchingRule struct {
+ csvFieldName string
+ csvFieldValueRE string
}
-func TestIntegrationChannelsList(t *testing.T) {
+func setupTestEnv(t *testing.T) (*testEnv, func()) {
+ t.Helper()
+
sseKey := uuid.New().String()
require.NotEmpty(t, sseKey, "sseKey must be generated for integration tests")
- apiKey := os.Getenv("SLACK_MCP_OPENAI_API")
- require.NotEmpty(t, apiKey, "SLACK_MCP_OPENAI_API must be set for integration tests")
cfg := util.MCPConfig{
SSEKey: sseKey,
@@ -64,158 +40,124 @@ func TestIntegrationChannelsList(t *testing.T) {
MessageToolMark: true,
}
- mcp, err := util.SetupMCP(cfg)
- if err != nil {
- t.Fatalf("Failed to set up MCP server: %v", err)
+ 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",
}
- fwd, err := util.SetupForwarding(context.Background(), "http://"+mcp.Host+":"+strconv.Itoa(mcp.Port))
- if err != nil {
- t.Fatalf("Failed to set up ngrok forwarding: %v", err)
+ 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()
}
- defer fwd.Shutdown()
- defer mcp.Shutdown()
- client := openai.NewClient(option.WithAPIKey(apiKey))
- ctx := context.Background()
+ return &testEnv{
+ mcpClient: mcpClient,
+ ctx: ctx,
+ }, cleanup
+}
+
+func runChannelTest(t *testing.T, env *testEnv, channelType string, expectedChannels []matchingRule) {
+ t.Helper()
- type matchingRule struct {
- csvFieldName string
- csvFieldValueRE string
- RowPosition *int
- TotalRows *int
+ callReq := mcp.CallToolRequest{}
+ callReq.Params.Name = "channels_list"
+ callReq.Params.Arguments = map[string]any{
+ "channel_types": channelType,
}
- type tc struct {
- name string
- input string
- expectedToolName string
- expectedToolOutputMatchingRules []matchingRule
- expectedLLMOutputMatchingRules []string
+ 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)
+ }
}
- cases := []tc{
- {
- name: "Get list of channels",
- input: "Provide a list of slack channels.",
- expectedToolName: "channels_list",
- expectedToolOutputMatchingRules: []matchingRule{
- {
- csvFieldName: "Name",
- csvFieldValueRE: `^#general$`,
- },
- {
- csvFieldName: "Name",
- csvFieldValueRE: `^#testcase-1$`,
- },
- {
- csvFieldName: "Name",
- csvFieldValueRE: `^#testcase-2$`,
- },
- {
- csvFieldName: "Name",
- csvFieldValueRE: `^#testcase-3$`,
- },
- },
- expectedLLMOutputMatchingRules: []string{
- "channels", "#general", "#testcase-1", "#testcase-2", "#testcase-3",
- },
- },
+ 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 _, tc := range cases {
- t.Run(tc.name, func(t *testing.T) {
- params := responses.ResponseNewParams{
- Model: "gpt-4.1-mini",
- Tools: []responses.ToolUnionParam{
- {
- OfMcp: &responses.ToolMcpParam{
- ServerLabel: "slack-mcp-server",
- ServerURL: fmt.Sprintf("%s://%s/sse", fwd.URL.Scheme, fwd.URL.Host),
- RequireApproval: responses.ToolMcpRequireApprovalUnionParam{
- OfMcpToolApprovalSetting: param.NewOpt("never"),
- },
- Headers: map[string]string{
- "Authorization": "Bearer " + sseKey,
- },
- },
- },
- },
- Input: responses.ResponseNewParamsInputUnion{
- OfString: openai.String(tc.input),
- },
- }
+ 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)
- resp, err := client.Responses.New(ctx, params)
- require.NoError(t, err, "API call failed")
-
- assert.NotNil(t, resp.Status, "completed")
-
- var llmOutput strings.Builder
- var toolOutput strings.Builder
- for _, out := range resp.Output {
- if out.Type == "message" && out.Role == "assistant" {
- for _, c := range out.Content {
- if c.Type == "output_text" {
- llmOutput.WriteString(c.Text)
- }
- }
- }
- if out.Type == "mcp_call" && out.Name == tc.expectedToolName {
- toolOutput.WriteString(out.Output)
- }
+ 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())
+ }
+}
- require.NotEmpty(t, toolOutput, "no tool output captured")
+func TestIntegrationPublicChannelsList(t *testing.T) {
+ env, cleanup := setupTestEnv(t)
+ defer cleanup()
- // Parse CSV
- reader := csv.NewReader(strings.NewReader(toolOutput.String()))
- rows, err := reader.ReadAll()
- require.NoError(t, err, "failed to parse CSV")
+ expectedChannels := []matchingRule{
+ {csvFieldName: "Name", csvFieldValueRE: `^#general$`},
+ {csvFieldName: "Name", csvFieldValueRE: `^#testcase-1$`},
+ {csvFieldName: "Name", csvFieldValueRE: `^#testcase-2$`},
+ {csvFieldName: "Name", csvFieldValueRE: `^#testcase-3$`},
+ }
- header := rows[0]
- dataRows := rows[1:]
- colIndex := map[string]int{}
- for i, col := range header {
- colIndex[col] = i
- }
+ runChannelTest(t, env, "public_channel", expectedChannels)
+}
- for _, rule := range tc.expectedToolOutputMatchingRules {
- if rule.TotalRows != nil && *rule.TotalRows > 0 {
- assert.Equalf(t, *rule.TotalRows, len(dataRows),
- "expected %d data rows, got %d", rule.TotalRows, len(dataRows))
- }
-
- 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)
-
- if rule.RowPosition != nil && *rule.RowPosition >= 0 {
- require.Lessf(t, rule.RowPosition, len(dataRows), "RowPosition %d out of range (only %d data rows)", rule.RowPosition, len(dataRows))
- value := dataRows[*rule.RowPosition][idx]
- assert.Regexpf(t, re, value, "row %d, column %q: expected to match %q, got %q",
- rule.RowPosition, rule.csvFieldName, rule.csvFieldValueRE, value)
- continue
- }
-
- 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 TestIntegrationPrivateChannelsList(t *testing.T) {
+ env, cleanup := setupTestEnv(t)
+ defer cleanup()
- for _, pattern := range tc.expectedLLMOutputMatchingRules {
- re, err := regexp.Compile(pattern)
- require.NoErrorf(t, err, "invalid LLM regex %q", pattern)
- assert.Regexpf(t, re, llmOutput.String(), "LLM output did not match regex %q; output:\n%s",
- pattern, llmOutput.String())
- }
- })
+ expectedChannels := []matchingRule{
+ {csvFieldName: "Name", csvFieldValueRE: `^#testcase-4$`},
}
+
+ runChannelTest(t, env, "private_channel", expectedChannels)
}
diff --git a/pkg/handler/conversations.go b/pkg/handler/conversations.go
index f8a5486a..cd85dc72 100644
--- a/pkg/handler/conversations.go
+++ b/pkg/handler/conversations.go
@@ -81,8 +81,8 @@ type addMessageParams struct {
}
type ConversationsHandler struct {
- apiProvider *provider.ApiProvider // Legacy mode
- tokenStorage oauth.TokenStorage // OAuth mode
+ apiProvider *provider.ApiProvider // Legacy mode
+ tokenStorage oauth.TokenStorage // OAuth mode
oauthEnabled bool
logger *zap.Logger
}
@@ -118,7 +118,12 @@ func (h *ConversationsHandler) getSlackClient(ctx context.Context) (*slack.Clien
}
// Use user token by default
- return slack.New(userCtx.AccessToken), nil
+ // Set API URL from auth.test response to support external tokens and GovSlack
+ opts := []slack.Option{}
+ if userCtx.URL != "" {
+ opts = append(opts, slack.OptionAPIURL(userCtx.URL+"api/"))
+ }
+ return slack.New(userCtx.AccessToken, opts...), nil
}
// getBotSlackClient creates a Slack client using bot token (OAuth mode)
@@ -138,7 +143,12 @@ func (h *ConversationsHandler) getBotSlackClient(ctx context.Context) (*slack.Cl
}
// Use bot token
- return slack.New(userCtx.BotToken), nil
+ // Set API URL from auth.test response to support external tokens and GovSlack
+ opts := []slack.Option{}
+ if userCtx.URL != "" {
+ opts = append(opts, slack.OptionAPIURL(userCtx.URL+"api/"))
+ }
+ return slack.New(userCtx.BotToken, opts...), nil
}
// getProvider returns the provider (legacy mode) or error (OAuth mode)
@@ -149,6 +159,72 @@ func (h *ConversationsHandler) getProvider() (*provider.ApiProvider, error) {
return h.apiProvider, nil
}
+// resolveChannelName resolves a channel name (e.g., "#general" or "@username") to a channel ID
+// using the Slack API. This is used in OAuth mode where there's no channel cache.
+func (h *ConversationsHandler) resolveChannelName(ctx context.Context, client *slack.Client, channelName string) (string, error) {
+ if client == nil {
+ return "", fmt.Errorf("slack client is nil")
+ }
+
+ // Handle @username for DMs
+ if strings.HasPrefix(channelName, "@") {
+ username := strings.TrimPrefix(channelName, "@")
+ // Look up user by name
+ users, err := client.GetUsersContext(ctx)
+ if err != nil {
+ return "", fmt.Errorf("failed to get users: %w", err)
+ }
+ for _, user := range users {
+ if user.Name == username || user.Profile.DisplayName == username {
+ // Open a DM with this user
+ channel, _, _, err := client.OpenConversationContext(ctx, &slack.OpenConversationParameters{
+ Users: []string{user.ID},
+ })
+ if err != nil {
+ return "", fmt.Errorf("failed to open DM with user %s: %w", username, err)
+ }
+ if channel == nil {
+ return "", fmt.Errorf("failed to open DM with user %s: nil channel returned", username)
+ }
+ return channel.ID, nil
+ }
+ }
+ return "", fmt.Errorf("user %q not found", username)
+ }
+
+ // Handle #channel-name
+ name := strings.TrimPrefix(channelName, "#")
+
+ // Search through public and private channels
+ channelTypes := []string{"public_channel", "private_channel"}
+ for _, chanType := range channelTypes {
+ cursor := ""
+ for {
+ params := &slack.GetConversationsParameters{
+ Types: []string{chanType},
+ Limit: 200,
+ Cursor: cursor,
+ }
+ channels, nextCursor, err := client.GetConversationsContext(ctx, params)
+ if err != nil {
+ h.logger.Debug("Failed to get conversations", zap.String("type", chanType), zap.Error(err))
+ break
+ }
+ for _, c := range channels {
+ if c.Name == name {
+ return c.ID, nil
+ }
+ }
+ if nextCursor == "" {
+ break
+ }
+ cursor = nextCursor
+ }
+ }
+
+ return "", fmt.Errorf("channel %q not found", channelName)
+}
+
// UsersResource streams a CSV of all users
func (ch *ConversationsHandler) UsersResource(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) {
ch.logger.Debug("UsersResource called", zap.Any("params", request.Params))
@@ -265,7 +341,7 @@ func (ch *ConversationsHandler) ConversationsAddMessageHandler(ctx context.Conte
zap.String("thread_ts", params.threadTs),
zap.String("content_type", params.contentType),
)
-
+
var respChannel, respTimestamp string
if ch.oauthEnabled {
respChannel, respTimestamp, err = slackClient.PostMessageContext(ctx, params.channel, options...)
@@ -298,9 +374,12 @@ func (ch *ConversationsHandler) ConversationsAddMessageHandler(ctx context.Conte
Latest: respTimestamp,
Inclusive: true,
}
-
+
var history *slack.GetConversationHistoryResponse
if ch.oauthEnabled {
+ if slackClient == nil {
+ return nil, fmt.Errorf("slack client is nil in OAuth mode")
+ }
history, err = slackClient.GetConversationHistoryContext(ctx, &historyParams)
} else {
history, err = ch.apiProvider.Slack().GetConversationHistoryContext(ctx, &historyParams)
@@ -309,9 +388,13 @@ func (ch *ConversationsHandler) ConversationsAddMessageHandler(ctx context.Conte
ch.logger.Error("GetConversationHistoryContext failed", zap.Error(err))
return nil, err
}
+ if history == nil {
+ ch.logger.Error("GetConversationHistoryContext returned nil response")
+ return nil, fmt.Errorf("failed to get conversation history: nil response")
+ }
ch.logger.Debug("Fetched conversation history", zap.Int("message_count", len(history.Messages)))
- messages := ch.convertMessagesFromHistory(history.Messages, historyParams.ChannelID, false)
+ messages := ch.convertMessagesFromHistory(ctx, slackClient, history.Messages, historyParams.ChannelID, false)
return marshalMessagesToCSV(messages)
}
@@ -329,7 +412,7 @@ func (ch *ConversationsHandler) ConversationsHistoryHandler(ctx context.Context,
slackClient = client
}
- params, err := ch.parseParamsToolConversations(request)
+ params, err := ch.parseParamsToolConversations(ctx, slackClient, request)
if err != nil {
ch.logger.Error("Failed to parse history params", zap.Error(err))
return nil, err
@@ -350,9 +433,12 @@ func (ch *ConversationsHandler) ConversationsHistoryHandler(ctx context.Context,
Cursor: params.cursor,
Inclusive: false,
}
-
+
var history *slack.GetConversationHistoryResponse
if ch.oauthEnabled {
+ if slackClient == nil {
+ return nil, fmt.Errorf("slack client is nil in OAuth mode")
+ }
history, err = slackClient.GetConversationHistoryContext(ctx, &historyParams)
} else {
history, err = ch.apiProvider.Slack().GetConversationHistoryContext(ctx, &historyParams)
@@ -361,10 +447,14 @@ func (ch *ConversationsHandler) ConversationsHistoryHandler(ctx context.Context,
ch.logger.Error("GetConversationHistoryContext failed", zap.Error(err))
return nil, err
}
+ if history == nil {
+ ch.logger.Error("GetConversationHistoryContext returned nil response")
+ return nil, fmt.Errorf("failed to get conversation history: nil response")
+ }
ch.logger.Debug("Fetched conversation history", zap.Int("message_count", len(history.Messages)))
- messages := ch.convertMessagesFromHistory(history.Messages, params.channel, params.activity)
+ messages := ch.convertMessagesFromHistory(ctx, slackClient, history.Messages, params.channel, params.activity)
if len(messages) > 0 && history.HasMore {
messages[len(messages)-1].Cursor = history.ResponseMetaData.NextCursor
@@ -386,7 +476,7 @@ func (ch *ConversationsHandler) ConversationsRepliesHandler(ctx context.Context,
slackClient = client
}
- params, err := ch.parseParamsToolConversations(request)
+ params, err := ch.parseParamsToolConversations(ctx, slackClient, request)
if err != nil {
ch.logger.Error("Failed to parse replies params", zap.Error(err))
return nil, err
@@ -406,7 +496,7 @@ func (ch *ConversationsHandler) ConversationsRepliesHandler(ctx context.Context,
Cursor: params.cursor,
Inclusive: false,
}
-
+
var replies []slack.Message
var hasMore bool
var nextCursor string
@@ -421,7 +511,7 @@ func (ch *ConversationsHandler) ConversationsRepliesHandler(ctx context.Context,
}
ch.logger.Debug("Fetched conversation replies", zap.Int("count", len(replies)))
- messages := ch.convertMessagesFromHistory(replies, params.channel, params.activity)
+ messages := ch.convertMessagesFromHistory(ctx, slackClient, replies, params.channel, params.activity)
if len(messages) > 0 && hasMore {
messages[len(messages)-1].Cursor = nextCursor
}
@@ -441,7 +531,7 @@ func (ch *ConversationsHandler) ConversationsSearchHandler(ctx context.Context,
slackClient = client
}
- params, err := ch.parseParamsToolSearch(request)
+ params, err := ch.parseParamsToolSearch(ctx, slackClient, request)
if err != nil {
ch.logger.Error("Failed to parse search params", zap.Error(err))
return nil, err
@@ -455,7 +545,7 @@ func (ch *ConversationsHandler) ConversationsSearchHandler(ctx context.Context,
Count: params.limit,
Page: params.page,
}
-
+
var messagesRes *slack.SearchMessages
if ch.oauthEnabled {
messagesRes, _, err = slackClient.SearchContext(ctx, params.query, searchParams)
@@ -468,7 +558,7 @@ func (ch *ConversationsHandler) ConversationsSearchHandler(ctx context.Context,
}
ch.logger.Debug("Search completed", zap.Int("matches", len(messagesRes.Matches)))
- messages := ch.convertMessagesFromSearch(messagesRes.Matches)
+ messages := ch.convertMessagesFromSearch(ctx, slackClient, messagesRes.Matches)
if len(messages) > 0 && messagesRes.Pagination.Page < messagesRes.Pagination.PageCount {
nextCursor := fmt.Sprintf("page:%d", messagesRes.Pagination.Page+1)
messages[len(messages)-1].Cursor = base64.StdEncoding.EncodeToString([]byte(nextCursor))
@@ -498,17 +588,30 @@ func isChannelAllowed(channel string) bool {
return !isNegated
}
-func (ch *ConversationsHandler) convertMessagesFromHistory(slackMessages []slack.Message, channel string, includeActivity bool) []Message {
+func (ch *ConversationsHandler) convertMessagesFromHistory(ctx context.Context, slackClient *slack.Client, slackMessages []slack.Message, channel string, includeActivity bool) []Message {
// Get users map (if available)
- var usersMap *provider.UsersCache
+ var usersMap map[string]slack.User
if !ch.oauthEnabled {
- usersMap = ch.apiProvider.ProvideUsersMap()
+ cache := ch.apiProvider.ProvideUsersMap()
+ usersMap = cache.Users
} else {
- // OAuth mode: no cache, use empty map
- usersMap = &provider.UsersCache{
- Users: make(map[string]slack.User),
- UsersInv: make(map[string]string),
+ // OAuth mode: fetch user info from Slack API
+ // Collect all user IDs from messages AND from mentions in message text
+ var userIDs []string
+ userMentionRe := regexp.MustCompile(`<@(U[A-Z0-9]+)(?:\|[^>]*)?>`)
+ for _, msg := range slackMessages {
+ if msg.User != "" {
+ userIDs = append(userIDs, msg.User)
+ }
+ // Extract user IDs from mentions in the text
+ matches := userMentionRe.FindAllStringSubmatch(msg.Text, -1)
+ for _, match := range matches {
+ if len(match) > 1 {
+ userIDs = append(userIDs, match[1])
+ }
+ }
}
+ usersMap = ch.fetchUsersForMessages(ctx, slackClient, userIDs)
}
var messages []Message
warn := false
@@ -518,7 +621,7 @@ func (ch *ConversationsHandler) convertMessagesFromHistory(slackMessages []slack
continue
}
- userName, realName, ok := getUserInfo(msg.User, usersMap.Users)
+ userName, realName, ok := getUserInfo(msg.User, usersMap)
if !ok && msg.SubType == "bot_message" {
userName, realName, ok = getBotInfo(msg.Username)
@@ -535,6 +638,8 @@ func (ch *ConversationsHandler) convertMessagesFromHistory(slackMessages []slack
}
msgText := msg.Text + text.AttachmentsTo2CSV(msg.Text, msg.Attachments)
+ // Expand user mentions to display names
+ msgText = expandUserMentions(msgText, usersMap)
var reactionParts []string
for _, r := range msg.Reactions {
@@ -568,23 +673,40 @@ func (ch *ConversationsHandler) convertMessagesFromHistory(slackMessages []slack
return messages
}
-func (ch *ConversationsHandler) convertMessagesFromSearch(slackMessages []slack.SearchMessage) []Message {
+func (ch *ConversationsHandler) convertMessagesFromSearch(ctx context.Context, slackClient *slack.Client, slackMessages []slack.SearchMessage) []Message {
// Get users map (if available)
- var usersMap *provider.UsersCache
+ var usersMap map[string]slack.User
if !ch.oauthEnabled {
- usersMap = ch.apiProvider.ProvideUsersMap()
+ cache := ch.apiProvider.ProvideUsersMap()
+ usersMap = cache.Users
} else {
- // OAuth mode: no cache, use empty map
- usersMap = &provider.UsersCache{
- Users: make(map[string]slack.User),
- UsersInv: make(map[string]string),
+ // OAuth mode: fetch user info from Slack API
+ // Collect all user IDs from messages AND from mentions in message text
+ var userIDs []string
+ userMentionRe := regexp.MustCompile(`<@(U[A-Z0-9]+)(?:\|[^>]*)?>`)
+ for _, msg := range slackMessages {
+ if msg.User != "" {
+ userIDs = append(userIDs, msg.User)
+ }
+ // Also collect user IDs from DM channel names (they appear as user IDs like U1234)
+ if strings.HasPrefix(msg.Channel.Name, "U") {
+ userIDs = append(userIDs, msg.Channel.Name)
+ }
+ // Extract user IDs from mentions in the text
+ matches := userMentionRe.FindAllStringSubmatch(msg.Text, -1)
+ for _, match := range matches {
+ if len(match) > 1 {
+ userIDs = append(userIDs, match[1])
+ }
+ }
}
+ usersMap = ch.fetchUsersForMessages(ctx, slackClient, userIDs)
}
var messages []Message
warn := false
for _, msg := range slackMessages {
- userName, realName, ok := getUserInfo(msg.User, usersMap.Users)
+ userName, realName, ok := getUserInfo(msg.User, usersMap)
if !ok && msg.User == "" && msg.Username != "" {
userName, realName, ok = getBotInfo(msg.Username)
@@ -601,6 +723,32 @@ func (ch *ConversationsHandler) convertMessagesFromSearch(slackMessages []slack.
}
msgText := msg.Text + text.AttachmentsTo2CSV(msg.Text, msg.Attachments)
+ // Expand user mentions to display names (search API may already do this, but be safe)
+ msgText = expandUserMentions(msgText, usersMap)
+
+ // Format channel name properly
+ channelDisplay := fmt.Sprintf("#%s", msg.Channel.Name)
+ // Check if this is a DM (channel ID starts with D) or if the name looks like a user ID
+ if strings.HasPrefix(msg.Channel.ID, "D") || strings.HasPrefix(msg.Channel.Name, "U") {
+ // This is a DM - try to get the user's name
+ if strings.HasPrefix(msg.Channel.Name, "U") {
+ // The "name" is actually a user ID - look it up
+ if user, exists := usersMap[msg.Channel.Name]; exists {
+ // Priority: RealName → DisplayName → Name for consistent formatting
+ if user.RealName != "" {
+ channelDisplay = "@" + user.RealName
+ } else if user.Profile.DisplayName != "" {
+ channelDisplay = "@" + user.Profile.DisplayName
+ } else {
+ channelDisplay = "@" + user.Name
+ }
+ } else {
+ channelDisplay = "@" + msg.Channel.Name
+ }
+ } else {
+ channelDisplay = "@" + msg.Channel.Name
+ }
+ }
messages = append(messages, Message{
MsgID: msg.Timestamp,
@@ -608,7 +756,7 @@ func (ch *ConversationsHandler) convertMessagesFromSearch(slackMessages []slack.
UserName: userName,
RealName: realName,
Text: text.ProcessText(msgText),
- Channel: fmt.Sprintf("#%s", msg.Channel.Name),
+ Channel: channelDisplay,
ThreadTs: threadTs,
Time: timestamp,
Reactions: "",
@@ -628,7 +776,7 @@ func (ch *ConversationsHandler) convertMessagesFromSearch(slackMessages []slack.
return messages
}
-func (ch *ConversationsHandler) parseParamsToolConversations(request mcp.CallToolRequest) (*conversationParams, error) {
+func (ch *ConversationsHandler) parseParamsToolConversations(ctx context.Context, slackClient *slack.Client, request mcp.CallToolRequest) (*conversationParams, error) {
channel := request.GetString("channel_id", "")
if channel == "" {
ch.logger.Error("channel_id missing in conversations params")
@@ -660,28 +808,40 @@ func (ch *ConversationsHandler) parseParamsToolConversations(request mcp.CallToo
}
if strings.HasPrefix(channel, "#") || strings.HasPrefix(channel, "@") {
- if ready, err := ch.apiProvider.IsReady(); !ready {
- if errors.Is(err, provider.ErrUsersNotReady) {
- ch.logger.Warn(
- "WARNING: Slack users sync is not ready yet, you may experience some limited functionality and see UIDs instead of resolved names as well as unable to query users by their @handles. Users sync is part of channels sync and operations on channels depend on users collection (IM, MPIM). Please wait until users are synced and try again",
- zap.Error(err),
- )
+ // OAuth mode: resolve channel name to ID using Slack API
+ if ch.oauthEnabled {
+ ch.logger.Debug("Resolving channel name in OAuth mode", zap.String("channel", channel))
+ resolvedID, err := ch.resolveChannelName(ctx, slackClient, channel)
+ if err != nil {
+ ch.logger.Error("Failed to resolve channel name", zap.String("channel", channel), zap.Error(err))
+ return nil, fmt.Errorf("failed to resolve channel name %q: %w", channel, err)
}
- if errors.Is(err, provider.ErrChannelsNotReady) {
- ch.logger.Warn(
- "WARNING: Slack channels sync is not ready yet, you may experience some limited functionality and be able to request conversation only by Channel ID, not by its name. Please wait until channels are synced and try again.",
- zap.Error(err),
- )
+ channel = resolvedID
+ } else {
+ // Legacy mode: use channel cache
+ if ready, err := ch.apiProvider.IsReady(); !ready {
+ if errors.Is(err, provider.ErrUsersNotReady) {
+ ch.logger.Warn(
+ "WARNING: Slack users sync is not ready yet, you may experience some limited functionality and see UIDs instead of resolved names as well as unable to query users by their @handles. Users sync is part of channels sync and operations on channels depend on users collection (IM, MPIM). Please wait until users are synced and try again",
+ zap.Error(err),
+ )
+ }
+ if errors.Is(err, provider.ErrChannelsNotReady) {
+ ch.logger.Warn(
+ "WARNING: Slack channels sync is not ready yet, you may experience some limited functionality and be able to request conversation only by Channel ID, not by its name. Please wait until channels are synced and try again.",
+ zap.Error(err),
+ )
+ }
+ return nil, fmt.Errorf("channel %q not found in empty cache", channel)
}
- return nil, fmt.Errorf("channel %q not found in empty cache", channel)
- }
- channelsMaps := ch.apiProvider.ProvideChannelsMaps()
- chn, ok := channelsMaps.ChannelsInv[channel]
- if !ok {
- ch.logger.Error("Channel not found in synced cache", zap.String("channel", channel))
- return nil, fmt.Errorf("channel %q not found in synced cache. Try to remove old cache file and restart MCP Server", channel)
+ channelsMaps := ch.apiProvider.ProvideChannelsMaps()
+ chn, ok := channelsMaps.ChannelsInv[channel]
+ if !ok {
+ ch.logger.Error("Channel not found in synced cache", zap.String("channel", channel))
+ return nil, fmt.Errorf("channel %q not found in synced cache. Try to remove old cache file and restart MCP Server", channel)
+ }
+ channel = channelsMaps.Channels[chn].ID
}
- channel = channelsMaps.Channels[chn].ID
}
return &conversationParams{
@@ -756,7 +916,7 @@ func (ch *ConversationsHandler) parseParamsToolAddMessage(request mcp.CallToolRe
}, nil
}
-func (ch *ConversationsHandler) parseParamsToolSearch(req mcp.CallToolRequest) (*searchParams, error) {
+func (ch *ConversationsHandler) parseParamsToolSearch(ctx context.Context, slackClient *slack.Client, req mcp.CallToolRequest) (*searchParams, error) {
rawQuery := strings.TrimSpace(req.GetString("search_query", ""))
freeText, filters := splitQuery(rawQuery)
@@ -764,14 +924,14 @@ func (ch *ConversationsHandler) parseParamsToolSearch(req mcp.CallToolRequest) (
addFilter(filters, "is", "thread")
}
if chName := req.GetString("filter_in_channel", ""); chName != "" {
- f, err := ch.paramFormatChannel(chName)
+ f, err := ch.paramFormatChannel(ctx, slackClient, chName)
if err != nil {
ch.logger.Error("Invalid channel filter", zap.String("filter", chName), zap.Error(err))
return nil, err
}
addFilter(filters, "in", f)
} else if im := req.GetString("filter_in_im_or_mpim", ""); im != "" {
- f, err := ch.paramFormatUser(im)
+ f, err := ch.paramFormatUser(ctx, slackClient, im)
if err != nil {
ch.logger.Error("Invalid IM/MPIM filter", zap.String("filter", im), zap.Error(err))
return nil, err
@@ -779,7 +939,7 @@ func (ch *ConversationsHandler) parseParamsToolSearch(req mcp.CallToolRequest) (
addFilter(filters, "in", f)
}
if with := req.GetString("filter_users_with", ""); with != "" {
- f, err := ch.paramFormatUser(with)
+ f, err := ch.paramFormatUser(ctx, slackClient, with)
if err != nil {
ch.logger.Error("Invalid with-user filter", zap.String("filter", with), zap.Error(err))
return nil, err
@@ -787,7 +947,7 @@ func (ch *ConversationsHandler) parseParamsToolSearch(req mcp.CallToolRequest) (
addFilter(filters, "with", f)
}
if from := req.GetString("filter_users_from", ""); from != "" {
- f, err := ch.paramFormatUser(from)
+ f, err := ch.paramFormatUser(ctx, slackClient, from)
if err != nil {
ch.logger.Error("Invalid from-user filter", zap.String("filter", from), zap.Error(err))
return nil, err
@@ -849,31 +1009,45 @@ func (ch *ConversationsHandler) parseParamsToolSearch(req mcp.CallToolRequest) (
}, nil
}
-func (ch *ConversationsHandler) paramFormatUser(raw string) (string, error) {
- if ch.oauthEnabled {
- // OAuth mode: require user IDs, not names
- raw = strings.TrimSpace(raw)
- if strings.HasPrefix(raw, "U") {
- return fmt.Sprintf("<@%s>", raw), nil
- }
- return "", fmt.Errorf("in OAuth mode, please use user ID (U...) instead of name: %s", raw)
- }
-
- users := ch.apiProvider.ProvideUsersMap()
+func (ch *ConversationsHandler) paramFormatUser(ctx context.Context, slackClient *slack.Client, raw string) (string, error) {
raw = strings.TrimSpace(raw)
+
+ // Handle user ID directly
if strings.HasPrefix(raw, "U") {
- u, ok := users.Users[raw]
- if !ok {
- return "", fmt.Errorf("user %q not found", raw)
- }
- return fmt.Sprintf("<@%s>", u.ID), nil
+ return fmt.Sprintf("<@%s>", raw), nil
}
+
+ // Strip @ prefix if present
if strings.HasPrefix(raw, "<@") {
raw = raw[2:]
+ if idx := strings.Index(raw, ">"); idx >= 0 {
+ raw = raw[:idx]
+ }
+ return fmt.Sprintf("<@%s>", raw), nil
}
if strings.HasPrefix(raw, "@") {
raw = raw[1:]
}
+
+ if ch.oauthEnabled {
+ // OAuth mode: resolve username to user ID via Slack API
+ if slackClient == nil {
+ return "", fmt.Errorf("slack client is nil")
+ }
+ users, err := slackClient.GetUsersContext(ctx)
+ if err != nil {
+ return "", fmt.Errorf("failed to get users: %w", err)
+ }
+ for _, user := range users {
+ if user.Name == raw || user.Profile.DisplayName == raw {
+ return fmt.Sprintf("<@%s>", user.ID), nil
+ }
+ }
+ return "", fmt.Errorf("user %q not found", raw)
+ }
+
+ // Legacy mode: use cached users
+ users := ch.apiProvider.ProvideUsersMap()
uid, ok := users.UsersInv[raw]
if !ok {
return "", fmt.Errorf("user %q not found", raw)
@@ -881,32 +1055,67 @@ func (ch *ConversationsHandler) paramFormatUser(raw string) (string, error) {
return fmt.Sprintf("<@%s>", uid), nil
}
-func (ch *ConversationsHandler) paramFormatChannel(raw string) (string, error) {
+func (ch *ConversationsHandler) paramFormatChannel(ctx context.Context, slackClient *slack.Client, raw string) (string, error) {
raw = strings.TrimSpace(raw)
-
- if ch.oauthEnabled {
- // OAuth mode: use channel ID directly
- if strings.HasPrefix(raw, "C") || strings.HasPrefix(raw, "G") {
+
+ // Handle channel ID directly - for search, we need the channel name
+ if strings.HasPrefix(raw, "C") || strings.HasPrefix(raw, "G") {
+ if ch.oauthEnabled {
+ // In OAuth mode with a channel ID, we need to get the channel name for search
+ if slackClient != nil {
+ info, err := slackClient.GetConversationInfoContext(ctx, &slack.GetConversationInfoInput{
+ ChannelID: raw,
+ })
+ if err == nil && info != nil {
+ return info.Name, nil
+ }
+ }
+ // Fallback: use the ID (search might still work)
return raw, nil
}
- return "", fmt.Errorf("in OAuth mode, please use channel ID (C... or G...) instead of name: %s", raw)
- }
-
- cms := ch.apiProvider.ProvideChannelsMaps()
- if strings.HasPrefix(raw, "#") {
- if id, ok := cms.ChannelsInv[raw]; ok {
- return cms.Channels[id].Name, nil
- }
- return "", fmt.Errorf("channel %q not found", raw)
- }
- // Handle both C (standard channels) and G (private groups/channels) prefixes
- if strings.HasPrefix(raw, "C") || strings.HasPrefix(raw, "G") {
+ // Legacy mode: look up name from cache
+ cms := ch.apiProvider.ProvideChannelsMaps()
if chn, ok := cms.Channels[raw]; ok {
return chn.Name, nil
}
- return "", fmt.Errorf("channel %q not found", raw)
+ return raw, nil // Fallback to ID
}
- return "", fmt.Errorf("invalid channel format: %q", raw)
+
+ // Handle channel name
+ name := strings.TrimPrefix(raw, "#")
+
+ if ch.oauthEnabled {
+ // OAuth mode: resolve channel name via Slack API (just validate it exists)
+ if slackClient == nil {
+ return "", fmt.Errorf("slack client is nil")
+ }
+ // Search for the channel to validate it exists
+ channelTypes := []string{"public_channel", "private_channel"}
+ for _, chanType := range channelTypes {
+ params := &slack.GetConversationsParameters{
+ Types: []string{chanType},
+ Limit: 200,
+ }
+ channels, _, err := slackClient.GetConversationsContext(ctx, params)
+ if err != nil {
+ continue
+ }
+ for _, c := range channels {
+ if c.Name == name {
+ return c.Name, nil
+ }
+ }
+ }
+ // Channel not found but try using the name anyway (might be in later pages)
+ return name, nil
+ }
+
+ // Legacy mode: look up from cache
+ cms := ch.apiProvider.ProvideChannelsMaps()
+ if id, ok := cms.ChannelsInv[raw]; ok {
+ return cms.Channels[id].Name, nil
+ }
+ return "", fmt.Errorf("channel %q not found", raw)
}
func marshalMessagesToCSV(messages []Message) (*mcp.CallToolResult, error) {
@@ -924,10 +1133,87 @@ func getUserInfo(userID string, usersMap map[string]slack.User) (userName, realN
return userID, userID, false
}
+// fetchUsersForMessages fetches user info from Slack API for the given user IDs
+// and returns a map of userID -> slack.User. This is used in OAuth mode where
+// we don't have a pre-populated users cache.
+func (ch *ConversationsHandler) fetchUsersForMessages(ctx context.Context, client *slack.Client, userIDs []string) map[string]slack.User {
+ usersMap := make(map[string]slack.User)
+ if client == nil {
+ return usersMap
+ }
+
+ // Deduplicate user IDs
+ seen := make(map[string]bool)
+ var uniqueIDs []string
+ for _, id := range userIDs {
+ if id != "" && !seen[id] {
+ seen[id] = true
+ uniqueIDs = append(uniqueIDs, id)
+ }
+ }
+
+ // Fetch each user's info
+ for _, userID := range uniqueIDs {
+ user, err := client.GetUserInfoContext(ctx, userID)
+ if err != nil {
+ ch.logger.Debug("Failed to fetch user info", zap.String("userID", userID), zap.Error(err))
+ continue
+ }
+ if user == nil {
+ ch.logger.Debug("User info returned nil", zap.String("userID", userID))
+ continue
+ }
+ usersMap[userID] = *user
+ }
+
+ return usersMap
+}
+
func getBotInfo(botID string) (userName, realName string, ok bool) {
return botID, botID, true
}
+// expandUserMentions replaces Slack user mentions (<@U1234567>) with display names (@Name)
+// using the provided users map. If a display name is already in the mention (<@U1234567|Name>),
+// it's handled by the text processor. This function handles the case where the mention
+// only contains the user ID.
+func expandUserMentions(text string, usersMap map[string]slack.User) string {
+ if usersMap == nil {
+ return text
+ }
+
+ // Match user mentions without display name: <@U1234567>
+ // Don't match mentions that already have display name: <@U1234567|Name>
+ userMentionRe := regexp.MustCompile(`<@(U[A-Z0-9]+)>`)
+
+ return userMentionRe.ReplaceAllStringFunc(text, func(match string) string {
+ // Extract user ID from <@U1234567>
+ submatch := userMentionRe.FindStringSubmatch(match)
+ if len(submatch) < 2 {
+ return match
+ }
+ userID := submatch[1]
+
+ // Look up user in map
+ if user, exists := usersMap[userID]; exists {
+ // Priority: RealName → DisplayName → Name for consistent formatting
+ name := user.RealName
+ if name == "" {
+ name = user.Profile.DisplayName
+ }
+ if name == "" {
+ name = user.Name
+ }
+ if name != "" {
+ return "@" + name
+ }
+ }
+
+ // Fallback to just the user ID
+ return "@" + userID
+ })
+}
+
func limitByNumeric(limit string, defaultLimit int) (int, error) {
if limit == "" {
return defaultLimit, nil
diff --git a/pkg/oauth/manager.go b/pkg/oauth/manager.go
index baab0464..f9ddc8e9 100644
--- a/pkg/oauth/manager.go
+++ b/pkg/oauth/manager.go
@@ -7,6 +7,8 @@ import (
"net/url"
"strings"
"time"
+
+ "github.com/korotovsky/slack-mcp-server/pkg/provider/edge"
)
type Manager struct {
@@ -57,7 +59,7 @@ func (m *Manager) GetAuthURL(state string) string {
"state": {state},
}
- return "https://slack.com/oauth/v2/authorize?" + params.Encode()
+ return "https://" + edge.GetSlackBaseDomain() + "/oauth/v2/authorize?" + params.Encode()
}
// HandleCallback exchanges OAuth code for access token
@@ -69,7 +71,7 @@ func (m *Manager) HandleCallback(code, state string) (*TokenResponse, error) {
"redirect_uri": {m.redirectURI},
}
- resp, err := m.httpClient.PostForm("https://slack.com/api/oauth.v2.access", data)
+ resp, err := m.httpClient.PostForm("https://"+edge.GetSlackBaseDomain()+"/api/oauth.v2.access", data)
if err != nil {
return nil, fmt.Errorf("failed to exchange code: %w", err)
}
@@ -99,8 +101,8 @@ func (m *Manager) HandleCallback(code, state string) (*TokenResponse, error) {
}
token := &TokenResponse{
- AccessToken: result.AuthedUser.AccessToken, // User token (xoxp-...)
- BotToken: result.AccessToken, // Bot token (xoxb-...) if available
+ AccessToken: result.AuthedUser.AccessToken, // User token (xoxp-...)
+ BotToken: result.AccessToken, // Bot token (xoxb-...) if available
UserID: result.AuthedUser.ID,
TeamID: result.Team.ID,
BotUserID: result.BotUserID,
@@ -124,7 +126,7 @@ func (m *Manager) HandleCallback(code, state string) (*TokenResponse, error) {
// ValidateToken validates an access token with Slack
func (m *Manager) ValidateToken(accessToken string) (*TokenInfo, error) {
- req, err := http.NewRequest("POST", "https://slack.com/api/auth.test", nil)
+ req, err := http.NewRequest("POST", "https://"+edge.GetSlackBaseDomain()+"/api/auth.test", nil)
if err != nil {
return nil, err
}
@@ -143,6 +145,7 @@ func (m *Manager) ValidateToken(accessToken string) (*TokenInfo, error) {
Error string `json:"error"`
UserID string `json:"user_id"`
TeamID string `json:"team_id"`
+ URL string `json:"url"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
@@ -156,6 +159,7 @@ func (m *Manager) ValidateToken(accessToken string) (*TokenInfo, error) {
return &TokenInfo{
UserID: result.UserID,
TeamID: result.TeamID,
+ URL: result.URL,
}, nil
}
diff --git a/pkg/oauth/types.go b/pkg/oauth/types.go
index 3770901d..b475fc6a 100644
--- a/pkg/oauth/types.go
+++ b/pkg/oauth/types.go
@@ -16,6 +16,7 @@ type TokenResponse struct {
type TokenInfo struct {
UserID string
TeamID string
+ URL string // Workspace URL from auth.test (e.g., https://workspace.slack.com/)
}
// OAuthManager handles OAuth 2.0 flow with Slack
diff --git a/pkg/provider/api.go b/pkg/provider/api.go
index f67f1b9e..b85c824e 100644
--- a/pkg/provider/api.go
+++ b/pkg/provider/api.go
@@ -123,9 +123,11 @@ type ApiProvider struct {
func NewMCPSlackClient(authProvider auth.Provider, logger *zap.Logger) (*MCPSlackClient, error) {
httpClient := transport.ProvideHTTPClient(authProvider.Cookies(), logger)
- slackClient := slack.New(authProvider.SlackToken(),
- slack.OptionHTTPClient(httpClient),
- )
+ slackOpts := []slack.Option{slack.OptionHTTPClient(httpClient)}
+ if os.Getenv("SLACK_MCP_GOVSLACK") == "true" {
+ slackOpts = append(slackOpts, slack.OptionAPIURL("https://slack-gov.com/api/"))
+ }
+ slackClient := slack.New(authProvider.SlackToken(), slackOpts...)
authResp, err := slackClient.AuthTest()
if err != nil {
diff --git a/pkg/provider/edge/edge.go b/pkg/provider/edge/edge.go
index cf1e3bac..1ef99eef 100644
--- a/pkg/provider/edge/edge.go
+++ b/pkg/provider/edge/edge.go
@@ -64,6 +64,15 @@ var (
ErrNoToken = errors.New("token is empty")
)
+// GetSlackBaseDomain returns the base domain for Slack API endpoints.
+// Returns "slack-gov.com" if SLACK_MCP_GOVSLACK=true, otherwise "slack.com".
+func GetSlackBaseDomain() string {
+ if os.Getenv("SLACK_MCP_GOVSLACK") == "true" {
+ return "slack-gov.com"
+ }
+ return "slack.com"
+}
+
func NewWithClient(workspaceName string, teamID string, token string, cl *http.Client, opt ...Option) (*Client, error) {
if teamID == "" {
return nil, ErrNoTeamID
@@ -79,8 +88,8 @@ func NewWithClient(workspaceName string, teamID string, token string, cl *http.C
cl: cl,
token: token,
teamID: teamID,
- webclientAPI: fmt.Sprintf("https://%s.slack.com/api/", workspaceName),
- edgeAPI: fmt.Sprintf("https://edgeapi.slack.com/cache/%s/", teamID),
+ webclientAPI: fmt.Sprintf("https://%s.%s/api/", workspaceName, GetSlackBaseDomain()),
+ edgeAPI: fmt.Sprintf("https://edgeapi.%s/cache/%s/", GetSlackBaseDomain(), teamID),
tape: tape,
}, nil
}
@@ -118,7 +127,7 @@ func NewWithInfo(info *slack.AuthTestResponse, prov auth.Provider, opt ...Option
token: prov.SlackToken(),
teamID: info.TeamID,
webclientAPI: info.URL + "api/",
- edgeAPI: fmt.Sprintf("https://edgeapi.slack.com/cache/%s/", info.TeamID),
+ edgeAPI: fmt.Sprintf("https://edgeapi.%s/cache/%s/", GetSlackBaseDomain(), info.TeamID),
tape: nopTape{},
}
diff --git a/pkg/server/auth/context.go b/pkg/server/auth/context.go
index cc51723f..4f9291f7 100644
--- a/pkg/server/auth/context.go
+++ b/pkg/server/auth/context.go
@@ -12,6 +12,7 @@ type UserContext struct {
AccessToken string // User token (xoxp-...) for per-request client creation
BotToken string // Bot token (xoxb-...) if available - for posting as bot
BotUserID string // Bot user ID if available
+ URL string // Workspace URL from auth.test (e.g., https://workspace.slack.com/)
}
// WithUserContext adds user context to the context
diff --git a/pkg/server/auth/oauth_middleware.go b/pkg/server/auth/oauth_middleware.go
index 7a283078..0352530c 100644
--- a/pkg/server/auth/oauth_middleware.go
+++ b/pkg/server/auth/oauth_middleware.go
@@ -50,6 +50,7 @@ func OAuthMiddleware(oauthMgr oauth.OAuthManager, logger *zap.Logger) server.Too
AccessToken: token, // User token for per-request client
BotToken: storedToken.BotToken, // Bot token if available
BotUserID: storedToken.BotUserID, // Bot user ID if available
+ URL: tokenInfo.URL, // Workspace URL for API calls
}
// Inject user context
@@ -64,4 +65,3 @@ func OAuthMiddleware(oauthMgr oauth.OAuthManager, logger *zap.Logger) server.Too
}
}
}
-
diff --git a/pkg/text/text_processor.go b/pkg/text/text_processor.go
index 9731d61d..87cbdc94 100644
--- a/pkg/text/text_processor.go
+++ b/pkg/text/text_processor.go
@@ -193,6 +193,21 @@ func HumanizeCertificates(certs []*x509.Certificate) string {
}
func filterSpecialChars(text string) string {
+ // Handle Slack user mentions: <@U1234567> or <@U1234567|displayname>
+ // Replace with @displayname or @userid
+ userMentionWithName := regexp.MustCompile(`<@(U[A-Z0-9]+)\|([^>]+)>`)
+ text = userMentionWithName.ReplaceAllString(text, "@$2")
+
+ userMentionNoName := regexp.MustCompile(`<@(U[A-Z0-9]+)>`)
+ text = userMentionNoName.ReplaceAllString(text, "@$1")
+
+ // Handle Slack channel mentions: <#C1234567> or <#C1234567|channel-name>
+ channelMentionWithName := regexp.MustCompile(`<#(C[A-Z0-9]+)\|([^>]+)>`)
+ text = channelMentionWithName.ReplaceAllString(text, "#$2")
+
+ channelMentionNoName := regexp.MustCompile(`<#(C[A-Z0-9]+)>`)
+ text = channelMentionNoName.ReplaceAllString(text, "#$1")
+
replaceWithCommaCheck := func(match []string, isLast bool) string {
var url, linkText string