#
tokens: 20148/50000 20/20 files
lines: on (toggle) GitHub
raw markdown copy reset
# 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 | 
```