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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .github/workflows/slack-mcp.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
name: Slack MCP tests
on:
pull_request:
paths:
- "modules/slack-mcp/**"
- ".github/workflows/slack-mcp.yaml"
push:
branches: [main]
paths:
- "modules/slack-mcp/**"
- ".github/workflows/slack-mcp.yaml"

jobs:
adapter:
runs-on: ubuntu-latest
defaults:
run:
working-directory: modules/slack-mcp
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "24"
- run: npm install --global pnpm@11.19.0 --ignore-scripts --no-audit --no-fund
- run: pnpm install --frozen-lockfile --ignore-scripts
- run: node --test tests/adapter.test.mjs

terraform:
runs-on: ubuntu-latest
defaults:
run:
working-directory: modules/slack-mcp
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
- uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3
with:
terraform_version: "1.13.5"
- run: terraform fmt -check
- run: terraform init -backend=false
- run: terraform test
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,3 +110,23 @@ module "linear_mcp" {
external_auth_id = "linear"
}
```

## `slack-mcp`

[`modules/slack-mcp`](modules/slack-mcp) installs a Slack MCP launcher backed
by Coder external authentication. Read-only is the default: an explicit tool
allowlist filters discovery and rejects write calls before forwarding them.
Unlinked accounts keep a healthy empty MCP and receive Coder linking guidance.

```hcl
module "slack_mcp" {
source = "./modules/slack-mcp"
agent_id = coder_agent.main.id
external_auth_id = "slack"
access_mode = "read-only"
}
```

Register the exported launcher command in your clients. Choose `read-write`
to expose all tools permitted by the Slack grant. See the module README for
Codex environment forwarding and runtime requirements.
120 changes: 120 additions & 0 deletions modules/slack-mcp/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
# Slack MCP

Installs a stdio MCP adapter backed by the workspace owner's Coder external
authentication. It connects to Slack's [official Streamable HTTP
endpoint](https://docs.slack.dev/ai/slack-mcp-server/),
`https://mcp.slack.com/mcp`, using a user OAuth token from an MCP-enabled
internal or Marketplace Slack app.

## Usage

Configure the Slack user OAuth provider on the Coder deployment first. The
module does not create Slack apps, client secrets, or account links.

```hcl
data "coder_external_auth" "slack" {
id = "slack"
optional = true
}

module "slack_mcp" {
source = "./modules/slack-mcp"
agent_id = coder_agent.main.id
external_auth_id = "slack"
access_mode = "read-only"
}
```

Register `module.slack_mcp.launcher_command` in each coding client's MCP
configuration. It resolves to `coder-slack-mcp` on the agent PATH. Claude Code
accepts a user-scope MCP entry such as:

```json
{
"mcpServers": {
"slack": { "command": "coder-slack-mcp" }
}
}
```

Codex filters stdio environments; allow forwarding the Coder context by name:

```toml
[mcp_servers.slack]
command = "coder-slack-mcp"
env_vars = ["CODER", "CODER_AGENT_TOKEN", "CODER_AGENT_URL", "GIT_ASKPASS"]
```

Client registration belongs to the calling template. Clients in a different
container need their own registration and access to the launcher, dependencies,
and Coder credential context.

## Read-only enforcement

The default `access_mode = "read-only"` exposes only these reviewed tools:

- `slack_search_public`, `slack_search_public_and_private`
- `slack_search_channels`, `slack_search_users`, `slack_search_emojis`
- `slack_read_channel`, `slack_read_thread`, `slack_read_user_profile`
- `slack_list_channel_members`, `slack_list_user_channels`
- `slack_read_file`, `slack_get_reactions`

The same explicit allowlist is enforced on every incoming `tools/call` before
acquiring credentials or forwarding the request. Direct calls to send,
schedule, draft, or other non-allowlisted tools return `isError: true` without
reaching Slack. Unknown future tools are denied regardless of their name prefix
or `readOnlyHint` annotation. Paginated discovery is traversed so a write-only
page does not hide read tools on later pages.

This controls calls through this adapter, not the underlying user's OAuth
permissions or direct API access. Set `access_mode = "read-write"` to expose
and forward all tools permitted by the Slack OAuth grant. Restart coding
clients after changing mode to load the new launcher and refresh tools.

## Optional authentication and refresh

Unlinked accounts still initialize and respond to ping, with zero tools and
no requests sent to Slack. When Coder returns a linking URL, it is included in
read-tool error replies and printed once to stderr. Only the linking-page URL
is retained; query parameters and raw subprocess diagnostics are not relayed.
If no URL is available, the reply gives the CLI command to obtain one.

The adapter polls authentication every 30 seconds and notifies clients when
linking/unlinking changes tool availability. Clients that cache tools may need
a reconnect. Rejected credentials hide tools while keeping MCP healthy;
upstream outages produce sanitized MCP errors and can recover on later calls.

