From ca4a3cf182da0f60f71e89c2f8223cfcb2c5adc5 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Mon, 15 Jun 2026 23:11:53 -0700 Subject: [PATCH 01/28] feat: scaffold ai/slackbot-mcp-client directory structure Add manifests, READMEs, requirements, and static files for all four MCP client examples (no-auth, slack-identity, DCR, external-auth). Co-Authored-By: Claude --- ai/README.md | 9 ++++ ai/slackbot-mcp-client/README.md | 14 +++++ .../.slack/.gitignore | 2 + .../.slack/config.json | 6 +++ .../.slack/hooks.json | 5 ++ .../dynamic-client-registration/README.md | 11 ++++ .../dynamic-client-registration/manifest.json | 28 ++++++++++ .../external-auth/.slack/.gitignore | 2 + .../external-auth/.slack/config.json | 6 +++ .../external-auth/.slack/hooks.json | 5 ++ .../external-auth/README.md | 16 ++++++ .../external-auth/manifest.json | 51 ++++++++++++++++++ ai/slackbot-mcp-client/no-auth/.gitignore | 4 ++ .../no-auth/.slack/.gitignore | 2 + .../no-auth/.slack/config.json | 6 +++ .../no-auth/.slack/hooks.json | 5 ++ ai/slackbot-mcp-client/no-auth/README.md | 16 ++++++ ai/slackbot-mcp-client/no-auth/manifest.json | 28 ++++++++++ .../no-auth/requirements.txt | 9 ++++ .../no-auth/src/__init__.py | 0 ai/slackbot-mcp-client/no-auth/src/dice.html | 54 +++++++++++++++++++ .../no-auth/tests/__init__.py | 0 .../slack-identity/.gitignore | 5 ++ .../slack-identity/.slack/.gitignore | 2 + .../slack-identity/.slack/config.json | 6 +++ .../slack-identity/.slack/hooks.json | 5 ++ .../slack-identity/README.md | 17 ++++++ .../slack-identity/manifest.json | 31 +++++++++++ .../slack-identity/requirements.txt | 9 ++++ .../slack-identity/src/__init__.py | 0 .../slack-identity/tests/__init__.py | 0 31 files changed, 354 insertions(+) create mode 100644 ai/README.md create mode 100644 ai/slackbot-mcp-client/README.md create mode 100644 ai/slackbot-mcp-client/dynamic-client-registration/.slack/.gitignore create mode 100644 ai/slackbot-mcp-client/dynamic-client-registration/.slack/config.json create mode 100644 ai/slackbot-mcp-client/dynamic-client-registration/.slack/hooks.json create mode 100644 ai/slackbot-mcp-client/dynamic-client-registration/README.md create mode 100644 ai/slackbot-mcp-client/dynamic-client-registration/manifest.json create mode 100644 ai/slackbot-mcp-client/external-auth/.slack/.gitignore create mode 100644 ai/slackbot-mcp-client/external-auth/.slack/config.json create mode 100644 ai/slackbot-mcp-client/external-auth/.slack/hooks.json create mode 100644 ai/slackbot-mcp-client/external-auth/README.md create mode 100644 ai/slackbot-mcp-client/external-auth/manifest.json create mode 100644 ai/slackbot-mcp-client/no-auth/.gitignore create mode 100644 ai/slackbot-mcp-client/no-auth/.slack/.gitignore create mode 100644 ai/slackbot-mcp-client/no-auth/.slack/config.json create mode 100644 ai/slackbot-mcp-client/no-auth/.slack/hooks.json create mode 100644 ai/slackbot-mcp-client/no-auth/README.md create mode 100644 ai/slackbot-mcp-client/no-auth/manifest.json create mode 100644 ai/slackbot-mcp-client/no-auth/requirements.txt create mode 100644 ai/slackbot-mcp-client/no-auth/src/__init__.py create mode 100644 ai/slackbot-mcp-client/no-auth/src/dice.html create mode 100644 ai/slackbot-mcp-client/no-auth/tests/__init__.py create mode 100644 ai/slackbot-mcp-client/slack-identity/.gitignore create mode 100644 ai/slackbot-mcp-client/slack-identity/.slack/.gitignore create mode 100644 ai/slackbot-mcp-client/slack-identity/.slack/config.json create mode 100644 ai/slackbot-mcp-client/slack-identity/.slack/hooks.json create mode 100644 ai/slackbot-mcp-client/slack-identity/README.md create mode 100644 ai/slackbot-mcp-client/slack-identity/manifest.json create mode 100644 ai/slackbot-mcp-client/slack-identity/requirements.txt create mode 100644 ai/slackbot-mcp-client/slack-identity/src/__init__.py create mode 100644 ai/slackbot-mcp-client/slack-identity/tests/__init__.py diff --git a/ai/README.md b/ai/README.md new file mode 100644 index 0000000..89c2b7d --- /dev/null +++ b/ai/README.md @@ -0,0 +1,9 @@ +# AI in Slack + +Agent experiences and MCP features in an interactive conversation interface. + +Read the [docs](https://docs.slack.dev/ai/) to learn concepts behind these constructions, or explore implementations of specific features. + +## What's on display + +- **[Slackbot MCP Client](slackbot-mcp-client/)**: Connect MCP servers to Slackbot with different options for authentication. diff --git a/ai/slackbot-mcp-client/README.md b/ai/slackbot-mcp-client/README.md new file mode 100644 index 0000000..3fb4c67 --- /dev/null +++ b/ai/slackbot-mcp-client/README.md @@ -0,0 +1,14 @@ +# Slackbot MCP Client + +Connect MCP servers to Slackbot with different options for authentication. + +Read the [docs](https://docs.slack.dev/ai/slackbot-mcp-client) to explore more concepts around MCP. + +## Included examples + +### Authentication methods + +- **[Dynamic client registration](https://docs.slack.dev/ai/slackbot-mcp-client/dynamic-client-registration)**: Connect a remote MCP server to the Slackbot MCP client using Dynamic Client Registration (DCR). [Implementation](./dynamic-client-registration/). +- **[External auth](https://docs.slack.dev/ai/slackbot-mcp-client/external-auth)**: Connect a remote MCP server to the Slackbot MCP client with manual OAuth provider configuration. [Implementation](./external-auth/). +- **[No auth](https://docs.slack.dev/ai/slackbot-mcp-client/no-auth)**: Run an unauthenticated MCP server for the Slackbot MCP client that responds with an interactive UI. [Implementation](./no-auth/). +- **[Slack identity](https://docs.slack.dev/ai/slackbot-mcp-client/slack-identity)**: Run an MCP server for the Slackbot MCP client that responds with Block Kit and authenticates against existing installations. [Implementation](./slack-identity/). diff --git a/ai/slackbot-mcp-client/dynamic-client-registration/.slack/.gitignore b/ai/slackbot-mcp-client/dynamic-client-registration/.slack/.gitignore new file mode 100644 index 0000000..973ba60 --- /dev/null +++ b/ai/slackbot-mcp-client/dynamic-client-registration/.slack/.gitignore @@ -0,0 +1,2 @@ +apps.dev.json +cache/ diff --git a/ai/slackbot-mcp-client/dynamic-client-registration/.slack/config.json b/ai/slackbot-mcp-client/dynamic-client-registration/.slack/config.json new file mode 100644 index 0000000..909afbe --- /dev/null +++ b/ai/slackbot-mcp-client/dynamic-client-registration/.slack/config.json @@ -0,0 +1,6 @@ +{ + "manifest": { + "source": "local" + }, + "project_id": "00000000-0000-0000-0000-000000000000" +} diff --git a/ai/slackbot-mcp-client/dynamic-client-registration/.slack/hooks.json b/ai/slackbot-mcp-client/dynamic-client-registration/.slack/hooks.json new file mode 100644 index 0000000..30ea9bc --- /dev/null +++ b/ai/slackbot-mcp-client/dynamic-client-registration/.slack/hooks.json @@ -0,0 +1,5 @@ +{ + "hooks": { + "get-manifest": "cat manifest.json #" + } +} diff --git a/ai/slackbot-mcp-client/dynamic-client-registration/README.md b/ai/slackbot-mcp-client/dynamic-client-registration/README.md new file mode 100644 index 0000000..634214a --- /dev/null +++ b/ai/slackbot-mcp-client/dynamic-client-registration/README.md @@ -0,0 +1,11 @@ +# Dynamic Client Registration + +Connect a remote MCP server to the Slackbot MCP client using [Dynamic Client Registration](https://blog.modelcontextprotocol.io/posts/client_registration/) (DCR). + +## Setup + +```sh +$ slack install --environment deployed # Create Slack app +``` + +Ask Slackbot: "Find all active documents in my Notion workspace" diff --git a/ai/slackbot-mcp-client/dynamic-client-registration/manifest.json b/ai/slackbot-mcp-client/dynamic-client-registration/manifest.json new file mode 100644 index 0000000..6dd8933 --- /dev/null +++ b/ai/slackbot-mcp-client/dynamic-client-registration/manifest.json @@ -0,0 +1,28 @@ +{ + "display_information": { + "name": "MCP Client - DCR", + "description": "Connects Notion MCP server to Slackbot MCP client using dynamic client registration" + }, + "features": { + "bot_user": { + "display_name": "MCP Client - DCR", + "always_online": true + } + }, + "oauth_config": { + "scopes": { + "bot": ["mcp:connect"] + } + }, + "settings": { + "org_deploy_enabled": true, + "socket_mode_enabled": false, + "token_rotation_enabled": false + }, + "mcp_servers": { + "Notion": { + "url": "https://mcp.notion.com/mcp", + "auth_type": "dynamic_client_registration" + } + } +} diff --git a/ai/slackbot-mcp-client/external-auth/.slack/.gitignore b/ai/slackbot-mcp-client/external-auth/.slack/.gitignore new file mode 100644 index 0000000..973ba60 --- /dev/null +++ b/ai/slackbot-mcp-client/external-auth/.slack/.gitignore @@ -0,0 +1,2 @@ +apps.dev.json +cache/ diff --git a/ai/slackbot-mcp-client/external-auth/.slack/config.json b/ai/slackbot-mcp-client/external-auth/.slack/config.json new file mode 100644 index 0000000..909afbe --- /dev/null +++ b/ai/slackbot-mcp-client/external-auth/.slack/config.json @@ -0,0 +1,6 @@ +{ + "manifest": { + "source": "local" + }, + "project_id": "00000000-0000-0000-0000-000000000000" +} diff --git a/ai/slackbot-mcp-client/external-auth/.slack/hooks.json b/ai/slackbot-mcp-client/external-auth/.slack/hooks.json new file mode 100644 index 0000000..30ea9bc --- /dev/null +++ b/ai/slackbot-mcp-client/external-auth/.slack/hooks.json @@ -0,0 +1,5 @@ +{ + "hooks": { + "get-manifest": "cat manifest.json #" + } +} diff --git a/ai/slackbot-mcp-client/external-auth/README.md b/ai/slackbot-mcp-client/external-auth/README.md new file mode 100644 index 0000000..9496ffd --- /dev/null +++ b/ai/slackbot-mcp-client/external-auth/README.md @@ -0,0 +1,16 @@ +# External Auth + +Connect a remote MCP server to the Slackbot MCP client with manual OAuth provider configuration. + +## Setup + +> Callback URL: https://oauth2.slack.com/external/auth/callback + +```sh +$ open https://github.com/settings/developers # Create GitHub app +$ slack manifest # Replace values +$ slack install --environment deployed # Create Slack app +$ slack external-auth add-secret +``` + +Ask Slackbot: "Show me my recent GitHub pull requests" diff --git a/ai/slackbot-mcp-client/external-auth/manifest.json b/ai/slackbot-mcp-client/external-auth/manifest.json new file mode 100644 index 0000000..0e31c42 --- /dev/null +++ b/ai/slackbot-mcp-client/external-auth/manifest.json @@ -0,0 +1,51 @@ +{ + "display_information": { + "name": "MCP Client - External Auth", + "description": "Connects GitHub MCP server to Slackbot MCP client using an external auth provider" + }, + "features": { + "bot_user": { + "display_name": "MCP Client - External Auth", + "always_online": true + } + }, + "oauth_config": { + "scopes": { + "bot": ["mcp:connect"] + } + }, + "settings": { + "org_deploy_enabled": true, + "socket_mode_enabled": false, + "token_rotation_enabled": false + }, + "external_auth_providers": { + "oauth2": { + "github": { + "provider_type": "CUSTOM", + "options": { + "client_id": "YOUR_GITHUB_CLIENT_ID", + "scope": ["repo"], + "provider_name": "GitHub", + "authorization_url": "https://github.com/login/oauth/authorize", + "token_url": "https://github.com/login/oauth/access_token", + "identity_config": { + "url": "https://api.github.com/user", + "account_identifier": "$.login" + }, + "use_pkce": false, + "token_url_config": { + "use_basic_auth_scheme": false + } + } + } + } + }, + "mcp_servers": { + "GitHub": { + "url": "https://api.githubcopilot.com/mcp/", + "auth_type": "manual_auth", + "auth_provider_key": "github" + } + } +} diff --git a/ai/slackbot-mcp-client/no-auth/.gitignore b/ai/slackbot-mcp-client/no-auth/.gitignore new file mode 100644 index 0000000..71a31ee --- /dev/null +++ b/ai/slackbot-mcp-client/no-auth/.gitignore @@ -0,0 +1,4 @@ +__pycache__ +.venv +!.env.example +.env* diff --git a/ai/slackbot-mcp-client/no-auth/.slack/.gitignore b/ai/slackbot-mcp-client/no-auth/.slack/.gitignore new file mode 100644 index 0000000..973ba60 --- /dev/null +++ b/ai/slackbot-mcp-client/no-auth/.slack/.gitignore @@ -0,0 +1,2 @@ +apps.dev.json +cache/ diff --git a/ai/slackbot-mcp-client/no-auth/.slack/config.json b/ai/slackbot-mcp-client/no-auth/.slack/config.json new file mode 100644 index 0000000..909afbe --- /dev/null +++ b/ai/slackbot-mcp-client/no-auth/.slack/config.json @@ -0,0 +1,6 @@ +{ + "manifest": { + "source": "local" + }, + "project_id": "00000000-0000-0000-0000-000000000000" +} diff --git a/ai/slackbot-mcp-client/no-auth/.slack/hooks.json b/ai/slackbot-mcp-client/no-auth/.slack/hooks.json new file mode 100644 index 0000000..ce474c9 --- /dev/null +++ b/ai/slackbot-mcp-client/no-auth/.slack/hooks.json @@ -0,0 +1,5 @@ +{ + "hooks": { + "get-hooks": "python3 -m slack_cli_hooks.hooks.get_hooks" + } +} diff --git a/ai/slackbot-mcp-client/no-auth/README.md b/ai/slackbot-mcp-client/no-auth/README.md new file mode 100644 index 0000000..b8c3a36 --- /dev/null +++ b/ai/slackbot-mcp-client/no-auth/README.md @@ -0,0 +1,16 @@ +# No Auth + +Run an unauthenticated MCP server for the Slackbot MCP client that responds with an [interactive UI](https://modelcontextprotocol.io/extensions/apps/overview). + +## Setup + +```sh +$ ngrok http 3000 # Update manifest with these values +$ slack manifest # Review values +$ slack install --environment local # Create a new app +$ slack app settings # Gather signing secret +$ slack env set SLACK_SIGNING_SECRET +$ slack run +``` + +Ask Slackbot: "Roll 2d20" diff --git a/ai/slackbot-mcp-client/no-auth/manifest.json b/ai/slackbot-mcp-client/no-auth/manifest.json new file mode 100644 index 0000000..816c1c9 --- /dev/null +++ b/ai/slackbot-mcp-client/no-auth/manifest.json @@ -0,0 +1,28 @@ +{ + "display_information": { + "name": "MCP Client - No Auth", + "description": "Connects app MCP server to Slackbot MCP client without authentication" + }, + "features": { + "bot_user": { + "display_name": "MCP Client - No Auth", + "always_online": true + } + }, + "oauth_config": { + "scopes": { + "bot": ["mcp:connect"] + } + }, + "settings": { + "org_deploy_enabled": true, + "socket_mode_enabled": false, + "token_rotation_enabled": false + }, + "mcp_servers": { + "Dice Game": { + "url": "https://1234-56-78-90-0.ngrok-free.app/mcp", + "auth_type": "no_auth" + } + } +} diff --git a/ai/slackbot-mcp-client/no-auth/requirements.txt b/ai/slackbot-mcp-client/no-auth/requirements.txt new file mode 100644 index 0000000..08e77c4 --- /dev/null +++ b/ai/slackbot-mcp-client/no-auth/requirements.txt @@ -0,0 +1,9 @@ +mcp>=1.27,<2 +slack_bolt>=1.28.0 +slack_sdk>=3.42.0 +slack_cli_hooks>=0.3.0 +starlette>=1.0.0 +uvicorn>=0.49.0 +httpx>=0.28.0 +pytest>=9.0.0 +ruff>=0.15.0 diff --git a/ai/slackbot-mcp-client/no-auth/src/__init__.py b/ai/slackbot-mcp-client/no-auth/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ai/slackbot-mcp-client/no-auth/src/dice.html b/ai/slackbot-mcp-client/no-auth/src/dice.html new file mode 100644 index 0000000..1fbbac1 --- /dev/null +++ b/ai/slackbot-mcp-client/no-auth/src/dice.html @@ -0,0 +1,54 @@ + + + + + + + Dice Roller + + + +
Rolling...
+ + + + diff --git a/ai/slackbot-mcp-client/no-auth/tests/__init__.py b/ai/slackbot-mcp-client/no-auth/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ai/slackbot-mcp-client/slack-identity/.gitignore b/ai/slackbot-mcp-client/slack-identity/.gitignore new file mode 100644 index 0000000..982d9c3 --- /dev/null +++ b/ai/slackbot-mcp-client/slack-identity/.gitignore @@ -0,0 +1,5 @@ +__pycache__ +.venv +installations/ +!.env.example +.env* diff --git a/ai/slackbot-mcp-client/slack-identity/.slack/.gitignore b/ai/slackbot-mcp-client/slack-identity/.slack/.gitignore new file mode 100644 index 0000000..973ba60 --- /dev/null +++ b/ai/slackbot-mcp-client/slack-identity/.slack/.gitignore @@ -0,0 +1,2 @@ +apps.dev.json +cache/ diff --git a/ai/slackbot-mcp-client/slack-identity/.slack/config.json b/ai/slackbot-mcp-client/slack-identity/.slack/config.json new file mode 100644 index 0000000..909afbe --- /dev/null +++ b/ai/slackbot-mcp-client/slack-identity/.slack/config.json @@ -0,0 +1,6 @@ +{ + "manifest": { + "source": "local" + }, + "project_id": "00000000-0000-0000-0000-000000000000" +} diff --git a/ai/slackbot-mcp-client/slack-identity/.slack/hooks.json b/ai/slackbot-mcp-client/slack-identity/.slack/hooks.json new file mode 100644 index 0000000..ce474c9 --- /dev/null +++ b/ai/slackbot-mcp-client/slack-identity/.slack/hooks.json @@ -0,0 +1,5 @@ +{ + "hooks": { + "get-hooks": "python3 -m slack_cli_hooks.hooks.get_hooks" + } +} diff --git a/ai/slackbot-mcp-client/slack-identity/README.md b/ai/slackbot-mcp-client/slack-identity/README.md new file mode 100644 index 0000000..bb28cea --- /dev/null +++ b/ai/slackbot-mcp-client/slack-identity/README.md @@ -0,0 +1,17 @@ +# Slack Identity + +Run an MCP server for the Slackbot MCP client that responds with Block Kit and authenticates against existing installations. + +## Setup + +```sh +$ ngrok http 3000 +$ slack install --app local # Create a new app +$ slack app settings +$ slack env init # Update defaults +$ slack manifest # Validate fields +$ slack run +$ open https://1234-56-78-90-0.ngrok-free.app/slack/install # Install the app +``` + +Ask Slackbot: "Make me a profile card using the latest MCP tools" diff --git a/ai/slackbot-mcp-client/slack-identity/manifest.json b/ai/slackbot-mcp-client/slack-identity/manifest.json new file mode 100644 index 0000000..0f41aae --- /dev/null +++ b/ai/slackbot-mcp-client/slack-identity/manifest.json @@ -0,0 +1,31 @@ +{ + "display_information": { + "name": "MCP Client - Slack ID", + "description": "Connects app MCP server to Slackbot MCP client using Slack identity auth" + }, + "features": { + "bot_user": { + "display_name": "MCP Client - Slack ID", + "always_online": true + } + }, + "oauth_config": { + "redirect_urls": [ + "https://1234-56-78-90-0.ngrok-free.app/slack/oauth_redirect" + ], + "scopes": { + "bot": ["mcp:connect", "users:read", "users:read.email"] + } + }, + "settings": { + "org_deploy_enabled": true, + "socket_mode_enabled": false, + "token_rotation_enabled": false + }, + "mcp_servers": { + "Profile Card": { + "url": "https://1234-56-78-90-0.ngrok-free.app/mcp", + "auth_type": "slack_identity_auth" + } + } +} diff --git a/ai/slackbot-mcp-client/slack-identity/requirements.txt b/ai/slackbot-mcp-client/slack-identity/requirements.txt new file mode 100644 index 0000000..08e77c4 --- /dev/null +++ b/ai/slackbot-mcp-client/slack-identity/requirements.txt @@ -0,0 +1,9 @@ +mcp>=1.27,<2 +slack_bolt>=1.28.0 +slack_sdk>=3.42.0 +slack_cli_hooks>=0.3.0 +starlette>=1.0.0 +uvicorn>=0.49.0 +httpx>=0.28.0 +pytest>=9.0.0 +ruff>=0.15.0 diff --git a/ai/slackbot-mcp-client/slack-identity/src/__init__.py b/ai/slackbot-mcp-client/slack-identity/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ai/slackbot-mcp-client/slack-identity/tests/__init__.py b/ai/slackbot-mcp-client/slack-identity/tests/__init__.py new file mode 100644 index 0000000..e69de29 From 9ca5919ad37131da961c528ac09435ecf21caa6c Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Mon, 15 Jun 2026 23:12:24 -0700 Subject: [PATCH 02/28] feat: track .env.example files for MCP client examples Force-add .env.example files that are excluded by the .env* gitignore pattern to provide template environment configuration. Co-Authored-By: Claude --- ai/slackbot-mcp-client/no-auth/.env.example | 3 +++ ai/slackbot-mcp-client/slack-identity/.env.example | 6 ++++++ 2 files changed, 9 insertions(+) create mode 100644 ai/slackbot-mcp-client/no-auth/.env.example create mode 100644 ai/slackbot-mcp-client/slack-identity/.env.example diff --git a/ai/slackbot-mcp-client/no-auth/.env.example b/ai/slackbot-mcp-client/no-auth/.env.example new file mode 100644 index 0000000..3a8a0ae --- /dev/null +++ b/ai/slackbot-mcp-client/no-auth/.env.example @@ -0,0 +1,3 @@ +SLACK_BOT_TOKEN= +SLACK_SIGNING_SECRET= +PORT=3000 diff --git a/ai/slackbot-mcp-client/slack-identity/.env.example b/ai/slackbot-mcp-client/slack-identity/.env.example new file mode 100644 index 0000000..6b8abd7 --- /dev/null +++ b/ai/slackbot-mcp-client/slack-identity/.env.example @@ -0,0 +1,6 @@ +SLACK_CLIENT_ID= +SLACK_CLIENT_SECRET= +SLACK_SIGNING_SECRET= +SLACK_STATE_SECRET= +BASE_URL=https://1234-56-78-90-0.ngrok-free.app +PORT=3000 From 1974f67bba17865e82253704826983a6112bee8c Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Mon, 15 Jun 2026 23:14:34 -0700 Subject: [PATCH 03/28] feat(no-auth): implement Bolt + MCP dice game server Co-Authored-By: Claude --- ai/slackbot-mcp-client/no-auth/app.py | 9 ++ ai/slackbot-mcp-client/no-auth/src/app.py | 108 ++++++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 ai/slackbot-mcp-client/no-auth/app.py create mode 100644 ai/slackbot-mcp-client/no-auth/src/app.py diff --git a/ai/slackbot-mcp-client/no-auth/app.py b/ai/slackbot-mcp-client/no-auth/app.py new file mode 100644 index 0000000..a54402c --- /dev/null +++ b/ai/slackbot-mcp-client/no-auth/app.py @@ -0,0 +1,9 @@ +import os + +import uvicorn + +from src.app import app + +if __name__ == "__main__": + port = int(os.environ.get("PORT", "3000")) + uvicorn.run(app, host="0.0.0.0", port=port) diff --git a/ai/slackbot-mcp-client/no-auth/src/app.py b/ai/slackbot-mcp-client/no-auth/src/app.py new file mode 100644 index 0000000..0fa3d7f --- /dev/null +++ b/ai/slackbot-mcp-client/no-auth/src/app.py @@ -0,0 +1,108 @@ +import contextlib +import os +import random +from pathlib import Path + +from mcp.server.fastmcp import FastMCP +from mcp.types import CallToolResult, TextContent +from slack_bolt import App +from slack_bolt.adapter.starlette import SlackRequestHandler +from slack_sdk.signature import SignatureVerifier +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import JSONResponse, Response +from starlette.routing import Mount, Route +from starlette.types import ASGIApp, Receive, Scope, Send + +DICE_HTML = (Path(__file__).parent / "dice.html").read_text() +RESOURCE_URI = "ui://dice-roller/dice.html" +RESOURCE_MIME_TYPE = "text/html;profile=mcp-app" + +# --- Bolt App --- + +bolt_app = App( + token=os.environ.get("SLACK_BOT_TOKEN"), + signing_secret=os.environ.get("SLACK_SIGNING_SECRET"), +) +bolt_handler = SlackRequestHandler(bolt_app) + +# --- MCP Server --- + +mcp_server = FastMCP("Dice Game", stateless_http=True, json_response=True) + + +@mcp_server.tool( + name="roll_dice", + title="Roll Dice", + description="Roll one or more dice with a configurable number of sides.", + annotations={"readOnlyHint": True}, + meta={"ui": {"resourceUri": RESOURCE_URI}}, +) +def roll_dice(sides: int = 6, count: int = 1) -> CallToolResult: + rolls = [random.randint(1, sides) for _ in range(count)] + total = sum(rolls) + label = f"{count}d{sides}" + rolls_display = f" [{', '.join(str(r) for r in rolls)}]" if count > 1 else "" + + return CallToolResult( + content=[TextContent(type="text", text=f"Rolled {label}:{rolls_display} = {total}")], + structuredContent={"sides": sides, "count": count, "rolls": rolls, "total": total}, + ) + + +@mcp_server.resource(RESOURCE_URI, name="Dice Roller", mime_type=RESOURCE_MIME_TYPE) +def dice_resource() -> str: + return DICE_HTML + + +# --- Slack Signature Verification Middleware --- + + +class SlackSignatureMiddleware: + def __init__(self, app: ASGIApp) -> None: + self.app = app + self.verifier = SignatureVerifier( + signing_secret=os.environ.get("SLACK_SIGNING_SECRET", "") + ) + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + request = Request(scope, receive, send) + body = await request.body() + + if not self.verifier.is_valid_request(body, dict(request.headers)): + response = JSONResponse( + {"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid request"}, "id": None}, + status_code=401, + ) + await response(scope, receive, send) + return + + await self.app(scope, receive, send) + + +# --- Starlette App --- + +mcp_starlette_app = mcp_server.streamable_http_app() + + +@contextlib.asynccontextmanager +async def lifespan(a): + async with mcp_server.session_manager.run(): + yield + + +async def slack_events(request: Request) -> Response: + return await bolt_handler.handle(request) + + +app = Starlette( + routes=[ + Route("/slack/events", endpoint=slack_events, methods=["POST"]), + Mount("/mcp", app=SlackSignatureMiddleware(mcp_starlette_app)), + ], + lifespan=lifespan, +) From 074eccd5083eb29522973b13e6d85c2ff491cd5b Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Mon, 15 Jun 2026 23:29:28 -0700 Subject: [PATCH 04/28] test(no-auth): add integration tests for dice game MCP server Fix body-consuming bug in SlackSignatureMiddleware that prevented the downstream MCP handler from reading the request body. Add replay_receive to make the body available after signature verification. Co-Authored-By: Claude --- ai/slackbot-mcp-client/no-auth/src/app.py | 23 +++- .../no-auth/tests/test_app.py | 120 ++++++++++++++++++ 2 files changed, 139 insertions(+), 4 deletions(-) create mode 100644 ai/slackbot-mcp-client/no-auth/tests/test_app.py diff --git a/ai/slackbot-mcp-client/no-auth/src/app.py b/ai/slackbot-mcp-client/no-auth/src/app.py index 0fa3d7f..109f55a 100644 --- a/ai/slackbot-mcp-client/no-auth/src/app.py +++ b/ai/slackbot-mcp-client/no-auth/src/app.py @@ -45,8 +45,15 @@ def roll_dice(sides: int = 6, count: int = 1) -> CallToolResult: rolls_display = f" [{', '.join(str(r) for r in rolls)}]" if count > 1 else "" return CallToolResult( - content=[TextContent(type="text", text=f"Rolled {label}:{rolls_display} = {total}")], - structuredContent={"sides": sides, "count": count, "rolls": rolls, "total": total}, + content=[ + TextContent(type="text", text=f"Rolled {label}:{rolls_display} = {total}") + ], + structuredContent={ + "sides": sides, + "count": count, + "rolls": rolls, + "total": total, + }, ) @@ -75,13 +82,21 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: if not self.verifier.is_valid_request(body, dict(request.headers)): response = JSONResponse( - {"jsonrpc": "2.0", "error": {"code": -32600, "message": "Invalid request"}, "id": None}, + { + "jsonrpc": "2.0", + "error": {"code": -32600, "message": "Invalid request"}, + "id": None, + }, status_code=401, ) await response(scope, receive, send) return - await self.app(scope, receive, send) + # Replay the consumed body so the downstream app can read it again + async def replay_receive(): + return {"type": "http.request", "body": body, "more_body": False} + + await self.app(scope, replay_receive, send) # --- Starlette App --- diff --git a/ai/slackbot-mcp-client/no-auth/tests/test_app.py b/ai/slackbot-mcp-client/no-auth/tests/test_app.py new file mode 100644 index 0000000..bff1db6 --- /dev/null +++ b/ai/slackbot-mcp-client/no-auth/tests/test_app.py @@ -0,0 +1,120 @@ +import hashlib +import hmac +import json +import os +import time +from unittest.mock import patch + +import pytest + +# Patch environment before importing the app module (reads env at import time) +os.environ["SLACK_BOT_TOKEN"] = "xoxb-test" +os.environ["SLACK_SIGNING_SECRET"] = "test_signing_secret" + +# Mock auth.test so Bolt doesn't make a real API call during init +_mock_auth = patch( + "slack_sdk.web.client.WebClient.auth_test", + return_value={"ok": True, "bot_id": "B123", "user_id": "U123", "team_id": "T123"}, +) +_mock_auth.start() + +from starlette.testclient import TestClient # noqa: E402 + +from src.app import app # noqa: E402 + +SIGNING_SECRET = "test_signing_secret" + + +def sign_request(body: str, secret: str = SIGNING_SECRET) -> dict: + """Generate valid Slack signature headers for the given request body.""" + timestamp = str(int(time.time())) + sig_basestring = f"v0:{timestamp}:{body}" + signature = ( + "v0=" + + hmac.new(secret.encode(), sig_basestring.encode(), hashlib.sha256).hexdigest() + ) + return { + "x-slack-request-timestamp": timestamp, + "x-slack-signature": signature, + "content-type": "application/json", + "accept": "application/json", + } + + +# The MCP starlette sub-app registers its route at /mcp internally, +# and it is mounted at /mcp in the main app, so the full path is /mcp/mcp. +MCP_PATH = "/mcp/mcp" + + +@pytest.fixture(scope="module") +def client(): + """Create a TestClient that triggers the app lifespan (starts session manager).""" + with TestClient(app, base_url="http://localhost:8000") as c: + yield c + + +def test_roll_dice(client): + """POST to /mcp with a valid signature and tools/call for roll_dice.""" + body = json.dumps( + { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": "roll_dice", "arguments": {"sides": 6, "count": 2}}, + } + ) + headers = sign_request(body) + resp = client.post(MCP_PATH, content=body, headers=headers) + + assert resp.status_code == 200 + data = resp.json() + assert data["jsonrpc"] == "2.0" + assert data["id"] == 1 + result = data["result"] + structured = result["structuredContent"] + assert structured["sides"] == 6 + assert structured["count"] == 2 + assert len(structured["rolls"]) == 2 + assert all(1 <= r <= 6 for r in structured["rolls"]) + assert structured["total"] == sum(structured["rolls"]) + + +def test_dice_resource(client): + """POST to /mcp with a valid signature and resources/read for the dice HTML.""" + body = json.dumps( + { + "jsonrpc": "2.0", + "id": 1, + "method": "resources/read", + "params": {"uri": "ui://dice-roller/dice.html"}, + } + ) + headers = sign_request(body) + resp = client.post(MCP_PATH, content=body, headers=headers) + + assert resp.status_code == 200 + data = resp.json() + assert data["jsonrpc"] == "2.0" + assert data["id"] == 1 + contents = data["result"]["contents"] + assert len(contents) >= 1 + # The resource should contain the Dice Roller HTML + text = contents[0]["text"] + assert "Dice Roller" in text + + +def test_rejects_unsigned(client): + """POST to /mcp without Slack signature headers returns 401.""" + body = json.dumps( + { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": "roll_dice", "arguments": {"sides": 6, "count": 1}}, + } + ) + resp = client.post( + MCP_PATH, content=body, headers={"content-type": "application/json"} + ) + + assert resp.status_code == 401 From f5f3e2825696504db723673383a309c020347c47 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Mon, 15 Jun 2026 23:34:09 -0700 Subject: [PATCH 05/28] feat(slack-identity): implement Bolt + MCP profile card server Co-Authored-By: Claude --- ai/slackbot-mcp-client/slack-identity/app.py | 9 + .../slack-identity/src/app.py | 236 ++++++++++++++++++ 2 files changed, 245 insertions(+) create mode 100644 ai/slackbot-mcp-client/slack-identity/app.py create mode 100644 ai/slackbot-mcp-client/slack-identity/src/app.py diff --git a/ai/slackbot-mcp-client/slack-identity/app.py b/ai/slackbot-mcp-client/slack-identity/app.py new file mode 100644 index 0000000..a54402c --- /dev/null +++ b/ai/slackbot-mcp-client/slack-identity/app.py @@ -0,0 +1,9 @@ +import os + +import uvicorn + +from src.app import app + +if __name__ == "__main__": + port = int(os.environ.get("PORT", "3000")) + uvicorn.run(app, host="0.0.0.0", port=port) diff --git a/ai/slackbot-mcp-client/slack-identity/src/app.py b/ai/slackbot-mcp-client/slack-identity/src/app.py new file mode 100644 index 0000000..bdf0701 --- /dev/null +++ b/ai/slackbot-mcp-client/slack-identity/src/app.py @@ -0,0 +1,236 @@ +import contextlib +import os + +from mcp.server.fastmcp import Context, FastMCP +from mcp.server.session import ServerSession +from mcp.types import CallToolResult, TextContent +from slack_bolt import App +from slack_bolt.adapter.starlette import SlackRequestHandler +from slack_bolt.oauth.oauth_settings import OAuthSettings +from slack_sdk.oauth.installation_store import FileInstallationStore +from slack_sdk.oauth.state_store import FileOAuthStateStore +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web import WebClient +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import JSONResponse, Response +from starlette.routing import Mount, Route +from starlette.types import ASGIApp, Receive, Scope, Send + +# --- Installation Store --- + +installation_store = FileInstallationStore(base_dir="./data/installations") + +# --- Bolt App with OAuth --- + +bolt_app = App( + signing_secret=os.environ.get("SLACK_SIGNING_SECRET"), + oauth_settings=OAuthSettings( + client_id=os.environ.get("SLACK_CLIENT_ID"), + client_secret=os.environ.get("SLACK_CLIENT_SECRET"), + scopes=["users:read", "users:read.email"], + installation_store=installation_store, + state_store=FileOAuthStateStore( + expiration_seconds=600, base_dir="./data/states" + ), + ), +) +bolt_handler = SlackRequestHandler(bolt_app) + +# --- MCP Server --- + +mcp_server = FastMCP("Profile Card", stateless_http=True, json_response=True) + + +@mcp_server.tool( + name="get_profile_card", + title="Get Profile Card", + description="Get a profile card for a Slack user by their user ID.", + annotations={"readOnlyHint": True}, + meta={"slack": {"supportsBlockKit": True}}, +) +async def get_profile_card( + user_id: str, ctx: Context[ServerSession, None] +) -> CallToolResult: + meta = ctx.request_context.meta + slack = meta.model_extra.get("slack", {}) if meta else {} + + if not slack.get("user_id") or not slack.get("team_id"): + return CallToolResult( + content=[ + TextContent( + type="text", + text="Missing Slack identity context. " + "This tool must be called from Slack.", + ) + ], + ) + + team_id = slack["team_id"] + slack_user_id = slack["user_id"] + enterprise_id = slack.get("enterprise_id") + + try: + installation = installation_store.find_installation( + enterprise_id=enterprise_id, + team_id=team_id, + user_id=slack_user_id, + is_enterprise_install=bool(enterprise_id), + ) + if not installation or not installation.bot_token: + raise ValueError("No bot token") + bot_token = installation.bot_token + except Exception: + return CallToolResult( + content=[ + TextContent( + type="text", + text="App not installed to this workspace. Please install first.", + ) + ], + _meta={ + "slack": { + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "Please install the *MCP Profile Card* app " + "to access profile information.", + }, + "accessory": { + "type": "button", + "text": { + "type": "plain_text", + "text": "Install", + }, + "url": f"{os.environ.get('BASE_URL', '')}/slack/install", + "action_id": "install_app", + }, + } + ] + } + }, + ) + + try: + client = WebClient(token=bot_token) + result = client.users_info(user=user_id) + profile = result["user"]["profile"] + except Exception: + return CallToolResult( + content=[ + TextContent( + type="text", + text=f"Failed to fetch profile for {user_id}.", + ) + ], + ) + + return CallToolResult( + content=[ + TextContent( + type="text", + text=f"Profile card for {profile['real_name']}\n" + f"Title: {profile.get('title', '')}\n" + f"Email: {profile.get('email', '')}", + ) + ], + _meta={ + "slack": { + "blocks": [ + { + "type": "card", + "icon": { + "type": "image", + "image_url": profile.get("image_72", ""), + "alt_text": profile.get("real_name", ""), + }, + "title": { + "type": "mrkdwn", + "text": profile.get("real_name", ""), + }, + "subtitle": { + "type": "mrkdwn", + "text": profile.get("title", ""), + }, + "body": { + "type": "mrkdwn", + "text": f"*Email:* {profile.get('email', '')}", + }, + } + ] + } + }, + ) + + +# --- Slack Signature Verification Middleware --- + + +class SlackSignatureMiddleware: + def __init__(self, app: ASGIApp) -> None: + self.app = app + self.verifier = SignatureVerifier( + signing_secret=os.environ.get("SLACK_SIGNING_SECRET", "") + ) + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + request = Request(scope, receive, send) + body = await request.body() + + if not self.verifier.is_valid_request(body, dict(request.headers)): + response = JSONResponse( + { + "jsonrpc": "2.0", + "error": {"code": -32600, "message": "Invalid request"}, + "id": None, + }, + status_code=401, + ) + await response(scope, receive, send) + return + + # Replay the consumed body so the downstream app can read it again + async def replay_receive(): + return {"type": "http.request", "body": body, "more_body": False} + + await self.app(scope, replay_receive, send) + + +# --- Starlette App --- + +mcp_starlette_app = mcp_server.streamable_http_app() + + +@contextlib.asynccontextmanager +async def lifespan(a): + async with mcp_server.session_manager.run(): + yield + + +async def slack_events(request: Request) -> Response: + return await bolt_handler.handle(request) + + +async def slack_install(request: Request) -> Response: + return await bolt_handler.handle(request) + + +async def slack_oauth_redirect(request: Request) -> Response: + return await bolt_handler.handle(request) + + +app = Starlette( + routes=[ + Route("/slack/events", endpoint=slack_events, methods=["POST"]), + Route("/slack/install", endpoint=slack_install, methods=["GET"]), + Route("/slack/oauth_redirect", endpoint=slack_oauth_redirect, methods=["GET"]), + Mount("/mcp", app=SlackSignatureMiddleware(mcp_starlette_app)), + ], + lifespan=lifespan, +) From 57d078a929b25d0aadfa49bfd5319b62e5ad17c3 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Mon, 15 Jun 2026 23:39:28 -0700 Subject: [PATCH 06/28] test(slack-identity): add integration tests, update CI and README - Add 4 integration tests for profile card MCP server - Add mcp:connect to OAuth scopes to match manifest - Add MCP examples to CI test matrix with fail-fast: false - Add dependabot entries for both MCP example directories - Update root README with AI/MCP examples section - Make mypy conditional in CI (only if installed) Co-Authored-By: Claude --- .github/dependabot.yml | 8 + .github/workflows/test.yml | 5 +- README.md | 9 + .../slack-identity/src/app.py | 2 +- .../slack-identity/tests/test_app.py | 175 ++++++++++++++++++ 5 files changed, 197 insertions(+), 2 deletions(-) create mode 100644 ai/slackbot-mcp-client/slack-identity/tests/test_app.py diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 4b23914..79e73f8 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -8,3 +8,11 @@ updates: directory: "block-kit" schedule: interval: "daily" + - package-ecosystem: "pip" + directory: "ai/slackbot-mcp-client/no-auth" + schedule: + interval: "daily" + - package-ecosystem: "pip" + directory: "ai/slackbot-mcp-client/slack-identity" + schedule: + interval: "daily" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bfbebdf..74bfabf 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,9 +9,12 @@ jobs: name: "pytest@python3.14" runs-on: ubuntu-latest strategy: + fail-fast: false matrix: showcase: - "block-kit" + - "ai/slackbot-mcp-client/no-auth" + - "ai/slackbot-mcp-client/slack-identity" steps: - name: Checkout code uses: actions/checkout@v6 @@ -25,5 +28,5 @@ jobs: pip install -r requirements.txt ruff check ruff format --diff --check - mypy ./**/*.py + if pip show mypy > /dev/null 2>&1; then mypy ./**/*.py; fi pytest -v diff --git a/README.md b/README.md index 2334580..a59115b 100644 --- a/README.md +++ b/README.md @@ -5,3 +5,12 @@ This collections of examples highlights features of a Slack app in the language ## Available demonstration - **[Block Kit](./block-kit)**: The framework of visual components arranged to create app layouts. + +## AI + +MCP server examples that integrate with Slack's MCP client (Slackbot). + +- **[No Auth (Dice Game)](./ai/slackbot-mcp-client/no-auth)**: An MCP server with no user-level auth — demonstrates tools, structured content, and a custom UI resource. +- **[Slack Identity (Profile Card)](./ai/slackbot-mcp-client/slack-identity)**: An MCP server using Slack identity context (`_meta.slack`) to look up user profiles via OAuth. +- **[DCR Auth](./ai/slackbot-mcp-client/dcr)**: Manifest-only example for Dynamic Client Registration auth flow. +- **[External Auth](./ai/slackbot-mcp-client/external-auth)**: Manifest-only example for external OAuth provider auth flow. diff --git a/ai/slackbot-mcp-client/slack-identity/src/app.py b/ai/slackbot-mcp-client/slack-identity/src/app.py index bdf0701..27db6bb 100644 --- a/ai/slackbot-mcp-client/slack-identity/src/app.py +++ b/ai/slackbot-mcp-client/slack-identity/src/app.py @@ -28,7 +28,7 @@ oauth_settings=OAuthSettings( client_id=os.environ.get("SLACK_CLIENT_ID"), client_secret=os.environ.get("SLACK_CLIENT_SECRET"), - scopes=["users:read", "users:read.email"], + scopes=["mcp:connect", "users:read", "users:read.email"], installation_store=installation_store, state_store=FileOAuthStateStore( expiration_seconds=600, base_dir="./data/states" diff --git a/ai/slackbot-mcp-client/slack-identity/tests/test_app.py b/ai/slackbot-mcp-client/slack-identity/tests/test_app.py new file mode 100644 index 0000000..15ee44a --- /dev/null +++ b/ai/slackbot-mcp-client/slack-identity/tests/test_app.py @@ -0,0 +1,175 @@ +import hashlib +import hmac +import json +import os +import time +from unittest.mock import MagicMock, patch + +import pytest + +os.environ["SLACK_SIGNING_SECRET"] = "test_signing_secret" +os.environ["SLACK_CLIENT_ID"] = "111.222" +os.environ["SLACK_CLIENT_SECRET"] = "client_secret" + +from starlette.testclient import TestClient # noqa: E402 + +from src.app import app # noqa: E402 + +SIGNING_SECRET = "test_signing_secret" +MCP_PATH = "/mcp/mcp" + + +def sign_request(body: str, secret: str = SIGNING_SECRET) -> dict: + timestamp = str(int(time.time())) + sig_basestring = f"v0:{timestamp}:{body}" + signature = ( + "v0=" + + hmac.new(secret.encode(), sig_basestring.encode(), hashlib.sha256).hexdigest() + ) + return { + "x-slack-request-timestamp": timestamp, + "x-slack-signature": signature, + "content-type": "application/json", + "accept": "application/json", + } + + +@pytest.fixture(scope="module") +def client(): + with TestClient(app, base_url="http://localhost:8000") as c: + yield c + + +def test_get_profile_card(client): + """Successful profile card fetch with mocked installation and users.info.""" + mock_installation = MagicMock() + mock_installation.bot_token = "xoxb-fake-token" + + body = json.dumps( + { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "get_profile_card", + "arguments": {"user_id": "U12345"}, + "_meta": { + "slack": { + "user_id": "U99999", + "team_id": "T11111", + } + }, + }, + } + ) + headers = sign_request(body) + + with ( + patch( + "src.app.installation_store.find_installation", + return_value=mock_installation, + ), + patch("src.app.WebClient") as MockWebClient, + ): + mock_client = MagicMock() + mock_client.users_info.return_value = { + "ok": True, + "user": { + "profile": { + "real_name": "Test User", + "title": "Engineer", + "email": "test@example.com", + "image_72": "https://example.com/avatar.png", + } + }, + } + MockWebClient.return_value = mock_client + + resp = client.post(MCP_PATH, content=body, headers=headers) + + assert resp.status_code == 200 + data = resp.json() + result = data["result"] + assert "Test User" in result["content"][0]["text"] + assert "Engineer" in result["content"][0]["text"] + blocks = result["_meta"]["slack"]["blocks"] + assert blocks[0]["type"] == "card" + assert blocks[0]["title"]["text"] == "Test User" + + +def test_missing_slack_context(client): + """Request without _meta.slack returns missing context error.""" + body = json.dumps( + { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "get_profile_card", + "arguments": {"user_id": "U12345"}, + }, + } + ) + headers = sign_request(body) + resp = client.post(MCP_PATH, content=body, headers=headers) + + assert resp.status_code == 200 + data = resp.json() + result = data["result"] + assert "Missing Slack identity" in result["content"][0]["text"] + + +def test_missing_installation(client): + """When no installation found, returns install button block.""" + body = json.dumps( + { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "get_profile_card", + "arguments": {"user_id": "U12345"}, + "_meta": { + "slack": { + "user_id": "U99999", + "team_id": "T11111", + } + }, + }, + } + ) + headers = sign_request(body) + + with patch( + "src.app.installation_store.find_installation", + side_effect=Exception("Not found"), + ): + resp = client.post(MCP_PATH, content=body, headers=headers) + + assert resp.status_code == 200 + data = resp.json() + result = data["result"] + assert "not installed" in result["content"][0]["text"].lower() + blocks = result["_meta"]["slack"]["blocks"] + assert blocks[0]["type"] == "section" + assert blocks[0]["accessory"]["type"] == "button" + + +def test_rejects_unsigned(client): + """POST without Slack signature headers returns 401.""" + body = json.dumps( + { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "get_profile_card", + "arguments": {"user_id": "U12345"}, + }, + } + ) + resp = client.post( + MCP_PATH, content=body, headers={"content-type": "application/json"} + ) + + assert resp.status_code == 401 From 5a194edb77da9fee9f2aae49a059d292cec970fa Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Mon, 15 Jun 2026 23:40:58 -0700 Subject: [PATCH 07/28] fix: add mypy to all examples, fix type errors - Add mypy>=2.1.0 to both MCP example requirements - Use ToolAnnotations object instead of dict literal - Handle None case for model_extra in slack-identity - Remove conditional mypy in CI (all examples have it now) Co-Authored-By: Claude --- .github/workflows/test.yml | 2 +- ai/slackbot-mcp-client/no-auth/requirements.txt | 1 + ai/slackbot-mcp-client/no-auth/src/app.py | 4 ++-- ai/slackbot-mcp-client/slack-identity/requirements.txt | 1 + ai/slackbot-mcp-client/slack-identity/src/app.py | 7 ++++--- 5 files changed, 9 insertions(+), 6 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 74bfabf..f78197d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -28,5 +28,5 @@ jobs: pip install -r requirements.txt ruff check ruff format --diff --check - if pip show mypy > /dev/null 2>&1; then mypy ./**/*.py; fi + mypy ./**/*.py pytest -v diff --git a/ai/slackbot-mcp-client/no-auth/requirements.txt b/ai/slackbot-mcp-client/no-auth/requirements.txt index 08e77c4..7b5be95 100644 --- a/ai/slackbot-mcp-client/no-auth/requirements.txt +++ b/ai/slackbot-mcp-client/no-auth/requirements.txt @@ -5,5 +5,6 @@ slack_cli_hooks>=0.3.0 starlette>=1.0.0 uvicorn>=0.49.0 httpx>=0.28.0 +mypy>=2.1.0 pytest>=9.0.0 ruff>=0.15.0 diff --git a/ai/slackbot-mcp-client/no-auth/src/app.py b/ai/slackbot-mcp-client/no-auth/src/app.py index 109f55a..91a4118 100644 --- a/ai/slackbot-mcp-client/no-auth/src/app.py +++ b/ai/slackbot-mcp-client/no-auth/src/app.py @@ -4,7 +4,7 @@ from pathlib import Path from mcp.server.fastmcp import FastMCP -from mcp.types import CallToolResult, TextContent +from mcp.types import CallToolResult, TextContent, ToolAnnotations from slack_bolt import App from slack_bolt.adapter.starlette import SlackRequestHandler from slack_sdk.signature import SignatureVerifier @@ -35,7 +35,7 @@ name="roll_dice", title="Roll Dice", description="Roll one or more dice with a configurable number of sides.", - annotations={"readOnlyHint": True}, + annotations=ToolAnnotations(readOnlyHint=True), meta={"ui": {"resourceUri": RESOURCE_URI}}, ) def roll_dice(sides: int = 6, count: int = 1) -> CallToolResult: diff --git a/ai/slackbot-mcp-client/slack-identity/requirements.txt b/ai/slackbot-mcp-client/slack-identity/requirements.txt index 08e77c4..7b5be95 100644 --- a/ai/slackbot-mcp-client/slack-identity/requirements.txt +++ b/ai/slackbot-mcp-client/slack-identity/requirements.txt @@ -5,5 +5,6 @@ slack_cli_hooks>=0.3.0 starlette>=1.0.0 uvicorn>=0.49.0 httpx>=0.28.0 +mypy>=2.1.0 pytest>=9.0.0 ruff>=0.15.0 diff --git a/ai/slackbot-mcp-client/slack-identity/src/app.py b/ai/slackbot-mcp-client/slack-identity/src/app.py index 27db6bb..57587b0 100644 --- a/ai/slackbot-mcp-client/slack-identity/src/app.py +++ b/ai/slackbot-mcp-client/slack-identity/src/app.py @@ -3,7 +3,7 @@ from mcp.server.fastmcp import Context, FastMCP from mcp.server.session import ServerSession -from mcp.types import CallToolResult, TextContent +from mcp.types import CallToolResult, TextContent, ToolAnnotations from slack_bolt import App from slack_bolt.adapter.starlette import SlackRequestHandler from slack_bolt.oauth.oauth_settings import OAuthSettings @@ -46,14 +46,15 @@ name="get_profile_card", title="Get Profile Card", description="Get a profile card for a Slack user by their user ID.", - annotations={"readOnlyHint": True}, + annotations=ToolAnnotations(readOnlyHint=True), meta={"slack": {"supportsBlockKit": True}}, ) async def get_profile_card( user_id: str, ctx: Context[ServerSession, None] ) -> CallToolResult: meta = ctx.request_context.meta - slack = meta.model_extra.get("slack", {}) if meta else {} + model_extra = meta.model_extra if meta else None + slack = model_extra.get("slack", {}) if model_extra else {} if not slack.get("user_id") or not slack.get("team_id"): return CallToolResult( From d45246f68799df8a6cb8a4d781458421759a4ee3 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Mon, 15 Jun 2026 23:44:20 -0700 Subject: [PATCH 08/28] fix: correct DCR link in root README to match directory name Co-Authored-By: Claude --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a59115b..79c2f49 100644 --- a/README.md +++ b/README.md @@ -12,5 +12,5 @@ MCP server examples that integrate with Slack's MCP client (Slackbot). - **[No Auth (Dice Game)](./ai/slackbot-mcp-client/no-auth)**: An MCP server with no user-level auth — demonstrates tools, structured content, and a custom UI resource. - **[Slack Identity (Profile Card)](./ai/slackbot-mcp-client/slack-identity)**: An MCP server using Slack identity context (`_meta.slack`) to look up user profiles via OAuth. -- **[DCR Auth](./ai/slackbot-mcp-client/dcr)**: Manifest-only example for Dynamic Client Registration auth flow. +- **[Dynamic Client Registration](./ai/slackbot-mcp-client/dynamic-client-registration)**: Manifest-only example for Dynamic Client Registration auth flow. - **[External Auth](./ai/slackbot-mcp-client/external-auth)**: Manifest-only example for external OAuth provider auth flow. From e3d33cd6a42de7d8a597b58a0f49234e2c6a2533 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Mon, 15 Jun 2026 23:46:06 -0700 Subject: [PATCH 09/28] fix: match root README format to JS examples repo Co-Authored-By: Claude --- README.md | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 79c2f49..5a58c76 100644 --- a/README.md +++ b/README.md @@ -2,15 +2,7 @@ This collections of examples highlights features of a Slack app in the language of Bolt for Python. -## Available demonstration +## Available demonstrations +- **[AI in Slack](./ai)**: Agent experiences and MCP features in an interactive conversation interface. - **[Block Kit](./block-kit)**: The framework of visual components arranged to create app layouts. - -## AI - -MCP server examples that integrate with Slack's MCP client (Slackbot). - -- **[No Auth (Dice Game)](./ai/slackbot-mcp-client/no-auth)**: An MCP server with no user-level auth — demonstrates tools, structured content, and a custom UI resource. -- **[Slack Identity (Profile Card)](./ai/slackbot-mcp-client/slack-identity)**: An MCP server using Slack identity context (`_meta.slack`) to look up user profiles via OAuth. -- **[Dynamic Client Registration](./ai/slackbot-mcp-client/dynamic-client-registration)**: Manifest-only example for Dynamic Client Registration auth flow. -- **[External Auth](./ai/slackbot-mcp-client/external-auth)**: Manifest-only example for external OAuth provider auth flow. From 4e6122523657d32ff64403c17e9357b69da96329 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Tue, 16 Jun 2026 09:13:27 -0700 Subject: [PATCH 10/28] ci: sort showcase examples alphabetically --- .github/dependabot.yml | 6 +++--- .github/workflows/test.yml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 79e73f8..53b7e66 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,14 +5,14 @@ updates: schedule: interval: "daily" - package-ecosystem: "pip" - directory: "block-kit" + directory: "ai/slackbot-mcp-client/no-auth" schedule: interval: "daily" - package-ecosystem: "pip" - directory: "ai/slackbot-mcp-client/no-auth" + directory: "ai/slackbot-mcp-client/slack-identity" schedule: interval: "daily" - package-ecosystem: "pip" - directory: "ai/slackbot-mcp-client/slack-identity" + directory: "block-kit" schedule: interval: "daily" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f78197d..a5e385d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,9 +12,9 @@ jobs: fail-fast: false matrix: showcase: - - "block-kit" - "ai/slackbot-mcp-client/no-auth" - "ai/slackbot-mcp-client/slack-identity" + - "block-kit" steps: - name: Checkout code uses: actions/checkout@v6 From 91800c79d0a71ebd192af3d320fa5cb73294f64e Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Tue, 16 Jun 2026 10:33:46 -0700 Subject: [PATCH 11/28] fix: resolve MCP routing, CSP, and host-header issues - Use Route instead of Mount to serve /mcp directly (eliminates 307 redirect that Slack's MCP client does not follow on POST) - Add _meta.ui.csp with esm.sh domains to dice resource (unblocks script loading in sandboxed iframe) - Document --host-header=rewrite for ngrok (satisfies DNS-rebinding protection allowlist) - Reorder app.py sections to mirror JS examples (MCP first, Bolt second) - Clean up test files (inline MCP_PATH, move helpers to bottom) Co-Authored-By: Claude --- ai/slackbot-mcp-client/no-auth/README.md | 2 +- ai/slackbot-mcp-client/no-auth/src/app.py | 40 ++++++++++----- .../no-auth/tests/test_app.py | 51 +++++++++---------- .../slack-identity/README.md | 2 +- .../slack-identity/src/app.py | 42 ++++++++------- .../slack-identity/tests/test_app.py | 45 ++++++++-------- 6 files changed, 99 insertions(+), 83 deletions(-) diff --git a/ai/slackbot-mcp-client/no-auth/README.md b/ai/slackbot-mcp-client/no-auth/README.md index b8c3a36..be49cae 100644 --- a/ai/slackbot-mcp-client/no-auth/README.md +++ b/ai/slackbot-mcp-client/no-auth/README.md @@ -5,7 +5,7 @@ Run an unauthenticated MCP server for the Slackbot MCP client that responds with ## Setup ```sh -$ ngrok http 3000 # Update manifest with these values +$ ngrok http 3000 --host-header=rewrite # Rewrite Host to localhost so the MCP server's DNS-rebinding protection accepts the request; update manifest with these values $ slack manifest # Review values $ slack install --environment local # Create a new app $ slack app settings # Gather signing secret diff --git a/ai/slackbot-mcp-client/no-auth/src/app.py b/ai/slackbot-mcp-client/no-auth/src/app.py index 91a4118..deeca4a 100644 --- a/ai/slackbot-mcp-client/no-auth/src/app.py +++ b/ai/slackbot-mcp-client/no-auth/src/app.py @@ -11,21 +11,13 @@ from starlette.applications import Starlette from starlette.requests import Request from starlette.responses import JSONResponse, Response -from starlette.routing import Mount, Route +from starlette.routing import Route from starlette.types import ASGIApp, Receive, Scope, Send DICE_HTML = (Path(__file__).parent / "dice.html").read_text() RESOURCE_URI = "ui://dice-roller/dice.html" RESOURCE_MIME_TYPE = "text/html;profile=mcp-app" -# --- Bolt App --- - -bolt_app = App( - token=os.environ.get("SLACK_BOT_TOKEN"), - signing_secret=os.environ.get("SLACK_SIGNING_SECRET"), -) -bolt_handler = SlackRequestHandler(bolt_app) - # --- MCP Server --- mcp_server = FastMCP("Dice Game", stateless_http=True, json_response=True) @@ -57,11 +49,32 @@ def roll_dice(sides: int = 6, count: int = 1) -> CallToolResult: ) -@mcp_server.resource(RESOURCE_URI, name="Dice Roller", mime_type=RESOURCE_MIME_TYPE) +@mcp_server.resource( + RESOURCE_URI, + name="Dice Roller", + mime_type=RESOURCE_MIME_TYPE, + meta={ + "ui": { + "csp": { + "resourceDomains": ["https://esm.sh"], + "connectDomains": ["https://esm.sh"], + } + } + }, +) def dice_resource() -> str: return DICE_HTML +# --- Bolt App --- + +bolt_app = App( + token=os.environ.get("SLACK_BOT_TOKEN"), + signing_secret=os.environ.get("SLACK_SIGNING_SECRET"), +) +bolt_handler = SlackRequestHandler(bolt_app) + + # --- Slack Signature Verification Middleware --- @@ -92,7 +105,6 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: await response(scope, receive, send) return - # Replay the consumed body so the downstream app can read it again async def replay_receive(): return {"type": "http.request", "body": body, "more_body": False} @@ -117,7 +129,11 @@ async def slack_events(request: Request) -> Response: app = Starlette( routes=[ Route("/slack/events", endpoint=slack_events, methods=["POST"]), - Mount("/mcp", app=SlackSignatureMiddleware(mcp_starlette_app)), + Route( + "/mcp", + endpoint=SlackSignatureMiddleware(mcp_starlette_app), + methods=["POST"], + ), ], lifespan=lifespan, ) diff --git a/ai/slackbot-mcp-client/no-auth/tests/test_app.py b/ai/slackbot-mcp-client/no-auth/tests/test_app.py index bff1db6..e96e03c 100644 --- a/ai/slackbot-mcp-client/no-auth/tests/test_app.py +++ b/ai/slackbot-mcp-client/no-auth/tests/test_app.py @@ -6,46 +6,22 @@ from unittest.mock import patch import pytest +from starlette.testclient import TestClient -# Patch environment before importing the app module (reads env at import time) os.environ["SLACK_BOT_TOKEN"] = "xoxb-test" os.environ["SLACK_SIGNING_SECRET"] = "test_signing_secret" -# Mock auth.test so Bolt doesn't make a real API call during init _mock_auth = patch( "slack_sdk.web.client.WebClient.auth_test", return_value={"ok": True, "bot_id": "B123", "user_id": "U123", "team_id": "T123"}, ) _mock_auth.start() -from starlette.testclient import TestClient # noqa: E402 - from src.app import app # noqa: E402 SIGNING_SECRET = "test_signing_secret" -def sign_request(body: str, secret: str = SIGNING_SECRET) -> dict: - """Generate valid Slack signature headers for the given request body.""" - timestamp = str(int(time.time())) - sig_basestring = f"v0:{timestamp}:{body}" - signature = ( - "v0=" - + hmac.new(secret.encode(), sig_basestring.encode(), hashlib.sha256).hexdigest() - ) - return { - "x-slack-request-timestamp": timestamp, - "x-slack-signature": signature, - "content-type": "application/json", - "accept": "application/json", - } - - -# The MCP starlette sub-app registers its route at /mcp internally, -# and it is mounted at /mcp in the main app, so the full path is /mcp/mcp. -MCP_PATH = "/mcp/mcp" - - @pytest.fixture(scope="module") def client(): """Create a TestClient that triggers the app lifespan (starts session manager).""" @@ -64,7 +40,7 @@ def test_roll_dice(client): } ) headers = sign_request(body) - resp = client.post(MCP_PATH, content=body, headers=headers) + resp = client.post("/mcp", content=body, headers=headers) assert resp.status_code == 200 data = resp.json() @@ -90,7 +66,7 @@ def test_dice_resource(client): } ) headers = sign_request(body) - resp = client.post(MCP_PATH, content=body, headers=headers) + resp = client.post("/mcp", content=body, headers=headers) assert resp.status_code == 200 data = resp.json() @@ -114,7 +90,26 @@ def test_rejects_unsigned(client): } ) resp = client.post( - MCP_PATH, content=body, headers={"content-type": "application/json"} + "/mcp", content=body, headers={"content-type": "application/json"} ) assert resp.status_code == 401 + + +# --- Helpers --- + + +def sign_request(body: str, secret: str = SIGNING_SECRET) -> dict: + """Generate valid Slack signature headers for the given request body.""" + timestamp = str(int(time.time())) + sig_basestring = f"v0:{timestamp}:{body}" + signature = ( + "v0=" + + hmac.new(secret.encode(), sig_basestring.encode(), hashlib.sha256).hexdigest() + ) + return { + "x-slack-request-timestamp": timestamp, + "x-slack-signature": signature, + "content-type": "application/json", + "accept": "application/json", + } diff --git a/ai/slackbot-mcp-client/slack-identity/README.md b/ai/slackbot-mcp-client/slack-identity/README.md index bb28cea..fe7c64d 100644 --- a/ai/slackbot-mcp-client/slack-identity/README.md +++ b/ai/slackbot-mcp-client/slack-identity/README.md @@ -5,7 +5,7 @@ Run an MCP server for the Slackbot MCP client that responds with Block Kit and a ## Setup ```sh -$ ngrok http 3000 +$ ngrok http 3000 --host-header=rewrite # Rewrite Host to localhost so the MCP server's DNS-rebinding protection accepts the request $ slack install --app local # Create a new app $ slack app settings $ slack env init # Update defaults diff --git a/ai/slackbot-mcp-client/slack-identity/src/app.py b/ai/slackbot-mcp-client/slack-identity/src/app.py index 57587b0..efca00a 100644 --- a/ai/slackbot-mcp-client/slack-identity/src/app.py +++ b/ai/slackbot-mcp-client/slack-identity/src/app.py @@ -14,29 +14,13 @@ from starlette.applications import Starlette from starlette.requests import Request from starlette.responses import JSONResponse, Response -from starlette.routing import Mount, Route +from starlette.routing import Route from starlette.types import ASGIApp, Receive, Scope, Send # --- Installation Store --- installation_store = FileInstallationStore(base_dir="./data/installations") -# --- Bolt App with OAuth --- - -bolt_app = App( - signing_secret=os.environ.get("SLACK_SIGNING_SECRET"), - oauth_settings=OAuthSettings( - client_id=os.environ.get("SLACK_CLIENT_ID"), - client_secret=os.environ.get("SLACK_CLIENT_SECRET"), - scopes=["mcp:connect", "users:read", "users:read.email"], - installation_store=installation_store, - state_store=FileOAuthStateStore( - expiration_seconds=600, base_dir="./data/states" - ), - ), -) -bolt_handler = SlackRequestHandler(bolt_app) - # --- MCP Server --- mcp_server = FastMCP("Profile Card", stateless_http=True, json_response=True) @@ -166,6 +150,23 @@ async def get_profile_card( ) +# --- Bolt App with OAuth --- + +bolt_app = App( + signing_secret=os.environ.get("SLACK_SIGNING_SECRET"), + oauth_settings=OAuthSettings( + client_id=os.environ.get("SLACK_CLIENT_ID"), + client_secret=os.environ.get("SLACK_CLIENT_SECRET"), + scopes=["mcp:connect", "users:read", "users:read.email"], + installation_store=installation_store, + state_store=FileOAuthStateStore( + expiration_seconds=600, base_dir="./data/states" + ), + ), +) +bolt_handler = SlackRequestHandler(bolt_app) + + # --- Slack Signature Verification Middleware --- @@ -196,7 +197,6 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: await response(scope, receive, send) return - # Replay the consumed body so the downstream app can read it again async def replay_receive(): return {"type": "http.request", "body": body, "more_body": False} @@ -231,7 +231,11 @@ async def slack_oauth_redirect(request: Request) -> Response: Route("/slack/events", endpoint=slack_events, methods=["POST"]), Route("/slack/install", endpoint=slack_install, methods=["GET"]), Route("/slack/oauth_redirect", endpoint=slack_oauth_redirect, methods=["GET"]), - Mount("/mcp", app=SlackSignatureMiddleware(mcp_starlette_app)), + Route( + "/mcp", + endpoint=SlackSignatureMiddleware(mcp_starlette_app), + methods=["POST"], + ), ], lifespan=lifespan, ) diff --git a/ai/slackbot-mcp-client/slack-identity/tests/test_app.py b/ai/slackbot-mcp-client/slack-identity/tests/test_app.py index 15ee44a..1fdd8c8 100644 --- a/ai/slackbot-mcp-client/slack-identity/tests/test_app.py +++ b/ai/slackbot-mcp-client/slack-identity/tests/test_app.py @@ -6,32 +6,15 @@ from unittest.mock import MagicMock, patch import pytest +from starlette.testclient import TestClient os.environ["SLACK_SIGNING_SECRET"] = "test_signing_secret" os.environ["SLACK_CLIENT_ID"] = "111.222" os.environ["SLACK_CLIENT_SECRET"] = "client_secret" -from starlette.testclient import TestClient # noqa: E402 - from src.app import app # noqa: E402 SIGNING_SECRET = "test_signing_secret" -MCP_PATH = "/mcp/mcp" - - -def sign_request(body: str, secret: str = SIGNING_SECRET) -> dict: - timestamp = str(int(time.time())) - sig_basestring = f"v0:{timestamp}:{body}" - signature = ( - "v0=" - + hmac.new(secret.encode(), sig_basestring.encode(), hashlib.sha256).hexdigest() - ) - return { - "x-slack-request-timestamp": timestamp, - "x-slack-signature": signature, - "content-type": "application/json", - "accept": "application/json", - } @pytest.fixture(scope="module") @@ -85,7 +68,7 @@ def test_get_profile_card(client): } MockWebClient.return_value = mock_client - resp = client.post(MCP_PATH, content=body, headers=headers) + resp = client.post("/mcp", content=body, headers=headers) assert resp.status_code == 200 data = resp.json() @@ -111,7 +94,7 @@ def test_missing_slack_context(client): } ) headers = sign_request(body) - resp = client.post(MCP_PATH, content=body, headers=headers) + resp = client.post("/mcp", content=body, headers=headers) assert resp.status_code == 200 data = resp.json() @@ -144,7 +127,7 @@ def test_missing_installation(client): "src.app.installation_store.find_installation", side_effect=Exception("Not found"), ): - resp = client.post(MCP_PATH, content=body, headers=headers) + resp = client.post("/mcp", content=body, headers=headers) assert resp.status_code == 200 data = resp.json() @@ -169,7 +152,25 @@ def test_rejects_unsigned(client): } ) resp = client.post( - MCP_PATH, content=body, headers={"content-type": "application/json"} + "/mcp", content=body, headers={"content-type": "application/json"} ) assert resp.status_code == 401 + + +# --- Helpers --- + + +def sign_request(body: str, secret: str = SIGNING_SECRET) -> dict: + timestamp = str(int(time.time())) + sig_basestring = f"v0:{timestamp}:{body}" + signature = ( + "v0=" + + hmac.new(secret.encode(), sig_basestring.encode(), hashlib.sha256).hexdigest() + ) + return { + "x-slack-request-timestamp": timestamp, + "x-slack-signature": signature, + "content-type": "application/json", + "accept": "application/json", + } From bebe344741a54726f3fe713d2186b7b926a01e87 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Tue, 16 Jun 2026 14:32:40 -0700 Subject: [PATCH 12/28] refactor: polish to mirror adjacent example --- .../no-auth/requirements.txt | 10 ++--- ai/slackbot-mcp-client/no-auth/src/app.py | 41 ++++++++++--------- .../no-auth/tests/test_app.py | 10 ++--- 3 files changed, 31 insertions(+), 30 deletions(-) diff --git a/ai/slackbot-mcp-client/no-auth/requirements.txt b/ai/slackbot-mcp-client/no-auth/requirements.txt index 7b5be95..b2833fc 100644 --- a/ai/slackbot-mcp-client/no-auth/requirements.txt +++ b/ai/slackbot-mcp-client/no-auth/requirements.txt @@ -1,10 +1,10 @@ +httpx2>=2.4.0 mcp>=1.27,<2 +mypy>=2.1.0 +pytest>=9.0.0 +ruff>=0.15.0 slack_bolt>=1.28.0 -slack_sdk>=3.42.0 slack_cli_hooks>=0.3.0 +slack_sdk>=3.42.0 starlette>=1.0.0 uvicorn>=0.49.0 -httpx>=0.28.0 -mypy>=2.1.0 -pytest>=9.0.0 -ruff>=0.15.0 diff --git a/ai/slackbot-mcp-client/no-auth/src/app.py b/ai/slackbot-mcp-client/no-auth/src/app.py index deeca4a..7c777e5 100644 --- a/ai/slackbot-mcp-client/no-auth/src/app.py +++ b/ai/slackbot-mcp-client/no-auth/src/app.py @@ -10,7 +10,7 @@ from slack_sdk.signature import SignatureVerifier from starlette.applications import Starlette from starlette.requests import Request -from starlette.responses import JSONResponse, Response +from starlette.responses import JSONResponse from starlette.routing import Route from starlette.types import ASGIApp, Receive, Scope, Send @@ -18,7 +18,10 @@ RESOURCE_URI = "ui://dice-roller/dice.html" RESOURCE_MIME_TYPE = "text/html;profile=mcp-app" -# --- MCP Server --- +"""Creates an MCP server with a dice roller tool and UI resource. + +https://github.com/modelcontextprotocol/python-sdk#getting-started +""" mcp_server = FastMCP("Dice Game", stateless_http=True, json_response=True) @@ -28,7 +31,11 @@ title="Roll Dice", description="Roll one or more dice with a configurable number of sides.", annotations=ToolAnnotations(readOnlyHint=True), - meta={"ui": {"resourceUri": RESOURCE_URI}}, + meta={ + "ui": { + "resourceUri": RESOURCE_URI, + }, + }, ) def roll_dice(sides: int = 6, count: int = 1) -> CallToolResult: rolls = [random.randint(1, sides) for _ in range(count)] @@ -66,23 +73,21 @@ def dice_resource() -> str: return DICE_HTML -# --- Bolt App --- +"""Creates a Bolt app with a custom /mcp route. + +https://docs.slack.dev/tools/bolt-python/getting-started +""" bolt_app = App( token=os.environ.get("SLACK_BOT_TOKEN"), signing_secret=os.environ.get("SLACK_SIGNING_SECRET"), ) -bolt_handler = SlackRequestHandler(bolt_app) - - -# --- Slack Signature Verification Middleware --- - class SlackSignatureMiddleware: def __init__(self, app: ASGIApp) -> None: self.app = app self.verifier = SignatureVerifier( - signing_secret=os.environ.get("SLACK_SIGNING_SECRET", "") + signing_secret=os.environ["SLACK_SIGNING_SECRET"] ) async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: @@ -111,27 +116,25 @@ async def replay_receive(): await self.app(scope, replay_receive, send) -# --- Starlette App --- - -mcp_starlette_app = mcp_server.streamable_http_app() - - @contextlib.asynccontextmanager async def lifespan(a): async with mcp_server.session_manager.run(): yield -async def slack_events(request: Request) -> Response: - return await bolt_handler.handle(request) +mcp_app = mcp_server.streamable_http_app() app = Starlette( routes=[ - Route("/slack/events", endpoint=slack_events, methods=["POST"]), + Route( + "/slack/events", + endpoint=SlackRequestHandler(bolt_app).handle, + methods=["POST"], + ), Route( "/mcp", - endpoint=SlackSignatureMiddleware(mcp_starlette_app), + endpoint=SlackSignatureMiddleware(mcp_app), methods=["POST"], ), ], diff --git a/ai/slackbot-mcp-client/no-auth/tests/test_app.py b/ai/slackbot-mcp-client/no-auth/tests/test_app.py index e96e03c..7d14c22 100644 --- a/ai/slackbot-mcp-client/no-auth/tests/test_app.py +++ b/ai/slackbot-mcp-client/no-auth/tests/test_app.py @@ -29,7 +29,7 @@ def client(): yield c -def test_roll_dice(client): +def test_returns_tool_call_results(client): """POST to /mcp with a valid signature and tools/call for roll_dice.""" body = json.dumps( { @@ -55,7 +55,7 @@ def test_roll_dice(client): assert structured["total"] == sum(structured["rolls"]) -def test_dice_resource(client): +def test_serves_ui_resources(client): """POST to /mcp with a valid signature and resources/read for the dice HTML.""" body = json.dumps( { @@ -74,12 +74,10 @@ def test_dice_resource(client): assert data["id"] == 1 contents = data["result"]["contents"] assert len(contents) >= 1 - # The resource should contain the Dice Roller HTML - text = contents[0]["text"] - assert "Dice Roller" in text + assert "Dice Roller" in contents[0]["text"] -def test_rejects_unsigned(client): +def test_rejects_unsigned_requests(client): """POST to /mcp without Slack signature headers returns 401.""" body = json.dumps( { From b2c2b8bf4f07fa8442ab5c377c1c451a117de68e Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Tue, 16 Jun 2026 14:35:03 -0700 Subject: [PATCH 13/28] refactor: clean up app structure, tests, and dependencies - Add docstrings mirroring JS examples, remove section comments - Inline bolt_handler, rename tests to match JS equivalents - Replace httpx with httpx2, sort requirements alphabetically - Use os.environ[] instead of fallback empty string for signing secret Co-Authored-By: Claude --- ai/slackbot-mcp-client/no-auth/src/app.py | 1 + .../slack-identity/requirements.txt | 10 ++--- .../slack-identity/src/app.py | 44 +++++++------------ .../slack-identity/tests/test_app.py | 28 ++---------- 4 files changed, 24 insertions(+), 59 deletions(-) diff --git a/ai/slackbot-mcp-client/no-auth/src/app.py b/ai/slackbot-mcp-client/no-auth/src/app.py index 7c777e5..2436b38 100644 --- a/ai/slackbot-mcp-client/no-auth/src/app.py +++ b/ai/slackbot-mcp-client/no-auth/src/app.py @@ -83,6 +83,7 @@ def dice_resource() -> str: signing_secret=os.environ.get("SLACK_SIGNING_SECRET"), ) + class SlackSignatureMiddleware: def __init__(self, app: ASGIApp) -> None: self.app = app diff --git a/ai/slackbot-mcp-client/slack-identity/requirements.txt b/ai/slackbot-mcp-client/slack-identity/requirements.txt index 7b5be95..b2833fc 100644 --- a/ai/slackbot-mcp-client/slack-identity/requirements.txt +++ b/ai/slackbot-mcp-client/slack-identity/requirements.txt @@ -1,10 +1,10 @@ +httpx2>=2.4.0 mcp>=1.27,<2 +mypy>=2.1.0 +pytest>=9.0.0 +ruff>=0.15.0 slack_bolt>=1.28.0 -slack_sdk>=3.42.0 slack_cli_hooks>=0.3.0 +slack_sdk>=3.42.0 starlette>=1.0.0 uvicorn>=0.49.0 -httpx>=0.28.0 -mypy>=2.1.0 -pytest>=9.0.0 -ruff>=0.15.0 diff --git a/ai/slackbot-mcp-client/slack-identity/src/app.py b/ai/slackbot-mcp-client/slack-identity/src/app.py index efca00a..35d8de6 100644 --- a/ai/slackbot-mcp-client/slack-identity/src/app.py +++ b/ai/slackbot-mcp-client/slack-identity/src/app.py @@ -13,15 +13,16 @@ from slack_sdk.web import WebClient from starlette.applications import Starlette from starlette.requests import Request -from starlette.responses import JSONResponse, Response +from starlette.responses import JSONResponse from starlette.routing import Route from starlette.types import ASGIApp, Receive, Scope, Send -# --- Installation Store --- - installation_store = FileInstallationStore(base_dir="./data/installations") -# --- MCP Server --- +"""Creates an MCP server with a profile card tool using Slack identity. + +https://github.com/modelcontextprotocol/python-sdk#getting-started +""" mcp_server = FastMCP("Profile Card", stateless_http=True, json_response=True) @@ -150,7 +151,10 @@ async def get_profile_card( ) -# --- Bolt App with OAuth --- +"""Creates a Bolt app with OAuth and a custom /mcp route. + +https://docs.slack.dev/tools/bolt-python/getting-started +""" bolt_app = App( signing_secret=os.environ.get("SLACK_SIGNING_SECRET"), @@ -166,15 +170,11 @@ async def get_profile_card( ) bolt_handler = SlackRequestHandler(bolt_app) - -# --- Slack Signature Verification Middleware --- - - class SlackSignatureMiddleware: def __init__(self, app: ASGIApp) -> None: self.app = app self.verifier = SignatureVerifier( - signing_secret=os.environ.get("SLACK_SIGNING_SECRET", "") + signing_secret=os.environ["SLACK_SIGNING_SECRET"] ) async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: @@ -203,37 +203,23 @@ async def replay_receive(): await self.app(scope, replay_receive, send) -# --- Starlette App --- - -mcp_starlette_app = mcp_server.streamable_http_app() - - @contextlib.asynccontextmanager async def lifespan(a): async with mcp_server.session_manager.run(): yield -async def slack_events(request: Request) -> Response: - return await bolt_handler.handle(request) - - -async def slack_install(request: Request) -> Response: - return await bolt_handler.handle(request) - - -async def slack_oauth_redirect(request: Request) -> Response: - return await bolt_handler.handle(request) +mcp_app = mcp_server.streamable_http_app() app = Starlette( routes=[ - Route("/slack/events", endpoint=slack_events, methods=["POST"]), - Route("/slack/install", endpoint=slack_install, methods=["GET"]), - Route("/slack/oauth_redirect", endpoint=slack_oauth_redirect, methods=["GET"]), + Route("/slack/events", endpoint=bolt_handler.handle, methods=["POST"]), + Route("/slack/install", endpoint=bolt_handler.handle, methods=["GET"]), + Route("/slack/oauth_redirect", endpoint=bolt_handler.handle, methods=["GET"]), Route( "/mcp", - endpoint=SlackSignatureMiddleware(mcp_starlette_app), + endpoint=SlackSignatureMiddleware(mcp_app), methods=["POST"], ), ], diff --git a/ai/slackbot-mcp-client/slack-identity/tests/test_app.py b/ai/slackbot-mcp-client/slack-identity/tests/test_app.py index 1fdd8c8..8251219 100644 --- a/ai/slackbot-mcp-client/slack-identity/tests/test_app.py +++ b/ai/slackbot-mcp-client/slack-identity/tests/test_app.py @@ -23,7 +23,7 @@ def client(): yield c -def test_get_profile_card(client): +def test_returns_tool_call_response(client): """Successful profile card fetch with mocked installation and users.info.""" mock_installation = MagicMock() mock_installation.bot_token = "xoxb-fake-token" @@ -80,29 +80,7 @@ def test_get_profile_card(client): assert blocks[0]["title"]["text"] == "Test User" -def test_missing_slack_context(client): - """Request without _meta.slack returns missing context error.""" - body = json.dumps( - { - "jsonrpc": "2.0", - "id": 1, - "method": "tools/call", - "params": { - "name": "get_profile_card", - "arguments": {"user_id": "U12345"}, - }, - } - ) - headers = sign_request(body) - resp = client.post("/mcp", content=body, headers=headers) - - assert resp.status_code == 200 - data = resp.json() - result = data["result"] - assert "Missing Slack identity" in result["content"][0]["text"] - - -def test_missing_installation(client): +def test_requires_team_installation(client): """When no installation found, returns install button block.""" body = json.dumps( { @@ -138,7 +116,7 @@ def test_missing_installation(client): assert blocks[0]["accessory"]["type"] == "button" -def test_rejects_unsigned(client): +def test_rejects_unsigned_requests(client): """POST without Slack signature headers returns 401.""" body = json.dumps( { From a26e6587a966264f7759380d4b17de378130f54d Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Tue, 16 Jun 2026 14:38:40 -0700 Subject: [PATCH 14/28] style: add missing blank line before class definition Co-Authored-By: Claude --- ai/slackbot-mcp-client/slack-identity/src/app.py | 1 + 1 file changed, 1 insertion(+) diff --git a/ai/slackbot-mcp-client/slack-identity/src/app.py b/ai/slackbot-mcp-client/slack-identity/src/app.py index 35d8de6..4bdfbc9 100644 --- a/ai/slackbot-mcp-client/slack-identity/src/app.py +++ b/ai/slackbot-mcp-client/slack-identity/src/app.py @@ -170,6 +170,7 @@ async def get_profile_card( ) bolt_handler = SlackRequestHandler(bolt_app) + class SlackSignatureMiddleware: def __init__(self, app: ASGIApp) -> None: self.app = app From a6cac924aba9b5ab8dcabbfdbb9c1a4a4d60b677 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Tue, 16 Jun 2026 14:44:17 -0700 Subject: [PATCH 15/28] fix: correct MCP SDK quickstart link in docstrings Co-Authored-By: Claude --- ai/slackbot-mcp-client/no-auth/src/app.py | 2 +- ai/slackbot-mcp-client/slack-identity/src/app.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ai/slackbot-mcp-client/no-auth/src/app.py b/ai/slackbot-mcp-client/no-auth/src/app.py index 2436b38..9a8c2e1 100644 --- a/ai/slackbot-mcp-client/no-auth/src/app.py +++ b/ai/slackbot-mcp-client/no-auth/src/app.py @@ -20,7 +20,7 @@ """Creates an MCP server with a dice roller tool and UI resource. -https://github.com/modelcontextprotocol/python-sdk#getting-started +https://github.com/modelcontextprotocol/python-sdk#quickstart """ mcp_server = FastMCP("Dice Game", stateless_http=True, json_response=True) diff --git a/ai/slackbot-mcp-client/slack-identity/src/app.py b/ai/slackbot-mcp-client/slack-identity/src/app.py index 4bdfbc9..4535ef3 100644 --- a/ai/slackbot-mcp-client/slack-identity/src/app.py +++ b/ai/slackbot-mcp-client/slack-identity/src/app.py @@ -21,7 +21,7 @@ """Creates an MCP server with a profile card tool using Slack identity. -https://github.com/modelcontextprotocol/python-sdk#getting-started +https://github.com/modelcontextprotocol/python-sdk#quickstart """ mcp_server = FastMCP("Profile Card", stateless_http=True, json_response=True) From 6c45bf5bfcfb74c0fd8a3416376fc914d947b73e Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Tue, 16 Jun 2026 14:53:43 -0700 Subject: [PATCH 16/28] style: remove docstrings and section comments from tests Co-Authored-By: Claude --- ai/slackbot-mcp-client/no-auth/tests/test_app.py | 8 -------- ai/slackbot-mcp-client/slack-identity/tests/test_app.py | 6 ------ 2 files changed, 14 deletions(-) diff --git a/ai/slackbot-mcp-client/no-auth/tests/test_app.py b/ai/slackbot-mcp-client/no-auth/tests/test_app.py index 7d14c22..414a3c8 100644 --- a/ai/slackbot-mcp-client/no-auth/tests/test_app.py +++ b/ai/slackbot-mcp-client/no-auth/tests/test_app.py @@ -24,13 +24,11 @@ @pytest.fixture(scope="module") def client(): - """Create a TestClient that triggers the app lifespan (starts session manager).""" with TestClient(app, base_url="http://localhost:8000") as c: yield c def test_returns_tool_call_results(client): - """POST to /mcp with a valid signature and tools/call for roll_dice.""" body = json.dumps( { "jsonrpc": "2.0", @@ -56,7 +54,6 @@ def test_returns_tool_call_results(client): def test_serves_ui_resources(client): - """POST to /mcp with a valid signature and resources/read for the dice HTML.""" body = json.dumps( { "jsonrpc": "2.0", @@ -78,7 +75,6 @@ def test_serves_ui_resources(client): def test_rejects_unsigned_requests(client): - """POST to /mcp without Slack signature headers returns 401.""" body = json.dumps( { "jsonrpc": "2.0", @@ -94,11 +90,7 @@ def test_rejects_unsigned_requests(client): assert resp.status_code == 401 -# --- Helpers --- - - def sign_request(body: str, secret: str = SIGNING_SECRET) -> dict: - """Generate valid Slack signature headers for the given request body.""" timestamp = str(int(time.time())) sig_basestring = f"v0:{timestamp}:{body}" signature = ( diff --git a/ai/slackbot-mcp-client/slack-identity/tests/test_app.py b/ai/slackbot-mcp-client/slack-identity/tests/test_app.py index 8251219..6b5cbe9 100644 --- a/ai/slackbot-mcp-client/slack-identity/tests/test_app.py +++ b/ai/slackbot-mcp-client/slack-identity/tests/test_app.py @@ -24,7 +24,6 @@ def client(): def test_returns_tool_call_response(client): - """Successful profile card fetch with mocked installation and users.info.""" mock_installation = MagicMock() mock_installation.bot_token = "xoxb-fake-token" @@ -81,7 +80,6 @@ def test_returns_tool_call_response(client): def test_requires_team_installation(client): - """When no installation found, returns install button block.""" body = json.dumps( { "jsonrpc": "2.0", @@ -117,7 +115,6 @@ def test_requires_team_installation(client): def test_rejects_unsigned_requests(client): - """POST without Slack signature headers returns 401.""" body = json.dumps( { "jsonrpc": "2.0", @@ -136,9 +133,6 @@ def test_rejects_unsigned_requests(client): assert resp.status_code == 401 -# --- Helpers --- - - def sign_request(body: str, secret: str = SIGNING_SECRET) -> dict: timestamp = str(int(time.time())) sig_basestring = f"v0:{timestamp}:{body}" From f23714643ee5d9505435b025db59190af6df326e Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Tue, 16 Jun 2026 15:03:45 -0700 Subject: [PATCH 17/28] fix: pin exact dependency versions to match repo convention Co-Authored-By: Claude --- .../no-auth/requirements.txt | 20 +++++++++---------- .../slack-identity/requirements.txt | 20 +++++++++---------- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/ai/slackbot-mcp-client/no-auth/requirements.txt b/ai/slackbot-mcp-client/no-auth/requirements.txt index b2833fc..c26e03f 100644 --- a/ai/slackbot-mcp-client/no-auth/requirements.txt +++ b/ai/slackbot-mcp-client/no-auth/requirements.txt @@ -1,10 +1,10 @@ -httpx2>=2.4.0 -mcp>=1.27,<2 -mypy>=2.1.0 -pytest>=9.0.0 -ruff>=0.15.0 -slack_bolt>=1.28.0 -slack_cli_hooks>=0.3.0 -slack_sdk>=3.42.0 -starlette>=1.0.0 -uvicorn>=0.49.0 +httpx2==2.4.0 +mcp==1.27.2 +mypy==2.1.0 +pytest==9.1.0 +ruff==0.15.17 +slack_bolt==1.28.0 +slack_cli_hooks==0.3.0 +slack_sdk==3.42.0 +starlette==1.3.1 +uvicorn==0.49.0 diff --git a/ai/slackbot-mcp-client/slack-identity/requirements.txt b/ai/slackbot-mcp-client/slack-identity/requirements.txt index b2833fc..c26e03f 100644 --- a/ai/slackbot-mcp-client/slack-identity/requirements.txt +++ b/ai/slackbot-mcp-client/slack-identity/requirements.txt @@ -1,10 +1,10 @@ -httpx2>=2.4.0 -mcp>=1.27,<2 -mypy>=2.1.0 -pytest>=9.0.0 -ruff>=0.15.0 -slack_bolt>=1.28.0 -slack_cli_hooks>=0.3.0 -slack_sdk>=3.42.0 -starlette>=1.0.0 -uvicorn>=0.49.0 +httpx2==2.4.0 +mcp==1.27.2 +mypy==2.1.0 +pytest==9.1.0 +ruff==0.15.17 +slack_bolt==1.28.0 +slack_cli_hooks==0.3.0 +slack_sdk==3.42.0 +starlette==1.3.1 +uvicorn==0.49.0 From c5a97ae2ac464d58dabe6d7dcd85db9537f4a7e6 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Tue, 16 Jun 2026 16:07:27 -0700 Subject: [PATCH 18/28] test: mirror JS test request IDs and resource assertions Co-Authored-By: Claude --- ai/slackbot-mcp-client/no-auth/tests/test_app.py | 11 ++++++----- .../slack-identity/tests/test_app.py | 4 ++-- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/ai/slackbot-mcp-client/no-auth/tests/test_app.py b/ai/slackbot-mcp-client/no-auth/tests/test_app.py index 414a3c8..aa50832 100644 --- a/ai/slackbot-mcp-client/no-auth/tests/test_app.py +++ b/ai/slackbot-mcp-client/no-auth/tests/test_app.py @@ -32,7 +32,7 @@ def test_returns_tool_call_results(client): body = json.dumps( { "jsonrpc": "2.0", - "id": 1, + "id": 2, "method": "tools/call", "params": {"name": "roll_dice", "arguments": {"sides": 6, "count": 2}}, } @@ -43,7 +43,7 @@ def test_returns_tool_call_results(client): assert resp.status_code == 200 data = resp.json() assert data["jsonrpc"] == "2.0" - assert data["id"] == 1 + assert data["id"] == 2 result = data["result"] structured = result["structuredContent"] assert structured["sides"] == 6 @@ -57,7 +57,7 @@ def test_serves_ui_resources(client): body = json.dumps( { "jsonrpc": "2.0", - "id": 1, + "id": 3, "method": "resources/read", "params": {"uri": "ui://dice-roller/dice.html"}, } @@ -68,9 +68,10 @@ def test_serves_ui_resources(client): assert resp.status_code == 200 data = resp.json() assert data["jsonrpc"] == "2.0" - assert data["id"] == 1 + assert data["id"] == 3 contents = data["result"]["contents"] - assert len(contents) >= 1 + assert contents[0]["uri"] == "ui://dice-roller/dice.html" + assert contents[0]["mimeType"] == "text/html;profile=mcp-app" assert "Dice Roller" in contents[0]["text"] diff --git a/ai/slackbot-mcp-client/slack-identity/tests/test_app.py b/ai/slackbot-mcp-client/slack-identity/tests/test_app.py index 6b5cbe9..c0b3386 100644 --- a/ai/slackbot-mcp-client/slack-identity/tests/test_app.py +++ b/ai/slackbot-mcp-client/slack-identity/tests/test_app.py @@ -30,7 +30,7 @@ def test_returns_tool_call_response(client): body = json.dumps( { "jsonrpc": "2.0", - "id": 1, + "id": 2, "method": "tools/call", "params": { "name": "get_profile_card", @@ -83,7 +83,7 @@ def test_requires_team_installation(client): body = json.dumps( { "jsonrpc": "2.0", - "id": 1, + "id": 3, "method": "tools/call", "params": { "name": "get_profile_card", From 3e3eb76fbecf3e10c6dbaee08eff2a2fd424fff6 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Tue, 16 Jun 2026 16:10:19 -0700 Subject: [PATCH 19/28] test: match JS Accept header in signed requests Co-Authored-By: Claude --- ai/slackbot-mcp-client/no-auth/tests/test_app.py | 2 +- ai/slackbot-mcp-client/slack-identity/tests/test_app.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ai/slackbot-mcp-client/no-auth/tests/test_app.py b/ai/slackbot-mcp-client/no-auth/tests/test_app.py index aa50832..ad0e4f7 100644 --- a/ai/slackbot-mcp-client/no-auth/tests/test_app.py +++ b/ai/slackbot-mcp-client/no-auth/tests/test_app.py @@ -102,5 +102,5 @@ def sign_request(body: str, secret: str = SIGNING_SECRET) -> dict: "x-slack-request-timestamp": timestamp, "x-slack-signature": signature, "content-type": "application/json", - "accept": "application/json", + "accept": "application/json, text/event-stream", } diff --git a/ai/slackbot-mcp-client/slack-identity/tests/test_app.py b/ai/slackbot-mcp-client/slack-identity/tests/test_app.py index c0b3386..5cbdcc0 100644 --- a/ai/slackbot-mcp-client/slack-identity/tests/test_app.py +++ b/ai/slackbot-mcp-client/slack-identity/tests/test_app.py @@ -144,5 +144,5 @@ def sign_request(body: str, secret: str = SIGNING_SECRET) -> dict: "x-slack-request-timestamp": timestamp, "x-slack-signature": signature, "content-type": "application/json", - "accept": "application/json", + "accept": "application/json, text/event-stream", } From e7e14484c30b191def10524b9b8c4c7d1b3b180d Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Tue, 16 Jun 2026 16:13:45 -0700 Subject: [PATCH 20/28] refactor: mirror JS sign_request return shape in tests Co-Authored-By: Claude --- .../no-auth/tests/test_app.py | 33 +++++++++++++------ .../slack-identity/tests/test_app.py | 23 ++++++++----- 2 files changed, 38 insertions(+), 18 deletions(-) diff --git a/ai/slackbot-mcp-client/no-auth/tests/test_app.py b/ai/slackbot-mcp-client/no-auth/tests/test_app.py index ad0e4f7..bf67ba0 100644 --- a/ai/slackbot-mcp-client/no-auth/tests/test_app.py +++ b/ai/slackbot-mcp-client/no-auth/tests/test_app.py @@ -37,8 +37,17 @@ def test_returns_tool_call_results(client): "params": {"name": "roll_dice", "arguments": {"sides": 6, "count": 2}}, } ) - headers = sign_request(body) - resp = client.post("/mcp", content=body, headers=headers) + sig = sign_request(body) + resp = client.post( + "/mcp", + content=body, + headers={ + "content-type": "application/json", + "accept": "application/json, text/event-stream", + "x-slack-request-timestamp": sig["timestamp"], + "x-slack-signature": sig["signature"], + }, + ) assert resp.status_code == 200 data = resp.json() @@ -62,8 +71,17 @@ def test_serves_ui_resources(client): "params": {"uri": "ui://dice-roller/dice.html"}, } ) - headers = sign_request(body) - resp = client.post("/mcp", content=body, headers=headers) + sig = sign_request(body) + resp = client.post( + "/mcp", + content=body, + headers={ + "content-type": "application/json", + "accept": "application/json, text/event-stream", + "x-slack-request-timestamp": sig["timestamp"], + "x-slack-signature": sig["signature"], + }, + ) assert resp.status_code == 200 data = resp.json() @@ -98,9 +116,4 @@ def sign_request(body: str, secret: str = SIGNING_SECRET) -> dict: "v0=" + hmac.new(secret.encode(), sig_basestring.encode(), hashlib.sha256).hexdigest() ) - return { - "x-slack-request-timestamp": timestamp, - "x-slack-signature": signature, - "content-type": "application/json", - "accept": "application/json, text/event-stream", - } + return {"timestamp": timestamp, "signature": signature} diff --git a/ai/slackbot-mcp-client/slack-identity/tests/test_app.py b/ai/slackbot-mcp-client/slack-identity/tests/test_app.py index 5cbdcc0..6ff8a6a 100644 --- a/ai/slackbot-mcp-client/slack-identity/tests/test_app.py +++ b/ai/slackbot-mcp-client/slack-identity/tests/test_app.py @@ -44,7 +44,13 @@ def test_returns_tool_call_response(client): }, } ) - headers = sign_request(body) + sig = sign_request(body) + headers = { + "content-type": "application/json", + "accept": "application/json, text/event-stream", + "x-slack-request-timestamp": sig["timestamp"], + "x-slack-signature": sig["signature"], + } with ( patch( @@ -97,7 +103,13 @@ def test_requires_team_installation(client): }, } ) - headers = sign_request(body) + sig = sign_request(body) + headers = { + "content-type": "application/json", + "accept": "application/json, text/event-stream", + "x-slack-request-timestamp": sig["timestamp"], + "x-slack-signature": sig["signature"], + } with patch( "src.app.installation_store.find_installation", @@ -140,9 +152,4 @@ def sign_request(body: str, secret: str = SIGNING_SECRET) -> dict: "v0=" + hmac.new(secret.encode(), sig_basestring.encode(), hashlib.sha256).hexdigest() ) - return { - "x-slack-request-timestamp": timestamp, - "x-slack-signature": signature, - "content-type": "application/json", - "accept": "application/json, text/event-stream", - } + return {"timestamp": timestamp, "signature": signature} From e73d79710d20533a67b48a20523428522a2b140d Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Tue, 16 Jun 2026 16:33:30 -0700 Subject: [PATCH 21/28] docs: clarify ngrok and manifest setup comments Co-Authored-By: Claude --- ai/slackbot-mcp-client/no-auth/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ai/slackbot-mcp-client/no-auth/README.md b/ai/slackbot-mcp-client/no-auth/README.md index be49cae..2202cc3 100644 --- a/ai/slackbot-mcp-client/no-auth/README.md +++ b/ai/slackbot-mcp-client/no-auth/README.md @@ -5,8 +5,8 @@ Run an unauthenticated MCP server for the Slackbot MCP client that responds with ## Setup ```sh -$ ngrok http 3000 --host-header=rewrite # Rewrite Host to localhost so the MCP server's DNS-rebinding protection accepts the request; update manifest with these values -$ slack manifest # Review values +$ ngrok http 3000 --host-header=rewrite # Update manifest with new URL +$ slack manifest # Review saved values $ slack install --environment local # Create a new app $ slack app settings # Gather signing secret $ slack env set SLACK_SIGNING_SECRET From c1a0e3d75a04404825411d5f3dec66e6bcf09a66 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Tue, 16 Jun 2026 16:53:07 -0700 Subject: [PATCH 22/28] test: match JS auth.test mock response Co-Authored-By: Claude --- ai/slackbot-mcp-client/no-auth/tests/test_app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ai/slackbot-mcp-client/no-auth/tests/test_app.py b/ai/slackbot-mcp-client/no-auth/tests/test_app.py index bf67ba0..7210e8e 100644 --- a/ai/slackbot-mcp-client/no-auth/tests/test_app.py +++ b/ai/slackbot-mcp-client/no-auth/tests/test_app.py @@ -13,7 +13,7 @@ _mock_auth = patch( "slack_sdk.web.client.WebClient.auth_test", - return_value={"ok": True, "bot_id": "B123", "user_id": "U123", "team_id": "T123"}, + return_value={"ok": True, "bot_id": "B0101", "user_id": "U0123"}, ) _mock_auth.start() From 2a944f156a5e48927a9021184111a8507c5ef2d7 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Tue, 16 Jun 2026 16:55:33 -0700 Subject: [PATCH 23/28] test: align slack-identity mock data and assertions with JS Co-Authored-By: Claude --- .../slack-identity/tests/test_app.py | 32 +++++++++++-------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/ai/slackbot-mcp-client/slack-identity/tests/test_app.py b/ai/slackbot-mcp-client/slack-identity/tests/test_app.py index 6ff8a6a..973a3f4 100644 --- a/ai/slackbot-mcp-client/slack-identity/tests/test_app.py +++ b/ai/slackbot-mcp-client/slack-identity/tests/test_app.py @@ -15,6 +15,9 @@ from src.app import app # noqa: E402 SIGNING_SECRET = "test_signing_secret" +TEAM_ID = "T0001" +USER_ID = "U0001" +BOT_TOKEN = "xoxb-test-bot-token" @pytest.fixture(scope="module") @@ -25,7 +28,7 @@ def client(): def test_returns_tool_call_response(client): mock_installation = MagicMock() - mock_installation.bot_token = "xoxb-fake-token" + mock_installation.bot_token = BOT_TOKEN body = json.dumps( { @@ -34,11 +37,11 @@ def test_returns_tool_call_response(client): "method": "tools/call", "params": { "name": "get_profile_card", - "arguments": {"user_id": "U12345"}, + "arguments": {"user_id": USER_ID}, "_meta": { "slack": { - "user_id": "U99999", - "team_id": "T11111", + "user_id": USER_ID, + "team_id": TEAM_ID, } }, }, @@ -63,12 +66,13 @@ def test_returns_tool_call_response(client): mock_client.users_info.return_value = { "ok": True, "user": { + "id": USER_ID, "profile": { "real_name": "Test User", - "title": "Engineer", + "title": "VIP", "email": "test@example.com", - "image_72": "https://example.com/avatar.png", - } + "image_72": "https://avatars.slack-edge.com/2026-01-01/123456_abc123def456_72.jpg", + }, }, } MockWebClient.return_value = mock_client @@ -79,10 +83,12 @@ def test_returns_tool_call_response(client): data = resp.json() result = data["result"] assert "Test User" in result["content"][0]["text"] - assert "Engineer" in result["content"][0]["text"] + assert "VIP" in result["content"][0]["text"] + assert "test@example.com" in result["content"][0]["text"] blocks = result["_meta"]["slack"]["blocks"] assert blocks[0]["type"] == "card" assert blocks[0]["title"]["text"] == "Test User" + assert blocks[0]["subtitle"]["text"] == "VIP" def test_requires_team_installation(client): @@ -93,11 +99,11 @@ def test_requires_team_installation(client): "method": "tools/call", "params": { "name": "get_profile_card", - "arguments": {"user_id": "U12345"}, + "arguments": {"user_id": "U9999"}, "_meta": { "slack": { - "user_id": "U99999", - "team_id": "T11111", + "user_id": "U9999", + "team_id": "T9999", } }, }, @@ -123,7 +129,7 @@ def test_requires_team_installation(client): assert "not installed" in result["content"][0]["text"].lower() blocks = result["_meta"]["slack"]["blocks"] assert blocks[0]["type"] == "section" - assert blocks[0]["accessory"]["type"] == "button" + assert "/slack/install" in blocks[0]["accessory"]["url"] def test_rejects_unsigned_requests(client): @@ -134,7 +140,7 @@ def test_rejects_unsigned_requests(client): "method": "tools/call", "params": { "name": "get_profile_card", - "arguments": {"user_id": "U12345"}, + "arguments": {"user_id": "U999"}, }, } ) From 4832f255aff2c1dd87bac8da4d12896dcc2cf3c4 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Tue, 16 Jun 2026 17:50:32 -0700 Subject: [PATCH 24/28] refactor: simplify slack-identity app and align storage dirs with JS Co-Authored-By: Claude --- .../slack-identity/.gitignore | 1 + .../slack-identity/README.md | 2 +- .../slack-identity/src/app.py | 19 ++++++++++++------- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/ai/slackbot-mcp-client/slack-identity/.gitignore b/ai/slackbot-mcp-client/slack-identity/.gitignore index 982d9c3..0efc176 100644 --- a/ai/slackbot-mcp-client/slack-identity/.gitignore +++ b/ai/slackbot-mcp-client/slack-identity/.gitignore @@ -1,5 +1,6 @@ __pycache__ .venv installations/ +states/ !.env.example .env* diff --git a/ai/slackbot-mcp-client/slack-identity/README.md b/ai/slackbot-mcp-client/slack-identity/README.md index fe7c64d..a022f58 100644 --- a/ai/slackbot-mcp-client/slack-identity/README.md +++ b/ai/slackbot-mcp-client/slack-identity/README.md @@ -5,7 +5,7 @@ Run an MCP server for the Slackbot MCP client that responds with Block Kit and a ## Setup ```sh -$ ngrok http 3000 --host-header=rewrite # Rewrite Host to localhost so the MCP server's DNS-rebinding protection accepts the request +$ ngrok http 3000 --host-header=rewrite $ slack install --app local # Create a new app $ slack app settings $ slack env init # Update defaults diff --git a/ai/slackbot-mcp-client/slack-identity/src/app.py b/ai/slackbot-mcp-client/slack-identity/src/app.py index 4535ef3..b67a4aa 100644 --- a/ai/slackbot-mcp-client/slack-identity/src/app.py +++ b/ai/slackbot-mcp-client/slack-identity/src/app.py @@ -17,8 +17,6 @@ from starlette.routing import Route from starlette.types import ASGIApp, Receive, Scope, Send -installation_store = FileInstallationStore(base_dir="./data/installations") - """Creates an MCP server with a profile card tool using Slack identity. https://github.com/modelcontextprotocol/python-sdk#quickstart @@ -32,14 +30,18 @@ title="Get Profile Card", description="Get a profile card for a Slack user by their user ID.", annotations=ToolAnnotations(readOnlyHint=True), - meta={"slack": {"supportsBlockKit": True}}, + meta={ + "slack": { + "supportsBlockKit": True, + }, + }, ) async def get_profile_card( - user_id: str, ctx: Context[ServerSession, None] + user_id: str, + ctx: Context[ServerSession, None], ) -> CallToolResult: meta = ctx.request_context.meta - model_extra = meta.model_extra if meta else None - slack = model_extra.get("slack", {}) if model_extra else {} + slack = (meta.model_extra or {}).get("slack", {}) if meta else {} if not slack.get("user_id") or not slack.get("team_id"): return CallToolResult( @@ -156,6 +158,8 @@ async def get_profile_card( https://docs.slack.dev/tools/bolt-python/getting-started """ +installation_store = FileInstallationStore(base_dir="./installations") + bolt_app = App( signing_secret=os.environ.get("SLACK_SIGNING_SECRET"), oauth_settings=OAuthSettings( @@ -164,7 +168,8 @@ async def get_profile_card( scopes=["mcp:connect", "users:read", "users:read.email"], installation_store=installation_store, state_store=FileOAuthStateStore( - expiration_seconds=600, base_dir="./data/states" + expiration_seconds=600, + base_dir="./states", ), ), ) From 2b14b57146723dd381aa709ee1bcd4de6db72ed1 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Tue, 16 Jun 2026 21:58:29 -0700 Subject: [PATCH 25/28] style: keep missing-context message on a single line Co-Authored-By: Claude --- ai/slackbot-mcp-client/slack-identity/src/app.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ai/slackbot-mcp-client/slack-identity/src/app.py b/ai/slackbot-mcp-client/slack-identity/src/app.py index b67a4aa..eb8f63b 100644 --- a/ai/slackbot-mcp-client/slack-identity/src/app.py +++ b/ai/slackbot-mcp-client/slack-identity/src/app.py @@ -48,8 +48,7 @@ async def get_profile_card( content=[ TextContent( type="text", - text="Missing Slack identity context. " - "This tool must be called from Slack.", + text="Missing Slack identity context. This tool must be called from Slack.", ) ], ) From 5cb407142f6623860d37c0ab6be589ea3e7ca841 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Wed, 17 Jun 2026 10:52:39 -0700 Subject: [PATCH 26/28] refactor: expect missing auth with inline request handler --- .../slack-identity/src/app.py | 47 ++++++++++--------- .../slack-identity/tests/test_app.py | 2 +- 2 files changed, 27 insertions(+), 22 deletions(-) diff --git a/ai/slackbot-mcp-client/slack-identity/src/app.py b/ai/slackbot-mcp-client/slack-identity/src/app.py index eb8f63b..d6bef31 100644 --- a/ai/slackbot-mcp-client/slack-identity/src/app.py +++ b/ai/slackbot-mcp-client/slack-identity/src/app.py @@ -53,21 +53,13 @@ async def get_profile_card( ], ) - team_id = slack["team_id"] - slack_user_id = slack["user_id"] - enterprise_id = slack.get("enterprise_id") - - try: - installation = installation_store.find_installation( - enterprise_id=enterprise_id, - team_id=team_id, - user_id=slack_user_id, - is_enterprise_install=bool(enterprise_id), - ) - if not installation or not installation.bot_token: - raise ValueError("No bot token") - bot_token = installation.bot_token - except Exception: + installation = installation_store.find_installation( + enterprise_id=slack.get("enterprise_id"), + team_id=slack["team_id"], + user_id=slack["user_id"], + is_enterprise_install=bool(slack.get("enterprise_id")), + ) + if not installation or not installation.bot_token: return CallToolResult( content=[ TextContent( @@ -101,9 +93,11 @@ async def get_profile_card( ) try: - client = WebClient(token=bot_token) + client = WebClient(token=installation.bot_token) result = client.users_info(user=user_id) - profile = result["user"]["profile"] + profile = (result["user"] or {}).get("profile") + if not profile: + raise ValueError("No profile found") except Exception: return CallToolResult( content=[ @@ -172,7 +166,6 @@ async def get_profile_card( ), ), ) -bolt_handler = SlackRequestHandler(bolt_app) class SlackSignatureMiddleware: @@ -219,9 +212,21 @@ async def lifespan(a): app = Starlette( routes=[ - Route("/slack/events", endpoint=bolt_handler.handle, methods=["POST"]), - Route("/slack/install", endpoint=bolt_handler.handle, methods=["GET"]), - Route("/slack/oauth_redirect", endpoint=bolt_handler.handle, methods=["GET"]), + Route( + "/slack/events", + endpoint=SlackRequestHandler(bolt_app).handle, + methods=["POST"], + ), + Route( + "/slack/install", + endpoint=SlackRequestHandler(bolt_app).handle, + methods=["GET"], + ), + Route( + "/slack/oauth_redirect", + endpoint=SlackRequestHandler(bolt_app).handle, + methods=["GET"], + ), Route( "/mcp", endpoint=SlackSignatureMiddleware(mcp_app), diff --git a/ai/slackbot-mcp-client/slack-identity/tests/test_app.py b/ai/slackbot-mcp-client/slack-identity/tests/test_app.py index 973a3f4..9ccd3d9 100644 --- a/ai/slackbot-mcp-client/slack-identity/tests/test_app.py +++ b/ai/slackbot-mcp-client/slack-identity/tests/test_app.py @@ -119,7 +119,7 @@ def test_requires_team_installation(client): with patch( "src.app.installation_store.find_installation", - side_effect=Exception("Not found"), + return_value=None, ): resp = client.post("/mcp", content=body, headers=headers) From 4b8580e1f022f1f0186c663249497890bbd1e086 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Wed, 17 Jun 2026 23:16:53 -0700 Subject: [PATCH 27/28] feat: split rich responses into dedicated examples Reorganize the Slackbot MCP client examples so authentication methods and rich responses are separate concerns: - Add rich-responses/mcp-apps (dice roller with interactive UI, no_auth) - Strip the interactive UI from no-auth so it demonstrates auth only - Strip Block Kit from slack-identity so it demonstrates auth only - Split the section README into "Authentication methods" and "Rich responses", and point docs links at page anchors - Fix .gitignore ordering so .env.example is no longer ignored - Add rich-responses/mcp-apps to the CI matrix and dependabot Co-Authored-By: Claude --- .github/dependabot.yml | 4 + .github/workflows/test.yml | 1 + ai/slackbot-mcp-client/README.md | 12 +- ai/slackbot-mcp-client/no-auth/.gitignore | 2 +- ai/slackbot-mcp-client/no-auth/README.md | 2 +- ai/slackbot-mcp-client/no-auth/src/app.py | 35 +---- .../no-auth/tests/test_app.py | 38 +---- .../rich-responses/README.md | 9 ++ .../rich-responses/mcp-apps/.env.example | 3 + .../rich-responses/mcp-apps/.gitignore | 4 + .../rich-responses/mcp-apps/.slack/.gitignore | 2 + .../mcp-apps/.slack/config.json | 6 + .../rich-responses/mcp-apps/.slack/hooks.json | 5 + .../rich-responses/mcp-apps/README.md | 16 ++ .../rich-responses/mcp-apps/app.py | 9 ++ .../rich-responses/mcp-apps/manifest.json | 28 ++++ .../rich-responses/mcp-apps/requirements.txt | 10 ++ .../rich-responses/mcp-apps/src/__init__.py | 0 .../rich-responses/mcp-apps/src/app.py | 143 ++++++++++++++++++ .../mcp-apps}/src/dice.html | 0 .../rich-responses/mcp-apps/tests/__init__.py | 0 .../rich-responses/mcp-apps/tests/test_app.py | 119 +++++++++++++++ .../slack-identity/.gitignore | 2 +- .../slack-identity/README.md | 2 +- .../slack-identity/src/app.py | 58 +------ .../slack-identity/tests/test_app.py | 8 +- 26 files changed, 377 insertions(+), 141 deletions(-) create mode 100644 ai/slackbot-mcp-client/rich-responses/README.md create mode 100644 ai/slackbot-mcp-client/rich-responses/mcp-apps/.env.example create mode 100644 ai/slackbot-mcp-client/rich-responses/mcp-apps/.gitignore create mode 100644 ai/slackbot-mcp-client/rich-responses/mcp-apps/.slack/.gitignore create mode 100644 ai/slackbot-mcp-client/rich-responses/mcp-apps/.slack/config.json create mode 100644 ai/slackbot-mcp-client/rich-responses/mcp-apps/.slack/hooks.json create mode 100644 ai/slackbot-mcp-client/rich-responses/mcp-apps/README.md create mode 100644 ai/slackbot-mcp-client/rich-responses/mcp-apps/app.py create mode 100644 ai/slackbot-mcp-client/rich-responses/mcp-apps/manifest.json create mode 100644 ai/slackbot-mcp-client/rich-responses/mcp-apps/requirements.txt create mode 100644 ai/slackbot-mcp-client/rich-responses/mcp-apps/src/__init__.py create mode 100644 ai/slackbot-mcp-client/rich-responses/mcp-apps/src/app.py rename ai/slackbot-mcp-client/{no-auth => rich-responses/mcp-apps}/src/dice.html (100%) create mode 100644 ai/slackbot-mcp-client/rich-responses/mcp-apps/tests/__init__.py create mode 100644 ai/slackbot-mcp-client/rich-responses/mcp-apps/tests/test_app.py diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 53b7e66..be057f8 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -12,6 +12,10 @@ updates: directory: "ai/slackbot-mcp-client/slack-identity" schedule: interval: "daily" + - package-ecosystem: "pip" + directory: "ai/slackbot-mcp-client/rich-responses/mcp-apps" + schedule: + interval: "daily" - package-ecosystem: "pip" directory: "block-kit" schedule: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a5e385d..ea376e3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,6 +13,7 @@ jobs: matrix: showcase: - "ai/slackbot-mcp-client/no-auth" + - "ai/slackbot-mcp-client/rich-responses/mcp-apps" - "ai/slackbot-mcp-client/slack-identity" - "block-kit" steps: diff --git a/ai/slackbot-mcp-client/README.md b/ai/slackbot-mcp-client/README.md index 3fb4c67..61b8bcd 100644 --- a/ai/slackbot-mcp-client/README.md +++ b/ai/slackbot-mcp-client/README.md @@ -8,7 +8,11 @@ Read the [docs](https://docs.slack.dev/ai/slackbot-mcp-client) to explore more c ### Authentication methods -- **[Dynamic client registration](https://docs.slack.dev/ai/slackbot-mcp-client/dynamic-client-registration)**: Connect a remote MCP server to the Slackbot MCP client using Dynamic Client Registration (DCR). [Implementation](./dynamic-client-registration/). -- **[External auth](https://docs.slack.dev/ai/slackbot-mcp-client/external-auth)**: Connect a remote MCP server to the Slackbot MCP client with manual OAuth provider configuration. [Implementation](./external-auth/). -- **[No auth](https://docs.slack.dev/ai/slackbot-mcp-client/no-auth)**: Run an unauthenticated MCP server for the Slackbot MCP client that responds with an interactive UI. [Implementation](./no-auth/). -- **[Slack identity](https://docs.slack.dev/ai/slackbot-mcp-client/slack-identity)**: Run an MCP server for the Slackbot MCP client that responds with Block Kit and authenticates against existing installations. [Implementation](./slack-identity/). +- **[Dynamic client registration](https://docs.slack.dev/ai/slackbot-mcp-client#dcr)**: Connect a remote MCP server to the Slackbot MCP client using Dynamic Client Registration (DCR). [Implementation](./dynamic-client-registration/). +- **[External auth](https://docs.slack.dev/ai/slackbot-mcp-client#manual-oauth)**: Connect a remote MCP server to the Slackbot MCP client with manual OAuth provider configuration. [Implementation](./external-auth/). +- **[No auth](https://docs.slack.dev/ai/slackbot-mcp-client#no-auth)**: Run an unauthenticated MCP server for the Slackbot MCP client. [Implementation](./no-auth/). +- **[Slack identity](https://docs.slack.dev/ai/slackbot-mcp-client#slack-identity)**: Run an MCP server for the Slackbot MCP client that authenticates against existing installations. [Implementation](./slack-identity/). + +### Rich responses + +- **[MCP Apps](https://docs.slack.dev/ai/slackbot-mcp-client/returning-rich-responses#mcp-apps)**: Run an MCP server for the Slackbot MCP client that responds with an interactive UI. [Implementation](./rich-responses/mcp-apps/). diff --git a/ai/slackbot-mcp-client/no-auth/.gitignore b/ai/slackbot-mcp-client/no-auth/.gitignore index 71a31ee..0f53357 100644 --- a/ai/slackbot-mcp-client/no-auth/.gitignore +++ b/ai/slackbot-mcp-client/no-auth/.gitignore @@ -1,4 +1,4 @@ __pycache__ .venv -!.env.example .env* +!.env.example diff --git a/ai/slackbot-mcp-client/no-auth/README.md b/ai/slackbot-mcp-client/no-auth/README.md index 2202cc3..f418372 100644 --- a/ai/slackbot-mcp-client/no-auth/README.md +++ b/ai/slackbot-mcp-client/no-auth/README.md @@ -1,6 +1,6 @@ # No Auth -Run an unauthenticated MCP server for the Slackbot MCP client that responds with an [interactive UI](https://modelcontextprotocol.io/extensions/apps/overview). +Run an unauthenticated MCP server for the Slackbot MCP client. ## Setup diff --git a/ai/slackbot-mcp-client/no-auth/src/app.py b/ai/slackbot-mcp-client/no-auth/src/app.py index 9a8c2e1..846c2f6 100644 --- a/ai/slackbot-mcp-client/no-auth/src/app.py +++ b/ai/slackbot-mcp-client/no-auth/src/app.py @@ -1,7 +1,6 @@ import contextlib import os import random -from pathlib import Path from mcp.server.fastmcp import FastMCP from mcp.types import CallToolResult, TextContent, ToolAnnotations @@ -14,11 +13,7 @@ from starlette.routing import Route from starlette.types import ASGIApp, Receive, Scope, Send -DICE_HTML = (Path(__file__).parent / "dice.html").read_text() -RESOURCE_URI = "ui://dice-roller/dice.html" -RESOURCE_MIME_TYPE = "text/html;profile=mcp-app" - -"""Creates an MCP server with a dice roller tool and UI resource. +"""Creates an MCP server with a dice roller tool. https://github.com/modelcontextprotocol/python-sdk#quickstart """ @@ -31,11 +26,6 @@ title="Roll Dice", description="Roll one or more dice with a configurable number of sides.", annotations=ToolAnnotations(readOnlyHint=True), - meta={ - "ui": { - "resourceUri": RESOURCE_URI, - }, - }, ) def roll_dice(sides: int = 6, count: int = 1) -> CallToolResult: rolls = [random.randint(1, sides) for _ in range(count)] @@ -47,32 +37,9 @@ def roll_dice(sides: int = 6, count: int = 1) -> CallToolResult: content=[ TextContent(type="text", text=f"Rolled {label}:{rolls_display} = {total}") ], - structuredContent={ - "sides": sides, - "count": count, - "rolls": rolls, - "total": total, - }, ) -@mcp_server.resource( - RESOURCE_URI, - name="Dice Roller", - mime_type=RESOURCE_MIME_TYPE, - meta={ - "ui": { - "csp": { - "resourceDomains": ["https://esm.sh"], - "connectDomains": ["https://esm.sh"], - } - } - }, -) -def dice_resource() -> str: - return DICE_HTML - - """Creates a Bolt app with a custom /mcp route. https://docs.slack.dev/tools/bolt-python/getting-started diff --git a/ai/slackbot-mcp-client/no-auth/tests/test_app.py b/ai/slackbot-mcp-client/no-auth/tests/test_app.py index 7210e8e..a9c824c 100644 --- a/ai/slackbot-mcp-client/no-auth/tests/test_app.py +++ b/ai/slackbot-mcp-client/no-auth/tests/test_app.py @@ -54,43 +54,7 @@ def test_returns_tool_call_results(client): assert data["jsonrpc"] == "2.0" assert data["id"] == 2 result = data["result"] - structured = result["structuredContent"] - assert structured["sides"] == 6 - assert structured["count"] == 2 - assert len(structured["rolls"]) == 2 - assert all(1 <= r <= 6 for r in structured["rolls"]) - assert structured["total"] == sum(structured["rolls"]) - - -def test_serves_ui_resources(client): - body = json.dumps( - { - "jsonrpc": "2.0", - "id": 3, - "method": "resources/read", - "params": {"uri": "ui://dice-roller/dice.html"}, - } - ) - sig = sign_request(body) - resp = client.post( - "/mcp", - content=body, - headers={ - "content-type": "application/json", - "accept": "application/json, text/event-stream", - "x-slack-request-timestamp": sig["timestamp"], - "x-slack-signature": sig["signature"], - }, - ) - - assert resp.status_code == 200 - data = resp.json() - assert data["jsonrpc"] == "2.0" - assert data["id"] == 3 - contents = data["result"]["contents"] - assert contents[0]["uri"] == "ui://dice-roller/dice.html" - assert contents[0]["mimeType"] == "text/html;profile=mcp-app" - assert "Dice Roller" in contents[0]["text"] + assert "Rolled 2d6:" in result["content"][0]["text"] def test_rejects_unsigned_requests(client): diff --git a/ai/slackbot-mcp-client/rich-responses/README.md b/ai/slackbot-mcp-client/rich-responses/README.md new file mode 100644 index 0000000..9fc5298 --- /dev/null +++ b/ai/slackbot-mcp-client/rich-responses/README.md @@ -0,0 +1,9 @@ +# Rich Responses + +Return interactive experiences from an MCP server alongside plain text. + +Read the [docs](https://docs.slack.dev/ai/slackbot-mcp-client/returning-rich-responses) to explore more concepts around rich responses. + +## Included examples + +- **[MCP Apps](https://docs.slack.dev/ai/slackbot-mcp-client/returning-rich-responses#mcp-apps)**: Run an MCP server for the Slackbot MCP client that responds with an [interactive UI](https://modelcontextprotocol.io/extensions/apps/overview). [Implementation](./mcp-apps/). diff --git a/ai/slackbot-mcp-client/rich-responses/mcp-apps/.env.example b/ai/slackbot-mcp-client/rich-responses/mcp-apps/.env.example new file mode 100644 index 0000000..3a8a0ae --- /dev/null +++ b/ai/slackbot-mcp-client/rich-responses/mcp-apps/.env.example @@ -0,0 +1,3 @@ +SLACK_BOT_TOKEN= +SLACK_SIGNING_SECRET= +PORT=3000 diff --git a/ai/slackbot-mcp-client/rich-responses/mcp-apps/.gitignore b/ai/slackbot-mcp-client/rich-responses/mcp-apps/.gitignore new file mode 100644 index 0000000..0f53357 --- /dev/null +++ b/ai/slackbot-mcp-client/rich-responses/mcp-apps/.gitignore @@ -0,0 +1,4 @@ +__pycache__ +.venv +.env* +!.env.example diff --git a/ai/slackbot-mcp-client/rich-responses/mcp-apps/.slack/.gitignore b/ai/slackbot-mcp-client/rich-responses/mcp-apps/.slack/.gitignore new file mode 100644 index 0000000..973ba60 --- /dev/null +++ b/ai/slackbot-mcp-client/rich-responses/mcp-apps/.slack/.gitignore @@ -0,0 +1,2 @@ +apps.dev.json +cache/ diff --git a/ai/slackbot-mcp-client/rich-responses/mcp-apps/.slack/config.json b/ai/slackbot-mcp-client/rich-responses/mcp-apps/.slack/config.json new file mode 100644 index 0000000..909afbe --- /dev/null +++ b/ai/slackbot-mcp-client/rich-responses/mcp-apps/.slack/config.json @@ -0,0 +1,6 @@ +{ + "manifest": { + "source": "local" + }, + "project_id": "00000000-0000-0000-0000-000000000000" +} diff --git a/ai/slackbot-mcp-client/rich-responses/mcp-apps/.slack/hooks.json b/ai/slackbot-mcp-client/rich-responses/mcp-apps/.slack/hooks.json new file mode 100644 index 0000000..ce474c9 --- /dev/null +++ b/ai/slackbot-mcp-client/rich-responses/mcp-apps/.slack/hooks.json @@ -0,0 +1,5 @@ +{ + "hooks": { + "get-hooks": "python3 -m slack_cli_hooks.hooks.get_hooks" + } +} diff --git a/ai/slackbot-mcp-client/rich-responses/mcp-apps/README.md b/ai/slackbot-mcp-client/rich-responses/mcp-apps/README.md new file mode 100644 index 0000000..fd8d702 --- /dev/null +++ b/ai/slackbot-mcp-client/rich-responses/mcp-apps/README.md @@ -0,0 +1,16 @@ +# MCP Apps + +Run an MCP server for the Slackbot MCP client that responds with an [interactive UI](https://modelcontextprotocol.io/extensions/apps/overview). + +## Setup + +```sh +$ ngrok http 3000 --host-header=rewrite # Update manifest with new URL +$ slack manifest # Review saved values +$ slack install --environment local # Create a new app +$ slack app settings # Gather signing secret +$ slack env set SLACK_SIGNING_SECRET +$ slack run +``` + +Ask Slackbot: "Roll 2d20" diff --git a/ai/slackbot-mcp-client/rich-responses/mcp-apps/app.py b/ai/slackbot-mcp-client/rich-responses/mcp-apps/app.py new file mode 100644 index 0000000..a54402c --- /dev/null +++ b/ai/slackbot-mcp-client/rich-responses/mcp-apps/app.py @@ -0,0 +1,9 @@ +import os + +import uvicorn + +from src.app import app + +if __name__ == "__main__": + port = int(os.environ.get("PORT", "3000")) + uvicorn.run(app, host="0.0.0.0", port=port) diff --git a/ai/slackbot-mcp-client/rich-responses/mcp-apps/manifest.json b/ai/slackbot-mcp-client/rich-responses/mcp-apps/manifest.json new file mode 100644 index 0000000..55c81ef --- /dev/null +++ b/ai/slackbot-mcp-client/rich-responses/mcp-apps/manifest.json @@ -0,0 +1,28 @@ +{ + "display_information": { + "name": "MCP Client - MCP Apps", + "description": "Connects app MCP server to Slackbot MCP client that responds with an interactive UI" + }, + "features": { + "bot_user": { + "display_name": "MCP Client - MCP Apps", + "always_online": true + } + }, + "oauth_config": { + "scopes": { + "bot": ["mcp:connect"] + } + }, + "settings": { + "org_deploy_enabled": true, + "socket_mode_enabled": false, + "token_rotation_enabled": false + }, + "mcp_servers": { + "Dice Game": { + "url": "https://1234-56-78-90-0.ngrok-free.app/mcp", + "auth_type": "no_auth" + } + } +} diff --git a/ai/slackbot-mcp-client/rich-responses/mcp-apps/requirements.txt b/ai/slackbot-mcp-client/rich-responses/mcp-apps/requirements.txt new file mode 100644 index 0000000..c26e03f --- /dev/null +++ b/ai/slackbot-mcp-client/rich-responses/mcp-apps/requirements.txt @@ -0,0 +1,10 @@ +httpx2==2.4.0 +mcp==1.27.2 +mypy==2.1.0 +pytest==9.1.0 +ruff==0.15.17 +slack_bolt==1.28.0 +slack_cli_hooks==0.3.0 +slack_sdk==3.42.0 +starlette==1.3.1 +uvicorn==0.49.0 diff --git a/ai/slackbot-mcp-client/rich-responses/mcp-apps/src/__init__.py b/ai/slackbot-mcp-client/rich-responses/mcp-apps/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ai/slackbot-mcp-client/rich-responses/mcp-apps/src/app.py b/ai/slackbot-mcp-client/rich-responses/mcp-apps/src/app.py new file mode 100644 index 0000000..9a8c2e1 --- /dev/null +++ b/ai/slackbot-mcp-client/rich-responses/mcp-apps/src/app.py @@ -0,0 +1,143 @@ +import contextlib +import os +import random +from pathlib import Path + +from mcp.server.fastmcp import FastMCP +from mcp.types import CallToolResult, TextContent, ToolAnnotations +from slack_bolt import App +from slack_bolt.adapter.starlette import SlackRequestHandler +from slack_sdk.signature import SignatureVerifier +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import JSONResponse +from starlette.routing import Route +from starlette.types import ASGIApp, Receive, Scope, Send + +DICE_HTML = (Path(__file__).parent / "dice.html").read_text() +RESOURCE_URI = "ui://dice-roller/dice.html" +RESOURCE_MIME_TYPE = "text/html;profile=mcp-app" + +"""Creates an MCP server with a dice roller tool and UI resource. + +https://github.com/modelcontextprotocol/python-sdk#quickstart +""" + +mcp_server = FastMCP("Dice Game", stateless_http=True, json_response=True) + + +@mcp_server.tool( + name="roll_dice", + title="Roll Dice", + description="Roll one or more dice with a configurable number of sides.", + annotations=ToolAnnotations(readOnlyHint=True), + meta={ + "ui": { + "resourceUri": RESOURCE_URI, + }, + }, +) +def roll_dice(sides: int = 6, count: int = 1) -> CallToolResult: + rolls = [random.randint(1, sides) for _ in range(count)] + total = sum(rolls) + label = f"{count}d{sides}" + rolls_display = f" [{', '.join(str(r) for r in rolls)}]" if count > 1 else "" + + return CallToolResult( + content=[ + TextContent(type="text", text=f"Rolled {label}:{rolls_display} = {total}") + ], + structuredContent={ + "sides": sides, + "count": count, + "rolls": rolls, + "total": total, + }, + ) + + +@mcp_server.resource( + RESOURCE_URI, + name="Dice Roller", + mime_type=RESOURCE_MIME_TYPE, + meta={ + "ui": { + "csp": { + "resourceDomains": ["https://esm.sh"], + "connectDomains": ["https://esm.sh"], + } + } + }, +) +def dice_resource() -> str: + return DICE_HTML + + +"""Creates a Bolt app with a custom /mcp route. + +https://docs.slack.dev/tools/bolt-python/getting-started +""" + +bolt_app = App( + token=os.environ.get("SLACK_BOT_TOKEN"), + signing_secret=os.environ.get("SLACK_SIGNING_SECRET"), +) + + +class SlackSignatureMiddleware: + def __init__(self, app: ASGIApp) -> None: + self.app = app + self.verifier = SignatureVerifier( + signing_secret=os.environ["SLACK_SIGNING_SECRET"] + ) + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + request = Request(scope, receive, send) + body = await request.body() + + if not self.verifier.is_valid_request(body, dict(request.headers)): + response = JSONResponse( + { + "jsonrpc": "2.0", + "error": {"code": -32600, "message": "Invalid request"}, + "id": None, + }, + status_code=401, + ) + await response(scope, receive, send) + return + + async def replay_receive(): + return {"type": "http.request", "body": body, "more_body": False} + + await self.app(scope, replay_receive, send) + + +@contextlib.asynccontextmanager +async def lifespan(a): + async with mcp_server.session_manager.run(): + yield + + +mcp_app = mcp_server.streamable_http_app() + + +app = Starlette( + routes=[ + Route( + "/slack/events", + endpoint=SlackRequestHandler(bolt_app).handle, + methods=["POST"], + ), + Route( + "/mcp", + endpoint=SlackSignatureMiddleware(mcp_app), + methods=["POST"], + ), + ], + lifespan=lifespan, +) diff --git a/ai/slackbot-mcp-client/no-auth/src/dice.html b/ai/slackbot-mcp-client/rich-responses/mcp-apps/src/dice.html similarity index 100% rename from ai/slackbot-mcp-client/no-auth/src/dice.html rename to ai/slackbot-mcp-client/rich-responses/mcp-apps/src/dice.html diff --git a/ai/slackbot-mcp-client/rich-responses/mcp-apps/tests/__init__.py b/ai/slackbot-mcp-client/rich-responses/mcp-apps/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ai/slackbot-mcp-client/rich-responses/mcp-apps/tests/test_app.py b/ai/slackbot-mcp-client/rich-responses/mcp-apps/tests/test_app.py new file mode 100644 index 0000000..7210e8e --- /dev/null +++ b/ai/slackbot-mcp-client/rich-responses/mcp-apps/tests/test_app.py @@ -0,0 +1,119 @@ +import hashlib +import hmac +import json +import os +import time +from unittest.mock import patch + +import pytest +from starlette.testclient import TestClient + +os.environ["SLACK_BOT_TOKEN"] = "xoxb-test" +os.environ["SLACK_SIGNING_SECRET"] = "test_signing_secret" + +_mock_auth = patch( + "slack_sdk.web.client.WebClient.auth_test", + return_value={"ok": True, "bot_id": "B0101", "user_id": "U0123"}, +) +_mock_auth.start() + +from src.app import app # noqa: E402 + +SIGNING_SECRET = "test_signing_secret" + + +@pytest.fixture(scope="module") +def client(): + with TestClient(app, base_url="http://localhost:8000") as c: + yield c + + +def test_returns_tool_call_results(client): + body = json.dumps( + { + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": {"name": "roll_dice", "arguments": {"sides": 6, "count": 2}}, + } + ) + sig = sign_request(body) + resp = client.post( + "/mcp", + content=body, + headers={ + "content-type": "application/json", + "accept": "application/json, text/event-stream", + "x-slack-request-timestamp": sig["timestamp"], + "x-slack-signature": sig["signature"], + }, + ) + + assert resp.status_code == 200 + data = resp.json() + assert data["jsonrpc"] == "2.0" + assert data["id"] == 2 + result = data["result"] + structured = result["structuredContent"] + assert structured["sides"] == 6 + assert structured["count"] == 2 + assert len(structured["rolls"]) == 2 + assert all(1 <= r <= 6 for r in structured["rolls"]) + assert structured["total"] == sum(structured["rolls"]) + + +def test_serves_ui_resources(client): + body = json.dumps( + { + "jsonrpc": "2.0", + "id": 3, + "method": "resources/read", + "params": {"uri": "ui://dice-roller/dice.html"}, + } + ) + sig = sign_request(body) + resp = client.post( + "/mcp", + content=body, + headers={ + "content-type": "application/json", + "accept": "application/json, text/event-stream", + "x-slack-request-timestamp": sig["timestamp"], + "x-slack-signature": sig["signature"], + }, + ) + + assert resp.status_code == 200 + data = resp.json() + assert data["jsonrpc"] == "2.0" + assert data["id"] == 3 + contents = data["result"]["contents"] + assert contents[0]["uri"] == "ui://dice-roller/dice.html" + assert contents[0]["mimeType"] == "text/html;profile=mcp-app" + assert "Dice Roller" in contents[0]["text"] + + +def test_rejects_unsigned_requests(client): + body = json.dumps( + { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": "roll_dice", "arguments": {"sides": 6, "count": 1}}, + } + ) + resp = client.post( + "/mcp", content=body, headers={"content-type": "application/json"} + ) + + assert resp.status_code == 401 + + +def sign_request(body: str, secret: str = SIGNING_SECRET) -> dict: + timestamp = str(int(time.time())) + sig_basestring = f"v0:{timestamp}:{body}" + signature = ( + "v0=" + + hmac.new(secret.encode(), sig_basestring.encode(), hashlib.sha256).hexdigest() + ) + return {"timestamp": timestamp, "signature": signature} diff --git a/ai/slackbot-mcp-client/slack-identity/.gitignore b/ai/slackbot-mcp-client/slack-identity/.gitignore index 0efc176..70379ff 100644 --- a/ai/slackbot-mcp-client/slack-identity/.gitignore +++ b/ai/slackbot-mcp-client/slack-identity/.gitignore @@ -2,5 +2,5 @@ __pycache__ .venv installations/ states/ -!.env.example .env* +!.env.example diff --git a/ai/slackbot-mcp-client/slack-identity/README.md b/ai/slackbot-mcp-client/slack-identity/README.md index a022f58..f2bbc8b 100644 --- a/ai/slackbot-mcp-client/slack-identity/README.md +++ b/ai/slackbot-mcp-client/slack-identity/README.md @@ -1,6 +1,6 @@ # Slack Identity -Run an MCP server for the Slackbot MCP client that responds with Block Kit and authenticates against existing installations. +Run an MCP server for the Slackbot MCP client that authenticates against existing installations. ## Setup diff --git a/ai/slackbot-mcp-client/slack-identity/src/app.py b/ai/slackbot-mcp-client/slack-identity/src/app.py index d6bef31..ccc413d 100644 --- a/ai/slackbot-mcp-client/slack-identity/src/app.py +++ b/ai/slackbot-mcp-client/slack-identity/src/app.py @@ -30,11 +30,6 @@ title="Get Profile Card", description="Get a profile card for a Slack user by their user ID.", annotations=ToolAnnotations(readOnlyHint=True), - meta={ - "slack": { - "supportsBlockKit": True, - }, - }, ) async def get_profile_card( user_id: str, @@ -60,36 +55,15 @@ async def get_profile_card( is_enterprise_install=bool(slack.get("enterprise_id")), ) if not installation or not installation.bot_token: + install_url = f"{os.environ.get('BASE_URL', '')}/slack/install" return CallToolResult( content=[ TextContent( type="text", - text="App not installed to this workspace. Please install first.", + text="App not installed to this workspace. " + f"Please install first: {install_url}", ) ], - _meta={ - "slack": { - "blocks": [ - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": "Please install the *MCP Profile Card* app " - "to access profile information.", - }, - "accessory": { - "type": "button", - "text": { - "type": "plain_text", - "text": "Install", - }, - "url": f"{os.environ.get('BASE_URL', '')}/slack/install", - "action_id": "install_app", - }, - } - ] - } - }, ) try: @@ -117,32 +91,6 @@ async def get_profile_card( f"Email: {profile.get('email', '')}", ) ], - _meta={ - "slack": { - "blocks": [ - { - "type": "card", - "icon": { - "type": "image", - "image_url": profile.get("image_72", ""), - "alt_text": profile.get("real_name", ""), - }, - "title": { - "type": "mrkdwn", - "text": profile.get("real_name", ""), - }, - "subtitle": { - "type": "mrkdwn", - "text": profile.get("title", ""), - }, - "body": { - "type": "mrkdwn", - "text": f"*Email:* {profile.get('email', '')}", - }, - } - ] - } - }, ) diff --git a/ai/slackbot-mcp-client/slack-identity/tests/test_app.py b/ai/slackbot-mcp-client/slack-identity/tests/test_app.py index 9ccd3d9..f2dcd56 100644 --- a/ai/slackbot-mcp-client/slack-identity/tests/test_app.py +++ b/ai/slackbot-mcp-client/slack-identity/tests/test_app.py @@ -85,10 +85,6 @@ def test_returns_tool_call_response(client): assert "Test User" in result["content"][0]["text"] assert "VIP" in result["content"][0]["text"] assert "test@example.com" in result["content"][0]["text"] - blocks = result["_meta"]["slack"]["blocks"] - assert blocks[0]["type"] == "card" - assert blocks[0]["title"]["text"] == "Test User" - assert blocks[0]["subtitle"]["text"] == "VIP" def test_requires_team_installation(client): @@ -127,9 +123,7 @@ def test_requires_team_installation(client): data = resp.json() result = data["result"] assert "not installed" in result["content"][0]["text"].lower() - blocks = result["_meta"]["slack"]["blocks"] - assert blocks[0]["type"] == "section" - assert "/slack/install" in blocks[0]["accessory"]["url"] + assert "/slack/install" in result["content"][0]["text"] def test_rejects_unsigned_requests(client): From 00794e8596b5746df1334ace7479dc50e2033bb6 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Wed, 17 Jun 2026 23:38:52 -0700 Subject: [PATCH 28/28] chore: alphabetize dependabot entries and inline install message Order the dependabot pip directories alphabetically to match the CI test matrix, and keep the not-installed message on a single line. Co-Authored-By: Claude --- .github/dependabot.yml | 4 ++-- ai/slackbot-mcp-client/slack-identity/src/app.py | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index be057f8..6b3c55b 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -9,11 +9,11 @@ updates: schedule: interval: "daily" - package-ecosystem: "pip" - directory: "ai/slackbot-mcp-client/slack-identity" + directory: "ai/slackbot-mcp-client/rich-responses/mcp-apps" schedule: interval: "daily" - package-ecosystem: "pip" - directory: "ai/slackbot-mcp-client/rich-responses/mcp-apps" + directory: "ai/slackbot-mcp-client/slack-identity" schedule: interval: "daily" - package-ecosystem: "pip" diff --git a/ai/slackbot-mcp-client/slack-identity/src/app.py b/ai/slackbot-mcp-client/slack-identity/src/app.py index ccc413d..a409e62 100644 --- a/ai/slackbot-mcp-client/slack-identity/src/app.py +++ b/ai/slackbot-mcp-client/slack-identity/src/app.py @@ -60,8 +60,7 @@ async def get_profile_card( content=[ TextContent( type="text", - text="App not installed to this workspace. " - f"Please install first: {install_url}", + text=f"App not installed to this workspace. Please install first: {install_url}", ) ], )