This is page 1 of 6. Use http://codebase.md/nspady/google-calendar-mcp?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .cursorignore
├── .dockerignore
├── .env.example
├── .github
│ └── workflows
│ ├── ci.yml
│ ├── publish.yml
│ └── README.md
├── .gitignore
├── .release-please-manifest.json
├── AGENTS.md
├── CHANGELOG.md
├── CLAUDE.md
├── docker-compose.yml
├── Dockerfile
├── docs
│ ├── advanced-usage.md
│ ├── architecture.md
│ ├── authentication.md
│ ├── deployment.md
│ ├── development.md
│ ├── docker.md
│ ├── README.md
│ └── testing.md
├── examples
│ ├── http-client.js
│ └── http-with-curl.sh
├── future_features
│ └── ARCHITECTURE_REDESIGN.md
├── gcp-oauth.keys.example.json
├── instructions
│ └── file_structure.md
├── LICENSE
├── package-lock.json
├── package.json
├── README.md
├── scripts
│ ├── account-manager.js
│ ├── build.js
│ ├── dev.js
│ └── test-docker.sh
├── src
│ ├── auth
│ │ ├── client.ts
│ │ ├── paths.d.ts
│ │ ├── paths.js
│ │ ├── server.ts
│ │ ├── tokenManager.ts
│ │ └── utils.ts
│ ├── auth-server.ts
│ ├── config
│ │ └── TransportConfig.ts
│ ├── handlers
│ │ ├── core
│ │ │ ├── BaseToolHandler.ts
│ │ │ ├── BatchRequestHandler.ts
│ │ │ ├── CreateEventHandler.ts
│ │ │ ├── DeleteEventHandler.ts
│ │ │ ├── FreeBusyEventHandler.ts
│ │ │ ├── GetCurrentTimeHandler.ts
│ │ │ ├── GetEventHandler.ts
│ │ │ ├── ListCalendarsHandler.ts
│ │ │ ├── ListColorsHandler.ts
│ │ │ ├── ListEventsHandler.ts
│ │ │ ├── RecurringEventHelpers.ts
│ │ │ ├── SearchEventsHandler.ts
│ │ │ └── UpdateEventHandler.ts
│ │ ├── utils
│ │ │ └── datetime.ts
│ │ └── utils.ts
│ ├── index.ts
│ ├── schemas
│ │ └── types.ts
│ ├── server.ts
│ ├── services
│ │ └── conflict-detection
│ │ ├── config.ts
│ │ ├── ConflictAnalyzer.ts
│ │ ├── ConflictDetectionService.ts
│ │ ├── EventSimilarityChecker.ts
│ │ ├── index.ts
│ │ └── types.ts
│ ├── tests
│ │ ├── integration
│ │ │ ├── claude-mcp-integration.test.ts
│ │ │ ├── direct-integration.test.ts
│ │ │ ├── docker-integration.test.ts
│ │ │ ├── openai-mcp-integration.test.ts
│ │ │ └── test-data-factory.ts
│ │ └── unit
│ │ ├── console-statements.test.ts
│ │ ├── handlers
│ │ │ ├── BatchListEvents.test.ts
│ │ │ ├── BatchRequestHandler.test.ts
│ │ │ ├── CalendarNameResolution.test.ts
│ │ │ ├── create-event-blocking.test.ts
│ │ │ ├── CreateEventHandler.test.ts
│ │ │ ├── datetime-utils.test.ts
│ │ │ ├── duplicate-event-display.test.ts
│ │ │ ├── GetCurrentTimeHandler.test.ts
│ │ │ ├── GetEventHandler.test.ts
│ │ │ ├── list-events-registry.test.ts
│ │ │ ├── ListEventsHandler.test.ts
│ │ │ ├── RecurringEventHelpers.test.ts
│ │ │ ├── UpdateEventHandler.recurring.test.ts
│ │ │ ├── UpdateEventHandler.test.ts
│ │ │ ├── utils-conflict-format.test.ts
│ │ │ └── utils.test.ts
│ │ ├── index.test.ts
│ │ ├── schemas
│ │ │ ├── enhanced-properties.test.ts
│ │ │ ├── no-refs.test.ts
│ │ │ ├── schema-compatibility.test.ts
│ │ │ ├── tool-registration.test.ts
│ │ │ └── validators.test.ts
│ │ ├── services
│ │ │ └── conflict-detection
│ │ │ ├── ConflictAnalyzer.test.ts
│ │ │ └── EventSimilarityChecker.test.ts
│ │ └── utils
│ │ ├── event-id-validator.test.ts
│ │ └── field-mask-builder.test.ts
│ ├── tools
│ │ └── registry.ts
│ ├── transports
│ │ ├── http.ts
│ │ └── stdio.ts
│ ├── types
│ │ └── structured-responses.ts
│ └── utils
│ ├── event-id-validator.ts
│ ├── field-mask-builder.ts
│ └── response-builder.ts
├── tsconfig.json
├── tsconfig.lint.json
└── vitest.config.ts
```
# Files
--------------------------------------------------------------------------------
/.release-please-manifest.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | ".": "2.0.6"
3 | }
4 |
```
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
```
1 | gcp-oauth.keys.json
2 | .gcp-saved-tokens.json
```
--------------------------------------------------------------------------------
/.cursorignore:
--------------------------------------------------------------------------------
```
1 | .gcp-saved-tokens.json
2 | gcp-oauth.keys.json
3 |
```
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
```
1 | node_modules/
2 | build/
3 | *.log
4 | gcp-oauth.keys.json
5 | .gcp-saved-tokens.json
6 | coverage/
7 | .nyc_output/
8 | coverage/
9 | settings.local.json
10 | .env
11 |
```
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
```
1 | # Google Calendar MCP Server Configuration
2 | # Transport mode
3 | TRANSPORT=stdio # Recommended for Claude Desktop integration
4 |
5 | # HTTP settings (when TRANSPORT=http)
6 | PORT=3000
7 | HOST=0.0.0.0
8 |
9 | # Development
10 | DEBUG=false
11 | NODE_ENV=production
12 |
13 | # OAuth credentials path (required)
14 | GOOGLE_OAUTH_CREDENTIALS=./gcp-oauth.keys.json
15 |
16 | ## Optional: Custom token storage location
17 | # GOOGLE_CALENDAR_MCP_TOKEN_PATH=/custom/path/to/tokens
18 |
19 | ## OPTIONAL: Test Configuration (for development/testing only)
20 | # [email protected]
21 | # SEND_UPDATES=none
22 | # AUTO_CLEANUP=true
23 |
24 | ## OPTIONAL: Anthropic API config (used only for integration testing)
25 | # ANTHROPIC_MODEL=claude-3-5-haiku-20241022
26 | # CLAUDE_API_KEY={your_api_key}
27 |
28 | # OPTIONAL: Open AI API config (used only for integration testing)
29 | # OPENAI_API_KEY={your_api_key}
30 | # OPENAI_MODEL=gpt-4.1
```
--------------------------------------------------------------------------------
/docs/README.md:
--------------------------------------------------------------------------------
```markdown
1 | # Documentation Index
2 |
3 | Welcome to the Google Calendar MCP Server documentation.
4 |
5 | ## Getting Started
6 |
7 | - [Main README](../README.md) - Quick start guide and overview
8 | - [Authentication Setup](authentication.md) - Detailed Google Cloud setup instructions
9 |
10 | ## User Guides
11 |
12 | - [Advanced Usage](advanced-usage.md) - Multi-account, batch operations, smart scheduling
13 | - [Troubleshooting](troubleshooting.md) - Common issues and solutions
14 |
15 | ## Deployment
16 |
17 | - [Deployment Guide](deployment.md) - HTTP transport, Docker, cloud deployment
18 |
19 | ## Development
20 |
21 | - [Development Guide](development.md) - Contributing and development setup
22 | - [Architecture Overview](architecture.md) - Technical architecture details
23 | - [Testing Guide](testing.md) - Running and writing tests
24 | - [Development Scripts](development-scripts.md) - Using the dev command system
25 |
26 | ## Reference
27 |
28 | - [API Documentation](https://developers.google.com/calendar/api/v3/reference) - Google Calendar API
29 | - [MCP Specification](https://modelcontextprotocol.io/docs) - Model Context Protocol
30 |
31 | ## Quick Links
32 |
33 | ### For Users
34 | 1. Start with the [Main README](../README.md)
35 | 2. Follow [Authentication Setup](authentication.md)
36 | 3. Check [Troubleshooting](troubleshooting.md) if needed
37 |
38 | ### For Developers
39 | 1. Read [Architecture Overview](architecture.md)
40 | 2. Set up with [Development Guide](development.md)
41 | 3. Run tests with [Testing Guide](testing.md)
42 |
43 | ### For Deployment
44 | 1. Review [Deployment Guide](deployment.md)
45 | 2. Check security considerations
46 | 3. Set up monitoring
47 |
48 | ## Need Help?
49 |
50 | - [GitHub Issues](https://github.com/nspady/google-calendar-mcp/issues)
51 | - [GitHub Discussions](https://github.com/nspady/google-calendar-mcp/discussions)
```
--------------------------------------------------------------------------------
/.github/workflows/README.md:
--------------------------------------------------------------------------------
```markdown
1 | # GitHub Actions Workflows
2 |
3 | This directory contains automated CI/CD workflows for the Google Calendar MCP project.
4 |
5 | ## Workflows
6 |
7 | ### 1. `schema-validation.yml` - Schema Validation and Tests
8 | **Triggers**: Push/PR to main or develop branches
9 |
10 | Simple workflow focused on schema validation and basic testing:
11 | - Builds the project
12 | - Validates MCP schemas for compatibility
13 | - Runs schema-specific tests
14 | - Runs unit tests via `npm run dev test`
15 |
16 | ### 2. `ci.yml` - Comprehensive CI Pipeline
17 | **Triggers**: Push/PR to main, develop, or feature branches
18 |
19 | Full CI pipeline with multiple jobs running in parallel/sequence:
20 |
21 | #### Jobs:
22 | 1. **code-quality**:
23 | - Checks for console.log statements
24 | - Ensures code follows best practices
25 |
26 | 2. **build-and-validate**:
27 | - Builds the project
28 | - Validates MCP schemas
29 | - Uploads build artifacts
30 |
31 | 3. **unit-tests**:
32 | - Runs on multiple Node.js versions (18, 20)
33 | - Executes all unit tests
34 | - Runs schema compatibility tests
35 |
36 | 4. **integration-tests** (optional):
37 | - Only runs on main branch or PRs
38 | - Executes direct integration tests
39 | - Continues on error to not block CI
40 |
41 | 5. **coverage**:
42 | - Generates test coverage reports
43 | - Uploads coverage artifacts
44 |
45 | ## Running Locally
46 |
47 | To test workflows locally before pushing:
48 |
49 | ```bash
50 | # Run schema validation
51 | npm run dev validate-schemas
52 |
53 | # Run dev tests (unit tests only)
54 | npm run dev test
55 |
56 | # Run all tests
57 | npm test
58 |
59 | # Run with coverage
60 | npm run dev coverage
61 | ```
62 |
63 | ## Environment Variables
64 |
65 | All workflows set `NODE_ENV=test` to:
66 | - Use test account credentials
67 | - Skip authentication prompts
68 | - Enable test-specific behavior
69 |
70 | ## Best Practices
71 |
72 | 1. **Always run `npm run dev test` before pushing** - catches most issues quickly
73 | 2. **Schema changes** - Run `npm run validate-schemas` to ensure compatibility
74 | 3. **Console statements** - Use `process.stderr.write()` instead of `console.log()`
75 | 4. **Integration tests** - These may fail in CI due to API limits; that's OK
76 |
77 | ## Troubleshooting
78 |
79 | ### Schema validation fails
80 | - Check for `oneOf`, `anyOf`, `allOf` in tool schemas
81 | - Ensure datetime fields have proper format and timezone info
82 | - Run `npm run dev validate-schemas` locally
83 |
84 | ### Unit tests fail
85 | - Run `npm run dev test` locally
86 | - Check for recent schema changes that might affect tests
87 | - Ensure all console.log statements are removed
88 |
89 | ### Integration tests fail
90 | - These are marked as `continue-on-error` in CI
91 | - Usually due to API rate limits or authentication issues
92 | - Can be ignored if unit tests pass
93 |
94 | ## Adding New Workflows
95 |
96 | When adding new workflows:
97 | 1. Test locally first
98 | 2. Use matrix builds for multiple versions
99 | 3. Set appropriate environment variables
100 | 4. Consider job dependencies and parallelization
101 | 5. Add documentation here
```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
1 | # Google Calendar MCP Server
2 |
3 | A Model Context Protocol (MCP) server that provides Google Calendar integration for AI assistants like Claude.
4 |
5 | ## Features
6 |
7 | - **Multi-Calendar Support**: List events from multiple calendars simultaneously
8 | - **Event Management**: Create, update, delete, and search calendar events
9 | - **Recurring Events**: Advanced modification capabilities for recurring events
10 | - **Free/Busy Queries**: Check availability across calendars
11 | - **Smart Scheduling**: Natural language understanding for dates and times
12 | - **Inteligent Import**: Add calendar events from images, PDFs or web links
13 |
14 | ## Quick Start
15 |
16 | ### Prerequisites
17 |
18 | 1. A Google Cloud project with the Calendar API enabled
19 | 2. OAuth 2.0 credentials (Desktop app type)
20 |
21 | ### Google Cloud Setup
22 |
23 | 1. Go to the [Google Cloud Console](https://console.cloud.google.com)
24 | 2. Create a new project or select an existing one.
25 | 3. Enable the [Google Calendar API](https://console.cloud.google.com/apis/library/calendar-json.googleapis.com) for your project. Ensure that the right project is selected from the top bar before enabling the API.
26 | 4. Create OAuth 2.0 credentials:
27 | - Go to Credentials
28 | - Click "Create Credentials" > "OAuth client ID"
29 | - Choose "User data" for the type of data that the app will be accessing
30 | - Add your app name and contact information
31 | - Add the following scopes (optional):
32 | - `https://www.googleapis.com/auth/calendar.events` and `https://www.googleapis.com/auth/calendar`
33 | - Select "Desktop app" as the application type (Important!)
34 | - Save the auth key, you'll need to add its path to the JSON in the next step
35 | - Add your email address as a test user under the [Audience screen](https://console.cloud.google.com/auth/audience)
36 | - Note: it might take a few minutes for the test user to be added. The OAuth consent will not allow you to proceed until the test user has propagated.
37 | - Note about test mode: While an app is in test mode the auth tokens will expire after 1 week and need to be refreshed (see Re-authentication section below).
38 |
39 | ### Installation
40 |
41 | **Option 1: Use with npx (Recommended)**
42 |
43 | Add to your Claude Desktop configuration:
44 |
45 | **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`
46 | **Windows**: `%APPDATA%\Claude\claude_desktop_config.json`
47 | ```json
48 | {
49 | "mcpServers": {
50 | "google-calendar": {
51 | "command": "npx",
52 | "args": ["@cocal/google-calendar-mcp"],
53 | "env": {
54 | "GOOGLE_OAUTH_CREDENTIALS": "/path/to/your/gcp-oauth.keys.json"
55 | }
56 | }
57 | }
58 | }
59 | ```
60 |
61 | **⚠️ Important Note for npx Users**: When using npx, you **must** specify the credentials file path using the `GOOGLE_OAUTH_CREDENTIALS` environment variable.
62 |
63 | **Option 2: Local Installation**
64 |
65 | ```bash
66 | git clone https://github.com/nspady/google-calendar-mcp.git
67 | cd google-calendar-mcp
68 | npm install
69 | npm run build
70 | ```
71 |
72 | Then add to Claude Desktop config using the local path or by specifying the path with the `GOOGLE_OAUTH_CREDENTIALS` environment variable.
73 |
74 | **Option 3: Docker Installation**
75 |
76 | ```bash
77 | git clone https://github.com/nspady/google-calendar-mcp.git
78 | cd google-calendar-mcp
79 | cp /path/to/your/gcp-oauth.keys.json .
80 | docker compose up
81 | ```
82 |
83 | See the [Docker deployment guide](docs/docker.md) for detailed configuration options including HTTP transport mode.
84 |
85 | ### First Run
86 |
87 | 1. Start Claude Desktop
88 | 2. The server will prompt for authentication on first use
89 | 3. Complete the OAuth flow in your browser
90 | 4. You're ready to use calendar features!
91 |
92 | ### Re-authentication
93 |
94 | If you're in test mode (default), tokens expire after 7 days. If you are using a client like Claude Desktop it should open up a browser window to automatically re-auth. However, if you see authentication errors you can also resolve by following these steps:
95 |
96 | **For npx users:**
97 | ```bash
98 | export GOOGLE_OAUTH_CREDENTIALS="/path/to/your/gcp-oauth.keys.json"
99 | npx @cocal/google-calendar-mcp auth
100 | ```
101 |
102 | **For local installation:**
103 | ```bash
104 | npm run auth
105 | ```
106 |
107 | **To avoid weekly re-authentication**, publish your app to production mode (without verification):
108 | 1. Go to Google Cloud Console → "APIs & Services" → "OAuth consent screen"
109 | 2. Click "PUBLISH APP" and confirm
110 | 3. Your tokens will no longer expire after 7 days but Google will show a more threatning warning when connecting to the app about it being unverified.
111 |
112 | See [Authentication Guide](docs/authentication.md#moving-to-production-mode-recommended) for details.
113 |
114 | ## Example Usage
115 |
116 | Along with the normal capabilities you would expect for a calendar integration you can also do really dynamic, multi-step processes like:
117 |
118 | 1. **Cross-calendar availability**:
119 | ```
120 | Please provide availability looking at both my personal and work calendar for this upcoming week.
121 | I am looking for a good time to meet with someone in London for 1 hr.
122 | ```
123 |
124 | 2. Add events from screenshots, images and other data sources:
125 | ```
126 | Add this event to my calendar based on the attached screenshot.
127 | ```
128 | Supported image formats: PNG, JPEG, GIF
129 | Images can contain event details like date, time, location, and description
130 |
131 | 3. Calendar analysis:
132 | ```
133 | What events do I have coming up this week that aren't part of my usual routine?
134 | ```
135 | 4. Check attendance:
136 | ```
137 | Which events tomorrow have attendees who have not accepted the invitation?
138 | ```
139 | 5. Auto coordinate events:
140 | ```
141 | Here's some available that was provided to me by someone. {available times}
142 | Take a look at the times provided and let me know which ones are open on my calendar.
143 | ```
144 |
145 | ## Available Tools
146 |
147 | | Tool | Description |
148 | |------|-------------|
149 | | `list-calendars` | List all available calendars |
150 | | `list-events` | List events with date filtering |
151 | | `search-events` | Search events by text query |
152 | | `create-event` | Create new calendar events |
153 | | `update-event` | Update existing events |
154 | | `delete-event` | Delete events |
155 | | `get-freebusy` | Check availability across calendars, including external calendars |
156 | | `list-colors` | List available event colors |
157 |
158 | ## Documentation
159 |
160 | - [Authentication Setup](docs/authentication.md) - Detailed Google Cloud setup
161 | - [Advanced Usage](docs/advanced-usage.md) - Multi-account, batch operations
162 | - [Deployment Guide](docs/deployment.md) - HTTP transport, remote access
163 | - [Docker Guide](docs/docker.md) - Docker deployment with stdio and HTTP modes
164 | - [OAuth Verification](docs/oauth-verification.md) - Moving from test to production mode
165 | - [Architecture](docs/architecture.md) - Technical architecture overview
166 | - [Development](docs/development.md) - Contributing and testing
167 | - [Testing](docs/testing.md) - Unit and integration testing guide
168 |
169 | ## Configuration
170 |
171 | **Environment Variables:**
172 | - `GOOGLE_OAUTH_CREDENTIALS` - Path to OAuth credentials file
173 | - `GOOGLE_CALENDAR_MCP_TOKEN_PATH` - Custom token storage location (optional)
174 |
175 | **Claude Desktop Config Location:**
176 | - macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`
177 | - Windows: `%APPDATA%\Claude\claude_desktop_config.json`
178 |
179 |
180 | ## Security
181 |
182 | - OAuth tokens are stored securely in your system's config directory
183 | - Credentials never leave your local machine
184 | - All calendar operations require explicit user consent
185 |
186 | ### Troubleshooting
187 |
188 | 1. **OAuth Credentials File Not Found:**
189 | - For npx users: You **must** specify the credentials file path using `GOOGLE_OAUTH_CREDENTIALS`
190 | - Verify file paths are absolute and accessible
191 |
192 | 2. **Authentication Errors:**
193 | - Ensure your credentials file contains credentials for a **Desktop App** type
194 | - Verify your user email is added as a **Test User** in the Google Cloud OAuth Consent screen
195 | - Try deleting saved tokens and re-authenticating
196 | - Check that no other process is blocking ports 3000-3004
197 |
198 | 3. **Build Errors:**
199 | - Run `npm install && npm run build` again
200 | - Check Node.js version (use LTS)
201 | - Delete the `build/` directory and run `npm run build`
202 | 4. **"Something went wrong" screen during browser authentication**
203 | - Perform manual authentication per the below steps
204 | - Use a Chromium-based browser to open the authentication URL. Test app authentication may not be supported on some non-Chromium browsers.
205 |
206 | 5. **"User Rate Limit Exceeded" errors**
207 | - This typically occurs when your OAuth credentials are missing project information
208 | - Ensure your `gcp-oauth.keys.json` file includes `project_id`
209 | - Re-download credentials from Google Cloud Console if needed
210 | - The file should have format: `{"installed": {"project_id": "your-project-id", ...}}`
211 |
212 | ### Manual Authentication
213 | For re-authentication or troubleshooting:
214 | ```bash
215 | # For npx installations
216 | export GOOGLE_OAUTH_CREDENTIALS="/path/to/your/credentials.json"
217 | npx @cocal/google-calendar-mcp auth
218 |
219 | # For local installations
220 | npm run auth
221 | ```
222 |
223 | ## License
224 |
225 | MIT
226 |
227 | ## Support
228 |
229 | - [GitHub Issues](https://github.com/nspady/google-calendar-mcp/issues)
230 | - [Documentation](docs/)
231 |
```
--------------------------------------------------------------------------------
/AGENTS.md:
--------------------------------------------------------------------------------
```markdown
1 | # Repository Guidelines
2 |
3 | ## Project Structure & Modules
4 | - Source: `src/` (entry `index.ts`), builds to `build/` via esbuild.
5 | - Handlers: `src/handlers/core/` (tool implementations), utilities in `src/handlers/utils/`.
6 | - Schemas: `src/schemas/` (Zod definitions shared between server and tests).
7 | - Services: `src/services/` (conflict detection, helpers), transports in `src/transports/`.
8 | - Auth: `src/auth/`, OAuth helper `src/auth-server.ts`.
9 | - Tests: `src/tests/unit/` and `src/tests/integration/`.
10 | - Docs: `docs/` (auth, testing, deployment, architecture).
11 |
12 | ## Build, Test, and Dev
13 | - `npm run build`: Bundle to `build/index.js` and `build/auth-server.js` (Node 18 ESM).
14 | - `npm start`: Run stdio transport (for Claude Desktop). Example: `npx @cocal/google-calendar-mcp`.
15 | - `npm run start:http`: HTTP transport on `:3000` (use `start:http:public` for `0.0.0.0`).
16 | - `npm test`: Vitest unit tests. `npm run test:integration` for Google/LLM integration.
17 | - `npm run dev`: Helper menu (auth, http, docker, targeted test runs).
18 | - `npm run auth`: Launch local OAuth flow (stores tokens in `~/.config/google-calendar-mcp`).
19 |
20 | ## Coding Style & Naming
21 | - TypeScript, strict typing (avoid `any`). 2‑space indentation.
22 | - Files: PascalCase for handlers/services (e.g., `GetEventHandler.ts`), camelCase for functions/vars.
23 | - ESM modules with `type: module`; prefer named exports.
24 | - Validation with Zod in `src/schemas/`; validate inputs at handler boundaries.
25 | - Linting: `npm run lint` (TypeScript no‑emit checks).
26 |
27 | ## Testing Guidelines
28 | - Framework: Vitest with V8 coverage (`npm run test:coverage`).
29 | - Unit test names: `*.test.ts` mirroring source paths (e.g., `src/tests/unit/handlers/...`).
30 | - Integration requires env: `GOOGLE_OAUTH_CREDENTIALS`, `TEST_CALENDAR_ID`; authenticate with `npm run dev auth:test`.
31 | - Use `src/tests/integration/test-data-factory.ts` utilities; ensure tests clean up created events.
32 |
33 | ## Commit & PRs
34 | - Commits: Imperative mood, concise subject, optional scope. Examples:
35 | - `Fix timezone handling for list-events`
36 | - `services(conflict): improve duplicate detection`
37 | - Reference issues/PRs with `(#NN)` when applicable.
38 | - PRs: clear description, rationale, screenshots/log snippets when debugging; link issues; list notable env/config changes.
39 | - Required before PR: `npm run lint && npm test && npm run build` (and relevant integration tests if affected).
40 |
41 | ## Security & Config
42 | - Keep credentials out of git; use `.env` and `GOOGLE_OAUTH_CREDENTIALS` path.
43 | - Test vs normal accounts controlled via `GOOGLE_ACCOUNT_MODE`; prefer `test` for integration.
44 | - Tokens stored locally in `~/.config/google-calendar-mcp/tokens.json`.
45 |
46 | ## Adding New Tools (MCP)
47 | - Implement handler in `src/handlers/core/YourToolHandler.ts` extending `BaseToolHandler`.
48 | - Define/extend Zod schema in `src/schemas/` and add unit + integration tests.
49 | - Handlers are auto‑registered; update docs if adding public tool names.
50 |
51 |
```
--------------------------------------------------------------------------------
/CLAUDE.md:
--------------------------------------------------------------------------------
```markdown
1 | # CLAUDE.md
2 |
3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4 |
5 | ## Overview
6 |
7 | Google Calendar MCP Server - A Model Context Protocol (MCP) server providing Google Calendar integration for AI assistants. Built with TypeScript, supports both stdio and HTTP transports, with OAuth 2.0 authentication.
8 |
9 | ## Development Commands
10 |
11 | ```bash
12 | npm install # Install dependencies
13 | npm run build # Build with esbuild (outputs to build/)
14 | npm run dev # Show interactive development menu with all commands
15 | npm run lint # TypeScript type checking (no emit)
16 |
17 | # Testing - Quick Start
18 | npm test # Unit tests only (no auth required)
19 | npm run test:watch # Unit tests in watch mode
20 | npm run dev test:integration:direct # Direct integration tests (recommended for dev)
21 | npm run dev coverage # Generate test coverage report
22 |
23 | # Testing - Full Suite (rarely needed, incurrs LLM usage costs)
24 | npm run dev test:integration:claude # Claude + MCP integration (requires CLAUDE_API_KEY)
25 | npm run dev test:integration:openai # OpenAI + MCP integration (requires OPENAI_API_KEY)
26 | npm run dev test:integration:all # All integration tests (requires all API keys)
27 |
28 | # Authentication
29 | npm run auth # Authenticate main account
30 | npm run dev auth:test # Authenticate test account (for integration tests)
31 | npm run dev account:status # Check authentication status
32 |
33 | # Running the server
34 | npm start # Start with stdio transport
35 | npm run dev http # Start HTTP server on localhost:3000
36 | npm run dev http:public # HTTP server accessible from any host
37 | ```
38 |
39 | ## Architecture
40 |
41 | ### Handler Architecture
42 |
43 | All MCP tools follow a consistent handler pattern:
44 |
45 | 1. **Handler Registration**: Handlers are auto-registered via `src/tools/registry.ts`
46 | 2. **Base Class**: All handlers extend `BaseToolHandler` from `src/handlers/core/BaseToolHandler.ts`
47 | 3. **Schema Definition**: Input schemas defined in `src/tools/registry.ts` using Zod
48 | 4. **Handler Implementation**: Core logic in `src/handlers/core/` directory
49 |
50 | **Request Flow:**
51 | ```
52 | Client → Transport Layer → Schema Validation (Zod) → Handler → Google Calendar API → Response
53 | ```
54 |
55 | ### Adding New Tools
56 |
57 | 1. Create handler class in `src/handlers/core/YourToolHandler.ts`:
58 | - Extend `BaseToolHandler`
59 | - Implement `runTool(args, oauth2Client)` method
60 | - Use `this.getCalendar(oauth2Client)` to get Calendar API client
61 | - Use `this.handleGoogleApiError(error)` for error handling
62 |
63 | 2. Define schema in `src/tools/registry.ts`:
64 | - Add to `ToolSchemas` object with Zod schema
65 | - Add to `ToolRegistry.tools` array with name, description, handler class
66 |
67 | 3. Add tests:
68 | - Unit tests in `src/tests/unit/handlers/YourToolHandler.test.ts`
69 | - Integration tests in `src/tests/integration/` if needed
70 |
71 | **No manual registration needed** - handlers are auto-discovered by the registry system.
72 |
73 | ### Authentication System
74 |
75 | - **OAuth 2.0** with refresh token support
76 | - **Multi-account**: Supports `normal` (default) and `test` accounts via `GOOGLE_ACCOUNT_MODE` env var
77 | - **Token Storage**: `~/.config/google-calendar-mcp/tokens.json` (platform-specific paths)
78 | - **Token Validation**: Automatic refresh on expiry
79 | - **Components**:
80 | - `src/auth/client.ts` - OAuth2Client initialization
81 | - `src/auth/server.ts` - Auth server for OAuth flow
82 | - `src/auth/tokenManager.ts` - Token management and validation
83 |
84 | ### Transport Layer
85 |
86 | - **stdio** (default): Process communication for Claude Desktop
87 | - **HTTP**: RESTful API with SSE for remote deployment
88 | - **Configuration**: `src/config/TransportConfig.ts`
89 | - **Handlers**: `src/transports/stdio.ts` and `src/transports/http.ts`
90 |
91 | ### Testing Strategy
92 |
93 | **Unit Tests** (`src/tests/unit/`):
94 | - No external dependencies (mocked)
95 | - Schema validation, error handling, datetime logic
96 | - Run with `npm test` (no setup required)
97 |
98 | **Integration Tests** (`src/tests/integration/`):
99 |
100 | Three types of integration tests, each with different requirements:
101 |
102 | 1. **Direct Integration** (most commonly used):
103 | - File: `direct-integration.test.ts`
104 | - Tests real Google Calendar API calls
105 | - **Setup Required**:
106 | ```bash
107 | # 1. Set credentials path
108 | export GOOGLE_OAUTH_CREDENTIALS=./gcp-oauth.keys.json
109 |
110 | # 2. Set test calendar (use "primary" or a specific calendar ID)
111 | export TEST_CALENDAR_ID=primary
112 |
113 | # 3. Authenticate test account
114 | npm run dev auth:test
115 |
116 | # 4. Run tests
117 | npm run dev test:integration:direct
118 | ```
119 |
120 | 2. **LLM Integration** (rarely needed):
121 | - Files: `claude-mcp-integration.test.ts`, `openai-mcp-integration.test.ts`
122 | - Tests end-to-end MCP protocol with AI models
123 | - **Additional Setup** (beyond direct integration setup):
124 | ```bash
125 | # For Claude tests
126 | export CLAUDE_API_KEY=sk-ant-...
127 | npm run dev test:integration:claude
128 |
129 | # For OpenAI tests
130 | export OPENAI_API_KEY=sk-...
131 | npm run dev test:integration:openai
132 |
133 | # For both
134 | npm run dev test:integration:all
135 | ```
136 | - ⚠️ Consumes API credits and takes 2-5 minutes
137 |
138 | **Quick Setup Summary:**
139 | ```bash
140 | # Minimal setup for development (direct integration tests only):
141 | export GOOGLE_OAUTH_CREDENTIALS=./gcp-oauth.keys.json
142 | export TEST_CALENDAR_ID=primary
143 | npm run dev auth:test
144 | npm run dev test:integration:direct
145 | ```
146 |
147 | ### Key Services
148 |
149 | **Conflict Detection** (`src/services/conflict-detection/`):
150 | - `ConflictAnalyzer.ts` - Detects scheduling conflicts
151 | - `EventSimilarityChecker.ts` - Identifies duplicate events
152 | - `ConflictDetectionService.ts` - Main service coordinating conflict checks
153 | - Used by `create-event` and `update-event` handlers
154 |
155 | **Structured Responses** (`src/types/structured-responses.ts`):
156 | - TypeScript interfaces for consistent response formats
157 | - Used across handlers for type safety
158 |
159 | **Utilities**:
160 | - `src/utils/field-mask-builder.ts` - Builds Google API field masks
161 | - `src/utils/event-id-validator.ts` - Validates Google Calendar event IDs
162 | - `src/utils/response-builder.ts` - Formats MCP responses
163 | - `src/handlers/utils/datetime.ts` - Timezone and datetime utilities
164 |
165 | ## Important Patterns
166 |
167 | ### Timezone Handling
168 |
169 | - **Preferred Format**: ISO 8601 without timezone (e.g., `2024-01-01T10:00:00`)
170 | - Uses `timeZone` parameter or calendar's default timezone
171 | - **Also Supported**: ISO 8601 with timezone (e.g., `2024-01-01T10:00:00-08:00`)
172 | - **All-day Events**: Date only format (e.g., `2024-01-01`)
173 | - **Helper**: `getCalendarTimezone()` method in `BaseToolHandler`
174 |
175 | ### Multi-Calendar Support
176 |
177 | - `list-events` accepts single calendar ID or JSON array: `'["cal1", "cal2"]'`
178 | - Batch requests handled by `BatchRequestHandler.ts`
179 | - Maximum 50 calendars per request
180 |
181 | ### Recurring Events
182 |
183 | - Modification scopes: `thisEventOnly`, `thisAndFollowing`, `all`
184 | - Handled by `RecurringEventHelpers.ts`
185 | - Special validation in `update-event` schema
186 |
187 | ### Error Handling
188 |
189 | - Use `McpError` from `@modelcontextprotocol/sdk/types.js`
190 | - `BaseToolHandler.handleGoogleApiError()` for consistent Google API error handling
191 | - Maps HTTP status codes to appropriate MCP error codes
192 |
193 | ### Structured Output Migration
194 |
195 | The codebase uses a structured response format for tool outputs. Recent commits (see git status) show migration to structured outputs using types from `src/types/structured-responses.ts`. When updating handlers, ensure responses conform to these structured formats.
196 |
197 | ### MCP Structure
198 |
199 | MCP tools return errors as successful responses with error content, not as thrown exceptions. Integration tests must validate result.content[0].text for error messages, while unit tests of handlers directly can still catch thrown McpError exceptions before the MCP transport layer wraps them.
200 |
201 | ## Code Quality
202 |
203 | - **TypeScript**: Strict mode, avoid `any` types
204 | - **Formatting**: Use existing patterns in handlers
205 | - **Testing**: Add unit tests for all new handlers
206 | - **Error Messages**: Clear, actionable error messages referencing Google Calendar concepts
207 |
208 | ## Google Calendar API
209 |
210 | - **Version**: v3 (`googleapis` package)
211 | - **Timeout**: 3 seconds per API call (configured in `BaseToolHandler`)
212 | - **Rate Limiting**: Google Calendar API has quotas - integration tests may hit limits
213 | - **Scopes Required**:
214 | - `https://www.googleapis.com/auth/calendar.events`
215 | - `https://www.googleapis.com/auth/calendar`
216 |
217 | ## Deployment
218 |
219 | - **npx**: `npx @cocal/google-calendar-mcp` (requires `GOOGLE_OAUTH_CREDENTIALS` env var)
220 | - **Docker**: See `docs/docker.md` for Docker deployment with stdio and HTTP modes
221 | - **Claude Desktop Config**: See README.md for local stdio configuration
222 |
223 | ### Deployment Modes
224 |
225 | **Local Development (Claude Desktop):**
226 | - Use **stdio mode** (default)
227 | - No server or domain required
228 | - Direct process communication
229 | - See README.md for setup
230 |
231 | **Key Differences:**
232 | - **stdio**: For Claude Desktop only, local machine
233 | - **HTTP**: For testing, development, debugging (local only)
234 |
235 |
```
--------------------------------------------------------------------------------
/src/auth/paths.d.ts:
--------------------------------------------------------------------------------
```typescript
1 | export function getSecureTokenPath(): string;
2 | export function getLegacyTokenPath(): string;
3 | export function getAccountMode(): 'normal' | 'test';
4 |
5 |
```
--------------------------------------------------------------------------------
/tsconfig.lint.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "extends": "./tsconfig.json",
3 | "include": [
4 | "src/**/*.ts"
5 | ],
6 | "exclude": [
7 | "src/tests/**"
8 | ],
9 | "compilerOptions": {
10 | "noEmit": true,
11 | "allowJs": true
12 | }
13 | }
14 |
15 |
```
--------------------------------------------------------------------------------
/gcp-oauth.keys.example.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "installed": {
3 | "client_id": "YOUR_GOOGLE_CLIENT_ID",
4 | "client_secret": "YOUR_GOOGLE_CLIENT_SECRET",
5 | "redirect_uris": ["http://localhost:3000/oauth2callback"]
6 | }
7 | }
8 |
```
--------------------------------------------------------------------------------
/src/services/conflict-detection/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | export { ConflictDetectionService } from './ConflictDetectionService.js';
2 | export { EventSimilarityChecker } from './EventSimilarityChecker.js';
3 | export { ConflictAnalyzer } from './ConflictAnalyzer.js';
4 | export * from './types.js';
```
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "compilerOptions": {
3 | "target": "ES2022",
4 | "module": "Node16",
5 | "moduleResolution": "Node16",
6 | "outDir": "./build",
7 | "rootDir": "./src",
8 | "strict": true,
9 | "esModuleInterop": true,
10 | "skipLibCheck": true,
11 | "forceConsistentCasingInFileNames": true,
12 | "resolveJsonModule": true,
13 | "types": ["node"]
14 | },
15 | "include": ["src/**/*"],
16 | "exclude": ["node_modules"]
17 | }
18 |
```
--------------------------------------------------------------------------------
/src/transports/stdio.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3 |
4 | export class StdioTransportHandler {
5 | private server: McpServer;
6 |
7 | constructor(server: McpServer) {
8 | this.server = server;
9 | }
10 |
11 | async connect(): Promise<void> {
12 | const transport = new StdioServerTransport();
13 | await this.server.connect(transport);
14 | }
15 | }
```
--------------------------------------------------------------------------------
/src/services/conflict-detection/config.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Centralized configuration for conflict detection thresholds
3 | */
4 |
5 | export const CONFLICT_DETECTION_CONFIG = {
6 | /**
7 | * Thresholds for duplicate event detection
8 | */
9 | DUPLICATE_THRESHOLDS: {
10 | /**
11 | * Events with similarity >= this value are flagged as potential duplicates
12 | * and shown as warnings during creation
13 | */
14 | WARNING: 0.7,
15 |
16 | /**
17 | * Events with similarity >= this value are considered exact duplicates
18 | * and block creation unless explicitly overridden with allowDuplicates flag
19 | */
20 | BLOCKING: 0.95
21 | },
22 |
23 | /**
24 | * Default similarity threshold for duplicate detection
25 | * Used when duplicateSimilarityThreshold is not specified in the request
26 | */
27 | DEFAULT_DUPLICATE_THRESHOLD: 0.7
28 | } as const;
29 |
30 | export type ConflictDetectionConfig = typeof CONFLICT_DETECTION_CONFIG;
```
--------------------------------------------------------------------------------
/vitest.config.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { defineConfig } from 'vitest/config'
2 | import { loadEnv } from 'vite'
3 |
4 | export default defineConfig({
5 | test: {
6 | globals: true, // Use Vitest globals (describe, it, expect) like Jest
7 | environment: 'node', // Specify the test environment
8 | // Load environment variables from .env file
9 | env: loadEnv('', process.cwd(), ''),
10 | // Increase timeout for AI API calls
11 | testTimeout: 30000,
12 | include: [
13 | 'src/tests/**/*.test.ts'
14 | ],
15 | // Exclude integration tests by default (they require credentials)
16 | exclude: ['**/node_modules/**'],
17 | // Enable coverage
18 | coverage: {
19 | provider: 'v8', // or 'istanbul'
20 | reporter: ['text', 'json', 'html'],
21 | exclude: [
22 | '**/node_modules/**',
23 | 'src/tests/integration/**',
24 | 'build/**',
25 | 'scripts/**',
26 | '*.config.*'
27 | ],
28 | },
29 | },
30 | })
```
--------------------------------------------------------------------------------
/docs/development.md:
--------------------------------------------------------------------------------
```markdown
1 | # Development Guide
2 |
3 | ## Setup
4 |
5 | ```bash
6 | git clone https://github.com/nspady/google-calendar-mcp.git
7 | cd google-calendar-mcp
8 | npm install
9 | npm run build
10 | npm run auth # Authenticate main account
11 | npm run dev auth:test # Authenticate test account (used for integration tests)
12 | ```
13 |
14 | ## Development
15 |
16 | ```bash
17 | npm run dev # Interactive development menu
18 | npm run build # Build project
19 | npm run lint # Type-check with TypeScript (no emit)
20 | npm test # Run tests
21 | ```
22 |
23 | ## Contributing
24 |
25 | - Follow existing code patterns
26 | - Add tests for new features
27 | - Use TypeScript strictly (avoid `any`)
28 | - Run `npm run dev` for development tools
29 |
30 | ## Adding New Tools
31 |
32 | 1. Create handler in `src/handlers/core/NewToolHandler.ts`
33 | 2. Define schema in `src/schemas/`
34 | 3. Add tests in `src/tests/`
35 | 4. Auto-discovered by registry system
36 |
37 | See existing handlers for patterns.
38 |
```
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
```dockerfile
1 | # Google Calendar MCP Server - Optimized Dockerfile
2 | # syntax=docker/dockerfile:1
3 |
4 | FROM node:18-alpine
5 |
6 | # Create app user for security
7 | RUN addgroup -g 1001 -S nodejs && \
8 | adduser -S -u 1001 -G nodejs nodejs
9 |
10 | # Set working directory
11 | WORKDIR /app
12 |
13 | # Copy package files for dependency caching
14 | COPY package*.json ./
15 |
16 | # Copy build scripts and source files needed for build
17 | COPY scripts ./scripts
18 | COPY src ./src
19 | COPY tsconfig.json .
20 |
21 | # Install all dependencies (including dev dependencies for build)
22 | RUN npm ci --no-audit --no-fund --silent
23 |
24 | # Build the project
25 | RUN npm run build
26 |
27 | # Remove dev dependencies to reduce image size
28 | RUN npm prune --production --silent
29 |
30 | # Create config directory and set permissions
31 | RUN mkdir -p /home/nodejs/.config/google-calendar-mcp && \
32 | chown -R nodejs:nodejs /home/nodejs/.config && \
33 | chown -R nodejs:nodejs /app
34 |
35 | # Switch to non-root user
36 | USER nodejs
37 |
38 | # Expose port for HTTP mode (optional)
39 | EXPOSE 3000
40 |
41 | # Default command - run directly to avoid npm output
42 | CMD ["node", "build/index.js"]
```
--------------------------------------------------------------------------------
/src/auth/paths.js:
--------------------------------------------------------------------------------
```javascript
1 | #!/usr/bin/env node
2 |
3 | /**
4 | * Shared path utilities for token management
5 | * This module provides consistent token path resolution across all scripts
6 | */
7 |
8 | import path from 'path';
9 | import { homedir } from 'os';
10 |
11 | /**
12 | * Get the secure token storage path
13 | * Uses XDG Base Directory specification on Unix-like systems
14 | */
15 | export function getSecureTokenPath() {
16 | const configDir = process.env.XDG_CONFIG_HOME || path.join(homedir(), '.config');
17 | return path.join(configDir, 'google-calendar-mcp', 'tokens.json');
18 | }
19 |
20 | /**
21 | * Get the legacy token path (for migration purposes)
22 | */
23 | export function getLegacyTokenPath() {
24 | return path.join(process.cwd(), '.gcp-saved-tokens.json');
25 | }
26 |
27 | /**
28 | * Get current account mode from environment
29 | * Uses same logic as utils.ts but compatible with both JS and TS
30 | */
31 | export function getAccountMode() {
32 | // If set explicitly via environment variable use that instead
33 | const explicitMode = process.env.GOOGLE_ACCOUNT_MODE?.toLowerCase();
34 | if (explicitMode === 'test' || explicitMode === 'normal') {
35 | return explicitMode;
36 | }
37 |
38 | // Auto-detect test environment
39 | if (process.env.NODE_ENV === 'test') {
40 | return 'test';
41 | }
42 |
43 | // Default to normal for regular app usage
44 | return 'normal';
45 | }
```
--------------------------------------------------------------------------------
/src/schemas/types.ts:
--------------------------------------------------------------------------------
```typescript
1 | // TypeScript interfaces for Google Calendar data structures
2 |
3 | export interface CalendarListEntry {
4 | id?: string | null;
5 | summary?: string | null;
6 | }
7 |
8 | export interface CalendarEventReminder {
9 | method: 'email' | 'popup';
10 | minutes: number;
11 | }
12 |
13 | export interface CalendarEventAttendee {
14 | email?: string | null;
15 | responseStatus?: string | null;
16 | }
17 |
18 | export interface CalendarEvent {
19 | id?: string | null;
20 | summary?: string | null;
21 | start?: {
22 | dateTime?: string | null;
23 | date?: string | null;
24 | timeZone?: string | null;
25 | };
26 | end?: {
27 | dateTime?: string | null;
28 | date?: string | null;
29 | timeZone?: string | null;
30 | };
31 | location?: string | null;
32 | attendees?: CalendarEventAttendee[] | null;
33 | colorId?: string | null;
34 | reminders?: {
35 | useDefault: boolean;
36 | overrides?: CalendarEventReminder[];
37 | };
38 | recurrence?: string[] | null;
39 | }
40 |
41 | // Type-safe response based on Google Calendar FreeBusy API
42 | export interface FreeBusyResponse {
43 | kind: "calendar#freeBusy";
44 | timeMin: string;
45 | timeMax: string;
46 | groups?: {
47 | [key: string]: {
48 | errors?: { domain: string; reason: string }[];
49 | calendars?: string[];
50 | };
51 | };
52 | calendars: {
53 | [key: string]: {
54 | errors?: { domain: string; reason: string }[];
55 | busy: {
56 | start: string;
57 | end: string;
58 | }[];
59 | };
60 | };
61 | }
```
--------------------------------------------------------------------------------
/src/handlers/core/DeleteEventHandler.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
2 | import { OAuth2Client } from "google-auth-library";
3 | import { BaseToolHandler } from "./BaseToolHandler.js";
4 | import { DeleteEventInput } from "../../tools/registry.js";
5 | import { DeleteEventResponse } from "../../types/structured-responses.js";
6 | import { createStructuredResponse } from "../../utils/response-builder.js";
7 |
8 | export class DeleteEventHandler extends BaseToolHandler {
9 | async runTool(args: any, oauth2Client: OAuth2Client): Promise<CallToolResult> {
10 | const validArgs = args as DeleteEventInput;
11 | await this.deleteEvent(oauth2Client, validArgs);
12 |
13 | const response: DeleteEventResponse = {
14 | success: true,
15 | eventId: validArgs.eventId,
16 | calendarId: validArgs.calendarId,
17 | message: "Event deleted successfully"
18 | };
19 |
20 | return createStructuredResponse(response);
21 | }
22 |
23 | private async deleteEvent(
24 | client: OAuth2Client,
25 | args: DeleteEventInput
26 | ): Promise<void> {
27 | try {
28 | const calendar = this.getCalendar(client);
29 | await calendar.events.delete({
30 | calendarId: args.calendarId,
31 | eventId: args.eventId,
32 | sendUpdates: args.sendUpdates,
33 | });
34 | } catch (error) {
35 | throw this.handleGoogleApiError(error);
36 | }
37 | }
38 | }
39 |
```
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
```yaml
1 | # Google Calendar MCP Server - Docker Compose Configuration
2 | # Simple, production-ready setup following Docker best practices
3 |
4 | services:
5 | calendar-mcp:
6 | build: .
7 | container_name: calendar-mcp
8 | restart: unless-stopped
9 |
10 | # Environment configuration via .env file
11 | env_file: .env
12 |
13 | # OAuth credentials and token storage
14 | volumes:
15 | - ./gcp-oauth.keys.json:/app/gcp-oauth.keys.json:ro
16 | - calendar-tokens:/home/nodejs/.config/google-calendar-mcp
17 |
18 | # Expose ports for HTTP mode and OAuth authentication
19 | ports:
20 | - "3000:3000" # HTTP mode MCP server
21 | - "3500:3500" # OAuth authentication
22 | - "3501:3501"
23 | - "3502:3502"
24 | - "3503:3503"
25 | - "3504:3504"
26 | - "3505:3505"
27 |
28 |
29 | # Resource limits for production stability
30 | deploy:
31 | resources:
32 | limits:
33 | memory: 512M
34 | cpus: "1.0"
35 | reservations:
36 | memory: 256M
37 | cpus: "0.5"
38 |
39 | # Security options
40 | security_opt:
41 | - no-new-privileges:true
42 |
43 | # Health check for HTTP mode (safe for stdio mode too)
44 | healthcheck:
45 | test: ["CMD-SHELL", "if [ \"$TRANSPORT\" = \"http\" ]; then curl -f http://localhost:${PORT:-3000}/health || exit 1; else exit 0; fi"]
46 | interval: 30s
47 | timeout: 10s
48 | retries: 3
49 | start_period: 40s
50 |
51 | # Persistent volume for OAuth tokens
52 | volumes:
53 | calendar-tokens:
54 | driver: local
```
--------------------------------------------------------------------------------
/src/services/conflict-detection/types.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { calendar_v3 } from "googleapis";
2 |
3 | /**
4 | * Internal conflict info used by the conflict detection service.
5 | * Contains additional internal fields not exposed in public API responses.
6 | */
7 | export interface InternalConflictInfo {
8 | type: 'overlap' | 'duplicate';
9 | calendar: string;
10 | event: {
11 | id: string;
12 | title: string;
13 | url?: string;
14 | start?: string;
15 | end?: string;
16 | };
17 | fullEvent?: calendar_v3.Schema$Event;
18 | overlap?: {
19 | duration: string;
20 | percentage: number;
21 | startTime: string;
22 | endTime: string;
23 | };
24 | similarity?: number;
25 | }
26 |
27 | /**
28 | * Internal duplicate info used by the conflict detection service.
29 | * Contains additional internal fields not exposed in public API responses.
30 | */
31 | export interface InternalDuplicateInfo {
32 | event: {
33 | id: string;
34 | title: string;
35 | start?: string;
36 | end?: string;
37 | url?: string;
38 | similarity: number;
39 | };
40 | fullEvent?: calendar_v3.Schema$Event;
41 | calendarId?: string;
42 | suggestion: string;
43 | }
44 |
45 | export interface ConflictCheckResult {
46 | hasConflicts: boolean;
47 | conflicts: InternalConflictInfo[];
48 | duplicates: InternalDuplicateInfo[];
49 | }
50 |
51 | export interface EventTimeRange {
52 | start: Date;
53 | end: Date;
54 | isAllDay: boolean;
55 | }
56 |
57 | export interface ConflictDetectionOptions {
58 | checkDuplicates?: boolean;
59 | checkConflicts?: boolean;
60 | calendarsToCheck?: string[];
61 | duplicateSimilarityThreshold?: number;
62 | includeDeclinedEvents?: boolean;
63 | }
```
--------------------------------------------------------------------------------
/.github/workflows/publish.yml:
--------------------------------------------------------------------------------
```yaml
1 | name: Release
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 |
8 | permissions:
9 | contents: write
10 | pull-requests: write
11 | id-token: write
12 |
13 | jobs:
14 | release-please:
15 | runs-on: ubuntu-latest
16 | steps:
17 | - uses: googleapis/release-please-action@v4
18 | id: release
19 | with:
20 | release-type: node
21 |
22 | # Only run the following steps if a release was created
23 | - uses: actions/checkout@v4
24 | if: ${{ steps.release.outputs.release_created }}
25 |
26 | - uses: actions/setup-node@v4
27 | if: ${{ steps.release.outputs.release_created }}
28 | with:
29 | node-version: '18'
30 | registry-url: 'https://registry.npmjs.org'
31 |
32 | - name: Install dependencies
33 | if: ${{ steps.release.outputs.release_created }}
34 | run: npm ci
35 |
36 | - name: Build project
37 | if: ${{ steps.release.outputs.release_created }}
38 | run: npm run build
39 |
40 | - name: Publish to NPM
41 | if: ${{ steps.release.outputs.release_created }}
42 | run: |
43 | VERSION="${{ steps.release.outputs.tag_name }}"
44 |
45 | # Check if this is a prerelease version (contains -, like v1.3.0-beta.0)
46 | if [[ "$VERSION" == *"-"* ]]; then
47 | echo "Publishing prerelease version $VERSION with beta tag"
48 | npm publish --provenance --access public --tag beta
49 | else
50 | echo "Publishing stable version $VERSION with latest tag"
51 | npm publish --provenance --access public
52 | fi
53 | env:
54 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
55 |
```
--------------------------------------------------------------------------------
/scripts/build.js:
--------------------------------------------------------------------------------
```javascript
1 | #!/usr/bin/env node
2 |
3 | import * as esbuild from 'esbuild';
4 | import { fileURLToPath } from 'url';
5 | import { dirname, join } from 'path';
6 |
7 | const __dirname = dirname(fileURLToPath(import.meta.url));
8 | const isWatch = process.argv.includes('--watch');
9 |
10 | /** @type {import('esbuild').BuildOptions} */
11 | const buildOptions = {
12 | entryPoints: [join(__dirname, '../src/index.ts')],
13 | bundle: true,
14 | platform: 'node',
15 | target: 'node18',
16 | outfile: join(__dirname, '../build/index.js'),
17 | format: 'esm',
18 | banner: {
19 | js: '#!/usr/bin/env node\n',
20 | },
21 | packages: 'external', // Don't bundle node_modules
22 | sourcemap: true,
23 | };
24 |
25 | /** @type {import('esbuild').BuildOptions} */
26 | const authServerBuildOptions = {
27 | entryPoints: [join(__dirname, '../src/auth-server.ts')],
28 | bundle: true,
29 | platform: 'node',
30 | target: 'node18',
31 | outfile: join(__dirname, '../build/auth-server.js'),
32 | format: 'esm',
33 | packages: 'external', // Don't bundle node_modules
34 | sourcemap: true,
35 | };
36 |
37 | if (isWatch) {
38 | const context = await esbuild.context(buildOptions);
39 | const authContext = await esbuild.context(authServerBuildOptions);
40 | await Promise.all([context.watch(), authContext.watch()]);
41 | process.stderr.write('Watching for changes...\n');
42 | } else {
43 | await Promise.all([
44 | esbuild.build(buildOptions),
45 | esbuild.build(authServerBuildOptions)
46 | ]);
47 |
48 | // Make the file executable on non-Windows platforms
49 | if (process.platform !== 'win32') {
50 | const { chmod } = await import('fs/promises');
51 | await chmod(buildOptions.outfile, 0o755);
52 | }
53 | }
```
--------------------------------------------------------------------------------
/docs/architecture.md:
--------------------------------------------------------------------------------
```markdown
1 | # Architecture Overview
2 |
3 | ## Transport Layer
4 |
5 | - **stdio** (default): Direct process communication for Claude Desktop
6 | - **HTTP**: RESTful API with SSE for remote deployment
7 |
8 | ## Authentication System
9 |
10 | OAuth 2.0 with refresh tokens, multi-account support, secure storage in `~/.config/google-calendar-mcp/tokens.json`.
11 |
12 | ## Handler Architecture
13 |
14 | - `src/handlers/core/` - Individual tool handlers extending `BaseToolHandler`
15 | - `src/tools/registry.ts` - Auto-registration system discovers and registers handlers
16 | - `src/schemas/` - Input validation and type definitions
17 |
18 | ## Request Flow
19 |
20 | ```
21 | Client → Transport → Schema Validation → Handler → Google API → Response
22 | ```
23 |
24 | ## MCP Tools
25 |
26 | The server provides calendar management tools that LLMs can use for calendar operations:
27 |
28 | ### Available Tools
29 |
30 | - `list-calendars` - List all available calendars
31 | - `list-events` - List events with date filtering
32 | - `search-events` - Search events by text query
33 | - `create-event` - Create new calendar events
34 | - `update-event` - Update existing events
35 | - `delete-event` - Delete events
36 | - `get-freebusy` - Check availability across calendars
37 | - `list-colors` - List available event colors
38 | - `get-current-time` - Get current system time and timezone information
39 |
40 | ## Key Features
41 |
42 | - **Auto-registration**: Handlers automatically discovered
43 | - **Multi-account**: Normal/test account support
44 | - **Rate limiting**: Respects Google Calendar quotas
45 | - **Batch operations**: Efficient multi-calendar queries
46 | - **Recurring events**: Advanced modification scopes
47 | - **Contextual resources**: Real-time date/time information
```
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "name": "@cocal/google-calendar-mcp",
3 | "version": "2.0.6",
4 | "description": "Google Calendar MCP Server with extensive support for calendar management",
5 | "type": "module",
6 | "main": "build/index.js",
7 | "bin": {
8 | "google-calendar-mcp": "build/index.js"
9 | },
10 | "files": [
11 | "build/",
12 | "README.md",
13 | "LICENSE"
14 | ],
15 | "keywords": [
16 | "mcp",
17 | "model-context-protocol",
18 | "claude",
19 | "google-calendar",
20 | "calendar",
21 | "ai",
22 | "llm",
23 | "integration"
24 | ],
25 | "repository": {
26 | "type": "git",
27 | "url": "git+https://github.com/nspady/google-calendar-mcp.git"
28 | },
29 | "bugs": {
30 | "url": "https://github.com/nspady/google-calendar-mcp/issues"
31 | },
32 | "homepage": "https://github.com/nspady/google-calendar-mcp#readme",
33 | "author": "nspady",
34 | "license": "MIT",
35 | "scripts": {
36 | "start": "node build/index.js",
37 | "build": "node scripts/build.js",
38 | "auth": "node build/auth-server.js",
39 | "dev": "node scripts/dev.js",
40 | "lint": "tsc --noEmit -p tsconfig.lint.json",
41 | "test": "vitest run src/tests/unit",
42 | "test:watch": "vitest src/tests/unit",
43 | "test:integration": "vitest run src/tests/integration",
44 | "test:all": "vitest run src/tests",
45 | "test:coverage": "vitest run src/tests/unit --coverage",
46 | "start:http": "node build/index.js --transport http --port 3000",
47 | "start:http:public": "node build/index.js --transport http --port 3000 --host 0.0.0.0"
48 | },
49 | "dependencies": {
50 | "@google-cloud/local-auth": "^3.0.1",
51 | "@modelcontextprotocol/sdk": "^1.12.1",
52 | "google-auth-library": "^9.15.0",
53 | "googleapis": "^144.0.0",
54 | "open": "^7.4.2",
55 | "zod": "^3.22.4",
56 | "zod-to-json-schema": "^3.24.5"
57 | },
58 | "devDependencies": {
59 | "@anthropic-ai/sdk": "^0.52.0",
60 | "@types/node": "^20.10.4",
61 | "@vitest/coverage-v8": "^3.1.1",
62 | "esbuild": "^0.25.0",
63 | "openai": "^4.104.0",
64 | "typescript": "^5.3.3",
65 | "vitest": "^3.1.1"
66 | }
67 | }
68 |
```
--------------------------------------------------------------------------------
/src/config/TransportConfig.ts:
--------------------------------------------------------------------------------
```typescript
1 | export interface TransportConfig {
2 | type: 'stdio' | 'http';
3 | port?: number;
4 | host?: string;
5 | }
6 |
7 | export interface ServerConfig {
8 | transport: TransportConfig;
9 | debug?: boolean;
10 | }
11 |
12 | export function parseArgs(args: string[]): ServerConfig {
13 | // Start with environment variables as base config
14 | const config: ServerConfig = {
15 | transport: {
16 | type: (process.env.TRANSPORT as 'stdio' | 'http') || 'stdio',
17 | port: process.env.PORT ? parseInt(process.env.PORT, 10) : 3000,
18 | host: process.env.HOST || '127.0.0.1'
19 | },
20 | debug: process.env.DEBUG === 'true' || false
21 | };
22 |
23 | for (let i = 0; i < args.length; i++) {
24 | const arg = args[i];
25 |
26 | switch (arg) {
27 | case '--transport':
28 | const transport = args[++i];
29 | if (transport === 'stdio' || transport === 'http') {
30 | config.transport.type = transport;
31 | }
32 | break;
33 | case '--port':
34 | config.transport.port = parseInt(args[++i], 10);
35 | break;
36 | case '--host':
37 | config.transport.host = args[++i];
38 | break;
39 | case '--debug':
40 | config.debug = true;
41 | break;
42 | case '--help':
43 | process.stderr.write(`
44 | Google Calendar MCP Server
45 |
46 | Usage: node build/index.js [options]
47 |
48 | Options:
49 | --transport <type> Transport type: stdio (default) | http
50 | --port <number> Port for HTTP transport (default: 3000)
51 | --host <string> Host for HTTP transport (default: 127.0.0.1)
52 | --debug Enable debug logging
53 | --help Show this help message
54 |
55 | Environment Variables:
56 | TRANSPORT Transport type: stdio | http
57 | PORT Port for HTTP transport
58 | HOST Host for HTTP transport
59 | DEBUG Enable debug logging (true/false)
60 |
61 | Examples:
62 | node build/index.js # stdio (local use)
63 | node build/index.js --transport http --port 3000 # HTTP server
64 | PORT=3000 TRANSPORT=http node build/index.js # Using env vars
65 | `);
66 | process.exit(0);
67 | }
68 | }
69 |
70 | return config;
71 | }
```
--------------------------------------------------------------------------------
/src/handlers/core/GetEventHandler.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
2 | import { OAuth2Client } from "google-auth-library";
3 | import { BaseToolHandler } from "./BaseToolHandler.js";
4 | import { calendar_v3 } from 'googleapis';
5 | import { buildSingleEventFieldMask } from "../../utils/field-mask-builder.js";
6 | import { createStructuredResponse } from "../../utils/response-builder.js";
7 | import { GetEventResponse, convertGoogleEventToStructured } from "../../types/structured-responses.js";
8 |
9 | interface GetEventArgs {
10 | calendarId: string;
11 | eventId: string;
12 | fields?: string[];
13 | }
14 |
15 | export class GetEventHandler extends BaseToolHandler {
16 | async runTool(args: GetEventArgs, oauth2Client: OAuth2Client): Promise<CallToolResult> {
17 | const validArgs = args;
18 |
19 | try {
20 | const event = await this.getEvent(oauth2Client, validArgs);
21 |
22 | if (!event) {
23 | throw new Error(`Event with ID '${validArgs.eventId}' not found in calendar '${validArgs.calendarId}'.`);
24 | }
25 |
26 | const response: GetEventResponse = {
27 | event: convertGoogleEventToStructured(event, validArgs.calendarId)
28 | };
29 |
30 | return createStructuredResponse(response);
31 | } catch (error) {
32 | throw this.handleGoogleApiError(error);
33 | }
34 | }
35 |
36 | private async getEvent(
37 | client: OAuth2Client,
38 | args: GetEventArgs
39 | ): Promise<calendar_v3.Schema$Event | null> {
40 | const calendar = this.getCalendar(client);
41 |
42 | const fieldMask = buildSingleEventFieldMask(args.fields);
43 |
44 | try {
45 | const response = await calendar.events.get({
46 | calendarId: args.calendarId,
47 | eventId: args.eventId,
48 | ...(fieldMask && { fields: fieldMask })
49 | });
50 |
51 | return response.data;
52 | } catch (error: any) {
53 | // Handle 404 as a not found case
54 | if (error?.code === 404 || error?.response?.status === 404) {
55 | return null;
56 | }
57 | throw error;
58 | }
59 | }
60 | }
```
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
```yaml
1 | name: CI
2 |
3 | on:
4 | push:
5 | branches: [ main, 'feature/**' ]
6 | pull_request:
7 | branches: [ main ]
8 |
9 | jobs:
10 | # Job 1: Test and Coverage (includes build, quality checks, and coverage reporting)
11 | test-and-coverage:
12 | runs-on: ubuntu-latest
13 |
14 | strategy:
15 | matrix:
16 | node-version: ['18']
17 |
18 | steps:
19 | - name: Checkout code
20 | uses: actions/checkout@v4
21 |
22 | - name: Setup Node.js ${{ matrix.node-version }}
23 | uses: actions/setup-node@v4
24 | with:
25 | node-version: ${{ matrix.node-version }}
26 | cache: 'npm'
27 |
28 | - name: Install dependencies
29 | run: npm ci
30 |
31 | - name: Run unit tests with coverage
32 | run: |
33 | echo "🧪 Running unit tests with coverage..."
34 | echo "======================================"
35 | npm run test:coverage | tee test-output.log
36 |
37 | echo ""
38 | echo "📊 Test Results Summary:"
39 | echo "======================="
40 |
41 | # Extract test results from the log
42 | TEST_FILES=$(grep "Test Files" test-output.log | tail -1 | sed 's/.*Test Files[[:space:]]*//' | sed 's/[[:space:]]*passed.*//')
43 | TESTS_PASSED=$(grep "Tests" test-output.log | tail -1 | sed 's/.*Tests[[:space:]]*//' | sed 's/[[:space:]]*passed.*//')
44 |
45 | echo "✅ Test Files: $TEST_FILES passed"
46 | echo "✅ Total Tests: $TESTS_PASSED passed"
47 | echo "📁 Unit Test Files: $(find src/tests/unit -name '*.test.ts' | wc -l | tr -d ' ') files"
48 | echo "📁 Source Files: $(find src -name '*.ts' -not -path 'src/tests/*' | wc -l | tr -d ' ') files"
49 |
50 | echo ""
51 | echo "📈 Coverage Summary (from detailed report above):"
52 | echo "================================================="
53 | echo "• Lines, Functions, Branches, and Statements coverage shown in table above"
54 | echo "• Full HTML report available in coverage/index.html artifact"
55 |
56 | # Clean up temp file
57 | rm -f test-output.log
58 | env:
59 | NODE_ENV: test
60 |
61 | - name: Upload coverage reports
62 | uses: actions/upload-artifact@v4
63 | with:
64 | name: coverage-report
65 | path: coverage/
66 | retention-days: 30
67 |
68 |
```
--------------------------------------------------------------------------------
/src/handlers/core/ListColorsHandler.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
2 | import { OAuth2Client } from "google-auth-library";
3 | import { BaseToolHandler } from "./BaseToolHandler.js";
4 | import { calendar_v3 } from "googleapis";
5 | import { createStructuredResponse } from "../../utils/response-builder.js";
6 | import { ListColorsResponse } from "../../types/structured-responses.js";
7 |
8 | export class ListColorsHandler extends BaseToolHandler {
9 | async runTool(_: any, oauth2Client: OAuth2Client): Promise<CallToolResult> {
10 | const colors = await this.listColors(oauth2Client);
11 |
12 | const response: ListColorsResponse = {
13 | event: {},
14 | calendar: {}
15 | };
16 |
17 | // Convert event colors
18 | if (colors.event) {
19 | for (const [id, color] of Object.entries(colors.event)) {
20 | response.event[id] = {
21 | background: color.background || '',
22 | foreground: color.foreground || ''
23 | };
24 | }
25 | }
26 |
27 | // Convert calendar colors
28 | if (colors.calendar) {
29 | for (const [id, color] of Object.entries(colors.calendar)) {
30 | response.calendar[id] = {
31 | background: color.background || '',
32 | foreground: color.foreground || ''
33 | };
34 | }
35 | }
36 |
37 | return createStructuredResponse(response);
38 | }
39 |
40 | private async listColors(client: OAuth2Client): Promise<calendar_v3.Schema$Colors> {
41 | try {
42 | const calendar = this.getCalendar(client);
43 | const response = await calendar.colors.get();
44 | if (!response.data) throw new Error('Failed to retrieve colors');
45 | return response.data;
46 | } catch (error) {
47 | throw this.handleGoogleApiError(error);
48 | }
49 | }
50 |
51 | /**
52 | * Formats the color information into a user-friendly string.
53 | */
54 | private formatColorList(colors: calendar_v3.Schema$Colors): string {
55 | const eventColors = colors.event || {};
56 | return Object.entries(eventColors)
57 | .map(([id, colorInfo]) => `Color ID: ${id} - ${colorInfo.background} (background) / ${colorInfo.foreground} (foreground)`)
58 | .join("\n");
59 | }
60 | }
61 |
```
--------------------------------------------------------------------------------
/src/auth/client.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { OAuth2Client } from 'google-auth-library';
2 | import * as fs from 'fs/promises';
3 | import { getKeysFilePath, generateCredentialsErrorMessage, OAuthCredentials } from './utils.js';
4 |
5 | async function loadCredentialsFromFile(): Promise<OAuthCredentials> {
6 | const keysContent = await fs.readFile(getKeysFilePath(), "utf-8");
7 | const keys = JSON.parse(keysContent);
8 |
9 | if (keys.installed) {
10 | // Standard OAuth credentials file format
11 | const { client_id, client_secret, redirect_uris } = keys.installed;
12 | return { client_id, client_secret, redirect_uris };
13 | } else if (keys.client_id && keys.client_secret) {
14 | // Direct format
15 | return {
16 | client_id: keys.client_id,
17 | client_secret: keys.client_secret,
18 | redirect_uris: keys.redirect_uris || ['http://localhost:3000/oauth2callback']
19 | };
20 | } else {
21 | throw new Error('Invalid credentials file format. Expected either "installed" object or direct client_id/client_secret fields.');
22 | }
23 | }
24 |
25 | async function loadCredentialsWithFallback(): Promise<OAuthCredentials> {
26 | // Load credentials from file (CLI param, env var, or default path)
27 | try {
28 | return await loadCredentialsFromFile();
29 | } catch (fileError) {
30 | // Generate helpful error message
31 | const errorMessage = generateCredentialsErrorMessage();
32 | throw new Error(`${errorMessage}\n\nOriginal error: ${fileError instanceof Error ? fileError.message : fileError}`);
33 | }
34 | }
35 |
36 | export async function initializeOAuth2Client(): Promise<OAuth2Client> {
37 | // Always use real OAuth credentials - no mocking.
38 | // Unit tests should mock at the handler level, integration tests need real credentials.
39 | try {
40 | const credentials = await loadCredentialsWithFallback();
41 |
42 | // Use the first redirect URI as the default for the base client
43 | return new OAuth2Client({
44 | clientId: credentials.client_id,
45 | clientSecret: credentials.client_secret,
46 | redirectUri: credentials.redirect_uris[0],
47 | });
48 | } catch (error) {
49 | throw new Error(`Error loading OAuth keys: ${error instanceof Error ? error.message : error}`);
50 | }
51 | }
52 |
53 | export async function loadCredentials(): Promise<{ client_id: string; client_secret: string }> {
54 | try {
55 | const credentials = await loadCredentialsWithFallback();
56 |
57 | if (!credentials.client_id || !credentials.client_secret) {
58 | throw new Error('Client ID or Client Secret missing in credentials.');
59 | }
60 | return {
61 | client_id: credentials.client_id,
62 | client_secret: credentials.client_secret
63 | };
64 | } catch (error) {
65 | throw new Error(`Error loading credentials: ${error instanceof Error ? error.message : error}`);
66 | }
67 | }
```
--------------------------------------------------------------------------------
/src/tests/unit/handlers/datetime-utils.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, it, expect } from 'vitest';
2 | import { hasTimezoneInDatetime, convertToRFC3339, createTimeObject } from '../../../handlers/utils/datetime.js';
3 |
4 | describe('Datetime Utilities', () => {
5 | describe('hasTimezoneInDatetime', () => {
6 | it('should return true for timezone-aware datetime strings', () => {
7 | expect(hasTimezoneInDatetime('2024-01-01T10:00:00Z')).toBe(true);
8 | expect(hasTimezoneInDatetime('2024-01-01T10:00:00+05:00')).toBe(true);
9 | expect(hasTimezoneInDatetime('2024-01-01T10:00:00-08:00')).toBe(true);
10 | });
11 |
12 | it('should return false for timezone-naive datetime strings', () => {
13 | expect(hasTimezoneInDatetime('2024-01-01T10:00:00')).toBe(false);
14 | expect(hasTimezoneInDatetime('2024-01-01 10:00:00')).toBe(false);
15 | });
16 | });
17 |
18 | describe('convertToRFC3339', () => {
19 | it('should return timezone-aware datetime unchanged', () => {
20 | const datetime = '2024-01-01T10:00:00Z';
21 | expect(convertToRFC3339(datetime, 'America/Los_Angeles')).toBe(datetime);
22 | });
23 |
24 | it('should return timezone-aware datetime with offset unchanged', () => {
25 | const datetime = '2024-01-01T10:00:00-08:00';
26 | expect(convertToRFC3339(datetime, 'America/Los_Angeles')).toBe(datetime);
27 | });
28 |
29 | it('should convert timezone-naive datetime using fallback timezone', () => {
30 | const datetime = '2024-06-15T14:30:00';
31 | const result = convertToRFC3339(datetime, 'UTC');
32 |
33 | // Should result in a timezone-aware string (the exact time depends on system timezone)
34 | expect(result).toMatch(/2024-06-15T\d{2}:\d{2}:\d{2}Z/);
35 | expect(result).not.toBe(datetime); // Should be different from input
36 | });
37 |
38 | it('should fallback to UTC for invalid timezone conversion', () => {
39 | const datetime = '2024-01-01T10:00:00';
40 | const result = convertToRFC3339(datetime, 'Invalid/Timezone');
41 |
42 | // Should fallback to UTC
43 | expect(result).toBe('2024-01-01T10:00:00Z');
44 | });
45 | });
46 |
47 | describe('createTimeObject', () => {
48 | it('should create time object without timeZone for timezone-aware datetime', () => {
49 | const datetime = '2024-01-01T10:00:00Z';
50 | const result = createTimeObject(datetime, 'America/Los_Angeles');
51 |
52 | expect(result).toEqual({
53 | dateTime: datetime
54 | });
55 | });
56 |
57 | it('should create time object with timeZone for timezone-naive datetime', () => {
58 | const datetime = '2024-01-01T10:00:00';
59 | const timezone = 'America/Los_Angeles';
60 | const result = createTimeObject(datetime, timezone);
61 |
62 | expect(result).toEqual({
63 | dateTime: datetime,
64 | timeZone: timezone
65 | });
66 | });
67 | });
68 | });
```
--------------------------------------------------------------------------------
/src/handlers/core/ListCalendarsHandler.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
2 | import { OAuth2Client } from "google-auth-library";
3 | import { BaseToolHandler } from "./BaseToolHandler.js";
4 | import { calendar_v3 } from "googleapis";
5 | import { ListCalendarsResponse } from "../../types/structured-responses.js";
6 | import { createStructuredResponse } from "../../utils/response-builder.js";
7 |
8 | export class ListCalendarsHandler extends BaseToolHandler {
9 | async runTool(_: any, oauth2Client: OAuth2Client): Promise<CallToolResult> {
10 | const calendars = await this.listCalendars(oauth2Client);
11 |
12 | const response: ListCalendarsResponse = {
13 | calendars: calendars.map(cal => ({
14 | id: cal.id || '',
15 | summary: cal.summary ?? undefined,
16 | description: cal.description ?? undefined,
17 | location: cal.location ?? undefined,
18 | timeZone: cal.timeZone ?? undefined,
19 | summaryOverride: cal.summaryOverride ?? undefined,
20 | colorId: cal.colorId ?? undefined,
21 | backgroundColor: cal.backgroundColor ?? undefined,
22 | foregroundColor: cal.foregroundColor ?? undefined,
23 | hidden: cal.hidden ?? undefined,
24 | selected: cal.selected ?? undefined,
25 | accessRole: cal.accessRole ?? undefined,
26 | defaultReminders: cal.defaultReminders?.map(r => ({
27 | method: (r.method as 'email' | 'popup') || 'popup',
28 | minutes: r.minutes || 0
29 | })),
30 | notificationSettings: cal.notificationSettings ? {
31 | notifications: cal.notificationSettings.notifications?.map(n => ({
32 | type: n.type ?? undefined,
33 | method: n.method ?? undefined
34 | }))
35 | } : undefined,
36 | primary: cal.primary ?? undefined,
37 | deleted: cal.deleted ?? undefined,
38 | conferenceProperties: cal.conferenceProperties ? {
39 | allowedConferenceSolutionTypes: cal.conferenceProperties.allowedConferenceSolutionTypes ?? undefined
40 | } : undefined
41 | })),
42 | totalCount: calendars.length
43 | };
44 |
45 | return createStructuredResponse(response);
46 | }
47 |
48 | private async listCalendars(client: OAuth2Client): Promise<calendar_v3.Schema$CalendarListEntry[]> {
49 | try {
50 | const calendar = this.getCalendar(client);
51 | const response = await calendar.calendarList.list();
52 | return response.data.items || [];
53 | } catch (error) {
54 | throw this.handleGoogleApiError(error);
55 | }
56 | }
57 | }
58 |
```
--------------------------------------------------------------------------------
/src/auth-server.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { initializeOAuth2Client } from './auth/client.js';
2 | import { AuthServer } from './auth/server.js';
3 |
4 | async function runAuthServer() {
5 | let authServer: AuthServer | null = null; // Keep reference for cleanup
6 | try {
7 | const oauth2Client = await initializeOAuth2Client();
8 |
9 | authServer = new AuthServer(oauth2Client);
10 |
11 | const success = await authServer.start(true);
12 |
13 | if (!success && !authServer.authCompletedSuccessfully) {
14 | process.stderr.write('Authentication failed. Could not start server or validate existing tokens.\n');
15 | process.exit(1);
16 | } else if (authServer.authCompletedSuccessfully) {
17 | process.stderr.write('Authentication successful.\n');
18 | process.exit(0);
19 | }
20 |
21 | // If we reach here, the server started and is waiting for the browser callback
22 | process.stderr.write('Authentication server started. Please complete the authentication in your browser...\n');
23 |
24 |
25 | process.stderr.write(`Waiting for OAuth callback on port ${authServer.getRunningPort()}...\n`);
26 |
27 | // Poll for completion or handle SIGINT
28 | let lastDebugLog = 0;
29 | const pollInterval = setInterval(async () => {
30 | try {
31 | if (authServer?.authCompletedSuccessfully) {
32 | process.stderr.write('Authentication completed successfully detected. Stopping server...\n');
33 | clearInterval(pollInterval);
34 | await authServer.stop();
35 | process.stderr.write('Authentication successful. Server stopped.\n');
36 | process.exit(0);
37 | } else {
38 | // Add debug logging every 10 seconds to show we're still waiting
39 | const now = Date.now();
40 | if (now - lastDebugLog > 10000) {
41 | process.stderr.write('Still waiting for authentication to complete...\n');
42 | lastDebugLog = now;
43 | }
44 | }
45 | } catch (error: unknown) {
46 | process.stderr.write(`Error in polling interval: ${error instanceof Error ? error.message : 'Unknown error'}\n`);
47 | clearInterval(pollInterval);
48 | if (authServer) await authServer.stop();
49 | process.exit(1);
50 | }
51 | }, 5000); // Check every second
52 |
53 | // Handle process termination (SIGINT)
54 | process.on('SIGINT', async () => {
55 | clearInterval(pollInterval); // Stop polling
56 | if (authServer) {
57 | await authServer.stop();
58 | }
59 | process.exit(0);
60 | });
61 |
62 | } catch (error: unknown) {
63 | process.stderr.write(`Authentication error: ${error instanceof Error ? error.message : 'Unknown error'}\n`);
64 | if (authServer) await authServer.stop(); // Attempt cleanup
65 | process.exit(1);
66 | }
67 | }
68 |
69 | // Run the auth server if this file is executed directly
70 | if (import.meta.url.endsWith('auth-server.js')) {
71 | runAuthServer().catch((error: unknown) => {
72 | process.stderr.write(`Unhandled error: ${error instanceof Error ? error.message : 'Unknown error'}\n`);
73 | process.exit(1);
74 | });
75 | }
```
--------------------------------------------------------------------------------
/src/tests/unit/handlers/create-event-blocking.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, it, expect, vi } from 'vitest';
2 | import { CreateEventHandler } from '../../../handlers/core/CreateEventHandler.js';
3 | import { OAuth2Client } from 'google-auth-library';
4 | import { calendar_v3 } from 'googleapis';
5 | import { CONFLICT_DETECTION_CONFIG } from '../../../services/conflict-detection/config.js';
6 |
7 | describe('CreateEventHandler Blocking Logic', () => {
8 | const mockOAuth2Client = {
9 | getAccessToken: vi.fn().mockResolvedValue({ token: 'mock-token' })
10 | } as unknown as OAuth2Client;
11 |
12 | it('should show full event details when blocking due to high similarity', async () => {
13 | const handler = new CreateEventHandler();
14 |
15 | // Mock the conflict detection service
16 | const existingEvent: calendar_v3.Schema$Event = {
17 | id: 'existing-lunch-123',
18 | summary: 'Lunch with Josh',
19 | description: 'Monthly catch-up lunch',
20 | location: 'The Coffee Shop',
21 | start: {
22 | dateTime: '2024-01-15T12:00:00-08:00',
23 | timeZone: 'America/Los_Angeles'
24 | },
25 | end: {
26 | dateTime: '2024-01-15T13:00:00-08:00',
27 | timeZone: 'America/Los_Angeles'
28 | },
29 | attendees: [
30 | { email: '[email protected]', displayName: 'Josh', responseStatus: 'accepted' }
31 | ],
32 | htmlLink: 'https://calendar.google.com/calendar/event?eid=existing-lunch-123'
33 | };
34 |
35 | // Mock the checkConflicts method to return a high similarity duplicate
36 | vi.spyOn(handler['conflictDetectionService'], 'checkConflicts').mockResolvedValue({
37 | hasConflicts: true,
38 | duplicates: [{
39 | event: {
40 | id: 'existing-lunch-123',
41 | title: 'Lunch with Josh',
42 | url: 'https://calendar.google.com/calendar/event?eid=existing-lunch-123',
43 | similarity: 1.0 // 100% similar
44 | },
45 | fullEvent: existingEvent,
46 | calendarId: 'primary',
47 | suggestion: 'This appears to be a duplicate. Consider updating the existing event instead.'
48 | }],
49 | conflicts: []
50 | });
51 |
52 | // Mock getCalendarTimezone
53 | vi.spyOn(handler as any, 'getCalendarTimezone').mockResolvedValue('America/Los_Angeles');
54 |
55 | const args = {
56 | calendarId: 'primary',
57 | summary: 'Lunch with Josh',
58 | start: '2024-01-15T12:00:00',
59 | end: '2024-01-15T13:00:00',
60 | location: 'The Coffee Shop'
61 | };
62 |
63 | // Now it should throw an error instead of returning a text message
64 | await expect(handler.runTool(args, mockOAuth2Client)).rejects.toThrow(
65 | 'Duplicate event detected (100% similar). Event "Lunch with Josh" already exists. To create anyway, set allowDuplicates to true.'
66 | );
67 | });
68 |
69 | it('should use centralized threshold configuration', () => {
70 | // Verify that the config has the expected thresholds
71 | expect(CONFLICT_DETECTION_CONFIG.DEFAULT_DUPLICATE_THRESHOLD).toBe(0.7);
72 | expect(CONFLICT_DETECTION_CONFIG.DUPLICATE_THRESHOLDS.WARNING).toBe(0.7);
73 | expect(CONFLICT_DETECTION_CONFIG.DUPLICATE_THRESHOLDS.BLOCKING).toBe(0.95);
74 | });
75 | });
```
--------------------------------------------------------------------------------
/src/tests/unit/handlers/utils.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, it, expect } from 'vitest';
2 | import { generateEventUrl, getEventUrl } from '../../../handlers/utils.js';
3 | import { calendar_v3 } from 'googleapis';
4 |
5 | describe('Event URL Utilities', () => {
6 | describe('generateEventUrl', () => {
7 | it('should generate a proper Google Calendar event URL', () => {
8 | const calendarId = '[email protected]';
9 | const eventId = 'abc123def456';
10 | const url = generateEventUrl(calendarId, eventId);
11 |
12 | expect(url).toBe('https://calendar.google.com/calendar/event?eid=abc123def456&cid=user%40example.com');
13 | });
14 |
15 | it('should properly encode special characters in calendar ID', () => {
16 | const calendarId = '[email protected]';
17 | const eventId = 'event123';
18 | const url = generateEventUrl(calendarId, eventId);
19 |
20 | expect(url).toBe('https://calendar.google.com/calendar/event?eid=event123&cid=user%40test-calendar.com');
21 | });
22 |
23 | it('should properly encode special characters in event ID', () => {
24 | const calendarId = '[email protected]';
25 | const eventId = 'event+with+special&chars';
26 | const url = generateEventUrl(calendarId, eventId);
27 |
28 | expect(url).toBe('https://calendar.google.com/calendar/event?eid=event%2Bwith%2Bspecial%26chars&cid=user%40example.com');
29 | });
30 | });
31 |
32 | describe('getEventUrl', () => {
33 | const mockEvent: calendar_v3.Schema$Event = {
34 | id: 'test123',
35 | summary: 'Test Event',
36 | start: { dateTime: '2024-03-15T10:00:00-07:00' },
37 | end: { dateTime: '2024-03-15T11:00:00-07:00' },
38 | location: 'Conference Room A',
39 | description: 'Test meeting'
40 | };
41 |
42 | it('should use htmlLink when available', () => {
43 | const eventWithHtmlLink = {
44 | ...mockEvent,
45 | htmlLink: 'https://calendar.google.com/event?eid=existing123'
46 | };
47 |
48 | const result = getEventUrl(eventWithHtmlLink);
49 | expect(result).toBe('https://calendar.google.com/event?eid=existing123');
50 | });
51 |
52 | it('should generate URL when htmlLink is not available but calendarId is provided', () => {
53 | const result = getEventUrl(mockEvent, '[email protected]');
54 | expect(result).toBe('https://calendar.google.com/calendar/event?eid=test123&cid=user%40example.com');
55 | });
56 |
57 | it('should return null when htmlLink is not available and calendarId is not provided', () => {
58 | const result = getEventUrl(mockEvent);
59 | expect(result).toBeNull();
60 | });
61 |
62 | it('should return null when event has no ID', () => {
63 | const eventWithoutId = { ...mockEvent, id: undefined };
64 | const result = getEventUrl(eventWithoutId, '[email protected]');
65 | expect(result).toBeNull();
66 | });
67 | });
68 | });
```
--------------------------------------------------------------------------------
/src/tests/unit/schemas/no-refs.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, it, expect } from 'vitest';
2 | import { ToolRegistry } from '../../../tools/registry.js';
3 |
4 | describe('Schema $ref Prevention Tests', () => {
5 | it('should not generate $ref references in JSON schemas, causes issues with Claude Desktop', () => {
6 | const tools = ToolRegistry.getToolsWithSchemas();
7 |
8 | // Convert each tool schema to JSON Schema and check for $ref
9 | for (const tool of tools) {
10 | const jsonSchema = JSON.stringify(tool.inputSchema);
11 |
12 | // Check for any $ref references
13 | const hasRef = jsonSchema.includes('"$ref"');
14 |
15 | if (hasRef) {
16 | console.error(`Tool "${tool.name}" contains $ref in schema:`, jsonSchema);
17 | }
18 |
19 | expect(hasRef).toBe(false);
20 | }
21 | });
22 |
23 | it('should have unique schema instances for similar parameters', () => {
24 | const tools = ToolRegistry.getToolsWithSchemas();
25 |
26 | // Find tools with timeMin/timeMax or start/end parameters
27 | const timeParams = [];
28 |
29 | for (const tool of tools) {
30 | const schemaStr = JSON.stringify(tool.inputSchema);
31 | if (schemaStr.includes('timeMin') || schemaStr.includes('timeMax') ||
32 | schemaStr.includes('"start"') || schemaStr.includes('"end"')) {
33 | timeParams.push(tool.name);
34 | }
35 | }
36 |
37 | // Ensure we're testing the right tools
38 | expect(timeParams.length).toBeGreaterThan(0);
39 | console.log('Tools with time parameters:', timeParams);
40 | });
41 |
42 | it('should detect if shared schema instances are reused', () => {
43 | // This test checks the source code structure to prevent regression
44 | const registryCode = require('fs').readFileSync(
45 | require('path').join(__dirname, '../../../tools/registry.ts'),
46 | 'utf8'
47 | );
48 |
49 | // Check for problematic patterns that could cause $ref generation
50 | // Note: Removed negative lookahead (?!\.) to catch ALL schema reuse including .optional() and .describe()
51 | const sharedSchemaUsage = [
52 | /timeMin:\s*[A-Z][a-zA-Z]*Schema/, // timeMin: SomeSchema (any usage)
53 | /timeMax:\s*[A-Z][a-zA-Z]*Schema/, // timeMax: SomeSchema
54 | /start:\s*[A-Z][a-zA-Z]*Schema/, // start: SomeSchema
55 | /end:\s*[A-Z][a-zA-Z]*Schema/, // end: SomeSchema
56 | /calendarId:\s*[A-Z][a-zA-Z]*Schema/, // calendarId: SomeSchema
57 | /eventId:\s*[A-Z][a-zA-Z]*Schema/, // eventId: SomeSchema
58 | /query:\s*[A-Z][a-zA-Z]*Schema/, // query: SomeSchema
59 | /summary:\s*[A-Z][a-zA-Z]*Schema/, // summary: SomeSchema
60 | /description:\s*[A-Z][a-zA-Z]*Schema/, // description: SomeSchema
61 | /location:\s*[A-Z][a-zA-Z]*Schema/, // location: SomeSchema
62 | /colorId:\s*[A-Z][a-zA-Z]*Schema/, // colorId: SomeSchema
63 | /reminders:\s*[A-Z][a-zA-Z]*Schema/, // reminders: SomeSchema
64 | /attendees:\s*[A-Z][a-zA-Z]*Schema/, // attendees: SomeSchema
65 | /email:\s*[A-Z][a-zA-Z]*Schema/ // email: SomeSchema
66 | ];
67 |
68 | for (const pattern of sharedSchemaUsage) {
69 | const matches = registryCode.match(pattern);
70 | if (matches) {
71 | console.error(`Found potentially problematic schema usage: ${matches[0]}`);
72 | expect(matches).toBeNull();
73 | }
74 | }
75 | });
76 | });
```
--------------------------------------------------------------------------------
/src/utils/event-id-validator.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Event ID validation utility for Google Calendar API
3 | */
4 |
5 | /**
6 | * Validates a custom event ID according to Google Calendar requirements
7 | * @param eventId The event ID to validate
8 | * @returns true if valid, false otherwise
9 | */
10 | export function isValidEventId(eventId: string): boolean {
11 | // Check length constraints (5-1024 characters)
12 | if (eventId.length < 5 || eventId.length > 1024) {
13 | return false;
14 | }
15 |
16 | // Check character constraints (base32hex encoding)
17 | // Google Calendar allows only: lowercase letters a-v and digits 0-9
18 | // Based on RFC2938 section 3.1.2
19 | const validPattern = /^[a-v0-9]+$/;
20 | return validPattern.test(eventId);
21 | }
22 |
23 | /**
24 | * Validates and throws an error if the event ID is invalid
25 | * @param eventId The event ID to validate
26 | * @throws Error if the event ID is invalid
27 | */
28 | export function validateEventId(eventId: string): void {
29 | if (!isValidEventId(eventId)) {
30 | const errors: string[] = [];
31 |
32 | if (eventId.length < 5) {
33 | errors.push("must be at least 5 characters long");
34 | }
35 |
36 | if (eventId.length > 1024) {
37 | errors.push("must not exceed 1024 characters");
38 | }
39 |
40 | if (!/^[a-v0-9]+$/.test(eventId)) {
41 | errors.push("can only contain lowercase letters a-v and digits 0-9 (base32hex encoding)");
42 | }
43 |
44 | throw new Error(`Invalid event ID: ${errors.join(", ")}`);
45 | }
46 | }
47 |
48 | /**
49 | * Sanitizes a string to make it a valid event ID
50 | * Converts to base32hex encoding (lowercase a-v and 0-9 only)
51 | * @param input The input string to sanitize
52 | * @returns A valid event ID
53 | */
54 | export function sanitizeEventId(input: string): string {
55 | // Convert to lowercase first
56 | let sanitized = input.toLowerCase();
57 |
58 | // Replace invalid characters:
59 | // - Keep digits 0-9 as is
60 | // - Map letters w-z to a-d (shift back)
61 | // - Map other characters to valid base32hex characters
62 | sanitized = sanitized.replace(/[^a-v0-9]/g, (char) => {
63 | // Map w-z to a-d
64 | if (char >= 'w' && char <= 'z') {
65 | return String.fromCharCode(char.charCodeAt(0) - 22); // w->a, x->b, y->c, z->d
66 | }
67 | // Map any other character to a default valid character
68 | return '';
69 | });
70 |
71 | // Remove any empty spaces from the mapping
72 | sanitized = sanitized.replace(/\s+/g, '');
73 |
74 | // Ensure minimum length
75 | if (sanitized.length < 5) {
76 | // Generate a base32hex timestamp
77 | const timestamp = Date.now().toString(32).replace(/[w-z]/g, (c) =>
78 | String.fromCharCode(c.charCodeAt(0) - 22)
79 | );
80 |
81 | if (sanitized.length === 0) {
82 | sanitized = `event${timestamp}`.substring(0, 26); // Match Google's 26-char format
83 | } else {
84 | sanitized = `${sanitized}${timestamp}`.substring(0, 26);
85 | }
86 | }
87 |
88 | // Ensure maximum length
89 | if (sanitized.length > 1024) {
90 | sanitized = sanitized.slice(0, 1024);
91 | }
92 |
93 | // Final validation - ensure only valid characters
94 | sanitized = sanitized.replace(/[^a-v0-9]/g, '');
95 |
96 | // If still too short after all operations, generate a default
97 | if (sanitized.length < 5) {
98 | // Generate a valid base32hex ID
99 | const now = Date.now();
100 | const base32hex = now.toString(32).replace(/[w-z]/g, (c) =>
101 | String.fromCharCode(c.charCodeAt(0) - 22)
102 | );
103 | sanitized = `ev${base32hex}`.substring(0, 26);
104 | }
105 |
106 | return sanitized;
107 | }
```
--------------------------------------------------------------------------------
/src/handlers/core/SearchEventsHandler.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
2 | import { OAuth2Client } from "google-auth-library";
3 | import { SearchEventsInput } from "../../tools/registry.js";
4 | import { BaseToolHandler } from "./BaseToolHandler.js";
5 | import { calendar_v3 } from 'googleapis';
6 | import { convertToRFC3339 } from "../utils/datetime.js";
7 | import { buildListFieldMask } from "../../utils/field-mask-builder.js";
8 | import { createStructuredResponse, convertEventsToStructured } from "../../utils/response-builder.js";
9 | import { SearchEventsResponse } from "../../types/structured-responses.js";
10 |
11 | export class SearchEventsHandler extends BaseToolHandler {
12 | async runTool(args: any, oauth2Client: OAuth2Client): Promise<CallToolResult> {
13 | const validArgs = args as SearchEventsInput;
14 | const events = await this.searchEvents(oauth2Client, validArgs);
15 |
16 | const response: SearchEventsResponse = {
17 | events: convertEventsToStructured(events, validArgs.calendarId),
18 | totalCount: events.length,
19 | query: validArgs.query,
20 | calendarId: validArgs.calendarId
21 | };
22 |
23 | if (validArgs.timeMin || validArgs.timeMax) {
24 | const timezone = validArgs.timeZone || await this.getCalendarTimezone(oauth2Client, validArgs.calendarId);
25 | response.timeRange = {
26 | start: validArgs.timeMin ? convertToRFC3339(validArgs.timeMin, timezone) : '',
27 | end: validArgs.timeMax ? convertToRFC3339(validArgs.timeMax, timezone) : ''
28 | };
29 | }
30 |
31 | return createStructuredResponse(response);
32 | }
33 |
34 | private async searchEvents(
35 | client: OAuth2Client,
36 | args: SearchEventsInput
37 | ): Promise<calendar_v3.Schema$Event[]> {
38 | try {
39 | const calendar = this.getCalendar(client);
40 |
41 | // Determine timezone with correct precedence:
42 | // 1. Explicit timeZone parameter (highest priority)
43 | // 2. Calendar's default timezone (fallback)
44 | const timezone = args.timeZone || await this.getCalendarTimezone(client, args.calendarId);
45 |
46 | // Convert time boundaries to RFC3339 format for Google Calendar API
47 | // Note: convertToRFC3339 will still respect timezone in datetime string as highest priority
48 | const timeMin = convertToRFC3339(args.timeMin, timezone);
49 | const timeMax = convertToRFC3339(args.timeMax, timezone);
50 |
51 | const fieldMask = buildListFieldMask(args.fields);
52 |
53 | const response = await calendar.events.list({
54 | calendarId: args.calendarId,
55 | q: args.query,
56 | timeMin,
57 | timeMax,
58 | singleEvents: true,
59 | orderBy: 'startTime',
60 | ...(fieldMask && { fields: fieldMask }),
61 | ...(args.privateExtendedProperty && { privateExtendedProperty: args.privateExtendedProperty as any }),
62 | ...(args.sharedExtendedProperty && { sharedExtendedProperty: args.sharedExtendedProperty as any })
63 | });
64 | return response.data.items || [];
65 | } catch (error) {
66 | throw this.handleGoogleApiError(error);
67 | }
68 | }
69 |
70 | }
71 |
```
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
```markdown
1 | # Changelog
2 |
3 | ## [2.0.6](https://github.com/nspady/google-calendar-mcp/compare/v2.0.5...v2.0.6) (2025-10-22)
4 |
5 |
6 | ### Bug Fixes
7 |
8 | * support converting between timed and all-day events in update-event ([#119](https://github.com/nspady/google-calendar-mcp/issues/119)) ([407e4c8](https://github.com/nspady/google-calendar-mcp/commit/407e4c89753932e13f9ccd55800999b4b12288be))
9 |
10 | ## [2.0.5](https://github.com/nspady/google-calendar-mcp/compare/v2.0.4...v2.0.5) (2025-10-19)
11 |
12 |
13 | ### Bug Fixes
14 |
15 | * **list-events:** support native arrays for Python MCP clients ([#95](https://github.com/nspady/google-calendar-mcp/issues/95)) ([#116](https://github.com/nspady/google-calendar-mcp/issues/116)) ([0e91c23](https://github.com/nspady/google-calendar-mcp/commit/0e91c23c9ae9db0c0ff863cd9019f6212544f62a))
16 |
17 | ## [2.0.4](https://github.com/nspady/google-calendar-mcp/compare/v2.0.3...v2.0.4) (2025-10-15)
18 |
19 |
20 | ### Bug Fixes
21 |
22 | * resolve macOS installation error and improve publish workflow ([ec13f39](https://github.com/nspady/google-calendar-mcp/commit/ec13f397652a864cccd003f05ddd03d4e046316f)), closes [#113](https://github.com/nspady/google-calendar-mcp/issues/113)
23 |
24 | ## [2.0.3](https://github.com/nspady/google-calendar-mcp/compare/v2.0.2...v2.0.3) (2025-10-15)
25 |
26 |
27 | ### Bug Fixes
28 |
29 | * move esbuild to devDependencies and fix publish workflow ([3900358](https://github.com/nspady/google-calendar-mcp/commit/39003589278dbab95c85f27af012293405f34f74)), closes [#113](https://github.com/nspady/google-calendar-mcp/issues/113)
30 |
31 | ## [2.0.2](https://github.com/nspady/google-calendar-mcp/compare/v2.0.1...v2.0.2) (2025-10-14)
32 |
33 |
34 | ### Bug Fixes
35 |
36 | * **auth:** improve port availability error message ([9205fd7](https://github.com/nspady/google-calendar-mcp/commit/9205fd75445702d9e49520e4183c96a93078ea46)), closes [#110](https://github.com/nspady/google-calendar-mcp/issues/110)
37 |
38 | ## [2.0.1](https://github.com/nspady/google-calendar-mcp/compare/v2.0.0...v2.0.1) (2025-10-13)
39 |
40 |
41 | ### Bug Fixes
42 |
43 | * auto-resolve calendar names and summaryOverride to IDs (closes [#104](https://github.com/nspady/google-calendar-mcp/issues/104)) ([#105](https://github.com/nspady/google-calendar-mcp/issues/105)) ([d10225c](https://github.com/nspady/google-calendar-mcp/commit/d10225ca767a0641fef118cf3d56869bf66e2421))
44 | * Resolve rollup optional dependency issue in CI ([#102](https://github.com/nspady/google-calendar-mcp/issues/102)) ([0bc39bd](https://github.com/nspady/google-calendar-mcp/commit/0bc39bd54fdb57828b033153974e1a93e2b38737))
45 | * Support single-quoted JSON arrays in list-events calendarId ([d2af7cf](https://github.com/nspady/google-calendar-mcp/commit/d2af7cf99e3d090bceb388cbf10f7f9649100e3c))
46 | * update publish workflow to use release-please ([47addc9](https://github.com/nspady/google-calendar-mcp/commit/47addc95cc04e552017afd7523638795bf9f9090))
47 |
48 | ## [2.0.2](https://github.com/nspady/google-calendar-mcp/compare/v2.0.1...v2.0.2) (2025-10-13)
49 |
50 | ### Bug Fixes
51 |
52 | * auto-resolve calendar names and summaryOverride to IDs (closes [#104](https://github.com/nspady/google-calendar-mcp/issues/104)) ([#105](https://github.com/nspady/google-calendar-mcp/issues/105)) ([d10225c](https://github.com/nspady/google-calendar-mcp/commit/d10225ca767a0641fef118cf3d56869bf66e2421))
53 | * update publish workflow to use release-please ([47addc9](https://github.com/nspady/google-calendar-mcp/commit/47addc95cc04e552017afd7523638795bf9f9090))
54 |
55 | ## [2.0.1](https://github.com/nspady/google-calendar-mcp/compare/v2.0.0...v2.0.1) (2025-10-11)
56 |
57 | ### Bug Fixes
58 |
59 | * Resolve rollup optional dependency issue in CI ([#102](https://github.com/nspady/google-calendar-mcp/issues/102)) ([0bc39bd](https://github.com/nspady/google-calendar-mcp/commit/0bc39bd54fdb57828b033153974e1a93e2b38737))
60 | * Support single-quoted JSON arrays in list-events calendarId ([d2af7cf](https://github.com/nspady/google-calendar-mcp/commit/d2af7cf99e3d090bceb388cbf10f7f9649100e3c))
61 |
```
--------------------------------------------------------------------------------
/src/utils/field-mask-builder.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Field mask builder for Google Calendar API partial response
3 | */
4 |
5 | // Allowed fields that can be requested from Google Calendar API
6 | export const ALLOWED_EVENT_FIELDS = [
7 | 'id',
8 | 'summary',
9 | 'description',
10 | 'start',
11 | 'end',
12 | 'location',
13 | 'attendees',
14 | 'colorId',
15 | 'transparency',
16 | 'extendedProperties',
17 | 'reminders',
18 | 'conferenceData',
19 | 'attachments',
20 | 'status',
21 | 'htmlLink',
22 | 'created',
23 | 'updated',
24 | 'creator',
25 | 'organizer',
26 | 'recurrence',
27 | 'recurringEventId',
28 | 'originalStartTime',
29 | 'visibility',
30 | 'iCalUID',
31 | 'sequence',
32 | 'hangoutLink',
33 | 'anyoneCanAddSelf',
34 | 'guestsCanInviteOthers',
35 | 'guestsCanModify',
36 | 'guestsCanSeeOtherGuests',
37 | 'privateCopy',
38 | 'locked',
39 | 'source',
40 | 'eventType'
41 | ] as const;
42 |
43 | export type AllowedEventField = typeof ALLOWED_EVENT_FIELDS[number];
44 |
45 | // Default fields always included
46 | export const DEFAULT_EVENT_FIELDS: AllowedEventField[] = [
47 | 'id',
48 | 'summary',
49 | 'start',
50 | 'end',
51 | 'status',
52 | 'htmlLink',
53 | 'location',
54 | 'attendees'
55 | ];
56 |
57 | /**
58 | * Validates that requested fields are allowed
59 | */
60 | export function validateFields(fields: string[]): AllowedEventField[] {
61 | const validFields: AllowedEventField[] = [];
62 | const invalidFields: string[] = [];
63 |
64 | for (const field of fields) {
65 | if (ALLOWED_EVENT_FIELDS.includes(field as AllowedEventField)) {
66 | validFields.push(field as AllowedEventField);
67 | } else {
68 | invalidFields.push(field);
69 | }
70 | }
71 |
72 | if (invalidFields.length > 0) {
73 | throw new Error(`Invalid fields requested: ${invalidFields.join(', ')}. Allowed fields: ${ALLOWED_EVENT_FIELDS.join(', ')}`);
74 | }
75 |
76 | return validFields;
77 | }
78 |
79 | /**
80 | * Builds a Google Calendar API field mask for partial response
81 | * @param requestedFields Optional array of additional fields to include
82 | * @param includeDefaults Whether to include default fields (default: true)
83 | * @returns Field mask string for Google Calendar API
84 | */
85 | export function buildEventFieldMask(
86 | requestedFields?: string[],
87 | includeDefaults: boolean = true
88 | ): string | undefined {
89 | // If no custom fields requested and we should include defaults, return undefined
90 | // to let Google API return its default field set
91 | if (!requestedFields || requestedFields.length === 0) {
92 | return undefined;
93 | }
94 |
95 | // Validate requested fields
96 | const validFields = validateFields(requestedFields);
97 |
98 | // Combine with defaults if needed
99 | const allFields = includeDefaults
100 | ? [...new Set([...DEFAULT_EVENT_FIELDS, ...validFields])]
101 | : validFields;
102 |
103 | // Build the field mask for events.list
104 | // Format: items(field1,field2,field3)
105 | return `items(${allFields.join(',')})`;
106 | }
107 |
108 | /**
109 | * Builds a field mask for a single event (events.get)
110 | */
111 | export function buildSingleEventFieldMask(
112 | requestedFields?: string[],
113 | includeDefaults: boolean = true
114 | ): string | undefined {
115 | // If no custom fields requested, return undefined for default response
116 | if (!requestedFields || requestedFields.length === 0) {
117 | return undefined;
118 | }
119 |
120 | // Validate requested fields
121 | const validFields = validateFields(requestedFields);
122 |
123 | // Combine with defaults if needed
124 | const allFields = includeDefaults
125 | ? [...new Set([...DEFAULT_EVENT_FIELDS, ...validFields])]
126 | : validFields;
127 |
128 | // For single event, just return comma-separated fields
129 | return allFields.join(',');
130 | }
131 |
132 | /**
133 | * Builds the full field mask parameter for list operations
134 | * Includes nextPageToken, nextSyncToken, etc.
135 | */
136 | export function buildListFieldMask(
137 | requestedFields?: string[],
138 | includeDefaults: boolean = true
139 | ): string | undefined {
140 | // If no custom fields requested, return undefined for default response
141 | if (!requestedFields || requestedFields.length === 0) {
142 | return undefined;
143 | }
144 |
145 | const eventFieldMask = buildEventFieldMask(requestedFields, includeDefaults);
146 | if (!eventFieldMask) {
147 | return undefined;
148 | }
149 |
150 | // Include pagination tokens and other list metadata
151 | return `${eventFieldMask},nextPageToken,nextSyncToken,kind,etag,summary,updated,timeZone,accessRole,defaultReminders`;
152 | }
```
--------------------------------------------------------------------------------
/src/transports/http.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2 | import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
3 | import http from "http";
4 |
5 | export interface HttpTransportConfig {
6 | port?: number;
7 | host?: string;
8 | }
9 |
10 | export class HttpTransportHandler {
11 | private server: McpServer;
12 | private config: HttpTransportConfig;
13 |
14 | constructor(server: McpServer, config: HttpTransportConfig = {}) {
15 | this.server = server;
16 | this.config = config;
17 | }
18 |
19 | async connect(): Promise<void> {
20 | const port = this.config.port || 3000;
21 | const host = this.config.host || '127.0.0.1';
22 |
23 | // Configure transport for stateless mode to allow multiple initialization cycles
24 | const transport = new StreamableHTTPServerTransport({
25 | sessionIdGenerator: undefined // Stateless mode - allows multiple initializations
26 | });
27 |
28 | await this.server.connect(transport);
29 |
30 | // Create HTTP server to handle the StreamableHTTP transport
31 | const httpServer = http.createServer(async (req, res) => {
32 | // Validate Origin header to prevent DNS rebinding attacks (MCP spec requirement)
33 | const origin = req.headers.origin;
34 | const allowedOrigins = [
35 | 'http://localhost',
36 | 'http://127.0.0.1',
37 | 'https://localhost',
38 | 'https://127.0.0.1'
39 | ];
40 |
41 | // For requests with Origin header, validate it
42 | if (origin && !allowedOrigins.some(allowed => origin.startsWith(allowed))) {
43 | res.writeHead(403, { 'Content-Type': 'application/json' });
44 | res.end(JSON.stringify({
45 | error: 'Forbidden: Invalid origin',
46 | message: 'Origin header validation failed'
47 | }));
48 | return;
49 | }
50 |
51 | // Basic request size limiting (prevent DoS)
52 | const contentLength = parseInt(req.headers['content-length'] || '0', 10);
53 | const maxRequestSize = 10 * 1024 * 1024; // 10MB limit
54 | if (contentLength > maxRequestSize) {
55 | res.writeHead(413, { 'Content-Type': 'application/json' });
56 | res.end(JSON.stringify({
57 | error: 'Payload Too Large',
58 | message: 'Request size exceeds maximum allowed size'
59 | }));
60 | return;
61 | }
62 |
63 | // Handle CORS
64 | res.setHeader('Access-Control-Allow-Origin', '*');
65 | res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
66 | res.setHeader('Access-Control-Allow-Headers', 'Content-Type, mcp-session-id');
67 |
68 | if (req.method === 'OPTIONS') {
69 | res.writeHead(200);
70 | res.end();
71 | return;
72 | }
73 |
74 | // Validate Accept header for MCP requests (spec requirement)
75 | if (req.method === 'POST' || req.method === 'GET') {
76 | const acceptHeader = req.headers.accept;
77 | if (acceptHeader && !acceptHeader.includes('application/json') && !acceptHeader.includes('text/event-stream') && !acceptHeader.includes('*/*')) {
78 | res.writeHead(406, { 'Content-Type': 'application/json' });
79 | res.end(JSON.stringify({
80 | error: 'Not Acceptable',
81 | message: 'Accept header must include application/json or text/event-stream'
82 | }));
83 | return;
84 | }
85 | }
86 |
87 | // Handle health check endpoint
88 | if (req.method === 'GET' && req.url === '/health') {
89 | res.writeHead(200, { 'Content-Type': 'application/json' });
90 | res.end(JSON.stringify({
91 | status: 'healthy',
92 | server: 'google-calendar-mcp',
93 | timestamp: new Date().toISOString()
94 | }));
95 | return;
96 | }
97 |
98 | try {
99 | await transport.handleRequest(req, res);
100 | } catch (error) {
101 | process.stderr.write(`Error handling request: ${error instanceof Error ? error.message : error}\n`);
102 | if (!res.headersSent) {
103 | res.writeHead(500, { 'Content-Type': 'application/json' });
104 | res.end(JSON.stringify({
105 | jsonrpc: '2.0',
106 | error: {
107 | code: -32603,
108 | message: 'Internal server error',
109 | },
110 | id: null,
111 | }));
112 | }
113 | }
114 | });
115 |
116 | httpServer.listen(port, host, () => {
117 | process.stderr.write(`Google Calendar MCP Server listening on http://${host}:${port}\n`);
118 | });
119 | }
120 | }
```
--------------------------------------------------------------------------------
/src/services/conflict-detection/ConflictAnalyzer.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { calendar_v3 } from "googleapis";
2 | import { EventTimeRange } from "./types.js";
3 | import { EventSimilarityChecker } from "./EventSimilarityChecker.js";
4 |
5 | export class ConflictAnalyzer {
6 | private similarityChecker: EventSimilarityChecker;
7 |
8 | constructor() {
9 | this.similarityChecker = new EventSimilarityChecker();
10 | }
11 | /**
12 | * Analyze overlap between two events
13 | * Uses consolidated overlap logic from EventSimilarityChecker
14 | */
15 | analyzeOverlap(event1: calendar_v3.Schema$Event, event2: calendar_v3.Schema$Event): {
16 | hasOverlap: boolean;
17 | duration?: string;
18 | percentage?: number;
19 | startTime?: string;
20 | endTime?: string;
21 | } {
22 | // Use consolidated overlap check
23 | const hasOverlap = this.similarityChecker.eventsOverlap(event1, event2);
24 |
25 | if (!hasOverlap) {
26 | return { hasOverlap: false };
27 | }
28 |
29 | // Get time ranges for detailed analysis
30 | const time1 = this.getEventTimeRange(event1);
31 | const time2 = this.getEventTimeRange(event2);
32 |
33 | if (!time1 || !time2) {
34 | return { hasOverlap: false };
35 | }
36 |
37 | // Calculate overlap details
38 | const overlapDuration = this.similarityChecker.calculateOverlapDuration(event1, event2);
39 | const overlapStart = new Date(Math.max(time1.start.getTime(), time2.start.getTime()));
40 | const overlapEnd = new Date(Math.min(time1.end.getTime(), time2.end.getTime()));
41 |
42 | // Calculate percentage of overlap relative to the first event
43 | const event1Duration = time1.end.getTime() - time1.start.getTime();
44 | const overlapPercentage = Math.round((overlapDuration / event1Duration) * 100);
45 |
46 | return {
47 | hasOverlap: true,
48 | duration: this.formatDuration(overlapDuration),
49 | percentage: overlapPercentage,
50 | startTime: overlapStart.toISOString(),
51 | endTime: overlapEnd.toISOString()
52 | };
53 | }
54 |
55 | /**
56 | * Get event time range
57 | */
58 | private getEventTimeRange(event: calendar_v3.Schema$Event): EventTimeRange | null {
59 | const startTime = event.start?.dateTime || event.start?.date;
60 | const endTime = event.end?.dateTime || event.end?.date;
61 |
62 | if (!startTime || !endTime) return null;
63 |
64 | const start = new Date(startTime);
65 | const end = new Date(endTime);
66 |
67 | // Check if it's an all-day event
68 | const isAllDay = !event.start?.dateTime && !!event.start?.date;
69 |
70 | return { start, end, isAllDay };
71 | }
72 |
73 | /**
74 | * Format duration in human-readable format
75 | */
76 | private formatDuration(milliseconds: number): string {
77 | const minutes = Math.floor(milliseconds / (1000 * 60));
78 | const hours = Math.floor(minutes / 60);
79 | const days = Math.floor(hours / 24);
80 |
81 | if (days > 0) {
82 | const remainingHours = hours % 24;
83 | return remainingHours > 0
84 | ? `${days} day${days > 1 ? 's' : ''} ${remainingHours} hour${remainingHours > 1 ? 's' : ''}`
85 | : `${days} day${days > 1 ? 's' : ''}`;
86 | }
87 |
88 | if (hours > 0) {
89 | const remainingMinutes = minutes % 60;
90 | return remainingMinutes > 0
91 | ? `${hours} hour${hours > 1 ? 's' : ''} ${remainingMinutes} minute${remainingMinutes > 1 ? 's' : ''}`
92 | : `${hours} hour${hours > 1 ? 's' : ''}`;
93 | }
94 |
95 | return `${minutes} minute${minutes > 1 ? 's' : ''}`;
96 | }
97 |
98 | /**
99 | * Check if an event conflicts with a busy time slot
100 | */
101 | checkBusyConflict(event: calendar_v3.Schema$Event, busySlot: { start?: string | null; end?: string | null }): boolean {
102 | // Handle null values from Google's API
103 | const start = busySlot.start ?? undefined;
104 | const end = busySlot.end ?? undefined;
105 |
106 | if (!start || !end) return false;
107 |
108 | // Convert busy slot to event format for consistency
109 | const busyEvent: calendar_v3.Schema$Event = {
110 | start: { dateTime: start },
111 | end: { dateTime: end }
112 | };
113 |
114 | return this.similarityChecker.eventsOverlap(event, busyEvent);
115 | }
116 |
117 | /**
118 | * Filter events that overlap with a given time range
119 | */
120 | findOverlappingEvents(
121 | events: calendar_v3.Schema$Event[],
122 | targetEvent: calendar_v3.Schema$Event
123 | ): calendar_v3.Schema$Event[] {
124 | return events.filter(event => {
125 | // Skip the same event
126 | if (event.id === targetEvent.id) return false;
127 |
128 | // Skip cancelled events
129 | if (event.status === 'cancelled') return false;
130 |
131 | // Use consolidated overlap check
132 | return this.similarityChecker.eventsOverlap(targetEvent, event);
133 | });
134 | }
135 | }
```
--------------------------------------------------------------------------------
/src/auth/utils.ts:
--------------------------------------------------------------------------------
```typescript
1 | import * as path from 'path';
2 | import * as os from 'os';
3 | import * as fs from 'fs';
4 | import { fileURLToPath } from 'url';
5 | import { getSecureTokenPath as getSharedSecureTokenPath, getLegacyTokenPath as getSharedLegacyTokenPath, getAccountMode as getSharedAccountMode } from './paths.js';
6 |
7 | // Helper to get the project root directory reliably
8 | function getProjectRoot(): string {
9 | const __dirname = path.dirname(fileURLToPath(import.meta.url));
10 | // In build output (e.g., build/bundle.js), __dirname is .../build
11 | // Go up ONE level to get the project root
12 | const projectRoot = path.join(__dirname, ".."); // Corrected: Go up ONE level
13 | return path.resolve(projectRoot); // Ensure absolute path
14 | }
15 |
16 | // Get the current account mode (normal or test) - delegates to shared implementation
17 | export function getAccountMode(): 'normal' | 'test' {
18 | return getSharedAccountMode() as 'normal' | 'test';
19 | }
20 |
21 | // Helper to detect if we're running in a test environment
22 | function isRunningInTestEnvironment(): boolean {
23 | // Simple and reliable: just check NODE_ENV
24 | return process.env.NODE_ENV === 'test';
25 | }
26 |
27 | // Returns the absolute path for the saved token file - delegates to shared implementation
28 | export function getSecureTokenPath(): string {
29 | return getSharedSecureTokenPath();
30 | }
31 |
32 | // Returns the legacy token path for backward compatibility - delegates to shared implementation
33 | export function getLegacyTokenPath(): string {
34 | return getSharedLegacyTokenPath();
35 | }
36 |
37 | // Returns the absolute path for the GCP OAuth keys file with priority:
38 | // 1. Environment variable GOOGLE_OAUTH_CREDENTIALS (highest priority)
39 | // 2. Default file path (lowest priority)
40 | export function getKeysFilePath(): string {
41 | // Priority 1: Environment variable
42 | const envCredentialsPath = process.env.GOOGLE_OAUTH_CREDENTIALS;
43 | if (envCredentialsPath) {
44 | return path.resolve(envCredentialsPath);
45 | }
46 |
47 | // Priority 2: Default file path
48 | const projectRoot = getProjectRoot();
49 | const keysPath = path.join(projectRoot, "gcp-oauth.keys.json");
50 | return keysPath; // Already absolute from getProjectRoot
51 | }
52 |
53 | // Helper to determine if we're currently in test mode
54 | export function isTestMode(): boolean {
55 | return getAccountMode() === 'test';
56 | }
57 |
58 | // Interface for OAuth credentials
59 | export interface OAuthCredentials {
60 | client_id: string;
61 | client_secret: string;
62 | redirect_uris: string[];
63 | }
64 |
65 | // Interface for credentials file with project_id
66 | export interface OAuthCredentialsWithProject {
67 | installed?: {
68 | project_id?: string;
69 | client_id?: string;
70 | client_secret?: string;
71 | redirect_uris?: string[];
72 | };
73 | project_id?: string;
74 | client_id?: string;
75 | client_secret?: string;
76 | redirect_uris?: string[];
77 | }
78 |
79 | // Get project ID from OAuth credentials file
80 | // Returns undefined if credentials file doesn't exist, is invalid, or missing project_id
81 | export function getCredentialsProjectId(): string | undefined {
82 | try {
83 | // Use existing helper to get credentials file path
84 | const credentialsPath = getKeysFilePath();
85 |
86 | if (!fs.existsSync(credentialsPath)) {
87 | return undefined;
88 | }
89 |
90 | const credentialsContent = fs.readFileSync(credentialsPath, 'utf-8');
91 | const credentials: OAuthCredentialsWithProject = JSON.parse(credentialsContent);
92 |
93 | // Extract project_id from installed format or direct format
94 | if (credentials.installed?.project_id) {
95 | return credentials.installed.project_id;
96 | } else if (credentials.project_id) {
97 | return credentials.project_id;
98 | }
99 |
100 | return undefined;
101 | } catch (error) {
102 | // If we can't read project ID, return undefined (backward compatibility)
103 | return undefined;
104 | }
105 | }
106 |
107 | // Generate helpful error message for missing credentials
108 | export function generateCredentialsErrorMessage(): string {
109 | return `
110 | OAuth credentials not found. Please provide credentials using one of these methods:
111 |
112 | 1. Environment variable:
113 | Set GOOGLE_OAUTH_CREDENTIALS to the path of your credentials file:
114 | export GOOGLE_OAUTH_CREDENTIALS="/path/to/gcp-oauth.keys.json"
115 |
116 | 2. Default file path:
117 | Place your gcp-oauth.keys.json file in the package root directory.
118 |
119 | Token storage:
120 | - Tokens are saved to: ${getSecureTokenPath()}
121 | - To use a custom token location, set GOOGLE_CALENDAR_MCP_TOKEN_PATH environment variable
122 |
123 | To get OAuth credentials:
124 | 1. Go to the Google Cloud Console (https://console.cloud.google.com/)
125 | 2. Create or select a project
126 | 3. Enable the Google Calendar API
127 | 4. Create OAuth 2.0 credentials
128 | 5. Download the credentials file as gcp-oauth.keys.json
129 | `.trim();
130 | }
131 |
```
--------------------------------------------------------------------------------
/src/tests/unit/utils/field-mask-builder.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { describe, it, expect } from 'vitest';
2 | import {
3 | buildEventFieldMask,
4 | buildSingleEventFieldMask,
5 | buildListFieldMask,
6 | validateFields,
7 | ALLOWED_EVENT_FIELDS,
8 | DEFAULT_EVENT_FIELDS
9 | } from '../../../utils/field-mask-builder.js';
10 |
11 | describe('Field Mask Builder', () => {
12 | describe('validateFields', () => {
13 | it('should accept valid fields', () => {
14 | const validFields = ['description', 'colorId', 'transparency'];
15 | const result = validateFields(validFields);
16 | expect(result).toEqual(validFields);
17 | });
18 |
19 | it('should reject invalid fields', () => {
20 | const invalidFields = ['invalid', 'notafield'];
21 | expect(() => validateFields(invalidFields)).toThrow('Invalid fields requested: invalid, notafield');
22 | });
23 |
24 | it('should handle mixed valid and invalid fields', () => {
25 | const mixedFields = ['description', 'invalid', 'colorId'];
26 | expect(() => validateFields(mixedFields)).toThrow('Invalid fields requested: invalid');
27 | });
28 |
29 | it('should accept all allowed fields', () => {
30 | const result = validateFields([...ALLOWED_EVENT_FIELDS]);
31 | expect(result).toEqual(ALLOWED_EVENT_FIELDS);
32 | });
33 | });
34 |
35 | describe('buildEventFieldMask', () => {
36 | it('should return undefined when no fields requested', () => {
37 | expect(buildEventFieldMask()).toBeUndefined();
38 | expect(buildEventFieldMask([])).toBeUndefined();
39 | });
40 |
41 | it('should build field mask with requested fields and defaults', () => {
42 | const fields = ['description', 'colorId'];
43 | const result = buildEventFieldMask(fields);
44 | expect(result).toContain('items(');
45 | expect(result).toContain('description');
46 | expect(result).toContain('colorId');
47 | // Should also include defaults
48 | DEFAULT_EVENT_FIELDS.forEach(field => {
49 | expect(result).toContain(field);
50 | });
51 | });
52 |
53 | it('should build field mask without defaults when specified', () => {
54 | const fields = ['description', 'colorId'];
55 | const result = buildEventFieldMask(fields, false);
56 | expect(result).toBe('items(description,colorId)');
57 | });
58 |
59 | it('should handle duplicate fields', () => {
60 | const fields = ['description', 'description', 'id', 'summary'];
61 | const result = buildEventFieldMask(fields);
62 | // Should deduplicate
63 | const fieldCount = (result?.match(/description/g) || []).length;
64 | expect(fieldCount).toBe(1);
65 | });
66 |
67 | it('should throw for invalid fields', () => {
68 | const fields = ['description', 'invalidfield'];
69 | expect(() => buildEventFieldMask(fields)).toThrow('Invalid fields requested: invalidfield');
70 | });
71 | });
72 |
73 | describe('buildSingleEventFieldMask', () => {
74 | it('should return undefined when no fields requested', () => {
75 | expect(buildSingleEventFieldMask()).toBeUndefined();
76 | expect(buildSingleEventFieldMask([])).toBeUndefined();
77 | });
78 |
79 | it('should build comma-separated field list with defaults', () => {
80 | const fields = ['description', 'colorId'];
81 | const result = buildSingleEventFieldMask(fields);
82 | expect(result).not.toContain('items(');
83 | expect(result).toContain('description');
84 | expect(result).toContain('colorId');
85 | // Should also include defaults
86 | DEFAULT_EVENT_FIELDS.forEach(field => {
87 | expect(result).toContain(field);
88 | });
89 | });
90 |
91 | it('should build field list without defaults when specified', () => {
92 | const fields = ['description', 'colorId'];
93 | const result = buildSingleEventFieldMask(fields, false);
94 | expect(result).toBe('description,colorId');
95 | });
96 | });
97 |
98 | describe('buildListFieldMask', () => {
99 | it('should return undefined when no fields requested', () => {
100 | expect(buildListFieldMask()).toBeUndefined();
101 | expect(buildListFieldMask([])).toBeUndefined();
102 | });
103 |
104 | it('should include list metadata fields', () => {
105 | const fields = ['description'];
106 | const result = buildListFieldMask(fields);
107 | expect(result).toContain('nextPageToken');
108 | expect(result).toContain('nextSyncToken');
109 | expect(result).toContain('kind');
110 | expect(result).toContain('etag');
111 | expect(result).toContain('timeZone');
112 | expect(result).toContain('accessRole');
113 | });
114 |
115 | it('should include event fields in items()', () => {
116 | const fields = ['description', 'colorId'];
117 | const result = buildListFieldMask(fields);
118 | expect(result).toContain('items(');
119 | expect(result).toContain('description');
120 | expect(result).toContain('colorId');
121 | });
122 | });
123 | });
```
--------------------------------------------------------------------------------
/src/handlers/core/FreeBusyEventHandler.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { BaseToolHandler } from './BaseToolHandler.js';
2 | import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
3 | import { OAuth2Client } from "google-auth-library";
4 | import { GetFreeBusyInput } from "../../tools/registry.js";
5 | import { FreeBusyResponse as GoogleFreeBusyResponse } from '../../schemas/types.js';
6 | import { FreeBusyResponse } from '../../types/structured-responses.js';
7 | import { createStructuredResponse } from '../../utils/response-builder.js';
8 | import { McpError } from '@modelcontextprotocol/sdk/types.js';
9 | import { ErrorCode } from '@modelcontextprotocol/sdk/types.js';
10 | import { convertToRFC3339 } from '../utils/datetime.js';
11 |
12 | export class FreeBusyEventHandler extends BaseToolHandler {
13 | async runTool(args: any, oauth2Client: OAuth2Client): Promise<CallToolResult> {
14 | const validArgs = args as GetFreeBusyInput;
15 |
16 | if(!this.isLessThanThreeMonths(validArgs.timeMin,validArgs.timeMax)){
17 | throw new McpError(
18 | ErrorCode.InvalidRequest,
19 | "The time gap between timeMin and timeMax must be less than 3 months"
20 | );
21 | }
22 |
23 | const result = await this.queryFreeBusy(oauth2Client, validArgs);
24 |
25 | const response: FreeBusyResponse = {
26 | timeMin: validArgs.timeMin,
27 | timeMax: validArgs.timeMax,
28 | calendars: this.formatCalendarsData(result)
29 | };
30 |
31 | return createStructuredResponse(response);
32 | }
33 |
34 | private async queryFreeBusy(
35 | client: OAuth2Client,
36 | args: GetFreeBusyInput
37 | ): Promise<GoogleFreeBusyResponse> {
38 | try {
39 | const calendar = this.getCalendar(client);
40 |
41 | // Determine timezone with correct precedence:
42 | // 1. Explicit timeZone parameter (highest priority)
43 | // 2. Primary calendar's default timezone (fallback)
44 | // 3. UTC if calendar timezone retrieval fails
45 | let timezone: string;
46 | if (args.timeZone) {
47 | timezone = args.timeZone;
48 | } else {
49 | try {
50 | timezone = await this.getCalendarTimezone(client, 'primary');
51 | } catch (error) {
52 | // If we can't get the primary calendar's timezone, fall back to UTC
53 | // This can happen if the user doesn't have access to 'primary' calendar
54 | timezone = 'UTC';
55 | }
56 | }
57 |
58 | // Convert time boundaries to RFC3339 format for Google Calendar API
59 | // This handles both timezone-aware and timezone-naive datetime strings
60 | const timeMin = convertToRFC3339(args.timeMin, timezone);
61 | const timeMax = convertToRFC3339(args.timeMax, timezone);
62 |
63 | // Build request body
64 | // Note: The timeZone parameter affects the response format, not request interpretation
65 | // Since timeMin/timeMax are in RFC3339 (with timezone), they're unambiguous
66 | // But we include timeZone so busy periods in the response use consistent timezone
67 | const requestBody: any = {
68 | timeMin,
69 | timeMax,
70 | items: args.calendars,
71 | timeZone: timezone, // Always include to ensure response consistency
72 | };
73 |
74 | // Only add optional expansion fields if provided
75 | if (args.groupExpansionMax !== undefined) {
76 | requestBody.groupExpansionMax = args.groupExpansionMax;
77 | }
78 | if (args.calendarExpansionMax !== undefined) {
79 | requestBody.calendarExpansionMax = args.calendarExpansionMax;
80 | }
81 |
82 | const response = await calendar.freebusy.query({
83 | requestBody,
84 | });
85 | return response.data as GoogleFreeBusyResponse;
86 | } catch (error) {
87 | throw this.handleGoogleApiError(error);
88 | }
89 | }
90 |
91 | private isLessThanThreeMonths(timeMin: string, timeMax: string): boolean {
92 | const minDate = new Date(timeMin);
93 | const maxDate = new Date(timeMax);
94 |
95 | const diffInMilliseconds = maxDate.getTime() - minDate.getTime();
96 | const threeMonthsInMilliseconds = 3 * 30 * 24 * 60 * 60 * 1000;
97 |
98 | return diffInMilliseconds <= threeMonthsInMilliseconds;
99 | }
100 |
101 | private formatCalendarsData(response: GoogleFreeBusyResponse): Record<string, {
102 | busy: Array<{ start: string; end: string }>;
103 | errors?: Array<{ domain?: string; reason?: string }>;
104 | }> {
105 | const calendars: Record<string, any> = {};
106 |
107 | if (response.calendars) {
108 | for (const [calId, calData] of Object.entries(response.calendars) as [string, any][]) {
109 | calendars[calId] = {
110 | busy: calData.busy?.map((slot: any) => ({
111 | start: slot.start,
112 | end: slot.end
113 | })) || []
114 | };
115 |
116 | if (calData.errors?.length > 0) {
117 | calendars[calId].errors = calData.errors.map((err: any) => ({
118 | domain: err.domain,
119 | reason: err.reason
120 | }));
121 | }
122 | }
123 | }
124 |
125 | return calendars;
126 | }
127 | }
128 |
```
--------------------------------------------------------------------------------
/docs/advanced-usage.md:
--------------------------------------------------------------------------------
```markdown
1 | # Advanced Usage Guide
2 |
3 | This guide covers advanced features and use cases for the Google Calendar MCP Server.
4 |
5 | ## Multi-Account Support
6 |
7 | The server supports managing multiple Google accounts (e.g., personal and a test calendar).
8 |
9 | ### Setup Multiple Accounts
10 |
11 | ```bash
12 | # Authenticate normal account
13 | npm run auth
14 |
15 | # Authenticate test account
16 | npm run auth:test
17 |
18 | # Check status
19 | npm run account:status
20 | ```
21 |
22 | ### Account Management Commands
23 |
24 | ```bash
25 | npm run account:clear:normal # Clear normal account tokens
26 | npm run account:clear:test # Clear test account tokens
27 | npm run account:migrate # Migrate from old token format
28 | ```
29 |
30 | ### Using Multiple Accounts
31 |
32 | The server intelligently determines which account to use:
33 | - Normal operations use your primary account
34 | - Integration tests automatically use the test account
35 | - Accounts are isolated and secure
36 |
37 | ## Batch Operations
38 |
39 | ### List Events from Multiple Calendars
40 |
41 | Request events from several calendars simultaneously:
42 |
43 | ```
44 | "Show me all events from my work, personal, and team calendars for next week"
45 | ```
46 |
47 | The server will:
48 | 1. Query all specified calendars in parallel
49 | 2. Merge and sort results chronologically
50 | 3. Handle different timezones correctly
51 |
52 | ### Batch Event Creation
53 |
54 | Create multiple related events:
55 |
56 | ```
57 | "Schedule a 3-part training series every Monday at 2pm for the next 3 weeks"
58 | ```
59 |
60 | ## Recurring Events
61 |
62 | ### Modification Scopes
63 |
64 | When updating recurring events, you can specify the scope:
65 |
66 | 1. **This event only**: Modify a single instance
67 | ```
68 | "Move tomorrow's standup to 11am (just this one)"
69 | ```
70 |
71 | 2. **This and following events**: Modify from a specific date forward
72 | ```
73 | "Change all future team meetings to 30 minutes starting next week"
74 | ```
75 |
76 | 3. **All events**: Modify the entire series
77 | ```
78 | "Update the location for all weekly reviews to Conference Room B"
79 | ```
80 |
81 | ### Complex Recurrence Patterns
82 |
83 | The server supports all Google Calendar recurrence rules:
84 | - Daily, weekly, monthly, yearly patterns
85 | - Custom intervals (e.g., every 3 days)
86 | - Specific days (e.g., every Tuesday and Thursday)
87 | - End conditions (after N occurrences or by date)
88 |
89 | ## Timezone Handling
90 |
91 | All times require explicit timezone information:
92 | - Automatic timezone detection based on your calendar settings
93 | - Support for scheduling across timezones
94 | - Proper handling of daylight saving time transitions
95 |
96 | ### Availability Checking
97 |
98 | Find optimal meeting times:
99 |
100 | ```
101 | "Find a 90-minute slot next week when both my work and personal calendars are free, preferably in the afternoon"
102 | ```
103 |
104 | ## Working with Images
105 |
106 | ### Extract Events from Screenshots
107 |
108 | ```
109 | "Add this event to my calendar [attach screenshot]"
110 | ```
111 |
112 | Supported formats: PNG, JPEG, GIF
113 |
114 | The server can extract:
115 | - Date and time information
116 | - Event titles and descriptions
117 | - Location details
118 | - Attendee lists
119 |
120 | ### Best Practices for Image Recognition
121 |
122 | 1. Ensure text is clear and readable
123 | 2. Include full date/time information in the image
124 | 3. Highlight or circle important details
125 | 4. Use high contrast images
126 |
127 | ## Advanced Search
128 |
129 | ### Search Operators
130 |
131 | - **By attendee**: "meetings with [email protected]"
132 | - **By location**: "events at headquarters"
133 | - **By time range**: "morning meetings this month"
134 | - **By status**: "tentative events this week"
135 |
136 | ### Complex Queries
137 |
138 | Combine multiple criteria:
139 |
140 | ```
141 | "Find all meetings with the sales team at the main office that are longer than an hour in the next two weeks"
142 | ```
143 |
144 | ## Calendar Analysis
145 |
146 | ### Meeting Patterns
147 |
148 | ```
149 | "How much time did I spend in meetings last week?"
150 | "What percentage of my meetings are recurring?"
151 | "Which day typically has the most meetings?"
152 | ```
153 | ## Performance Optimization
154 |
155 | ### Rate Limiting
156 |
157 | Built-in protection against API limits:
158 | - Automatic retry with exponential backoff in batch operations
159 | - HTTP transport includes basic rate limiting (100 requests per IP per 15 minutes)
160 |
161 | ## Integration Examples
162 |
163 | ### Daily Schedule
164 |
165 | ```
166 | "Show me today's events and check for any scheduling conflicts between all my calendars"
167 | ```
168 |
169 | ### Weekly Planning
170 |
171 | ```
172 | "Look at next week and suggest the best times for deep work blocks of at least 2 hours"
173 | ```
174 |
175 | ### Meeting Preparation
176 |
177 | ```
178 | "For each meeting tomorrow, tell me who's attending, what the agenda is, and what materials I should review"
179 | ```
180 |
181 | ## Security Considerations
182 |
183 | ### Permission Scopes
184 |
185 | The server only requests necessary permissions:
186 | - `calendar.events`: Full event management
187 | - Never requests email or profile access
188 | - No access to other Google services
189 |
190 | ### Token Security
191 |
192 | - Tokens encrypted at rest
193 | - Automatic token refresh
194 | - Secure credential storage
195 | - No tokens in logs or debug output
196 |
197 | ## Debugging
198 |
199 | ### Enable Debug Logging
200 |
201 | ```bash
202 | DEBUG=mcp:* npm start
203 | ```
204 |
205 | ### Common Issues
206 |
207 | 1. **Token refresh failures**: Check network connectivity
208 | 2. **API quota exceeded**: Implement backoff strategies
209 | 3. **Timezone mismatches**: Ensure consistent timezone usage
210 |
211 | See [Troubleshooting Guide](troubleshooting.md) for detailed solutions.
```
--------------------------------------------------------------------------------
/src/handlers/utils/datetime.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Datetime utilities for Google Calendar MCP Server
3 | * Provides timezone handling and datetime conversion utilities
4 | */
5 |
6 | /**
7 | * Checks if a datetime string includes timezone information
8 | * @param datetime ISO 8601 datetime string
9 | * @returns True if timezone is included, false if timezone-naive
10 | */
11 | export function hasTimezoneInDatetime(datetime: string): boolean {
12 | return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(Z|[+-]\d{2}:\d{2})$/.test(datetime);
13 | }
14 |
15 | /**
16 | * Converts a flexible datetime string to RFC3339 format required by Google Calendar API
17 | *
18 | * Precedence rules:
19 | * 1. If datetime already has timezone info (Z or ±HH:MM), use as-is
20 | * 2. If datetime is timezone-naive, interpret it as local time in fallbackTimezone and convert to UTC
21 | *
22 | * @param datetime ISO 8601 datetime string (with or without timezone)
23 | * @param fallbackTimezone Timezone to use if datetime is timezone-naive (IANA format)
24 | * @returns RFC3339 formatted datetime string in UTC
25 | */
26 | export function convertToRFC3339(datetime: string, fallbackTimezone: string): string {
27 | if (hasTimezoneInDatetime(datetime)) {
28 | // Already has timezone, use as-is
29 | return datetime;
30 | } else {
31 | // Timezone-naive, interpret as local time in fallbackTimezone and convert to UTC
32 | try {
33 | // Parse the datetime components
34 | const match = datetime.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})$/);
35 | if (!match) {
36 | throw new Error('Invalid datetime format');
37 | }
38 |
39 | const [, year, month, day, hour, minute, second] = match.map(Number);
40 |
41 | // Create a temporary date in UTC to get the baseline
42 | const utcDate = new Date(Date.UTC(year, month - 1, day, hour, minute, second));
43 |
44 | // Find what UTC time corresponds to the desired local time in the target timezone
45 | // We do this by binary search approach or by using the timezone offset
46 | const targetDate = convertLocalTimeToUTC(year, month - 1, day, hour, minute, second, fallbackTimezone);
47 |
48 | return targetDate.toISOString().replace(/\.000Z$/, 'Z');
49 | } catch (error) {
50 | // Fallback: if timezone conversion fails, append Z for UTC
51 | return datetime + 'Z';
52 | }
53 | }
54 | }
55 |
56 | /**
57 | * Convert a local time in a specific timezone to UTC
58 | */
59 | function convertLocalTimeToUTC(year: number, month: number, day: number, hour: number, minute: number, second: number, timezone: string): Date {
60 | // Create a date that we'll use to find the correct UTC time
61 | // Start with the assumption that it's in UTC
62 | let testDate = new Date(Date.UTC(year, month, day, hour, minute, second));
63 |
64 | // Get what this UTC time looks like in the target timezone
65 | const options: Intl.DateTimeFormatOptions = {
66 | timeZone: timezone,
67 | year: 'numeric',
68 | month: '2-digit',
69 | day: '2-digit',
70 | hour: '2-digit',
71 | minute: '2-digit',
72 | second: '2-digit',
73 | hour12: false
74 | };
75 |
76 | // Format the test date in the target timezone
77 | const formatter = new Intl.DateTimeFormat('sv-SE', options);
78 | const formattedInTargetTZ = formatter.format(testDate);
79 |
80 | // Parse the formatted result to see what time it shows
81 | const [datePart, timePart] = formattedInTargetTZ.split(' ');
82 | const [targetYear, targetMonth, targetDay] = datePart.split('-').map(Number);
83 | const [targetHour, targetMinute, targetSecond] = timePart.split(':').map(Number);
84 |
85 | // Calculate the difference between what we want and what we got
86 | const wantedTime = new Date(year, month, day, hour, minute, second).getTime();
87 | const actualTime = new Date(targetYear, targetMonth - 1, targetDay, targetHour, targetMinute, targetSecond).getTime();
88 | const offsetMs = wantedTime - actualTime;
89 |
90 | // Adjust the UTC time by the offset
91 | return new Date(testDate.getTime() + offsetMs);
92 | }
93 |
94 | /**
95 | * Creates a time object for Google Calendar API, handling both timezone-aware and timezone-naive datetime strings
96 | * Also handles all-day events by using 'date' field instead of 'dateTime'
97 | * @param datetime ISO 8601 datetime string (with or without timezone)
98 | * @param fallbackTimezone Timezone to use if datetime is timezone-naive (IANA format)
99 | * @returns Google Calendar API time object
100 | */
101 | export function createTimeObject(datetime: string, fallbackTimezone: string): { dateTime?: string; date?: string; timeZone?: string } {
102 | // Check if this is a date-only string (all-day event)
103 | // Date-only format: YYYY-MM-DD (no time component)
104 | if (!/T/.test(datetime)) {
105 | // This is a date-only string, use the 'date' field for all-day event
106 | return { date: datetime };
107 | }
108 |
109 | // This is a datetime string with time component
110 | if (hasTimezoneInDatetime(datetime)) {
111 | // Timezone included in datetime - use as-is, no separate timeZone property needed
112 | return { dateTime: datetime };
113 | } else {
114 | // Timezone-naive datetime - use fallback timezone
115 | return { dateTime: datetime, timeZone: fallbackTimezone };
116 | }
117 | }
```
--------------------------------------------------------------------------------
/docs/authentication.md:
--------------------------------------------------------------------------------
```markdown
1 | # Authentication Setup Guide
2 |
3 | This guide provides detailed instructions for setting up Google OAuth 2.0 authentication for the Google Calendar MCP Server.
4 |
5 | ## Google Cloud Setup
6 |
7 | ### 1. Create a Google Cloud Project
8 |
9 | 1. Go to the [Google Cloud Console](https://console.cloud.google.com)
10 | 2. Click "Select a project" → "New Project"
11 | 3. Enter a project name (e.g., "Calendar MCP")
12 | 4. Click "Create"
13 |
14 | ### 2. Enable the Google Calendar API
15 |
16 | 1. In your project, go to "APIs & Services" → "Library"
17 | 2. Search for "Google Calendar API"
18 | 3. Click on it and press "Enable"
19 | 4. Wait for the API to be enabled (usually takes a few seconds)
20 |
21 | ### 3. Create OAuth 2.0 Credentials
22 |
23 | 1. Go to "APIs & Services" → "Credentials"
24 | 2. Click "Create Credentials" → "OAuth client ID"
25 | 3. If prompted, configure the OAuth consent screen first:
26 | - Choose "External" user type
27 | - Fill in the required fields:
28 | - App name: "Calendar MCP" (or your choice)
29 | - User support email: Your email
30 | - Developer contact: Your email
31 | - Add scopes:
32 | - Click "Add or Remove Scopes"
33 | - Add: `https://www.googleapis.com/auth/calendar.events`
34 | - Or use the broader scope: `https://www.googleapis.com/auth/calendar`
35 | - Add test users:
36 | - Add your email address
37 | - **Important**: Wait 2-3 minutes for test users to propagate
38 |
39 | 4. Create the OAuth client:
40 | - Application type: **Desktop app** (Important!)
41 | - Name: "Calendar MCP Client"
42 | - Click "Create"
43 |
44 | 5. Download the credentials:
45 | - Click the download button (⬇️) next to your new client
46 | - Save as `gcp-oauth.keys.json`
47 |
48 | ## Credential File Format
49 |
50 | Your credentials file should look like this:
51 |
52 | ```json
53 | {
54 | "installed": {
55 | "client_id": "YOUR_CLIENT_ID.apps.googleusercontent.com",
56 | "client_secret": "YOUR_CLIENT_SECRET",
57 | "auth_uri": "https://accounts.google.com/o/oauth2/auth",
58 | "token_uri": "https://oauth2.googleapis.com/token",
59 | "redirect_uris": ["http://localhost"]
60 | }
61 | }
62 | ```
63 |
64 | ## Credential Storage Options
65 |
66 | ### Option 1: Environment Variable (Recommended)
67 |
68 | Set the `GOOGLE_OAUTH_CREDENTIALS` environment variable to point to your credentials file:
69 |
70 | ```bash
71 | export GOOGLE_OAUTH_CREDENTIALS="/path/to/your/gcp-oauth.keys.json"
72 | ```
73 |
74 | In Claude Desktop config:
75 | ```json
76 | {
77 | "mcpServers": {
78 | "google-calendar": {
79 | "command": "npx",
80 | "args": ["@cocal/google-calendar-mcp"],
81 | "env": {
82 | "GOOGLE_OAUTH_CREDENTIALS": "/path/to/your/gcp-oauth.keys.json"
83 | }
84 | }
85 | }
86 | }
87 | ```
88 |
89 | ### Option 2: Default Location
90 |
91 | Place the credentials file in the project root as `gcp-oauth.keys.json`.
92 |
93 | ## Token Storage
94 |
95 | OAuth tokens are automatically stored in a secure location:
96 |
97 | - **macOS/Linux**: `~/.config/google-calendar-mcp/tokens.json`
98 | - **Windows**: `%APPDATA%\google-calendar-mcp\tokens.json`
99 |
100 | To use a custom location, set:
101 | ```bash
102 | export GOOGLE_CALENDAR_MCP_TOKEN_PATH="/custom/path/tokens.json"
103 | ```
104 |
105 | ## First-Time Authentication
106 |
107 | 1. Start Claude Desktop after configuration
108 | 2. The server will automatically open your browser for authentication
109 | 3. Sign in with your Google account
110 | 4. Grant the requested calendar permissions
111 | 5. You'll see a success message in the browser
112 | 6. Return to Claude - you're ready to use calendar features!
113 |
114 | ## Re-authentication
115 |
116 | If your tokens expire or become invalid:
117 |
118 | ### For NPX Installation
119 | If you're using the MCP via `npx` (e.g., in Claude Desktop):
120 |
121 | ```bash
122 | # Set your credentials path first
123 | export GOOGLE_OAUTH_CREDENTIALS="/path/to/your/gcp-oauth.keys.json"
124 |
125 | # Run the auth command
126 | npx @cocal/google-calendar-mcp auth
127 | ```
128 |
129 | ### For Local Installation
130 | ```bash
131 | npm run auth
132 | ```
133 |
134 | The server will guide you through the authentication flow again.
135 |
136 | ## Important Notes
137 |
138 | ### Test Mode Limitations
139 |
140 | While your app is in test mode:
141 | - OAuth tokens expire after 7 days
142 | - Limited to test users you've explicitly added
143 | - Perfect for personal use
144 |
145 | ### Avoiding Token Expiration
146 |
147 | Test mode tokens expire after 7 days. For personal use, you can simply re-authenticate weekly using the commands above.
148 |
149 | If you need longer-lived tokens, you can publish your app to production mode in Google Cloud Console. The the number of users will be restricted unless the application completes a full approval review. Google will also warn that the app is unverified and required bypassing a warning screen.
150 |
151 |
152 | ### Security Best Practices
153 |
154 | 1. **Never commit credentials**: Add `gcp-oauth.keys.json` to `.gitignore`
155 | 2. **Secure file permissions**:
156 | ```bash
157 | chmod 600 /path/to/gcp-oauth.keys.json
158 | ```
159 | 3. **Use environment variables**: Keeps credentials out of config files
160 | 4. **Regularly rotate**: Regenerate credentials if compromised
161 |
162 | ## Troubleshooting
163 |
164 | ### "Invalid credentials" error
165 | - Ensure you selected "Desktop app" as the application type
166 | - Check that the credentials file is valid JSON
167 | - Verify the file path is correct
168 |
169 | ### "Access blocked" error
170 | - Add your email as a test user in OAuth consent screen
171 | - Wait 2-3 minutes for changes to propagate
172 |
173 | ### "Token expired" error
174 | - Run `npm run auth` to re-authenticate
175 | - Check if you're in test mode (7-day expiration)
176 |
177 | See [Troubleshooting Guide](troubleshooting.md) for more issues and solutions.
```
--------------------------------------------------------------------------------
/examples/http-client.js:
--------------------------------------------------------------------------------
```javascript
1 | #!/usr/bin/env node
2 |
3 | /**
4 | * Modern HTTP Client for Google Calendar MCP Server
5 | *
6 | * This demonstrates how to connect to the Google Calendar MCP server
7 | * when it's running in StreamableHTTP transport mode. To test this
8 | * make sure you have the server running locally `npm run start:http`
9 | */
10 |
11 | import { Client } from "@modelcontextprotocol/sdk/client/index.js";
12 | import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
13 |
14 | async function main() {
15 | const serverUrl = process.argv[2] || 'http://localhost:3000';
16 |
17 | console.log(`🔗 Connecting to Google Calendar MCP Server at: ${serverUrl}`);
18 |
19 | try {
20 | // First test health endpoint to ensure server is running
21 | console.log('🏥 Testing server health...');
22 | const healthResponse = await fetch(`${serverUrl}/health`, {
23 | headers: {
24 | 'Accept': 'application/json'
25 | }
26 | });
27 |
28 | if (healthResponse.ok) {
29 | const healthData = await healthResponse.json();
30 | console.log('✅ Server is healthy:', healthData);
31 | } else {
32 | console.error('❌ Server health check failed');
33 | return;
34 | }
35 |
36 | // Create MCP client
37 | const client = new Client({
38 | name: "google-calendar-http-client",
39 | version: "1.0.0"
40 | }, {
41 | capabilities: {
42 | tools: {}
43 | }
44 | });
45 |
46 | // Skip direct initialization test to avoid double-initialization error
47 | console.log('\n🔍 Skipping direct initialization test...');
48 |
49 | // Connect with SDK transport
50 | console.log('\n🚀 Connecting with MCP SDK transport...');
51 | const transport = new StreamableHTTPClientTransport(new URL(serverUrl));
52 |
53 | await client.connect(transport);
54 | console.log('✅ Connected to server');
55 |
56 | // List available tools
57 | console.log('\n📋 Listing available tools...');
58 | const tools = await client.listTools();
59 | console.log(`Found ${tools.tools.length} tools:`);
60 |
61 | tools.tools.forEach((tool, index) => {
62 | console.log(` ${index + 1}. ${tool.name}`);
63 | console.log(` Description: ${tool.description}`);
64 | });
65 |
66 | // Test some basic tools
67 | console.log('\n🛠️ Testing tools...');
68 |
69 | // Test list-calendars
70 | try {
71 | console.log('\n📅 Testing list-calendars...');
72 | const calendarsResult = await client.callTool({
73 | name: 'list-calendars',
74 | arguments: {}
75 | });
76 | console.log('✅ list-calendars successful');
77 | console.log('Result:', calendarsResult.content[0].text.substring(0, 300) + '...');
78 | } catch (error) {
79 | console.log('❌ list-calendars failed:', error.message);
80 | }
81 |
82 | // Test list-colors
83 | try {
84 | console.log('\n🎨 Testing list-colors...');
85 | const colorsResult = await client.callTool({
86 | name: 'list-colors',
87 | arguments: {}
88 | });
89 | console.log('✅ list-colors successful');
90 | console.log('Result:', colorsResult.content[0].text.substring(0, 300) + '...');
91 | } catch (error) {
92 | console.log('❌ list-colors failed:', error.message);
93 | }
94 |
95 | // Test list-events for primary calendar
96 | try {
97 | console.log('\n📆 Testing list-events...');
98 |
99 | // Create ISO strings using standard JavaScript toISOString()
100 | const now = new Date();
101 | const nextWeek = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
102 |
103 | // Use standard RFC 3339 format with milliseconds (now properly supported)
104 | const timeMin = now.toISOString().split('.')[0] + 'Z';
105 | const timeMax = nextWeek.toISOString().split('.')[0] + 'Z';
106 |
107 | console.log(`📅 Fetching events from ${timeMin} to ${timeMax}`);
108 |
109 | const eventsResult = await client.callTool({
110 | name: 'list-events',
111 | arguments: {
112 | calendarId: 'primary',
113 | timeMin: timeMin,
114 | timeMax: timeMax
115 | }
116 | });
117 | console.log('✅ list-events successful');
118 | console.log('Result:', eventsResult.content[0].text.substring(0, 500) + '...');
119 | } catch (error) {
120 | console.log('❌ list-events failed:', error.message);
121 | }
122 |
123 | // Close the connection
124 | console.log('\n🔒 Closing connection...');
125 | await client.close();
126 | console.log('✅ Connection closed');
127 |
128 | console.log('\n🎉 Google Calendar MCP client test completed!');
129 |
130 | } catch (error) {
131 | console.error('❌ Error:', error);
132 |
133 | if (error.message.includes('Authentication required')) {
134 | console.log('\n💡 Authentication required:');
135 | console.log(' Run: npm run auth');
136 | console.log(' Then restart the server: npm run start:http');
137 | } else if (error.message.includes('ECONNREFUSED')) {
138 | console.log('\n💡 Server not running:');
139 | console.log(' Start server: npm run start:http');
140 | } else {
141 | console.log('\n💡 Check that:');
142 | console.log(' 1. Server is running (npm run start:http)');
143 | console.log(' 2. Authentication is complete (npm run auth)');
144 | console.log(' 3. Server URL is correct');
145 | }
146 |
147 | process.exit(1);
148 | }
149 | }
150 |
151 | // Handle graceful shutdown
152 | process.on('SIGINT', () => {
153 | console.log('\n👋 Shutting down HTTP client...');
154 | process.exit(0);
155 | });
156 |
157 | main().catch(error => {
158 | console.error('❌ Unhandled error:', error);
159 | process.exit(1);
160 | });
161 |
```
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { fileURLToPath } from "url";
2 | import { GoogleCalendarMcpServer } from './server.js';
3 | import { parseArgs } from './config/TransportConfig.js';
4 | import { readFileSync } from "fs";
5 | import { join, dirname } from "path";
6 |
7 | // Import modular components
8 | import { initializeOAuth2Client } from './auth/client.js';
9 | import { AuthServer } from './auth/server.js';
10 |
11 | // Get package version
12 | const __filename = fileURLToPath(import.meta.url);
13 | const __dirname = dirname(__filename);
14 | const packageJsonPath = join(__dirname, '..', 'package.json');
15 | const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
16 | const VERSION = packageJson.version;
17 |
18 | // --- Main Application Logic ---
19 | async function main() {
20 | try {
21 | // Parse command line arguments
22 | const config = parseArgs(process.argv.slice(2));
23 |
24 | // Create and initialize the server
25 | const server = new GoogleCalendarMcpServer(config);
26 | await server.initialize();
27 |
28 | // Start the server with the appropriate transport
29 | await server.start();
30 |
31 | } catch (error: unknown) {
32 | process.stderr.write(`Failed to start server: ${error instanceof Error ? error.message : error}\n`);
33 | process.exit(1);
34 | }
35 | }
36 |
37 |
38 | // --- Command Line Interface ---
39 | async function runAuthServer(): Promise<void> {
40 | // Use the same logic as auth-server.ts
41 | try {
42 | // Initialize OAuth client
43 | const oauth2Client = await initializeOAuth2Client();
44 |
45 | // Create and start the auth server
46 | const authServerInstance = new AuthServer(oauth2Client);
47 |
48 | // Start with browser opening (true by default)
49 | const success = await authServerInstance.start(true);
50 |
51 | if (!success && !authServerInstance.authCompletedSuccessfully) {
52 | // Failed to start and tokens weren't already valid
53 | process.stderr.write(
54 | "Authentication failed. Could not start server or validate existing tokens. Check port availability (3000-3004) and try again.\n"
55 | );
56 | process.exit(1);
57 | } else if (authServerInstance.authCompletedSuccessfully) {
58 | // Auth was successful (either existing tokens were valid or flow completed just now)
59 | process.stderr.write("Authentication successful.\n");
60 | process.exit(0); // Exit cleanly if auth is already done
61 | }
62 |
63 | // If we reach here, the server started and is waiting for the browser callback
64 | process.stderr.write(
65 | "Authentication server started. Please complete the authentication in your browser...\n"
66 | );
67 |
68 | // Wait for completion
69 | const intervalId = setInterval(async () => {
70 | if (authServerInstance.authCompletedSuccessfully) {
71 | clearInterval(intervalId);
72 | await authServerInstance.stop();
73 | process.stderr.write("Authentication completed successfully!\n");
74 | process.exit(0);
75 | }
76 | }, 1000);
77 | } catch (error) {
78 | process.stderr.write(`Authentication failed: ${error}\n`);
79 | process.exit(1);
80 | }
81 | }
82 |
83 | function showHelp(): void {
84 | process.stdout.write(`
85 | Google Calendar MCP Server v${VERSION}
86 |
87 | Usage:
88 | npx @cocal/google-calendar-mcp [command]
89 |
90 | Commands:
91 | auth Run the authentication flow
92 | start Start the MCP server (default)
93 | version Show version information
94 | help Show this help message
95 |
96 | Examples:
97 | npx @cocal/google-calendar-mcp auth
98 | npx @cocal/google-calendar-mcp start
99 | npx @cocal/google-calendar-mcp version
100 | npx @cocal/google-calendar-mcp
101 |
102 | Environment Variables:
103 | GOOGLE_OAUTH_CREDENTIALS Path to OAuth credentials file
104 | `);
105 | }
106 |
107 | function showVersion(): void {
108 | process.stdout.write(`Google Calendar MCP Server v${VERSION}\n`);
109 | }
110 |
111 | // --- Exports & Execution Guard ---
112 | // Export main for testing or potential programmatic use
113 | export { main, runAuthServer };
114 |
115 | // Parse CLI arguments
116 | function parseCliArgs(): { command: string | undefined } {
117 | const args = process.argv.slice(2);
118 | let command: string | undefined;
119 |
120 | for (let i = 0; i < args.length; i++) {
121 | const arg = args[i];
122 |
123 | // Handle special version/help flags as commands
124 | if (arg === '--version' || arg === '-v' || arg === '--help' || arg === '-h') {
125 | command = arg;
126 | continue;
127 | }
128 |
129 | // Skip transport options and their values
130 | if (arg === '--transport' || arg === '--port' || arg === '--host') {
131 | i++; // Skip the next argument (the value)
132 | continue;
133 | }
134 |
135 | // Skip other flags
136 | if (arg === '--debug') {
137 | continue;
138 | }
139 |
140 | // Check for command (first non-option argument)
141 | if (!command && !arg.startsWith('--')) {
142 | command = arg;
143 | continue;
144 | }
145 | }
146 |
147 | return { command };
148 | }
149 |
150 | // CLI logic here (run always)
151 | const { command } = parseCliArgs();
152 |
153 | switch (command) {
154 | case "auth":
155 | runAuthServer().catch((error) => {
156 | process.stderr.write(`Authentication failed: ${error}\n`);
157 | process.exit(1);
158 | });
159 | break;
160 | case "start":
161 | case void 0:
162 | main().catch((error) => {
163 | process.stderr.write(`Failed to start server: ${error}\n`);
164 | process.exit(1);
165 | });
166 | break;
167 | case "version":
168 | case "--version":
169 | case "-v":
170 | showVersion();
171 | break;
172 | case "help":
173 | case "--help":
174 | case "-h":
175 | showHelp();
176 | break;
177 | default:
178 | process.stderr.write(`Unknown command: ${command}\n`);
179 | showHelp();
180 | process.exit(1);
181 | }
```
--------------------------------------------------------------------------------
/examples/http-with-curl.sh:
--------------------------------------------------------------------------------
```bash
1 | #!/bin/bash
2 |
3 | # Test script for Google Calendar MCP Server HTTP mode using curl
4 | # This demonstrates basic HTTP requests to test the MCP server
5 |
6 | SERVER_URL="${1:-http://localhost:3000}"
7 | SESSION_ID="curl-test-session-$(date +%s)"
8 |
9 | echo "🚀 Testing Google Calendar MCP Server at: $SERVER_URL"
10 | echo "🆔 Using session ID: $SESSION_ID"
11 | echo "=================================================="
12 |
13 | # Test 1: Health check
14 | echo -e "\n🏥 Testing health endpoint..."
15 | curl -s "$SERVER_URL/health" | jq '.' || echo "Health check failed"
16 |
17 | # Test 2: Initialize MCP session
18 | echo -e "\n🤝 Testing MCP initialize..."
19 |
20 | # MCP Initialize request
21 | INIT_REQUEST='{
22 | "jsonrpc": "2.0",
23 | "id": 1,
24 | "method": "initialize",
25 | "params": {
26 | "protocolVersion": "2024-11-05",
27 | "capabilities": {
28 | "tools": {}
29 | },
30 | "clientInfo": {
31 | "name": "curl-test-client",
32 | "version": "1.0.0"
33 | }
34 | }
35 | }'
36 |
37 | echo "Sending initialize request..."
38 | INIT_RESPONSE=$(curl -s -X POST "$SERVER_URL" \
39 | -H "Content-Type: application/json" \
40 | -H "Accept: application/json, text/event-stream" \
41 | -H "mcp-session-id: $SESSION_ID" \
42 | -d "$INIT_REQUEST")
43 |
44 | # Try to parse as JSON, if that fails, check if it's SSE format
45 | if echo "$INIT_RESPONSE" | jq '.' >/dev/null 2>&1; then
46 | # Direct JSON response - extract and parse the nested content
47 | echo "$INIT_RESPONSE" | jq -r '.result.content[0].text // empty' | jq '.' 2>/dev/null || echo "$INIT_RESPONSE" | jq '.'
48 | elif echo "$INIT_RESPONSE" | grep -q "^data:"; then
49 | # SSE format - extract data and parse nested content
50 | echo "$INIT_RESPONSE" | grep "^data:" | sed 's/^data: //' | jq -r '.result.content[0].text // empty' | jq '.' 2>/dev/null
51 | else
52 | echo "❌ Unknown response format"
53 | echo "$INIT_RESPONSE"
54 | fi
55 |
56 | # Check if initialization was successful
57 | if echo "$INIT_RESPONSE" | grep -q "result\|initialize"; then
58 | echo "✅ Initialization successful"
59 | else
60 | echo "❌ Initialization failed - stopping tests"
61 | exit 1
62 | fi
63 |
64 | # Test 3: List Tools request (after successful initialization)
65 | echo -e "\n📋 Testing list tools..."
66 | LIST_TOOLS_REQUEST='{
67 | "jsonrpc": "2.0",
68 | "id": 2,
69 | "method": "tools/list",
70 | "params": {}
71 | }'
72 |
73 | TOOLS_RESPONSE=$(curl -s -X POST "$SERVER_URL" \
74 | -H "Content-Type: application/json" \
75 | -H "Accept: application/json, text/event-stream" \
76 | -H "mcp-session-id: $SESSION_ID" \
77 | -d "$LIST_TOOLS_REQUEST")
78 |
79 | # Parse response appropriately
80 | if echo "$TOOLS_RESPONSE" | jq '.' >/dev/null 2>&1; then
81 | # Direct JSON - show the full tools list response (not nested content)
82 | echo "$TOOLS_RESPONSE" | jq '.result.tools[] | {name, description}'
83 | elif echo "$TOOLS_RESPONSE" | grep -q "^data:"; then
84 | # SSE format
85 | echo "$TOOLS_RESPONSE" | grep "^data:" | sed 's/^data: //' | jq '.result.tools[] | {name, description}'
86 | else
87 | echo "❌ List tools failed - unknown format"
88 | echo "$TOOLS_RESPONSE"
89 | fi
90 |
91 | # Test 4: Call list-calendars tool
92 | echo -e "\n📅 Testing list-calendars tool..."
93 |
94 | LIST_CALENDARS_REQUEST='{
95 | "jsonrpc": "2.0",
96 | "id": 3,
97 | "method": "tools/call",
98 | "params": {
99 | "name": "list-calendars",
100 | "arguments": {}
101 | }
102 | }'
103 |
104 | CALENDARS_RESPONSE=$(curl -s -X POST "$SERVER_URL" \
105 | -H "Content-Type: application/json" \
106 | -H "Accept: application/json, text/event-stream" \
107 | -H "mcp-session-id: $SESSION_ID" \
108 | -d "$LIST_CALENDARS_REQUEST")
109 |
110 | # Parse response appropriately
111 | if echo "$CALENDARS_RESPONSE" | jq '.' >/dev/null 2>&1; then
112 | # Extract the nested JSON from content[0].text and parse it
113 | echo "$CALENDARS_RESPONSE" | jq -r '.result.content[0].text' | jq '.calendars[] | {id, summary, timeZone, accessRole}'
114 | elif echo "$CALENDARS_RESPONSE" | grep -q "^data:"; then
115 | # SSE format - extract data, then nested content
116 | echo "$CALENDARS_RESPONSE" | grep "^data:" | sed 's/^data: //' | jq -r '.result.content[0].text' | jq '.calendars[] | {id, summary, timeZone, accessRole}'
117 | else
118 | echo "❌ List calendars failed - unknown format"
119 | echo "$CALENDARS_RESPONSE"
120 | fi
121 |
122 | # Test 5: Call list-colors tool
123 | echo -e "\n🎨 Testing list-colors tool..."
124 |
125 | LIST_COLORS_REQUEST='{
126 | "jsonrpc": "2.0",
127 | "id": 4,
128 | "method": "tools/call",
129 | "params": {
130 | "name": "list-colors",
131 | "arguments": {}
132 | }
133 | }'
134 |
135 | COLORS_RESPONSE=$(curl -s -X POST "$SERVER_URL" \
136 | -H "Content-Type: application/json" \
137 | -H "Accept: application/json, text/event-stream" \
138 | -H "mcp-session-id: $SESSION_ID" \
139 | -d "$LIST_COLORS_REQUEST")
140 |
141 | # Parse response appropriately
142 | if echo "$COLORS_RESPONSE" | jq '.' >/dev/null 2>&1; then
143 | # Extract nested JSON and display color summary
144 | echo "$COLORS_RESPONSE" | jq -r '.result.content[0].text' | jq '{
145 | eventColors: .event | length,
146 | calendarColors: .calendar | length,
147 | sampleEventColor: .event["1"],
148 | sampleCalendarColor: .calendar["1"]
149 | }'
150 | elif echo "$COLORS_RESPONSE" | grep -q "^data:"; then
151 | # SSE format
152 | echo "$COLORS_RESPONSE" | grep "^data:" | sed 's/^data: //' | jq -r '.result.content[0].text' | jq '{
153 | eventColors: .event | length,
154 | calendarColors: .calendar | length,
155 | sampleEventColor: .event["1"],
156 | sampleCalendarColor: .calendar["1"]
157 | }'
158 | else
159 | echo "❌ List colors failed - unknown format"
160 | echo "$COLORS_RESPONSE"
161 | fi
162 |
163 | echo -e "\n✅ HTTP testing completed!"
164 | echo -e "\n💡 To test with different server URL: $0 http://your-server:port"
165 |
```
--------------------------------------------------------------------------------
/docs/deployment.md:
--------------------------------------------------------------------------------
```markdown
1 | # Deployment Guide
2 |
3 | This guide covers deploying the Google Calendar MCP Server for remote access via HTTP transport.
4 |
5 | ## Transport Modes
6 |
7 | ### stdio Transport (Default)
8 | - Local use only
9 | - Direct communication with Claude Desktop
10 | - No network exposure
11 | - Automatic authentication handling
12 |
13 | ### HTTP Transport
14 | - Remote deployment capable
15 | - Server-Sent Events (SSE) for real-time communication
16 | - Built-in security features
17 | - Suitable for cloud deployment
18 |
19 | ## HTTP Server Features
20 |
21 | - ✅ **CORS Support**: Basic cross-origin access support
22 | - ✅ **Health Monitoring**: Basic health check endpoint
23 | - ✅ **Graceful Shutdown**: Proper resource cleanup
24 | - ✅ **Origin Validation**: DNS rebinding protection
25 |
26 | ## Local HTTP Deployment
27 |
28 | ### Basic HTTP Server
29 |
30 | ```bash
31 | # Start on localhost only (default port 3000)
32 | npm run start:http
33 | ```
34 |
35 | ### Public HTTP Server
36 |
37 | ```bash
38 | # Listen on all interfaces (0.0.0.0)
39 | npm run start:http:public
40 | ```
41 |
42 | ### Environment Variables
43 |
44 | ```bash
45 | PORT=3000 # Server port
46 | HOST=localhost # Bind address
47 | TRANSPORT=http # Transport mode
48 | ```
49 |
50 | ## Docker Deployment
51 |
52 | ### Using Docker Compose (Recommended)
53 |
54 | ```bash
55 | # stdio mode
56 | docker compose up -d server
57 |
58 | # HTTP mode
59 | docker compose --profile http up -d
60 | ```
61 |
62 | See [Docker Guide](docker.md) for complete setup instructions.
63 |
64 | ### Using Docker Run
65 |
66 | ```bash
67 | # Create volume for token storage
68 | docker volume create mcp-tokens
69 |
70 | # stdio mode
71 | docker run -i \
72 | -v ./gcp-oauth.keys.json:/usr/src/app/gcp-oauth.keys.json:ro \
73 | -v mcp-tokens:/home/nodejs/.config/google-calendar-mcp \
74 | -e TRANSPORT=stdio \
75 | --name calendar-mcp \
76 | google-calendar-mcp
77 |
78 | # HTTP mode
79 | docker run -d \
80 | -p 3000:3000 \
81 | -v ./gcp-oauth.keys.json:/usr/src/app/gcp-oauth.keys.json:ro \
82 | -v mcp-tokens:/home/nodejs/.config/google-calendar-mcp \
83 | -e TRANSPORT=http \
84 | -e HOST=0.0.0.0 \
85 | --name calendar-mcp \
86 | google-calendar-mcp
87 |
88 | ```
89 |
90 | ### Building Custom Image
91 |
92 | Use the provided Dockerfile which includes proper user setup and token storage:
93 |
94 | ```bash
95 | # Build image
96 | docker build -t google-calendar-mcp .
97 |
98 | # Run with authentication
99 | docker run -it google-calendar-mcp npm run auth
100 | ```
101 |
102 | ## Cloud Deployment
103 |
104 | ### Google Cloud Run
105 |
106 | ```bash
107 | # Build and push image
108 | gcloud builds submit --tag gcr.io/PROJECT-ID/calendar-mcp
109 |
110 | # Deploy
111 | gcloud run deploy calendar-mcp \
112 | --image gcr.io/PROJECT-ID/calendar-mcp \
113 | --platform managed \
114 | --region us-central1 \
115 | --allow-unauthenticated \
116 | --set-env-vars="TRANSPORT=http"
117 | ```
118 |
119 | ### AWS ECS
120 |
121 | 1. Push image to ECR
122 | 2. Create task definition with environment variables
123 | 3. Deploy service with ALB
124 |
125 | ### Heroku
126 |
127 | ```bash
128 | # Create app
129 | heroku create your-calendar-mcp
130 |
131 | # Set buildpack
132 | heroku buildpacks:set heroku/nodejs
133 |
134 | # Configure
135 | heroku config:set TRANSPORT=http
136 | heroku config:set GOOGLE_OAUTH_CREDENTIALS=./gcp-oauth.keys.json
137 |
138 | # Deploy
139 | git push heroku main
140 | ```
141 |
142 | ## Security Configuration
143 |
144 | ### HTTPS/TLS
145 |
146 | Always use HTTPS in production:
147 |
148 | 1. **Behind a Reverse Proxy** (Recommended)
149 | ```nginx
150 | server {
151 | listen 443 ssl;
152 | server_name calendar-mcp.example.com;
153 |
154 | ssl_certificate /path/to/cert.pem;
155 | ssl_certificate_key /path/to/key.pem;
156 |
157 | location / {
158 | proxy_pass http://localhost:3000;
159 | proxy_http_version 1.1;
160 | proxy_set_header Upgrade $http_upgrade;
161 | proxy_set_header Connection '';
162 | proxy_set_header Host $host;
163 | proxy_set_header X-Real-IP $remote_addr;
164 | }
165 | }
166 | ```
167 |
168 | 2. **Direct TLS** (Not built-in)
169 | For TLS support, use a reverse proxy like nginx or a cloud load balancer with SSL termination.
170 |
171 |
172 | ### Authentication Flow
173 |
174 | 1. Client connects to HTTP endpoint
175 | 2. Server redirects to Google OAuth
176 | 3. User authenticates with Google
177 | 4. Server stores tokens securely
178 | 5. Client receives session token
179 | 6. All requests use session token
180 |
181 | ## Monitoring
182 |
183 | ### Health Checks
184 |
185 | ```bash
186 | # Health check
187 | curl http://localhost:3000/health
188 | ```
189 |
190 | ### Logging
191 |
192 | ```bash
193 | # Enable debug logging
194 | DEBUG=mcp:* npm run start:http
195 |
196 | # JSON logging for production
197 | NODE_ENV=production npm run start:http
198 | ```
199 |
200 |
201 | ## Production Checklist
202 |
203 | **OAuth App Setup:**
204 | - [ ] **Publish OAuth app to production in Google Cloud Console**
205 | - [ ] **Set up proper redirect URIs for your domain**
206 | - [ ] **Use production OAuth credentials (not test/development)**
207 | - [ ] **Consider submitting for verification to remove user warnings**
208 |
209 | **Infrastructure:**
210 | - [ ] Use HTTPS/TLS encryption
211 | - [ ] Configure reverse proxy for production
212 | - [ ] Set up SSL termination
213 | - [ ] Set up monitoring/alerting for authentication failures
214 | - [ ] Configure log aggregation
215 | - [ ] Implement backup strategy for token storage
216 | - [ ] Test disaster recovery and re-authentication procedures
217 | - [ ] Review security headers
218 | - [ ] Enable graceful shutdown
219 |
220 | **Note**: The 7-day token expiration is resolved by publishing your OAuth app to production in Google Cloud Console.
221 |
222 | ## Troubleshooting
223 |
224 | ### Connection Issues
225 | - Check firewall rules
226 | - Verify CORS configuration
227 | - Test with curl first
228 |
229 | ### Authentication Failures
230 | - Ensure credentials are accessible
231 | - Check token permissions
232 | - Verify redirect URIs
233 |
234 | ### Performance Problems
235 | - Enable caching headers
236 | - Use CDN for static assets
237 | - Monitor memory usage
238 |
239 | See [Troubleshooting Guide](troubleshooting.md) for more solutions.
```