├── requirements.txt ├── .dockerignore ├── env.example ├── docker-compose.yml ├── LICENSE ├── Dockerfile ├── .gitignore ├── README.md └── app.py /requirements.txt: -------------------------------------------------------------------------------- 1 | fastmcp>=2.0.0 2 | requests>=2.31.0 3 | python-dotenv>=1.0.0 4 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Git 2 | .git 3 | .gitignore 4 | 5 | # Python 6 | __pycache__ 7 | *.pyc 8 | *.pyo 9 | *.pyd 10 | .Python 11 | venv/ 12 | .venv/ 13 | *.egg-info/ 14 | 15 | # IDE 16 | .vscode/ 17 | .idea/ 18 | *.swp 19 | *.swo 20 | 21 | # Environment files (secrets) 22 | .env 23 | .env.* 24 | !.env.example 25 | 26 | # Docker 27 | Dockerfile* 28 | docker-compose* 29 | .dockerignore 30 | 31 | # Documentation 32 | README.md 33 | LICENSE 34 | *.md 35 | 36 | # Testing 37 | tests/ 38 | pytest.ini 39 | .pytest_cache/ 40 | 41 | -------------------------------------------------------------------------------- /env.example: -------------------------------------------------------------------------------- 1 | # Cal.com MCP Server Configuration 2 | # Copy this file to .env and fill in your values 3 | 4 | # Required: Your Cal.com API Key (v2) 5 | # Get this from your Cal.com instance: Settings > Developer > API Keys 6 | CALCOM_API_KEY=cal_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx 7 | 8 | # Your self-hosted Cal.com API base URL 9 | # For self-hosted instances, this is typically your API domain + /v2 10 | # Example: https://cal-api.kibibit.io/v2 11 | CALCOM_API_BASE_URL=https://cal-api.kibibit.io/v2 12 | 13 | # Port for the MCP server (default: 8010) 14 | MCP_PORT=8010 15 | 16 | # Optional: Bearer token for MCP server authentication 17 | # If set, clients must provide "Authorization: Bearer " header 18 | # Generate a secure random token, e.g.: openssl rand -hex 32 19 | MCP_AUTH_TOKEN=your_secret_token_here 20 | 21 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | services: 4 | calcom-mcp: 5 | build: . 6 | image: calcom-mcp:latest 7 | container_name: calcom-mcp 8 | restart: unless-stopped 9 | ports: 10 | - "8010:8010" 11 | environment: 12 | # Required: Your Cal.com API key 13 | - CALCOM_API_KEY=${CALCOM_API_KEY} 14 | # Your self-hosted Cal.com API URL (without trailing slash) 15 | - CALCOM_API_BASE_URL=${CALCOM_API_BASE_URL:-https://cal-api.kibibit.io/v2} 16 | # Port for the MCP server (change if needed) 17 | - MCP_PORT=8010 18 | # Optional: Use .env file for secrets 19 | env_file: 20 | - .env 21 | # Resource limits (adjust based on your Unraid setup) 22 | deploy: 23 | resources: 24 | limits: 25 | memory: 256M 26 | reservations: 27 | memory: 128M 28 | 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Arley Daniel Peter 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.12-slim 2 | 3 | LABEL maintainer="Cal.com MCP Server" 4 | LABEL description="FastMCP server for Cal.com API integration" 5 | 6 | # Set working directory 7 | WORKDIR /app 8 | 9 | # Install dependencies first for better caching 10 | COPY requirements.txt . 11 | RUN pip install --no-cache-dir -r requirements.txt 12 | 13 | # Copy application code 14 | COPY app.py . 15 | 16 | # Create non-root user for security 17 | RUN useradd --create-home --shell /bin/bash appuser && \ 18 | chown -R appuser:appuser /app 19 | USER appuser 20 | 21 | # Expose the default port 22 | EXPOSE 8010 23 | 24 | # Environment variables with defaults (API key passed at runtime only) 25 | ENV CALCOM_API_BASE_URL="https://api.cal.com/v2" 26 | ENV MCP_PORT=8010 27 | 28 | # Health check - FastMCP exposes /sse endpoint 29 | HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \ 30 | CMD python -c "import requests; r = requests.get('http://localhost:${MCP_PORT}/sse', timeout=5, stream=True); r.close()" || exit 1 31 | 32 | # Run the FastMCP server with SSE transport 33 | CMD ["sh", "-c", "fastmcp run app.py --transport sse --port ${MCP_PORT} --host 0.0.0.0"] 34 | 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # UV 98 | # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | #uv.lock 102 | 103 | # poetry 104 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 105 | # This is especially recommended for binary packages to ensure reproducibility, and is more 106 | # commonly ignored for libraries. 107 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 108 | #poetry.lock 109 | 110 | # pdm 111 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 112 | #pdm.lock 113 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 114 | # in version control. 115 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 116 | .pdm.toml 117 | .pdm-python 118 | .pdm-build/ 119 | 120 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 121 | __pypackages__/ 122 | 123 | # Celery stuff 124 | celerybeat-schedule 125 | celerybeat.pid 126 | 127 | # SageMath parsed files 128 | *.sage.py 129 | 130 | # Environments 131 | .env 132 | .venv 133 | env/ 134 | venv/ 135 | ENV/ 136 | env.bak/ 137 | venv.bak/ 138 | 139 | # Spyder project settings 140 | .spyderproject 141 | .spyproject 142 | 143 | # Rope project settings 144 | .ropeproject 145 | 146 | # mkdocs documentation 147 | /site 148 | 149 | # mypy 150 | .mypy_cache/ 151 | .dmypy.json 152 | dmypy.json 153 | 154 | # Pyre type checker 155 | .pyre/ 156 | 157 | # pytype static type analyzer 158 | .pytype/ 159 | 160 | # Cython debug symbols 161 | cython_debug/ 162 | 163 | # PyCharm 164 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 165 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 166 | # and can be added to the global gitignore or merged into this file. For a more nuclear 167 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 168 | #.idea/ 169 | 170 | # Ruff stuff: 171 | .ruff_cache/ 172 | 173 | # PyPI configuration file 174 | .pypirc 175 | 176 | # Cursor IDE 177 | .cursorindexingignore 178 | .cursor/ 179 | .specstory/ 180 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cal.com FastMCP Server 2 | 3 | > ⚠️ **Disclaimer**: This project is not affiliated with or endorsed by Cal.com. I am an independent developer and have no association with Cal.com in any official capacity. 4 | 5 | This project provides a FastMCP server for interacting with the Cal.com API. It allows Language Learning Models (LLMs) to use tools to connect with important Cal.com functionalities like managing event types and bookings. 6 | 7 | ## Prerequisites 8 | 9 | - Python 3.8+ 10 | - A Cal.com account and API Key (v2) 11 | 12 | --- 13 | 14 | ## 🐳 Docker Deployment (Recommended for Unraid) 15 | 16 | ### Quick Start with Docker Compose 17 | 18 | 1. **Clone the repository:** 19 | ```bash 20 | git clone https://github.com/Danielpeter-99/calcom-mcp.git 21 | cd calcom-mcp 22 | ``` 23 | 24 | 2. **Create your `.env` file:** 25 | ```bash 26 | cp env.example .env 27 | ``` 28 | 29 | 3. **Edit `.env` with your configuration:** 30 | ```bash 31 | # Required: Your Cal.com API Key 32 | CALCOM_API_KEY=cal_live_your_api_key_here 33 | 34 | # For self-hosted Cal.com instances: 35 | CALCOM_API_BASE_URL=https://cal-api.kibibit.io/v2 36 | 37 | # Port for MCP server 38 | MCP_PORT=8010 39 | ``` 40 | 41 | 4. **Build and run:** 42 | ```bash 43 | docker-compose up -d --build 44 | ``` 45 | 46 | 5. **Verify it's running:** 47 | ```bash 48 | docker logs calcom-mcp 49 | ``` 50 | 51 | ### Unraid Community Applications Setup 52 | 53 | If you prefer to set up the container manually in Unraid: 54 | 55 | 1. **Go to Docker tab** → **Add Container** 56 | 57 | 2. **Configure the container:** 58 | | Field | Value | 59 | |-------|-------| 60 | | Name | `calcom-mcp` | 61 | | Repository | Build from source or use pre-built image | 62 | | Network Type | `bridge` | 63 | | Port Mapping | Host: `8010` → Container: `8010` | 64 | 65 | 3. **Add Environment Variables:** 66 | - `CALCOM_API_KEY` = `your_api_key_here` (required) 67 | - `CALCOM_API_BASE_URL` = `https://cal-api.kibibit.io/v2` 68 | - `MCP_PORT` = `8010` 69 | 70 | 4. **Apply and start the container** 71 | 72 | ### Building the Docker Image Manually 73 | 74 | ```bash 75 | # Build the image 76 | docker build -t calcom-mcp:latest . 77 | 78 | # Run the container 79 | docker run -d \ 80 | --name calcom-mcp \ 81 | --restart unless-stopped \ 82 | -p 8010:8010 \ 83 | -e CALCOM_API_KEY="your_api_key_here" \ 84 | -e CALCOM_API_BASE_URL="https://cal-api.kibibit.io/v2" \ 85 | calcom-mcp:latest 86 | ``` 87 | 88 | ### Self-Hosted Cal.com Configuration 89 | 90 | For self-hosted Cal.com instances like yours at `cal.kibibit.io`: 91 | 92 | | Variable | Description | Example | 93 | |----------|-------------|---------| 94 | | `CALCOM_API_BASE_URL` | Your Cal.com API endpoint (with `/v2`) | `https://cal-api.kibibit.io/v2` | 95 | | `CALCOM_API_KEY` | API key from your Cal.com instance | Get from Settings → Developer → API Keys | 96 | 97 | **Note:** The API base URL should point to your API subdomain (`cal-api.kibibit.io`), not your main Cal.com UI (`cal.kibibit.io`). 98 | 99 | --- 100 | 101 | ## Local Development Setup 102 | 103 | 1. **Clone the repository (if applicable) or download the files.** 104 | ```bash 105 | git clone https://github.com/Danielpeter-99/calcom-mcp.git 106 | cd calcom-mcp 107 | ``` 108 | 109 | 2. **Create a virtual environment (recommended):** 110 | ```bash 111 | python -m venv venv 112 | source venv/bin/activate # On Windows: venv\Scripts\activate 113 | ``` 114 | 115 | 3. **Install dependencies:** 116 | ```bash 117 | pip install -r requirements.txt 118 | ``` 119 | 120 | 4. **Set up the Cal.com API Key:** 121 | You need to set the `CALCOM_API_KEY` environment variable. You can get your API key from your Cal.com settings page (usually under Developer or Security settings). 122 | 123 | - **Linux/macOS:** 124 | ```bash 125 | export CALCOM_API_KEY="your_actual_api_key_here" 126 | export CALCOM_API_BASE_URL="https://cal-api.kibibit.io/v2" # For self-hosted 127 | ``` 128 | To make it permanent, add this line to your shell configuration file (e.g., `.bashrc`, `.zshrc`). 129 | 130 | - **Windows (PowerShell):** 131 | ```powershell 132 | $env:CALCOM_API_KEY="your_actual_api_key_here" 133 | $env:CALCOM_API_BASE_URL="https://cal-api.kibibit.io/v2" # For self-hosted 134 | ``` 135 | To make it permanent, you can set it through the System Properties > Environment Variables. 136 | 137 | ## Running the Server 138 | 139 | Once the setup is complete, you can run the FastMCP server: 140 | 141 | ```bash 142 | fastmcp run app.py --transport sse --port 8010 143 | ``` 144 | 145 | The server will start at localhost:8010, and you should see output indicating it's running. If the `CALCOM_API_KEY` is not set, a warning will be displayed. 146 | 147 | --- 148 | 149 | ## Connecting to MCP Clients 150 | 151 | ### Claude Desktop / Cursor / Other MCP Clients 152 | 153 | Add this to your MCP client configuration: 154 | 155 | ```json 156 | { 157 | "mcpServers": { 158 | "calcom": { 159 | "url": "http://YOUR_UNRAID_IP:8010/sse" 160 | } 161 | } 162 | } 163 | ``` 164 | 165 | Replace `YOUR_UNRAID_IP` with your Unraid server's IP address (e.g., `192.168.1.100`). 166 | 167 | --- 168 | 169 | ## Available Tools 170 | 171 | The server currently provides the following tools for LLM interaction: 172 | 173 | - `get_api_status()`: Check if the Cal.com API key is configured in the environment. Returns a string indicating the status. 174 | - `list_event_types()`: Fetch a list of all event types from Cal.com for the authenticated account. Returns a dictionary with the list of event types or an error message. 175 | - `get_bookings(...)`: Fetch a list of bookings from Cal.com, with optional filters (event_type_id, user_id, status, date_from, date_to, limit). Returns a dictionary with the list of bookings or an error message. 176 | - `create_booking(...)`: Create a new booking in Cal.com for a specific event type and attendee. Requires parameters like start_time, attendee details, and event type identifiers. Returns a dictionary with booking details or an error message. 177 | - `list_schedules(...)`: List all schedules available to the authenticated user or for a specific user/team. Optional filters: user_id, team_id, limit. Returns a dictionary with the list of schedules or an error message. 178 | - `list_teams(...)`: List all teams available to the authenticated user. Optional filter: limit. Returns a dictionary with the list of teams or an error message. 179 | - `list_users(...)`: List all users available to the authenticated account. Optional filter: limit. Returns a dictionary with the list of users or an error message. 180 | - `list_webhooks(...)`: List all webhooks configured for the authenticated account. Optional filter: limit. Returns a dictionary with the list of webhooks or an error message. 181 | 182 | **Note:** All tools require the `CALCOM_API_KEY` environment variable to be set. If it is not set, tools will return a structured error message. 183 | 184 | ## Tool Usage and Error Handling 185 | 186 | - All tools return either the API response (as a dictionary or string) or a structured error message with details about the failure. 187 | - Error messages include the type of error, HTTP status code (if applicable), and the response text from the Cal.com API. 188 | - For best results, always check for the presence of an `error` key in the response before using the returned data. 189 | - Tools are designed to be robust and provide informative feedback for both successful and failed API calls. 190 | 191 | ## Development Notes 192 | 193 | - The Cal.com API base URL is configurable via the `CALCOM_API_BASE_URL` environment variable (defaults to `https://api.cal.com/v2`). 194 | - Authentication is primarily handled using a Bearer token with the `CALCOM_API_KEY`. 195 | - The `create_booking` tool uses the `cal-api-version: 2024-08-13` header as specified in the Cal.com API v2 documentation for that endpoint. 196 | - Error handling is included in the API calls to provide informative responses. 197 | 198 | ## 🚀 Built With 199 | 200 | [![Python](https://img.shields.io/badge/Python-3.8+-blue?logo=python&logoColor=white)](https://www.python.org/) 201 | [![FastMCP](https://img.shields.io/badge/FastMCP-Framework-8A2BE2?logo=fastapi&logoColor=white)](https://github.com/jlowin/fastmcp) 202 | [![Cal.com API](https://img.shields.io/badge/Cal.com%20API-v2-00B8A9?logo=google-calendar&logoColor=white)](https://cal.com/docs/api-reference/v2/introduction) 203 | [![Docker](https://img.shields.io/badge/Docker-Ready-2496ED?logo=docker&logoColor=white)](https://www.docker.com/) 204 | 205 | 206 | ## Important Security Note 207 | 208 | **Never hardcode your `CALCOM_API_KEY` directly into the source code.** Always use environment variables as described in the setup instructions to keep your API key secure. 209 | -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | """ 2 | Cal.com MCP Server 3 | 4 | A FastMCP server for interacting with the Cal.com API. This enables LLMs to manage event types, 5 | create bookings, and access Cal.com scheduling data programmatically. 6 | 7 | Author: Arley Peter 8 | License: MIT 9 | Disclaimer: This project is not affiliated with or endorsed by Cal.com in any way. 10 | 11 | Note: For authentication, configure bearer token validation in your reverse proxy (nginx). 12 | """ 13 | 14 | import os 15 | import requests 16 | from fastmcp import FastMCP 17 | from dotenv import load_dotenv 18 | 19 | # Load environment variables from .env file 20 | load_dotenv() 21 | 22 | # Get Cal.com API configuration from environment variables 23 | CALCOM_API_KEY = os.getenv("CALCOM_API_KEY") 24 | CALCOM_API_BASE_URL = os.getenv("CALCOM_API_BASE_URL", "https://api.cal.com/v2").rstrip("/") 25 | 26 | # Initialize the FastMCP server 27 | mcp = FastMCP("Cal.com MCP Server") 28 | 29 | print(f"Cal.com API Key: {'***' + CALCOM_API_KEY[-4:] if CALCOM_API_KEY else 'NOT SET'}") 30 | print(f"Cal.com API Base URL: {CALCOM_API_BASE_URL}") 31 | 32 | @mcp.tool() 33 | def get_api_status() -> str: 34 | """Check if the Cal.com API key is configured in the environment. 35 | 36 | Returns: 37 | A string indicating whether the Cal.com API key is configured or not. 38 | """ 39 | if CALCOM_API_KEY: 40 | return "Cal.com API key is configured." 41 | else: 42 | return "Cal.com API key is NOT configured. Please set the CALCOM_API_KEY environment variable." 43 | 44 | @mcp.tool() 45 | def list_event_types() -> list[dict] | dict: 46 | """Fetch a simplified list of active (non-hidden) event types from Cal.com. 47 | This is preferred for LLMs to easily present options or make booking decisions. 48 | 49 | Returns: 50 | A list of dictionaries, each with 'id', 'title', 'slug', 'length_minutes', 51 | 'owner_profile_slug' (user or team slug), and 'location_summary'. 52 | Returns an error dictionary if the API call fails or no event types are found. 53 | """ 54 | if not CALCOM_API_KEY: 55 | return {"error": "Cal.com API key not configured. Please set the CALCOM_API_KEY environment variable."} 56 | 57 | headers = { 58 | "Authorization": f"Bearer {CALCOM_API_KEY}", 59 | "Content-Type": "application/json" 60 | } 61 | 62 | raw_response_data = {} 63 | try: 64 | response = requests.get(f"{CALCOM_API_BASE_URL}/event-types", headers=headers) 65 | response.raise_for_status() 66 | raw_response_data = response.json() 67 | except requests.exceptions.HTTPError as http_err: 68 | return {"error": f"HTTP error occurred: {http_err}", "status_code": response.status_code, "response_text": response.text} 69 | except requests.exceptions.RequestException as req_err: 70 | return {"error": f"Request exception occurred: {req_err}"} 71 | except Exception as e: 72 | return {"error": f"An unexpected error occurred during API call or data processing: {e}"} 73 | 74 | options = [] 75 | event_type_groups = raw_response_data.get("data", {}).get("eventTypeGroups", []) 76 | 77 | if not event_type_groups and raw_response_data.get("data", {}).get("eventTypes"): 78 | event_types_direct = raw_response_data.get("data", {}).get("eventTypes", []) 79 | for et in event_types_direct: 80 | if not et.get("hidden"): 81 | owner_slug_info = f"user_id_{et.get('userId')}" 82 | if et.get("teamId"): 83 | owner_slug_info = f"team_id_{et.get('teamId')}" 84 | 85 | location_types = [ 86 | loc.get("type", "unknown") 87 | .replace("integrations:google:meet", "Google Meet") 88 | .replace("integrations:zoom:zoom_video", "Zoom") # Common Zoom integration key 89 | .replace("integrations:microsoft:teams", "Microsoft Teams") # Common Teams key 90 | .replace("inPerson", "In-person") 91 | for loc in et.get("locations", []) 92 | ] 93 | location_summary = ", ".join(location_types) or "Provider configured" 94 | # Check for Cal Video (often 'dailyCo', 'calvideo', or similar) 95 | if any("daily" in loc_type.lower() or "calvideo" in loc_type.lower() for loc_type in location_types): 96 | location_summary = "Cal Video" 97 | 98 | options.append({ 99 | "id": et.get("id"), 100 | "title": et.get("title"), 101 | "slug": et.get("slug"), 102 | "length_minutes": et.get("length"), 103 | "owner_info": owner_slug_info, 104 | "location_summary": location_summary, 105 | "requires_confirmation": et.get("requiresConfirmation", False), 106 | "description_preview": (et.get("description") or "")[:100] + "..." if et.get("description") else "No description." 107 | }) 108 | 109 | else: 110 | for group in event_type_groups: 111 | owner_profile_slug = group.get("profile", {}).get("slug", f"group_owner_id_{group.get('id')}") # Fallback if slug missing 112 | for et in group.get("eventTypes", []): 113 | if not et.get("hidden"): # Only include non-hidden event types 114 | location_types = [ 115 | loc.get("type", "unknown") 116 | .replace("integrations:google:meet", "Google Meet") 117 | .replace("integrations:zoom:zoom_video", "Zoom") 118 | .replace("integrations:microsoft:teams", "Microsoft Teams") 119 | .replace("inPerson", "In-person") 120 | for loc in et.get("locations", []) 121 | ] 122 | location_summary = ", ".join(location_types) or "Provider configured" 123 | if any("daily" in loc_type.lower() or "calvideo" in loc_type.lower() for loc_type in location_types): 124 | location_summary = "Cal Video" 125 | 126 | options.append({ 127 | "id": et.get("id"), 128 | "title": et.get("title"), 129 | "slug": et.get("slug"), 130 | "length_minutes": et.get("length"), 131 | "owner_profile_slug": owner_profile_slug, 132 | "location_summary": location_summary, 133 | "requires_confirmation": et.get("requiresConfirmation", False), 134 | # Add a snippet of the description if available 135 | "description_preview": (et.get("description") or "")[:100] + "..." if et.get("description") else "No description." 136 | }) 137 | 138 | if not options: 139 | # Check if there was an issue with the raw response structure itself if it wasn't an HTTP/Request error 140 | if not raw_response_data or "data" not in raw_response_data: 141 | return {"error": "Failed to parse event types from Cal.com API response.", "raw_response_preview": str(raw_response_data)[:200]} 142 | return {"message": "No active (non-hidden) event types found for the configured API key."} 143 | 144 | return options 145 | 146 | @mcp.tool() 147 | def get_bookings(event_type_id: int = None, user_id: int = None, status: str = None, date_from: str = None, date_to: str = None, limit: int = 20) -> dict: 148 | """Fetch a list of bookings from Cal.com, with optional filters. 149 | 150 | Args: 151 | event_type_id: Optional. Filter bookings by a specific event type ID. 152 | user_id: Optional. Filter bookings by a specific user ID (typically the user associated with the API key or a managed user). 153 | status: Optional. Filter bookings by status (e.g., 'ACCEPTED', 'PENDING', 'CANCELLED', 'REJECTED'). 154 | date_from: Optional. Filter bookings from this date (ISO 8601 format, e.g., '2023-10-26T10:00:00.000Z'). 155 | date_to: Optional. Filter bookings up to this date (ISO 8601 format, e.g., '2023-10-27T10:00:00.000Z'). 156 | limit: Optional. Maximum number of bookings to return (default is 20). 157 | 158 | Returns: 159 | A dictionary containing the API response (list of bookings) or an error message. 160 | """ 161 | if not CALCOM_API_KEY: 162 | return {"error": "Cal.com API key not configured. Please set the CALCOM_API_KEY environment variable."} 163 | headers = { 164 | "Authorization": f"Bearer {CALCOM_API_KEY}", 165 | "Content-Type": "application/json" 166 | } 167 | params = {} 168 | if event_type_id is not None: 169 | params['eventTypeId'] = event_type_id 170 | if user_id is not None: 171 | params['userId'] = user_id 172 | if status is not None: 173 | params['status'] = status 174 | if date_from is not None: 175 | params['dateFrom'] = date_from 176 | if date_to is not None: 177 | params['dateTo'] = date_to 178 | if limit is not None: 179 | params['take'] = limit 180 | try: 181 | response = requests.get(f"{CALCOM_API_BASE_URL}/bookings", headers=headers, params=params) 182 | response.raise_for_status() 183 | return response.json() 184 | except requests.exceptions.HTTPError as http_err: 185 | return {"error": f"HTTP error occurred: {http_err}", "status_code": response.status_code, "response_text": response.text} 186 | except requests.exceptions.RequestException as req_err: 187 | return {"error": f"Request exception occurred: {req_err}"} 188 | except Exception as e: 189 | return {"error": f"An unexpected error occurred: {e}"} 190 | 191 | @mcp.tool() 192 | def create_booking( 193 | start_time: str, 194 | attendee_name: str, 195 | attendee_email: str, 196 | attendee_timezone: str, 197 | event_type_id: int = None, 198 | event_type_slug: str = None, 199 | username: str = None, 200 | team_slug: str = None, 201 | organization_slug: str = None, 202 | attendee_phone_number: str = None, 203 | attendee_language: str = None, 204 | guests: list[str] = None, 205 | location_input: str = None, 206 | metadata: dict = None, 207 | length_in_minutes: int = None, 208 | booking_fields_responses: dict = None 209 | ) -> dict: 210 | """Create a new booking in Cal.com for a specific event type and attendee. 211 | 212 | Args: 213 | start_time: Required. The start time of the booking in ISO 8601 format in UTC (e.g., '2024-08-13T09:00:00Z'). 214 | attendee_name: Required. The name of the primary attendee. 215 | attendee_email: Required. The email of the primary attendee. 216 | attendee_timezone: Required. The IANA time zone of the primary attendee (e.g., 'America/New_York'). 217 | event_type_id: Optional. The ID of the event type to book. Either this or (eventTypeSlug + username/teamSlug) is required. 218 | event_type_slug: Optional. The slug of the event type. Used with username or team_slug if event_type_id is not provided. 219 | username: Optional. The username of the event owner. Used with event_type_slug. 220 | team_slug: Optional. The slug of the team owning the event type. Used with event_type_slug. 221 | organization_slug: Optional. The organization slug, used with event_type_slug and username/team_slug if applicable. 222 | attendee_phone_number: Optional. Phone number for the attendee (e.g., for SMS reminders). 223 | attendee_language: Optional. Preferred language for the attendee (e.g., 'en', 'it'). 224 | guests: Optional. A list of additional guest email addresses. 225 | location_input: Optional. Specifies the meeting location. Can be a simple string for Cal Video, or a URL for custom locations. 226 | metadata: Optional. A dictionary of custom key-value pairs (max 50 keys, 40 char key, 500 char value). 227 | length_in_minutes: Optional. If the event type allows variable lengths, specify the desired duration. 228 | booking_fields_responses: Optional. A dictionary for responses to custom booking fields (slug: value). 229 | 230 | Returns: 231 | A dictionary containing the API response (booking details) or an error message. 232 | """ 233 | if not CALCOM_API_KEY: 234 | return {"error": "Cal.com API key not configured. Please set the CALCOM_API_KEY environment variable."} 235 | if not event_type_id and not (event_type_slug and (username or team_slug)): 236 | return {"error": "Either 'event_type_id' or ('event_type_slug' and 'username'/'team_slug') must be provided."} 237 | headers = { 238 | "Authorization": f"Bearer {CALCOM_API_KEY}", 239 | "Content-Type": "application/json", 240 | "cal-api-version": "2024-08-13" 241 | } 242 | payload = { 243 | "start": start_time, 244 | "attendee": { 245 | "name": attendee_name, 246 | "email": attendee_email, 247 | "timeZone": attendee_timezone 248 | } 249 | } 250 | if event_type_id: 251 | payload['eventTypeId'] = event_type_id 252 | else: 253 | payload['eventTypeSlug'] = event_type_slug 254 | if username: 255 | payload['username'] = username 256 | elif team_slug: 257 | payload['teamSlug'] = team_slug 258 | if organization_slug: 259 | payload['organizationSlug'] = organization_slug 260 | if attendee_phone_number: 261 | payload['attendee']['phoneNumber'] = attendee_phone_number 262 | if attendee_language: 263 | payload['attendee']['language'] = attendee_language 264 | if guests: 265 | payload['guests'] = guests 266 | if location_input: 267 | payload['location'] = location_input 268 | if metadata: 269 | payload['metadata'] = metadata 270 | if length_in_minutes: 271 | payload['lengthInMinutes'] = length_in_minutes 272 | if booking_fields_responses: 273 | payload['bookingFieldsResponses'] = booking_fields_responses 274 | try: 275 | response = requests.post(f"{CALCOM_API_BASE_URL}/bookings", headers=headers, json=payload) 276 | response.raise_for_status() 277 | return response.json() 278 | except requests.exceptions.HTTPError as http_err: 279 | error_details = {"error": f"HTTP error occurred: {http_err}", "status_code": response.status_code} 280 | try: 281 | error_details["response_text"] = response.json() 282 | except ValueError: 283 | error_details["response_text"] = response.text 284 | return error_details 285 | except requests.exceptions.RequestException as req_err: 286 | return {"error": f"Request exception occurred: {req_err}"} 287 | except Exception as e: 288 | return {"error": f"An unexpected error occurred: {e}"} 289 | 290 | @mcp.tool() 291 | def list_schedules(user_id: int = None, team_id: int = None, limit: int = 20) -> dict: 292 | """List all schedules available to the authenticated user or for a specific user/team. 293 | 294 | Args: 295 | user_id: Optional. Filter schedules by user ID. 296 | team_id: Optional. Filter schedules by team ID. 297 | limit: Optional. Maximum number of schedules to return (default 20). 298 | 299 | Returns: 300 | A dictionary containing the API response (list of schedules) or an error message. 301 | """ 302 | if not CALCOM_API_KEY: 303 | return {"error": "Cal.com API key not configured. Please set the CALCOM_API_KEY environment variable."} 304 | headers = { 305 | "Authorization": f"Bearer {CALCOM_API_KEY}", 306 | "Content-Type": "application/json" 307 | } 308 | params = {} 309 | if user_id is not None: 310 | params["userId"] = user_id 311 | if team_id is not None: 312 | params["teamId"] = team_id 313 | if limit is not None: 314 | params["take"] = limit 315 | try: 316 | response = requests.get(f"{CALCOM_API_BASE_URL}/schedules", headers=headers, params=params) 317 | response.raise_for_status() 318 | return response.json() 319 | except requests.exceptions.HTTPError as http_err: 320 | return {"error": f"HTTP error occurred: {http_err}", "status_code": response.status_code, "response_text": response.text} 321 | except requests.exceptions.RequestException as req_err: 322 | return {"error": f"Request exception occurred: {req_err}"} 323 | except Exception as e: 324 | return {"error": f"An unexpected error occurred: {e}"} 325 | 326 | @mcp.tool() 327 | def list_teams(limit: int = 20) -> dict: 328 | """List all teams available to the authenticated user. 329 | 330 | Args: 331 | limit: Optional. Maximum number of teams to return (default 20). 332 | 333 | Returns: 334 | A dictionary containing the API response (list of teams) or an error message. 335 | """ 336 | if not CALCOM_API_KEY: 337 | return {"error": "Cal.com API key not configured. Please set the CALCOM_API_KEY environment variable."} 338 | headers = { 339 | "Authorization": f"Bearer {CALCOM_API_KEY}", 340 | "Content-Type": "application/json" 341 | } 342 | params = {"take": limit} if limit is not None else {} 343 | try: 344 | response = requests.get(f"{CALCOM_API_BASE_URL}/teams", headers=headers, params=params) 345 | response.raise_for_status() 346 | return response.json() 347 | except requests.exceptions.HTTPError as http_err: 348 | return {"error": f"HTTP error occurred: {http_err}", "status_code": response.status_code, "response_text": response.text} 349 | except requests.exceptions.RequestException as req_err: 350 | return {"error": f"Request exception occurred: {req_err}"} 351 | except Exception as e: 352 | return {"error": f"An unexpected error occurred: {e}"} 353 | 354 | @mcp.tool() 355 | def list_users(limit: int = 20) -> dict: 356 | """List all users available to the authenticated account. 357 | 358 | Args: 359 | limit: Optional. Maximum number of users to return (default 20). 360 | 361 | Returns: 362 | A dictionary containing the API response (list of users) or an error message. 363 | """ 364 | if not CALCOM_API_KEY: 365 | return {"error": "Cal.com API key not configured. Please set the CALCOM_API_KEY environment variable."} 366 | headers = { 367 | "Authorization": f"Bearer {CALCOM_API_KEY}", 368 | "Content-Type": "application/json" 369 | } 370 | params = {"take": limit} if limit is not None else {} 371 | try: 372 | response = requests.get(f"{CALCOM_API_BASE_URL}/users", headers=headers, params=params) 373 | response.raise_for_status() 374 | return response.json() 375 | except requests.exceptions.HTTPError as http_err: 376 | return {"error": f"HTTP error occurred: {http_err}", "status_code": response.status_code, "response_text": response.text} 377 | except requests.exceptions.RequestException as req_err: 378 | return {"error": f"Request exception occurred: {req_err}"} 379 | except Exception as e: 380 | return {"error": f"An unexpected error occurred: {e}"} 381 | 382 | @mcp.tool() 383 | def list_webhooks(limit: int = 20) -> dict: 384 | """List all webhooks configured for the authenticated account. 385 | 386 | Args: 387 | limit: Optional. Maximum number of webhooks to return (default 20). 388 | 389 | Returns: 390 | A dictionary containing the API response (list of webhooks) or an error message. 391 | """ 392 | if not CALCOM_API_KEY: 393 | return {"error": "Cal.com API key not configured. Please set the CALCOM_API_KEY environment variable."} 394 | headers = { 395 | "Authorization": f"Bearer {CALCOM_API_KEY}", 396 | "Content-Type": "application/json" 397 | } 398 | params = {"take": limit} if limit is not None else {} 399 | try: 400 | response = requests.get(f"{CALCOM_API_BASE_URL}/webhooks", headers=headers, params=params) 401 | response.raise_for_status() 402 | return response.json() 403 | except requests.exceptions.HTTPError as http_err: 404 | return {"error": f"HTTP error occurred: {http_err}", "status_code": response.status_code, "response_text": response.text} 405 | except requests.exceptions.RequestException as req_err: 406 | return {"error": f"Request exception occurred: {req_err}"} 407 | except Exception as e: 408 | return {"error": f"An unexpected error occurred: {e}"} 409 | 410 | if __name__ == "__main__": 411 | print("Starting Cal.com MCP Server...") 412 | if not CALCOM_API_KEY: 413 | print("WARNING: CALCOM_API_KEY environment variable is not set. Some tools may not function.") 414 | mcp.run() 415 | --------------------------------------------------------------------------------