diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 4b23914..6b3c55b 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,6 +4,18 @@ updates: directory: "/" schedule: interval: "daily" + - package-ecosystem: "pip" + directory: "ai/slackbot-mcp-client/no-auth" + schedule: + interval: "daily" + - package-ecosystem: "pip" + directory: "ai/slackbot-mcp-client/rich-responses/mcp-apps" + schedule: + interval: "daily" + - package-ecosystem: "pip" + directory: "ai/slackbot-mcp-client/slack-identity" + schedule: + interval: "daily" - package-ecosystem: "pip" directory: "block-kit" schedule: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bfbebdf..ea376e3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,8 +9,12 @@ jobs: name: "pytest@python3.14" runs-on: ubuntu-latest strategy: + fail-fast: false 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: - name: Checkout code diff --git a/README.md b/README.md index 2334580..5a58c76 100644 --- a/README.md +++ b/README.md @@ -2,6 +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. 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..61b8bcd --- /dev/null +++ b/ai/slackbot-mcp-client/README.md @@ -0,0 +1,18 @@ +# 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#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/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/.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/no-auth/.gitignore b/ai/slackbot-mcp-client/no-auth/.gitignore new file mode 100644 index 0000000..0f53357 --- /dev/null +++ b/ai/slackbot-mcp-client/no-auth/.gitignore @@ -0,0 +1,4 @@ +__pycache__ +.venv +.env* +!.env.example 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..f418372 --- /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. + +## 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/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/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..c26e03f --- /dev/null +++ b/ai/slackbot-mcp-client/no-auth/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/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/app.py b/ai/slackbot-mcp-client/no-auth/src/app.py new file mode 100644 index 0000000..846c2f6 --- /dev/null +++ b/ai/slackbot-mcp-client/no-auth/src/app.py @@ -0,0 +1,110 @@ +import contextlib +import os +import random + +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 + +"""Creates an MCP server with a dice roller tool. + +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), +) +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}") + ], + ) + + +"""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/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/no-auth/tests/test_app.py b/ai/slackbot-mcp-client/no-auth/tests/test_app.py new file mode 100644 index 0000000..a9c824c --- /dev/null +++ b/ai/slackbot-mcp-client/no-auth/tests/test_app.py @@ -0,0 +1,83 @@ +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"] + assert "Rolled 2d6:" in result["content"][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/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/rich-responses/mcp-apps/src/dice.html b/ai/slackbot-mcp-client/rich-responses/mcp-apps/src/dice.html new file mode 100644 index 0000000..1fbbac1 --- /dev/null +++ b/ai/slackbot-mcp-client/rich-responses/mcp-apps/src/dice.html @@ -0,0 +1,54 @@ + + +
+ + + +