From 77ab1cd9ec6d28a43fb7a0c760420542b23a4cb4 Mon Sep 17 00:00:00 2001 From: Taylor Wilsdon Date: Thu, 17 Apr 2025 15:39:27 -0700 Subject: [PATCH 01/13] Slack OpenAPI server --- servers/slack/Dockerfile | 51 +++ servers/slack/README.md | 55 ++++ servers/slack/compose.yaml | 7 + servers/slack/main.py | 301 +++++++++++++++++ servers/slack/requirements.txt | 6 + servers/slack/slack.ts | 582 +++++++++++++++++++++++++++++++++ 6 files changed, 1002 insertions(+) create mode 100644 servers/slack/Dockerfile create mode 100644 servers/slack/README.md create mode 100644 servers/slack/compose.yaml create mode 100644 servers/slack/main.py create mode 100644 servers/slack/requirements.txt create mode 100644 servers/slack/slack.ts diff --git a/servers/slack/Dockerfile b/servers/slack/Dockerfile new file mode 100644 index 0000000..e91fca9 --- /dev/null +++ b/servers/slack/Dockerfile @@ -0,0 +1,51 @@ +# syntax=docker/dockerfile:1 + +# Comments are provided throughout this file to help you get started. +# If you need more help, visit the Dockerfile reference guide at +# https://docs.docker.com/go/dockerfile-reference/ + +# Want to help us make this template better? Share your feedback here: https://forms.gle/ybq9Krt8jtBL3iCk7 + +ARG PYTHON_VERSION=3.10.12 +FROM python:${PYTHON_VERSION}-slim as base + +# Prevents Python from writing pyc files. +ENV PYTHONDONTWRITEBYTECODE=1 + +# Keeps Python from buffering stdout and stderr to avoid situations where +# the application crashes without emitting any logs due to buffering. +ENV PYTHONUNBUFFERED=1 + +WORKDIR /app + +# Create a non-privileged user that the app will run under. +# See https://docs.docker.com/go/dockerfile-user-best-practices/ +ARG UID=10001 +RUN adduser \ + --disabled-password \ + --gecos "" \ + --home "/nonexistent" \ + --shell "/sbin/nologin" \ + --no-create-home \ + --uid "${UID}" \ + appuser + +# Download dependencies as a separate step to take advantage of Docker's caching. +# Leverage a cache mount to /root/.cache/pip to speed up subsequent builds. +# Leverage a bind mount to requirements.txt to avoid having to copy them into +# into this layer. +RUN --mount=type=cache,target=/root/.cache/pip \ + --mount=type=bind,source=requirements.txt,target=requirements.txt \ + python -m pip install -r requirements.txt + +# Switch to the non-privileged user to run the application. +USER appuser + +# Copy the source code into the container. +COPY . . + +# Expose the port that the application listens on. +EXPOSE 8000 + +# Run the application. +CMD uvicorn 'main:app' --host=0.0.0.0 --port=8000 diff --git a/servers/slack/README.md b/servers/slack/README.md new file mode 100644 index 0000000..66f8523 --- /dev/null +++ b/servers/slack/README.md @@ -0,0 +1,55 @@ +# β›… Weather Tool Server + +A sleek and simple FastAPI-based server to provide weather data using OpenAPI standards. + +πŸ“¦ Built with: +⚑️ FastAPI β€’ πŸ“œ OpenAPI β€’ 🧰 Python + +--- + +## πŸš€ Quickstart + +Clone the repo and get started in seconds: + +```bash +git clone https://github.com/open-webui/openapi-servers +cd openapi-servers/servers/weather + +# Install dependencies +pip install -r requirements.txt + +# Run the server +uvicorn main:app --host 0.0.0.0 --reload +``` + +--- + +## πŸ” About + +This server is part of the OpenAPI Tools Collection. Use it to fetch real-time weather information, location-based forecasts, and more β€” all wrapped in a developer-friendly OpenAPI interface. + +Compatible with any OpenAPI-supported ecosystem, including: + +- πŸŒ€ FastAPI +- πŸ“˜ Swagger UI +- πŸ§ͺ API testing tools + +--- + +## 🚧 Customization + +Plug in your favorite weather provider API, tailor endpoints, or extend the OpenAPI spec. Ideal for integration into AI agents, automated dashboards, or personal assistants. + +--- + +## 🌐 API Documentation + +Once running, explore auto-generated interactive docs: + +πŸ–₯️ Swagger UI: http://localhost:8000/docs +πŸ“„ OpenAPI JSON: http://localhost:8000/openapi.json + +--- + +Made with ❀️ by the Open WebUI community 🌍 +Explore more tools ➑️ https://github.com/open-webui/openapi-servers \ No newline at end of file diff --git a/servers/slack/compose.yaml b/servers/slack/compose.yaml new file mode 100644 index 0000000..9fc4d98 --- /dev/null +++ b/servers/slack/compose.yaml @@ -0,0 +1,7 @@ +services: + server: + build: + context: . + ports: + - 8000:8000 + diff --git a/servers/slack/main.py b/servers/slack/main.py new file mode 100644 index 0000000..4b2f23c --- /dev/null +++ b/servers/slack/main.py @@ -0,0 +1,301 @@ +# [Previous imports and setup remain the same...] +import os +import httpx +import inspect +from typing import Optional, List, Dict, Any, Type +from fastapi import FastAPI, HTTPException, Body, Depends +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel, Field +from dotenv import load_dotenv + +# Load environment variables from .env file +load_dotenv() + +# --- Environment Variable Checks --- +SLACK_BOT_TOKEN = os.getenv("SLACK_BOT_TOKEN") +SLACK_TEAM_ID = os.getenv("SLACK_TEAM_ID") +SLACK_CHANNEL_IDS_STR = os.getenv("SLACK_CHANNEL_IDS") # Optional + +if not SLACK_BOT_TOKEN: + raise ValueError("SLACK_BOT_TOKEN environment variable not set.") +if not SLACK_TEAM_ID: + raise ValueError("SLACK_TEAM_ID environment variable not set.") + +PREDEFINED_CHANNEL_IDS = [ + channel_id.strip() + for channel_id in SLACK_CHANNEL_IDS_STR.split(',') +] if SLACK_CHANNEL_IDS_STR else None + +# --- FastAPI App Setup --- +app = FastAPI( + title="Slack API Server", + version="1.0.0", + description="FastAPI server providing Slack functionalities via specific, dynamically generated tool endpoints.", +) + +origins = ["*"] + +app.add_middleware( + CORSMiddleware, + allow_origins=origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# [Previous Pydantic models remain the same...] +class ListChannelsArgs(BaseModel): + limit: Optional[int] = Field(100, description="Maximum number of channels to return (default 100, max 200)") + cursor: Optional[str] = Field(None, description="Pagination cursor for next page of results") + +class PostMessageArgs(BaseModel): + channel_id: str = Field(..., description="The ID of the channel to post to") + text: str = Field(..., description="The message text to post") + +class ReplyToThreadArgs(BaseModel): + channel_id: str = Field(..., description="The ID of the channel containing the thread") + thread_ts: str = Field(..., description="The timestamp of the parent message (e.g., '1234567890.123456')") + text: str = Field(..., description="The reply text") + +class AddReactionArgs(BaseModel): + channel_id: str = Field(..., description="The ID of the channel containing the message") + timestamp: str = Field(..., description="The timestamp of the message to react to") + reaction: str = Field(..., description="The name of the emoji reaction (without colons)") + +class GetChannelHistoryArgs(BaseModel): + channel_id: str = Field(..., description="The ID of the channel") + limit: Optional[int] = Field(10, description="Number of messages to retrieve (default 10)") + +class GetThreadRepliesArgs(BaseModel): + channel_id: str = Field(..., description="The ID of the channel containing the thread") + thread_ts: str = Field(..., description="The timestamp of the parent message (e.g., '1234567890.123456')") + +class GetUsersArgs(BaseModel): + cursor: Optional[str] = Field(None, description="Pagination cursor for next page of results") + limit: Optional[int] = Field(100, description="Maximum number of users to return (default 100, max 200)") + +class GetUserProfileArgs(BaseModel): + user_id: str = Field(..., description="The ID of the user") + +class ToolResponse(BaseModel): + content: Dict[str, Any] = Field(..., description="The JSON response from the Slack API call") + +# --- Slack Client Class --- +class SlackClient: + BASE_URL = "https://slack.com/api/" + + def __init__(self, token: str, team_id: str): + self.headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json; charset=utf-8", + } + self.team_id = team_id + + async def _request(self, method: str, endpoint: str, params: Optional[Dict] = None, json_data: Optional[Dict] = None) -> Dict[str, Any]: + async with httpx.AsyncClient(base_url=self.BASE_URL, headers=self.headers) as client: + try: + response = await client.request(method, endpoint, params=params, json=json_data) + response.raise_for_status() + data = response.json() + if not data.get("ok"): + error_msg = data.get("error", "Unknown Slack API error") + print(f"Slack API Error for {method} {endpoint}: {error_msg}") + raise HTTPException(status_code=400, detail={"slack_error": error_msg, "message": f"Slack API Error: {error_msg}"}) + return data + except httpx.HTTPStatusError as e: + print(f"HTTP Error: {e.response.status_code} - {e.response.text}") + raise HTTPException(status_code=e.response.status_code, detail=f"Slack API HTTP Error: {e.response.text}") + except httpx.RequestError as e: + print(f"Request Error: {e}") + raise HTTPException(status_code=503, detail=f"Error connecting to Slack API: {e}") + except Exception as e: + print(f"Unexpected Error during Slack request: {e}") + raise HTTPException(status_code=500, detail=f"An internal error occurred during the Slack request: {e}") + + async def get_channel_history(self, args: GetChannelHistoryArgs) -> Dict[str, Any]: + params = {"channel": args.channel_id, "limit": args.limit} + return await self._request("GET", "conversations.history", params=params) + + async def get_channels(self, args: ListChannelsArgs) -> Dict[str, Any]: + limit = args.limit + cursor = args.cursor + + async def fetch_channel_with_history(channel_id: str) -> Dict[str, Any]: + # First get channel info + channel_info = await self._request("GET", "conversations.info", params={"channel": channel_id}) + if not channel_info.get("ok") or channel_info.get("channel", {}).get("is_archived"): + return None + + channel_data = channel_info["channel"] + + # Then get channel history + try: + history = await self._request( + "GET", + "conversations.history", + params={ + "channel": channel_id, + "limit": 10 # Get last 10 messages by default + } + ) + # Add history to channel data + if history.get("ok"): + channel_data["history"] = history.get("messages", []) + except Exception as e: + print(f"Error fetching history for channel {channel_id}: {e}") + channel_data["history"] = [] + + return channel_data + + if PREDEFINED_CHANNEL_IDS: + channels_info = [] + for channel_id in PREDEFINED_CHANNEL_IDS: + try: + if channel_data := await fetch_channel_with_history(channel_id): + channels_info.append(channel_data) + except Exception as e: + print(f"Could not fetch info for predefined channel {channel_id}: {e}") + + return { + "ok": True, + "channels": channels_info, + "response_metadata": {"next_cursor": ""} + } + else: + # First get list of channels + params = { + "types": "public_channel", + "exclude_archived": "true", + "limit": min(limit, 200), + "team_id": self.team_id, + } + if cursor: + params["cursor"] = cursor + + channels_list = await self._request("GET", "conversations.list", params=params) + + if not channels_list.get("ok"): + return channels_list + + # Then fetch history for each channel + channels_with_history = [] + for channel in channels_list["channels"]: + try: + if channel_data := await fetch_channel_with_history(channel["id"]): + channels_with_history.append(channel_data) + except Exception as e: + print(f"Error fetching history for channel {channel['id']}: {e}") + channels_with_history.append(channel) # Fall back to channel info without history + + return { + "ok": True, + "channels": channels_with_history, + "response_metadata": channels_list.get("response_metadata", {"next_cursor": ""}) + } + + async def post_message(self, args: PostMessageArgs) -> Dict[str, Any]: + payload = {"channel": args.channel_id, "text": args.text} + return await self._request("POST", "chat.postMessage", json_data=payload) + + async def post_reply(self, args: ReplyToThreadArgs) -> Dict[str, Any]: + payload = {"channel": args.channel_id, "thread_ts": args.thread_ts, "text": args.text} + return await self._request("POST", "chat.postMessage", json_data=payload) + + async def add_reaction(self, args: AddReactionArgs) -> Dict[str, Any]: + payload = {"channel": args.channel_id, "timestamp": args.timestamp, "name": args.reaction} + return await self._request("POST", "reactions.add", json_data=payload) + + async def get_thread_replies(self, args: GetThreadRepliesArgs) -> Dict[str, Any]: + params = {"channel": args.channel_id, "ts": args.thread_ts} + return await self._request("GET", "conversations.replies", params=params) + + async def get_users(self, args: GetUsersArgs) -> Dict[str, Any]: + params = { + "limit": min(args.limit, 200), + "team_id": self.team_id, + } + if args.cursor: + params["cursor"] = args.cursor + return await self._request("GET", "users.list", params=params) + + async def get_user_profile(self, args: GetUserProfileArgs) -> Dict[str, Any]: + params = {"user": args.user_id, "include_labels": "true"} + return await self._request("GET", "users.profile.get", params=params) + +# --- Instantiate Slack Client --- +slack_client = SlackClient(token=SLACK_BOT_TOKEN, team_id=SLACK_TEAM_ID) + +# --- Tool Definitions & Endpoint Generation --- +TOOL_MAPPING = { + "slack_list_channels": { + "args_model": ListChannelsArgs, + "method": slack_client.get_channels, + "description": "List public or pre-defined channels in the workspace with pagination", + }, + "slack_post_message": { + "args_model": PostMessageArgs, + "method": slack_client.post_message, + "description": "Post a new message to a Slack channel", + }, + "slack_reply_to_thread": { + "args_model": ReplyToThreadArgs, + "method": slack_client.post_reply, + "description": "Reply to a specific message thread in Slack", + }, + "slack_add_reaction": { + "args_model": AddReactionArgs, + "method": slack_client.add_reaction, + "description": "Add a reaction emoji to a message", + }, + "slack_get_channel_history": { + "args_model": GetChannelHistoryArgs, + "method": slack_client.get_channel_history, + "description": "Get recent messages from a channel", + }, + "slack_get_thread_replies": { + "args_model": GetThreadRepliesArgs, + "method": slack_client.get_thread_replies, + "description": "Get all replies in a message thread", + }, + "slack_get_users": { + "args_model": GetUsersArgs, + "method": slack_client.get_users, + "description": "Get a list of all users in the workspace with their basic profile information", + }, + "slack_get_user_profile": { + "args_model": GetUserProfileArgs, + "method": slack_client.get_user_profile, + "description": "Get detailed profile information for a specific user", + }, +} + +# Dynamically create endpoints for each tool +for tool_name, config in TOOL_MAPPING.items(): + args_model = config["args_model"] + method_to_call = config["method"] + tool_description = config["description"] + + async def endpoint_func(args: args_model = Body(...), # type: ignore + method=method_to_call): # Capture method in closure + try: + result = await method(args=args) + return {"content": result} + except HTTPException as e: + raise e + except Exception as e: + print(f"Error executing tool: {e}") + raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") + + app.post( + f"/{tool_name}", + response_model=ToolResponse, + summary=tool_description, + description=f"Executes the {tool_name} tool. Arguments are passed in the request body.", + tags=["Slack Tools"], + name=tool_name + )(endpoint_func) + +# --- Root Endpoint --- +@app.get("/", summary="Root endpoint", include_in_schema=False) +async def read_root(): + return {"message": "Slack API Server is running. See /docs for available tool endpoints."} diff --git a/servers/slack/requirements.txt b/servers/slack/requirements.txt new file mode 100644 index 0000000..9d2bd0a --- /dev/null +++ b/servers/slack/requirements.txt @@ -0,0 +1,6 @@ +fastapi +uvicorn[standard] +pydantic +python-multipart +httpx +python-dotenv diff --git a/servers/slack/slack.ts b/servers/slack/slack.ts new file mode 100644 index 0000000..dde2c8b --- /dev/null +++ b/servers/slack/slack.ts @@ -0,0 +1,582 @@ +#!/usr/bin/env node +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { + CallToolRequest, + CallToolRequestSchema, + ListToolsRequestSchema, + Tool, +} from "@modelcontextprotocol/sdk/types.js"; + +// Type definitions for tool arguments +interface ListChannelsArgs { + limit?: number; + cursor?: string; +} + +interface PostMessageArgs { + channel_id: string; + text: string; +} + +interface ReplyToThreadArgs { + channel_id: string; + thread_ts: string; + text: string; +} + +interface AddReactionArgs { + channel_id: string; + timestamp: string; + reaction: string; +} + +interface GetChannelHistoryArgs { + channel_id: string; + limit?: number; +} + +interface GetThreadRepliesArgs { + channel_id: string; + thread_ts: string; +} + +interface GetUsersArgs { + cursor?: string; + limit?: number; +} + +interface GetUserProfileArgs { + user_id: string; +} + +// Tool definitions +const listChannelsTool: Tool = { + name: "slack_list_channels", + description: "List public or pre-defined channels in the workspace with pagination", + inputSchema: { + type: "object", + properties: { + limit: { + type: "number", + description: + "Maximum number of channels to return (default 100, max 200)", + default: 100, + }, + cursor: { + type: "string", + description: "Pagination cursor for next page of results", + }, + }, + }, +}; + +const postMessageTool: Tool = { + name: "slack_post_message", + description: "Post a new message to a Slack channel", + inputSchema: { + type: "object", + properties: { + channel_id: { + type: "string", + description: "The ID of the channel to post to", + }, + text: { + type: "string", + description: "The message text to post", + }, + }, + required: ["channel_id", "text"], + }, +}; + +const replyToThreadTool: Tool = { + name: "slack_reply_to_thread", + description: "Reply to a specific message thread in Slack", + inputSchema: { + type: "object", + properties: { + channel_id: { + type: "string", + description: "The ID of the channel containing the thread", + }, + thread_ts: { + type: "string", + description: "The timestamp of the parent message in the format '1234567890.123456'. Timestamps in the format without the period can be converted by adding the period such that 6 numbers come after it.", + }, + text: { + type: "string", + description: "The reply text", + }, + }, + required: ["channel_id", "thread_ts", "text"], + }, +}; + +const addReactionTool: Tool = { + name: "slack_add_reaction", + description: "Add a reaction emoji to a message", + inputSchema: { + type: "object", + properties: { + channel_id: { + type: "string", + description: "The ID of the channel containing the message", + }, + timestamp: { + type: "string", + description: "The timestamp of the message to react to", + }, + reaction: { + type: "string", + description: "The name of the emoji reaction (without ::)", + }, + }, + required: ["channel_id", "timestamp", "reaction"], + }, +}; + +const getChannelHistoryTool: Tool = { + name: "slack_get_channel_history", + description: "Get recent messages from a channel", + inputSchema: { + type: "object", + properties: { + channel_id: { + type: "string", + description: "The ID of the channel", + }, + limit: { + type: "number", + description: "Number of messages to retrieve (default 10)", + default: 10, + }, + }, + required: ["channel_id"], + }, +}; + +const getThreadRepliesTool: Tool = { + name: "slack_get_thread_replies", + description: "Get all replies in a message thread", + inputSchema: { + type: "object", + properties: { + channel_id: { + type: "string", + description: "The ID of the channel containing the thread", + }, + thread_ts: { + type: "string", + description: "The timestamp of the parent message in the format '1234567890.123456'. Timestamps in the format without the period can be converted by adding the period such that 6 numbers come after it.", + }, + }, + required: ["channel_id", "thread_ts"], + }, +}; + +const getUsersTool: Tool = { + name: "slack_get_users", + description: + "Get a list of all users in the workspace with their basic profile information", + inputSchema: { + type: "object", + properties: { + cursor: { + type: "string", + description: "Pagination cursor for next page of results", + }, + limit: { + type: "number", + description: "Maximum number of users to return (default 100, max 200)", + default: 100, + }, + }, + }, +}; + +const getUserProfileTool: Tool = { + name: "slack_get_user_profile", + description: "Get detailed profile information for a specific user", + inputSchema: { + type: "object", + properties: { + user_id: { + type: "string", + description: "The ID of the user", + }, + }, + required: ["user_id"], + }, +}; + +class SlackClient { + private botHeaders: { Authorization: string; "Content-Type": string }; + + constructor(botToken: string) { + this.botHeaders = { + Authorization: `Bearer ${botToken}`, + "Content-Type": "application/json", + }; + } + + async getChannels(limit: number = 100, cursor?: string): Promise { + const predefinedChannelIds = process.env.SLACK_CHANNEL_IDS; + if (!predefinedChannelIds) { + const params = new URLSearchParams({ + types: "public_channel", + exclude_archived: "true", + limit: Math.min(limit, 200).toString(), + team_id: process.env.SLACK_TEAM_ID!, + }); + + if (cursor) { + params.append("cursor", cursor); + } + + const response = await fetch( + `https://slack.com/api/conversations.list?${params}`, + { headers: this.botHeaders }, + ); + + return response.json(); + } + + const predefinedChannelIdsArray = predefinedChannelIds.split(",").map((id: string) => id.trim()); + const channels = []; + + for (const channelId of predefinedChannelIdsArray) { + const params = new URLSearchParams({ + channel: channelId, + }); + + const response = await fetch( + `https://slack.com/api/conversations.info?${params}`, + { headers: this.botHeaders } + ); + const data = await response.json(); + + if (data.ok && data.channel && !data.channel.is_archived) { + channels.push(data.channel); + } + } + + return { + ok: true, + channels: channels, + response_metadata: { next_cursor: "" }, + }; + } + + async postMessage(channel_id: string, text: string): Promise { + const response = await fetch("https://slack.com/api/chat.postMessage", { + method: "POST", + headers: this.botHeaders, + body: JSON.stringify({ + channel: channel_id, + text: text, + }), + }); + + return response.json(); + } + + async postReply( + channel_id: string, + thread_ts: string, + text: string, + ): Promise { + const response = await fetch("https://slack.com/api/chat.postMessage", { + method: "POST", + headers: this.botHeaders, + body: JSON.stringify({ + channel: channel_id, + thread_ts: thread_ts, + text: text, + }), + }); + + return response.json(); + } + + async addReaction( + channel_id: string, + timestamp: string, + reaction: string, + ): Promise { + const response = await fetch("https://slack.com/api/reactions.add", { + method: "POST", + headers: this.botHeaders, + body: JSON.stringify({ + channel: channel_id, + timestamp: timestamp, + name: reaction, + }), + }); + + return response.json(); + } + + async getChannelHistory( + channel_id: string, + limit: number = 10, + ): Promise { + const params = new URLSearchParams({ + channel: channel_id, + limit: limit.toString(), + }); + + const response = await fetch( + `https://slack.com/api/conversations.history?${params}`, + { headers: this.botHeaders }, + ); + + return response.json(); + } + + async getThreadReplies(channel_id: string, thread_ts: string): Promise { + const params = new URLSearchParams({ + channel: channel_id, + ts: thread_ts, + }); + + const response = await fetch( + `https://slack.com/api/conversations.replies?${params}`, + { headers: this.botHeaders }, + ); + + return response.json(); + } + + async getUsers(limit: number = 100, cursor?: string): Promise { + const params = new URLSearchParams({ + limit: Math.min(limit, 200).toString(), + team_id: process.env.SLACK_TEAM_ID!, + }); + + if (cursor) { + params.append("cursor", cursor); + } + + const response = await fetch(`https://slack.com/api/users.list?${params}`, { + headers: this.botHeaders, + }); + + return response.json(); + } + + async getUserProfile(user_id: string): Promise { + const params = new URLSearchParams({ + user: user_id, + include_labels: "true", + }); + + const response = await fetch( + `https://slack.com/api/users.profile.get?${params}`, + { headers: this.botHeaders }, + ); + + return response.json(); + } +} + +async function main() { + const botToken = process.env.SLACK_BOT_TOKEN; + const teamId = process.env.SLACK_TEAM_ID; + + if (!botToken || !teamId) { + console.error( + "Please set SLACK_BOT_TOKEN and SLACK_TEAM_ID environment variables", + ); + process.exit(1); + } + + console.error("Starting Slack MCP Server..."); + const server = new Server( + { + name: "Slack MCP Server", + version: "1.0.0", + }, + { + capabilities: { + tools: {}, + }, + }, + ); + + const slackClient = new SlackClient(botToken); + + server.setRequestHandler( + CallToolRequestSchema, + async (request: CallToolRequest) => { + console.error("Received CallToolRequest:", request); + try { + if (!request.params.arguments) { + throw new Error("No arguments provided"); + } + + switch (request.params.name) { + case "slack_list_channels": { + const args = request.params + .arguments as unknown as ListChannelsArgs; + const response = await slackClient.getChannels( + args.limit, + args.cursor, + ); + return { + content: [{ type: "text", text: JSON.stringify(response) }], + }; + } + + case "slack_post_message": { + const args = request.params.arguments as unknown as PostMessageArgs; + if (!args.channel_id || !args.text) { + throw new Error( + "Missing required arguments: channel_id and text", + ); + } + const response = await slackClient.postMessage( + args.channel_id, + args.text, + ); + return { + content: [{ type: "text", text: JSON.stringify(response) }], + }; + } + + case "slack_reply_to_thread": { + const args = request.params + .arguments as unknown as ReplyToThreadArgs; + if (!args.channel_id || !args.thread_ts || !args.text) { + throw new Error( + "Missing required arguments: channel_id, thread_ts, and text", + ); + } + const response = await slackClient.postReply( + args.channel_id, + args.thread_ts, + args.text, + ); + return { + content: [{ type: "text", text: JSON.stringify(response) }], + }; + } + + case "slack_add_reaction": { + const args = request.params.arguments as unknown as AddReactionArgs; + if (!args.channel_id || !args.timestamp || !args.reaction) { + throw new Error( + "Missing required arguments: channel_id, timestamp, and reaction", + ); + } + const response = await slackClient.addReaction( + args.channel_id, + args.timestamp, + args.reaction, + ); + return { + content: [{ type: "text", text: JSON.stringify(response) }], + }; + } + + case "slack_get_channel_history": { + const args = request.params + .arguments as unknown as GetChannelHistoryArgs; + if (!args.channel_id) { + throw new Error("Missing required argument: channel_id"); + } + const response = await slackClient.getChannelHistory( + args.channel_id, + args.limit, + ); + return { + content: [{ type: "text", text: JSON.stringify(response) }], + }; + } + + case "slack_get_thread_replies": { + const args = request.params + .arguments as unknown as GetThreadRepliesArgs; + if (!args.channel_id || !args.thread_ts) { + throw new Error( + "Missing required arguments: channel_id and thread_ts", + ); + } + const response = await slackClient.getThreadReplies( + args.channel_id, + args.thread_ts, + ); + return { + content: [{ type: "text", text: JSON.stringify(response) }], + }; + } + + case "slack_get_users": { + const args = request.params.arguments as unknown as GetUsersArgs; + const response = await slackClient.getUsers( + args.limit, + args.cursor, + ); + return { + content: [{ type: "text", text: JSON.stringify(response) }], + }; + } + + case "slack_get_user_profile": { + const args = request.params + .arguments as unknown as GetUserProfileArgs; + if (!args.user_id) { + throw new Error("Missing required argument: user_id"); + } + const response = await slackClient.getUserProfile(args.user_id); + return { + content: [{ type: "text", text: JSON.stringify(response) }], + }; + } + + default: + throw new Error(`Unknown tool: ${request.params.name}`); + } + } catch (error) { + console.error("Error executing tool:", error); + return { + content: [ + { + type: "text", + text: JSON.stringify({ + error: error instanceof Error ? error.message : String(error), + }), + }, + ], + }; + } + }, + ); + + server.setRequestHandler(ListToolsRequestSchema, async () => { + console.error("Received ListToolsRequest"); + return { + tools: [ + listChannelsTool, + postMessageTool, + replyToThreadTool, + addReactionTool, + getChannelHistoryTool, + getThreadRepliesTool, + getUsersTool, + getUserProfileTool, + ], + }; + }); + + const transport = new StdioServerTransport(); + console.error("Connecting server to transport..."); + await server.connect(transport); + + console.error("Slack MCP Server running on stdio"); +} + +main().catch((error) => { + console.error("Fatal error in main():", error); + process.exit(1); +}); \ No newline at end of file From bd133a7578c1bb86b35435cd4001e50681418f29 Mon Sep 17 00:00:00 2001 From: Taylor Wilsdon Date: Thu, 17 Apr 2025 15:42:35 -0700 Subject: [PATCH 02/13] stub out readme --- servers/slack/README.md | 78 ++++++++++++++++++++++++++++++++++------- 1 file changed, 65 insertions(+), 13 deletions(-) diff --git a/servers/slack/README.md b/servers/slack/README.md index 66f8523..9e836bd 100644 --- a/servers/slack/README.md +++ b/servers/slack/README.md @@ -1,23 +1,28 @@ -# β›… Weather Tool Server +# πŸ’¬ Slack Tool Server -A sleek and simple FastAPI-based server to provide weather data using OpenAPI standards. +A powerful FastAPI-based server providing Slack workspace interactions using OpenAPI standards. πŸ“¦ Built with: -⚑️ FastAPI β€’ πŸ“œ OpenAPI β€’ 🧰 Python +⚑️ FastAPI β€’ πŸ“œ OpenAPI β€’ 🐍 Python β€’ πŸ’¬ Slack API --- ## πŸš€ Quickstart -Clone the repo and get started in seconds: +Clone the repo and get started: ```bash git clone https://github.com/open-webui/openapi-servers -cd openapi-servers/servers/weather +cd openapi-servers/servers/slack # Install dependencies pip install -r requirements.txt +# Set up environment variables +export SLACK_BOT_TOKEN="xoxb-your-bot-token" +export SLACK_TEAM_ID="your-team-id" +export SLACK_CHANNEL_IDS="comma,separated,channel,ids" # Optional: restrict to specific channels + # Run the server uvicorn main:app --host 0.0.0.0 --reload ``` @@ -26,29 +31,76 @@ uvicorn main:app --host 0.0.0.0 --reload ## πŸ” About -This server is part of the OpenAPI Tools Collection. Use it to fetch real-time weather information, location-based forecasts, and more β€” all wrapped in a developer-friendly OpenAPI interface. +This server is part of the OpenAPI Tools Collection. It provides a comprehensive interface to Slack workspace operations, including: -Compatible with any OpenAPI-supported ecosystem, including: +- πŸ“‹ List channels with message history +- πŸ“€ Post messages and replies +- πŸ‘₯ User information and profiles +- πŸ‘‹ Add reactions to messages +- πŸ“œ View message threads and history -- πŸŒ€ FastAPI -- πŸ“˜ Swagger UI -- πŸ§ͺ API testing tools +All functionality is wrapped in a developer-friendly OpenAPI interface, making it perfect for integration with AI agents, automation tools, or custom Slack applications. --- -## 🚧 Customization +## πŸ”‘ Prerequisites -Plug in your favorite weather provider API, tailor endpoints, or extend the OpenAPI spec. Ideal for integration into AI agents, automated dashboards, or personal assistants. +1. **Slack Bot Token**: Create a Slack App and get a Bot User OAuth Token + - Visit [Slack API Apps](https://api.slack.com/apps) + - Create a new app or select existing + - Add necessary bot scopes: + - `channels:history` + - `channels:read` + - `chat:write` + - `reactions:write` + - `users:read` + - `users:read.email` + - Install the app to your workspace + +2. **Team ID**: Your Slack workspace/team ID + - Found in workspace settings or URL + +3. **Channel IDs** (Optional): + - Restrict the server to specific channels + - Comma-separated list of channel IDs + +--- + +## πŸ› οΈ Available Tools + +The server provides the following Slack tools: + +- `slack_list_channels`: List channels with recent message history +- `slack_post_message`: Send messages to channels +- `slack_reply_to_thread`: Reply to message threads +- `slack_add_reaction`: Add emoji reactions to messages +- `slack_get_channel_history`: Get channel message history +- `slack_get_thread_replies`: Get replies in a thread +- `slack_get_users`: List workspace users +- `slack_get_user_profile`: Get detailed user profiles + +Each tool is available as a dedicated endpoint with full OpenAPI documentation. --- ## 🌐 API Documentation -Once running, explore auto-generated interactive docs: +Once running, explore the interactive API documentation: πŸ–₯️ Swagger UI: http://localhost:8000/docs πŸ“„ OpenAPI JSON: http://localhost:8000/openapi.json +The documentation includes detailed schemas, example requests, and response formats for all available tools. + +--- + +## πŸ”’ Security Notes + +- Keep your `SLACK_BOT_TOKEN` secure +- Use environment variables for sensitive credentials +- Consider implementing additional authentication for the API server in production +- Review Slack's [security best practices](https://api.slack.com/authentication/best-practices) + --- Made with ❀️ by the Open WebUI community 🌍 From f27804f4f95f3463b46f1ef43369a7e06fd128cf Mon Sep 17 00:00:00 2001 From: Taylor Wilsdon Date: Thu, 17 Apr 2025 15:59:49 -0700 Subject: [PATCH 03/13] fix pydantic json schema warnings --- servers/slack/main.py | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/servers/slack/main.py b/servers/slack/main.py index 4b2f23c..a8d050e 100644 --- a/servers/slack/main.py +++ b/servers/slack/main.py @@ -2,7 +2,7 @@ import os import httpx import inspect -from typing import Optional, List, Dict, Any, Type +from typing import Optional, List, Dict, Any, Type, Callable from fastapi import FastAPI, HTTPException, Body, Depends from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel, Field @@ -269,31 +269,35 @@ TOOL_MAPPING = { }, } -# Dynamically create endpoints for each tool -for tool_name, config in TOOL_MAPPING.items(): - args_model = config["args_model"] - method_to_call = config["method"] - tool_description = config["description"] - - async def endpoint_func(args: args_model = Body(...), # type: ignore - method=method_to_call): # Capture method in closure +# Define a function factory to create endpoint handlers +def create_endpoint_handler(tool_name: str, method: Callable, args_model: Type[BaseModel]): + async def endpoint_handler(args: args_model = Body(...)) -> ToolResponse: try: result = await method(args=args) return {"content": result} except HTTPException as e: raise e except Exception as e: - print(f"Error executing tool: {e}") + print(f"Error executing tool {tool_name}: {e}") raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") + return endpoint_handler +# Register endpoints for each tool +for tool_name, config in TOOL_MAPPING.items(): + handler = create_endpoint_handler( + tool_name=tool_name, + method=config["method"], + args_model=config["args_model"] + ) + app.post( f"/{tool_name}", response_model=ToolResponse, - summary=tool_description, + summary=config["description"], description=f"Executes the {tool_name} tool. Arguments are passed in the request body.", tags=["Slack Tools"], name=tool_name - )(endpoint_func) + )(handler) # --- Root Endpoint --- @app.get("/", summary="Root endpoint", include_in_schema=False) From d86da0e3994c3e7d0e523a5fc235bc526d75abcc Mon Sep 17 00:00:00 2001 From: Taylor Wilsdon Date: Thu, 17 Apr 2025 15:59:58 -0700 Subject: [PATCH 04/13] fix pydantic json schema warnings --- servers/slack/main.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/servers/slack/main.py b/servers/slack/main.py index a8d050e..d4afb38 100644 --- a/servers/slack/main.py +++ b/servers/slack/main.py @@ -119,19 +119,19 @@ class SlackClient: async def get_channels(self, args: ListChannelsArgs) -> Dict[str, Any]: limit = args.limit cursor = args.cursor - + async def fetch_channel_with_history(channel_id: str) -> Dict[str, Any]: # First get channel info channel_info = await self._request("GET", "conversations.info", params={"channel": channel_id}) if not channel_info.get("ok") or channel_info.get("channel", {}).get("is_archived"): return None - + channel_data = channel_info["channel"] - + # Then get channel history try: history = await self._request( - "GET", + "GET", "conversations.history", params={ "channel": channel_id, @@ -144,7 +144,7 @@ class SlackClient: except Exception as e: print(f"Error fetching history for channel {channel_id}: {e}") channel_data["history"] = [] - + return channel_data if PREDEFINED_CHANNEL_IDS: @@ -155,7 +155,7 @@ class SlackClient: channels_info.append(channel_data) except Exception as e: print(f"Could not fetch info for predefined channel {channel_id}: {e}") - + return { "ok": True, "channels": channels_info, @@ -171,12 +171,12 @@ class SlackClient: } if cursor: params["cursor"] = cursor - + channels_list = await self._request("GET", "conversations.list", params=params) - + if not channels_list.get("ok"): return channels_list - + # Then fetch history for each channel channels_with_history = [] for channel in channels_list["channels"]: @@ -186,7 +186,7 @@ class SlackClient: except Exception as e: print(f"Error fetching history for channel {channel['id']}: {e}") channels_with_history.append(channel) # Fall back to channel info without history - + return { "ok": True, "channels": channels_with_history, @@ -289,7 +289,7 @@ for tool_name, config in TOOL_MAPPING.items(): method=config["method"], args_model=config["args_model"] ) - + app.post( f"/{tool_name}", response_model=ToolResponse, From fbd765fa4e7997d0305882e7273fbe12a4d46c0e Mon Sep 17 00:00:00 2001 From: Taylor Wilsdon Date: Thu, 17 Apr 2025 16:00:27 -0700 Subject: [PATCH 05/13] stub out readme --- servers/slack/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/servers/slack/README.md b/servers/slack/README.md index 9e836bd..66a9a5b 100644 --- a/servers/slack/README.md +++ b/servers/slack/README.md @@ -2,7 +2,7 @@ A powerful FastAPI-based server providing Slack workspace interactions using OpenAPI standards. -πŸ“¦ Built with: +πŸ“¦ Built with: ⚑️ FastAPI β€’ πŸ“œ OpenAPI β€’ 🐍 Python β€’ πŸ’¬ Slack API --- @@ -87,7 +87,7 @@ Each tool is available as a dedicated endpoint with full OpenAPI documentation. Once running, explore the interactive API documentation: -πŸ–₯️ Swagger UI: http://localhost:8000/docs +πŸ–₯️ Swagger UI: http://localhost:8000/docs πŸ“„ OpenAPI JSON: http://localhost:8000/openapi.json The documentation includes detailed schemas, example requests, and response formats for all available tools. @@ -103,5 +103,5 @@ The documentation includes detailed schemas, example requests, and response form --- -Made with ❀️ by the Open WebUI community 🌍 +Made with ❀️ by the Open WebUI community 🌍 Explore more tools ➑️ https://github.com/open-webui/openapi-servers \ No newline at end of file From fd91c939f24ae4dc8048e69e8c29456be9739158 Mon Sep 17 00:00:00 2001 From: Taylor Wilsdon Date: Thu, 17 Apr 2025 16:17:58 -0700 Subject: [PATCH 06/13] Update README.md --- servers/slack/README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/servers/slack/README.md b/servers/slack/README.md index 66a9a5b..d2055d1 100644 --- a/servers/slack/README.md +++ b/servers/slack/README.md @@ -44,7 +44,7 @@ All functionality is wrapped in a developer-friendly OpenAPI interface, making i --- ## πŸ”‘ Prerequisites - +Most of this is pulled straight from the Slack Python SDK so the barebones readme can easily be supplemented by reading the official one. Setup looks like: 1. **Slack Bot Token**: Create a Slack App and get a Bot User OAuth Token - Visit [Slack API Apps](https://api.slack.com/apps) - Create a new app or select existing @@ -56,9 +56,10 @@ All functionality is wrapped in a developer-friendly OpenAPI interface, making i - `users:read` - `users:read.email` - Install the app to your workspace + - You'll get the bot token on the last screen. 2. **Team ID**: Your Slack workspace/team ID - - Found in workspace settings or URL + - Found in workspace settings or URL (go to your slack instance via web and it'll be after the slash) 3. **Channel IDs** (Optional): - Restrict the server to specific channels @@ -104,4 +105,4 @@ The documentation includes detailed schemas, example requests, and response form --- Made with ❀️ by the Open WebUI community 🌍 -Explore more tools ➑️ https://github.com/open-webui/openapi-servers \ No newline at end of file +Explore more tools ➑️ https://github.com/open-webui/openapi-servers From fa3eda516efe168d87d289bf391f0e13f35cfb9f Mon Sep 17 00:00:00 2001 From: Taylor Wilsdon Date: Fri, 18 Apr 2025 10:17:08 -0400 Subject: [PATCH 07/13] Delete servers/slack/slack.ts --- servers/slack/slack.ts | 582 ----------------------------------------- 1 file changed, 582 deletions(-) delete mode 100644 servers/slack/slack.ts diff --git a/servers/slack/slack.ts b/servers/slack/slack.ts deleted file mode 100644 index dde2c8b..0000000 --- a/servers/slack/slack.ts +++ /dev/null @@ -1,582 +0,0 @@ -#!/usr/bin/env node -import { Server } from "@modelcontextprotocol/sdk/server/index.js"; -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; -import { - CallToolRequest, - CallToolRequestSchema, - ListToolsRequestSchema, - Tool, -} from "@modelcontextprotocol/sdk/types.js"; - -// Type definitions for tool arguments -interface ListChannelsArgs { - limit?: number; - cursor?: string; -} - -interface PostMessageArgs { - channel_id: string; - text: string; -} - -interface ReplyToThreadArgs { - channel_id: string; - thread_ts: string; - text: string; -} - -interface AddReactionArgs { - channel_id: string; - timestamp: string; - reaction: string; -} - -interface GetChannelHistoryArgs { - channel_id: string; - limit?: number; -} - -interface GetThreadRepliesArgs { - channel_id: string; - thread_ts: string; -} - -interface GetUsersArgs { - cursor?: string; - limit?: number; -} - -interface GetUserProfileArgs { - user_id: string; -} - -// Tool definitions -const listChannelsTool: Tool = { - name: "slack_list_channels", - description: "List public or pre-defined channels in the workspace with pagination", - inputSchema: { - type: "object", - properties: { - limit: { - type: "number", - description: - "Maximum number of channels to return (default 100, max 200)", - default: 100, - }, - cursor: { - type: "string", - description: "Pagination cursor for next page of results", - }, - }, - }, -}; - -const postMessageTool: Tool = { - name: "slack_post_message", - description: "Post a new message to a Slack channel", - inputSchema: { - type: "object", - properties: { - channel_id: { - type: "string", - description: "The ID of the channel to post to", - }, - text: { - type: "string", - description: "The message text to post", - }, - }, - required: ["channel_id", "text"], - }, -}; - -const replyToThreadTool: Tool = { - name: "slack_reply_to_thread", - description: "Reply to a specific message thread in Slack", - inputSchema: { - type: "object", - properties: { - channel_id: { - type: "string", - description: "The ID of the channel containing the thread", - }, - thread_ts: { - type: "string", - description: "The timestamp of the parent message in the format '1234567890.123456'. Timestamps in the format without the period can be converted by adding the period such that 6 numbers come after it.", - }, - text: { - type: "string", - description: "The reply text", - }, - }, - required: ["channel_id", "thread_ts", "text"], - }, -}; - -const addReactionTool: Tool = { - name: "slack_add_reaction", - description: "Add a reaction emoji to a message", - inputSchema: { - type: "object", - properties: { - channel_id: { - type: "string", - description: "The ID of the channel containing the message", - }, - timestamp: { - type: "string", - description: "The timestamp of the message to react to", - }, - reaction: { - type: "string", - description: "The name of the emoji reaction (without ::)", - }, - }, - required: ["channel_id", "timestamp", "reaction"], - }, -}; - -const getChannelHistoryTool: Tool = { - name: "slack_get_channel_history", - description: "Get recent messages from a channel", - inputSchema: { - type: "object", - properties: { - channel_id: { - type: "string", - description: "The ID of the channel", - }, - limit: { - type: "number", - description: "Number of messages to retrieve (default 10)", - default: 10, - }, - }, - required: ["channel_id"], - }, -}; - -const getThreadRepliesTool: Tool = { - name: "slack_get_thread_replies", - description: "Get all replies in a message thread", - inputSchema: { - type: "object", - properties: { - channel_id: { - type: "string", - description: "The ID of the channel containing the thread", - }, - thread_ts: { - type: "string", - description: "The timestamp of the parent message in the format '1234567890.123456'. Timestamps in the format without the period can be converted by adding the period such that 6 numbers come after it.", - }, - }, - required: ["channel_id", "thread_ts"], - }, -}; - -const getUsersTool: Tool = { - name: "slack_get_users", - description: - "Get a list of all users in the workspace with their basic profile information", - inputSchema: { - type: "object", - properties: { - cursor: { - type: "string", - description: "Pagination cursor for next page of results", - }, - limit: { - type: "number", - description: "Maximum number of users to return (default 100, max 200)", - default: 100, - }, - }, - }, -}; - -const getUserProfileTool: Tool = { - name: "slack_get_user_profile", - description: "Get detailed profile information for a specific user", - inputSchema: { - type: "object", - properties: { - user_id: { - type: "string", - description: "The ID of the user", - }, - }, - required: ["user_id"], - }, -}; - -class SlackClient { - private botHeaders: { Authorization: string; "Content-Type": string }; - - constructor(botToken: string) { - this.botHeaders = { - Authorization: `Bearer ${botToken}`, - "Content-Type": "application/json", - }; - } - - async getChannels(limit: number = 100, cursor?: string): Promise { - const predefinedChannelIds = process.env.SLACK_CHANNEL_IDS; - if (!predefinedChannelIds) { - const params = new URLSearchParams({ - types: "public_channel", - exclude_archived: "true", - limit: Math.min(limit, 200).toString(), - team_id: process.env.SLACK_TEAM_ID!, - }); - - if (cursor) { - params.append("cursor", cursor); - } - - const response = await fetch( - `https://slack.com/api/conversations.list?${params}`, - { headers: this.botHeaders }, - ); - - return response.json(); - } - - const predefinedChannelIdsArray = predefinedChannelIds.split(",").map((id: string) => id.trim()); - const channels = []; - - for (const channelId of predefinedChannelIdsArray) { - const params = new URLSearchParams({ - channel: channelId, - }); - - const response = await fetch( - `https://slack.com/api/conversations.info?${params}`, - { headers: this.botHeaders } - ); - const data = await response.json(); - - if (data.ok && data.channel && !data.channel.is_archived) { - channels.push(data.channel); - } - } - - return { - ok: true, - channels: channels, - response_metadata: { next_cursor: "" }, - }; - } - - async postMessage(channel_id: string, text: string): Promise { - const response = await fetch("https://slack.com/api/chat.postMessage", { - method: "POST", - headers: this.botHeaders, - body: JSON.stringify({ - channel: channel_id, - text: text, - }), - }); - - return response.json(); - } - - async postReply( - channel_id: string, - thread_ts: string, - text: string, - ): Promise { - const response = await fetch("https://slack.com/api/chat.postMessage", { - method: "POST", - headers: this.botHeaders, - body: JSON.stringify({ - channel: channel_id, - thread_ts: thread_ts, - text: text, - }), - }); - - return response.json(); - } - - async addReaction( - channel_id: string, - timestamp: string, - reaction: string, - ): Promise { - const response = await fetch("https://slack.com/api/reactions.add", { - method: "POST", - headers: this.botHeaders, - body: JSON.stringify({ - channel: channel_id, - timestamp: timestamp, - name: reaction, - }), - }); - - return response.json(); - } - - async getChannelHistory( - channel_id: string, - limit: number = 10, - ): Promise { - const params = new URLSearchParams({ - channel: channel_id, - limit: limit.toString(), - }); - - const response = await fetch( - `https://slack.com/api/conversations.history?${params}`, - { headers: this.botHeaders }, - ); - - return response.json(); - } - - async getThreadReplies(channel_id: string, thread_ts: string): Promise { - const params = new URLSearchParams({ - channel: channel_id, - ts: thread_ts, - }); - - const response = await fetch( - `https://slack.com/api/conversations.replies?${params}`, - { headers: this.botHeaders }, - ); - - return response.json(); - } - - async getUsers(limit: number = 100, cursor?: string): Promise { - const params = new URLSearchParams({ - limit: Math.min(limit, 200).toString(), - team_id: process.env.SLACK_TEAM_ID!, - }); - - if (cursor) { - params.append("cursor", cursor); - } - - const response = await fetch(`https://slack.com/api/users.list?${params}`, { - headers: this.botHeaders, - }); - - return response.json(); - } - - async getUserProfile(user_id: string): Promise { - const params = new URLSearchParams({ - user: user_id, - include_labels: "true", - }); - - const response = await fetch( - `https://slack.com/api/users.profile.get?${params}`, - { headers: this.botHeaders }, - ); - - return response.json(); - } -} - -async function main() { - const botToken = process.env.SLACK_BOT_TOKEN; - const teamId = process.env.SLACK_TEAM_ID; - - if (!botToken || !teamId) { - console.error( - "Please set SLACK_BOT_TOKEN and SLACK_TEAM_ID environment variables", - ); - process.exit(1); - } - - console.error("Starting Slack MCP Server..."); - const server = new Server( - { - name: "Slack MCP Server", - version: "1.0.0", - }, - { - capabilities: { - tools: {}, - }, - }, - ); - - const slackClient = new SlackClient(botToken); - - server.setRequestHandler( - CallToolRequestSchema, - async (request: CallToolRequest) => { - console.error("Received CallToolRequest:", request); - try { - if (!request.params.arguments) { - throw new Error("No arguments provided"); - } - - switch (request.params.name) { - case "slack_list_channels": { - const args = request.params - .arguments as unknown as ListChannelsArgs; - const response = await slackClient.getChannels( - args.limit, - args.cursor, - ); - return { - content: [{ type: "text", text: JSON.stringify(response) }], - }; - } - - case "slack_post_message": { - const args = request.params.arguments as unknown as PostMessageArgs; - if (!args.channel_id || !args.text) { - throw new Error( - "Missing required arguments: channel_id and text", - ); - } - const response = await slackClient.postMessage( - args.channel_id, - args.text, - ); - return { - content: [{ type: "text", text: JSON.stringify(response) }], - }; - } - - case "slack_reply_to_thread": { - const args = request.params - .arguments as unknown as ReplyToThreadArgs; - if (!args.channel_id || !args.thread_ts || !args.text) { - throw new Error( - "Missing required arguments: channel_id, thread_ts, and text", - ); - } - const response = await slackClient.postReply( - args.channel_id, - args.thread_ts, - args.text, - ); - return { - content: [{ type: "text", text: JSON.stringify(response) }], - }; - } - - case "slack_add_reaction": { - const args = request.params.arguments as unknown as AddReactionArgs; - if (!args.channel_id || !args.timestamp || !args.reaction) { - throw new Error( - "Missing required arguments: channel_id, timestamp, and reaction", - ); - } - const response = await slackClient.addReaction( - args.channel_id, - args.timestamp, - args.reaction, - ); - return { - content: [{ type: "text", text: JSON.stringify(response) }], - }; - } - - case "slack_get_channel_history": { - const args = request.params - .arguments as unknown as GetChannelHistoryArgs; - if (!args.channel_id) { - throw new Error("Missing required argument: channel_id"); - } - const response = await slackClient.getChannelHistory( - args.channel_id, - args.limit, - ); - return { - content: [{ type: "text", text: JSON.stringify(response) }], - }; - } - - case "slack_get_thread_replies": { - const args = request.params - .arguments as unknown as GetThreadRepliesArgs; - if (!args.channel_id || !args.thread_ts) { - throw new Error( - "Missing required arguments: channel_id and thread_ts", - ); - } - const response = await slackClient.getThreadReplies( - args.channel_id, - args.thread_ts, - ); - return { - content: [{ type: "text", text: JSON.stringify(response) }], - }; - } - - case "slack_get_users": { - const args = request.params.arguments as unknown as GetUsersArgs; - const response = await slackClient.getUsers( - args.limit, - args.cursor, - ); - return { - content: [{ type: "text", text: JSON.stringify(response) }], - }; - } - - case "slack_get_user_profile": { - const args = request.params - .arguments as unknown as GetUserProfileArgs; - if (!args.user_id) { - throw new Error("Missing required argument: user_id"); - } - const response = await slackClient.getUserProfile(args.user_id); - return { - content: [{ type: "text", text: JSON.stringify(response) }], - }; - } - - default: - throw new Error(`Unknown tool: ${request.params.name}`); - } - } catch (error) { - console.error("Error executing tool:", error); - return { - content: [ - { - type: "text", - text: JSON.stringify({ - error: error instanceof Error ? error.message : String(error), - }), - }, - ], - }; - } - }, - ); - - server.setRequestHandler(ListToolsRequestSchema, async () => { - console.error("Received ListToolsRequest"); - return { - tools: [ - listChannelsTool, - postMessageTool, - replyToThreadTool, - addReactionTool, - getChannelHistoryTool, - getThreadRepliesTool, - getUsersTool, - getUserProfileTool, - ], - }; - }); - - const transport = new StdioServerTransport(); - console.error("Connecting server to transport..."); - await server.connect(transport); - - console.error("Slack MCP Server running on stdio"); -} - -main().catch((error) => { - console.error("Fatal error in main():", error); - process.exit(1); -}); \ No newline at end of file From e9757341c2ec450458f55cbb61e94d61ba809a7d Mon Sep 17 00:00:00 2001 From: Taylor Wilsdon Date: Fri, 18 Apr 2025 10:19:11 -0400 Subject: [PATCH 08/13] Update readme --- servers/slack/README.md | 4 +- servers/slack/slack.ts | 582 ---------------------------------------- 2 files changed, 2 insertions(+), 584 deletions(-) delete mode 100644 servers/slack/slack.ts diff --git a/servers/slack/README.md b/servers/slack/README.md index d2055d1..4c3258a 100644 --- a/servers/slack/README.md +++ b/servers/slack/README.md @@ -21,7 +21,7 @@ pip install -r requirements.txt # Set up environment variables export SLACK_BOT_TOKEN="xoxb-your-bot-token" export SLACK_TEAM_ID="your-team-id" -export SLACK_CHANNEL_IDS="comma,separated,channel,ids" # Optional: restrict to specific channels +export SLACK_CHANNEL_IDS="comma,separated,channel,ids" # Optional: restrict to specific channels - leave blank to include all channels that the bot user has been added to # Run the server uvicorn main:app --host 0.0.0.0 --reload @@ -44,7 +44,7 @@ All functionality is wrapped in a developer-friendly OpenAPI interface, making i --- ## πŸ”‘ Prerequisites -Most of this is pulled straight from the Slack Python SDK so the barebones readme can easily be supplemented by reading the official one. Setup looks like: +Most of this is pulled straight from the Slack Python SDK so the barebones readme can easily be supplemented by reading the official docs. To set up, you need to follow these steps: 1. **Slack Bot Token**: Create a Slack App and get a Bot User OAuth Token - Visit [Slack API Apps](https://api.slack.com/apps) - Create a new app or select existing diff --git a/servers/slack/slack.ts b/servers/slack/slack.ts deleted file mode 100644 index dde2c8b..0000000 --- a/servers/slack/slack.ts +++ /dev/null @@ -1,582 +0,0 @@ -#!/usr/bin/env node -import { Server } from "@modelcontextprotocol/sdk/server/index.js"; -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; -import { - CallToolRequest, - CallToolRequestSchema, - ListToolsRequestSchema, - Tool, -} from "@modelcontextprotocol/sdk/types.js"; - -// Type definitions for tool arguments -interface ListChannelsArgs { - limit?: number; - cursor?: string; -} - -interface PostMessageArgs { - channel_id: string; - text: string; -} - -interface ReplyToThreadArgs { - channel_id: string; - thread_ts: string; - text: string; -} - -interface AddReactionArgs { - channel_id: string; - timestamp: string; - reaction: string; -} - -interface GetChannelHistoryArgs { - channel_id: string; - limit?: number; -} - -interface GetThreadRepliesArgs { - channel_id: string; - thread_ts: string; -} - -interface GetUsersArgs { - cursor?: string; - limit?: number; -} - -interface GetUserProfileArgs { - user_id: string; -} - -// Tool definitions -const listChannelsTool: Tool = { - name: "slack_list_channels", - description: "List public or pre-defined channels in the workspace with pagination", - inputSchema: { - type: "object", - properties: { - limit: { - type: "number", - description: - "Maximum number of channels to return (default 100, max 200)", - default: 100, - }, - cursor: { - type: "string", - description: "Pagination cursor for next page of results", - }, - }, - }, -}; - -const postMessageTool: Tool = { - name: "slack_post_message", - description: "Post a new message to a Slack channel", - inputSchema: { - type: "object", - properties: { - channel_id: { - type: "string", - description: "The ID of the channel to post to", - }, - text: { - type: "string", - description: "The message text to post", - }, - }, - required: ["channel_id", "text"], - }, -}; - -const replyToThreadTool: Tool = { - name: "slack_reply_to_thread", - description: "Reply to a specific message thread in Slack", - inputSchema: { - type: "object", - properties: { - channel_id: { - type: "string", - description: "The ID of the channel containing the thread", - }, - thread_ts: { - type: "string", - description: "The timestamp of the parent message in the format '1234567890.123456'. Timestamps in the format without the period can be converted by adding the period such that 6 numbers come after it.", - }, - text: { - type: "string", - description: "The reply text", - }, - }, - required: ["channel_id", "thread_ts", "text"], - }, -}; - -const addReactionTool: Tool = { - name: "slack_add_reaction", - description: "Add a reaction emoji to a message", - inputSchema: { - type: "object", - properties: { - channel_id: { - type: "string", - description: "The ID of the channel containing the message", - }, - timestamp: { - type: "string", - description: "The timestamp of the message to react to", - }, - reaction: { - type: "string", - description: "The name of the emoji reaction (without ::)", - }, - }, - required: ["channel_id", "timestamp", "reaction"], - }, -}; - -const getChannelHistoryTool: Tool = { - name: "slack_get_channel_history", - description: "Get recent messages from a channel", - inputSchema: { - type: "object", - properties: { - channel_id: { - type: "string", - description: "The ID of the channel", - }, - limit: { - type: "number", - description: "Number of messages to retrieve (default 10)", - default: 10, - }, - }, - required: ["channel_id"], - }, -}; - -const getThreadRepliesTool: Tool = { - name: "slack_get_thread_replies", - description: "Get all replies in a message thread", - inputSchema: { - type: "object", - properties: { - channel_id: { - type: "string", - description: "The ID of the channel containing the thread", - }, - thread_ts: { - type: "string", - description: "The timestamp of the parent message in the format '1234567890.123456'. Timestamps in the format without the period can be converted by adding the period such that 6 numbers come after it.", - }, - }, - required: ["channel_id", "thread_ts"], - }, -}; - -const getUsersTool: Tool = { - name: "slack_get_users", - description: - "Get a list of all users in the workspace with their basic profile information", - inputSchema: { - type: "object", - properties: { - cursor: { - type: "string", - description: "Pagination cursor for next page of results", - }, - limit: { - type: "number", - description: "Maximum number of users to return (default 100, max 200)", - default: 100, - }, - }, - }, -}; - -const getUserProfileTool: Tool = { - name: "slack_get_user_profile", - description: "Get detailed profile information for a specific user", - inputSchema: { - type: "object", - properties: { - user_id: { - type: "string", - description: "The ID of the user", - }, - }, - required: ["user_id"], - }, -}; - -class SlackClient { - private botHeaders: { Authorization: string; "Content-Type": string }; - - constructor(botToken: string) { - this.botHeaders = { - Authorization: `Bearer ${botToken}`, - "Content-Type": "application/json", - }; - } - - async getChannels(limit: number = 100, cursor?: string): Promise { - const predefinedChannelIds = process.env.SLACK_CHANNEL_IDS; - if (!predefinedChannelIds) { - const params = new URLSearchParams({ - types: "public_channel", - exclude_archived: "true", - limit: Math.min(limit, 200).toString(), - team_id: process.env.SLACK_TEAM_ID!, - }); - - if (cursor) { - params.append("cursor", cursor); - } - - const response = await fetch( - `https://slack.com/api/conversations.list?${params}`, - { headers: this.botHeaders }, - ); - - return response.json(); - } - - const predefinedChannelIdsArray = predefinedChannelIds.split(",").map((id: string) => id.trim()); - const channels = []; - - for (const channelId of predefinedChannelIdsArray) { - const params = new URLSearchParams({ - channel: channelId, - }); - - const response = await fetch( - `https://slack.com/api/conversations.info?${params}`, - { headers: this.botHeaders } - ); - const data = await response.json(); - - if (data.ok && data.channel && !data.channel.is_archived) { - channels.push(data.channel); - } - } - - return { - ok: true, - channels: channels, - response_metadata: { next_cursor: "" }, - }; - } - - async postMessage(channel_id: string, text: string): Promise { - const response = await fetch("https://slack.com/api/chat.postMessage", { - method: "POST", - headers: this.botHeaders, - body: JSON.stringify({ - channel: channel_id, - text: text, - }), - }); - - return response.json(); - } - - async postReply( - channel_id: string, - thread_ts: string, - text: string, - ): Promise { - const response = await fetch("https://slack.com/api/chat.postMessage", { - method: "POST", - headers: this.botHeaders, - body: JSON.stringify({ - channel: channel_id, - thread_ts: thread_ts, - text: text, - }), - }); - - return response.json(); - } - - async addReaction( - channel_id: string, - timestamp: string, - reaction: string, - ): Promise { - const response = await fetch("https://slack.com/api/reactions.add", { - method: "POST", - headers: this.botHeaders, - body: JSON.stringify({ - channel: channel_id, - timestamp: timestamp, - name: reaction, - }), - }); - - return response.json(); - } - - async getChannelHistory( - channel_id: string, - limit: number = 10, - ): Promise { - const params = new URLSearchParams({ - channel: channel_id, - limit: limit.toString(), - }); - - const response = await fetch( - `https://slack.com/api/conversations.history?${params}`, - { headers: this.botHeaders }, - ); - - return response.json(); - } - - async getThreadReplies(channel_id: string, thread_ts: string): Promise { - const params = new URLSearchParams({ - channel: channel_id, - ts: thread_ts, - }); - - const response = await fetch( - `https://slack.com/api/conversations.replies?${params}`, - { headers: this.botHeaders }, - ); - - return response.json(); - } - - async getUsers(limit: number = 100, cursor?: string): Promise { - const params = new URLSearchParams({ - limit: Math.min(limit, 200).toString(), - team_id: process.env.SLACK_TEAM_ID!, - }); - - if (cursor) { - params.append("cursor", cursor); - } - - const response = await fetch(`https://slack.com/api/users.list?${params}`, { - headers: this.botHeaders, - }); - - return response.json(); - } - - async getUserProfile(user_id: string): Promise { - const params = new URLSearchParams({ - user: user_id, - include_labels: "true", - }); - - const response = await fetch( - `https://slack.com/api/users.profile.get?${params}`, - { headers: this.botHeaders }, - ); - - return response.json(); - } -} - -async function main() { - const botToken = process.env.SLACK_BOT_TOKEN; - const teamId = process.env.SLACK_TEAM_ID; - - if (!botToken || !teamId) { - console.error( - "Please set SLACK_BOT_TOKEN and SLACK_TEAM_ID environment variables", - ); - process.exit(1); - } - - console.error("Starting Slack MCP Server..."); - const server = new Server( - { - name: "Slack MCP Server", - version: "1.0.0", - }, - { - capabilities: { - tools: {}, - }, - }, - ); - - const slackClient = new SlackClient(botToken); - - server.setRequestHandler( - CallToolRequestSchema, - async (request: CallToolRequest) => { - console.error("Received CallToolRequest:", request); - try { - if (!request.params.arguments) { - throw new Error("No arguments provided"); - } - - switch (request.params.name) { - case "slack_list_channels": { - const args = request.params - .arguments as unknown as ListChannelsArgs; - const response = await slackClient.getChannels( - args.limit, - args.cursor, - ); - return { - content: [{ type: "text", text: JSON.stringify(response) }], - }; - } - - case "slack_post_message": { - const args = request.params.arguments as unknown as PostMessageArgs; - if (!args.channel_id || !args.text) { - throw new Error( - "Missing required arguments: channel_id and text", - ); - } - const response = await slackClient.postMessage( - args.channel_id, - args.text, - ); - return { - content: [{ type: "text", text: JSON.stringify(response) }], - }; - } - - case "slack_reply_to_thread": { - const args = request.params - .arguments as unknown as ReplyToThreadArgs; - if (!args.channel_id || !args.thread_ts || !args.text) { - throw new Error( - "Missing required arguments: channel_id, thread_ts, and text", - ); - } - const response = await slackClient.postReply( - args.channel_id, - args.thread_ts, - args.text, - ); - return { - content: [{ type: "text", text: JSON.stringify(response) }], - }; - } - - case "slack_add_reaction": { - const args = request.params.arguments as unknown as AddReactionArgs; - if (!args.channel_id || !args.timestamp || !args.reaction) { - throw new Error( - "Missing required arguments: channel_id, timestamp, and reaction", - ); - } - const response = await slackClient.addReaction( - args.channel_id, - args.timestamp, - args.reaction, - ); - return { - content: [{ type: "text", text: JSON.stringify(response) }], - }; - } - - case "slack_get_channel_history": { - const args = request.params - .arguments as unknown as GetChannelHistoryArgs; - if (!args.channel_id) { - throw new Error("Missing required argument: channel_id"); - } - const response = await slackClient.getChannelHistory( - args.channel_id, - args.limit, - ); - return { - content: [{ type: "text", text: JSON.stringify(response) }], - }; - } - - case "slack_get_thread_replies": { - const args = request.params - .arguments as unknown as GetThreadRepliesArgs; - if (!args.channel_id || !args.thread_ts) { - throw new Error( - "Missing required arguments: channel_id and thread_ts", - ); - } - const response = await slackClient.getThreadReplies( - args.channel_id, - args.thread_ts, - ); - return { - content: [{ type: "text", text: JSON.stringify(response) }], - }; - } - - case "slack_get_users": { - const args = request.params.arguments as unknown as GetUsersArgs; - const response = await slackClient.getUsers( - args.limit, - args.cursor, - ); - return { - content: [{ type: "text", text: JSON.stringify(response) }], - }; - } - - case "slack_get_user_profile": { - const args = request.params - .arguments as unknown as GetUserProfileArgs; - if (!args.user_id) { - throw new Error("Missing required argument: user_id"); - } - const response = await slackClient.getUserProfile(args.user_id); - return { - content: [{ type: "text", text: JSON.stringify(response) }], - }; - } - - default: - throw new Error(`Unknown tool: ${request.params.name}`); - } - } catch (error) { - console.error("Error executing tool:", error); - return { - content: [ - { - type: "text", - text: JSON.stringify({ - error: error instanceof Error ? error.message : String(error), - }), - }, - ], - }; - } - }, - ); - - server.setRequestHandler(ListToolsRequestSchema, async () => { - console.error("Received ListToolsRequest"); - return { - tools: [ - listChannelsTool, - postMessageTool, - replyToThreadTool, - addReactionTool, - getChannelHistoryTool, - getThreadRepliesTool, - getUsersTool, - getUserProfileTool, - ], - }; - }); - - const transport = new StdioServerTransport(); - console.error("Connecting server to transport..."); - await server.connect(transport); - - console.error("Slack MCP Server running on stdio"); -} - -main().catch((error) => { - console.error("Fatal error in main():", error); - process.exit(1); -}); \ No newline at end of file From 08dff6aaeb0b423fcffdc7980555bd6432c60ad0 Mon Sep 17 00:00:00 2001 From: Taylor Wilsdon Date: Fri, 18 Apr 2025 10:29:32 -0400 Subject: [PATCH 09/13] cleanup --- servers/slack/main.py | 1 - 1 file changed, 1 deletion(-) diff --git a/servers/slack/main.py b/servers/slack/main.py index d4afb38..0b4d957 100644 --- a/servers/slack/main.py +++ b/servers/slack/main.py @@ -1,4 +1,3 @@ -# [Previous imports and setup remain the same...] import os import httpx import inspect From 565a563a77108a458617e87513feb6bd4081a19d Mon Sep 17 00:00:00 2001 From: Taylor Wilsdon Date: Sun, 20 Apr 2025 19:44:52 -0400 Subject: [PATCH 10/13] implement review feedback, add auth, logging, and improve robustness --- servers/slack/Dockerfile | 8 ++- servers/slack/main.py | 101 ++++++++++++++++++++++++--------- servers/slack/requirements.txt | 14 +++-- 3 files changed, 89 insertions(+), 34 deletions(-) diff --git a/servers/slack/Dockerfile b/servers/slack/Dockerfile index e91fca9..3ae0b8e 100644 --- a/servers/slack/Dockerfile +++ b/servers/slack/Dockerfile @@ -47,5 +47,9 @@ COPY . . # Expose the port that the application listens on. EXPOSE 8000 -# Run the application. -CMD uvicorn 'main:app' --host=0.0.0.0 --port=8000 +# Add a healthcheck to verify the server is running +HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ + CMD curl --fail http://localhost:8000/ || exit 1 + +# Run the application using the JSON array form to avoid shell interpretation issues. +CMD ["uvicorn", "main:app", "--host=0.0.0.0", "--port=8000"] diff --git a/servers/slack/main.py b/servers/slack/main.py index 0b4d957..cea6836 100644 --- a/servers/slack/main.py +++ b/servers/slack/main.py @@ -1,12 +1,19 @@ import os import httpx import inspect +import logging +import json # For JSONDecodeError from typing import Optional, List, Dict, Any, Type, Callable -from fastapi import FastAPI, HTTPException, Body, Depends +from fastapi import FastAPI, HTTPException, Body, Depends, Security +from fastapi.security import APIKeyHeader from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel, Field from dotenv import load_dotenv +# --- Logging Setup --- +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') +logger = logging.getLogger(__name__) + # Load environment variables from .env file load_dotenv() @@ -14,10 +21,15 @@ load_dotenv() SLACK_BOT_TOKEN = os.getenv("SLACK_BOT_TOKEN") SLACK_TEAM_ID = os.getenv("SLACK_TEAM_ID") SLACK_CHANNEL_IDS_STR = os.getenv("SLACK_CHANNEL_IDS") # Optional +ALLOWED_ORIGINS_STR = os.getenv("ALLOWED_ORIGINS", "*") # Default to allow all +SERVER_API_KEY = os.getenv("SERVER_API_KEY") # Optional API key for security if not SLACK_BOT_TOKEN: + # Fail fast if essential config is missing + logger.critical("SLACK_BOT_TOKEN environment variable not set.") raise ValueError("SLACK_BOT_TOKEN environment variable not set.") if not SLACK_TEAM_ID: + logger.critical("SLACK_TEAM_ID environment variable not set.") raise ValueError("SLACK_TEAM_ID environment variable not set.") PREDEFINED_CHANNEL_IDS = [ @@ -32,16 +44,38 @@ app = FastAPI( description="FastAPI server providing Slack functionalities via specific, dynamically generated tool endpoints.", ) -origins = ["*"] +# Configure CORS +allow_origins = [origin.strip() for origin in ALLOWED_ORIGINS_STR.split(',')] +if allow_origins == ["*"]: + logger.warning("CORS allow_origins is set to '*' which is insecure for production. Consider setting ALLOWED_ORIGINS environment variable.") app.add_middleware( CORSMiddleware, - allow_origins=origins, - allow_credentials=True, + allow_origins=allow_origins, + allow_credentials=True, # Allow credentials if origins are specific, adjust if needed allow_methods=["*"], allow_headers=["*"], ) +# --- API Key Security --- +api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False) # auto_error=False to handle optional key + +async def get_api_key(key: str = Security(api_key_header)): + if SERVER_API_KEY: # Only enforce key if it's set in the environment + if not key: + logger.warning("API Key required but not provided in X-API-Key header.") + raise HTTPException(status_code=401, detail="X-API-Key header required") + if key != SERVER_API_KEY: + logger.warning("Invalid API Key provided.") + raise HTTPException(status_code=401, detail="Invalid API Key") + # If key is valid and required, proceed + # If SERVER_API_KEY is not set, allow access without a key + # logger.info("API Key check passed (or not required).") # Optional: Log successful checks + return key # Return the key or None if not required/provided + +if not SERVER_API_KEY: + logger.warning("SERVER_API_KEY environment variable is not set. Server will allow unauthenticated requests.") + # [Previous Pydantic models remain the same...] class ListChannelsArgs(BaseModel): limit: Optional[int] = Field(100, description="Maximum number of channels to return (default 100, max 200)") @@ -98,18 +132,29 @@ class SlackClient: data = response.json() if not data.get("ok"): error_msg = data.get("error", "Unknown Slack API error") - print(f"Slack API Error for {method} {endpoint}: {error_msg}") - raise HTTPException(status_code=400, detail={"slack_error": error_msg, "message": f"Slack API Error: {error_msg}"}) + # Return the specific Slack error in the response + logger.warning(f"Slack API Error for {method} {endpoint}: {error_msg}") + raise HTTPException(status_code=400, detail={"slack_error": error_msg, "message": f"Slack API returned an error: {error_msg}"}) return data except httpx.HTTPStatusError as e: - print(f"HTTP Error: {e.response.status_code} - {e.response.text}") - raise HTTPException(status_code=e.response.status_code, detail=f"Slack API HTTP Error: {e.response.text}") + # Handle specific HTTP errors like rate limiting (429) + if e.response.status_code == 429: + retry_after = e.response.headers.get("Retry-After") + detail = f"Slack API rate limit exceeded. Retry after {retry_after} seconds." if retry_after else "Slack API rate limit exceeded." + logger.warning(f"Rate limit hit for {method} {endpoint}. Retry-After: {retry_after}") + raise HTTPException(status_code=429, detail=detail, headers={"Retry-After": retry_after} if retry_after else {}) + else: + logger.error(f"HTTP Error: {e.response.status_code} - {e.response.text}", exc_info=True) + raise HTTPException(status_code=e.response.status_code, detail=f"Slack API HTTP Error: Status {e.response.status_code}") except httpx.RequestError as e: - print(f"Request Error: {e}") - raise HTTPException(status_code=503, detail=f"Error connecting to Slack API: {e}") - except Exception as e: - print(f"Unexpected Error during Slack request: {e}") - raise HTTPException(status_code=500, detail=f"An internal error occurred during the Slack request: {e}") + logger.error(f"Request Error connecting to Slack API: {e}", exc_info=True) + raise HTTPException(status_code=503, detail=f"Could not connect to Slack API: {e}") + except json.JSONDecodeError as e: + logger.error(f"Failed to decode JSON response from Slack API for {method} {endpoint}: {e}", exc_info=True) + raise HTTPException(status_code=502, detail="Invalid response received from Slack API.") + except Exception as e: # Catch other unexpected errors + logger.exception(f"Unexpected error during Slack request for {method} {endpoint}: {e}") # Use logger.exception to include traceback + raise HTTPException(status_code=500, detail=f"An internal server error occurred: {type(e).__name__}") async def get_channel_history(self, args: GetChannelHistoryArgs) -> Dict[str, Any]: params = {"channel": args.channel_id, "limit": args.limit} @@ -134,15 +179,15 @@ class SlackClient: "conversations.history", params={ "channel": channel_id, - "limit": 10 # Get last 10 messages by default + "limit": 1 # Fetch minimal history by default to speed up get_channels. Consider asyncio.gather for concurrency. } ) # Add history to channel data if history.get("ok"): channel_data["history"] = history.get("messages", []) - except Exception as e: - print(f"Error fetching history for channel {channel_id}: {e}") - channel_data["history"] = [] + except Exception as e: # Catch errors during history fetch but don't fail the whole channel list + logger.warning(f"Error fetching history for channel {channel_id}: {e}", exc_info=True) + channel_data["history"] = [] # Ensure history key exists even if fetch fails return channel_data @@ -152,8 +197,8 @@ class SlackClient: try: if channel_data := await fetch_channel_with_history(channel_id): channels_info.append(channel_data) - except Exception as e: - print(f"Could not fetch info for predefined channel {channel_id}: {e}") + except Exception as e: # Catch errors fetching predefined channels + logger.warning(f"Could not fetch info for predefined channel {channel_id}: {e}", exc_info=True) return { "ok": True, @@ -182,9 +227,10 @@ class SlackClient: try: if channel_data := await fetch_channel_with_history(channel["id"]): channels_with_history.append(channel_data) - except Exception as e: - print(f"Error fetching history for channel {channel['id']}: {e}") - channels_with_history.append(channel) # Fall back to channel info without history + except Exception as e: # Catch errors during history fetch but don't fail the whole channel list + logger.warning(f"Error fetching history for channel {channel['id']}: {e}", exc_info=True) + channel["history"] = [] # Add empty history on error + channels_with_history.append(channel) return { "ok": True, @@ -268,17 +314,20 @@ TOOL_MAPPING = { }, } -# Define a function factory to create endpoint handlers +# Define a function factory to create endpoint handlers, including API key dependency def create_endpoint_handler(tool_name: str, method: Callable, args_model: Type[BaseModel]): - async def endpoint_handler(args: args_model = Body(...)) -> ToolResponse: + async def endpoint_handler( + args: args_model = Body(...), + api_key: str = Depends(get_api_key) # Add API key dependency here + ) -> ToolResponse: try: result = await method(args=args) return {"content": result} except HTTPException as e: raise e except Exception as e: - print(f"Error executing tool {tool_name}: {e}") - raise HTTPException(status_code=500, detail=f"Internal server error: {str(e)}") + logger.exception(f"Error executing tool {tool_name}: {e}") # Use logger.exception here too + raise HTTPException(status_code=500, detail=f"Internal server error: {type(e).__name__}") return endpoint_handler # Register endpoints for each tool diff --git a/servers/slack/requirements.txt b/servers/slack/requirements.txt index 9d2bd0a..4bd8a47 100644 --- a/servers/slack/requirements.txt +++ b/servers/slack/requirements.txt @@ -1,6 +1,8 @@ -fastapi -uvicorn[standard] -pydantic -python-multipart -httpx -python-dotenv +fastapi>=0.110.0,<0.111.0 +uvicorn[standard]>=0.29.0,<0.30.0 +pydantic>=2.6.0,<3.0.0 +httpx>=0.27.0,<0.28.0 +python-dotenv>=1.0.0,<2.0.0 +# NOTE: Run 'pip freeze > requirements.txt' in a virtual environment +# to capture the exact versions of all transitive dependencies +# for truly reproducible builds. From 49900846d00ad5b7283d160cd41e099695518e01 Mon Sep 17 00:00:00 2001 From: Taylor Wilsdon Date: Sun, 20 Apr 2025 19:51:36 -0400 Subject: [PATCH 11/13] =?UTF-8?q?refac:=20reuse=20shared=20httpx=20client?= =?UTF-8?q?=20and=20concurrent=20channel=C2=A0history=20fetch=20for=20majo?= =?UTF-8?q?r=20performance=20gain?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- servers/slack/main.py | 384 ++++++++++++++++++++++-------------------- 1 file changed, 205 insertions(+), 179 deletions(-) diff --git a/servers/slack/main.py b/servers/slack/main.py index cea6836..6b77cb2 100644 --- a/servers/slack/main.py +++ b/servers/slack/main.py @@ -1,276 +1,297 @@ -import os -import httpx -import inspect -import logging -import json # For JSONDecodeError -from typing import Optional, List, Dict, Any, Type, Callable -from fastapi import FastAPI, HTTPException, Body, Depends, Security -from fastapi.security import APIKeyHeader -from fastapi.middleware.cors import CORSMiddleware -from pydantic import BaseModel, Field -from dotenv import load_dotenv +"""Slack MCP Server – high‑performance version +------------------------------------------------ +Showcase‑level code quality and pythonic clarity. +""" -# --- Logging Setup --- -logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') +import os +import asyncio +import logging +import json # For JSONDecodeError +from typing import Optional, List, Dict, Any, Type, Callable + +import httpx +from dotenv import load_dotenv +from fastapi import FastAPI, HTTPException, Body, Depends, Security +from fastapi.middleware.cors import CORSMiddleware +from fastapi.security import APIKeyHeader +from pydantic import BaseModel, Field + +# --------------------------------------------------------------------------- +# Logging +# --------------------------------------------------------------------------- +logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s") logger = logging.getLogger(__name__) -# Load environment variables from .env file +# --------------------------------------------------------------------------- +# Environment variables +# --------------------------------------------------------------------------- load_dotenv() -# --- Environment Variable Checks --- SLACK_BOT_TOKEN = os.getenv("SLACK_BOT_TOKEN") SLACK_TEAM_ID = os.getenv("SLACK_TEAM_ID") -SLACK_CHANNEL_IDS_STR = os.getenv("SLACK_CHANNEL_IDS") # Optional -ALLOWED_ORIGINS_STR = os.getenv("ALLOWED_ORIGINS", "*") # Default to allow all -SERVER_API_KEY = os.getenv("SERVER_API_KEY") # Optional API key for security +SLACK_CHANNEL_IDS_STR = os.getenv("SLACK_CHANNEL_IDS") # Optional +ALLOWED_ORIGINS_STR = os.getenv("ALLOWED_ORIGINS", "*") +SERVER_API_KEY = os.getenv("SERVER_API_KEY") # Optional API key for security if not SLACK_BOT_TOKEN: - # Fail fast if essential config is missing logger.critical("SLACK_BOT_TOKEN environment variable not set.") raise ValueError("SLACK_BOT_TOKEN environment variable not set.") if not SLACK_TEAM_ID: logger.critical("SLACK_TEAM_ID environment variable not set.") raise ValueError("SLACK_TEAM_ID environment variable not set.") -PREDEFINED_CHANNEL_IDS = [ - channel_id.strip() - for channel_id in SLACK_CHANNEL_IDS_STR.split(',') -] if SLACK_CHANNEL_IDS_STR else None +PREDEFINED_CHANNEL_IDS: Optional[List[str]] = ( + [cid.strip() for cid in SLACK_CHANNEL_IDS_STR.split(",")] if SLACK_CHANNEL_IDS_STR else None +) -# --- FastAPI App Setup --- +# --------------------------------------------------------------------------- +# FastAPI app setup +# --------------------------------------------------------------------------- app = FastAPI( title="Slack API Server", version="1.0.0", description="FastAPI server providing Slack functionalities via specific, dynamically generated tool endpoints.", ) -# Configure CORS -allow_origins = [origin.strip() for origin in ALLOWED_ORIGINS_STR.split(',')] +# CORS +allow_origins = [origin.strip() for origin in ALLOWED_ORIGINS_STR.split(",")] if allow_origins == ["*"]: logger.warning("CORS allow_origins is set to '*' which is insecure for production. Consider setting ALLOWED_ORIGINS environment variable.") app.add_middleware( CORSMiddleware, allow_origins=allow_origins, - allow_credentials=True, # Allow credentials if origins are specific, adjust if needed + allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) -# --- API Key Security --- -api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False) # auto_error=False to handle optional key +# --------------------------------------------------------------------------- +# API key security +# --------------------------------------------------------------------------- +api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False) + async def get_api_key(key: str = Security(api_key_header)): - if SERVER_API_KEY: # Only enforce key if it's set in the environment + if SERVER_API_KEY: if not key: logger.warning("API Key required but not provided in X-API-Key header.") raise HTTPException(status_code=401, detail="X-API-Key header required") if key != SERVER_API_KEY: logger.warning("Invalid API Key provided.") raise HTTPException(status_code=401, detail="Invalid API Key") - # If key is valid and required, proceed - # If SERVER_API_KEY is not set, allow access without a key - # logger.info("API Key check passed (or not required).") # Optional: Log successful checks - return key # Return the key or None if not required/provided + return key # May be None when not required + if not SERVER_API_KEY: logger.warning("SERVER_API_KEY environment variable is not set. Server will allow unauthenticated requests.") -# [Previous Pydantic models remain the same...] +# --------------------------------------------------------------------------- +# Pydantic models (arguments & responses) +# --------------------------------------------------------------------------- + class ListChannelsArgs(BaseModel): limit: Optional[int] = Field(100, description="Maximum number of channels to return (default 100, max 200)") cursor: Optional[str] = Field(None, description="Pagination cursor for next page of results") + class PostMessageArgs(BaseModel): channel_id: str = Field(..., description="The ID of the channel to post to") text: str = Field(..., description="The message text to post") + class ReplyToThreadArgs(BaseModel): channel_id: str = Field(..., description="The ID of the channel containing the thread") thread_ts: str = Field(..., description="The timestamp of the parent message (e.g., '1234567890.123456')") text: str = Field(..., description="The reply text") + class AddReactionArgs(BaseModel): channel_id: str = Field(..., description="The ID of the channel containing the message") timestamp: str = Field(..., description="The timestamp of the message to react to") reaction: str = Field(..., description="The name of the emoji reaction (without colons)") + class GetChannelHistoryArgs(BaseModel): channel_id: str = Field(..., description="The ID of the channel") limit: Optional[int] = Field(10, description="Number of messages to retrieve (default 10)") + class GetThreadRepliesArgs(BaseModel): channel_id: str = Field(..., description="The ID of the channel containing the thread") thread_ts: str = Field(..., description="The timestamp of the parent message (e.g., '1234567890.123456')") + class GetUsersArgs(BaseModel): cursor: Optional[str] = Field(None, description="Pagination cursor for next page of results") limit: Optional[int] = Field(100, description="Maximum number of users to return (default 100, max 200)") + class GetUserProfileArgs(BaseModel): user_id: str = Field(..., description="The ID of the user") + class ToolResponse(BaseModel): content: Dict[str, Any] = Field(..., description="The JSON response from the Slack API call") -# --- Slack Client Class --- + +# --------------------------------------------------------------------------- +# Slack client (high‑performance) +# --------------------------------------------------------------------------- + class SlackClient: + """Thin async wrapper over Slack Web API with connection‑pool reuse.""" + BASE_URL = "https://slack.com/api/" - def __init__(self, token: str, team_id: str): + def __init__(self, token: str, team_id: str, *, max_connections: int = 20): + self.team_id = team_id self.headers = { "Authorization": f"Bearer {token}", "Content-Type": "application/json; charset=utf-8", } - self.team_id = team_id + limits = httpx.Limits(max_connections=max_connections, max_keepalive_connections=max_connections) + self._client = httpx.AsyncClient( + base_url=self.BASE_URL, + headers=self.headers, + limits=limits, + http2=True, + timeout=10, + ) - async def _request(self, method: str, endpoint: str, params: Optional[Dict] = None, json_data: Optional[Dict] = None) -> Dict[str, Any]: - async with httpx.AsyncClient(base_url=self.BASE_URL, headers=self.headers) as client: - try: - response = await client.request(method, endpoint, params=params, json=json_data) - response.raise_for_status() - data = response.json() - if not data.get("ok"): - error_msg = data.get("error", "Unknown Slack API error") - # Return the specific Slack error in the response - logger.warning(f"Slack API Error for {method} {endpoint}: {error_msg}") - raise HTTPException(status_code=400, detail={"slack_error": error_msg, "message": f"Slack API returned an error: {error_msg}"}) - return data - except httpx.HTTPStatusError as e: - # Handle specific HTTP errors like rate limiting (429) - if e.response.status_code == 429: - retry_after = e.response.headers.get("Retry-After") - detail = f"Slack API rate limit exceeded. Retry after {retry_after} seconds." if retry_after else "Slack API rate limit exceeded." - logger.warning(f"Rate limit hit for {method} {endpoint}. Retry-After: {retry_after}") - raise HTTPException(status_code=429, detail=detail, headers={"Retry-After": retry_after} if retry_after else {}) - else: - logger.error(f"HTTP Error: {e.response.status_code} - {e.response.text}", exc_info=True) - raise HTTPException(status_code=e.response.status_code, detail=f"Slack API HTTP Error: Status {e.response.status_code}") - except httpx.RequestError as e: - logger.error(f"Request Error connecting to Slack API: {e}", exc_info=True) - raise HTTPException(status_code=503, detail=f"Could not connect to Slack API: {e}") - except json.JSONDecodeError as e: - logger.error(f"Failed to decode JSON response from Slack API for {method} {endpoint}: {e}", exc_info=True) - raise HTTPException(status_code=502, detail="Invalid response received from Slack API.") - except Exception as e: # Catch other unexpected errors - logger.exception(f"Unexpected error during Slack request for {method} {endpoint}: {e}") # Use logger.exception to include traceback - raise HTTPException(status_code=500, detail=f"An internal server error occurred: {type(e).__name__}") - - async def get_channel_history(self, args: GetChannelHistoryArgs) -> Dict[str, Any]: - params = {"channel": args.channel_id, "limit": args.limit} - return await self._request("GET", "conversations.history", params=params) - - async def get_channels(self, args: ListChannelsArgs) -> Dict[str, Any]: - limit = args.limit - cursor = args.cursor - - async def fetch_channel_with_history(channel_id: str) -> Dict[str, Any]: - # First get channel info - channel_info = await self._request("GET", "conversations.info", params={"channel": channel_id}) - if not channel_info.get("ok") or channel_info.get("channel", {}).get("is_archived"): - return None - - channel_data = channel_info["channel"] - - # Then get channel history - try: - history = await self._request( - "GET", - "conversations.history", - params={ - "channel": channel_id, - "limit": 1 # Fetch minimal history by default to speed up get_channels. Consider asyncio.gather for concurrency. - } + # ---------------- private helpers ---------------- # + async def _request( + self, + method: str, + endpoint: str, + *, + params: Optional[Dict] = None, + json_data: Optional[Dict] = None, + ) -> Dict[str, Any]: + try: + response = await self._client.request(method, endpoint, params=params, json=json_data) + response.raise_for_status() + data = response.json() + if not data.get("ok"): + error_msg = data.get("error", "Unknown Slack API error") + raise HTTPException(status_code=400, detail={"slack_error": error_msg}) + return data + except httpx.HTTPStatusError as e: + if e.response.status_code == 429: + retry_after = e.response.headers.get("Retry-After") + detail = ( + f"Slack API rate limit exceeded. Retry after {retry_after} seconds." + if retry_after + else "Slack API rate limit exceeded." ) - # Add history to channel data - if history.get("ok"): - channel_data["history"] = history.get("messages", []) - except Exception as e: # Catch errors during history fetch but don't fail the whole channel list - logger.warning(f"Error fetching history for channel {channel_id}: {e}", exc_info=True) - channel_data["history"] = [] # Ensure history key exists even if fetch fails + logger.warning("Rate limit hit: %s", detail) + raise HTTPException(status_code=429, detail=detail, headers={"Retry-After": retry_after} if retry_after else {}) + logger.error("HTTP Error %s - %s", e.response.status_code, e.response.text, exc_info=True) + raise HTTPException(status_code=e.response.status_code, detail="Slack API HTTP Error") + except httpx.RequestError as e: + logger.error("Request Error connecting to Slack API: %s", e, exc_info=True) + raise HTTPException(status_code=503, detail=f"Could not connect to Slack API: {e}") + except json.JSONDecodeError as e: + logger.error("Failed to decode JSON: %s", e, exc_info=True) + raise HTTPException(status_code=502, detail="Invalid JSON from Slack API") + except Exception as e: # noqa: BLE001 + logger.exception("Unexpected error during Slack request: %s", e) + raise HTTPException(status_code=500, detail=f"Internal error: {type(e).__name__}") - return channel_data + # ---------------- public helpers ---------------- # + async def channel_with_history(self, channel_id: str, *, history_limit: int = 1) -> Optional[Dict[str, Any]]: + """Return channel metadata plus ≀ ``history_limit`` recent messages, or None.""" + try: + info = await self._request("GET", "conversations.info", params={"channel": channel_id}) + chan = info["channel"] + if chan.get("is_archived"): + return None + hist = await self._request( + "GET", + "conversations.history", + params={"channel": channel_id, "limit": history_limit}, + ) + chan["history"] = hist.get("messages", []) + return chan + except Exception as exc: # noqa: BLE001 + logger.warning("Skipping channel %s – %s", channel_id, exc, exc_info=True) + return None + # ---------------- API surface ---------------- # + async def get_channel_history(self, args: GetChannelHistoryArgs) -> Dict[str, Any]: + return await self._request("GET", "conversations.history", params={"channel": args.channel_id, "limit": args.limit}) + + async def get_channels(self, args: ListChannelsArgs) -> Dict[str, Any]: # noqa: C901 – keep cohesive + # 1. decide which ids to fetch if PREDEFINED_CHANNEL_IDS: - channels_info = [] - for channel_id in PREDEFINED_CHANNEL_IDS: - try: - if channel_data := await fetch_channel_with_history(channel_id): - channels_info.append(channel_data) - except Exception as e: # Catch errors fetching predefined channels - logger.warning(f"Could not fetch info for predefined channel {channel_id}: {e}", exc_info=True) - - return { - "ok": True, - "channels": channels_info, - "response_metadata": {"next_cursor": ""} - } + ids = PREDEFINED_CHANNEL_IDS + next_cursor = "" else: - # First get list of channels - params = { + params: Dict[str, Any] = { "types": "public_channel", "exclude_archived": "true", - "limit": min(limit, 200), + "limit": min(args.limit, 200), "team_id": self.team_id, } - if cursor: - params["cursor"] = cursor + if args.cursor: + params["cursor"] = args.cursor + clist = await self._request("GET", "conversations.list", params=params) + ids = [c["id"] for c in clist["channels"]] + next_cursor = clist.get("response_metadata", {}).get("next_cursor", "") - channels_list = await self._request("GET", "conversations.list", params=params) + # 2. fetch metadata + history concurrently under a semaphore + sem = asyncio.Semaphore(10) # adjust parallelism as desired - if not channels_list.get("ok"): - return channels_list + async def guarded(cid: str): + async with sem: + return await self.channel_with_history(cid) - # Then fetch history for each channel - channels_with_history = [] - for channel in channels_list["channels"]: - try: - if channel_data := await fetch_channel_with_history(channel["id"]): - channels_with_history.append(channel_data) - except Exception as e: # Catch errors during history fetch but don't fail the whole channel list - logger.warning(f"Error fetching history for channel {channel['id']}: {e}", exc_info=True) - channel["history"] = [] # Add empty history on error - channels_with_history.append(channel) - - return { - "ok": True, - "channels": channels_with_history, - "response_metadata": channels_list.get("response_metadata", {"next_cursor": ""}) - } + channels = [c for c in await asyncio.gather(*(guarded(cid) for cid in ids)) if c] + return {"ok": True, "channels": channels, "response_metadata": {"next_cursor": next_cursor}} async def post_message(self, args: PostMessageArgs) -> Dict[str, Any]: - payload = {"channel": args.channel_id, "text": args.text} - return await self._request("POST", "chat.postMessage", json_data=payload) + return await self._request("POST", "chat.postMessage", json_data={"channel": args.channel_id, "text": args.text}) async def post_reply(self, args: ReplyToThreadArgs) -> Dict[str, Any]: - payload = {"channel": args.channel_id, "thread_ts": args.thread_ts, "text": args.text} - return await self._request("POST", "chat.postMessage", json_data=payload) + return await self._request( + "POST", + "chat.postMessage", + json_data={"channel": args.channel_id, "thread_ts": args.thread_ts, "text": args.text}, + ) async def add_reaction(self, args: AddReactionArgs) -> Dict[str, Any]: - payload = {"channel": args.channel_id, "timestamp": args.timestamp, "name": args.reaction} - return await self._request("POST", "reactions.add", json_data=payload) + return await self._request( + "POST", + "reactions.add", + json_data={"channel": args.channel_id, "timestamp": args.timestamp, "name": args.reaction}, + ) async def get_thread_replies(self, args: GetThreadRepliesArgs) -> Dict[str, Any]: - params = {"channel": args.channel_id, "ts": args.thread_ts} - return await self._request("GET", "conversations.replies", params=params) + return await self._request("GET", "conversations.replies", params={"channel": args.channel_id, "ts": args.thread_ts}) async def get_users(self, args: GetUsersArgs) -> Dict[str, Any]: - params = { - "limit": min(args.limit, 200), - "team_id": self.team_id, - } + params = {"limit": min(args.limit, 200), "team_id": self.team_id} if args.cursor: params["cursor"] = args.cursor return await self._request("GET", "users.list", params=params) async def get_user_profile(self, args: GetUserProfileArgs) -> Dict[str, Any]: - params = {"user": args.user_id, "include_labels": "true"} - return await self._request("GET", "users.profile.get", params=params) + return await self._request("GET", "users.profile.get", params={"user": args.user_id, "include_labels": "true"}) -# --- Instantiate Slack Client --- + # ---------------- lifecycle ---------------- # + async def aclose(self) -> None: # call on app shutdown + await self._client.aclose() + + +# --------------------------------------------------------------------------- +# Instantiate Slack client +# --------------------------------------------------------------------------- slack_client = SlackClient(token=SLACK_BOT_TOKEN, team_id=SLACK_TEAM_ID) -# --- Tool Definitions & Endpoint Generation --- + +# --------------------------------------------------------------------------- +# Dynamic tool mapping / endpoint generation +# --------------------------------------------------------------------------- TOOL_MAPPING = { "slack_list_channels": { "args_model": ListChannelsArgs, @@ -314,40 +335,45 @@ TOOL_MAPPING = { }, } -# Define a function factory to create endpoint handlers, including API key dependency + +# ---------------- endpoint factory ---------------- # + def create_endpoint_handler(tool_name: str, method: Callable, args_model: Type[BaseModel]): - async def endpoint_handler( - args: args_model = Body(...), - api_key: str = Depends(get_api_key) # Add API key dependency here - ) -> ToolResponse: + async def handler(args: args_model = Body(...), api_key: str = Depends(get_api_key)) -> ToolResponse: # noqa: ANN001 try: result = await method(args=args) return {"content": result} - except HTTPException as e: - raise e - except Exception as e: - logger.exception(f"Error executing tool {tool_name}: {e}") # Use logger.exception here too - raise HTTPException(status_code=500, detail=f"Internal server error: {type(e).__name__}") - return endpoint_handler + except HTTPException: + raise # re‑raise untouched + except Exception as exc: # noqa: BLE001 + logger.exception("Error executing tool %s: %s", tool_name, exc) + raise HTTPException(status_code=500, detail=f"Internal server error: {type(exc).__name__}") -# Register endpoints for each tool -for tool_name, config in TOOL_MAPPING.items(): - handler = create_endpoint_handler( - tool_name=tool_name, - method=config["method"], - args_model=config["args_model"] - ) + return handler + +for name, cfg in TOOL_MAPPING.items(): app.post( - f"/{tool_name}", + f"/{name}", response_model=ToolResponse, - summary=config["description"], - description=f"Executes the {tool_name} tool. Arguments are passed in the request body.", + summary=cfg["description"], + description=f"Executes the {name} tool. Arguments are passed in the request body.", tags=["Slack Tools"], - name=tool_name - )(handler) + name=name, + )(create_endpoint_handler(name, cfg["method"], cfg["args_model"])) -# --- Root Endpoint --- + +# --------------------------------------------------------------------------- +# Lifecycle events +# --------------------------------------------------------------------------- +@app.on_event("shutdown") +async def _close_slack_client(): + await slack_client.aclose() + + +# --------------------------------------------------------------------------- +# Root endpoint +# --------------------------------------------------------------------------- @app.get("/", summary="Root endpoint", include_in_schema=False) async def read_root(): return {"message": "Slack API Server is running. See /docs for available tool endpoints."} From fe6db28cfe064c0e3b7af7ffa9ee53103505e123 Mon Sep 17 00:00:00 2001 From: Taylor Wilsdon Date: Mon, 21 Apr 2025 14:01:43 -0400 Subject: [PATCH 12/13] Add server api key docs --- servers/slack/README.md | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/servers/slack/README.md b/servers/slack/README.md index 4c3258a..b214823 100644 --- a/servers/slack/README.md +++ b/servers/slack/README.md @@ -19,9 +19,10 @@ cd openapi-servers/servers/slack pip install -r requirements.txt # Set up environment variables -export SLACK_BOT_TOKEN="xoxb-your-bot-token" -export SLACK_TEAM_ID="your-team-id" -export SLACK_CHANNEL_IDS="comma,separated,channel,ids" # Optional: restrict to specific channels - leave blank to include all channels that the bot user has been added to +export SLACK_BOT_TOKEN="xoxb-your-bot-token" # Required: Your Slack bot token +export SLACK_TEAM_ID="your-team-id" # Required: Your Slack team ID +export SLACK_CHANNEL_IDS="C1,C2" # Optional: Comma-separated channel IDs to restrict access to +export SERVER_API_KEY="your-secret-key" # Optional: If set, requires 'X-API-Key' header for requests # Run the server uvicorn main:app --host 0.0.0.0 --reload @@ -44,7 +45,9 @@ All functionality is wrapped in a developer-friendly OpenAPI interface, making i --- ## πŸ”‘ Prerequisites + Most of this is pulled straight from the Slack Python SDK so the barebones readme can easily be supplemented by reading the official docs. To set up, you need to follow these steps: + 1. **Slack Bot Token**: Create a Slack App and get a Bot User OAuth Token - Visit [Slack API Apps](https://api.slack.com/apps) - Create a new app or select existing @@ -57,13 +60,15 @@ Most of this is pulled straight from the Slack Python SDK so the barebones readm - `users:read.email` - Install the app to your workspace - You'll get the bot token on the last screen. - 2. **Team ID**: Your Slack workspace/team ID - Found in workspace settings or URL (go to your slack instance via web and it'll be after the slash) - 3. **Channel IDs** (Optional): - Restrict the server to specific channels - Comma-separated list of channel IDs +4. **Server API Key** (`SERVER_API_KEY`, Optional): + - If you set this environment variable to a secret value (e.g., a strong random string), the server will require this key to be passed in the `X-API-Key` HTTP header for all incoming requests. + - This provides a layer of authentication to protect your server endpoint. + - If left unset, the server will accept requests without API key authentication (less secure). --- @@ -88,8 +93,8 @@ Each tool is available as a dedicated endpoint with full OpenAPI documentation. Once running, explore the interactive API documentation: -πŸ–₯️ Swagger UI: http://localhost:8000/docs -πŸ“„ OpenAPI JSON: http://localhost:8000/openapi.json +πŸ–₯️ Swagger UI: http://localhost:8000/docs +πŸ“„ OpenAPI JSON: http://localhost:8000/openapi.json The documentation includes detailed schemas, example requests, and response formats for all available tools. @@ -99,10 +104,11 @@ The documentation includes detailed schemas, example requests, and response form - Keep your `SLACK_BOT_TOKEN` secure - Use environment variables for sensitive credentials -- Consider implementing additional authentication for the API server in production +- Consider implementing additional authentication for the API server in production. Setting the `SERVER_API_KEY` environment variable is the recommended way to add basic authentication. +- If `SERVER_API_KEY` is set, ensure clients send the correct key in the `X-API-Key` header. - Review Slack's [security best practices](https://api.slack.com/authentication/best-practices) --- -Made with ❀️ by the Open WebUI community 🌍 +Made with ❀️ by the Open WebUI community 🌍 Explore more tools ➑️ https://github.com/open-webui/openapi-servers From 0c285e8ea3ab11337a7a0917e0bd739328a58283 Mon Sep 17 00:00:00 2001 From: Taylor Wilsdon Date: Mon, 21 Apr 2025 14:01:50 -0400 Subject: [PATCH 13/13] clean up + trim trailing whitepsace --- servers/slack/README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/servers/slack/README.md b/servers/slack/README.md index b214823..691c7f3 100644 --- a/servers/slack/README.md +++ b/servers/slack/README.md @@ -59,7 +59,7 @@ Most of this is pulled straight from the Slack Python SDK so the barebones readm - `users:read` - `users:read.email` - Install the app to your workspace - - You'll get the bot token on the last screen. + - You'll get the bot token on the last screen. 2. **Team ID**: Your Slack workspace/team ID - Found in workspace settings or URL (go to your slack instance via web and it'll be after the slash) 3. **Channel IDs** (Optional): @@ -93,8 +93,8 @@ Each tool is available as a dedicated endpoint with full OpenAPI documentation. Once running, explore the interactive API documentation: -πŸ–₯️ Swagger UI: http://localhost:8000/docs -πŸ“„ OpenAPI JSON: http://localhost:8000/openapi.json +πŸ–₯️ Swagger UI: http://localhost:8000/docs +πŸ“„ OpenAPI JSON: http://localhost:8000/openapi.json The documentation includes detailed schemas, example requests, and response formats for all available tools. @@ -110,5 +110,5 @@ The documentation includes detailed schemas, example requests, and response form --- -Made with ❀️ by the Open WebUI community 🌍 +Made with ❀️ by the Open WebUI community 🌍 Explore more tools ➑️ https://github.com/open-webui/openapi-servers