Each upstream request obtains a current token using
`coder external-auth access-token <external_auth_id>`. Coder manages refresh.
Tokens stay in memory and are not written to files, Terraform values, client
configuration, or command arguments. Credentials go only to Slack's fixed
endpoint; redirects and a separate browser OAuth flow are disabled.

## Installation

The image must include standalone pnpm on PATH or at
`/usr/local/share/pnpm/bin/pnpm`, with Node environment management available.
The startup script provisions user-local Node 24 and the official MCP SDK
using the frozen dependency lockfile with lifecycle scripts disabled. Initial
installation needs public Node/npm registry access. Subsequent starts reuse
dependencies when the lockfile is unchanged. Installation does not require a
linked Slack account.

Inputs: `agent_id` is required; `external_auth_id` defaults to `slack`;
`access_mode` defaults to `read-only` and also accepts `read-write`.
Outputs: `launcher_command`, `$HOME`-relative `launcher_path`, and `access_mode`.

## Validation

```sh
cd modules/slack-mcp
pnpm install --frozen-lockfile --ignore-scripts
node --test tests/adapter.test.mjs
terraform init -backend=false
terraform test
```

Tests cover optional auth and URL guidance, refresh, read-only discovery,
direct/stale/unknown write rejection, pagination, and read-write forwarding to
a simulated upstream. Live validation should discover tools and exercise read
operations first; posting a real message requires an explicit user request.
30 changes: 30 additions & 0 deletions modules/slack-mcp/main.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
terraform {
required_providers {
coder = {
source = "coder/coder"
}
}
}

resource "coder_env" "slack_mcp_path" {
agent_id = var.agent_id
name = "PATH"
value = "$HOME/.local/bin:$PATH"
merge_strategy = "prepend"
}

resource "coder_script" "slack_mcp" {
agent_id = var.agent_id
display_name = "Install Slack MCP"
run_on_start = true
start_blocks_login = true
timeout = 300
script = templatefile("${path.module}/scripts/install.sh.tpl", {
external_auth_id = var.external_auth_id
access_mode = var.access_mode
adapter_js = file("${path.module}/scripts/slack-mcp.mjs")
package_json = file("${path.module}/package.json")
pnpm_lock = file("${path.module}/pnpm-lock.yaml")
dependency_hash = sha256(file("${path.module}/pnpm-lock.yaml"))
})
}
73 changes: 73 additions & 0 deletions modules/slack-mcp/main.tftest.hcl
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
mock_provider "coder" {}

run "defaults" {
command = plan
variables {
agent_id = "test-agent"
}
assert {
condition = output.launcher_command == "coder-slack-mcp"
error_message = "Clients must receive a stable launcher command."
}
assert {
condition = strcontains(coder_script.slack_mcp.script, "--frozen-lockfile --ignore-scripts")
error_message = "The installer must use the frozen dependency graph without lifecycle scripts."
}
assert {
condition = strcontains(coder_script.slack_mcp.script, "https://mcp.slack.com/mcp")
error_message = "Only the official Slack endpoint is supported."
}
}

run "default_read_only" {
command = plan
variables {
agent_id = "test-agent"
}
assert {
condition = strcontains(coder_script.slack_mcp.script, "'slack' 'read-only'")
error_message = "Existing consumers must retain read-only access by default."
}
}

run "custom_provider" {
command = plan
variables {
agent_id = "test-agent"
external_auth_id = "slack-secondary"
}
assert {
condition = strcontains(coder_script.slack_mcp.script, "'slack-secondary'")
error_message = "The launcher must pass the configured external-auth ID."
}
}

run "reject_shell_characters" {
command = plan
variables {
agent_id = "test-agent"
external_auth_id = "slack;false"
}
expect_failures = [var.external_auth_id]
}

run "read_write_mode" {
command = plan
variables {
agent_id = "test-agent"
access_mode = "read-write"
}
assert {
condition = strcontains(coder_script.slack_mcp.script, "'slack' 'read-write'")
error_message = "The launcher must pass the selected access mode to the adapter."
}
}

run "reject_invalid_mode" {
command = plan
variables {
agent_id = "test-agent"
access_mode = "custom-url"
}
expect_failures = [var.access_mode]
}
14 changes: 14 additions & 0 deletions modules/slack-mcp/outputs.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
output "launcher_command" {
description = "MCP stdio command on the agent PATH, usable in Claude Code, Codex, or other clients."
value = "coder-slack-mcp"
}

output "launcher_path" {
description = "User-scoped launcher location. Expand HOME before using this as an absolute client command."
value = "$HOME/.local/bin/coder-slack-mcp"
}

output "access_mode" {
description = "Selected MCP access mode. This does not change OAuth token permissions."
value = var.access_mode
}
9 changes: 9 additions & 0 deletions modules/slack-mcp/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"name": "coder-slack-mcp",
"version": "1.0.0",
"private": true,
"type": "module",
"dependencies": {
"@modelcontextprotocol/sdk": "1.32.1"
}
}
Loading
Loading