# 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 |
```