diff --git a/.github/workflows/slack-mcp.yaml b/.github/workflows/slack-mcp.yaml new file mode 100644 index 0000000..cfeb04c --- /dev/null +++ b/.github/workflows/slack-mcp.yaml @@ -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 diff --git a/README.md b/README.md index e4135bf..7f5d4e2 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/modules/slack-mcp/README.md b/modules/slack-mcp/README.md new file mode 100644 index 0000000..dcb41c4 --- /dev/null +++ b/modules/slack-mcp/README.md @@ -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 `. 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. diff --git a/modules/slack-mcp/main.tf b/modules/slack-mcp/main.tf new file mode 100644 index 0000000..c72c532 --- /dev/null +++ b/modules/slack-mcp/main.tf @@ -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")) + }) +} diff --git a/modules/slack-mcp/main.tftest.hcl b/modules/slack-mcp/main.tftest.hcl new file mode 100644 index 0000000..61baf9c --- /dev/null +++ b/modules/slack-mcp/main.tftest.hcl @@ -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] +} diff --git a/modules/slack-mcp/outputs.tf b/modules/slack-mcp/outputs.tf new file mode 100644 index 0000000..16f9071 --- /dev/null +++ b/modules/slack-mcp/outputs.tf @@ -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 +} diff --git a/modules/slack-mcp/package.json b/modules/slack-mcp/package.json new file mode 100644 index 0000000..30866c2 --- /dev/null +++ b/modules/slack-mcp/package.json @@ -0,0 +1,9 @@ +{ + "name": "coder-slack-mcp", + "version": "1.0.0", + "private": true, + "type": "module", + "dependencies": { + "@modelcontextprotocol/sdk": "1.32.1" + } +} diff --git a/modules/slack-mcp/pnpm-lock.yaml b/modules/slack-mcp/pnpm-lock.yaml new file mode 100644 index 0000000..f38b5eb --- /dev/null +++ b/modules/slack-mcp/pnpm-lock.yaml @@ -0,0 +1,786 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@modelcontextprotocol/sdk': + specifier: 1.32.1 + version: 1.32.1(zod@4.6.7) + +packages: + + '@hono/node-server@2.1.4': + resolution: {integrity: sha512-2g1qeHl4BduwDn6L8nkAvDrPUWPMlkQ//a5NhP/zIyaXKMjZNRV+cG9Csu+BiJFf6+68pqzjf4Hi5sJGKHuS1g==} + engines: {node: '>=20'} + peerDependencies: + hono: ^4 || ^5.0.0-0 + + '@modelcontextprotocol/sdk@1.32.1': + resolution: {integrity: sha512-2DdE+SJDtzLEEWzY1ZjY7Q+VcPhcV1KisD3zI4u0XZyktsjHum1mwbMI+JaulUBi2OZk+KJAi2uPXzxichPkdw==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + engines: {node: '>=18'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + content-disposition@2.0.1: + resolution: {integrity: sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.1.0: + resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==} + engines: {node: '>=18'} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + eventsource-parser@3.1.1: + resolution: {integrity: sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + + express-rate-limit@8.7.1: + resolution: {integrity: sha512-2FXbqkygil3mZiXzEtM0IjE/UBmBNot0J1GEVrJT4JbrvwOSoEtqtO+wHbiMQELWe1eNvHoYRg2dkjOid+6JkA==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.3.0: + resolution: {integrity: sha512-qCqy1xSoaugxOD4IVHb1ZWdkvCF2W3aE2okKouWj8VwJ+ztskSiXoBnCSJqfKT+cVPJhuJqe9YLbY425+Zh00g==} + engines: {node: '>= 18'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-uri@3.1.8: + resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==} + + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + hono@4.13.13: + resolution: {integrity: sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA==} + engines: {node: '>=16.9.0'} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ip-address@10.7.4: + resolution: {integrity: sha512-DWBMKyeM25D70nrIiTkgQth59/G+naKOTep6huF9j+GmeK3DMD60Zvxg5d5qQnz+38AHgou6ean2LECX+lrKDg==} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jose@6.2.12: + resolution: {integrity: sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@1.1.1: + resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + negotiator@1.1.0: + resolution: {integrity: sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==} + engines: {node: '>=18'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + + proxy-addr@2.0.8: + resolution: {integrity: sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==} + engines: {node: '>= 0.10'} + + qs@6.16.0: + resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==} + engines: {node: '>=0.6'} + + range-parser@1.3.0: + resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + peerDependencies: + zod: ^3.25.28 || ^4 + + zod@4.6.7: + resolution: {integrity: sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==} + +snapshots: + + '@hono/node-server@2.1.4(hono@4.13.13)': + dependencies: + hono: 4.13.13 + + '@modelcontextprotocol/sdk@1.32.1(zod@4.6.7)': + dependencies: + '@hono/node-server': 2.1.4(hono@4.13.13) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.1.1 + express: 5.3.0 + express-rate-limit: 8.7.1(express@5.3.0) + hono: 4.13.13 + jose: 6.2.12 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.6.7 + zod-to-json-schema: 3.25.2(zod@4.6.7) + transitivePeerDependencies: + - supports-color + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.1.0 + + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.8 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + body-parser@2.3.0: + dependencies: + bytes: 3.1.2 + content-type: 2.1.0 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + on-finished: 2.4.1 + qs: 6.16.0 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + content-disposition@2.0.1: {} + + content-type@1.0.5: {} + + content-type@2.1.0: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + depd@2.0.0: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + + encodeurl@2.0.0: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + escape-html@1.0.3: {} + + etag@1.8.1: {} + + eventsource-parser@3.1.1: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.1.1 + + express-rate-limit@8.7.1(express@5.3.0): + dependencies: + debug: 4.4.3 + express: 5.3.0 + ip-address: 10.7.4 + transitivePeerDependencies: + - supports-color + + express@5.3.0: + dependencies: + accepts: 2.0.0 + body-parser: 2.3.0 + content-disposition: 2.0.1 + content-type: 2.1.0 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.8 + qs: 6.16.0 + range-parser: 1.3.0 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + fast-deep-equal@3.1.3: {} + + fast-uri@3.1.8: {} + + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + forwarded@0.2.0: {} + + fresh@2.0.0: {} + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + hono@4.13.13: {} + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + + inherits@2.0.4: {} + + ip-address@10.7.4: {} + + ipaddr.js@1.9.1: {} + + is-promise@4.0.0: {} + + isexe@2.0.0: {} + + jose@6.2.12: {} + + json-schema-traverse@1.0.0: {} + + json-schema-typed@8.0.2: {} + + math-intrinsics@1.1.0: {} + + media-typer@1.1.1: {} + + merge-descriptors@2.0.0: {} + + mime-db@1.54.0: {} + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + ms@2.1.3: {} + + negotiator@1.1.0: + dependencies: + content-type: 2.1.0 + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + parseurl@1.3.3: {} + + path-key@3.1.1: {} + + path-to-regexp@8.4.2: {} + + pkce-challenge@5.0.1: {} + + proxy-addr@2.0.8: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + qs@6.16.0: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + + range-parser@1.3.0: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + unpipe: 1.0.0 + + require-from-string@2.0.2: {} + + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + + safer-buffer@2.1.2: {} + + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.3.0 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + statuses@2.0.2: {} + + toidentifier@1.0.1: {} + + type-is@2.1.0: + dependencies: + content-type: 2.1.0 + media-typer: 1.1.1 + mime-types: 3.0.2 + + unpipe@1.0.0: {} + + vary@1.1.2: {} + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + wrappy@1.0.2: {} + + zod-to-json-schema@3.25.2(zod@4.6.7): + dependencies: + zod: 4.6.7 + + zod@4.6.7: {} diff --git a/modules/slack-mcp/scripts/install.sh.tpl b/modules/slack-mcp/scripts/install.sh.tpl new file mode 100644 index 0000000..3ab4e1f --- /dev/null +++ b/modules/slack-mcp/scripts/install.sh.tpl @@ -0,0 +1,34 @@ +#!/bin/sh +# Install without requesting credentials; linking Slack is optional. +set -eu +umask 077 +PNPM=/usr/local/share/pnpm/bin/pnpm +[ -x "$PNPM" ] || PNPM="$(command -v pnpm)" || { echo 'Standalone pnpm is required to install Slack MCP.' >&2; exit 1; } +slack_dir="$HOME/.local/share/coder-slack-mcp" +export PNPM_HOME="$slack_dir/pnpm" +mkdir -p "$PNPM_HOME/bin" "$HOME/.local/bin" "$slack_dir/app" +export PATH="$PNPM_HOME/bin:$PNPM_HOME:$PATH" +if [ ! -x "$PNPM_HOME/bin/node" ]; then + "$PNPM" env use --global 24 +fi +cat > "$slack_dir/app/package.json" <<'PACKAGE' +${package_json} +PACKAGE +cat > "$slack_dir/app/pnpm-lock.yaml" <<'LOCK' +${pnpm_lock} +LOCK +if [ ! -d "$slack_dir/app/node_modules/@modelcontextprotocol/sdk" ] || \ + [ "$(cat "$slack_dir/dependency-hash" 2>/dev/null || true)" != '${dependency_hash}' ]; then + "$PNPM" --dir "$slack_dir/app" install --frozen-lockfile --ignore-scripts + printf '%s\n' '${dependency_hash}' > "$slack_dir/dependency-hash" +fi +cat > "$slack_dir/app/slack-mcp.mjs" <<'ADAPTER' +${adapter_js} +ADAPTER +cat > "$HOME/.local/bin/coder-slack-mcp" <<'LAUNCHER' +#!/bin/sh +exec "$HOME/.local/share/coder-slack-mcp/pnpm/bin/node" \ + "$HOME/.local/share/coder-slack-mcp/app/slack-mcp.mjs" '${external_auth_id}' '${access_mode}' +LAUNCHER +chmod 0755 "$HOME/.local/bin/coder-slack-mcp" +echo 'Slack MCP installed (${access_mode}); unlinked accounts expose no tools.' diff --git a/modules/slack-mcp/scripts/slack-mcp.mjs b/modules/slack-mcp/scripts/slack-mcp.mjs new file mode 100644 index 0000000..22c951e --- /dev/null +++ b/modules/slack-mcp/scripts/slack-mcp.mjs @@ -0,0 +1,212 @@ +// A healthy stdio MCP server even when optional Coder external auth is absent. +// Credentials are acquired at runtime and never written to client config. +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { pathToFileURL } from 'node:url'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { CallToolRequestSchema, ListToolsRequestSchema, McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js'; + +const run = promisify(execFile); +export const SLACK_URL = 'https://mcp.slack.com/mcp'; +// Reviewed read operations observed on Slack's official MCP server. Never +// infer permission from a name prefix or from upstream readOnlyHint alone. +const readTools = new Set([ + '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', +]); +export function toolAllowed(name, accessMode) { + return accessMode === 'read-write' || readTools.has(name); +} +export class AuthUnavailable extends Error { + constructor(authUrl) { + super('Slack authentication unavailable'); + this.authUrl = authUrl; + } +} + +export function authenticationUrl(stdout, providerId) { + try { + const url = new URL(stdout.trim()); + if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password || + url.pathname !== `/external-auth/${providerId}`) return undefined; + // Only return the Coder linking page, never query parameters or raw + // subprocess output that could contain credentials or diagnostics. + url.search = ''; + url.hash = ''; + return url.href; + } catch { return undefined; } +} + +export async function coderToken(providerId) { + try { + const { stdout } = await run(process.env.GIT_ASKPASS || 'coder', + ['external-auth', 'access-token', providerId], { timeout: 10000, maxBuffer: 65536 }); + const token = stdout.trim(); + if (!token || /\s/.test(token)) throw new AuthUnavailable(); + return token; + } catch (error) { + // Coder prints the linking URL to stdout and exits 1 when unlinked. + // Parse only that exact URL shape; never relay raw stdout/stderr. + throw new AuthUnavailable(authenticationUrl(error.stdout || '', providerId)); + } +} + +export function createAdapter({ providerId = 'slack', accessMode = 'read-only', getToken = () => coderToken(providerId), fetchImpl = fetch, log = message => console.error(message) } = {}) { + const upstreamUrl = SLACK_URL; + if (!['read-only', 'read-write'].includes(accessMode)) throw new Error('Invalid Slack access mode'); + const server = new Server({ name: 'coder-slack-mcp', version: '1.0.0' }, { + capabilities: { tools: { listChanged: true } }, + }); + let client; + let connecting; + let initialized = false; + let closed = false; + let poll; + let shutdownPromise; + let reportedAuthUrl; + const notify = () => { + if (initialized && !closed) server.sendToolListChanged().catch(() => {}); + }; + const disconnect = async () => { + const previous = client; + client = undefined; + if (previous) { + await previous.close().catch(() => {}); + notify(); + } + }; + const authenticatedFetch = async (url, options) => { + // No configurable upstream and no redirects: credentials go only to the + // selected official endpoint. No OAuth provider/browser flow is used. + if (String(url) !== upstreamUrl) throw new Error('Unexpected MCP endpoint'); + const token = await getToken(); + const headers = new Headers(options?.headers); + headers.set('authorization', `Bearer ${token}`); + const response = await fetchImpl(url, { ...options, headers, redirect: 'error' }); + if (response.status === 401 || response.status === 403) { + await response.body?.cancel(); + throw new AuthUnavailable(); + } + return response; + }; + const ensureClient = async () => { + if (closed) throw new Error('Adapter closed'); + // Check even when a session exists: account unlinking must hide tools. + await getToken(); + if (closed) throw new Error('Adapter closed'); + if (client) return client; + if (!connecting) connecting = (async () => { + const candidate = new Client({ name: 'coder-slack-mcp', version: '1.0.0' }); + try { + await candidate.connect(new StreamableHTTPClientTransport(new URL(upstreamUrl), { + fetch: authenticatedFetch, + reconnectionOptions: { maxRetries: 0 }, + })); + if (closed) throw new Error('Adapter closed'); + client = candidate; + notify(); + return candidate; + } catch (error) { + await candidate.close().catch(() => {}); + throw error; + } + })().finally(() => { connecting = undefined; }); + return connecting; + }; + const reportAuth = error => { + if (error instanceof AuthUnavailable && error.authUrl && error.authUrl !== reportedAuthUrl) { + reportedAuthUrl = error.authUrl; + log(`Slack MCP: link your account at ${error.authUrl}`); + } + }; + const unavailable = async error => { + await disconnect(); + if (error instanceof AuthUnavailable) { + reportAuth(error); + return true; + } + // Avoid returning SDK errors or upstream response bodies to the client. + throw new McpError(ErrorCode.InternalError, 'Slack MCP is temporarily unavailable. Try again later.'); + }; + server.setRequestHandler(ListToolsRequestSchema, async (request, extra) => { + try { + const remote = await ensureClient(); + if (accessMode === 'read-write') return await remote.listTools(request.params, { signal: extra.signal }); + // A page containing only write tools must not hide later read tools. + const tools = []; + const seen = new Set(); + let cursor = request.params?.cursor; + do { + const page = await remote.listTools({ ...request.params, cursor }, { signal: extra.signal }); + tools.push(...page.tools.filter(tool => toolAllowed(tool.name, accessMode))); + cursor = page.nextCursor; + if (cursor && seen.has(cursor)) throw new Error('Repeated upstream cursor'); + seen.add(cursor); + } while (cursor); + return { tools }; + } catch (error) { + if (await unavailable(error)) return { tools: [] }; + } + }); + server.setRequestHandler(CallToolRequestSchema, async (request, extra) => { + // Enforce before acquiring credentials or calling Slack. Hiding a write + // tool from discovery alone does not stop stale or handcrafted calls. + if (!toolAllowed(request.params.name, accessMode)) return { + isError: true, + content: [{ type: 'text', text: `Tool ${request.params.name} is disabled in Slack MCP read-only mode.` }], + }; + try { + return await (await ensureClient()).callTool(request.params, undefined, { signal: extra.signal }); + } catch (error) { + if (await unavailable(error)) return { + isError: true, + content: [{ type: 'text', text: error.authUrl + ? `Open ${error.authUrl} to link Slack in Coder, then retry. Authentication is optional for this workspace.` + : `Link Slack in Coder to use Slack tools. Run \`coder external-auth access-token ${providerId}\` in the workspace to obtain the authentication URL. Authentication is optional for this workspace.` }], + }; + } + }); + // Clients can discover newly linked accounts without restarting. Also + // detect account removal while idle; revoked tokens are caught by fetch. + const checkAuth = async () => { + try { await ensureClient(); } catch (error) { reportAuth(error); await disconnect(); } + }; + server.oninitialized = () => { + initialized = true; + poll = setInterval(() => { checkAuth(); }, 30000); + poll.unref(); + // Defer until the initial stdio handshake has completed. + setImmediate(() => { checkAuth(); }); + }; + const close = () => shutdownPromise ??= (async () => { + closed = true; + clearInterval(poll); + await disconnect(); + await connecting?.catch(() => {}); + await server.close(); + })(); + return { server, checkAuth, close }; +} + +async function main() { + const providerId = process.argv[2] || 'slack'; + if (!/^[a-z0-9][a-z0-9-]*$/.test(providerId)) throw new Error('Invalid provider ID'); + const adapter = createAdapter({ providerId, accessMode: process.argv[3] || 'read-only' }); + await adapter.server.connect(new StdioServerTransport()); + process.stdin.once('end', () => { adapter.close(); }); + for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => { + adapter.close().finally(() => process.exit(0)); + }); +} +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch(() => { + console.error('Slack MCP: unable to start the adapter.'); + process.exitCode = 1; + }); +} diff --git a/modules/slack-mcp/tests/adapter.test.mjs b/modules/slack-mcp/tests/adapter.test.mjs new file mode 100644 index 0000000..2727dce --- /dev/null +++ b/modules/slack-mcp/tests/adapter.test.mjs @@ -0,0 +1,269 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, writeFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; +import { ToolListChangedNotificationSchema } from '@modelcontextprotocol/sdk/types.js'; +import { AuthUnavailable, authenticationUrl, createAdapter, SLACK_URL } from '../scripts/slack-mcp.mjs'; + +function fakeSlack(accessMode = 'read-only') { + const endpoint = SLACK_URL; + const requests = []; + const fetchImpl = async (url, options) => { + assert.equal(String(url), endpoint); + assert.equal(options.redirect, 'error'); + requests.push({ method: options.method, token: options.headers.get('authorization'), body: options.body }); + if (options.method !== 'POST') return new Response(null, { status: 405 }); + const message = JSON.parse(options.body); + if (!('id' in message)) return new Response(null, { status: 202 }); + let result; + if (message.method === 'initialize') result = { + protocolVersion: message.params.protocolVersion, + capabilities: { tools: {} }, serverInfo: { name: 'fake-slack', version: '1' }, + }; + else if (message.method === 'tools/list') result = { + tools: [ + { name: 'slack_read_channel', description: 'Read issues', inputSchema: { type: 'object' } }, + { name: 'slack_send_message', inputSchema: { type: 'object' } }, + { name: 'slack_schedule_message', inputSchema: { type: 'object' } }, + { name: 'slack_send_message_draft', inputSchema: { type: 'object' } }, + { name: 'slack_future_write', annotations: { readOnlyHint: true }, inputSchema: { type: 'object' } }, + ], + }; + else result = { content: [{ type: 'text', text: 'Example message' }] }; + return Response.json({ jsonrpc: '2.0', id: message.id, result }); + }; + return { fetchImpl, requests }; +} + +async function connect(t, options) { + const adapter = createAdapter(options); + const client = new Client({ name: 'test', version: '1' }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await adapter.server.connect(serverTransport); + await client.connect(clientTransport); + t.after(async () => { await adapter.close(); await client.close(); }); + return { adapter, client }; +} + +test('unlinked accounts initialize successfully and expose zero tools without network calls', async t => { + const { client } = await connect(t, { + getToken: async () => { throw new AuthUnavailable(); }, + fetchImpl: async () => assert.fail('unlinked accounts must not contact Slack'), + }); + assert.deepEqual(await client.listTools(), { tools: [] }); + await client.ping(); + const call = await client.callTool({ name: 'slack_read_channel', arguments: {} }); + assert.equal(call.isError, true); + assert.match(call.content[0].text, /Link Slack in Coder/); +}); + +test('linking and unlinking update tools on the same MCP connection', async t => { + let linked = false; + const remote = fakeSlack(); + const { adapter, client } = await connect(t, { + getToken: async () => { if (!linked) throw new AuthUnavailable(); return 'fake-token'; }, + fetchImpl: remote.fetchImpl, + }); + assert.deepEqual((await client.listTools()).tools, []); + let changes = 0; + client.setNotificationHandler(ToolListChangedNotificationSchema, () => { changes++; }); + linked = true; + await adapter.checkAuth(); + assert.equal((await client.listTools()).tools[0].name, 'slack_read_channel'); + assert.equal((await client.callTool({ name: 'slack_read_channel', arguments: {} })).content[0].text, 'Example message'); + linked = false; + await adapter.checkAuth(); + assert.deepEqual((await client.listTools()).tools, []); + await client.ping(); + assert.ok(changes >= 2); +}); + +test('each upstream request retrieves a fresh Coder token, including long sessions', async t => { + let credentials = 0; + const remote = fakeSlack(); + const { client } = await connect(t, { + getToken: async () => `token-${++credentials}`, fetchImpl: remote.fetchImpl, + }); + await client.listTools(); + await client.callTool({ name: 'slack_read_channel', arguments: {} }); + const tokens = remote.requests.map(request => request.token); + assert.ok(tokens.length >= 3); + assert.equal(new Set(tokens).size, tokens.length); +}); + +test('rejected OAuth credentials leave a healthy empty MCP without browser authentication', async t => { + const { client } = await connect(t, { + getToken: async () => 'revoked-token', + fetchImpl: async () => new Response('sensitive upstream body', { + status: 401, headers: { 'www-authenticate': 'Bearer resource_metadata="https://example.invalid"' }, + }), + }); + assert.deepEqual((await client.listTools()).tools, []); + await client.ping(); +}); + +test('upstream outages produce sanitized errors and recover on a subsequent request', async t => { + const remote = fakeSlack(); + let failing = true; + const { client } = await connect(t, { + getToken: async () => 'fake-token', + fetchImpl: async (...args) => { if (failing) throw new Error('sensitive-error-body'); return remote.fetchImpl(...args); }, + }); + await assert.rejects(client.listTools(), error => { + assert.match(error.message, /temporarily unavailable/); + assert.ok(!error.message.includes('sensitive-error-body')); + return true; + }); + failing = false; + assert.equal((await client.listTools()).tools[0].name, 'slack_read_channel'); +}); + +test('actual stdio launcher remains healthy when the Coder subprocess is not authenticated', async t => { + const directory = await mkdtemp(join(tmpdir(), 'coder-slack-unlinked-')); + t.after(() => rm(directory, { recursive: true, force: true })); + const fakeCoder = join(directory, 'coder'); + await writeFile(fakeCoder, '#!/bin/sh\necho sensitive-subprocess-error >&2\nexit 1\n', { mode: 0o700 }); + const transport = new StdioClientTransport({ + command: process.execPath, + args: [new URL('../scripts/slack-mcp.mjs', import.meta.url).pathname, 'custom-slack'], + env: { GIT_ASKPASS: fakeCoder }, stderr: 'pipe', + }); + let stderr = ''; + transport.stderr.on('data', chunk => { stderr += chunk; }); + const client = new Client({ name: 'stdio-test', version: '1' }); + t.after(() => client.close()); + await client.connect(transport); + assert.deepEqual((await client.listTools()).tools, []); + await client.ping(); + await client.close(); + assert.equal(stderr, ''); +}); + +for (const mode of ['read-only', 'read-write']) { + test(`${mode} selects its fixed official endpoint and corresponding tools`, async t => { + const remote = fakeSlack(mode); + const { client } = await connect(t, { + accessMode: mode, getToken: async () => 'fake-token', fetchImpl: remote.fetchImpl, + }); + const names = (await client.listTools()).tools.map(tool => tool.name); + assert.deepEqual(names, mode === 'read-only' ? ['slack_read_channel'] : ['slack_read_channel', 'slack_send_message', 'slack_schedule_message', 'slack_send_message_draft', 'slack_future_write']); + }); +} + +test('invalid access modes cannot select an arbitrary endpoint', () => { + assert.throws(() => createAdapter({ accessMode: 'https://example.invalid' }), /Invalid Slack access mode/); +}); + +test('unlinked tool replies include Coder linking URL and diagnostics are emitted once', async t => { + const url = 'https://coder.example.test/external-auth/slack'; + const messages = []; + const { adapter, client } = await connect(t, { + getToken: async () => { throw new AuthUnavailable(url); }, + log: message => messages.push(message), + fetchImpl: async () => assert.fail('must not contact Slack'), + }); + assert.deepEqual((await client.listTools()).tools, []); + const result = await client.callTool({ name: 'slack_read_channel', arguments: {} }); + assert.equal(result.isError, true); + assert.ok(result.content[0].text.includes(url)); + await adapter.checkAuth(); + await client.ping(); + assert.deepEqual(messages, [`Slack MCP: link your account at ${url}`]); +}); + +test('authentication URL parsing strips queries and rejects arbitrary subprocess output', () => { + assert.equal(authenticationUrl('https://coder.example.test/external-auth/slack?secret=redacted#fragment', 'slack'), + 'https://coder.example.test/external-auth/slack'); + for (const output of ['sensitive-token', 'https://example.test/not-a-link', + 'https://user:password@coder.example.test/external-auth/slack', + 'Error: https://coder.example.test/external-auth/slack']) { + assert.equal(authenticationUrl(output, 'slack'), undefined); + } +}); + +test('real Coder CLI exit-one URL reaches the stdio tool result without leaking diagnostics', async t => { + const directory = await mkdtemp(join(tmpdir(), 'coder-slack-url-')); + t.after(() => rm(directory, { recursive: true, force: true })); + const fakeCoder = join(directory, 'coder'); + await writeFile(fakeCoder, '#!/bin/sh\necho https://coder.example.test/external-auth/custom-slack?secret=redacted\necho sensitive-error >&2\nexit 1\n', { mode: 0o700 }); + const transport = new StdioClientTransport({ + command: process.execPath, + args: [new URL('../scripts/slack-mcp.mjs', import.meta.url).pathname, 'custom-slack'], + env: { GIT_ASKPASS: fakeCoder }, stderr: 'pipe', + }); + let stderr = ''; + transport.stderr.on('data', chunk => { stderr += chunk; }); + const client = new Client({ name: 'url-test', version: '1' }); + t.after(() => client.close()); + await client.connect(transport); + const result = await client.callTool({ name: 'slack_read_channel', arguments: {} }); + assert.ok(result.content[0].text.includes('https://coder.example.test/external-auth/custom-slack')); + assert.ok(!result.content[0].text.includes('secret=')); + await client.close(); + assert.ok(stderr.includes('https://coder.example.test/external-auth/custom-slack')); + assert.ok(!stderr.includes('secret=') && !stderr.includes('sensitive-error')); +}); + +test('read-only blocks direct, stale, and unknown write calls before they reach Slack', async t => { + const remote = fakeSlack(); + const { client } = await connect(t, { + accessMode: 'read-only', getToken: async () => 'write-capable-fake-token', fetchImpl: remote.fetchImpl, + }); + assert.deepEqual((await client.listTools()).tools.map(tool => tool.name), ['slack_read_channel']); + for (const name of ['slack_send_message', 'slack_schedule_message', 'slack_send_message_draft', 'slack_future_write', 'slack_read_and_delete']) { + const result = await client.callTool({ name, arguments: {} }); + assert.equal(result.isError, true); + assert.match(result.content[0].text, /disabled.*read-only/); + } + assert.equal(remote.requests.filter(r => r.body && JSON.parse(r.body).method === 'tools/call').length, 0); + assert.equal((await client.callTool({ name: 'slack_read_channel', arguments: {} })).content[0].text, 'Example message'); +}); + +test('read-write forwards explicitly enabled write calls to the simulated upstream', async t => { + const remote = fakeSlack(); + const { client } = await connect(t, { + accessMode: 'read-write', getToken: async () => 'fake-token', fetchImpl: remote.fetchImpl, + }); + assert.equal((await client.callTool({ name: 'slack_send_message', arguments: {} })).isError, undefined); + assert.ok(remote.requests.some(r => r.body && JSON.parse(r.body).params?.name === 'slack_send_message')); +}); + +test('read-only discovery traverses a write-only page to find later read tools', async t => { + const remote = fakeSlack(); + let pages = 0; + const { client } = await connect(t, { + getToken: async () => 'fake-token', + fetchImpl: async (url, options) => { + const message = options.body ? JSON.parse(options.body) : undefined; + if (message?.method !== 'tools/list') return remote.fetchImpl(url, options); + pages++; + const result = message.params?.cursor === 'page-two' + ? { tools: [{ name: 'slack_read_thread', inputSchema: { type: 'object' } }] } + : { tools: [{ name: 'slack_send_message', inputSchema: { type: 'object' } }], nextCursor: 'page-two' }; + return Response.json({ jsonrpc: '2.0', id: message.id, result }); + }, + }); + const result = await client.listTools(); + assert.deepEqual(result.tools.map(tool => tool.name), ['slack_read_thread']); + assert.equal(result.nextCursor, undefined); + assert.equal(pages, 2); +}); + +test('read-write discovery sanitizes auth rejection after a session is established', async t => { + const remote = fakeSlack(); + let revoked = false; + const { client } = await connect(t, { + accessMode: 'read-write', getToken: async () => 'fake-token', + fetchImpl: async (url, options) => revoked + ? new Response('sensitive Slack response', { status: 401 }) + : remote.fetchImpl(url, options), + }); + assert.ok((await client.listTools()).tools.length > 1); + revoked = true; + assert.deepEqual((await client.listTools()).tools, []); + await client.ping(); +}); diff --git a/modules/slack-mcp/variables.tf b/modules/slack-mcp/variables.tf new file mode 100644 index 0000000..73b2949 --- /dev/null +++ b/modules/slack-mcp/variables.tf @@ -0,0 +1,26 @@ +variable "agent_id" { + description = "Coder agent on which to install the Slack MCP launcher." + type = string +} + +variable "external_auth_id" { + description = "ID of the Slack OAuth provider configured on the Coder deployment." + type = string + default = "slack" + + validation { + condition = can(regex("^[a-z0-9][a-z0-9-]*$", var.external_auth_id)) + error_message = "external_auth_id must contain lowercase letters, digits, and hyphens." + } +} + +variable "access_mode" { + description = "Slack MCP tool access: read-only or read-write. OAuth scopes must also permit writes." + type = string + default = "read-only" + + validation { + condition = contains(["read-only", "read-write"], var.access_mode) + error_message = "access_mode must be read-only or read-write." + } +}