# 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: -------------------------------------------------------------------------------- ``` 20.17.0 ``` -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- ``` { "semi": true, "singleQuote": true, "tabWidth": 2, "trailingComma": "es5" } ``` -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- ``` EXMAPLES_SLACK_BOT_TOKEN=xoxb-your-slack-token-here EXMAPLES_SLACK_USER_TOKEN=xoxp-your-slack-token-here ``` -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* lerna-debug.log* .pnpm-debug.log* # Diagnostic reports (https://nodejs.org/api/report.html) report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json # Runtime data pids *.pid *.seed *.pid.lock # Directory for instrumented libs generated by jscoverage/JSCover lib-cov # Coverage directory used by tools like istanbul coverage *.lcov # nyc test coverage .nyc_output # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) .grunt # Bower dependency directory (https://bower.io/) bower_components # node-waf configuration .lock-wscript # Compiled binary addons (https://nodejs.org/api/addons.html) build/Release # Dependency directories node_modules/ jspm_packages/ # Snowpack dependency directory (https://snowpack.dev/) web_modules/ # TypeScript cache *.tsbuildinfo # Optional npm cache directory .npm # Optional eslint cache .eslintcache # Optional stylelint cache .stylelintcache # Microbundle cache .rpt2_cache/ .rts2_cache_cjs/ .rts2_cache_es/ .rts2_cache_umd/ # Optional REPL history .node_repl_history # Output of 'npm pack' *.tgz # Yarn Integrity file .yarn-integrity # dotenv environment variable files .env .env.development.local .env.test.local .env.production.local .env.local # parcel-bundler cache (https://parceljs.org/) .cache .parcel-cache # Next.js build output .next out # Nuxt.js build / generate output .nuxt dist # Gatsby files .cache/ # Comment in the public line in if your project uses Gatsby and not Next.js # https://nextjs.org/blog/next-9-1#public-directory-support # public # vuepress build output .vuepress/dist # vuepress v2.x temp and cache directory .temp .cache # vitepress build output **/.vitepress/dist # vitepress cache directory **/.vitepress/cache # Docusaurus cache and generated files .docusaurus # Serverless directories .serverless/ # FuseBox cache .fusebox/ # DynamoDB Local files .dynamodb/ # TernJS port file .tern-port # Stores VSCode versions used for testing VSCode extensions .vscode-test # yarn v2 .yarn/cache .yarn/unplugged .yarn/build-state.yml .yarn/install-state.gz .pnp.* ``` -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- ```markdown # Examples This directory contains example clients for the Slack MCP Server, demonstrating both stdio and Streamable HTTP transport methods. ## Available Examples ### 1. Stdio Client (`get_users.ts`) Uses the traditional stdio transport to communicate with the MCP server. ### 2. Streamable HTTP Client (`get_users_http.ts`) Uses the newer Streamable HTTP transport to communicate with the MCP server over HTTP. ## Setup 1. Set up your environment variables in `.env`: ```env # For the server SLACK_BOT_TOKEN=xoxb-your-bot-token SLACK_USER_TOKEN=xoxp-your-user-token # For the examples (same values, different variable names) EXMAPLES_SLACK_BOT_TOKEN=xoxb-your-bot-token EXMAPLES_SLACK_USER_TOKEN=xoxp-your-user-token ``` ## Usage ### Running the Stdio Example ```bash # Run the stdio client example npm run examples ``` ### Running the HTTP Example ```bash # Terminal 1: Start the HTTP server npm run start -- -port 3000 # Terminal 2: Run the HTTP client example npm run examples:http # Or specify a custom server URL npm run examples:http http://localhost:3001/mcp ``` ## What the Examples Do Both examples: 1. Connect to the Slack MCP Server 2. List available tools 3. Call the `slack_get_users` tool with a limit of 100 users 4. Display the retrieved user information including: - User name - Real name - User ID - Pagination information if more users are available ## Transport Comparison ### Stdio Transport - **Pros**: Simple, no network setup required - **Cons**: Process-based communication, harder to debug network issues - **Use case**: Local development, direct integration ### Streamable HTTP Transport - **Pros**: Standard HTTP, easier debugging, supports web-based clients - **Cons**: Requires server setup, network configuration - **Use case**: Web applications, remote clients, production deployments ## Troubleshooting ### Common Issues 1. **Missing environment variables**: Ensure all required `SLACK_BOT_TOKEN`, `SLACK_USER_TOKEN`, `EXMAPLES_SLACK_BOT_TOKEN`, and `EXMAPLES_SLACK_USER_TOKEN` are set. 2. **Connection refused (HTTP)**: Make sure the HTTP server is running on the specified port before running the HTTP client. 3. **Permission errors**: Ensure your Slack tokens have the necessary permissions to list users in your workspace. ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown # slack-mcp-server 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. ## Transport Support This server supports both traditional and modern MCP transport methods: - **Stdio Transport** (default): Process-based communication for local integration - **Streamable HTTP Transport**: HTTP-based communication for web applications and remote clients ## Features Available tools: - `slack_list_channels` - List public channels in the workspace with pagination - `slack_post_message` - Post a new message to a Slack channel - `slack_reply_to_thread` - Reply to a specific message thread in Slack - `slack_add_reaction` - Add a reaction emoji to a message - `slack_get_channel_history` - Get recent messages from a channel - `slack_get_thread_replies` - Get all replies in a message thread - `slack_get_users` - Retrieve basic profile information of all users in the workspace - `slack_get_user_profiles` - Get multiple users' profile information in bulk (efficient for batch operations) - `slack_search_messages` - Search for messages in the workspace with powerful filters: - Basic query search - Location filters: `in_channel` - User filters: `from_user`, `with` - Date filters: `before` (YYYY-MM-DD), `after` (YYYY-MM-DD), `on` (YYYY-MM-DD), `during` (e.g., "July", "2023") - Content filters: `has` (emoji reactions), `is` (saved/thread) - Sorting options by relevance score or timestamp ## Quick Start ### Installation ```bash npm install @ubie-oss/slack-mcp-server ``` NOTE: Its now hosted in GitHub Registry so you need your PAT. ### Configuration You need to set the following environment variables: - `SLACK_BOT_TOKEN`: Slack Bot User OAuth Token - `SLACK_USER_TOKEN`: Slack User OAuth Token (required for some features like message search) - `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. You can also create a `.env` file to set these environment variables: ``` SLACK_BOT_TOKEN=xoxb-your-bot-token SLACK_USER_TOKEN=xoxp-your-user-token SLACK_SAFE_SEARCH=true # Optional: Enable safe search mode ``` ### Usage #### Start the MCP server **Stdio Transport (default)**: ```bash npx @ubie-oss/slack-mcp-server ``` **Streamable HTTP Transport**: ```bash npx @ubie-oss/slack-mcp-server -port 3000 ``` You can also run the installed module with node: ```bash # Stdio transport node node_modules/.bin/slack-mcp-server # HTTP transport node node_modules/.bin/slack-mcp-server -port 3000 ``` **Command Line Options**: - `-port <number>`: Start with Streamable HTTP transport on specified port - `-h, --help`: Show help message #### Client Configuration **For Stdio Transport (Claude Desktop, etc.)**: ```json { "slack": { "command": "npx", "args": [ "-y", "@ubie-oss/slack-mcp-server" ], "env": { "NPM_CONFIG_//npm.pkg.github.com/:_authToken": "<your-github-pat>", "SLACK_BOT_TOKEN": "<your-bot-token>", "SLACK_USER_TOKEN": "<your-user-token>", "SLACK_SAFE_SEARCH": "true" } } } ``` **For Streamable HTTP Transport (Web applications)**: Start the server: ```bash SLACK_BOT_TOKEN=<your-bot-token> SLACK_USER_TOKEN=<your-user-token> npx @ubie-oss/slack-mcp-server -port 3000 ``` Connect to: `http://localhost:3000/mcp` See [examples/README.md](examples/README.md) for detailed client examples. ## Implementation Pattern This server adopts the following implementation pattern: 1. Define request/response using Zod schemas - Request schema: Define input parameters - Response schema: Define responses limited to necessary fields 2. Implementation flow: - Validate request with Zod schema - Call Slack WebAPI - Parse response with Zod schema to limit to necessary fields - Return as JSON For example, the `slack_list_channels` implementation parses the request with `ListChannelsRequestSchema`, calls `slackClient.conversations.list`, and returns the response parsed with `ListChannelsResponseSchema`. ## Development ### Available Scripts - `npm run dev` - Start the server in development mode with hot reloading - `npm run build` - Build the project for production - `npm run start` - Start the production server - `npm run lint` - Run linting checks (ESLint and Prettier) - `npm run fix` - Automatically fix linting issues ### Contributing 1. Fork the repository 2. Create your feature branch 3. Run tests and linting: `npm run lint` 4. Commit your changes 5. Push to the branch 6. Create a Pull Request ``` -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- ```markdown # CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Project Overview 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. ## Development Commands ### Building and Running - `npm run build` - Compile TypeScript to JavaScript in `/dist` - `npm run dev` - Start server in development mode with hot reloading - `npm start` - Run the production build ### Code Quality - `npm run lint` - Run both ESLint and Prettier checks - `npm run fix` - Auto-fix all linting issues - `npm run lint:eslint` - Run ESLint only - `npm run lint:prettier` - Run Prettier only ### Examples - `npm run examples` - Run stdio transport example - `npm run examples:http` - Run HTTP transport example ## Architecture ### Core Structure The server follows a schema-driven design pattern: 1. **Request/Response Schemas** (`src/schemas.ts`): - All Slack API interactions are validated with Zod schemas - Request schemas define input parameters - Response schemas filter API responses to only necessary fields 2. **Main Server** (`src/index.ts`): - Dual transport support via command-line flag - Tool registration and request handling - Environment variable validation ### Transport Modes - **Stdio (default)**: For CLI integration (Claude Desktop, etc.) - **HTTP**: For web applications via `-port` flag ### Available Tools All tools follow the pattern: validate request → call Slack API → parse response → return JSON - Channel operations: list, post message, get history - Thread operations: reply, get replies - User operations: get users, profiles, bulk profiles - Message operations: search, add reactions ### Tool Selection Guidelines **When to use `slack_search_messages`:** - You need to find messages with specific criteria (keywords, user, date range, channel) - You want to filter/narrow down results based on conditions - You're looking for targeted information rather than browsing **When to use `slack_get_channel_history`:** - You want to see the latest conversation flow without specific filters - You need ALL messages including bot/automation messages (search excludes these) - You want to browse messages chronologically with pagination - You don't have specific search criteria and just want to understand recent activity ### Environment Requirements Must set in environment or `.env` file: - `SLACK_BOT_TOKEN`: Bot User OAuth Token - `SLACK_USER_TOKEN`: User OAuth Token (for search) ## Key Implementation Notes 1. **No Test Suite**: Currently no tests implemented (`"test": "echo \"No tests yet\""`) 2. **Type Safety**: All Slack API responses are parsed through Zod schemas to ensure type safety and limit response size 3. **Error Handling**: The server validates tokens on startup and provides clear error messages 4. **Publishing**: Uses GitHub Package Registry - requires PAT for installation 5. **ES Modules**: Project uses `"type": "module"` - use ES import syntax ## Common Tasks ### Adding a New Slack Tool 1. Define request/response schemas in `src/schemas.ts` 2. Add tool registration in `src/index.ts` server setup 3. Implement handler following existing pattern: validate → API call → parse → return 4. Update README.md with new tool documentation ### Search Messages Considerations 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 2. **Date Search**: The `on:` modifier may not find results due to timezone differences between the Slack workspace and the user's local time 3. **ID-Only Fields**: All search modifier fields require proper Slack IDs for consistency and reliability: - `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. - `from_user`: User ID (e.g., `U1234567`) - use `slack_get_users` to find user IDs 4. **Required Workflow**: Always use the appropriate listing tools first to convert names to IDs before searching 5. **Debug**: Search queries are logged to console for troubleshooting ### Known API Limitations 1. **Bot Message Exclusion**: The `search.messages` API excludes bot/automation messages by default, unlike the Slack UI 2. **Indexing Delays**: Messages are not indexed immediately; there can be delays between posting and searchability 3. **Proximity Filtering**: When multiple messages match in close proximity, only one result may be returned 4. **Rate Limiting**: Non-Marketplace apps have severe rate limits (1 request/minute, 15 messages max as of 2025) 5. **Comprehensive Alternative**: Use `conversations.history` for retrieving all messages including bot messages ### Modifying Schemas When updating schemas, ensure backward compatibility and update both request validation and response filtering to maintain efficiency. ``` -------------------------------------------------------------------------------- /ts-node-loader.js: -------------------------------------------------------------------------------- ```javascript import { register } from 'node:module'; import { pathToFileURL } from 'node:url'; register('ts-node/esm', pathToFileURL('./')); ``` -------------------------------------------------------------------------------- /tsconfig.dev.json: -------------------------------------------------------------------------------- ```json { "extends": "./tsconfig.json", "compilerOptions": { "outDir": "./dist" }, "include": ["src/**/*", "examples/**/*"], "exclude": ["node_modules", "dist"] } ``` -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- ```json { "extends": "./tsconfig.json", "compilerOptions": { "outDir": "./dist", "rootDir": "./src" }, "include": ["src/**/*"], "exclude": ["node_modules", "dist", "examples"] } ``` -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- ```yaml name: CI on: push: branches: [ main ] pull_request: branches: [ main ] jobs: lint-and-build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '20' cache: 'npm' - name: Install dependencies run: npm ci - name: Lint run: npm run lint - name: Build run: npm run build ``` -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- ```json { "compilerOptions": { "target": "es2022", "module": "NodeNext", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "moduleResolution": "NodeNext", "resolveJsonModule": true, "types": ["node"], "lib": ["es2022"], "declaration": true, "sourceMap": true, "allowSyntheticDefaultImports": true }, "ts-node": { "esm": true, "experimentalSpecifiers": true, "project": "./tsconfig.dev.json" } } ``` -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- ```javascript import js from "@eslint/js"; import globals from "globals"; import tseslint from "typescript-eslint"; import prettier from "eslint-config-prettier"; export default [ js.configs.recommended, { files: ["**/*.{js,mjs,cjs,ts}"], languageOptions: { globals: { ...globals.browser, ...globals.node }, ecmaVersion: 2022 } }, ...tseslint.configs.recommended, { files: ["**/*.ts"], plugins: { "@typescript-eslint": tseslint.plugin }, languageOptions: { parser: tseslint.parser, parserOptions: { project: true } }, rules: {} }, prettier ]; ``` -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- ```dockerfile # Use Node.js base image FROM node:20-slim AS builder # Set working directory WORKDIR /app # Copy package.json and package-lock.json COPY package*.json ./ # Install dependencies RUN npm ci # Copy source code COPY . . # Build TypeScript RUN npm run build # Runtime image with minimal footprint FROM node:20-slim WORKDIR /app # Copy only necessary files from builder stage COPY --from=builder /app/dist ./dist COPY --from=builder /app/package*.json ./ COPY --from=builder /app/node_modules ./node_modules # Set non-root user for security USER node # Set environment variables ENV NODE_ENV=production # Run the application ENTRYPOINT ["node", "dist/index.js"] ``` -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- ```yaml name: Publish Package on: release: types: [created] jobs: build-and-publish: runs-on: ubuntu-latest permissions: contents: read packages: write steps: - uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '20' registry-url: 'https://npm.pkg.github.com' scope: '@ubie-oss' cache: 'npm' - name: Install dependencies run: npm ci - name: Lint run: npm run lint - name: Publish to GitHub Packages run: npm publish env: NODE_AUTH_TOKEN: ${{secrets.GITHUB_TOKEN}} ``` -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- ```json { "name": "@ubie-oss/slack-mcp-server", "version": "0.1.4", "description": "A Slack MCP server", "main": "dist/index.js", "type": "module", "bin": { "slack-mcp-server": "dist/index.js" }, "scripts": { "dev": "node --import ./ts-node-loader.js src/index.ts", "build": "tsc -p tsconfig.build.json && shx chmod +x dist/*.js", "start": "node dist/index.js", "test": "echo \"No tests yet\"", "lint": "npm run lint:eslint && npm run lint:prettier", "lint:eslint": "eslint \"src/**/*.ts\" \"examples/**/*.ts\"", "lint:prettier": "prettier --check \"src/**/*.ts\" \"examples/**/*.ts\"", "fix": "npm run fix:eslint && npm run fix:prettier", "fix:eslint": "eslint \"src/**/*.ts\" \"examples/**/*.ts\" --fix", "fix:prettier": "prettier --write \"src/**/*.ts\" \"examples/**/*.ts\"", "examples": "node --import ./ts-node-loader.js examples/get_users.ts", "examples:http": "node --import ./ts-node-loader.js examples/get_users_http.ts", "prepublishOnly": "npm run build" }, "keywords": [ "mcp", "slack" ], "author": "Ubie, Inc.", "repository": { "type": "git", "url": "https://github.com/ubie-oss/slack-mcp-server.git" }, "homepage": "https://github.com/ubie-oss/slack-mcp-server", "bugs": { "url": "https://github.com/ubie-oss/slack-mcp-server/issues" }, "license": "Apache-2.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.12.1", "@slack/web-api": "^7.9.1", "@types/node": "^20.10.3", "dotenv": "^16.4.7", "express": "^5.1.0", "typescript": "^5.3.2", "zod": "^3.22.4", "zod-to-json-schema": "^3.22.4" }, "devDependencies": { "@eslint/js": "^9.24.0", "@types/express": "^5.0.3", "@typescript-eslint/eslint-plugin": "^6.19.0", "@typescript-eslint/parser": "^6.19.0", "eslint": "^8.57.1", "eslint-config-prettier": "^9.1.0", "globals": "^16.0.0", "prettier": "^3.2.2", "shx": "^0.3.4", "ts-node": "^10.9.2", "typescript-eslint": "^8.29.1" } } ``` -------------------------------------------------------------------------------- /examples/get_users_http.ts: -------------------------------------------------------------------------------- ```typescript import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; import { config } from 'dotenv'; import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'; import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; // Load environment variables from .env file config(); // Get and validate necessary environment variables const slackToken = process.env.EXMAPLES_SLACK_BOT_TOKEN; const userToken = process.env.EXMAPLES_SLACK_USER_TOKEN; if (!slackToken) { throw new Error('EXMAPLES_SLACK_BOT_TOKEN environment variable is required'); } if (!userToken) { throw new Error('EXMAPLES_SLACK_USER_TOKEN environment variable is required'); } async function main() { // Parse command line arguments for server URL const args = process.argv.slice(2); const serverUrl = args[0] || 'http://localhost:3000/mcp'; console.log(`Connecting to MCP server at: ${serverUrl}`); // Initialize MCP client const client = new Client( { name: 'slack-get-users-http-example-client', version: '1.0.0', }, { capabilities: {}, } ); // Create Streamable HTTP transport for connecting to the server const transport = new StreamableHTTPClientTransport(new URL(serverUrl)); try { // Connect to the server await client.connect(transport); console.log('Connected to MCP server via HTTP'); // List available tools const toolsResponse = await client.listTools(); console.log( 'Available tools:', toolsResponse.tools.map((t) => t.name).join(', ') ); // Call slack_get_users console.log('\nCalling slack_get_users...'); const response = (await client.callTool( { name: 'slack_get_users', arguments: { limit: 100, }, }, CallToolResultSchema )) as CallToolResult; if ( Array.isArray(response.content) && response.content.length > 0 && response.content[0]?.type === 'text' ) { // Parse the response and display user information const slackResponse = JSON.parse(response.content[0].text); console.log('Slack users retrieved successfully!'); console.log('Total users:', slackResponse.members?.length || 0); // Display basic information for each user if (slackResponse.members && slackResponse.members.length > 0) { console.log('\nUser information:'); slackResponse.members.forEach( (user: { id: string; name: string; real_name?: string }) => { console.log( `- ${user.name} (${user.real_name || 'N/A'}) [ID: ${user.id}]` ); } ); // Display pagination information if available if (slackResponse.response_metadata?.next_cursor) { console.log( `\nMore users available. Next cursor: ${slackResponse.response_metadata.next_cursor}` ); } } } else { console.error('Unexpected response format'); } } catch (error) { console.error('Error:', error); process.exit(1); } finally { // Close the connection await transport.close(); console.log('Connection closed'); } } main(); ``` -------------------------------------------------------------------------------- /examples/get_users.ts: -------------------------------------------------------------------------------- ```typescript import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; import { config } from 'dotenv'; import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'; import { fileURLToPath } from 'node:url'; import { dirname, resolve } from 'node:path'; import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; // Get the directory of the current file const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); // Load environment variables from .env file config(); // Get and validate necessary environment variables const slackToken = process.env.EXMAPLES_SLACK_BOT_TOKEN; const userToken = process.env.EXMAPLES_SLACK_USER_TOKEN; if (!slackToken) { throw new Error('EXMAPLES_SLACK_BOT_TOKEN environment variable is required'); } if (!userToken) { throw new Error('EXMAPLES_SLACK_USER_TOKEN environment variable is required'); } // After validation, can be safely treated as a string const env = { SLACK_BOT_TOKEN: slackToken, SLACK_USER_TOKEN: userToken, } as const satisfies Record<string, string>; async function main() { // Initialize MCP client const client = new Client( { name: 'slack-get-users-example-client', version: '1.0.0', }, { capabilities: {}, } ); // Create transport for connecting to the server const transport = new StdioClientTransport({ command: process.execPath, args: [ '--import', resolve(__dirname, '../ts-node-loader.js'), resolve(__dirname, '../src/index.ts'), ], env, }); try { // Connect to the server await client.connect(transport); console.log('Connected to MCP server'); // List available tools const toolsResponse = await client.listTools(); console.log('Available tools:', toolsResponse.tools); // Call slack_get_users const response = (await client.callTool( { name: 'slack_get_users', arguments: { limit: 100, }, }, CallToolResultSchema )) as CallToolResult; if ( Array.isArray(response.content) && response.content.length > 0 && response.content[0]?.type === 'text' ) { // Parse the response and display user information const slackResponse = JSON.parse(response.content[0].text); console.log('Slack users retrieved successfully!'); console.log('Total users:', slackResponse.members?.length || 0); // Display basic information for each user if (slackResponse.members && slackResponse.members.length > 0) { console.log('\nUser information:'); slackResponse.members.forEach( (user: { id: string; name: string; real_name?: string }) => { console.log( `- ${user.name} (${user.real_name || 'N/A'}) [ID: ${user.id}]` ); } ); // Display pagination information if available if (slackResponse.response_metadata?.next_cursor) { console.log( `\nMore users available. Next cursor: ${slackResponse.response_metadata.next_cursor}` ); } } } else { console.error('Unexpected response format'); } } catch (error) { console.error('Error:', error); process.exit(1); } finally { // Close the connection await transport.close(); } } main(); ``` -------------------------------------------------------------------------------- /src/schemas.ts: -------------------------------------------------------------------------------- ```typescript import { z } from 'zod'; // // Basic schemas // export const ChannelSchema = z .object({ conversation_host_id: z.string().optional(), created: z.number().optional(), id: z.string().optional(), is_archived: z.boolean().optional(), name: z.string().optional(), name_normalized: z.string().optional(), num_members: z.number().optional(), purpose: z .object({ creator: z.string().optional(), last_set: z.number().optional(), value: z.string().optional(), }) .optional(), shared_team_ids: z.array(z.string()).optional(), topic: z .object({ creator: z.string().optional(), last_set: z.number().optional(), value: z.string().optional(), }) .optional(), updated: z.number().optional(), }) .strip(); const ReactionSchema = z .object({ count: z.number().optional(), name: z.string().optional(), url: z.string().optional(), users: z.array(z.string()).optional(), }) .strip(); const ConversationsHistoryMessageSchema = z .object({ reactions: z.array(ReactionSchema).optional(), reply_count: z.number().optional(), reply_users: z.array(z.string()).optional(), reply_users_count: z.number().optional(), subtype: z.string().optional(), text: z.string().optional(), thread_ts: z.string().optional(), ts: z.string().optional(), type: z.string().optional(), user: z.string().nullable().optional(), }) .strip(); const MemberSchema = z .object({ id: z.string().optional(), name: z.string().optional(), real_name: z.string().optional(), }) .strip(); const ProfileSchema = z .object({ display_name: z.string().optional(), display_name_normalized: z.string().optional(), email: z.string().email().optional(), first_name: z.string().optional(), last_name: z.string().optional(), phone: z.string().optional(), real_name: z.string().optional(), real_name_normalized: z.string().optional(), title: z.string().optional(), }) .strip(); const SearchMessageSchema = z .object({ channel: z .object({ id: z.string().optional(), name: z.string().optional(), }) .optional(), permalink: z.string().url().optional(), text: z.string().optional(), ts: z.string().optional(), type: z.string().optional(), user: z.string().nullable().optional(), }) .strip(); // // Request schemas // export const AddReactionRequestSchema = z.object({ channel_id: z .string() .describe('The ID of the channel containing the message'), reaction: z.string().describe('The name of the emoji reaction (without ::)'), timestamp: z .string() .regex(/^\d{10}\.\d{6}$/, { message: "Timestamp must be in the format '1234567890.123456'", }) .describe( "The timestamp of the message to react to in the format '1234567890.123456'" ), }); export const GetChannelHistoryRequestSchema = z.object({ channel_id: z .string() .describe( '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.' ), cursor: z .string() .optional() .describe('Pagination cursor for next page of results'), limit: z .number() .int() .min(1) .max(1000) // Align with Slack API's default limit .optional() .default(100) // The reference repository uses 10, but aligning with list_channels etc., set to 100 .describe('Number of messages to retrieve (default 100)'), }); export const GetThreadRepliesRequestSchema = z.object({ channel_id: z .string() .describe('The ID of the channel containing the thread'), thread_ts: z .string() .regex(/^\d{10}\.\d{6}$/, { message: "Timestamp must be in the format '1234567890.123456'", }) .describe( "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." ), cursor: z .string() .optional() .describe('Pagination cursor for next page of results'), limit: z .number() .int() .min(1) .max(1000) .optional() .default(100) .describe('Number of replies to retrieve (default 100)'), }); export const GetUsersRequestSchema = z.object({ cursor: z .string() .optional() .describe('Pagination cursor for next page of results'), limit: z .number() .int() .min(1) .optional() .default(100) .describe('Maximum number of users to return (default 100)'), }); export const GetUserProfilesRequestSchema = z.object({ user_ids: z .array(z.string()) .min(1) .max(100) .describe('Array of user IDs to retrieve profiles for (max 100)'), }); export const ListChannelsRequestSchema = z.object({ cursor: z .string() .optional() .describe('Pagination cursor for next page of results'), limit: z .number() .int() .min(1) .max(1000) // Align with Slack API's default limit (conversations.list is actually cursor-based) .optional() .default(100) .describe('Maximum number of channels to return (default 100)'), }); export const PostMessageRequestSchema = z.object({ channel_id: z.string().describe('The ID of the channel to post to'), text: z.string().describe('The message text to post'), }); export const ReplyToThreadRequestSchema = z.object({ channel_id: z .string() .describe('The ID of the channel containing the thread'), text: z.string().describe('The reply text'), thread_ts: z .string() .regex(/^\d{10}\.\d{6}$/, { message: "Timestamp must be in the format '1234567890.123456'", }) .describe( "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." ), }); export const SearchChannelsRequestSchema = z.object({ query: z .string() .describe( 'Search channels by partial name match (case-insensitive). Searches across channel names.' ), limit: z .number() .int() .min(1) .max(100) .optional() .default(20) .describe('Maximum number of channels to return (default 20)'), include_archived: z .boolean() .optional() .default(false) .describe('Include archived channels in results (default false)'), }); export const SearchUsersRequestSchema = z.object({ query: z .string() .describe( 'Search users by name, display name, or real name (partial match, case-insensitive)' ), limit: z .number() .int() .min(1) .max(100) .optional() .default(20) .describe('Maximum number of users to return (default 20)'), include_bots: z .boolean() .optional() .default(false) .describe('Include bot users in results (default false)'), }); export const SearchMessagesRequestSchema = z.object({ query: z .string() .optional() .default('') .describe( '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.' ) .refine( (val) => { if (!val) return true; const modifierPattern = /\b(from|in|before|after|on|during|has|is|with):/i; return !modifierPattern.test(val); }, { message: 'Query field cannot contain modifiers (from:, in:, before:, etc.). Please use the dedicated fields for these filters.', } ), in_channel: z .string() .regex(/^C[A-Z0-9]+$/, { message: 'Must be a valid Slack channel ID (e.g., "C1234567")', }) .optional() .describe( 'Search within a specific channel. Must be a Slack channel ID (e.g., "C1234567"). Use slack_list_channels to find channel IDs first.' ), from_user: z .string() .regex(/^U[A-Z0-9]+$/, { message: 'Must be a valid Slack user ID (e.g., "U1234567")', }) .optional() .describe( '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.' ), // Date modifiers before: z .string() .regex(/^\d{4}-\d{2}-\d{2}$/, { message: 'Date must be in YYYY-MM-DD format', }) .optional() .describe('Search for messages before this date (YYYY-MM-DD)'), after: z .string() .regex(/^\d{4}-\d{2}-\d{2}$/, { message: 'Date must be in YYYY-MM-DD format', }) .optional() .describe('Search for messages after this date (YYYY-MM-DD)'), on: z .string() .regex(/^\d{4}-\d{2}-\d{2}$/, { message: 'Date must be in YYYY-MM-DD format', }) .optional() .describe('Search for messages on this specific date (YYYY-MM-DD)'), during: z .string() .optional() .describe( 'Search for messages during a specific time period (e.g., "July", "2023", "last week")' ), highlight: z .boolean() .optional() .default(false) .describe('Enable highlighting of search results'), sort: z .enum(['score', 'timestamp']) .optional() .default('score') .describe('Search result sort method (score or timestamp)'), sort_dir: z .enum(['asc', 'desc']) .optional() .default('desc') .describe('Sort direction (ascending or descending)'), count: z .number() .int() .min(1) .max(100) .optional() .default(20) .describe('Number of results per page (max 100)'), page: z .number() .int() .min(1) .max(100) .optional() .default(1) .describe('Page number of results (max 100)'), }); const SearchPaginationSchema = z.object({ first: z.number().optional(), last: z.number().optional(), page: z.number().optional(), page_count: z.number().optional(), per_page: z.number().optional(), total_count: z.number().optional(), }); // // Response schemas // const BaseResponseSchema = z .object({ error: z.string().optional(), ok: z.boolean().optional(), response_metadata: z .object({ next_cursor: z.string().optional(), }) .optional(), }) .strip(); export const ConversationsHistoryResponseSchema = BaseResponseSchema.extend({ messages: z.array(ConversationsHistoryMessageSchema).optional(), }); export const ConversationsRepliesResponseSchema = BaseResponseSchema.extend({ messages: z.array(ConversationsHistoryMessageSchema).optional(), }); export const GetUsersResponseSchema = BaseResponseSchema.extend({ members: z.array(MemberSchema).optional(), }); export const UserProfileResponseSchema = BaseResponseSchema.extend({ profile: ProfileSchema.optional(), }); export const GetUserProfilesResponseSchema = z.object({ profiles: z.array( z.object({ user_id: z.string(), profile: ProfileSchema.optional(), error: z.string().optional(), }) ), }); export const ListChannelsResponseSchema = BaseResponseSchema.extend({ channels: z.array(ChannelSchema).optional(), }); export const SearchMessagesResponseSchema = BaseResponseSchema.extend({ messages: z .object({ matches: z.array(SearchMessageSchema).optional(), pagination: SearchPaginationSchema.optional(), }) .optional(), }); ``` -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- ```typescript #!/usr/bin/env node import { ListToolsRequestSchema, CallToolRequestSchema, } from '@modelcontextprotocol/sdk/types.js'; import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { zodToJsonSchema } from 'zod-to-json-schema'; import { WebClient } from '@slack/web-api'; import dotenv from 'dotenv'; import express from 'express'; import { randomUUID } from 'node:crypto'; import { ListChannelsRequestSchema, PostMessageRequestSchema, ReplyToThreadRequestSchema, AddReactionRequestSchema, GetChannelHistoryRequestSchema, GetThreadRepliesRequestSchema, GetUsersRequestSchema, GetUserProfilesRequestSchema, ListChannelsResponseSchema, GetUsersResponseSchema, GetUserProfilesResponseSchema, UserProfileResponseSchema, SearchMessagesRequestSchema, SearchMessagesResponseSchema, SearchChannelsRequestSchema, SearchUsersRequestSchema, ConversationsHistoryResponseSchema, ConversationsRepliesResponseSchema, } from './schemas.js'; dotenv.config(); if (!process.env.SLACK_BOT_TOKEN) { console.error( 'SLACK_BOT_TOKEN is not set. Please set it in your environment or .env file.' ); process.exit(1); } if (!process.env.SLACK_USER_TOKEN) { console.error( 'SLACK_USER_TOKEN is not set. Please set it in your environment or .env file.' ); process.exit(1); } const slackClient = new WebClient(process.env.SLACK_BOT_TOKEN); const userClient = new WebClient(process.env.SLACK_USER_TOKEN); // Safe search mode to exclude private channels and DMs const safeSearchMode = process.env.SLACK_SAFE_SEARCH === 'true'; if (safeSearchMode) { console.error( 'Safe search mode enabled: Private channels and DMs will be excluded from search results' ); } // Parse command line arguments function parseArguments() { const args = process.argv.slice(2); let port: number | undefined; for (let i = 0; i < args.length; i++) { if (args[i] === '-port' && i + 1 < args.length) { const portValue = parseInt(args[i + 1], 10); if (isNaN(portValue) || portValue <= 0 || portValue > 65535) { console.error(`Invalid port number: ${args[i + 1]}`); process.exit(1); } port = portValue; i++; // Skip the next argument since it's the port value } else if (args[i] === '--help' || args[i] === '-h') { console.log(` Usage: slack-mcp-server [options] Options: -port <number> Start the server with Streamable HTTP transport on the specified port -h, --help Show this help message Examples: slack-mcp-server # Start with stdio transport (default) slack-mcp-server -port 3000 # Start with Streamable HTTP transport on port 3000 `); process.exit(0); } else if (args[i].startsWith('-')) { console.error(`Unknown option: ${args[i]}`); console.error('Use --help for usage information'); process.exit(1); } } return { port }; } function createServer(): Server { const server = new Server( { name: 'slack-mcp-server', version: '0.0.1', }, { capabilities: { tools: {}, }, } ); server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [ { name: 'slack_list_channels', description: 'List public channels in the workspace with pagination', inputSchema: zodToJsonSchema(ListChannelsRequestSchema), }, { name: 'slack_post_message', description: 'Post a new message to a Slack channel', inputSchema: zodToJsonSchema(PostMessageRequestSchema), }, { name: 'slack_reply_to_thread', description: 'Reply to a specific message thread in Slack', inputSchema: zodToJsonSchema(ReplyToThreadRequestSchema), }, { name: 'slack_add_reaction', description: 'Add a reaction emoji to a message', inputSchema: zodToJsonSchema(AddReactionRequestSchema), }, { name: 'slack_get_channel_history', description: '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.', inputSchema: zodToJsonSchema(GetChannelHistoryRequestSchema), }, { name: 'slack_get_thread_replies', description: 'Get all replies in a message thread', inputSchema: zodToJsonSchema(GetThreadRepliesRequestSchema), }, { name: 'slack_get_users', description: 'Retrieve basic profile information of all users in the workspace', inputSchema: zodToJsonSchema(GetUsersRequestSchema), }, { name: 'slack_get_user_profiles', description: 'Get multiple users profile information in bulk', inputSchema: zodToJsonSchema(GetUserProfilesRequestSchema), }, { name: 'slack_search_messages', description: '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.', inputSchema: zodToJsonSchema(SearchMessagesRequestSchema), }, { name: 'slack_search_channels', description: '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.', inputSchema: zodToJsonSchema(SearchChannelsRequestSchema), }, { name: 'slack_search_users', description: '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.', inputSchema: zodToJsonSchema(SearchUsersRequestSchema), }, ], }; }); server.setRequestHandler(CallToolRequestSchema, async (request) => { try { if (!request.params) { throw new Error('Params are required'); } switch (request.params.name) { case 'slack_list_channels': { const args = ListChannelsRequestSchema.parse( request.params.arguments ); const response = await slackClient.conversations.list({ limit: args.limit, cursor: args.cursor, types: 'public_channel', // Only public channels }); if (!response.ok) { throw new Error(`Failed to list channels: ${response.error}`); } const parsed = ListChannelsResponseSchema.parse(response); return { content: [{ type: 'text', text: JSON.stringify(parsed) }], }; } case 'slack_post_message': { const args = PostMessageRequestSchema.parse(request.params.arguments); const response = await slackClient.chat.postMessage({ channel: args.channel_id, text: args.text, }); if (!response.ok) { throw new Error(`Failed to post message: ${response.error}`); } return { content: [{ type: 'text', text: 'Message posted successfully' }], }; } case 'slack_reply_to_thread': { const args = ReplyToThreadRequestSchema.parse( request.params.arguments ); const response = await slackClient.chat.postMessage({ channel: args.channel_id, thread_ts: args.thread_ts, text: args.text, }); if (!response.ok) { throw new Error(`Failed to reply to thread: ${response.error}`); } return { content: [ { type: 'text', text: 'Reply sent to thread successfully' }, ], }; } case 'slack_add_reaction': { const args = AddReactionRequestSchema.parse(request.params.arguments); const response = await slackClient.reactions.add({ channel: args.channel_id, timestamp: args.timestamp, name: args.reaction, }); if (!response.ok) { throw new Error(`Failed to add reaction: ${response.error}`); } return { content: [{ type: 'text', text: 'Reaction added successfully' }], }; } case 'slack_get_channel_history': { const args = GetChannelHistoryRequestSchema.parse( request.params.arguments ); const response = await slackClient.conversations.history({ channel: args.channel_id, limit: args.limit, cursor: args.cursor, }); if (!response.ok) { throw new Error(`Failed to get channel history: ${response.error}`); } const parsedResponse = ConversationsHistoryResponseSchema.parse(response); return { content: [{ type: 'text', text: JSON.stringify(parsedResponse) }], }; } case 'slack_get_thread_replies': { const args = GetThreadRepliesRequestSchema.parse( request.params.arguments ); const response = await slackClient.conversations.replies({ channel: args.channel_id, ts: args.thread_ts, limit: args.limit, cursor: args.cursor, }); if (!response.ok) { throw new Error(`Failed to get thread replies: ${response.error}`); } const parsedResponse = ConversationsRepliesResponseSchema.parse(response); return { content: [{ type: 'text', text: JSON.stringify(parsedResponse) }], }; } case 'slack_get_users': { const args = GetUsersRequestSchema.parse(request.params.arguments); const response = await slackClient.users.list({ limit: args.limit, cursor: args.cursor, }); if (!response.ok) { throw new Error(`Failed to get users: ${response.error}`); } const parsed = GetUsersResponseSchema.parse(response); return { content: [{ type: 'text', text: JSON.stringify(parsed) }], }; } case 'slack_get_user_profiles': { const args = GetUserProfilesRequestSchema.parse( request.params.arguments ); // Use Promise.all for concurrent API calls const profilePromises = args.user_ids.map(async (userId) => { try { const response = await slackClient.users.profile.get({ user: userId, }); if (!response.ok) { return { user_id: userId, error: response.error || 'Unknown error', }; } const parsed = UserProfileResponseSchema.parse(response); return { user_id: userId, profile: parsed.profile, }; } catch (error) { return { user_id: userId, error: error instanceof Error ? error.message : 'Unknown error', }; } }); const results = await Promise.all(profilePromises); const responseData = GetUserProfilesResponseSchema.parse({ profiles: results, }); return { content: [{ type: 'text', text: JSON.stringify(responseData) }], }; } case 'slack_search_messages': { const parsedParams = SearchMessagesRequestSchema.parse( request.params.arguments ); let query = parsedParams.query || ''; if (parsedParams.in_channel) { // Resolve channel name from ID const channelInfo = await slackClient.conversations.info({ channel: parsedParams.in_channel, }); if (!channelInfo.ok || !channelInfo.channel?.name) { throw new Error( `Failed to get channel info: ${channelInfo.error}` ); } query += ` in:${channelInfo.channel.name}`; } // Handle from_user - always use user ID format if (parsedParams.from_user) { query += ` from:<@${parsedParams.from_user}>`; } // Date modifiers if (parsedParams.before) { query += ` before:${parsedParams.before}`; } if (parsedParams.after) { query += ` after:${parsedParams.after}`; } if (parsedParams.on) { query += ` on:${parsedParams.on}`; } if (parsedParams.during) { query += ` during:${parsedParams.during}`; } // Trim and log the final query for debugging query = query.trim(); console.log('Search query:', query); const response = await userClient.search.messages({ query: query, highlight: parsedParams.highlight, sort: parsedParams.sort, sort_dir: parsedParams.sort_dir, count: parsedParams.count, page: parsedParams.page, }); if (!response.ok) { throw new Error(`Failed to search messages: ${response.error}`); } // Apply safe search filtering if enabled (before parsing) if (safeSearchMode && response.messages?.matches) { const originalCount = response.messages.matches.length; response.messages.matches = response.messages.matches.filter( (msg: { channel?: { is_private?: boolean; is_im?: boolean; is_mpim?: boolean; }; }) => { // Exclude private channels, DMs, and multi-party DMs const channel = msg.channel; if (!channel) return true; // Keep if no channel info return !( channel.is_private || channel.is_im || channel.is_mpim ); } ); const filteredCount = originalCount - response.messages.matches.length; if (filteredCount > 0) { console.error( `Safe search: Filtered out ${filteredCount} messages from private channels/DMs` ); } } const parsed = SearchMessagesResponseSchema.parse(response); return { content: [{ type: 'text', text: JSON.stringify(parsed) }], }; } case 'slack_search_channels': { const args = SearchChannelsRequestSchema.parse( request.params.arguments ); // Fetch all channels with a reasonable limit const allChannels: Array<{ id?: string; name?: string; is_archived?: boolean; [key: string]: unknown; }> = []; let cursor: string | undefined; const maxPages = 5; // Limit to prevent infinite loops let pageCount = 0; // Fetch multiple pages if needed while (pageCount < maxPages) { const response = await slackClient.conversations.list({ types: 'public_channel', exclude_archived: !args.include_archived, limit: 1000, // Max allowed by Slack API cursor, }); if (!response.ok) { throw new Error(`Failed to search channels: ${response.error}`); } if (response.channels) { allChannels.push(...(response.channels as typeof allChannels)); } cursor = response.response_metadata?.next_cursor; pageCount++; // Stop if no more pages if (!cursor) break; } // Filter channels by name (case-insensitive partial match) const searchTerm = args.query.toLowerCase(); const filteredChannels = allChannels.filter((channel) => channel.name?.toLowerCase().includes(searchTerm) ); // Limit results const limitedChannels = filteredChannels.slice(0, args.limit); const response = { ok: true, channels: limitedChannels, }; const parsed = ListChannelsResponseSchema.parse(response); return { content: [{ type: 'text', text: JSON.stringify(parsed) }], }; } case 'slack_search_users': { const args = SearchUsersRequestSchema.parse(request.params.arguments); // Fetch all users with a reasonable limit const allUsers: Array<{ id?: string; name?: string; real_name?: string; is_bot?: boolean; profile?: { display_name?: string; display_name_normalized?: string; [key: string]: unknown; }; [key: string]: unknown; }> = []; let cursor: string | undefined; const maxPages = 5; // Limit to prevent infinite loops let pageCount = 0; // Fetch multiple pages if needed while (pageCount < maxPages) { const response = await slackClient.users.list({ limit: 1000, // Max allowed by Slack API cursor, }); if (!response.ok) { throw new Error(`Failed to search users: ${response.error}`); } if (response.members) { allUsers.push(...(response.members as typeof allUsers)); } cursor = response.response_metadata?.next_cursor; pageCount++; // Stop if no more pages if (!cursor) break; } // Filter users (case-insensitive partial match across multiple fields) const searchTerm = args.query.toLowerCase(); const filteredUsers = allUsers.filter((user) => { // Skip bots if requested if (!args.include_bots && user.is_bot) { return false; } // Search across multiple name fields const name = user.name?.toLowerCase() || ''; const realName = user.real_name?.toLowerCase() || ''; const displayName = user.profile?.display_name?.toLowerCase() || ''; const displayNameNormalized = user.profile?.display_name_normalized?.toLowerCase() || ''; return ( name.includes(searchTerm) || realName.includes(searchTerm) || displayName.includes(searchTerm) || displayNameNormalized.includes(searchTerm) ); }); // Limit results const limitedUsers = filteredUsers.slice(0, args.limit); const response = { ok: true, members: limitedUsers, }; const parsed = GetUsersResponseSchema.parse(response); return { content: [{ type: 'text', text: JSON.stringify(parsed) }], }; } default: throw new Error(`Unknown tool: ${request.params.name}`); } } catch (error) { console.error('Error handling request:', error); const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; throw new Error(errorMessage); } }); return server; } async function runStdioServer() { const server = createServer(); const transport = new StdioServerTransport(); await server.connect(transport); console.error('Slack MCP Server running on stdio'); } async function runHttpServer(port: number) { const app = express(); app.use(express.json()); // Map to store transports by session ID const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}; // Handle POST requests for client-to-server communication app.post('/mcp', async (req, res) => { try { // Check for existing session ID const sessionId = req.headers['mcp-session-id'] as string | undefined; let transport: StreamableHTTPServerTransport; if (sessionId && transports[sessionId]) { // Reuse existing transport transport = transports[sessionId]; } else { // Create new transport transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), onsessioninitialized: (newSessionId) => { // Store the transport by session ID transports[newSessionId] = transport; console.error(`New MCP session initialized: ${newSessionId}`); }, }); // Clean up transport when closed transport.onclose = () => { if (transport.sessionId) { delete transports[transport.sessionId]; console.error(`MCP session closed: ${transport.sessionId}`); } }; const server = createServer(); await server.connect(transport); } // Handle the request await transport.handleRequest(req, res, req.body); } catch (error) { console.error('Error handling MCP request:', error); if (!res.headersSent) { res.status(500).json({ jsonrpc: '2.0', error: { code: -32603, message: 'Internal server error', }, id: null, }); } } }); // Handle GET requests for server-to-client notifications via SSE app.get('/mcp', async (req, res) => { const sessionId = req.headers['mcp-session-id'] as string | undefined; if (!sessionId || !transports[sessionId]) { res.status(400).send('Invalid or missing session ID'); return; } const transport = transports[sessionId]; await transport.handleRequest(req, res); }); // Handle DELETE requests for session termination app.delete('/mcp', async (req, res) => { const sessionId = req.headers['mcp-session-id'] as string | undefined; if (!sessionId || !transports[sessionId]) { res.status(400).send('Invalid or missing session ID'); return; } const transport = transports[sessionId]; await transport.handleRequest(req, res); }); // Health check endpoint app.get('/health', (req, res) => { res.json({ status: 'ok', timestamp: new Date().toISOString() }); }); app.listen(port, () => { console.error( `Slack MCP Server running on Streamable HTTP at http://localhost:${port}/mcp` ); console.error(`Health check available at http://localhost:${port}/health`); }); } async function main() { const { port } = parseArguments(); if (port !== undefined) { // Run with Streamable HTTP transport await runHttpServer(port); } else { // Run with stdio transport (default) await runStdioServer(); } } main().catch((error) => { console.error('Fatal error in main():', error); process.exit(1); }); ```