This is page 1 of 3. Use http://codebase.md/markuspfundstein/mcp-gsuite?lines=true&page={x} to view the full context. # Directory Structure ``` ├── .gitignore ├── .python-version ├── Dockerfile ├── gmail-api-openapi-spec.yaml ├── gmail.v1.json ├── google-calendar-api-openapi-spec.yaml ├── LICENSE ├── pyproject.toml ├── README.md ├── smithery.yaml ├── src │ └── mcp_gsuite │ ├── __init__.py │ ├── calendar.py │ ├── gauth.py │ ├── gmail.py │ ├── server.py │ ├── toolhandler.py │ ├── tools_calendar.py │ └── tools_gmail.py └── uv.lock ``` # Files -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- ``` 1 | 3.13 2 | ``` -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` 1 | # Python-generated files 2 | __pycache__/ 3 | *.py[oc] 4 | build/ 5 | dist/ 6 | wheels/ 7 | *.egg-info 8 | 9 | # Virtual environments 10 | .venv 11 | .env 12 | .gauth.json 13 | oauth2creds.json 14 | .accounts.json 15 | .oauth2.*.json 16 | ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown 1 | # mcp-gsuite MCP server 2 | 3 | [](https://smithery.ai/server/mcp-gsuite) 4 | MCP server to interact with Google products. 5 | 6 | ## Example prompts 7 | 8 | Right now, this MCP server supports Gmail and Calendar integration with the following capabilities: 9 | 10 | 1. General 11 | * Multiple google accounts 12 | 13 | 2. Gmail 14 | * Get your Gmail user information 15 | * Query emails with flexible search (e.g., unread, from specific senders, date ranges, with attachments) 16 | * Retrieve complete email content by ID 17 | * Create new draft emails with recipients, subject, body and CC options 18 | * Delete draft emails 19 | * Reply to existing emails (can either send immediately or save as draft) 20 | * Retrieve multiple emails at once by their IDs. 21 | * Save multiple attachments from emails to your local system. 22 | 23 | 3. Calendar 24 | * Manage multiple calendars 25 | * Get calendar events within specified time ranges 26 | * Create calendar events with: 27 | + Title, start/end times 28 | + Optional location and description 29 | + Optional attendees 30 | + Custom timezone support 31 | + Notification preferences 32 | * Delete calendar events 33 | 34 | Example prompts you can try: 35 | 36 | * Retrieve my latest unread messages 37 | * Search my emails from the Scrum Master 38 | * Retrieve all emails from accounting 39 | * Take the email about ABC and summarize it 40 | * Write a nice response to Alice's last email and upload a draft. 41 | * Reply to Bob's email with a Thank you note. Store it as draft 42 | 43 | * What do I have on my agenda tomorrow? 44 | * Check my private account's Family agenda for next week 45 | * I need to plan an event with Tim for 2hrs next week. Suggest some time slots. 46 | 47 | ## Quickstart 48 | 49 | ### Install 50 | 51 | ### Installing via Smithery 52 | 53 | To install mcp-gsuite for Claude Desktop automatically via [Smithery](https://smithery.ai/server/mcp-gsuite): 54 | 55 | ```bash 56 | npx -y @smithery/cli install mcp-gsuite --client claude 57 | ``` 58 | 59 | #### Oauth 2 60 | 61 | Google Workspace (G Suite) APIs require OAuth2 authorization. Follow these steps to set up authentication: 62 | 63 | 1. Create OAuth2 Credentials: 64 | - Go to the [Google Cloud Console](https://console.cloud.google.com/) 65 | - Create a new project or select an existing one 66 | - Enable the Gmail API and Google Calendar API for your project 67 | - Go to "Credentials" → "Create Credentials" → "OAuth client ID" 68 | - Select "Desktop app" or "Web application" as the application type 69 | - Configure the OAuth consent screen with required information 70 | - Add authorized redirect URIs (include `http://localhost:4100/code` for local development) 71 | 72 | 2. Required OAuth2 Scopes: 73 | 74 | 75 | ```json 76 | [ 77 | "openid", 78 | "https://mail.google.com/", 79 | "https://www.googleapis.com/auth/calendar", 80 | "https://www.googleapis.com/auth/userinfo.email" 81 | ] 82 | ``` 83 | 84 | 3. Then create a `.gauth.json` in your working directory with client 85 | 86 | ```json 87 | { 88 | "web": { 89 | "client_id": "$your_client_id", 90 | "client_secret": "$your_client_secret", 91 | "redirect_uris": ["http://localhost:4100/code"], 92 | "auth_uri": "https://accounts.google.com/o/oauth2/auth", 93 | "token_uri": "https://oauth2.googleapis.com/token" 94 | } 95 | } 96 | ``` 97 | 98 | 4. Create a `.accounts.json` file with account information 99 | 100 | ```json 101 | { 102 | "accounts": [ 103 | { 104 | "email": "[email protected]", 105 | "account_type": "personal", 106 | "extra_info": "Additional info that you want to tell Claude: E.g. 'Contains Family Calendar'" 107 | } 108 | ] 109 | } 110 | ``` 111 | 112 | You can specifiy multiple accounts. Make sure they have access in your Google Auth app. The `extra_info` field is especially interesting as you can add info here that you want to tell the AI about the account (e.g. whether it has a specific agenda) 113 | 114 | Note: When you first execute one of the tools for a specific account, a browser will open, redirect you to Google and ask for your credentials, scope, etc. After a successful login, it stores the credentials in a local file called `.oauth.{email}.json` . Once you are authorized, the refresh token will be used. 115 | 116 | #### Claude Desktop 117 | 118 | On MacOS: `~/Library/Application\ Support/Claude/claude_desktop_config.json` 119 | 120 | On Windows: `%APPDATA%/Claude/claude_desktop_config.json` 121 | 122 | <details> 123 | <summary>Development/Unpublished Servers Configuration</summary> 124 | 125 | 126 | ```json 127 | { 128 | "mcpServers": { 129 | "mcp-gsuite": { 130 | "command": "uv", 131 | "args": [ 132 | "--directory", 133 | "<dir_to>/mcp-gsuite", 134 | "run", 135 | "mcp-gsuite" 136 | ] 137 | } 138 | } 139 | } 140 | ``` 141 | 142 | 143 | Note: You can also use the `uv run mcp-gsuite --accounts-file /path/to/custom/.accounts.json` to specify a different accounts file or `--credentials-dir /path/to/custom/credentials` to specify a different credentials directory. 144 | 145 | ```json 146 | { 147 | "mcpServers": { 148 | "mcp-gsuite": { 149 | "command": "uv", 150 | "args": [ 151 | "--directory", 152 | "<dir_to>/mcp-gsuite", 153 | "run", 154 | "mcp-gsuite", 155 | "--accounts-file", 156 | "/path/to/custom/.accounts.json", 157 | "--credentials-dir", 158 | "/path/to/custom/credentials" 159 | ] 160 | } 161 | } 162 | } 163 | ``` 164 | 165 | </details> 166 | 167 | <details> 168 | <summary>Published Servers Configuration</summary> 169 | 170 | 171 | ```json 172 | { 173 | "mcpServers": { 174 | "mcp-gsuite": { 175 | "command": "uvx", 176 | "args": [ 177 | "mcp-gsuite", 178 | "--accounts-file", 179 | "/path/to/custom/.accounts.json", 180 | "--credentials-dir", 181 | "/path/to/custom/credentials" 182 | ] 183 | } 184 | } 185 | } 186 | ``` 187 | 188 | </details> 189 | 190 | ### Configuration Options 191 | 192 | The MCP server can be configured with several command-line options to specify custom paths for authentication and account information: 193 | 194 | * `--gauth-file`: Specifies the path to the `.gauth.json` file containing OAuth2 client configuration. Default is `./.gauth.json`. 195 | * `--accounts-file`: Specifies the path to the `.accounts.json` file containing information about the Google accounts. Default is `./.accounts.json`. 196 | * `--credentials-dir`: Specifies the directory where OAuth credentials are stored after successful authentication. Default is the current working directory with a subdirectory for each account as `.oauth.{email}.json`. 197 | 198 | These options allow for flexibility in managing different environments or multiple sets of credentials and accounts, especially useful in development and testing scenarios. 199 | 200 | Example usage: 201 | 202 | ```bash 203 | uv run mcp-gsuite --gauth-file /path/to/custom/.gauth.json --accounts-file /path/to/custom/.accounts.json --credentials-dir /path/to/custom/credentials 204 | ``` 205 | 206 | This configuration is particularly useful when you have multiple instances of the server running with different configurations or when deploying to environments where the default paths are not suitable. 207 | 208 | ## Development 209 | 210 | ### Building and Publishing 211 | 212 | To prepare the package for distribution: 213 | 214 | 1. Sync dependencies and update lockfile: 215 | 216 | ```bash 217 | uv sync 218 | ``` 219 | 220 | 2. Build package distributions: 221 | 222 | ```bash 223 | uv build 224 | ``` 225 | 226 | This will create source and wheel distributions in the `dist/` directory. 227 | 228 | 3. Publish to PyPI: 229 | 230 | ```bash 231 | uv publish 232 | ``` 233 | 234 | Note: You'll need to set PyPI credentials via environment variables or command flags: 235 | * Token: `--token` or `UV_PUBLISH_TOKEN` 236 | * Or username/password: `--username`/`UV_PUBLISH_USERNAME` and `--password`/`UV_PUBLISH_PASSWORD` 237 | 238 | ### Debugging 239 | 240 | Since MCP servers run over stdio, debugging can be challenging. For the best debugging 241 | experience, we strongly recommend using the [MCP Inspector](https://github.com/modelcontextprotocol/inspector). 242 | 243 | You can launch the MCP Inspector via [ `npm` ](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm) with this command: 244 | 245 | ```bash 246 | npx @modelcontextprotocol/inspector uv --directory /path/to/mcp-gsuite run mcp-gsuite 247 | ``` 248 | 249 | Upon launching, the Inspector will display a URL that you can access in your browser to begin debugging. 250 | 251 | You can also watch the server logs with this command: 252 | 253 | ```bash 254 | tail -n 20 -f ~/Library/Logs/Claude/mcp-server-mcp-gsuite.log 255 | ``` 256 | ``` -------------------------------------------------------------------------------- /src/mcp_gsuite/__init__.py: -------------------------------------------------------------------------------- ```python 1 | from . import server 2 | import asyncio 3 | 4 | def main(): 5 | """Main entry point for the package.""" 6 | asyncio.run(server.main()) 7 | 8 | # Optionally expose other important items at package level 9 | __all__ = ['main', 'server'] ``` -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- ```toml 1 | [project] 2 | name = "mcp-gsuite" 3 | version = "0.4.1" 4 | description = "MCP Server to connect to Google G-Suite" 5 | readme = "README.md" 6 | requires-python = ">=3.13" 7 | dependencies = [ 8 | "beautifulsoup4>=4.12.3", 9 | "google-api-python-client>=2.154.0", 10 | "httplib2>=0.22.0", 11 | "mcp>=1.3.0", 12 | "oauth2client==4.1.3", 13 | "python-dotenv>=1.0.1", 14 | "pytz>=2024.2", 15 | "requests>=2.32.3", 16 | ] 17 | [[project.authors]] 18 | name = "Markus Pfundstein" 19 | email = "[email protected]" 20 | 21 | [build-system] 22 | requires = [ "hatchling",] 23 | build-backend = "hatchling.build" 24 | 25 | [dependency-groups] 26 | dev = [ 27 | "pyright>=1.1.389", 28 | ] 29 | 30 | [project.scripts] 31 | mcp-gsuite = "mcp_gsuite:main" 32 | ``` -------------------------------------------------------------------------------- /smithery.yaml: -------------------------------------------------------------------------------- ```yaml 1 | # Smithery configuration file: https://smithery.ai/docs/config#smitheryyaml 2 | 3 | startCommand: 4 | type: stdio 5 | configSchema: 6 | # JSON Schema defining the configuration options for the MCP. 7 | type: object 8 | required: 9 | - gauthFile 10 | - accountsFile 11 | properties: 12 | gauthFile: 13 | type: string 14 | description: Path to the OAuth2 client configuration file. 15 | accountsFile: 16 | type: string 17 | description: Path to the Google accounts configuration file. 18 | credentialsDir: 19 | type: string 20 | description: Directory where OAuth credentials are stored. 21 | commandFunction: 22 | # A function that produces the CLI command to start the MCP on stdio. 23 | |- 24 | (config) => ({command: 'uv', args: ['run', 'mcp-gsuite', '--gauth-file', config.gauthFile, '--accounts-file', config.accountsFile, '--credentials-dir', config.credentialsDir]}) 25 | ``` -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- ```dockerfile 1 | # Generated by https://smithery.ai. See: https://smithery.ai/docs/config#dockerfile 2 | # Use a Python image with uv pre-installed 3 | FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS uv 4 | 5 | # Set the working directory 6 | WORKDIR /app 7 | 8 | # Copy necessary configuration files 9 | COPY . . 10 | 11 | # Enable bytecode compilation 12 | ENV UV_COMPILE_BYTECODE=1 13 | 14 | # Use the copy link mode for mount points 15 | ENV UV_LINK_MODE=copy 16 | 17 | # Sync dependencies and build the project 18 | RUN --mount=type=cache,target=/root/.cache/uv --mount=type=bind,source=uv.lock,target=uv.lock --mount=type=bind,source=pyproject.toml,target=pyproject.toml uv sync --frozen --no-install-project --no-dev --no-editable 19 | 20 | # Install the project 21 | RUN --mount=type=cache,target=/root/.cache/uv uv sync --frozen --no-dev --no-editable 22 | 23 | # Final stage: running the application 24 | FROM python:3.13-slim-bookworm 25 | 26 | WORKDIR /app 27 | 28 | COPY --from=uv /root/.local /root/.local 29 | COPY --from=uv --chown=app:app /app/.venv /app/.venv 30 | 31 | # Place executables in the environment at the front of the path 32 | ENV PATH="/app/.venv/bin:$PATH" 33 | 34 | # Expose necessary ports 35 | EXPOSE 4100 36 | 37 | # Specify the entrypoint command 38 | ENTRYPOINT ["uv", "run", "mcp-gsuite"] 39 | ``` -------------------------------------------------------------------------------- /src/mcp_gsuite/toolhandler.py: -------------------------------------------------------------------------------- ```python 1 | from collections.abc import Sequence 2 | from mcp.types import ( 3 | Tool, 4 | TextContent, 5 | ImageContent, 6 | EmbeddedResource, 7 | ) 8 | 9 | from . import gauth 10 | 11 | USER_ID_ARG = "__user_id__" 12 | 13 | class ToolHandler(): 14 | def __init__(self, tool_name: str): 15 | self.name = tool_name 16 | 17 | def get_account_descriptions(self) -> list[str]: 18 | return [a.to_description() for a in gauth.get_account_info()] 19 | 20 | # we ingest this information into every tool that requires a specified __user_id__. 21 | # we also add what information actually can be used (account info). This way Claude 22 | # will know what to do. 23 | def get_supported_emails_tool_text(self) -> str: 24 | return f"""This tool requires a authorized Google account email for {USER_ID_ARG} argument. You can choose one of: {', '.join(self.get_account_descriptions())}""" 25 | 26 | def get_user_id_arg_schema(self) -> dict: 27 | return { 28 | "type": "string", 29 | "description": f"The EMAIL of the Google account for which you are executing this action. Can be one of: {', '.join(self.get_account_descriptions())}" 30 | } 31 | 32 | def get_tool_description(self) -> Tool: 33 | raise NotImplementedError() 34 | 35 | def run_tool(self, args: dict) -> Sequence[TextContent | ImageContent | EmbeddedResource]: 36 | raise NotImplementedError() ``` -------------------------------------------------------------------------------- /src/mcp_gsuite/server.py: -------------------------------------------------------------------------------- ```python 1 | 2 | import logging 3 | from collections.abc import Sequence 4 | from functools import lru_cache 5 | import subprocess 6 | from typing import Any 7 | import traceback 8 | from dotenv import load_dotenv 9 | from mcp.server import Server 10 | import threading 11 | import sys 12 | from mcp.types import ( 13 | Tool, 14 | TextContent, 15 | ImageContent, 16 | EmbeddedResource, 17 | ) 18 | import json 19 | from . import gauth 20 | from http.server import BaseHTTPRequestHandler,HTTPServer 21 | from urllib.parse import ( 22 | urlparse, 23 | parse_qs, 24 | ) 25 | 26 | class OauthListener(BaseHTTPRequestHandler): 27 | def do_GET(self): 28 | url = urlparse(self.path) 29 | if url.path != "/code": 30 | self.send_response(404) 31 | self.end_headers() 32 | return 33 | 34 | query = parse_qs(url.query) 35 | if "code" not in query: 36 | self.send_response(400) 37 | self.end_headers() 38 | return 39 | 40 | self.send_response(200) 41 | self.end_headers() 42 | self.wfile.write("Auth successful! You can close the tab!".encode("utf-8")) 43 | self.wfile.flush() 44 | 45 | storage = {} 46 | creds = gauth.get_credentials(authorization_code=query["code"][0], state=storage) 47 | 48 | t = threading.Thread(target = self.server.shutdown) 49 | t.daemon = True 50 | t.start() 51 | 52 | 53 | 54 | load_dotenv() 55 | 56 | from . import tools_gmail 57 | from . import tools_calendar 58 | from . import toolhandler 59 | 60 | 61 | # Load environment variables 62 | 63 | # Configure logging 64 | logging.basicConfig(level=logging.INFO) 65 | logger = logging.getLogger("mcp-gsuite") 66 | 67 | def start_auth_flow(user_id: str): 68 | auth_url = gauth.get_authorization_url(user_id, state={}) 69 | if sys.platform == "darwin" or sys.platform.startswith("linux"): 70 | subprocess.Popen(['open', auth_url]) 71 | else: 72 | import webbrowser 73 | webbrowser.open(auth_url) 74 | 75 | # start server for code callback 76 | server_address = ('', 4100) 77 | server = HTTPServer(server_address, OauthListener) 78 | server.serve_forever() 79 | 80 | 81 | def setup_oauth2(user_id: str): 82 | accounts = gauth.get_account_info() 83 | if len(accounts) == 0: 84 | raise RuntimeError("No accounts specified in .gauth.json") 85 | if user_id not in [a.email for a in accounts]: 86 | raise RuntimeError(f"Account for email: {user_id} not specified in .gauth.json") 87 | 88 | credentials = gauth.get_stored_credentials(user_id=user_id) 89 | if not credentials: 90 | start_auth_flow(user_id=user_id) 91 | else: 92 | if credentials.access_token_expired: 93 | logger.error("credentials expired. try refresh") 94 | 95 | # this call refreshes access token 96 | user_info = gauth.get_user_info(credentials=credentials) 97 | #logging.error(f"User info: {json.dumps(user_info)}") 98 | gauth.store_credentials(credentials=credentials, user_id=user_id) 99 | 100 | 101 | app = Server("mcp-gsuite") 102 | 103 | tool_handlers = {} 104 | def add_tool_handler(tool_class: toolhandler.ToolHandler): 105 | global tool_handlers 106 | 107 | tool_handlers[tool_class.name] = tool_class 108 | 109 | def get_tool_handler(name: str) -> toolhandler.ToolHandler | None: 110 | if name not in tool_handlers: 111 | return None 112 | 113 | return tool_handlers[name] 114 | 115 | add_tool_handler(tools_gmail.QueryEmailsToolHandler()) 116 | add_tool_handler(tools_gmail.GetEmailByIdToolHandler()) 117 | add_tool_handler(tools_gmail.CreateDraftToolHandler()) 118 | add_tool_handler(tools_gmail.DeleteDraftToolHandler()) 119 | add_tool_handler(tools_gmail.ReplyEmailToolHandler()) 120 | add_tool_handler(tools_gmail.GetAttachmentToolHandler()) 121 | add_tool_handler(tools_gmail.BulkGetEmailsByIdsToolHandler()) 122 | add_tool_handler(tools_gmail.BulkSaveAttachmentsToolHandler()) 123 | 124 | add_tool_handler(tools_calendar.ListCalendarsToolHandler()) 125 | add_tool_handler(tools_calendar.GetCalendarEventsToolHandler()) 126 | add_tool_handler(tools_calendar.CreateCalendarEventToolHandler()) 127 | add_tool_handler(tools_calendar.DeleteCalendarEventToolHandler()) 128 | 129 | @app.list_tools() 130 | async def list_tools() -> list[Tool]: 131 | """List available tools.""" 132 | 133 | return [th.get_tool_description() for th in tool_handlers.values()] 134 | 135 | 136 | @app.call_tool() 137 | async def call_tool(name: str, arguments: Any) -> Sequence[TextContent | ImageContent | EmbeddedResource]: 138 | try: 139 | if not isinstance(arguments, dict): 140 | raise RuntimeError("arguments must be dictionary") 141 | 142 | if toolhandler.USER_ID_ARG not in arguments: 143 | raise RuntimeError("user_id argument is missing in dictionary.") 144 | 145 | setup_oauth2(user_id=arguments.get(toolhandler.USER_ID_ARG, "")) 146 | 147 | tool_handler = get_tool_handler(name) 148 | if not tool_handler: 149 | raise ValueError(f"Unknown tool: {name}") 150 | 151 | return tool_handler.run_tool(arguments) 152 | except Exception as e: 153 | logging.error(traceback.format_exc()) 154 | logging.error(f"Error during call_tool: str(e)") 155 | raise RuntimeError(f"Caught Exception. Error: {str(e)}") 156 | 157 | 158 | async def main(): 159 | print(sys.platform) 160 | accounts = gauth.get_account_info() 161 | for account in accounts: 162 | creds = gauth.get_stored_credentials(user_id=account.email) 163 | if creds: 164 | logging.info(f"found credentials for {account.email}") 165 | 166 | from mcp.server.stdio import stdio_server 167 | 168 | async with stdio_server() as (read_stream, write_stream): 169 | await app.run( 170 | read_stream, 171 | write_stream, 172 | app.create_initialization_options() 173 | ) ``` -------------------------------------------------------------------------------- /src/mcp_gsuite/calendar.py: -------------------------------------------------------------------------------- ```python 1 | from googleapiclient.discovery import build 2 | from . import gauth 3 | import logging 4 | import traceback 5 | from datetime import datetime 6 | import pytz 7 | 8 | class CalendarService(): 9 | def __init__(self, user_id: str): 10 | credentials = gauth.get_stored_credentials(user_id=user_id) 11 | if not credentials: 12 | raise RuntimeError("No Oauth2 credentials stored") 13 | self.service = build('calendar', 'v3', credentials=credentials) # Note: using v3 for Calendar API 14 | 15 | def list_calendars(self) -> list: 16 | """ 17 | Lists all calendars accessible by the user. 18 | 19 | Returns: 20 | list: List of calendar objects with their metadata 21 | """ 22 | try: 23 | calendar_list = self.service.calendarList().list().execute() 24 | 25 | calendars = [] 26 | 27 | for calendar in calendar_list.get('items', []): 28 | if calendar.get('kind') == 'calendar#calendarListEntry': 29 | calendars.append({ 30 | 'id': calendar.get('id'), 31 | 'summary': calendar.get('summary'), 32 | 'primary': calendar.get('primary', False), 33 | 'time_zone': calendar.get('timeZone'), 34 | 'etag': calendar.get('etag'), 35 | 'access_role': calendar.get('accessRole') 36 | }) 37 | 38 | return calendars 39 | 40 | except Exception as e: 41 | logging.error(f"Error retrieving calendars: {str(e)}") 42 | logging.error(traceback.format_exc()) 43 | return [] 44 | 45 | def get_events(self, time_min=None, time_max=None, max_results=250, show_deleted=False, calendar_id: str ='primary'): 46 | """ 47 | Retrieve calendar events within a specified time range. 48 | 49 | Args: 50 | time_min (str, optional): Start time in RFC3339 format. Defaults to current time. 51 | time_max (str, optional): End time in RFC3339 format 52 | max_results (int): Maximum number of events to return (1-2500) 53 | show_deleted (bool): Whether to include deleted events 54 | 55 | Returns: 56 | list: List of calendar events 57 | """ 58 | try: 59 | # If no time_min specified, use current time 60 | if not time_min: 61 | time_min = datetime.now(pytz.UTC).isoformat() 62 | 63 | # Ensure max_results is within limits 64 | max_results = min(max(1, max_results), 2500) 65 | 66 | # Prepare parameters 67 | params = { 68 | 'calendarId': calendar_id, 69 | 'timeMin': time_min, 70 | 'maxResults': max_results, 71 | 'singleEvents': True, 72 | 'orderBy': 'startTime', 73 | 'showDeleted': show_deleted 74 | } 75 | 76 | # Add optional time_max if specified 77 | if time_max: 78 | params['timeMax'] = time_max 79 | 80 | # Execute the events().list() method 81 | events_result = self.service.events().list(**params).execute() 82 | 83 | # Extract the events 84 | events = events_result.get('items', []) 85 | 86 | # Process and return the events 87 | processed_events = [] 88 | for event in events: 89 | processed_event = { 90 | 'id': event.get('id'), 91 | 'summary': event.get('summary'), 92 | 'description': event.get('description'), 93 | 'start': event.get('start'), 94 | 'end': event.get('end'), 95 | 'status': event.get('status'), 96 | 'creator': event.get('creator'), 97 | 'organizer': event.get('organizer'), 98 | 'attendees': event.get('attendees'), 99 | 'location': event.get('location'), 100 | 'hangoutLink': event.get('hangoutLink'), 101 | 'conferenceData': event.get('conferenceData'), 102 | 'recurringEventId': event.get('recurringEventId') 103 | } 104 | processed_events.append(processed_event) 105 | 106 | return processed_events 107 | 108 | except Exception as e: 109 | logging.error(f"Error retrieving calendar events: {str(e)}") 110 | logging.error(traceback.format_exc()) 111 | return [] 112 | 113 | def create_event(self, summary: str, start_time: str, end_time: str, 114 | location: str | None = None, description: str | None = None, 115 | attendees: list | None = None, send_notifications: bool = True, 116 | timezone: str | None = None, 117 | calendar_id : str = 'primary') -> dict | None: 118 | """ 119 | Create a new calendar event. 120 | 121 | Args: 122 | summary (str): Title of the event 123 | start_time (str): Start time in RFC3339 format 124 | end_time (str): End time in RFC3339 format 125 | location (str, optional): Location of the event 126 | description (str, optional): Description of the event 127 | attendees (list, optional): List of attendee email addresses 128 | send_notifications (bool): Whether to send notifications to attendees 129 | timezone (str, optional): Timezone for the event (e.g. 'America/New_York') 130 | 131 | Returns: 132 | dict: Created event data or None if creation fails 133 | """ 134 | try: 135 | # Prepare event data 136 | event = { 137 | 'summary': summary, 138 | 'start': { 139 | 'dateTime': start_time, 140 | 'timeZone': timezone or 'UTC', 141 | }, 142 | 'end': { 143 | 'dateTime': end_time, 144 | 'timeZone': timezone or 'UTC', 145 | } 146 | } 147 | 148 | # Add optional fields if provided 149 | if location: 150 | event['location'] = location 151 | if description: 152 | event['description'] = description 153 | if attendees: 154 | event['attendees'] = [{'email': email} for email in attendees] 155 | 156 | # Create the event 157 | created_event = self.service.events().insert( 158 | calendarId=calendar_id, 159 | body=event, 160 | sendNotifications=send_notifications 161 | ).execute() 162 | 163 | return created_event 164 | 165 | except Exception as e: 166 | logging.error(f"Error creating calendar event: {str(e)}") 167 | logging.error(traceback.format_exc()) 168 | return None 169 | 170 | def delete_event(self, event_id: str, send_notifications: bool = True, calendar_id: str = 'primary') -> bool: 171 | """ 172 | Delete a calendar event by its ID. 173 | 174 | Args: 175 | event_id (str): The ID of the event to delete 176 | send_notifications (bool): Whether to send cancellation notifications to attendees 177 | 178 | Returns: 179 | bool: True if deletion was successful, False otherwise 180 | """ 181 | try: 182 | self.service.events().delete( 183 | calendarId=calendar_id, 184 | eventId=event_id, 185 | sendNotifications=send_notifications 186 | ).execute() 187 | return True 188 | 189 | except Exception as e: 190 | logging.error(f"Error deleting calendar event {event_id}: {str(e)}") 191 | logging.error(traceback.format_exc()) 192 | return False ``` -------------------------------------------------------------------------------- /src/mcp_gsuite/gauth.py: -------------------------------------------------------------------------------- ```python 1 | import logging 2 | from oauth2client.client import ( 3 | flow_from_clientsecrets, 4 | FlowExchangeError, 5 | OAuth2Credentials, 6 | Credentials, 7 | ) 8 | from googleapiclient.discovery import build 9 | import httplib2 10 | from google.auth.transport.requests import Request 11 | import os 12 | import pydantic 13 | import json 14 | import argparse 15 | 16 | 17 | def get_gauth_file() -> str: 18 | parser = argparse.ArgumentParser() 19 | parser.add_argument( 20 | "--gauth-file", 21 | type=str, 22 | default="./.gauth.json", 23 | help="Path to client secrets file", 24 | ) 25 | args, _ = parser.parse_known_args() 26 | return args.gauth_file 27 | 28 | 29 | CLIENTSECRETS_LOCATION = get_gauth_file() 30 | 31 | REDIRECT_URI = 'http://localhost:4100/code' 32 | SCOPES = [ 33 | "openid", 34 | "https://www.googleapis.com/auth/userinfo.email", 35 | "https://mail.google.com/", 36 | "https://www.googleapis.com/auth/calendar" 37 | ] 38 | 39 | 40 | class AccountInfo(pydantic.BaseModel): 41 | 42 | email: str 43 | account_type: str 44 | extra_info: str 45 | 46 | def __init__(self, email: str, account_type: str, extra_info: str = ""): 47 | super().__init__(email=email, account_type=account_type, extra_info=extra_info) 48 | 49 | def to_description(self): 50 | return f"""Account for email: {self.email} of type: {self.account_type}. Extra info for: {self.extra_info}""" 51 | 52 | 53 | def get_accounts_file() -> str: 54 | parser = argparse.ArgumentParser() 55 | parser.add_argument( 56 | "--accounts-file", 57 | type=str, 58 | default="./.accounts.json", 59 | help="Path to accounts configuration file", 60 | ) 61 | args, _ = parser.parse_known_args() 62 | return args.accounts_file 63 | 64 | 65 | def get_account_info() -> list[AccountInfo]: 66 | accounts_file = get_accounts_file() 67 | with open(accounts_file) as f: 68 | data = json.load(f) 69 | accounts = data.get("accounts", []) 70 | return [AccountInfo.model_validate(acc) for acc in accounts] 71 | 72 | class GetCredentialsException(Exception): 73 | """Error raised when an error occurred while retrieving credentials. 74 | 75 | Attributes: 76 | authorization_url: Authorization URL to redirect the user to in order to 77 | request offline access. 78 | """ 79 | 80 | def __init__(self, authorization_url): 81 | """Construct a GetCredentialsException.""" 82 | self.authorization_url = authorization_url 83 | 84 | 85 | class CodeExchangeException(GetCredentialsException): 86 | """Error raised when a code exchange has failed.""" 87 | 88 | 89 | class NoRefreshTokenException(GetCredentialsException): 90 | """Error raised when no refresh token has been found.""" 91 | 92 | 93 | class NoUserIdException(Exception): 94 | """Error raised when no user ID could be retrieved.""" 95 | 96 | 97 | def get_credentials_dir() -> str: 98 | parser = argparse.ArgumentParser() 99 | parser.add_argument( 100 | "--credentials-dir", 101 | type=str, 102 | default=".", 103 | help="Directory to store OAuth2 credentials", 104 | ) 105 | args, _ = parser.parse_known_args() 106 | return args.credentials_dir 107 | 108 | 109 | def _get_credential_filename(user_id: str) -> str: 110 | creds_dir = get_credentials_dir() 111 | return os.path.join(creds_dir, f".oauth2.{user_id}.json") 112 | 113 | 114 | def get_stored_credentials(user_id: str) -> OAuth2Credentials | None: 115 | """Retrieved stored credentials for the provided user ID. 116 | 117 | Args: 118 | user_id: User's ID. 119 | Returns: 120 | Stored oauth2client.client.OAuth2Credentials if found, None otherwise. 121 | """ 122 | try: 123 | 124 | cred_file_path = _get_credential_filename(user_id=user_id) 125 | if not os.path.exists(cred_file_path): 126 | logging.warning(f"No stored Oauth2 credentials yet at path: {cred_file_path}") 127 | return None 128 | 129 | with open(cred_file_path, 'r') as f: 130 | data = f.read() 131 | return Credentials.new_from_json(data) 132 | except Exception as e: 133 | logging.error(e) 134 | return None 135 | 136 | raise None 137 | 138 | 139 | def store_credentials(credentials: OAuth2Credentials, user_id: str): 140 | """Store OAuth 2.0 credentials in the specified directory.""" 141 | cred_file_path = _get_credential_filename(user_id=user_id) 142 | os.makedirs(os.path.dirname(cred_file_path), exist_ok=True) 143 | 144 | data = credentials.to_json() 145 | with open(cred_file_path, "w") as f: 146 | f.write(data) 147 | 148 | 149 | def exchange_code(authorization_code): 150 | """Exchange an authorization code for OAuth 2.0 credentials. 151 | 152 | Args: 153 | authorization_code: Authorization code to exchange for OAuth 2.0 154 | credentials. 155 | Returns: 156 | oauth2client.client.OAuth2Credentials instance. 157 | Raises: 158 | CodeExchangeException: an error occurred. 159 | """ 160 | flow = flow_from_clientsecrets(CLIENTSECRETS_LOCATION, ' '.join(SCOPES)) 161 | flow.redirect_uri = REDIRECT_URI 162 | try: 163 | credentials = flow.step2_exchange(authorization_code) 164 | return credentials 165 | except FlowExchangeError as error: 166 | logging.error('An error occurred: %s', error) 167 | raise CodeExchangeException(None) 168 | 169 | 170 | def get_user_info(credentials): 171 | """Send a request to the UserInfo API to retrieve the user's information. 172 | 173 | Args: 174 | credentials: oauth2client.client.OAuth2Credentials instance to authorize the 175 | request. 176 | Returns: 177 | User information as a dict. 178 | """ 179 | user_info_service = build( 180 | serviceName='oauth2', version='v2', 181 | http=credentials.authorize(httplib2.Http())) 182 | user_info = None 183 | try: 184 | user_info = user_info_service.userinfo().get().execute() 185 | except Exception as e: 186 | logging.error(f'An error occurred: {e}') 187 | if user_info and user_info.get('id'): 188 | return user_info 189 | else: 190 | raise NoUserIdException() 191 | 192 | 193 | def get_authorization_url(email_address, state): 194 | """Retrieve the authorization URL. 195 | 196 | Args: 197 | email_address: User's e-mail address. 198 | state: State for the authorization URL. 199 | Returns: 200 | Authorization URL to redirect the user to. 201 | """ 202 | flow = flow_from_clientsecrets(CLIENTSECRETS_LOCATION, ' '.join(SCOPES), redirect_uri=REDIRECT_URI) 203 | flow.params['access_type'] = 'offline' 204 | flow.params['approval_prompt'] = 'force' 205 | flow.params['user_id'] = email_address 206 | flow.params['state'] = state 207 | return flow.step1_get_authorize_url(state=state) 208 | 209 | 210 | def get_credentials(authorization_code, state): 211 | """Retrieve credentials using the provided authorization code. 212 | 213 | This function exchanges the authorization code for an access token and queries 214 | the UserInfo API to retrieve the user's e-mail address. 215 | If a refresh token has been retrieved along with an access token, it is stored 216 | in the application database using the user's e-mail address as key. 217 | If no refresh token has been retrieved, the function checks in the application 218 | database for one and returns it if found or raises a NoRefreshTokenException 219 | with the authorization URL to redirect the user to. 220 | 221 | Args: 222 | authorization_code: Authorization code to use to retrieve an access token. 223 | state: State to set to the authorization URL in case of error. 224 | Returns: 225 | oauth2client.client.OAuth2Credentials instance containing an access and 226 | refresh token. 227 | Raises: 228 | CodeExchangeError: Could not exchange the authorization code. 229 | NoRefreshTokenException: No refresh token could be retrieved from the 230 | available sources. 231 | """ 232 | email_address = '' 233 | try: 234 | credentials = exchange_code(authorization_code) 235 | user_info = get_user_info(credentials) 236 | import json 237 | logging.error(f"user_info: {json.dumps(user_info)}") 238 | email_address = user_info.get('email') 239 | 240 | if credentials.refresh_token is not None: 241 | store_credentials(credentials, user_id=email_address) 242 | return credentials 243 | else: 244 | credentials = get_stored_credentials(user_id=email_address) 245 | if credentials and credentials.refresh_token is not None: 246 | return credentials 247 | except CodeExchangeException as error: 248 | logging.error('An error occurred during code exchange.') 249 | # Drive apps should try to retrieve the user and credentials for the current 250 | # session. 251 | # If none is available, redirect the user to the authorization URL. 252 | error.authorization_url = get_authorization_url(email_address, state) 253 | raise error 254 | except NoUserIdException: 255 | logging.error('No user ID could be retrieved.') 256 | # No refresh token has been retrieved. 257 | authorization_url = get_authorization_url(email_address, state) 258 | raise NoRefreshTokenException(authorization_url) 259 | 260 | ``` -------------------------------------------------------------------------------- /src/mcp_gsuite/tools_calendar.py: -------------------------------------------------------------------------------- ```python 1 | from collections.abc import Sequence 2 | from mcp.types import ( 3 | Tool, 4 | TextContent, 5 | ImageContent, 6 | EmbeddedResource, 7 | LoggingLevel, 8 | ) 9 | from . import gauth 10 | from . import calendar 11 | import json 12 | from . import toolhandler 13 | 14 | CALENDAR_ID_ARG="__calendar_id__" 15 | 16 | def get_calendar_id_arg_schema() -> dict[str, str]: 17 | return { 18 | "type": "string", 19 | "description": """Optional ID of the specific agenda for which you are executing this action. 20 | If not provided, the default calendar is being used. 21 | If not known, the specific calendar id can be retrieved with the list_calendars tool""", 22 | "default": "primary" 23 | } 24 | 25 | 26 | class ListCalendarsToolHandler(toolhandler.ToolHandler): 27 | def __init__(self): 28 | super().__init__("list_calendars") 29 | 30 | def get_tool_description(self) -> Tool: 31 | return Tool( 32 | name=self.name, 33 | description="""Lists all calendars accessible by the user. 34 | Call it before any other tool whenever the user specifies a particular agenda (Family, Holidays, etc.).""", 35 | inputSchema={ 36 | "type": "object", 37 | "properties": { 38 | "__user_id__": self.get_user_id_arg_schema(), 39 | }, 40 | "required": [toolhandler.USER_ID_ARG] 41 | } 42 | ) 43 | 44 | def run_tool(self, args: dict) -> Sequence[TextContent | ImageContent | EmbeddedResource]: 45 | user_id = args.get(toolhandler.USER_ID_ARG) 46 | if not user_id: 47 | raise RuntimeError(f"Missing required argument: {toolhandler.USER_ID_ARG}") 48 | 49 | calendar_service = calendar.CalendarService(user_id=user_id) 50 | calendars = calendar_service.list_calendars() 51 | 52 | return [ 53 | TextContent( 54 | type="text", 55 | text=json.dumps(calendars, indent=2) 56 | ) 57 | ] 58 | 59 | class GetCalendarEventsToolHandler(toolhandler.ToolHandler): 60 | def __init__(self): 61 | super().__init__("get_calendar_events") 62 | 63 | def get_tool_description(self) -> Tool: 64 | return Tool( 65 | name=self.name, 66 | description="Retrieves calendar events from the user's Google Calendar within a specified time range.", 67 | inputSchema={ 68 | "type": "object", 69 | "properties": { 70 | "__user_id__": self.get_user_id_arg_schema(), 71 | "__calendar_id__": get_calendar_id_arg_schema(), 72 | "time_min": { 73 | "type": "string", 74 | "description": "Start time in RFC3339 format (e.g. 2024-12-01T00:00:00Z). Defaults to current time if not specified." 75 | }, 76 | "time_max": { 77 | "type": "string", 78 | "description": "End time in RFC3339 format (e.g. 2024-12-31T23:59:59Z). Optional." 79 | }, 80 | "max_results": { 81 | "type": "integer", 82 | "description": "Maximum number of events to return (1-2500)", 83 | "minimum": 1, 84 | "maximum": 2500, 85 | "default": 250 86 | }, 87 | "show_deleted": { 88 | "type": "boolean", 89 | "description": "Whether to include deleted events", 90 | "default": False 91 | } 92 | }, 93 | "required": [toolhandler.USER_ID_ARG] 94 | } 95 | ) 96 | 97 | def run_tool(self, args: dict) -> Sequence[TextContent | ImageContent | EmbeddedResource]: 98 | 99 | user_id = args.get(toolhandler.USER_ID_ARG) 100 | if not user_id: 101 | raise RuntimeError(f"Missing required argument: {toolhandler.USER_ID_ARG}") 102 | 103 | calendar_service = calendar.CalendarService(user_id=user_id) 104 | events = calendar_service.get_events( 105 | time_min=args.get('time_min'), 106 | time_max=args.get('time_max'), 107 | max_results=args.get('max_results', 250), 108 | show_deleted=args.get('show_deleted', False), 109 | calendar_id=args.get(CALENDAR_ID_ARG, 'primary'), 110 | ) 111 | 112 | return [ 113 | TextContent( 114 | type="text", 115 | text=json.dumps(events, indent=2) 116 | ) 117 | ] 118 | 119 | class CreateCalendarEventToolHandler(toolhandler.ToolHandler): 120 | def __init__(self): 121 | super().__init__("create_calendar_event") 122 | 123 | def get_tool_description(self) -> Tool: 124 | return Tool( 125 | name=self.name, 126 | description="Creates a new event in a specified Google Calendar of the specified user.", 127 | inputSchema={ 128 | "type": "object", 129 | "properties": { 130 | "__user_id__": self.get_user_id_arg_schema(), 131 | "__calendar_id__": get_calendar_id_arg_schema(), 132 | "summary": { 133 | "type": "string", 134 | "description": "Title of the event" 135 | }, 136 | "location": { 137 | "type": "string", 138 | "description": "Location of the event (optional)" 139 | }, 140 | "description": { 141 | "type": "string", 142 | "description": "Description or notes for the event (optional)" 143 | }, 144 | "start_time": { 145 | "type": "string", 146 | "description": "Start time in RFC3339 format (e.g. 2024-12-01T10:00:00Z)" 147 | }, 148 | "end_time": { 149 | "type": "string", 150 | "description": "End time in RFC3339 format (e.g. 2024-12-01T11:00:00Z)" 151 | }, 152 | "attendees": { 153 | "type": "array", 154 | "items": { 155 | "type": "string" 156 | }, 157 | "description": "List of attendee email addresses (optional)" 158 | }, 159 | "send_notifications": { 160 | "type": "boolean", 161 | "description": "Whether to send notifications to attendees", 162 | "default": True 163 | }, 164 | "timezone": { 165 | "type": "string", 166 | "description": "Timezone for the event (e.g. 'America/New_York'). Defaults to UTC if not specified." 167 | } 168 | }, 169 | "required": [toolhandler.USER_ID_ARG, "summary", "start_time", "end_time"] 170 | } 171 | ) 172 | 173 | def run_tool(self, args: dict) -> Sequence[TextContent | ImageContent | EmbeddedResource]: 174 | # Validate required arguments 175 | required = ["summary", "start_time", "end_time"] 176 | if not all(key in args for key in required): 177 | raise RuntimeError(f"Missing required arguments: {', '.join(required)}") 178 | 179 | user_id = args.get(toolhandler.USER_ID_ARG) 180 | if not user_id: 181 | raise RuntimeError(f"Missing required argument: {toolhandler.USER_ID_ARG}") 182 | 183 | calendar_service = calendar.CalendarService(user_id=user_id) 184 | event = calendar_service.create_event( 185 | summary=args["summary"], 186 | start_time=args["start_time"], 187 | end_time=args["end_time"], 188 | location=args.get("location"), 189 | description=args.get("description"), 190 | attendees=args.get("attendees", []), 191 | send_notifications=args.get("send_notifications", True), 192 | timezone=args.get("timezone"), 193 | calendar_id=args.get(CALENDAR_ID_ARG, 'primary'), 194 | ) 195 | 196 | return [ 197 | TextContent( 198 | type="text", 199 | text=json.dumps(event, indent=2) 200 | ) 201 | ] 202 | 203 | class DeleteCalendarEventToolHandler(toolhandler.ToolHandler): 204 | def __init__(self): 205 | super().__init__("delete_calendar_event") 206 | 207 | def get_tool_description(self) -> Tool: 208 | return Tool( 209 | name=self.name, 210 | description="Deletes an event from the user's Google Calendar by its event ID.", 211 | inputSchema={ 212 | "type": "object", 213 | "properties": { 214 | "__user_id__": self.get_user_id_arg_schema(), 215 | "__calendar_id__": get_calendar_id_arg_schema(), 216 | "event_id": { 217 | "type": "string", 218 | "description": "The ID of the calendar event to delete" 219 | }, 220 | "send_notifications": { 221 | "type": "boolean", 222 | "description": "Whether to send cancellation notifications to attendees", 223 | "default": True 224 | } 225 | }, 226 | "required": [toolhandler.USER_ID_ARG, "event_id"] 227 | } 228 | ) 229 | 230 | def run_tool(self, args: dict) -> Sequence[TextContent | ImageContent | EmbeddedResource]: 231 | if "event_id" not in args: 232 | raise RuntimeError("Missing required argument: event_id") 233 | 234 | user_id = args.get(toolhandler.USER_ID_ARG) 235 | if not user_id: 236 | raise RuntimeError(f"Missing required argument: {toolhandler.USER_ID_ARG}") 237 | 238 | calendar_service = calendar.CalendarService(user_id=user_id) 239 | success = calendar_service.delete_event( 240 | event_id=args["event_id"], 241 | send_notifications=args.get("send_notifications", True), 242 | calendar_id=args.get(CALENDAR_ID_ARG, 'primary'), 243 | ) 244 | 245 | return [ 246 | TextContent( 247 | type="text", 248 | text=json.dumps({ 249 | "success": success, 250 | "message": "Event successfully deleted" if success else "Failed to delete event" 251 | }, indent=2) 252 | ) 253 | ] ``` -------------------------------------------------------------------------------- /src/mcp_gsuite/gmail.py: -------------------------------------------------------------------------------- ```python 1 | from googleapiclient.discovery import build 2 | from . import gauth 3 | import logging 4 | import base64 5 | import traceback 6 | from email.mime.text import MIMEText 7 | from typing import Tuple 8 | 9 | 10 | class GmailService(): 11 | def __init__(self, user_id: str): 12 | credentials = gauth.get_stored_credentials(user_id=user_id) 13 | if not credentials: 14 | raise RuntimeError("No Oauth2 credentials stored") 15 | self.service = build('gmail', 'v1', credentials=credentials) 16 | 17 | def _parse_message(self, txt, parse_body=False) -> dict | None: 18 | """ 19 | Parse a Gmail message into a structured format. 20 | 21 | Args: 22 | txt (dict): Raw message from Gmail API 23 | parse_body (bool): Whether to parse and include the message body (default: False) 24 | 25 | Returns: 26 | dict: Parsed message containing comprehensive metadata 27 | None: If parsing fails 28 | """ 29 | try: 30 | message_id = txt.get('id') 31 | thread_id = txt.get('threadId') 32 | payload = txt.get('payload', {}) 33 | headers = payload.get('headers', []) 34 | 35 | metadata = { 36 | 'id': message_id, 37 | 'threadId': thread_id, 38 | 'historyId': txt.get('historyId'), 39 | 'internalDate': txt.get('internalDate'), 40 | 'sizeEstimate': txt.get('sizeEstimate'), 41 | 'labelIds': txt.get('labelIds', []), 42 | 'snippet': txt.get('snippet'), 43 | } 44 | 45 | for header in headers: 46 | name = header.get('name', '').lower() 47 | value = header.get('value', '') 48 | 49 | if name == 'subject': 50 | metadata['subject'] = value 51 | elif name == 'from': 52 | metadata['from'] = value 53 | elif name == 'to': 54 | metadata['to'] = value 55 | elif name == 'date': 56 | metadata['date'] = value 57 | elif name == 'cc': 58 | metadata['cc'] = value 59 | elif name == 'bcc': 60 | metadata['bcc'] = value 61 | elif name == 'message-id': 62 | metadata['message_id'] = value 63 | elif name == 'in-reply-to': 64 | metadata['in_reply_to'] = value 65 | elif name == 'references': 66 | metadata['references'] = value 67 | elif name == 'delivered-to': 68 | metadata['delivered_to'] = value 69 | 70 | if parse_body: 71 | body = self._extract_body(payload) 72 | if body: 73 | metadata['body'] = body 74 | 75 | metadata['mimeType'] = payload.get('mimeType') 76 | 77 | return metadata 78 | 79 | except Exception as e: 80 | logging.error(f"Error parsing message: {str(e)}") 81 | logging.error(traceback.format_exc()) 82 | return None 83 | 84 | def _extract_body(self, payload) -> str | None: 85 | """ 86 | Extract the email body from the payload. 87 | Handles both multipart and single part messages, including nested multiparts. 88 | """ 89 | try: 90 | # For single part text/plain messages 91 | if payload.get('mimeType') == 'text/plain': 92 | data = payload.get('body', {}).get('data') 93 | if data: 94 | return base64.urlsafe_b64decode(data).decode('utf-8') 95 | 96 | # For single part text/html messages 97 | if payload.get('mimeType') == 'text/html': 98 | data = payload.get('body', {}).get('data') 99 | if data: 100 | return base64.urlsafe_b64decode(data).decode('utf-8') 101 | 102 | # For multipart messages (both alternative and related) 103 | if payload.get('mimeType', '').startswith('multipart/'): 104 | parts = payload.get('parts', []) 105 | 106 | # First try to find a direct text/plain part 107 | for part in parts: 108 | if part.get('mimeType') == 'text/plain': 109 | data = part.get('body', {}).get('data') 110 | if data: 111 | return base64.urlsafe_b64decode(data).decode('utf-8') 112 | 113 | # If no direct text/plain, recursively check nested multipart structures 114 | for part in parts: 115 | if part.get('mimeType', '').startswith('multipart/'): 116 | nested_body = self._extract_body(part) 117 | if nested_body: 118 | return nested_body 119 | 120 | # If still no body found, try the first part as fallback 121 | if parts and 'body' in parts[0] and 'data' in parts[0]['body']: 122 | data = parts[0]['body']['data'] 123 | return base64.urlsafe_b64decode(data).decode('utf-8') 124 | 125 | return None 126 | 127 | except Exception as e: 128 | logging.error(f"Error extracting body: {str(e)}") 129 | return None 130 | 131 | def query_emails(self, query=None, max_results=100): 132 | """ 133 | Query emails from Gmail based on a search query. 134 | 135 | Args: 136 | query (str, optional): Gmail search query (e.g., 'is:unread', 'from:[email protected]') 137 | If None, returns all emails 138 | max_results (int): Maximum number of emails to retrieve (1-500, default: 100) 139 | 140 | Returns: 141 | list: List of parsed email messages, newest first 142 | """ 143 | try: 144 | # Ensure max_results is within API limits 145 | max_results = min(max(1, max_results), 500) 146 | 147 | # Get the list of messages 148 | result = self.service.users().messages().list( 149 | userId='me', 150 | maxResults=max_results, 151 | q=query if query else '' 152 | ).execute() 153 | 154 | messages = result.get('messages', []) 155 | parsed = [] 156 | 157 | # Fetch full message details for each message 158 | for msg in messages: 159 | txt = self.service.users().messages().get( 160 | userId='me', 161 | id=msg['id'] 162 | ).execute() 163 | parsed_message = self._parse_message(txt=txt, parse_body=False) 164 | if parsed_message: 165 | parsed.append(parsed_message) 166 | 167 | return parsed 168 | 169 | except Exception as e: 170 | logging.error(f"Error reading emails: {str(e)}") 171 | logging.error(traceback.format_exc()) 172 | return [] 173 | 174 | def get_email_by_id_with_attachments(self, email_id: str) -> Tuple[dict, dict] | Tuple[None, dict]: 175 | """ 176 | Fetch and parse a complete email message by its ID including attachment IDs. 177 | 178 | Args: 179 | email_id (str): The Gmail message ID to retrieve 180 | 181 | Returns: 182 | Tuple[dict, list]: Complete parsed email message including body and list of attachment IDs 183 | Tuple[None, list]: If retrieval or parsing fails, returns None for email and empty list for attachment IDs 184 | """ 185 | try: 186 | # Fetch the complete message by ID 187 | message = self.service.users().messages().get( 188 | userId='me', 189 | id=email_id 190 | ).execute() 191 | 192 | # Parse the message with body included 193 | parsed_email = self._parse_message(txt=message, parse_body=True) 194 | 195 | if parsed_email is None: 196 | return None, {} 197 | 198 | attachments = {} 199 | # Check if 'parts' exists in payload before trying to access it 200 | if "payload" in message and "parts" in message["payload"]: 201 | for part in message["payload"]["parts"]: 202 | if "body" in part and "attachmentId" in part["body"]: 203 | attachment_id = part["body"]["attachmentId"] 204 | part_id = part["partId"] 205 | attachment = { 206 | "filename": part["filename"], 207 | "mimeType": part["mimeType"], 208 | "attachmentId": attachment_id, 209 | "partId": part_id 210 | } 211 | attachments[part_id] = attachment 212 | else: 213 | # Handle case when there are no parts (single part message) 214 | logging.info(f"Email {email_id} does not have 'parts' in payload (likely single part message)") 215 | if "payload" in message and "body" in message["payload"] and "attachmentId" in message["payload"]["body"]: 216 | # Handle potential attachment in single part message 217 | attachment_id = message["payload"]["body"]["attachmentId"] 218 | attachment = { 219 | "filename": message["payload"].get("filename", "attachment"), 220 | "mimeType": message["payload"].get("mimeType", "application/octet-stream"), 221 | "attachmentId": attachment_id, 222 | "partId": "0" 223 | } 224 | attachments["0"] = attachment 225 | 226 | return parsed_email, attachments 227 | 228 | except Exception as e: 229 | logging.error(f"Error retrieving email {email_id}: {str(e)}") 230 | logging.error(traceback.format_exc()) 231 | return None, {} 232 | 233 | def create_draft(self, to: str, subject: str, body: str, cc: list[str] | None = None) -> dict | None: 234 | """ 235 | Create a draft email message. 236 | 237 | Args: 238 | to (str): Email address of the recipient 239 | subject (str): Subject line of the email 240 | body (str): Body content of the email 241 | cc (list[str], optional): List of email addresses to CC 242 | 243 | Returns: 244 | dict: Draft message data including the draft ID if successful 245 | None: If creation fails 246 | """ 247 | try: 248 | # Create message body 249 | message = { 250 | 'to': to, 251 | 'subject': subject, 252 | 'text': body, 253 | } 254 | if cc: 255 | message['cc'] = ','.join(cc) 256 | 257 | # Create the message in MIME format 258 | mime_message = MIMEText(body) 259 | mime_message['to'] = to 260 | mime_message['subject'] = subject 261 | if cc: 262 | mime_message['cc'] = ','.join(cc) 263 | 264 | # Encode the message 265 | raw_message = base64.urlsafe_b64encode(mime_message.as_bytes()).decode('utf-8') 266 | 267 | # Create the draft 268 | draft = self.service.users().drafts().create( 269 | userId='me', 270 | body={ 271 | 'message': { 272 | 'raw': raw_message 273 | } 274 | } 275 | ).execute() 276 | 277 | return draft 278 | 279 | except Exception as e: 280 | logging.error(f"Error creating draft: {str(e)}") 281 | logging.error(traceback.format_exc()) 282 | return None 283 | 284 | def delete_draft(self, draft_id: str) -> bool: 285 | """ 286 | Delete a draft email message. 287 | 288 | Args: 289 | draft_id (str): The ID of the draft to delete 290 | 291 | Returns: 292 | bool: True if deletion was successful, False otherwise 293 | """ 294 | try: 295 | self.service.users().drafts().delete( 296 | userId='me', 297 | id=draft_id 298 | ).execute() 299 | return True 300 | 301 | except Exception as e: 302 | logging.error(f"Error deleting draft {draft_id}: {str(e)}") 303 | logging.error(traceback.format_exc()) 304 | return False 305 | 306 | def create_reply(self, original_message: dict, reply_body: str, send: bool = False, cc: list[str] | None = None) -> dict | None: 307 | """ 308 | Create a reply to an email message and either send it or save as draft. 309 | 310 | Args: 311 | original_message (dict): The original message data (as returned by get_email_by_id) 312 | reply_body (str): Body content of the reply 313 | send (bool): If True, sends the reply immediately. If False, saves as draft. 314 | cc (list[str], optional): List of email addresses to CC 315 | 316 | Returns: 317 | dict: Sent message or draft data if successful 318 | None: If operation fails 319 | """ 320 | try: 321 | to_address = original_message.get('from') 322 | if not to_address: 323 | raise ValueError("Could not determine original sender's address") 324 | 325 | subject = original_message.get('subject', '') 326 | if not subject.lower().startswith('re:'): 327 | subject = f"Re: {subject}" 328 | 329 | 330 | original_date = original_message.get('date', '') 331 | original_from = original_message.get('from', '') 332 | original_body = original_message.get('body', '') 333 | 334 | full_reply_body = ( 335 | f"{reply_body}\n\n" 336 | f"On {original_date}, {original_from} wrote:\n" 337 | f"> {original_body.replace('\n', '\n> ') if original_body else '[No message body]'}" 338 | ) 339 | 340 | mime_message = MIMEText(full_reply_body) 341 | mime_message['to'] = to_address 342 | mime_message['subject'] = subject 343 | if cc: 344 | mime_message['cc'] = ','.join(cc) 345 | 346 | mime_message['In-Reply-To'] = original_message.get('id', '') 347 | mime_message['References'] = original_message.get('id', '') 348 | 349 | raw_message = base64.urlsafe_b64encode(mime_message.as_bytes()).decode('utf-8') 350 | 351 | message_body = { 352 | 'raw': raw_message, 353 | 'threadId': original_message.get('threadId') # Ensure it's added to the same thread 354 | } 355 | 356 | if send: 357 | # Send the reply immediately 358 | result = self.service.users().messages().send( 359 | userId='me', 360 | body=message_body 361 | ).execute() 362 | else: 363 | # Save as draft 364 | result = self.service.users().drafts().create( 365 | userId='me', 366 | body={ 367 | 'message': message_body 368 | } 369 | ).execute() 370 | 371 | return result 372 | 373 | except Exception as e: 374 | logging.error(f"Error {'sending' if send else 'drafting'} reply: {str(e)}") 375 | logging.error(traceback.format_exc()) 376 | return None 377 | 378 | def get_attachment(self, message_id: str, attachment_id: str) -> dict | None: 379 | """ 380 | Retrieves a Gmail attachment by its ID. 381 | 382 | Args: 383 | message_id (str): The ID of the Gmail message containing the attachment 384 | attachment_id (str): The ID of the attachment to retrieve 385 | 386 | Returns: 387 | dict: Attachment data including filename and base64-encoded content 388 | None: If retrieval fails 389 | """ 390 | try: 391 | attachment = self.service.users().messages().attachments().get( 392 | userId='me', 393 | messageId=message_id, 394 | id=attachment_id 395 | ).execute() 396 | return { 397 | "size": attachment.get("size"), 398 | "data": attachment.get("data") 399 | } 400 | 401 | except Exception as e: 402 | logging.error(f"Error retrieving attachment {attachment_id} from message {message_id}: {str(e)}") 403 | logging.error(traceback.format_exc()) 404 | return None ``` -------------------------------------------------------------------------------- /src/mcp_gsuite/tools_gmail.py: -------------------------------------------------------------------------------- ```python 1 | from collections.abc import Sequence 2 | from mcp.types import ( 3 | Tool, 4 | TextContent, 5 | ImageContent, 6 | EmbeddedResource, 7 | LoggingLevel, 8 | ) 9 | from . import gmail 10 | import json 11 | from . import toolhandler 12 | import base64 13 | 14 | def decode_base64_data(file_data): 15 | standard_base64_data = file_data.replace("-", "+").replace("_", "/") 16 | missing_padding = len(standard_base64_data) % 4 17 | if missing_padding: 18 | standard_base64_data += '=' * (4 - missing_padding) 19 | return base64.b64decode(standard_base64_data, validate=True) 20 | 21 | class QueryEmailsToolHandler(toolhandler.ToolHandler): 22 | def __init__(self): 23 | super().__init__("query_gmail_emails") 24 | 25 | def get_tool_description(self) -> Tool: 26 | return Tool( 27 | name=self.name, 28 | description="""Query Gmail emails based on an optional search query. 29 | Returns emails in reverse chronological order (newest first). 30 | Returns metadata such as subject and also a short summary of the content. 31 | """, 32 | inputSchema={ 33 | "type": "object", 34 | "properties": { 35 | "__user_id__": self.get_user_id_arg_schema(), 36 | "query": { 37 | "type": "string", 38 | "description": """Gmail search query (optional). Examples: 39 | - a $string: Search email body, subject, and sender information for $string 40 | - 'is:unread' for unread emails 41 | - 'from:[email protected]' for emails from a specific sender 42 | - 'newer_than:2d' for emails from last 2 days 43 | - 'has:attachment' for emails with attachments 44 | If not provided, returns recent emails without filtering.""", 45 | "required": False 46 | }, 47 | "max_results": { 48 | "type": "integer", 49 | "description": "Maximum number of emails to retrieve (1-500)", 50 | "minimum": 1, 51 | "maximum": 500, 52 | "default": 100 53 | } 54 | }, 55 | "required": [toolhandler.USER_ID_ARG] 56 | } 57 | ) 58 | 59 | def run_tool(self, args: dict) -> Sequence[TextContent | ImageContent | EmbeddedResource]: 60 | 61 | user_id = args.get(toolhandler.USER_ID_ARG) 62 | if not user_id: 63 | raise RuntimeError(f"Missing required argument: {toolhandler.USER_ID_ARG}") 64 | 65 | gmail_service = gmail.GmailService(user_id=user_id) 66 | query = args.get('query') 67 | max_results = args.get('max_results', 100) 68 | emails = gmail_service.query_emails(query=query, max_results=max_results) 69 | 70 | return [ 71 | TextContent( 72 | type="text", 73 | text=json.dumps(emails, indent=2) 74 | ) 75 | ] 76 | 77 | class GetEmailByIdToolHandler(toolhandler.ToolHandler): 78 | def __init__(self): 79 | super().__init__("get_gmail_email") 80 | 81 | def get_tool_description(self) -> Tool: 82 | return Tool( 83 | name=self.name, 84 | description="Retrieves a complete Gmail email message by its ID, including the full message body and attachment IDs.", 85 | inputSchema={ 86 | "type": "object", 87 | "properties": { 88 | "__user_id__": self.get_user_id_arg_schema(), 89 | "email_id": { 90 | "type": "string", 91 | "description": "The ID of the Gmail message to retrieve" 92 | } 93 | }, 94 | "required": ["email_id", toolhandler.USER_ID_ARG] 95 | } 96 | ) 97 | 98 | def run_tool(self, args: dict) -> Sequence[TextContent | ImageContent | EmbeddedResource]: 99 | if "email_id" not in args: 100 | raise RuntimeError("Missing required argument: email_id") 101 | 102 | user_id = args.get(toolhandler.USER_ID_ARG) 103 | if not user_id: 104 | raise RuntimeError(f"Missing required argument: {toolhandler.USER_ID_ARG}") 105 | gmail_service = gmail.GmailService(user_id=user_id) 106 | email, attachments = gmail_service.get_email_by_id_with_attachments(args["email_id"]) 107 | 108 | if email is None: 109 | return [ 110 | TextContent( 111 | type="text", 112 | text=f"Failed to retrieve email with ID: {args['email_id']}" 113 | ) 114 | ] 115 | 116 | email["attachments"] = attachments 117 | 118 | return [ 119 | TextContent( 120 | type="text", 121 | text=json.dumps(email, indent=2) 122 | ) 123 | ] 124 | 125 | class BulkGetEmailsByIdsToolHandler(toolhandler.ToolHandler): 126 | def __init__(self): 127 | super().__init__("bulk_get_gmail_emails") 128 | 129 | def get_tool_description(self) -> Tool: 130 | return Tool( 131 | name=self.name, 132 | description="Retrieves multiple Gmail email messages by their IDs in a single request, including the full message bodies and attachment IDs.", 133 | inputSchema={ 134 | "type": "object", 135 | "properties": { 136 | "__user_id__": self.get_user_id_arg_schema(), 137 | "email_ids": { 138 | "type": "array", 139 | "items": { 140 | "type": "string" 141 | }, 142 | "description": "List of Gmail message IDs to retrieve" 143 | } 144 | }, 145 | "required": ["email_ids", toolhandler.USER_ID_ARG] 146 | } 147 | ) 148 | 149 | def run_tool(self, args: dict) -> Sequence[TextContent | ImageContent | EmbeddedResource]: 150 | if "email_ids" not in args: 151 | raise RuntimeError("Missing required argument: email_ids") 152 | 153 | user_id = args.get(toolhandler.USER_ID_ARG) 154 | if not user_id: 155 | raise RuntimeError(f"Missing required argument: {toolhandler.USER_ID_ARG}") 156 | gmail_service = gmail.GmailService(user_id=user_id) 157 | 158 | results = [] 159 | for email_id in args["email_ids"]: 160 | email, attachments = gmail_service.get_email_by_id_with_attachments(email_id) 161 | if email is not None: 162 | email["attachments"] = attachments 163 | results.append(email) 164 | 165 | if not results: 166 | return [ 167 | TextContent( 168 | type="text", 169 | text=f"Failed to retrieve any emails from the provided IDs" 170 | ) 171 | ] 172 | 173 | return [ 174 | TextContent( 175 | type="text", 176 | text=json.dumps(results, indent=2) 177 | ) 178 | ] 179 | 180 | class CreateDraftToolHandler(toolhandler.ToolHandler): 181 | def __init__(self): 182 | super().__init__("create_gmail_draft") 183 | 184 | def get_tool_description(self) -> Tool: 185 | return Tool( 186 | name=self.name, 187 | description="""Creates a draft email message from scratch in Gmail with specified recipient, subject, body, and optional CC recipients. 188 | 189 | Do NOT use this tool when you want to draft or send a REPLY to an existing message. This tool does NOT include any previous message content. Use the reply_gmail_email tool 190 | with send=False instead." 191 | """, 192 | inputSchema={ 193 | "type": "object", 194 | "properties": { 195 | "__user_id__": self.get_user_id_arg_schema(), 196 | "to": { 197 | "type": "string", 198 | "description": "Email address of the recipient" 199 | }, 200 | "subject": { 201 | "type": "string", 202 | "description": "Subject line of the email" 203 | }, 204 | "body": { 205 | "type": "string", 206 | "description": "Body content of the email" 207 | }, 208 | "cc": { 209 | "type": "array", 210 | "items": { 211 | "type": "string" 212 | }, 213 | "description": "Optional list of email addresses to CC" 214 | } 215 | }, 216 | "required": ["to", "subject", "body", toolhandler.USER_ID_ARG] 217 | } 218 | ) 219 | 220 | def run_tool(self, args: dict) -> Sequence[TextContent | ImageContent | EmbeddedResource]: 221 | required = ["to", "subject", "body"] 222 | if not all(key in args for key in required): 223 | raise RuntimeError(f"Missing required arguments: {', '.join(required)}") 224 | 225 | user_id = args.get(toolhandler.USER_ID_ARG) 226 | if not user_id: 227 | raise RuntimeError(f"Missing required argument: {toolhandler.USER_ID_ARG}") 228 | gmail_service = gmail.GmailService(user_id=user_id) 229 | draft = gmail_service.create_draft( 230 | to=args["to"], 231 | subject=args["subject"], 232 | body=args["body"], 233 | cc=args.get("cc") 234 | ) 235 | 236 | if draft is None: 237 | return [ 238 | TextContent( 239 | type="text", 240 | text="Failed to create draft email" 241 | ) 242 | ] 243 | 244 | return [ 245 | TextContent( 246 | type="text", 247 | text=json.dumps(draft, indent=2) 248 | ) 249 | ] 250 | 251 | class DeleteDraftToolHandler(toolhandler.ToolHandler): 252 | def __init__(self): 253 | super().__init__("delete_gmail_draft") 254 | 255 | def get_tool_description(self) -> Tool: 256 | return Tool( 257 | name=self.name, 258 | description="Deletes a Gmail draft message by its ID. This action cannot be undone.", 259 | inputSchema={ 260 | "type": "object", 261 | "properties": { 262 | "__user_id__": self.get_user_id_arg_schema(), 263 | "draft_id": { 264 | "type": "string", 265 | "description": "The ID of the draft to delete" 266 | } 267 | }, 268 | "required": ["draft_id", toolhandler.USER_ID_ARG] 269 | } 270 | ) 271 | 272 | def run_tool(self, args: dict) -> Sequence[TextContent | ImageContent | EmbeddedResource]: 273 | if "draft_id" not in args: 274 | raise RuntimeError("Missing required argument: draft_id") 275 | 276 | user_id = args.get(toolhandler.USER_ID_ARG) 277 | if not user_id: 278 | raise RuntimeError(f"Missing required argument: {toolhandler.USER_ID_ARG}") 279 | gmail_service = gmail.GmailService(user_id=user_id) 280 | success = gmail_service.delete_draft(args["draft_id"]) 281 | 282 | return [ 283 | TextContent( 284 | type="text", 285 | text="Successfully deleted draft" if success else f"Failed to delete draft with ID: {args['draft_id']}" 286 | ) 287 | ] 288 | 289 | class ReplyEmailToolHandler(toolhandler.ToolHandler): 290 | def __init__(self): 291 | super().__init__("reply_gmail_email") 292 | 293 | def get_tool_description(self) -> Tool: 294 | return Tool( 295 | name=self.name, 296 | description="""Creates a reply to an existing Gmail email message and either sends it or saves as draft. 297 | 298 | Use this tool if you want to draft a reply. Use the 'cc' argument if you want to perform a "reply all". 299 | """, 300 | inputSchema={ 301 | "type": "object", 302 | "properties": { 303 | "__user_id__": self.get_user_id_arg_schema(), 304 | "original_message_id": { 305 | "type": "string", 306 | "description": "The ID of the Gmail message to reply to" 307 | }, 308 | "reply_body": { 309 | "type": "string", 310 | "description": "The body content of your reply message" 311 | }, 312 | "send": { 313 | "type": "boolean", 314 | "description": "If true, sends the reply immediately. If false, saves as draft.", 315 | "default": False 316 | }, 317 | "cc": { 318 | "type": "array", 319 | "items": { 320 | "type": "string" 321 | }, 322 | "description": "Optional list of email addresses to CC on the reply" 323 | } 324 | }, 325 | "required": ["original_message_id", "reply_body", toolhandler.USER_ID_ARG] 326 | } 327 | ) 328 | 329 | def run_tool(self, args: dict) -> Sequence[TextContent | ImageContent | EmbeddedResource]: 330 | if not all(key in args for key in ["original_message_id", "reply_body"]): 331 | raise RuntimeError("Missing required arguments: original_message_id and reply_body") 332 | 333 | user_id = args.get(toolhandler.USER_ID_ARG) 334 | if not user_id: 335 | raise RuntimeError(f"Missing required argument: {toolhandler.USER_ID_ARG}") 336 | gmail_service = gmail.GmailService(user_id=user_id) 337 | 338 | # First get the original message to extract necessary information 339 | original_message = gmail_service.get_email_by_id(args["original_message_id"]) 340 | if original_message is None: 341 | return [ 342 | TextContent( 343 | type="text", 344 | text=f"Failed to retrieve original message with ID: {args['original_message_id']}" 345 | ) 346 | ] 347 | 348 | # Create and send/draft the reply 349 | result = gmail_service.create_reply( 350 | original_message=original_message, 351 | reply_body=args.get("reply_body", ""), 352 | send=args.get("send", False), 353 | cc=args.get("cc") 354 | ) 355 | 356 | if result is None: 357 | return [ 358 | TextContent( 359 | type="text", 360 | text=f"Failed to {'send' if args.get('send', True) else 'draft'} reply email" 361 | ) 362 | ] 363 | 364 | return [ 365 | TextContent( 366 | type="text", 367 | text=json.dumps(result, indent=2) 368 | ) 369 | ] 370 | 371 | class GetAttachmentToolHandler(toolhandler.ToolHandler): 372 | def __init__(self): 373 | super().__init__("get_gmail_attachment") 374 | 375 | def get_tool_description(self) -> Tool: 376 | return Tool( 377 | name=self.name, 378 | description="Retrieves a Gmail attachment by its ID.", 379 | inputSchema={ 380 | "type": "object", 381 | "properties": { 382 | "__user_id__": self.get_user_id_arg_schema(), 383 | "message_id": { 384 | "type": "string", 385 | "description": "The ID of the Gmail message containing the attachment" 386 | }, 387 | "attachment_id": { 388 | "type": "string", 389 | "description": "The ID of the attachment to retrieve" 390 | }, 391 | "mime_type": { 392 | "type": "string", 393 | "description": "The MIME type of the attachment" 394 | }, 395 | "filename": { 396 | "type": "string", 397 | "description": "The filename of the attachment" 398 | }, 399 | "save_to_disk": { 400 | "type": "string", 401 | "description": "The fullpath to save the attachment to disk. If not provided, the attachment is returned as a resource." 402 | } 403 | }, 404 | "required": ["message_id", "attachment_id", "mime_type", "filename", toolhandler.USER_ID_ARG] 405 | } 406 | ) 407 | 408 | def run_tool(self, args: dict) -> Sequence[TextContent | ImageContent | EmbeddedResource]: 409 | if "message_id" not in args: 410 | raise RuntimeError("Missing required argument: message_id") 411 | if "attachment_id" not in args: 412 | raise RuntimeError("Missing required argument: attachment_id") 413 | if "mime_type" not in args: 414 | raise RuntimeError("Missing required argument: mime_type") 415 | if "filename" not in args: 416 | raise RuntimeError("Missing required argument: filename") 417 | filename = args["filename"] 418 | mime_type = args["mime_type"] 419 | user_id = args.get(toolhandler.USER_ID_ARG) 420 | if not user_id: 421 | raise RuntimeError(f"Missing required argument: {toolhandler.USER_ID_ARG}") 422 | gmail_service = gmail.GmailService(user_id=user_id) 423 | attachment_data = gmail_service.get_attachment(args["message_id"], args["attachment_id"]) 424 | 425 | if attachment_data is None: 426 | return [ 427 | TextContent( 428 | type="text", 429 | text=f"Failed to retrieve attachment with ID: {args['attachment_id']} from message: {args['message_id']}" 430 | ) 431 | ] 432 | 433 | file_data = attachment_data["data"] 434 | attachment_url = f"attachment://gmail/{args['message_id']}/{args['attachment_id']}/{filename}" 435 | if args.get("save_to_disk"): 436 | decoded_data = decode_base64_data(file_data) 437 | with open(args["save_to_disk"], "wb") as f: 438 | f.write(decoded_data) 439 | return [ 440 | TextContent( 441 | type="text", 442 | text=f"Attachment saved to disk: {args['save_to_disk']}" 443 | ) 444 | ] 445 | return [ 446 | EmbeddedResource( 447 | type="resource", 448 | resource={ 449 | "blob": file_data, 450 | "uri": attachment_url, 451 | "mimeType": mime_type, 452 | }, 453 | ) 454 | ] 455 | 456 | class BulkSaveAttachmentsToolHandler(toolhandler.ToolHandler): 457 | def __init__(self): 458 | super().__init__("bulk_save_gmail_attachments") 459 | 460 | def get_tool_description(self) -> Tool: 461 | return Tool( 462 | name=self.name, 463 | description="Saves multiple Gmail attachments to disk by their message IDs and attachment IDs in a single request.", 464 | inputSchema={ 465 | "type": "object", 466 | "properties": { 467 | "__user_id__": self.get_user_id_arg_schema(), 468 | "attachments": { 469 | "type": "array", 470 | "items": { 471 | "type": "object", 472 | "properties": { 473 | "message_id": { 474 | "type": "string", 475 | "description": "ID of the Gmail message containing the attachment" 476 | }, 477 | "part_id": { 478 | "type": "string", 479 | "description": "ID of the part containing the attachment" 480 | }, 481 | "save_path": { 482 | "type": "string", 483 | "description": "Path where the attachment should be saved" 484 | } 485 | }, 486 | "required": ["message_id", "part_id", "save_path"] 487 | } 488 | } 489 | }, 490 | "required": ["attachments", toolhandler.USER_ID_ARG] 491 | } 492 | ) 493 | 494 | def run_tool(self, args: dict) -> Sequence[TextContent | ImageContent | EmbeddedResource]: 495 | if "attachments" not in args: 496 | raise RuntimeError("Missing required argument: attachments") 497 | 498 | user_id = args.get(toolhandler.USER_ID_ARG) 499 | if not user_id: 500 | raise RuntimeError(f"Missing required argument: {toolhandler.USER_ID_ARG}") 501 | 502 | gmail_service = gmail.GmailService(user_id=user_id) 503 | results = [] 504 | 505 | for attachment_info in args["attachments"]: 506 | # get attachment data from message_id and part_id 507 | message, attachments = gmail_service.get_email_by_id_with_attachments( 508 | attachment_info["message_id"] 509 | ) 510 | if message is None: 511 | results.append( 512 | TextContent( 513 | type="text", 514 | text=f"Failed to retrieve message with ID: {attachment_info['message_id']}" 515 | ) 516 | ) 517 | continue 518 | # get attachment_id from part_id 519 | attachment_id = attachments[attachment_info["part_id"]]["attachmentId"] 520 | attachment_data = gmail_service.get_attachment( 521 | attachment_info["message_id"], 522 | attachment_id 523 | ) 524 | if attachment_data is None: 525 | results.append( 526 | TextContent( 527 | type="text", 528 | text=f"Failed to retrieve attachment with ID: {attachment_id} from message: {attachment_info['message_id']}" 529 | ) 530 | ) 531 | continue 532 | 533 | file_data = attachment_data["data"] 534 | try: 535 | decoded_data = decode_base64_data(file_data) 536 | with open(attachment_info["save_path"], "wb") as f: 537 | f.write(decoded_data) 538 | results.append( 539 | TextContent( 540 | type="text", 541 | text=f"Attachment saved to: {attachment_info['save_path']}" 542 | ) 543 | ) 544 | except Exception as e: 545 | results.append( 546 | TextContent( 547 | type="text", 548 | text=f"Failed to save attachment to {attachment_info['save_path']}: {str(e)}" 549 | ) 550 | ) 551 | continue 552 | 553 | return results 554 | ``` -------------------------------------------------------------------------------- /google-calendar-api-openapi-spec.yaml: -------------------------------------------------------------------------------- ```yaml 1 | swagger: "2.0" 2 | info: 3 | title: Calendar 4 | description: Manipulates events and other calendar data. 5 | contact: 6 | name: Google 7 | url: https://google.com 8 | version: v3 9 | host: www.googleapis.com 10 | basePath: /calendar/v3 11 | schemes: 12 | - http 13 | produces: 14 | - application/json 15 | consumes: 16 | - application/json 17 | paths: 18 | /calendars: 19 | post: 20 | summary: Create Calendar 21 | description: Creates a secondary calendar 22 | operationId: calendar.calendars.insert 23 | parameters: 24 | - in: body 25 | name: body 26 | schema: 27 | $ref: '#/definitions/holder' 28 | responses: 29 | 200: 30 | description: OK 31 | tags: 32 | - Calendar 33 | /calendars/{calendarId}: 34 | delete: 35 | summary: CreaDeletete Calendar 36 | description: Deletes a secondary calendar 37 | operationId: calendar.calendars.delete 38 | parameters: 39 | - in: path 40 | name: calendarId 41 | description: Calendar identifier 42 | responses: 43 | 200: 44 | description: OK 45 | tags: 46 | - Calendar 47 | get: 48 | summary: Get Calendar 49 | description: Returns metadata for a calendar 50 | operationId: calendar.calendars.get 51 | parameters: 52 | - in: path 53 | name: calendarId 54 | description: Calendar identifier 55 | responses: 56 | 200: 57 | description: OK 58 | tags: 59 | - Calendar 60 | patch: 61 | summary: Update Calendar 62 | description: Updates metadata for a calendar 63 | operationId: calendar.calendars.patch 64 | parameters: 65 | - in: body 66 | name: body 67 | schema: 68 | $ref: '#/definitions/holder' 69 | - in: path 70 | name: calendarId 71 | description: Calendar identifier 72 | responses: 73 | 200: 74 | description: OK 75 | tags: 76 | - Calendar 77 | put: 78 | summary: Update Calendar 79 | description: Updates metadata for a calendar 80 | operationId: calendar.calendars.update 81 | parameters: 82 | - in: body 83 | name: body 84 | schema: 85 | $ref: '#/definitions/holder' 86 | - in: path 87 | name: calendarId 88 | description: Calendar identifier 89 | responses: 90 | 200: 91 | description: OK 92 | tags: 93 | - Calendar 94 | /calendars/{calendarId}/acl: 95 | get: 96 | summary: Get Calendar ACL 97 | description: Returns the rules in the access control list for the calendar 98 | operationId: calendar.acl.list 99 | parameters: 100 | - in: path 101 | name: calendarId 102 | description: Calendar identifier 103 | - in: query 104 | name: maxResults 105 | description: Maximum number of entries returned on one result page 106 | - in: query 107 | name: pageToken 108 | description: Token specifying which result page to return 109 | - in: query 110 | name: showDeleted 111 | description: Whether to include deleted ACLs in the result 112 | - in: query 113 | name: syncToken 114 | description: Token obtained from the nextSyncToken field returned on the last 115 | page of results from the previous list request 116 | responses: 117 | 200: 118 | description: OK 119 | tags: 120 | - Calendar ACL 121 | post: 122 | summary: Create Calendar ACL 123 | description: Creates an access control rule 124 | operationId: calendar.acl.insert 125 | parameters: 126 | - in: body 127 | name: body 128 | schema: 129 | $ref: '#/definitions/holder' 130 | - in: path 131 | name: calendarId 132 | description: Calendar identifier 133 | responses: 134 | 200: 135 | description: OK 136 | tags: 137 | - Calendar ACL 138 | /calendars/{calendarId}/acl/watch: 139 | post: 140 | summary: Watch Calendar ACL 141 | description: Watch for changes to ACL resources 142 | operationId: calendar.acl.watch 143 | parameters: 144 | - in: path 145 | name: calendarId 146 | description: Calendar identifier 147 | - in: query 148 | name: maxResults 149 | description: Maximum number of entries returned on one result page 150 | - in: query 151 | name: pageToken 152 | description: Token specifying which result page to return 153 | - in: body 154 | name: resource 155 | schema: 156 | $ref: '#/definitions/holder' 157 | - in: query 158 | name: showDeleted 159 | description: Whether to include deleted ACLs in the result 160 | - in: query 161 | name: syncToken 162 | description: Token obtained from the nextSyncToken field returned on the last 163 | page of results from the previous list request 164 | responses: 165 | 200: 166 | description: OK 167 | tags: 168 | - Calendar ACL 169 | /calendars/{calendarId}/acl/{ruleId}: 170 | delete: 171 | summary: Delete Calendar ACL 172 | description: Deletes an access control rule 173 | operationId: calendar.acl.delete 174 | parameters: 175 | - in: path 176 | name: calendarId 177 | description: Calendar identifier 178 | - in: path 179 | name: ruleId 180 | description: ACL rule identifier 181 | responses: 182 | 200: 183 | description: OK 184 | tags: 185 | - Calendar ACL 186 | get: 187 | summary: Get Calendar ACL 188 | description: Returns an access control rule 189 | operationId: calendar.acl.get 190 | parameters: 191 | - in: path 192 | name: calendarId 193 | description: Calendar identifier 194 | - in: path 195 | name: ruleId 196 | description: ACL rule identifier 197 | responses: 198 | 200: 199 | description: OK 200 | tags: 201 | - Calendar ACL 202 | patch: 203 | summary: Update Calendar ACL 204 | description: Updates an access control rule 205 | operationId: calendar.acl.patch 206 | parameters: 207 | - in: body 208 | name: body 209 | schema: 210 | $ref: '#/definitions/holder' 211 | - in: path 212 | name: calendarId 213 | description: Calendar identifier 214 | - in: path 215 | name: ruleId 216 | description: ACL rule identifier 217 | responses: 218 | 200: 219 | description: OK 220 | tags: 221 | - Calendar ACL 222 | put: 223 | summary: Update Calendar ACL 224 | description: Updates an access control rule 225 | operationId: calendar.acl.update 226 | parameters: 227 | - in: body 228 | name: body 229 | schema: 230 | $ref: '#/definitions/holder' 231 | - in: path 232 | name: calendarId 233 | description: Calendar identifier 234 | - in: path 235 | name: ruleId 236 | description: ACL rule identifier 237 | responses: 238 | 200: 239 | description: OK 240 | tags: 241 | - Calendar ACL 242 | /calendars/{calendarId}/clear: 243 | post: 244 | summary: Clear Primary Calendar 245 | description: Clears a primary calendar 246 | operationId: calendar.calendars.clear 247 | parameters: 248 | - in: path 249 | name: calendarId 250 | description: Calendar identifier 251 | responses: 252 | 200: 253 | description: OK 254 | tags: 255 | - Calendar 256 | /calendars/{calendarId}/events: 257 | get: 258 | summary: Get Events 259 | description: Returns events on the specified calendar 260 | operationId: calendar.events.list 261 | parameters: 262 | - in: query 263 | name: alwaysIncludeEmail 264 | description: Whether to always include a value in the email field for the 265 | organizer, creator and attendees, even if no real email is available (i 266 | - in: path 267 | name: calendarId 268 | description: Calendar identifier 269 | - in: query 270 | name: iCalUID 271 | description: Specifies event ID in the iCalendar format to be included in 272 | the response 273 | - in: query 274 | name: maxAttendees 275 | description: The maximum number of attendees to include in the response 276 | - in: query 277 | name: maxResults 278 | description: Maximum number of events returned on one result page 279 | - in: query 280 | name: orderBy 281 | description: The order of the events returned in the result 282 | - in: query 283 | name: pageToken 284 | description: Token specifying which result page to return 285 | - in: query 286 | name: privateExtendedProperty 287 | description: Extended properties constraint specified as propertyName=value 288 | - in: query 289 | name: q 290 | description: Free text search terms to find events that match these terms 291 | in any field, except for extended properties 292 | - in: query 293 | name: sharedExtendedProperty 294 | description: Extended properties constraint specified as propertyName=value 295 | - in: query 296 | name: showDeleted 297 | description: Whether to include deleted events (with status equals "cancelled") 298 | in the result 299 | - in: query 300 | name: showHiddenInvitations 301 | description: Whether to include hidden invitations in the result 302 | - in: query 303 | name: singleEvents 304 | description: Whether to expand recurring events into instances and only return 305 | single one-off events and instances of recurring events, but not the underlying 306 | recurring events themselves 307 | - in: query 308 | name: syncToken 309 | description: Token obtained from the nextSyncToken field returned on the last 310 | page of results from the previous list request 311 | - in: query 312 | name: timeMax 313 | description: Upper bound (exclusive) for an event's start time to filter by 314 | - in: query 315 | name: timeMin 316 | description: Lower bound (inclusive) for an event's end time to filter by 317 | - in: query 318 | name: timeZone 319 | description: Time zone used in the response 320 | - in: query 321 | name: updatedMin 322 | description: Lower bound for an event's last modification time (as a RFC3339 323 | timestamp) to filter by 324 | responses: 325 | 200: 326 | description: OK 327 | tags: 328 | - Event 329 | post: 330 | summary: Create Event 331 | description: Creates an event 332 | operationId: calendar.events.insert 333 | parameters: 334 | - in: body 335 | name: body 336 | schema: 337 | $ref: '#/definitions/holder' 338 | - in: path 339 | name: calendarId 340 | description: Calendar identifier 341 | - in: query 342 | name: maxAttendees 343 | description: The maximum number of attendees to include in the response 344 | - in: query 345 | name: sendNotifications 346 | description: Whether to send notifications about the creation of the new event 347 | - in: query 348 | name: supportsAttachments 349 | description: Whether API client performing operation supports event attachments 350 | responses: 351 | 200: 352 | description: OK 353 | tags: 354 | - Event 355 | /calendars/{calendarId}/events/import: 356 | post: 357 | summary: Import Event 358 | description: Imports an event 359 | operationId: calendar.events.import 360 | parameters: 361 | - in: body 362 | name: body 363 | schema: 364 | $ref: '#/definitions/holder' 365 | - in: path 366 | name: calendarId 367 | description: Calendar identifier 368 | - in: query 369 | name: supportsAttachments 370 | description: Whether API client performing operation supports event attachments 371 | responses: 372 | 200: 373 | description: OK 374 | tags: 375 | - Event 376 | /calendars/{calendarId}/events/quickAdd: 377 | post: 378 | summary: Create Event 379 | description: Creates an event based on a simple text string 380 | operationId: calendar.events.quickAdd 381 | parameters: 382 | - in: path 383 | name: calendarId 384 | description: Calendar identifier 385 | - in: query 386 | name: sendNotifications 387 | description: Whether to send notifications about the creation of the event 388 | - in: query 389 | name: text 390 | description: The text describing the event to be created 391 | responses: 392 | 200: 393 | description: OK 394 | tags: 395 | - Event 396 | /calendars/{calendarId}/events/watch: 397 | post: 398 | summary: Watch Event 399 | description: Watch for changes to Events resources 400 | operationId: calendar.events.watch 401 | parameters: 402 | - in: query 403 | name: alwaysIncludeEmail 404 | description: Whether to always include a value in the email field for the 405 | organizer, creator and attendees, even if no real email is available (i 406 | - in: path 407 | name: calendarId 408 | description: Calendar identifier 409 | - in: query 410 | name: iCalUID 411 | description: Specifies event ID in the iCalendar format to be included in 412 | the response 413 | - in: query 414 | name: maxAttendees 415 | description: The maximum number of attendees to include in the response 416 | - in: query 417 | name: maxResults 418 | description: Maximum number of events returned on one result page 419 | - in: query 420 | name: orderBy 421 | description: The order of the events returned in the result 422 | - in: query 423 | name: pageToken 424 | description: Token specifying which result page to return 425 | - in: query 426 | name: privateExtendedProperty 427 | description: Extended properties constraint specified as propertyName=value 428 | - in: query 429 | name: q 430 | description: Free text search terms to find events that match these terms 431 | in any field, except for extended properties 432 | - in: body 433 | name: resource 434 | schema: 435 | $ref: '#/definitions/holder' 436 | - in: query 437 | name: sharedExtendedProperty 438 | description: Extended properties constraint specified as propertyName=value 439 | - in: query 440 | name: showDeleted 441 | description: Whether to include deleted events (with status equals "cancelled") 442 | in the result 443 | - in: query 444 | name: showHiddenInvitations 445 | description: Whether to include hidden invitations in the result 446 | - in: query 447 | name: singleEvents 448 | description: Whether to expand recurring events into instances and only return 449 | single one-off events and instances of recurring events, but not the underlying 450 | recurring events themselves 451 | - in: query 452 | name: syncToken 453 | description: Token obtained from the nextSyncToken field returned on the last 454 | page of results from the previous list request 455 | - in: query 456 | name: timeMax 457 | description: Upper bound (exclusive) for an event's start time to filter by 458 | - in: query 459 | name: timeMin 460 | description: Lower bound (inclusive) for an event's end time to filter by 461 | - in: query 462 | name: timeZone 463 | description: Time zone used in the response 464 | - in: query 465 | name: updatedMin 466 | description: Lower bound for an event's last modification time (as a RFC3339 467 | timestamp) to filter by 468 | responses: 469 | 200: 470 | description: OK 471 | tags: 472 | - Event 473 | /calendars/{calendarId}/events/{eventId}: 474 | delete: 475 | summary: Delete Event 476 | description: Deletes an event 477 | operationId: calendar.events.delete 478 | parameters: 479 | - in: path 480 | name: calendarId 481 | description: Calendar identifier 482 | - in: path 483 | name: eventId 484 | description: Event identifier 485 | - in: query 486 | name: sendNotifications 487 | description: Whether to send notifications about the deletion of the event 488 | responses: 489 | 200: 490 | description: OK 491 | tags: 492 | - Event 493 | get: 494 | summary: Get Event 495 | description: Returns an event 496 | operationId: calendar.events.get 497 | parameters: 498 | - in: query 499 | name: alwaysIncludeEmail 500 | description: Whether to always include a value in the email field for the 501 | organizer, creator and attendees, even if no real email is available (i 502 | - in: path 503 | name: calendarId 504 | description: Calendar identifier 505 | - in: path 506 | name: eventId 507 | description: Event identifier 508 | - in: query 509 | name: maxAttendees 510 | description: The maximum number of attendees to include in the response 511 | - in: query 512 | name: timeZone 513 | description: Time zone used in the response 514 | responses: 515 | 200: 516 | description: OK 517 | tags: 518 | - Event 519 | patch: 520 | summary: Update Event 521 | description: Updates an event 522 | operationId: calendar.events.patch 523 | parameters: 524 | - in: query 525 | name: alwaysIncludeEmail 526 | description: Whether to always include a value in the email field for the 527 | organizer, creator and attendees, even if no real email is available (i 528 | - in: body 529 | name: body 530 | schema: 531 | $ref: '#/definitions/holder' 532 | - in: path 533 | name: calendarId 534 | description: Calendar identifier 535 | - in: path 536 | name: eventId 537 | description: Event identifier 538 | - in: query 539 | name: maxAttendees 540 | description: The maximum number of attendees to include in the response 541 | - in: query 542 | name: sendNotifications 543 | description: Whether to send notifications about the event update (e 544 | - in: query 545 | name: supportsAttachments 546 | description: Whether API client performing operation supports event attachments 547 | responses: 548 | 200: 549 | description: OK 550 | tags: 551 | - Event 552 | put: 553 | summary: Update Event 554 | description: Updates an event 555 | operationId: calendar.events.update 556 | parameters: 557 | - in: query 558 | name: alwaysIncludeEmail 559 | description: Whether to always include a value in the email field for the 560 | organizer, creator and attendees, even if no real email is available (i 561 | - in: body 562 | name: body 563 | schema: 564 | $ref: '#/definitions/holder' 565 | - in: path 566 | name: calendarId 567 | description: Calendar identifier 568 | - in: path 569 | name: eventId 570 | description: Event identifier 571 | - in: query 572 | name: maxAttendees 573 | description: The maximum number of attendees to include in the response 574 | - in: query 575 | name: sendNotifications 576 | description: Whether to send notifications about the event update (e 577 | - in: query 578 | name: supportsAttachments 579 | description: Whether API client performing operation supports event attachments 580 | responses: 581 | 200: 582 | description: OK 583 | tags: 584 | - Event 585 | /calendars/{calendarId}/events/{eventId}/instances: 586 | get: 587 | summary: Get Event Instance 588 | description: Returns instances of the specified recurring event 589 | operationId: calendar.events.instances 590 | parameters: 591 | - in: query 592 | name: alwaysIncludeEmail 593 | description: Whether to always include a value in the email field for the 594 | organizer, creator and attendees, even if no real email is available (i 595 | - in: path 596 | name: calendarId 597 | description: Calendar identifier 598 | - in: path 599 | name: eventId 600 | description: Recurring event identifier 601 | - in: query 602 | name: maxAttendees 603 | description: The maximum number of attendees to include in the response 604 | - in: query 605 | name: maxResults 606 | description: Maximum number of events returned on one result page 607 | - in: query 608 | name: originalStart 609 | description: The original start time of the instance in the result 610 | - in: query 611 | name: pageToken 612 | description: Token specifying which result page to return 613 | - in: query 614 | name: showDeleted 615 | description: Whether to include deleted events (with status equals "cancelled") 616 | in the result 617 | - in: query 618 | name: timeMax 619 | description: Upper bound (exclusive) for an event's start time to filter by 620 | - in: query 621 | name: timeMin 622 | description: Lower bound (inclusive) for an event's end time to filter by 623 | - in: query 624 | name: timeZone 625 | description: Time zone used in the response 626 | responses: 627 | 200: 628 | description: OK 629 | tags: 630 | - Event 631 | /calendars/{calendarId}/events/{eventId}/move: 632 | post: 633 | summary: Move Event 634 | description: Moves an event to another calendar, i 635 | operationId: calendar.events.move 636 | parameters: 637 | - in: path 638 | name: calendarId 639 | description: Calendar identifier of the source calendar where the event currently 640 | is on 641 | - in: query 642 | name: destination 643 | description: Calendar identifier of the target calendar where the event is 644 | to be moved to 645 | - in: path 646 | name: eventId 647 | description: Event identifier 648 | - in: query 649 | name: sendNotifications 650 | description: Whether to send notifications about the change of the event's 651 | organizer 652 | responses: 653 | 200: 654 | description: OK 655 | tags: 656 | - Event 657 | /channels/stop: 658 | post: 659 | summary: Stop Watching Resource 660 | description: Stop watching resources through this channel 661 | operationId: calendar.channels.stop 662 | parameters: 663 | - in: body 664 | name: resource 665 | schema: 666 | $ref: '#/definitions/holder' 667 | responses: 668 | 200: 669 | description: OK 670 | tags: 671 | - Watch 672 | /colors: 673 | get: 674 | summary: Get Colors 675 | description: Returns the color definitions for calendars and events 676 | operationId: calendar.colors.get 677 | responses: 678 | 200: 679 | description: OK 680 | tags: 681 | - Color 682 | /freeBusy: 683 | post: 684 | summary: Return Free/Busy Information 685 | description: Returns free/busy information for a set of calendars 686 | operationId: calendar.freebusy.query 687 | parameters: 688 | - in: body 689 | name: body 690 | schema: 691 | $ref: '#/definitions/holder' 692 | responses: 693 | 200: 694 | description: OK 695 | tags: 696 | - Free/Busy 697 | /users/me/calendarList: 698 | get: 699 | summary: Return Entries 700 | description: Returns entries on the user's calendar list 701 | operationId: calendar.calendarList.list 702 | parameters: 703 | - in: query 704 | name: maxResults 705 | description: Maximum number of entries returned on one result page 706 | - in: query 707 | name: minAccessRole 708 | description: The minimum access role for the user in the returned entries 709 | - in: query 710 | name: pageToken 711 | description: Token specifying which result page to return 712 | - in: query 713 | name: showDeleted 714 | description: Whether to include deleted calendar list entries in the result 715 | - in: query 716 | name: showHidden 717 | description: Whether to show hidden entries 718 | - in: query 719 | name: syncToken 720 | description: Token obtained from the nextSyncToken field returned on the last 721 | page of results from the previous list request 722 | responses: 723 | 200: 724 | description: OK 725 | tags: 726 | - Event 727 | post: 728 | summary: Add Entry 729 | description: Adds an entry to the user's calendar list 730 | operationId: calendar.calendarList.insert 731 | parameters: 732 | - in: body 733 | name: body 734 | schema: 735 | $ref: '#/definitions/holder' 736 | - in: query 737 | name: colorRgbFormat 738 | description: Whether to use the foregroundColor and backgroundColor fields 739 | to write the calendar colors (RGB) 740 | responses: 741 | 200: 742 | description: OK 743 | tags: 744 | - Event 745 | /users/me/calendarList/watch: 746 | post: 747 | summary: Watch Entry 748 | description: Watch for changes to CalendarList resources 749 | operationId: calendar.calendarList.watch 750 | parameters: 751 | - in: query 752 | name: maxResults 753 | description: Maximum number of entries returned on one result page 754 | - in: query 755 | name: minAccessRole 756 | description: The minimum access role for the user in the returned entries 757 | - in: query 758 | name: pageToken 759 | description: Token specifying which result page to return 760 | - in: body 761 | name: resource 762 | schema: 763 | $ref: '#/definitions/holder' 764 | - in: query 765 | name: showDeleted 766 | description: Whether to include deleted calendar list entries in the result 767 | - in: query 768 | name: showHidden 769 | description: Whether to show hidden entries 770 | - in: query 771 | name: syncToken 772 | description: Token obtained from the nextSyncToken field returned on the last 773 | page of results from the previous list request 774 | responses: 775 | 200: 776 | description: OK 777 | tags: 778 | - Event 779 | /users/me/calendarList/{calendarId}: 780 | delete: 781 | summary: Delete Entry 782 | description: Deletes an entry on the user's calendar list 783 | operationId: calendar.calendarList.delete 784 | parameters: 785 | - in: path 786 | name: calendarId 787 | description: Calendar identifier 788 | responses: 789 | 200: 790 | description: OK 791 | tags: 792 | - Event 793 | get: 794 | summary: Get Entry 795 | description: Returns an entry on the user's calendar list 796 | operationId: calendar.calendarList.get 797 | parameters: 798 | - in: path 799 | name: calendarId 800 | description: Calendar identifier 801 | responses: 802 | 200: 803 | description: OK 804 | tags: 805 | - Event 806 | patch: 807 | summary: Update Entry 808 | description: Updates an entry on the user's calendar list 809 | operationId: calendar.calendarList.patch 810 | parameters: 811 | - in: body 812 | name: body 813 | schema: 814 | $ref: '#/definitions/holder' 815 | - in: path 816 | name: calendarId 817 | description: Calendar identifier 818 | - in: query 819 | name: colorRgbFormat 820 | description: Whether to use the foregroundColor and backgroundColor fields 821 | to write the calendar colors (RGB) 822 | responses: 823 | 200: 824 | description: OK 825 | tags: 826 | - Event 827 | put: 828 | summary: Update Entry 829 | description: Updates an entry on the user's calendar list 830 | operationId: calendar.calendarList.update 831 | parameters: 832 | - in: body 833 | name: body 834 | schema: 835 | $ref: '#/definitions/holder' 836 | - in: path 837 | name: calendarId 838 | description: Calendar identifier 839 | - in: query 840 | name: colorRgbFormat 841 | description: Whether to use the foregroundColor and backgroundColor fields 842 | to write the calendar colors (RGB) 843 | responses: 844 | 200: 845 | description: OK 846 | tags: 847 | - Event 848 | /users/me/settings: 849 | get: 850 | summary: Get Settings 851 | description: Returns all user settings for the authenticated user 852 | operationId: calendar.settings.list 853 | parameters: 854 | - in: query 855 | name: maxResults 856 | description: Maximum number of entries returned on one result page 857 | - in: query 858 | name: pageToken 859 | description: Token specifying which result page to return 860 | - in: query 861 | name: syncToken 862 | description: Token obtained from the nextSyncToken field returned on the last 863 | page of results from the previous list request 864 | responses: 865 | 200: 866 | description: OK 867 | tags: 868 | - Setting 869 | /users/me/settings/watch: 870 | post: 871 | summary: Watch Settings 872 | description: Watch for changes to Settings resources 873 | operationId: calendar.settings.watch 874 | parameters: 875 | - in: query 876 | name: maxResults 877 | description: Maximum number of entries returned on one result page 878 | - in: query 879 | name: pageToken 880 | description: Token specifying which result page to return 881 | - in: body 882 | name: resource 883 | schema: 884 | $ref: '#/definitions/holder' 885 | - in: query 886 | name: syncToken 887 | description: Token obtained from the nextSyncToken field returned on the last 888 | page of results from the previous list request 889 | responses: 890 | 200: 891 | description: OK 892 | tags: 893 | - Setting 894 | /users/me/settings/{setting}: 895 | get: 896 | summary: Get Setting 897 | description: Returns a single user setting 898 | operationId: calendar.settings.get 899 | parameters: 900 | - in: path 901 | name: setting 902 | description: The id of the user setting 903 | responses: 904 | 200: 905 | description: OK 906 | tags: 907 | - Setting 908 | definitions: 909 | Acl: 910 | properties: 911 | etag: 912 | description: This is a default description. 913 | type: parameters 914 | items: 915 | description: This is a default description. 916 | type: parameters 917 | kind: 918 | description: This is a default description. 919 | type: parameters 920 | nextPageToken: 921 | description: This is a default description. 922 | type: parameters 923 | nextSyncToken: 924 | description: This is a default description. 925 | type: parameters 926 | AclRule: 927 | properties: 928 | etag: 929 | description: This is a default description. 930 | type: parameters 931 | id: 932 | description: This is a default description. 933 | type: parameters 934 | kind: 935 | description: This is a default description. 936 | type: parameters 937 | role: 938 | description: This is a default description. 939 | type: parameters 940 | scope: 941 | description: This is a default description. 942 | type: parameters 943 | Calendar: 944 | properties: 945 | description: 946 | description: This is a default description. 947 | type: parameters 948 | etag: 949 | description: This is a default description. 950 | type: parameters 951 | id: 952 | description: This is a default description. 953 | type: parameters 954 | kind: 955 | description: This is a default description. 956 | type: parameters 957 | location: 958 | description: This is a default description. 959 | type: parameters 960 | summary: 961 | description: This is a default description. 962 | type: parameters 963 | timeZone: 964 | description: This is a default description. 965 | type: parameters 966 | CalendarList: 967 | properties: 968 | etag: 969 | description: This is a default description. 970 | type: parameters 971 | items: 972 | description: This is a default description. 973 | type: parameters 974 | kind: 975 | description: This is a default description. 976 | type: parameters 977 | nextPageToken: 978 | description: This is a default description. 979 | type: parameters 980 | nextSyncToken: 981 | description: This is a default description. 982 | type: parameters 983 | CalendarListEntry: 984 | properties: 985 | accessRole: 986 | description: This is a default description. 987 | type: parameters 988 | backgroundColor: 989 | description: This is a default description. 990 | type: parameters 991 | colorId: 992 | description: This is a default description. 993 | type: parameters 994 | defaultReminders: 995 | description: This is a default description. 996 | type: parameters 997 | deleted: 998 | description: This is a default description. 999 | type: parameters 1000 | description: 1001 | description: This is a default description. 1002 | type: parameters 1003 | etag: 1004 | description: This is a default description. 1005 | type: parameters 1006 | foregroundColor: 1007 | description: This is a default description. 1008 | type: parameters 1009 | hidden: 1010 | description: This is a default description. 1011 | type: parameters 1012 | id: 1013 | description: This is a default description. 1014 | type: parameters 1015 | kind: 1016 | description: This is a default description. 1017 | type: parameters 1018 | location: 1019 | description: This is a default description. 1020 | type: parameters 1021 | notificationSettings: 1022 | description: This is a default description. 1023 | type: parameters 1024 | primary: 1025 | description: This is a default description. 1026 | type: parameters 1027 | selected: 1028 | description: This is a default description. 1029 | type: parameters 1030 | summary: 1031 | description: This is a default description. 1032 | type: parameters 1033 | summaryOverride: 1034 | description: This is a default description. 1035 | type: parameters 1036 | timeZone: 1037 | description: This is a default description. 1038 | type: parameters 1039 | CalendarNotification: 1040 | properties: 1041 | method: 1042 | description: This is a default description. 1043 | type: parameters 1044 | type: 1045 | description: This is a default description. 1046 | type: parameters 1047 | Channel: 1048 | properties: 1049 | address: 1050 | description: This is a default description. 1051 | type: parameters 1052 | expiration: 1053 | description: This is a default description. 1054 | type: parameters 1055 | id: 1056 | description: This is a default description. 1057 | type: parameters 1058 | kind: 1059 | description: This is a default description. 1060 | type: parameters 1061 | params: 1062 | description: This is a default description. 1063 | type: parameters 1064 | payload: 1065 | description: This is a default description. 1066 | type: parameters 1067 | resourceId: 1068 | description: This is a default description. 1069 | type: parameters 1070 | resourceUri: 1071 | description: This is a default description. 1072 | type: parameters 1073 | token: 1074 | description: This is a default description. 1075 | type: parameters 1076 | type: 1077 | description: This is a default description. 1078 | type: parameters 1079 | ColorDefinition: 1080 | properties: 1081 | background: 1082 | description: This is a default description. 1083 | type: parameters 1084 | foreground: 1085 | description: This is a default description. 1086 | type: parameters 1087 | Colors: 1088 | properties: 1089 | calendar: 1090 | description: This is a default description. 1091 | type: parameters 1092 | event: 1093 | description: This is a default description. 1094 | type: parameters 1095 | kind: 1096 | description: This is a default description. 1097 | type: parameters 1098 | updated: 1099 | description: This is a default description. 1100 | type: parameters 1101 | Error: 1102 | properties: 1103 | domain: 1104 | description: This is a default description. 1105 | type: parameters 1106 | reason: 1107 | description: This is a default description. 1108 | type: parameters 1109 | Event: 1110 | properties: 1111 | anyoneCanAddSelf: 1112 | description: This is a default description. 1113 | type: parameters 1114 | attachments: 1115 | description: This is a default description. 1116 | type: parameters 1117 | attendees: 1118 | description: This is a default description. 1119 | type: parameters 1120 | attendeesOmitted: 1121 | description: This is a default description. 1122 | type: parameters 1123 | colorId: 1124 | description: This is a default description. 1125 | type: parameters 1126 | created: 1127 | description: This is a default description. 1128 | type: parameters 1129 | creator: 1130 | description: This is a default description. 1131 | type: parameters 1132 | description: 1133 | description: This is a default description. 1134 | type: parameters 1135 | endTimeUnspecified: 1136 | description: This is a default description. 1137 | type: parameters 1138 | etag: 1139 | description: This is a default description. 1140 | type: parameters 1141 | extendedProperties: 1142 | description: This is a default description. 1143 | type: parameters 1144 | gadget: 1145 | description: This is a default description. 1146 | type: parameters 1147 | guestsCanInviteOthers: 1148 | description: This is a default description. 1149 | type: parameters 1150 | guestsCanModify: 1151 | description: This is a default description. 1152 | type: parameters 1153 | guestsCanSeeOtherGuests: 1154 | description: This is a default description. 1155 | type: parameters 1156 | hangoutLink: 1157 | description: This is a default description. 1158 | type: parameters 1159 | htmlLink: 1160 | description: This is a default description. 1161 | type: parameters 1162 | iCalUID: 1163 | description: This is a default description. 1164 | type: parameters 1165 | id: 1166 | description: This is a default description. 1167 | type: parameters 1168 | kind: 1169 | description: This is a default description. 1170 | type: parameters 1171 | location: 1172 | description: This is a default description. 1173 | type: parameters 1174 | locked: 1175 | description: This is a default description. 1176 | type: parameters 1177 | organizer: 1178 | description: This is a default description. 1179 | type: parameters 1180 | privateCopy: 1181 | description: This is a default description. 1182 | type: parameters 1183 | recurrence: 1184 | description: This is a default description. 1185 | type: parameters 1186 | recurringEventId: 1187 | description: This is a default description. 1188 | type: parameters 1189 | reminders: 1190 | description: This is a default description. 1191 | type: parameters 1192 | sequence: 1193 | description: This is a default description. 1194 | type: parameters 1195 | source: 1196 | description: This is a default description. 1197 | type: parameters 1198 | status: 1199 | description: This is a default description. 1200 | type: parameters 1201 | summary: 1202 | description: This is a default description. 1203 | type: parameters 1204 | transparency: 1205 | description: This is a default description. 1206 | type: parameters 1207 | updated: 1208 | description: This is a default description. 1209 | type: parameters 1210 | visibility: 1211 | description: This is a default description. 1212 | type: parameters 1213 | EventAttachment: 1214 | properties: 1215 | fileId: 1216 | description: This is a default description. 1217 | type: parameters 1218 | fileUrl: 1219 | description: This is a default description. 1220 | type: parameters 1221 | iconLink: 1222 | description: This is a default description. 1223 | type: parameters 1224 | mimeType: 1225 | description: This is a default description. 1226 | type: parameters 1227 | title: 1228 | description: This is a default description. 1229 | type: parameters 1230 | EventAttendee: 1231 | properties: 1232 | additionalGuests: 1233 | description: This is a default description. 1234 | type: parameters 1235 | comment: 1236 | description: This is a default description. 1237 | type: parameters 1238 | displayName: 1239 | description: This is a default description. 1240 | type: parameters 1241 | email: 1242 | description: This is a default description. 1243 | type: parameters 1244 | id: 1245 | description: This is a default description. 1246 | type: parameters 1247 | optional: 1248 | description: This is a default description. 1249 | type: parameters 1250 | organizer: 1251 | description: This is a default description. 1252 | type: parameters 1253 | resource: 1254 | description: This is a default description. 1255 | type: parameters 1256 | responseStatus: 1257 | description: This is a default description. 1258 | type: parameters 1259 | self: 1260 | description: This is a default description. 1261 | type: parameters 1262 | EventDateTime: 1263 | properties: 1264 | date: 1265 | description: This is a default description. 1266 | type: parameters 1267 | dateTime: 1268 | description: This is a default description. 1269 | type: parameters 1270 | timeZone: 1271 | description: This is a default description. 1272 | type: parameters 1273 | EventReminder: 1274 | properties: 1275 | method: 1276 | description: This is a default description. 1277 | type: parameters 1278 | minutes: 1279 | description: This is a default description. 1280 | type: parameters 1281 | Events: 1282 | properties: 1283 | accessRole: 1284 | description: This is a default description. 1285 | type: parameters 1286 | defaultReminders: 1287 | description: This is a default description. 1288 | type: parameters 1289 | description: 1290 | description: This is a default description. 1291 | type: parameters 1292 | etag: 1293 | description: This is a default description. 1294 | type: parameters 1295 | items: 1296 | description: This is a default description. 1297 | type: parameters 1298 | kind: 1299 | description: This is a default description. 1300 | type: parameters 1301 | nextPageToken: 1302 | description: This is a default description. 1303 | type: parameters 1304 | nextSyncToken: 1305 | description: This is a default description. 1306 | type: parameters 1307 | summary: 1308 | description: This is a default description. 1309 | type: parameters 1310 | timeZone: 1311 | description: This is a default description. 1312 | type: parameters 1313 | updated: 1314 | description: This is a default description. 1315 | type: parameters 1316 | FreeBusyCalendar: 1317 | properties: 1318 | busy: 1319 | description: This is a default description. 1320 | type: parameters 1321 | errors: 1322 | description: This is a default description. 1323 | type: parameters 1324 | FreeBusyGroup: 1325 | properties: 1326 | calendars: 1327 | description: This is a default description. 1328 | type: parameters 1329 | errors: 1330 | description: This is a default description. 1331 | type: parameters 1332 | FreeBusyRequest: 1333 | properties: 1334 | calendarExpansionMax: 1335 | description: This is a default description. 1336 | type: parameters 1337 | groupExpansionMax: 1338 | description: This is a default description. 1339 | type: parameters 1340 | items: 1341 | description: This is a default description. 1342 | type: parameters 1343 | timeMax: 1344 | description: This is a default description. 1345 | type: parameters 1346 | timeMin: 1347 | description: This is a default description. 1348 | type: parameters 1349 | timeZone: 1350 | description: This is a default description. 1351 | type: parameters 1352 | FreeBusyRequestItem: 1353 | properties: 1354 | id: 1355 | description: This is a default description. 1356 | type: parameters 1357 | FreeBusyResponse: 1358 | properties: 1359 | calendars: 1360 | description: This is a default description. 1361 | type: parameters 1362 | groups: 1363 | description: This is a default description. 1364 | type: parameters 1365 | kind: 1366 | description: This is a default description. 1367 | type: parameters 1368 | timeMax: 1369 | description: This is a default description. 1370 | type: parameters 1371 | timeMin: 1372 | description: This is a default description. 1373 | type: parameters 1374 | Setting: 1375 | properties: 1376 | etag: 1377 | description: This is a default description. 1378 | type: parameters 1379 | id: 1380 | description: This is a default description. 1381 | type: parameters 1382 | kind: 1383 | description: This is a default description. 1384 | type: parameters 1385 | value: 1386 | description: This is a default description. 1387 | type: parameters 1388 | Settings: 1389 | properties: 1390 | etag: 1391 | description: This is a default description. 1392 | type: parameters 1393 | items: 1394 | description: This is a default description. 1395 | type: parameters 1396 | kind: 1397 | description: This is a default description. 1398 | type: parameters 1399 | nextPageToken: 1400 | description: This is a default description. 1401 | type: parameters 1402 | nextSyncToken: 1403 | description: This is a default description. 1404 | type: parameters 1405 | TimePeriod: 1406 | properties: 1407 | end: 1408 | description: This is a default description. 1409 | type: parameters 1410 | start: 1411 | description: This is a default description. 1412 | type: parameters ```