# Directory Structure ``` ├── .env.sample ├── .github │ └── workflows │ ├── ci.yml │ └── publish.yml ├── .gitignore ├── .node-version ├── .prettierrc ├── CLAUDE.md ├── Dockerfile ├── eslint.config.js ├── examples │ ├── get_users_http.ts │ ├── get_users.ts │ └── README.md ├── LICENSE ├── package-lock.json ├── package.json ├── README.md ├── src │ ├── index.ts │ └── schemas.ts ├── ts-node-loader.js ├── tsconfig.build.json ├── tsconfig.dev.json └── tsconfig.json ``` # Files -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- ``` 1 | 20.17.0 2 | ``` -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- ``` 1 | { 2 | "semi": true, 3 | "singleQuote": true, 4 | "tabWidth": 2, 5 | "trailingComma": "es5" 6 | } ``` -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- ``` 1 | EXMAPLES_SLACK_BOT_TOKEN=xoxb-your-slack-token-here 2 | EXMAPLES_SLACK_USER_TOKEN=xoxp-your-slack-token-here ``` -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # vitepress build output 108 | **/.vitepress/dist 109 | 110 | # vitepress cache directory 111 | **/.vitepress/cache 112 | 113 | # Docusaurus cache and generated files 114 | .docusaurus 115 | 116 | # Serverless directories 117 | .serverless/ 118 | 119 | # FuseBox cache 120 | .fusebox/ 121 | 122 | # DynamoDB Local files 123 | .dynamodb/ 124 | 125 | # TernJS port file 126 | .tern-port 127 | 128 | # Stores VSCode versions used for testing VSCode extensions 129 | .vscode-test 130 | 131 | # yarn v2 132 | .yarn/cache 133 | .yarn/unplugged 134 | .yarn/build-state.yml 135 | .yarn/install-state.gz 136 | .pnp.* 137 | ``` -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- ```markdown 1 | # Examples 2 | 3 | This directory contains example clients for the Slack MCP Server, demonstrating both stdio and Streamable HTTP transport methods. 4 | 5 | ## Available Examples 6 | 7 | ### 1. Stdio Client (`get_users.ts`) 8 | Uses the traditional stdio transport to communicate with the MCP server. 9 | 10 | ### 2. Streamable HTTP Client (`get_users_http.ts`) 11 | Uses the newer Streamable HTTP transport to communicate with the MCP server over HTTP. 12 | 13 | ## Setup 14 | 15 | 1. Set up your environment variables in `.env`: 16 | ```env 17 | # For the server 18 | SLACK_BOT_TOKEN=xoxb-your-bot-token 19 | SLACK_USER_TOKEN=xoxp-your-user-token 20 | 21 | # For the examples (same values, different variable names) 22 | EXMAPLES_SLACK_BOT_TOKEN=xoxb-your-bot-token 23 | EXMAPLES_SLACK_USER_TOKEN=xoxp-your-user-token 24 | ``` 25 | 26 | ## Usage 27 | 28 | ### Running the Stdio Example 29 | 30 | ```bash 31 | # Run the stdio client example 32 | npm run examples 33 | ``` 34 | 35 | ### Running the HTTP Example 36 | 37 | ```bash 38 | # Terminal 1: Start the HTTP server 39 | npm run start -- -port 3000 40 | 41 | # Terminal 2: Run the HTTP client example 42 | npm run examples:http 43 | 44 | # Or specify a custom server URL 45 | npm run examples:http http://localhost:3001/mcp 46 | ``` 47 | 48 | ## What the Examples Do 49 | 50 | Both examples: 51 | 1. Connect to the Slack MCP Server 52 | 2. List available tools 53 | 3. Call the `slack_get_users` tool with a limit of 100 users 54 | 4. Display the retrieved user information including: 55 | - User name 56 | - Real name 57 | - User ID 58 | - Pagination information if more users are available 59 | 60 | ## Transport Comparison 61 | 62 | ### Stdio Transport 63 | - **Pros**: Simple, no network setup required 64 | - **Cons**: Process-based communication, harder to debug network issues 65 | - **Use case**: Local development, direct integration 66 | 67 | ### Streamable HTTP Transport 68 | - **Pros**: Standard HTTP, easier debugging, supports web-based clients 69 | - **Cons**: Requires server setup, network configuration 70 | - **Use case**: Web applications, remote clients, production deployments 71 | 72 | ## Troubleshooting 73 | 74 | ### Common Issues 75 | 76 | 1. **Missing environment variables**: Ensure all required `SLACK_BOT_TOKEN`, `SLACK_USER_TOKEN`, `EXMAPLES_SLACK_BOT_TOKEN`, and `EXMAPLES_SLACK_USER_TOKEN` are set. 77 | 78 | 2. **Connection refused (HTTP)**: Make sure the HTTP server is running on the specified port before running the HTTP client. 79 | 80 | 3. **Permission errors**: Ensure your Slack tokens have the necessary permissions to list users in your workspace. ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown 1 | # slack-mcp-server 2 | 3 | A [MCP(Model Context Protocol)](https://www.anthropic.com/news/model-context-protocol) server for accessing Slack API. This server allows AI assistants to interact with the Slack API through a standardized interface. 4 | 5 | ## Transport Support 6 | 7 | This server supports both traditional and modern MCP transport methods: 8 | 9 | - **Stdio Transport** (default): Process-based communication for local integration 10 | - **Streamable HTTP Transport**: HTTP-based communication for web applications and remote clients 11 | 12 | ## Features 13 | 14 | Available tools: 15 | 16 | - `slack_list_channels` - List public channels in the workspace with pagination 17 | - `slack_post_message` - Post a new message to a Slack channel 18 | - `slack_reply_to_thread` - Reply to a specific message thread in Slack 19 | - `slack_add_reaction` - Add a reaction emoji to a message 20 | - `slack_get_channel_history` - Get recent messages from a channel 21 | - `slack_get_thread_replies` - Get all replies in a message thread 22 | - `slack_get_users` - Retrieve basic profile information of all users in the workspace 23 | - `slack_get_user_profiles` - Get multiple users' profile information in bulk (efficient for batch operations) 24 | - `slack_search_messages` - Search for messages in the workspace with powerful filters: 25 | - Basic query search 26 | - Location filters: `in_channel` 27 | - User filters: `from_user`, `with` 28 | - Date filters: `before` (YYYY-MM-DD), `after` (YYYY-MM-DD), `on` (YYYY-MM-DD), `during` (e.g., "July", "2023") 29 | - Content filters: `has` (emoji reactions), `is` (saved/thread) 30 | - Sorting options by relevance score or timestamp 31 | 32 | ## Quick Start 33 | 34 | ### Installation 35 | 36 | ```bash 37 | npm install @ubie-oss/slack-mcp-server 38 | ``` 39 | 40 | NOTE: Its now hosted in GitHub Registry so you need your PAT. 41 | 42 | ### Configuration 43 | 44 | You need to set the following environment variables: 45 | 46 | - `SLACK_BOT_TOKEN`: Slack Bot User OAuth Token 47 | - `SLACK_USER_TOKEN`: Slack User OAuth Token (required for some features like message search) 48 | - `SLACK_SAFE_SEARCH` (optional): When set to `true`, automatically excludes private channels, DMs, and group DMs from search results. This is enforced server-side and cannot be overridden by clients. 49 | 50 | You can also create a `.env` file to set these environment variables: 51 | 52 | ``` 53 | SLACK_BOT_TOKEN=xoxb-your-bot-token 54 | SLACK_USER_TOKEN=xoxp-your-user-token 55 | SLACK_SAFE_SEARCH=true # Optional: Enable safe search mode 56 | ``` 57 | 58 | ### Usage 59 | 60 | #### Start the MCP server 61 | 62 | **Stdio Transport (default)**: 63 | ```bash 64 | npx @ubie-oss/slack-mcp-server 65 | ``` 66 | 67 | **Streamable HTTP Transport**: 68 | ```bash 69 | npx @ubie-oss/slack-mcp-server -port 3000 70 | ``` 71 | 72 | You can also run the installed module with node: 73 | ```bash 74 | # Stdio transport 75 | node node_modules/.bin/slack-mcp-server 76 | 77 | # HTTP transport 78 | node node_modules/.bin/slack-mcp-server -port 3000 79 | ``` 80 | 81 | **Command Line Options**: 82 | - `-port <number>`: Start with Streamable HTTP transport on specified port 83 | - `-h, --help`: Show help message 84 | 85 | #### Client Configuration 86 | 87 | **For Stdio Transport (Claude Desktop, etc.)**: 88 | 89 | ```json 90 | { 91 | "slack": { 92 | "command": "npx", 93 | "args": [ 94 | "-y", 95 | "@ubie-oss/slack-mcp-server" 96 | ], 97 | "env": { 98 | "NPM_CONFIG_//npm.pkg.github.com/:_authToken": "<your-github-pat>", 99 | "SLACK_BOT_TOKEN": "<your-bot-token>", 100 | "SLACK_USER_TOKEN": "<your-user-token>", 101 | "SLACK_SAFE_SEARCH": "true" 102 | } 103 | } 104 | } 105 | ``` 106 | 107 | **For Streamable HTTP Transport (Web applications)**: 108 | 109 | Start the server: 110 | ```bash 111 | SLACK_BOT_TOKEN=<your-bot-token> SLACK_USER_TOKEN=<your-user-token> npx @ubie-oss/slack-mcp-server -port 3000 112 | ``` 113 | 114 | Connect to: `http://localhost:3000/mcp` 115 | 116 | See [examples/README.md](examples/README.md) for detailed client examples. 117 | 118 | ## Implementation Pattern 119 | 120 | This server adopts the following implementation pattern: 121 | 122 | 1. Define request/response using Zod schemas 123 | - Request schema: Define input parameters 124 | - Response schema: Define responses limited to necessary fields 125 | 126 | 2. Implementation flow: 127 | - Validate request with Zod schema 128 | - Call Slack WebAPI 129 | - Parse response with Zod schema to limit to necessary fields 130 | - Return as JSON 131 | 132 | For example, the `slack_list_channels` implementation parses the request with `ListChannelsRequestSchema`, calls `slackClient.conversations.list`, and returns the response parsed with `ListChannelsResponseSchema`. 133 | 134 | ## Development 135 | 136 | ### Available Scripts 137 | 138 | - `npm run dev` - Start the server in development mode with hot reloading 139 | - `npm run build` - Build the project for production 140 | - `npm run start` - Start the production server 141 | - `npm run lint` - Run linting checks (ESLint and Prettier) 142 | - `npm run fix` - Automatically fix linting issues 143 | 144 | ### Contributing 145 | 146 | 1. Fork the repository 147 | 2. Create your feature branch 148 | 3. Run tests and linting: `npm run lint` 149 | 4. Commit your changes 150 | 5. Push to the branch 151 | 6. Create a Pull Request 152 | ``` -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- ```markdown 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Project Overview 6 | 7 | This is a Model Context Protocol (MCP) server that provides AI assistants with standardized access to Slack APIs. It's written in TypeScript and supports both stdio (process-based) and HTTP transport methods. 8 | 9 | ## Development Commands 10 | 11 | ### Building and Running 12 | - `npm run build` - Compile TypeScript to JavaScript in `/dist` 13 | - `npm run dev` - Start server in development mode with hot reloading 14 | - `npm start` - Run the production build 15 | 16 | ### Code Quality 17 | - `npm run lint` - Run both ESLint and Prettier checks 18 | - `npm run fix` - Auto-fix all linting issues 19 | - `npm run lint:eslint` - Run ESLint only 20 | - `npm run lint:prettier` - Run Prettier only 21 | 22 | ### Examples 23 | - `npm run examples` - Run stdio transport example 24 | - `npm run examples:http` - Run HTTP transport example 25 | 26 | ## Architecture 27 | 28 | ### Core Structure 29 | The server follows a schema-driven design pattern: 30 | 31 | 1. **Request/Response Schemas** (`src/schemas.ts`): 32 | - All Slack API interactions are validated with Zod schemas 33 | - Request schemas define input parameters 34 | - Response schemas filter API responses to only necessary fields 35 | 36 | 2. **Main Server** (`src/index.ts`): 37 | - Dual transport support via command-line flag 38 | - Tool registration and request handling 39 | - Environment variable validation 40 | 41 | ### Transport Modes 42 | - **Stdio (default)**: For CLI integration (Claude Desktop, etc.) 43 | - **HTTP**: For web applications via `-port` flag 44 | 45 | ### Available Tools 46 | All tools follow the pattern: validate request → call Slack API → parse response → return JSON 47 | 48 | - Channel operations: list, post message, get history 49 | - Thread operations: reply, get replies 50 | - User operations: get users, profiles, bulk profiles 51 | - Message operations: search, add reactions 52 | 53 | ### Tool Selection Guidelines 54 | 55 | **When to use `slack_search_messages`:** 56 | - You need to find messages with specific criteria (keywords, user, date range, channel) 57 | - You want to filter/narrow down results based on conditions 58 | - You're looking for targeted information rather than browsing 59 | 60 | **When to use `slack_get_channel_history`:** 61 | - You want to see the latest conversation flow without specific filters 62 | - You need ALL messages including bot/automation messages (search excludes these) 63 | - You want to browse messages chronologically with pagination 64 | - You don't have specific search criteria and just want to understand recent activity 65 | 66 | ### Environment Requirements 67 | Must set in environment or `.env` file: 68 | - `SLACK_BOT_TOKEN`: Bot User OAuth Token 69 | - `SLACK_USER_TOKEN`: User OAuth Token (for search) 70 | 71 | ## Key Implementation Notes 72 | 73 | 1. **No Test Suite**: Currently no tests implemented (`"test": "echo \"No tests yet\""`) 74 | 75 | 2. **Type Safety**: All Slack API responses are parsed through Zod schemas to ensure type safety and limit response size 76 | 77 | 3. **Error Handling**: The server validates tokens on startup and provides clear error messages 78 | 79 | 4. **Publishing**: Uses GitHub Package Registry - requires PAT for installation 80 | 81 | 5. **ES Modules**: Project uses `"type": "module"` - use ES import syntax 82 | 83 | ## Common Tasks 84 | 85 | ### Adding a New Slack Tool 86 | 1. Define request/response schemas in `src/schemas.ts` 87 | 2. Add tool registration in `src/index.ts` server setup 88 | 3. Implement handler following existing pattern: validate → API call → parse → return 89 | 4. Update README.md with new tool documentation 90 | 91 | ### Search Messages Considerations 92 | 1. **Query Field**: The `query` field accepts plain text search terms only. Modifiers like `from:`, `in:`, `before:` etc. are NOT allowed in the query field - use the dedicated fields instead 93 | 2. **Date Search**: The `on:` modifier may not find results due to timezone differences between the Slack workspace and the user's local time 94 | 3. **ID-Only Fields**: All search modifier fields require proper Slack IDs for consistency and reliability: 95 | - `in_channel`: Channel ID (e.g., `C1234567`) - use `slack_list_channels` to find channel IDs. The server automatically converts channel IDs to channel names for search compatibility. 96 | - `from_user`: User ID (e.g., `U1234567`) - use `slack_get_users` to find user IDs 97 | 4. **Required Workflow**: Always use the appropriate listing tools first to convert names to IDs before searching 98 | 5. **Debug**: Search queries are logged to console for troubleshooting 99 | 100 | ### Known API Limitations 101 | 1. **Bot Message Exclusion**: The `search.messages` API excludes bot/automation messages by default, unlike the Slack UI 102 | 2. **Indexing Delays**: Messages are not indexed immediately; there can be delays between posting and searchability 103 | 3. **Proximity Filtering**: When multiple messages match in close proximity, only one result may be returned 104 | 4. **Rate Limiting**: Non-Marketplace apps have severe rate limits (1 request/minute, 15 messages max as of 2025) 105 | 5. **Comprehensive Alternative**: Use `conversations.history` for retrieving all messages including bot messages 106 | 107 | ### Modifying Schemas 108 | When updating schemas, ensure backward compatibility and update both request validation and response filtering to maintain efficiency. ``` -------------------------------------------------------------------------------- /ts-node-loader.js: -------------------------------------------------------------------------------- ```javascript 1 | import { register } from 'node:module'; 2 | import { pathToFileURL } from 'node:url'; 3 | 4 | register('ts-node/esm', pathToFileURL('./')); ``` -------------------------------------------------------------------------------- /tsconfig.dev.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./dist" 5 | }, 6 | "include": ["src/**/*", "examples/**/*"], 7 | "exclude": ["node_modules", "dist"] 8 | } ``` -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./dist", 5 | "rootDir": "./src" 6 | }, 7 | "include": ["src/**/*"], 8 | "exclude": ["node_modules", "dist", "examples"] 9 | } ``` -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- ```yaml 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | lint-and-build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | 15 | - name: Setup Node.js 16 | uses: actions/setup-node@v4 17 | with: 18 | node-version: '20' 19 | cache: 'npm' 20 | 21 | - name: Install dependencies 22 | run: npm ci 23 | 24 | - name: Lint 25 | run: npm run lint 26 | 27 | - name: Build 28 | run: npm run build 29 | ``` -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "compilerOptions": { 3 | "target": "es2022", 4 | "module": "NodeNext", 5 | "strict": true, 6 | "esModuleInterop": true, 7 | "skipLibCheck": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "moduleResolution": "NodeNext", 10 | "resolveJsonModule": true, 11 | "types": ["node"], 12 | "lib": ["es2022"], 13 | "declaration": true, 14 | "sourceMap": true, 15 | "allowSyntheticDefaultImports": true 16 | }, 17 | "ts-node": { 18 | "esm": true, 19 | "experimentalSpecifiers": true, 20 | "project": "./tsconfig.dev.json" 21 | } 22 | } ``` -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- ```javascript 1 | import js from "@eslint/js"; 2 | import globals from "globals"; 3 | import tseslint from "typescript-eslint"; 4 | import prettier from "eslint-config-prettier"; 5 | 6 | export default [ 7 | js.configs.recommended, 8 | { 9 | files: ["**/*.{js,mjs,cjs,ts}"], 10 | languageOptions: { 11 | globals: { ...globals.browser, ...globals.node }, 12 | ecmaVersion: 2022 13 | } 14 | }, 15 | ...tseslint.configs.recommended, 16 | { 17 | files: ["**/*.ts"], 18 | plugins: { "@typescript-eslint": tseslint.plugin }, 19 | languageOptions: { 20 | parser: tseslint.parser, 21 | parserOptions: { 22 | project: true 23 | } 24 | }, 25 | rules: {} 26 | }, 27 | prettier 28 | ]; ``` -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- ```dockerfile 1 | # Use Node.js base image 2 | FROM node:20-slim AS builder 3 | 4 | # Set working directory 5 | WORKDIR /app 6 | 7 | # Copy package.json and package-lock.json 8 | COPY package*.json ./ 9 | 10 | # Install dependencies 11 | RUN npm ci 12 | 13 | # Copy source code 14 | COPY . . 15 | 16 | # Build TypeScript 17 | RUN npm run build 18 | 19 | # Runtime image with minimal footprint 20 | FROM node:20-slim 21 | 22 | WORKDIR /app 23 | 24 | # Copy only necessary files from builder stage 25 | COPY --from=builder /app/dist ./dist 26 | COPY --from=builder /app/package*.json ./ 27 | COPY --from=builder /app/node_modules ./node_modules 28 | 29 | # Set non-root user for security 30 | USER node 31 | 32 | # Set environment variables 33 | ENV NODE_ENV=production 34 | 35 | # Run the application 36 | ENTRYPOINT ["node", "dist/index.js"] 37 | ``` -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- ```yaml 1 | name: Publish Package 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | build-and-publish: 9 | runs-on: ubuntu-latest 10 | permissions: 11 | contents: read 12 | packages: write 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - name: Setup Node.js 17 | uses: actions/setup-node@v4 18 | with: 19 | node-version: '20' 20 | registry-url: 'https://npm.pkg.github.com' 21 | scope: '@ubie-oss' 22 | cache: 'npm' 23 | 24 | - name: Install dependencies 25 | run: npm ci 26 | 27 | - name: Lint 28 | run: npm run lint 29 | 30 | - name: Publish to GitHub Packages 31 | run: npm publish 32 | env: 33 | NODE_AUTH_TOKEN: ${{secrets.GITHUB_TOKEN}} 34 | ``` -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "name": "@ubie-oss/slack-mcp-server", 3 | "version": "0.1.4", 4 | "description": "A Slack MCP server", 5 | "main": "dist/index.js", 6 | "type": "module", 7 | "bin": { 8 | "slack-mcp-server": "dist/index.js" 9 | }, 10 | "scripts": { 11 | "dev": "node --import ./ts-node-loader.js src/index.ts", 12 | "build": "tsc -p tsconfig.build.json && shx chmod +x dist/*.js", 13 | "start": "node dist/index.js", 14 | "test": "echo \"No tests yet\"", 15 | "lint": "npm run lint:eslint && npm run lint:prettier", 16 | "lint:eslint": "eslint \"src/**/*.ts\" \"examples/**/*.ts\"", 17 | "lint:prettier": "prettier --check \"src/**/*.ts\" \"examples/**/*.ts\"", 18 | "fix": "npm run fix:eslint && npm run fix:prettier", 19 | "fix:eslint": "eslint \"src/**/*.ts\" \"examples/**/*.ts\" --fix", 20 | "fix:prettier": "prettier --write \"src/**/*.ts\" \"examples/**/*.ts\"", 21 | "examples": "node --import ./ts-node-loader.js examples/get_users.ts", 22 | "examples:http": "node --import ./ts-node-loader.js examples/get_users_http.ts", 23 | "prepublishOnly": "npm run build" 24 | }, 25 | "keywords": [ 26 | "mcp", 27 | "slack" 28 | ], 29 | "author": "Ubie, Inc.", 30 | "repository": { 31 | "type": "git", 32 | "url": "https://github.com/ubie-oss/slack-mcp-server.git" 33 | }, 34 | "homepage": "https://github.com/ubie-oss/slack-mcp-server", 35 | "bugs": { 36 | "url": "https://github.com/ubie-oss/slack-mcp-server/issues" 37 | }, 38 | "license": "Apache-2.0", 39 | "dependencies": { 40 | "@modelcontextprotocol/sdk": "^1.12.1", 41 | "@slack/web-api": "^7.9.1", 42 | "@types/node": "^20.10.3", 43 | "dotenv": "^16.4.7", 44 | "express": "^5.1.0", 45 | "typescript": "^5.3.2", 46 | "zod": "^3.22.4", 47 | "zod-to-json-schema": "^3.22.4" 48 | }, 49 | "devDependencies": { 50 | "@eslint/js": "^9.24.0", 51 | "@types/express": "^5.0.3", 52 | "@typescript-eslint/eslint-plugin": "^6.19.0", 53 | "@typescript-eslint/parser": "^6.19.0", 54 | "eslint": "^8.57.1", 55 | "eslint-config-prettier": "^9.1.0", 56 | "globals": "^16.0.0", 57 | "prettier": "^3.2.2", 58 | "shx": "^0.3.4", 59 | "ts-node": "^10.9.2", 60 | "typescript-eslint": "^8.29.1" 61 | } 62 | } 63 | ``` -------------------------------------------------------------------------------- /examples/get_users_http.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { Client } from '@modelcontextprotocol/sdk/client/index.js'; 2 | import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; 3 | import { config } from 'dotenv'; 4 | import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'; 5 | import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; 6 | 7 | // Load environment variables from .env file 8 | config(); 9 | 10 | // Get and validate necessary environment variables 11 | const slackToken = process.env.EXMAPLES_SLACK_BOT_TOKEN; 12 | const userToken = process.env.EXMAPLES_SLACK_USER_TOKEN; 13 | 14 | if (!slackToken) { 15 | throw new Error('EXMAPLES_SLACK_BOT_TOKEN environment variable is required'); 16 | } 17 | 18 | if (!userToken) { 19 | throw new Error('EXMAPLES_SLACK_USER_TOKEN environment variable is required'); 20 | } 21 | 22 | async function main() { 23 | // Parse command line arguments for server URL 24 | const args = process.argv.slice(2); 25 | const serverUrl = args[0] || 'http://localhost:3000/mcp'; 26 | 27 | console.log(`Connecting to MCP server at: ${serverUrl}`); 28 | 29 | // Initialize MCP client 30 | const client = new Client( 31 | { 32 | name: 'slack-get-users-http-example-client', 33 | version: '1.0.0', 34 | }, 35 | { 36 | capabilities: {}, 37 | } 38 | ); 39 | 40 | // Create Streamable HTTP transport for connecting to the server 41 | const transport = new StreamableHTTPClientTransport(new URL(serverUrl)); 42 | 43 | try { 44 | // Connect to the server 45 | await client.connect(transport); 46 | console.log('Connected to MCP server via HTTP'); 47 | 48 | // List available tools 49 | const toolsResponse = await client.listTools(); 50 | console.log( 51 | 'Available tools:', 52 | toolsResponse.tools.map((t) => t.name).join(', ') 53 | ); 54 | 55 | // Call slack_get_users 56 | console.log('\nCalling slack_get_users...'); 57 | const response = (await client.callTool( 58 | { 59 | name: 'slack_get_users', 60 | arguments: { 61 | limit: 100, 62 | }, 63 | }, 64 | CallToolResultSchema 65 | )) as CallToolResult; 66 | 67 | if ( 68 | Array.isArray(response.content) && 69 | response.content.length > 0 && 70 | response.content[0]?.type === 'text' 71 | ) { 72 | // Parse the response and display user information 73 | const slackResponse = JSON.parse(response.content[0].text); 74 | 75 | console.log('Slack users retrieved successfully!'); 76 | console.log('Total users:', slackResponse.members?.length || 0); 77 | 78 | // Display basic information for each user 79 | if (slackResponse.members && slackResponse.members.length > 0) { 80 | console.log('\nUser information:'); 81 | slackResponse.members.forEach( 82 | (user: { id: string; name: string; real_name?: string }) => { 83 | console.log( 84 | `- ${user.name} (${user.real_name || 'N/A'}) [ID: ${user.id}]` 85 | ); 86 | } 87 | ); 88 | 89 | // Display pagination information if available 90 | if (slackResponse.response_metadata?.next_cursor) { 91 | console.log( 92 | `\nMore users available. Next cursor: ${slackResponse.response_metadata.next_cursor}` 93 | ); 94 | } 95 | } 96 | } else { 97 | console.error('Unexpected response format'); 98 | } 99 | } catch (error) { 100 | console.error('Error:', error); 101 | process.exit(1); 102 | } finally { 103 | // Close the connection 104 | await transport.close(); 105 | console.log('Connection closed'); 106 | } 107 | } 108 | 109 | main(); 110 | ``` -------------------------------------------------------------------------------- /examples/get_users.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { Client } from '@modelcontextprotocol/sdk/client/index.js'; 2 | import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; 3 | import { config } from 'dotenv'; 4 | import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'; 5 | import { fileURLToPath } from 'node:url'; 6 | import { dirname, resolve } from 'node:path'; 7 | import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; 8 | 9 | // Get the directory of the current file 10 | const __filename = fileURLToPath(import.meta.url); 11 | const __dirname = dirname(__filename); 12 | 13 | // Load environment variables from .env file 14 | config(); 15 | 16 | // Get and validate necessary environment variables 17 | const slackToken = process.env.EXMAPLES_SLACK_BOT_TOKEN; 18 | const userToken = process.env.EXMAPLES_SLACK_USER_TOKEN; 19 | 20 | if (!slackToken) { 21 | throw new Error('EXMAPLES_SLACK_BOT_TOKEN environment variable is required'); 22 | } 23 | 24 | if (!userToken) { 25 | throw new Error('EXMAPLES_SLACK_USER_TOKEN environment variable is required'); 26 | } 27 | 28 | // After validation, can be safely treated as a string 29 | const env = { 30 | SLACK_BOT_TOKEN: slackToken, 31 | SLACK_USER_TOKEN: userToken, 32 | } as const satisfies Record<string, string>; 33 | 34 | async function main() { 35 | // Initialize MCP client 36 | const client = new Client( 37 | { 38 | name: 'slack-get-users-example-client', 39 | version: '1.0.0', 40 | }, 41 | { 42 | capabilities: {}, 43 | } 44 | ); 45 | 46 | // Create transport for connecting to the server 47 | const transport = new StdioClientTransport({ 48 | command: process.execPath, 49 | args: [ 50 | '--import', 51 | resolve(__dirname, '../ts-node-loader.js'), 52 | resolve(__dirname, '../src/index.ts'), 53 | ], 54 | env, 55 | }); 56 | 57 | try { 58 | // Connect to the server 59 | await client.connect(transport); 60 | console.log('Connected to MCP server'); 61 | 62 | // List available tools 63 | const toolsResponse = await client.listTools(); 64 | console.log('Available tools:', toolsResponse.tools); 65 | 66 | // Call slack_get_users 67 | const response = (await client.callTool( 68 | { 69 | name: 'slack_get_users', 70 | arguments: { 71 | limit: 100, 72 | }, 73 | }, 74 | CallToolResultSchema 75 | )) as CallToolResult; 76 | 77 | if ( 78 | Array.isArray(response.content) && 79 | response.content.length > 0 && 80 | response.content[0]?.type === 'text' 81 | ) { 82 | // Parse the response and display user information 83 | const slackResponse = JSON.parse(response.content[0].text); 84 | 85 | console.log('Slack users retrieved successfully!'); 86 | console.log('Total users:', slackResponse.members?.length || 0); 87 | 88 | // Display basic information for each user 89 | if (slackResponse.members && slackResponse.members.length > 0) { 90 | console.log('\nUser information:'); 91 | slackResponse.members.forEach( 92 | (user: { id: string; name: string; real_name?: string }) => { 93 | console.log( 94 | `- ${user.name} (${user.real_name || 'N/A'}) [ID: ${user.id}]` 95 | ); 96 | } 97 | ); 98 | 99 | // Display pagination information if available 100 | if (slackResponse.response_metadata?.next_cursor) { 101 | console.log( 102 | `\nMore users available. Next cursor: ${slackResponse.response_metadata.next_cursor}` 103 | ); 104 | } 105 | } 106 | } else { 107 | console.error('Unexpected response format'); 108 | } 109 | } catch (error) { 110 | console.error('Error:', error); 111 | process.exit(1); 112 | } finally { 113 | // Close the connection 114 | await transport.close(); 115 | } 116 | } 117 | 118 | main(); 119 | ``` -------------------------------------------------------------------------------- /src/schemas.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { z } from 'zod'; 2 | 3 | // 4 | // Basic schemas 5 | // 6 | 7 | export const ChannelSchema = z 8 | .object({ 9 | conversation_host_id: z.string().optional(), 10 | created: z.number().optional(), 11 | id: z.string().optional(), 12 | is_archived: z.boolean().optional(), 13 | name: z.string().optional(), 14 | name_normalized: z.string().optional(), 15 | num_members: z.number().optional(), 16 | purpose: z 17 | .object({ 18 | creator: z.string().optional(), 19 | last_set: z.number().optional(), 20 | value: z.string().optional(), 21 | }) 22 | .optional(), 23 | shared_team_ids: z.array(z.string()).optional(), 24 | topic: z 25 | .object({ 26 | creator: z.string().optional(), 27 | last_set: z.number().optional(), 28 | value: z.string().optional(), 29 | }) 30 | .optional(), 31 | updated: z.number().optional(), 32 | }) 33 | .strip(); 34 | 35 | const ReactionSchema = z 36 | .object({ 37 | count: z.number().optional(), 38 | name: z.string().optional(), 39 | url: z.string().optional(), 40 | users: z.array(z.string()).optional(), 41 | }) 42 | .strip(); 43 | 44 | const ConversationsHistoryMessageSchema = z 45 | .object({ 46 | reactions: z.array(ReactionSchema).optional(), 47 | reply_count: z.number().optional(), 48 | reply_users: z.array(z.string()).optional(), 49 | reply_users_count: z.number().optional(), 50 | subtype: z.string().optional(), 51 | text: z.string().optional(), 52 | thread_ts: z.string().optional(), 53 | ts: z.string().optional(), 54 | type: z.string().optional(), 55 | user: z.string().nullable().optional(), 56 | }) 57 | .strip(); 58 | 59 | const MemberSchema = z 60 | .object({ 61 | id: z.string().optional(), 62 | name: z.string().optional(), 63 | real_name: z.string().optional(), 64 | }) 65 | .strip(); 66 | 67 | const ProfileSchema = z 68 | .object({ 69 | display_name: z.string().optional(), 70 | display_name_normalized: z.string().optional(), 71 | email: z.string().email().optional(), 72 | first_name: z.string().optional(), 73 | last_name: z.string().optional(), 74 | phone: z.string().optional(), 75 | real_name: z.string().optional(), 76 | real_name_normalized: z.string().optional(), 77 | title: z.string().optional(), 78 | }) 79 | .strip(); 80 | 81 | const SearchMessageSchema = z 82 | .object({ 83 | channel: z 84 | .object({ 85 | id: z.string().optional(), 86 | name: z.string().optional(), 87 | }) 88 | .optional(), 89 | permalink: z.string().url().optional(), 90 | text: z.string().optional(), 91 | ts: z.string().optional(), 92 | type: z.string().optional(), 93 | user: z.string().nullable().optional(), 94 | }) 95 | .strip(); 96 | 97 | // 98 | // Request schemas 99 | // 100 | 101 | export const AddReactionRequestSchema = z.object({ 102 | channel_id: z 103 | .string() 104 | .describe('The ID of the channel containing the message'), 105 | reaction: z.string().describe('The name of the emoji reaction (without ::)'), 106 | timestamp: z 107 | .string() 108 | .regex(/^\d{10}\.\d{6}$/, { 109 | message: "Timestamp must be in the format '1234567890.123456'", 110 | }) 111 | .describe( 112 | "The timestamp of the message to react to in the format '1234567890.123456'" 113 | ), 114 | }); 115 | 116 | export const GetChannelHistoryRequestSchema = z.object({ 117 | channel_id: z 118 | .string() 119 | .describe( 120 | 'The ID of the channel. Use this tool for: browsing latest messages without filters, getting ALL messages including bot/automation messages, sequential pagination. If you need to search by user, keywords, or dates, use slack_search_messages instead.' 121 | ), 122 | cursor: z 123 | .string() 124 | .optional() 125 | .describe('Pagination cursor for next page of results'), 126 | limit: z 127 | .number() 128 | .int() 129 | .min(1) 130 | .max(1000) // Align with Slack API's default limit 131 | .optional() 132 | .default(100) // The reference repository uses 10, but aligning with list_channels etc., set to 100 133 | .describe('Number of messages to retrieve (default 100)'), 134 | }); 135 | 136 | export const GetThreadRepliesRequestSchema = z.object({ 137 | channel_id: z 138 | .string() 139 | .describe('The ID of the channel containing the thread'), 140 | thread_ts: z 141 | .string() 142 | .regex(/^\d{10}\.\d{6}$/, { 143 | message: "Timestamp must be in the format '1234567890.123456'", 144 | }) 145 | .describe( 146 | "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." 147 | ), 148 | cursor: z 149 | .string() 150 | .optional() 151 | .describe('Pagination cursor for next page of results'), 152 | limit: z 153 | .number() 154 | .int() 155 | .min(1) 156 | .max(1000) 157 | .optional() 158 | .default(100) 159 | .describe('Number of replies to retrieve (default 100)'), 160 | }); 161 | 162 | export const GetUsersRequestSchema = z.object({ 163 | cursor: z 164 | .string() 165 | .optional() 166 | .describe('Pagination cursor for next page of results'), 167 | limit: z 168 | .number() 169 | .int() 170 | .min(1) 171 | .optional() 172 | .default(100) 173 | .describe('Maximum number of users to return (default 100)'), 174 | }); 175 | 176 | export const GetUserProfilesRequestSchema = z.object({ 177 | user_ids: z 178 | .array(z.string()) 179 | .min(1) 180 | .max(100) 181 | .describe('Array of user IDs to retrieve profiles for (max 100)'), 182 | }); 183 | 184 | export const ListChannelsRequestSchema = z.object({ 185 | cursor: z 186 | .string() 187 | .optional() 188 | .describe('Pagination cursor for next page of results'), 189 | limit: z 190 | .number() 191 | .int() 192 | .min(1) 193 | .max(1000) // Align with Slack API's default limit (conversations.list is actually cursor-based) 194 | .optional() 195 | .default(100) 196 | .describe('Maximum number of channels to return (default 100)'), 197 | }); 198 | 199 | export const PostMessageRequestSchema = z.object({ 200 | channel_id: z.string().describe('The ID of the channel to post to'), 201 | text: z.string().describe('The message text to post'), 202 | }); 203 | 204 | export const ReplyToThreadRequestSchema = z.object({ 205 | channel_id: z 206 | .string() 207 | .describe('The ID of the channel containing the thread'), 208 | text: z.string().describe('The reply text'), 209 | thread_ts: z 210 | .string() 211 | .regex(/^\d{10}\.\d{6}$/, { 212 | message: "Timestamp must be in the format '1234567890.123456'", 213 | }) 214 | .describe( 215 | "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." 216 | ), 217 | }); 218 | 219 | export const SearchChannelsRequestSchema = z.object({ 220 | query: z 221 | .string() 222 | .describe( 223 | 'Search channels by partial name match (case-insensitive). Searches across channel names.' 224 | ), 225 | limit: z 226 | .number() 227 | .int() 228 | .min(1) 229 | .max(100) 230 | .optional() 231 | .default(20) 232 | .describe('Maximum number of channels to return (default 20)'), 233 | include_archived: z 234 | .boolean() 235 | .optional() 236 | .default(false) 237 | .describe('Include archived channels in results (default false)'), 238 | }); 239 | 240 | export const SearchUsersRequestSchema = z.object({ 241 | query: z 242 | .string() 243 | .describe( 244 | 'Search users by name, display name, or real name (partial match, case-insensitive)' 245 | ), 246 | limit: z 247 | .number() 248 | .int() 249 | .min(1) 250 | .max(100) 251 | .optional() 252 | .default(20) 253 | .describe('Maximum number of users to return (default 20)'), 254 | include_bots: z 255 | .boolean() 256 | .optional() 257 | .default(false) 258 | .describe('Include bot users in results (default false)'), 259 | }); 260 | 261 | export const SearchMessagesRequestSchema = z.object({ 262 | query: z 263 | .string() 264 | .optional() 265 | .default('') 266 | .describe( 267 | 'Basic search query text only. Use this tool when you need to: search by keywords, filter by user/date/channel, find specific messages with criteria. For general channel browsing without filters, use slack_get_channel_history instead. Do NOT include modifiers like "from:", "in:", etc. - use the dedicated fields instead.' 268 | ) 269 | .refine( 270 | (val) => { 271 | if (!val) return true; 272 | const modifierPattern = 273 | /\b(from|in|before|after|on|during|has|is|with):/i; 274 | return !modifierPattern.test(val); 275 | }, 276 | { 277 | message: 278 | 'Query field cannot contain modifiers (from:, in:, before:, etc.). Please use the dedicated fields for these filters.', 279 | } 280 | ), 281 | 282 | in_channel: z 283 | .string() 284 | .regex(/^C[A-Z0-9]+$/, { 285 | message: 'Must be a valid Slack channel ID (e.g., "C1234567")', 286 | }) 287 | .optional() 288 | .describe( 289 | 'Search within a specific channel. Must be a Slack channel ID (e.g., "C1234567"). Use slack_list_channels to find channel IDs first.' 290 | ), 291 | 292 | from_user: z 293 | .string() 294 | .regex(/^U[A-Z0-9]+$/, { 295 | message: 'Must be a valid Slack user ID (e.g., "U1234567")', 296 | }) 297 | .optional() 298 | .describe( 299 | 'Search for messages from a specific user. IMPORTANT: You cannot use display names or usernames directly. First use slack_get_users to find the user by name and get their user ID (e.g., "U1234567"), then use that ID here.' 300 | ), 301 | 302 | // Date modifiers 303 | before: z 304 | .string() 305 | .regex(/^\d{4}-\d{2}-\d{2}$/, { 306 | message: 'Date must be in YYYY-MM-DD format', 307 | }) 308 | .optional() 309 | .describe('Search for messages before this date (YYYY-MM-DD)'), 310 | after: z 311 | .string() 312 | .regex(/^\d{4}-\d{2}-\d{2}$/, { 313 | message: 'Date must be in YYYY-MM-DD format', 314 | }) 315 | .optional() 316 | .describe('Search for messages after this date (YYYY-MM-DD)'), 317 | on: z 318 | .string() 319 | .regex(/^\d{4}-\d{2}-\d{2}$/, { 320 | message: 'Date must be in YYYY-MM-DD format', 321 | }) 322 | .optional() 323 | .describe('Search for messages on this specific date (YYYY-MM-DD)'), 324 | during: z 325 | .string() 326 | .optional() 327 | .describe( 328 | 'Search for messages during a specific time period (e.g., "July", "2023", "last week")' 329 | ), 330 | 331 | highlight: z 332 | .boolean() 333 | .optional() 334 | .default(false) 335 | .describe('Enable highlighting of search results'), 336 | sort: z 337 | .enum(['score', 'timestamp']) 338 | .optional() 339 | .default('score') 340 | .describe('Search result sort method (score or timestamp)'), 341 | sort_dir: z 342 | .enum(['asc', 'desc']) 343 | .optional() 344 | .default('desc') 345 | .describe('Sort direction (ascending or descending)'), 346 | 347 | count: z 348 | .number() 349 | .int() 350 | .min(1) 351 | .max(100) 352 | .optional() 353 | .default(20) 354 | .describe('Number of results per page (max 100)'), 355 | page: z 356 | .number() 357 | .int() 358 | .min(1) 359 | .max(100) 360 | .optional() 361 | .default(1) 362 | .describe('Page number of results (max 100)'), 363 | }); 364 | 365 | const SearchPaginationSchema = z.object({ 366 | first: z.number().optional(), 367 | last: z.number().optional(), 368 | page: z.number().optional(), 369 | page_count: z.number().optional(), 370 | per_page: z.number().optional(), 371 | total_count: z.number().optional(), 372 | }); 373 | 374 | // 375 | // Response schemas 376 | // 377 | 378 | const BaseResponseSchema = z 379 | .object({ 380 | error: z.string().optional(), 381 | ok: z.boolean().optional(), 382 | response_metadata: z 383 | .object({ 384 | next_cursor: z.string().optional(), 385 | }) 386 | .optional(), 387 | }) 388 | .strip(); 389 | 390 | export const ConversationsHistoryResponseSchema = BaseResponseSchema.extend({ 391 | messages: z.array(ConversationsHistoryMessageSchema).optional(), 392 | }); 393 | 394 | export const ConversationsRepliesResponseSchema = BaseResponseSchema.extend({ 395 | messages: z.array(ConversationsHistoryMessageSchema).optional(), 396 | }); 397 | 398 | export const GetUsersResponseSchema = BaseResponseSchema.extend({ 399 | members: z.array(MemberSchema).optional(), 400 | }); 401 | 402 | export const UserProfileResponseSchema = BaseResponseSchema.extend({ 403 | profile: ProfileSchema.optional(), 404 | }); 405 | 406 | export const GetUserProfilesResponseSchema = z.object({ 407 | profiles: z.array( 408 | z.object({ 409 | user_id: z.string(), 410 | profile: ProfileSchema.optional(), 411 | error: z.string().optional(), 412 | }) 413 | ), 414 | }); 415 | 416 | export const ListChannelsResponseSchema = BaseResponseSchema.extend({ 417 | channels: z.array(ChannelSchema).optional(), 418 | }); 419 | 420 | export const SearchMessagesResponseSchema = BaseResponseSchema.extend({ 421 | messages: z 422 | .object({ 423 | matches: z.array(SearchMessageSchema).optional(), 424 | pagination: SearchPaginationSchema.optional(), 425 | }) 426 | .optional(), 427 | }); 428 | ``` -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- ```typescript 1 | #!/usr/bin/env node 2 | 3 | import { 4 | ListToolsRequestSchema, 5 | CallToolRequestSchema, 6 | } from '@modelcontextprotocol/sdk/types.js'; 7 | import { Server } from '@modelcontextprotocol/sdk/server/index.js'; 8 | import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; 9 | import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; 10 | import { zodToJsonSchema } from 'zod-to-json-schema'; 11 | import { WebClient } from '@slack/web-api'; 12 | import dotenv from 'dotenv'; 13 | import express from 'express'; 14 | import { randomUUID } from 'node:crypto'; 15 | import { 16 | ListChannelsRequestSchema, 17 | PostMessageRequestSchema, 18 | ReplyToThreadRequestSchema, 19 | AddReactionRequestSchema, 20 | GetChannelHistoryRequestSchema, 21 | GetThreadRepliesRequestSchema, 22 | GetUsersRequestSchema, 23 | GetUserProfilesRequestSchema, 24 | ListChannelsResponseSchema, 25 | GetUsersResponseSchema, 26 | GetUserProfilesResponseSchema, 27 | UserProfileResponseSchema, 28 | SearchMessagesRequestSchema, 29 | SearchMessagesResponseSchema, 30 | SearchChannelsRequestSchema, 31 | SearchUsersRequestSchema, 32 | ConversationsHistoryResponseSchema, 33 | ConversationsRepliesResponseSchema, 34 | } from './schemas.js'; 35 | 36 | dotenv.config(); 37 | 38 | if (!process.env.SLACK_BOT_TOKEN) { 39 | console.error( 40 | 'SLACK_BOT_TOKEN is not set. Please set it in your environment or .env file.' 41 | ); 42 | process.exit(1); 43 | } 44 | 45 | if (!process.env.SLACK_USER_TOKEN) { 46 | console.error( 47 | 'SLACK_USER_TOKEN is not set. Please set it in your environment or .env file.' 48 | ); 49 | process.exit(1); 50 | } 51 | 52 | const slackClient = new WebClient(process.env.SLACK_BOT_TOKEN); 53 | const userClient = new WebClient(process.env.SLACK_USER_TOKEN); 54 | 55 | // Safe search mode to exclude private channels and DMs 56 | const safeSearchMode = process.env.SLACK_SAFE_SEARCH === 'true'; 57 | if (safeSearchMode) { 58 | console.error( 59 | 'Safe search mode enabled: Private channels and DMs will be excluded from search results' 60 | ); 61 | } 62 | 63 | // Parse command line arguments 64 | function parseArguments() { 65 | const args = process.argv.slice(2); 66 | let port: number | undefined; 67 | 68 | for (let i = 0; i < args.length; i++) { 69 | if (args[i] === '-port' && i + 1 < args.length) { 70 | const portValue = parseInt(args[i + 1], 10); 71 | if (isNaN(portValue) || portValue <= 0 || portValue > 65535) { 72 | console.error(`Invalid port number: ${args[i + 1]}`); 73 | process.exit(1); 74 | } 75 | port = portValue; 76 | i++; // Skip the next argument since it's the port value 77 | } else if (args[i] === '--help' || args[i] === '-h') { 78 | console.log(` 79 | Usage: slack-mcp-server [options] 80 | 81 | Options: 82 | -port <number> Start the server with Streamable HTTP transport on the specified port 83 | -h, --help Show this help message 84 | 85 | Examples: 86 | slack-mcp-server # Start with stdio transport (default) 87 | slack-mcp-server -port 3000 # Start with Streamable HTTP transport on port 3000 88 | `); 89 | process.exit(0); 90 | } else if (args[i].startsWith('-')) { 91 | console.error(`Unknown option: ${args[i]}`); 92 | console.error('Use --help for usage information'); 93 | process.exit(1); 94 | } 95 | } 96 | 97 | return { port }; 98 | } 99 | 100 | function createServer(): Server { 101 | const server = new Server( 102 | { 103 | name: 'slack-mcp-server', 104 | version: '0.0.1', 105 | }, 106 | { 107 | capabilities: { 108 | tools: {}, 109 | }, 110 | } 111 | ); 112 | 113 | server.setRequestHandler(ListToolsRequestSchema, async () => { 114 | return { 115 | tools: [ 116 | { 117 | name: 'slack_list_channels', 118 | description: 'List public channels in the workspace with pagination', 119 | inputSchema: zodToJsonSchema(ListChannelsRequestSchema), 120 | }, 121 | { 122 | name: 'slack_post_message', 123 | description: 'Post a new message to a Slack channel', 124 | inputSchema: zodToJsonSchema(PostMessageRequestSchema), 125 | }, 126 | { 127 | name: 'slack_reply_to_thread', 128 | description: 'Reply to a specific message thread in Slack', 129 | inputSchema: zodToJsonSchema(ReplyToThreadRequestSchema), 130 | }, 131 | { 132 | name: 'slack_add_reaction', 133 | description: 'Add a reaction emoji to a message', 134 | inputSchema: zodToJsonSchema(AddReactionRequestSchema), 135 | }, 136 | { 137 | name: 'slack_get_channel_history', 138 | description: 139 | 'Get messages from a channel in chronological order. Use this when: 1) You need the latest conversation flow without specific filters, 2) You want ALL messages including bot/automation messages, 3) You need to browse messages sequentially with pagination. Do NOT use if you have specific search criteria (user, keywords, dates) - use slack_search_messages instead.', 140 | inputSchema: zodToJsonSchema(GetChannelHistoryRequestSchema), 141 | }, 142 | { 143 | name: 'slack_get_thread_replies', 144 | description: 'Get all replies in a message thread', 145 | inputSchema: zodToJsonSchema(GetThreadRepliesRequestSchema), 146 | }, 147 | { 148 | name: 'slack_get_users', 149 | description: 150 | 'Retrieve basic profile information of all users in the workspace', 151 | inputSchema: zodToJsonSchema(GetUsersRequestSchema), 152 | }, 153 | { 154 | name: 'slack_get_user_profiles', 155 | description: 'Get multiple users profile information in bulk', 156 | inputSchema: zodToJsonSchema(GetUserProfilesRequestSchema), 157 | }, 158 | { 159 | name: 'slack_search_messages', 160 | description: 161 | 'Search for messages with specific criteria/filters. Use this when: 1) You need to find messages from a specific user, 2) You need messages from a specific date range, 3) You need to search by keywords, 4) You want to filter by channel. This tool is optimized for targeted searches. For general channel browsing without filters, use slack_get_channel_history instead.', 162 | inputSchema: zodToJsonSchema(SearchMessagesRequestSchema), 163 | }, 164 | { 165 | name: 'slack_search_channels', 166 | description: 167 | 'Search for channels by partial name match. Use this when you need to find channels containing specific keywords in their names. Returns up to the specified limit of matching channels.', 168 | inputSchema: zodToJsonSchema(SearchChannelsRequestSchema), 169 | }, 170 | { 171 | name: 'slack_search_users', 172 | description: 173 | 'Search for users by partial name match across username, display name, and real name. Use this when you need to find users containing specific keywords in their names. Returns up to the specified limit of matching users.', 174 | inputSchema: zodToJsonSchema(SearchUsersRequestSchema), 175 | }, 176 | ], 177 | }; 178 | }); 179 | 180 | server.setRequestHandler(CallToolRequestSchema, async (request) => { 181 | try { 182 | if (!request.params) { 183 | throw new Error('Params are required'); 184 | } 185 | switch (request.params.name) { 186 | case 'slack_list_channels': { 187 | const args = ListChannelsRequestSchema.parse( 188 | request.params.arguments 189 | ); 190 | const response = await slackClient.conversations.list({ 191 | limit: args.limit, 192 | cursor: args.cursor, 193 | types: 'public_channel', // Only public channels 194 | }); 195 | if (!response.ok) { 196 | throw new Error(`Failed to list channels: ${response.error}`); 197 | } 198 | const parsed = ListChannelsResponseSchema.parse(response); 199 | 200 | return { 201 | content: [{ type: 'text', text: JSON.stringify(parsed) }], 202 | }; 203 | } 204 | 205 | case 'slack_post_message': { 206 | const args = PostMessageRequestSchema.parse(request.params.arguments); 207 | const response = await slackClient.chat.postMessage({ 208 | channel: args.channel_id, 209 | text: args.text, 210 | }); 211 | if (!response.ok) { 212 | throw new Error(`Failed to post message: ${response.error}`); 213 | } 214 | return { 215 | content: [{ type: 'text', text: 'Message posted successfully' }], 216 | }; 217 | } 218 | 219 | case 'slack_reply_to_thread': { 220 | const args = ReplyToThreadRequestSchema.parse( 221 | request.params.arguments 222 | ); 223 | const response = await slackClient.chat.postMessage({ 224 | channel: args.channel_id, 225 | thread_ts: args.thread_ts, 226 | text: args.text, 227 | }); 228 | if (!response.ok) { 229 | throw new Error(`Failed to reply to thread: ${response.error}`); 230 | } 231 | return { 232 | content: [ 233 | { type: 'text', text: 'Reply sent to thread successfully' }, 234 | ], 235 | }; 236 | } 237 | case 'slack_add_reaction': { 238 | const args = AddReactionRequestSchema.parse(request.params.arguments); 239 | const response = await slackClient.reactions.add({ 240 | channel: args.channel_id, 241 | timestamp: args.timestamp, 242 | name: args.reaction, 243 | }); 244 | if (!response.ok) { 245 | throw new Error(`Failed to add reaction: ${response.error}`); 246 | } 247 | return { 248 | content: [{ type: 'text', text: 'Reaction added successfully' }], 249 | }; 250 | } 251 | 252 | case 'slack_get_channel_history': { 253 | const args = GetChannelHistoryRequestSchema.parse( 254 | request.params.arguments 255 | ); 256 | const response = await slackClient.conversations.history({ 257 | channel: args.channel_id, 258 | limit: args.limit, 259 | cursor: args.cursor, 260 | }); 261 | if (!response.ok) { 262 | throw new Error(`Failed to get channel history: ${response.error}`); 263 | } 264 | const parsedResponse = 265 | ConversationsHistoryResponseSchema.parse(response); 266 | return { 267 | content: [{ type: 'text', text: JSON.stringify(parsedResponse) }], 268 | }; 269 | } 270 | 271 | case 'slack_get_thread_replies': { 272 | const args = GetThreadRepliesRequestSchema.parse( 273 | request.params.arguments 274 | ); 275 | const response = await slackClient.conversations.replies({ 276 | channel: args.channel_id, 277 | ts: args.thread_ts, 278 | limit: args.limit, 279 | cursor: args.cursor, 280 | }); 281 | if (!response.ok) { 282 | throw new Error(`Failed to get thread replies: ${response.error}`); 283 | } 284 | const parsedResponse = 285 | ConversationsRepliesResponseSchema.parse(response); 286 | return { 287 | content: [{ type: 'text', text: JSON.stringify(parsedResponse) }], 288 | }; 289 | } 290 | 291 | case 'slack_get_users': { 292 | const args = GetUsersRequestSchema.parse(request.params.arguments); 293 | const response = await slackClient.users.list({ 294 | limit: args.limit, 295 | cursor: args.cursor, 296 | }); 297 | if (!response.ok) { 298 | throw new Error(`Failed to get users: ${response.error}`); 299 | } 300 | const parsed = GetUsersResponseSchema.parse(response); 301 | 302 | return { 303 | content: [{ type: 'text', text: JSON.stringify(parsed) }], 304 | }; 305 | } 306 | 307 | case 'slack_get_user_profiles': { 308 | const args = GetUserProfilesRequestSchema.parse( 309 | request.params.arguments 310 | ); 311 | 312 | // Use Promise.all for concurrent API calls 313 | const profilePromises = args.user_ids.map(async (userId) => { 314 | try { 315 | const response = await slackClient.users.profile.get({ 316 | user: userId, 317 | }); 318 | if (!response.ok) { 319 | return { 320 | user_id: userId, 321 | error: response.error || 'Unknown error', 322 | }; 323 | } 324 | const parsed = UserProfileResponseSchema.parse(response); 325 | return { 326 | user_id: userId, 327 | profile: parsed.profile, 328 | }; 329 | } catch (error) { 330 | return { 331 | user_id: userId, 332 | error: error instanceof Error ? error.message : 'Unknown error', 333 | }; 334 | } 335 | }); 336 | 337 | const results = await Promise.all(profilePromises); 338 | const responseData = GetUserProfilesResponseSchema.parse({ 339 | profiles: results, 340 | }); 341 | 342 | return { 343 | content: [{ type: 'text', text: JSON.stringify(responseData) }], 344 | }; 345 | } 346 | 347 | case 'slack_search_messages': { 348 | const parsedParams = SearchMessagesRequestSchema.parse( 349 | request.params.arguments 350 | ); 351 | 352 | let query = parsedParams.query || ''; 353 | 354 | if (parsedParams.in_channel) { 355 | // Resolve channel name from ID 356 | const channelInfo = await slackClient.conversations.info({ 357 | channel: parsedParams.in_channel, 358 | }); 359 | if (!channelInfo.ok || !channelInfo.channel?.name) { 360 | throw new Error( 361 | `Failed to get channel info: ${channelInfo.error}` 362 | ); 363 | } 364 | query += ` in:${channelInfo.channel.name}`; 365 | } 366 | 367 | // Handle from_user - always use user ID format 368 | if (parsedParams.from_user) { 369 | query += ` from:<@${parsedParams.from_user}>`; 370 | } 371 | 372 | // Date modifiers 373 | if (parsedParams.before) { 374 | query += ` before:${parsedParams.before}`; 375 | } 376 | if (parsedParams.after) { 377 | query += ` after:${parsedParams.after}`; 378 | } 379 | if (parsedParams.on) { 380 | query += ` on:${parsedParams.on}`; 381 | } 382 | if (parsedParams.during) { 383 | query += ` during:${parsedParams.during}`; 384 | } 385 | 386 | // Trim and log the final query for debugging 387 | query = query.trim(); 388 | console.log('Search query:', query); 389 | 390 | const response = await userClient.search.messages({ 391 | query: query, 392 | highlight: parsedParams.highlight, 393 | sort: parsedParams.sort, 394 | sort_dir: parsedParams.sort_dir, 395 | count: parsedParams.count, 396 | page: parsedParams.page, 397 | }); 398 | 399 | if (!response.ok) { 400 | throw new Error(`Failed to search messages: ${response.error}`); 401 | } 402 | 403 | // Apply safe search filtering if enabled (before parsing) 404 | if (safeSearchMode && response.messages?.matches) { 405 | const originalCount = response.messages.matches.length; 406 | response.messages.matches = response.messages.matches.filter( 407 | (msg: { 408 | channel?: { 409 | is_private?: boolean; 410 | is_im?: boolean; 411 | is_mpim?: boolean; 412 | }; 413 | }) => { 414 | // Exclude private channels, DMs, and multi-party DMs 415 | const channel = msg.channel; 416 | if (!channel) return true; // Keep if no channel info 417 | 418 | return !( 419 | channel.is_private || 420 | channel.is_im || 421 | channel.is_mpim 422 | ); 423 | } 424 | ); 425 | 426 | const filteredCount = 427 | originalCount - response.messages.matches.length; 428 | if (filteredCount > 0) { 429 | console.error( 430 | `Safe search: Filtered out ${filteredCount} messages from private channels/DMs` 431 | ); 432 | } 433 | } 434 | 435 | const parsed = SearchMessagesResponseSchema.parse(response); 436 | return { 437 | content: [{ type: 'text', text: JSON.stringify(parsed) }], 438 | }; 439 | } 440 | 441 | case 'slack_search_channels': { 442 | const args = SearchChannelsRequestSchema.parse( 443 | request.params.arguments 444 | ); 445 | 446 | // Fetch all channels with a reasonable limit 447 | const allChannels: Array<{ 448 | id?: string; 449 | name?: string; 450 | is_archived?: boolean; 451 | [key: string]: unknown; 452 | }> = []; 453 | let cursor: string | undefined; 454 | const maxPages = 5; // Limit to prevent infinite loops 455 | let pageCount = 0; 456 | 457 | // Fetch multiple pages if needed 458 | while (pageCount < maxPages) { 459 | const response = await slackClient.conversations.list({ 460 | types: 'public_channel', 461 | exclude_archived: !args.include_archived, 462 | limit: 1000, // Max allowed by Slack API 463 | cursor, 464 | }); 465 | 466 | if (!response.ok) { 467 | throw new Error(`Failed to search channels: ${response.error}`); 468 | } 469 | 470 | if (response.channels) { 471 | allChannels.push(...(response.channels as typeof allChannels)); 472 | } 473 | 474 | cursor = response.response_metadata?.next_cursor; 475 | pageCount++; 476 | 477 | // Stop if no more pages 478 | if (!cursor) break; 479 | } 480 | 481 | // Filter channels by name (case-insensitive partial match) 482 | const searchTerm = args.query.toLowerCase(); 483 | const filteredChannels = allChannels.filter((channel) => 484 | channel.name?.toLowerCase().includes(searchTerm) 485 | ); 486 | 487 | // Limit results 488 | const limitedChannels = filteredChannels.slice(0, args.limit); 489 | 490 | const response = { 491 | ok: true, 492 | channels: limitedChannels, 493 | }; 494 | 495 | const parsed = ListChannelsResponseSchema.parse(response); 496 | return { 497 | content: [{ type: 'text', text: JSON.stringify(parsed) }], 498 | }; 499 | } 500 | 501 | case 'slack_search_users': { 502 | const args = SearchUsersRequestSchema.parse(request.params.arguments); 503 | 504 | // Fetch all users with a reasonable limit 505 | const allUsers: Array<{ 506 | id?: string; 507 | name?: string; 508 | real_name?: string; 509 | is_bot?: boolean; 510 | profile?: { 511 | display_name?: string; 512 | display_name_normalized?: string; 513 | [key: string]: unknown; 514 | }; 515 | [key: string]: unknown; 516 | }> = []; 517 | let cursor: string | undefined; 518 | const maxPages = 5; // Limit to prevent infinite loops 519 | let pageCount = 0; 520 | 521 | // Fetch multiple pages if needed 522 | while (pageCount < maxPages) { 523 | const response = await slackClient.users.list({ 524 | limit: 1000, // Max allowed by Slack API 525 | cursor, 526 | }); 527 | 528 | if (!response.ok) { 529 | throw new Error(`Failed to search users: ${response.error}`); 530 | } 531 | 532 | if (response.members) { 533 | allUsers.push(...(response.members as typeof allUsers)); 534 | } 535 | 536 | cursor = response.response_metadata?.next_cursor; 537 | pageCount++; 538 | 539 | // Stop if no more pages 540 | if (!cursor) break; 541 | } 542 | 543 | // Filter users (case-insensitive partial match across multiple fields) 544 | const searchTerm = args.query.toLowerCase(); 545 | const filteredUsers = allUsers.filter((user) => { 546 | // Skip bots if requested 547 | if (!args.include_bots && user.is_bot) { 548 | return false; 549 | } 550 | 551 | // Search across multiple name fields 552 | const name = user.name?.toLowerCase() || ''; 553 | const realName = user.real_name?.toLowerCase() || ''; 554 | const displayName = user.profile?.display_name?.toLowerCase() || ''; 555 | const displayNameNormalized = 556 | user.profile?.display_name_normalized?.toLowerCase() || ''; 557 | 558 | return ( 559 | name.includes(searchTerm) || 560 | realName.includes(searchTerm) || 561 | displayName.includes(searchTerm) || 562 | displayNameNormalized.includes(searchTerm) 563 | ); 564 | }); 565 | 566 | // Limit results 567 | const limitedUsers = filteredUsers.slice(0, args.limit); 568 | 569 | const response = { 570 | ok: true, 571 | members: limitedUsers, 572 | }; 573 | 574 | const parsed = GetUsersResponseSchema.parse(response); 575 | return { 576 | content: [{ type: 'text', text: JSON.stringify(parsed) }], 577 | }; 578 | } 579 | 580 | default: 581 | throw new Error(`Unknown tool: ${request.params.name}`); 582 | } 583 | } catch (error) { 584 | console.error('Error handling request:', error); 585 | const errorMessage = 586 | error instanceof Error ? error.message : 'Unknown error occurred'; 587 | throw new Error(errorMessage); 588 | } 589 | }); 590 | 591 | return server; 592 | } 593 | 594 | async function runStdioServer() { 595 | const server = createServer(); 596 | const transport = new StdioServerTransport(); 597 | await server.connect(transport); 598 | console.error('Slack MCP Server running on stdio'); 599 | } 600 | 601 | async function runHttpServer(port: number) { 602 | const app = express(); 603 | app.use(express.json()); 604 | 605 | // Map to store transports by session ID 606 | const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; 607 | 608 | // Handle POST requests for client-to-server communication 609 | app.post('/mcp', async (req, res) => { 610 | try { 611 | // Check for existing session ID 612 | const sessionId = req.headers['mcp-session-id'] as string | undefined; 613 | let transport: StreamableHTTPServerTransport; 614 | 615 | if (sessionId && transports[sessionId]) { 616 | // Reuse existing transport 617 | transport = transports[sessionId]; 618 | } else { 619 | // Create new transport 620 | transport = new StreamableHTTPServerTransport({ 621 | sessionIdGenerator: () => randomUUID(), 622 | onsessioninitialized: (newSessionId) => { 623 | // Store the transport by session ID 624 | transports[newSessionId] = transport; 625 | console.error(`New MCP session initialized: ${newSessionId}`); 626 | }, 627 | }); 628 | 629 | // Clean up transport when closed 630 | transport.onclose = () => { 631 | if (transport.sessionId) { 632 | delete transports[transport.sessionId]; 633 | console.error(`MCP session closed: ${transport.sessionId}`); 634 | } 635 | }; 636 | 637 | const server = createServer(); 638 | await server.connect(transport); 639 | } 640 | 641 | // Handle the request 642 | await transport.handleRequest(req, res, req.body); 643 | } catch (error) { 644 | console.error('Error handling MCP request:', error); 645 | if (!res.headersSent) { 646 | res.status(500).json({ 647 | jsonrpc: '2.0', 648 | error: { 649 | code: -32603, 650 | message: 'Internal server error', 651 | }, 652 | id: null, 653 | }); 654 | } 655 | } 656 | }); 657 | 658 | // Handle GET requests for server-to-client notifications via SSE 659 | app.get('/mcp', async (req, res) => { 660 | const sessionId = req.headers['mcp-session-id'] as string | undefined; 661 | if (!sessionId || !transports[sessionId]) { 662 | res.status(400).send('Invalid or missing session ID'); 663 | return; 664 | } 665 | 666 | const transport = transports[sessionId]; 667 | await transport.handleRequest(req, res); 668 | }); 669 | 670 | // Handle DELETE requests for session termination 671 | app.delete('/mcp', async (req, res) => { 672 | const sessionId = req.headers['mcp-session-id'] as string | undefined; 673 | if (!sessionId || !transports[sessionId]) { 674 | res.status(400).send('Invalid or missing session ID'); 675 | return; 676 | } 677 | 678 | const transport = transports[sessionId]; 679 | await transport.handleRequest(req, res); 680 | }); 681 | 682 | // Health check endpoint 683 | app.get('/health', (req, res) => { 684 | res.json({ status: 'ok', timestamp: new Date().toISOString() }); 685 | }); 686 | 687 | app.listen(port, () => { 688 | console.error( 689 | `Slack MCP Server running on Streamable HTTP at http://localhost:${port}/mcp` 690 | ); 691 | console.error(`Health check available at http://localhost:${port}/health`); 692 | }); 693 | } 694 | 695 | async function main() { 696 | const { port } = parseArguments(); 697 | 698 | if (port !== undefined) { 699 | // Run with Streamable HTTP transport 700 | await runHttpServer(port); 701 | } else { 702 | // Run with stdio transport (default) 703 | await runStdioServer(); 704 | } 705 | } 706 | 707 | main().catch((error) => { 708 | console.error('Fatal error in main():', error); 709 | process.exit(1); 710 | }); 711 | ```