This is page 1 of 2. Use http://codebase.md/caue397/google-calendar-mcp?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .gitignore
├── llm
│ ├── example_server.ts
│ └── mcp-llms-full.txt
├── package-lock.json
├── package.json
├── README.md
├── scripts
│ └── build.js
├── src
│ ├── auth-server.ts
│ ├── index.ts
│ └── token-manager.ts
└── tsconfig.json
```
# Files
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
```
1 | node_modules/
2 | build/
3 | *.log
4 | gcp-oauth.keys.json
5 | .gcp-saved-tokens.json
```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
1 | # Google Calendar MCP Server
2 |
3 | This is a Model Context Protocol (MCP) server that provides integration with Google Calendar. It allows LLMs to read, create, and manage calendar events through a standardized interface.
4 |
5 | ## Features
6 |
7 | - List available calendars
8 | - List events from a calendar
9 | - Create new calendar events
10 | - Update existing events
11 | - Delete events
12 | - Process events from screenshots and images
13 |
14 | ## Requirements
15 |
16 | 1. Node.js 16 or higher
17 | 2. TypeScript 5.3 or higher
18 | 3. A Google Cloud project with the Calendar API enabled
19 | 4. OAuth 2.0 credentials (Client ID and Client Secret)
20 |
21 | ## Project Structure
22 |
23 | ```
24 | google-calendar-mcp/
25 | ├── src/ # TypeScript source files
26 | ├── build/ # Compiled JavaScript output
27 | ├── llm/ # LLM-specific configurations and prompts
28 | ├── package.json # Project dependencies and scripts
29 | └── tsconfig.json # TypeScript configuration
30 | ```
31 |
32 | ## Google Cloud Setup
33 |
34 | 1. Go to the [Google Cloud Console](https://console.cloud.google.com)
35 | 2. Create a new project or select an existing one.
36 | 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.
37 | 4. Create OAuth 2.0 credentials:
38 | - Go to Credentials
39 | - Click "Create Credentials" > "OAuth client ID"
40 | - Choose "User data" for the type of data that the app will be accessing
41 | - Add your app name and contact information
42 | - Add the following scopes (optional):
43 | - `https://www.googleapis.com/auth/calendar.events`
44 | - Select "Desktop app" as the application type
45 | - Add your email address as a test user under the [OAuth Consent screen](https://console.cloud.google.com/apis/credentials/consent)
46 | - Note: it will 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 propogated.
47 |
48 | ## Installation
49 |
50 | 1. Clone the repository
51 | 2. Install dependencies:
52 | ```bash
53 | npm install
54 | ```
55 | 3. Build the TypeScript code:
56 | ```bash
57 | npm run build
58 | ```
59 | 4. Download your Google OAuth credentials from the Google Cloud Console (under "Credentials") and rename the file to `gcp-oauth.keys.json` and place it in the root directory of the project.
60 |
61 | ## Available Scripts
62 |
63 | - `npm run build` - Build the TypeScript code
64 | - `npm run build:watch` - Build TypeScript in watch mode for development
65 | - `npm run dev` - Start the server in development mode using ts-node
66 | - `npm run auth` - Start the authentication server for Google OAuth flow
67 |
68 | ## Authentication
69 |
70 | The server supports both automatic and manual authentication flows:
71 |
72 | ### Automatic Authentication (Recommended)
73 | 1. Place your Google OAuth credentials in a file named `gcp-oauth.keys.json` in the root directory of the project.
74 | 2. Start the MCP server:
75 | ```bash
76 | npm start
77 | ```
78 | 3. If no valid authentication tokens are found, the server will automatically:
79 | - Start an authentication server (on ports 3000-3004)
80 | - Open a browser window for the OAuth flow
81 | - Save the tokens securely once authenticated
82 | - Shut down the auth server
83 | - Continue normal MCP server operation
84 |
85 | The server automatically manages token refresh and re-authentication when needed:
86 | - Tokens are automatically refreshed before expiration
87 | - If refresh fails, clear error messages guide you through re-authentication
88 | - Token files are stored securely with restricted permissions
89 |
90 | ### Manual Authentication
91 | For advanced users or troubleshooting, you can manually run the authentication flow:
92 | ```bash
93 | npm run auth
94 | ```
95 |
96 | This will:
97 | 1. Start the authentication server
98 | 2. Open a browser window for the OAuth flow
99 | 3. Save the tokens and exit
100 |
101 | ### Security Notes
102 | - OAuth credentials are stored in `gcp-oauth.keys.json`
103 | - Authentication tokens are stored in `.gcp-saved-tokens.json` with 600 permissions
104 | - Tokens are automatically refreshed in the background
105 | - Token integrity is validated before each API call
106 | - The auth server automatically shuts down after successful authentication
107 | - Never commit OAuth credentials or token files to version control
108 |
109 | ## Usage
110 |
111 | The server exposes the following tools:
112 | - `list-calendars`: List all available calendars
113 | - `list-events`: List events from a calendar
114 | - `create-event`: Create a new calendar event
115 | - `update-event`: Update an existing calendar event
116 | - `delete-event`: Delete a calendar event
117 |
118 | ## Using with Claude Desktop
119 |
120 | 1. Add this configuration to your Claude Desktop config file. E.g. `/Users/<user>/Library/Application Support/Claude/claude_desktop_config.json`:
121 | ```json
122 | {
123 | "mcpServers": {
124 | "google-calendar": {
125 | "command": "node",
126 | "args": ["path/to/build/index.js"]
127 | }
128 | }
129 | }
130 | ```
131 |
132 | 2. Restart Claude Desktop
133 |
134 | ## Example Usage
135 |
136 | Along with the normal capabilities you would expect for a calendar integration you can also do really dynamic things like add events from screenshots and images and much more.
137 |
138 | 1. Add events from screenshots and images:
139 | ```
140 | Add this event to my calendar based on the attached screenshot.
141 | ```
142 | Supported image formats: PNG, JPEG, GIF
143 | Images can contain event details like date, time, location, and description
144 |
145 | 2. Check attendance:
146 | ```
147 | Which events tomorrow have attendees who have not accepted the invitation?
148 | ```
149 | 3. Auto coordinate events:
150 | ```
151 | Here's some available that was provided to me by someone I am interviewing. Take a look at the available times and create an event for me to interview them that is free on my work calendar.
152 | ```
153 | 4. Provide your own availability:
154 | ```
155 | Please provide availability looking at both my personal and work calendar for this upcoming week. Choose times that work well for normal working hours on the East Coast. Meeting time is 1 hour
156 | ```
157 |
158 | ## Development
159 |
160 | ### Troubleshooting
161 |
162 | Common issues and solutions:
163 |
164 | 1. OAuth Token expires after one week (7 days)
165 | - Apps that are in testing mode, rather than production, will need to go through the OAuth flow again after a week.
166 |
167 | 3. OAuth Token Errors
168 | - Ensure your `gcp-oauth.keys.json` is correctly formatted
169 | - Try deleting `.gcp-saved-tokens.json` and re-authenticating
170 |
171 | 4. TypeScript Build Errors
172 | - Make sure all dependencies are installed: `npm install`
173 | - Check your Node.js version matches prerequisites
174 | - Clear the build directory: `rm -rf build/`
175 |
176 | 5. Image Processing Issues
177 | - Verify the image format is supported
178 | - Ensure the image contains clear, readable text
179 |
180 | ## Security Notes
181 |
182 | - The server runs locally and requires OAuth authentication
183 | - OAuth credentials should be stored in `gcp-oauth.keys.json` in the project root
184 | - Authentication tokens are stored in `.gcp-saved-tokens.json` with restricted file permissions
185 | - Tokens are automatically refreshed when expired
186 | - Never commit your OAuth credentials or token files to version control
187 | - For production use, get your OAuth application verified by Google
188 |
189 | ## License
190 |
191 | MIT
192 |
```
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "compilerOptions": {
3 | "target": "ES2015",
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 | },
14 | "include": ["src/**/*"],
15 | "exclude": ["node_modules"]
16 | }
17 |
```
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "name": "google-calendar-mcp",
3 | "version": "1.0.1",
4 | "description": "Google Calendar MCP Server",
5 | "type": "module",
6 | "bin": {
7 | "google-calendar-mcp": "./build/index.js"
8 | },
9 | "scripts": {
10 | "typecheck": "tsc --noEmit",
11 | "build": "npm run typecheck && node scripts/build.js",
12 | "start": "node build/index.js",
13 | "auth": "node build/auth-server.js",
14 | "dev": "ts-node --esm src/index.ts",
15 | "postinstall": "scripts/build.js"
16 | },
17 | "dependencies": {
18 | "@google-cloud/local-auth": "^3.0.1",
19 | "@modelcontextprotocol/sdk": "^1.0.3",
20 | "@types/express": "^4.17.21",
21 | "esbuild": "^0.25.0",
22 | "express": "^4.18.2",
23 | "force": "^0.0.3",
24 | "google-auth-library": "^9.15.0",
25 | "googleapis": "^144.0.0",
26 | "install": "^0.13.0",
27 | "zod": "^3.22.4"
28 | },
29 | "devDependencies": {
30 | "@types/node": "^20.10.4",
31 | "typescript": "^5.3.3"
32 | }
33 | }
34 |
```
--------------------------------------------------------------------------------
/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 | if (isWatch) {
26 | const context = await esbuild.context(buildOptions);
27 | await context.watch();
28 | console.log('Watching for changes...');
29 | } else {
30 | await esbuild.build(buildOptions);
31 |
32 | // Make the file executable on non-Windows platforms
33 | if (process.platform !== 'win32') {
34 | const { chmod } = await import('fs/promises');
35 | await chmod(buildOptions.outfile, 0o755);
36 | }
37 | }
```
--------------------------------------------------------------------------------
/src/token-manager.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { OAuth2Client, Credentials } from 'google-auth-library';
2 | import * as fs from 'fs/promises';
3 | import * as path from 'path';
4 |
5 | export class TokenManager {
6 | private oauth2Client: OAuth2Client;
7 | private tokenPath: string;
8 | private refreshTimer: NodeJS.Timeout | null = null;
9 | private maxRetries = 5;
10 | private refreshThreshold = 0.8; // Refresh at 80% of token lifetime
11 |
12 | constructor(oauth2Client: OAuth2Client) {
13 | this.oauth2Client = oauth2Client;
14 | this.tokenPath = this.getSecureTokenPath();
15 | this.setupTokenRefresh();
16 | }
17 |
18 | private getSecureTokenPath(): string {
19 | const url = new URL(import.meta.url);
20 | const pathname = url.protocol === 'file:' ?
21 | // Remove leading slash on Windows paths
22 | url.pathname.replace(/^\/([A-Z]:)/, '$1') :
23 | url.pathname;
24 |
25 | return path.join(
26 | path.dirname(pathname),
27 | '../.gcp-saved-tokens.json'
28 | );
29 | }
30 |
31 | private calculateJitter(retryCount: number): number {
32 | const baseDelay = 1000; // 1 second
33 | const maxJitter = 1000; // 1 second
34 | const exponentialDelay = baseDelay * Math.pow(2, retryCount);
35 | const jitter = Math.random() * maxJitter;
36 | return exponentialDelay + jitter;
37 | }
38 |
39 | private async setupTokenRefresh(): Promise<void> {
40 | const credentials = this.oauth2Client.credentials;
41 | if (!credentials.expiry_date) return;
42 |
43 | const now = Date.now();
44 | const timeUntilExpiry = credentials.expiry_date - now;
45 | const refreshTime = timeUntilExpiry * (1 - this.refreshThreshold);
46 |
47 | if (this.refreshTimer) {
48 | clearTimeout(this.refreshTimer);
49 | }
50 |
51 | this.refreshTimer = setTimeout(
52 | () => this.refreshToken(),
53 | Math.max(0, refreshTime)
54 | );
55 | }
56 |
57 | private async refreshToken(retryCount = 0): Promise<void> {
58 | try {
59 | const response = await this.oauth2Client.refreshAccessToken();
60 | const newTokens = response.credentials;
61 |
62 | if (!newTokens.access_token) {
63 | throw new Error('Received invalid tokens during refresh');
64 | }
65 |
66 | await this.saveTokens(newTokens);
67 | this.setupTokenRefresh();
68 | } catch (error) {
69 | console.error(`Token refresh attempt ${retryCount + 1} failed:`, error);
70 |
71 | if (retryCount < this.maxRetries) {
72 | const delay = this.calculateJitter(retryCount);
73 | setTimeout(() => this.refreshToken(retryCount + 1), delay);
74 | } else {
75 | console.error('Token refresh failed after maximum retries');
76 | }
77 | }
78 | }
79 |
80 | public async loadSavedTokens(): Promise<boolean> {
81 | try {
82 | const tokens = JSON.parse(await fs.readFile(this.tokenPath, 'utf-8'));
83 |
84 | if (!tokens || typeof tokens !== 'object') {
85 | console.error('Invalid token format');
86 | return false;
87 | }
88 |
89 | this.oauth2Client.setCredentials(tokens);
90 | this.setupTokenRefresh();
91 | return true;
92 | } catch (error) {
93 | if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
94 | console.error('Error loading tokens:', error);
95 | }
96 | return false;
97 | }
98 | }
99 |
100 | public async saveTokens(tokens: Credentials): Promise<void> {
101 | try {
102 | await fs.writeFile(
103 | this.tokenPath,
104 | JSON.stringify(tokens, null, 2),
105 | { mode: 0o600 }
106 | );
107 | this.oauth2Client.setCredentials(tokens);
108 | this.setupTokenRefresh();
109 | } catch (error) {
110 | console.error('Error saving tokens:', error);
111 | throw error;
112 | }
113 | }
114 |
115 | public async validateTokens(): Promise<boolean> {
116 | const credentials = this.oauth2Client.credentials;
117 | if (!credentials.access_token) return false;
118 |
119 | if (credentials.expiry_date) {
120 | const now = Date.now();
121 | if (now >= credentials.expiry_date) {
122 | try {
123 | await this.refreshToken();
124 | return true;
125 | } catch {
126 | return false;
127 | }
128 | }
129 | }
130 |
131 | return true;
132 | }
133 |
134 | public clearTokens(): void {
135 | if (this.refreshTimer) {
136 | clearTimeout(this.refreshTimer);
137 | this.refreshTimer = null;
138 | }
139 | }
140 | }
```
--------------------------------------------------------------------------------
/src/auth-server.ts:
--------------------------------------------------------------------------------
```typescript
1 | import * as fs from 'fs/promises';
2 | import * as path from 'path';
3 | import express from 'express';
4 | import { OAuth2Client } from 'google-auth-library';
5 | import { TokenManager } from './token-manager.js';
6 | import open from 'open';
7 |
8 | export class AuthServer {
9 | private server: express.Application | null = null;
10 | private httpServer: any = null;
11 | private tokenManager: TokenManager;
12 | private port: number;
13 | private credentials: { client_id: string; client_secret: string } | null = null;
14 |
15 | constructor(private oauth2Client: OAuth2Client) {
16 | this.tokenManager = new TokenManager(oauth2Client);
17 | this.port = 3000; // Start with default port
18 | }
19 |
20 | private getKeysFilePath(): string {
21 | return path.join(process.cwd(), 'gcp-oauth.keys.json');
22 | }
23 |
24 | private async loadCredentials(): Promise<void> {
25 | const content = await fs.readFile(this.getKeysFilePath(), 'utf-8');
26 | const keys = JSON.parse(content);
27 | this.credentials = {
28 | client_id: keys.installed.client_id,
29 | client_secret: keys.installed.client_secret
30 | };
31 | }
32 |
33 | private createOAuthClient(port: number): OAuth2Client {
34 | if (!this.credentials) {
35 | throw new Error('Credentials not loaded');
36 | }
37 | return new OAuth2Client(
38 | this.credentials.client_id,
39 | this.credentials.client_secret,
40 | `http://localhost:${port}/oauth2callback`
41 | );
42 | }
43 |
44 | private async startServer(): Promise<boolean> {
45 | // Try ports 3000 and 3001
46 | const ports = [3000, 3001];
47 |
48 | for (const port of ports) {
49 | this.port = port;
50 | try {
51 | // Create a new OAuth client with the current port
52 | this.oauth2Client = this.createOAuthClient(port);
53 |
54 | this.server = express();
55 |
56 | // Handle OAuth callback
57 | this.server.get('/oauth2callback', async (req, res) => {
58 | try {
59 | const code = req.query.code as string;
60 | if (!code) {
61 | throw new Error('No code received');
62 | }
63 |
64 | const { tokens } = await this.oauth2Client.getToken(code);
65 | await this.tokenManager.saveTokens(tokens);
66 |
67 | res.send('Authentication successful! You can close this window.');
68 | await this.stop();
69 | return true;
70 | } catch (error: unknown) {
71 | const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
72 | console.error('Error in OAuth callback:', errorMessage);
73 | res.status(500).send('Authentication failed. Please try again.');
74 | await this.stop();
75 | return false;
76 | }
77 | });
78 |
79 | // Try to start the server
80 | const serverStarted = await new Promise<boolean>((resolve) => {
81 | if (!this.server) {
82 | resolve(false);
83 | return;
84 | }
85 |
86 | this.httpServer = this.server.listen(port, () => {
87 | console.log(`Auth server listening on port ${port}`);
88 | resolve(true);
89 | });
90 |
91 | this.httpServer.on('error', (error: any) => {
92 | if (error.code === 'EADDRINUSE') {
93 | console.log(`Port ${port} is in use, trying next port...`);
94 | resolve(false);
95 | } else {
96 | console.error('Server error:', error);
97 | resolve(false);
98 | }
99 | });
100 | });
101 |
102 | if (serverStarted) {
103 | return true;
104 | }
105 | } catch (error) {
106 | console.error(`Error starting server on port ${port}:`, error);
107 | }
108 | }
109 |
110 | console.error('Failed to start server on any available port');
111 | return false;
112 | }
113 |
114 | public async start(): Promise<boolean> {
115 | console.log('Starting auth server...');
116 |
117 | try {
118 | const tokens = await this.tokenManager.loadSavedTokens();
119 | if (tokens) {
120 | console.log('Valid tokens found, no need to start auth server');
121 | return true;
122 | }
123 | } catch (error: unknown) {
124 | const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
125 | console.log('No valid tokens found:', errorMessage);
126 | }
127 |
128 | try {
129 | await this.loadCredentials();
130 |
131 | const serverStarted = await this.startServer();
132 | if (!serverStarted) {
133 | console.error('Failed to start auth server');
134 | return false;
135 | }
136 |
137 | const redirectUri = `http://localhost:${this.port}/oauth2callback`;
138 | console.log('Using redirect URI:', redirectUri);
139 |
140 | const authorizeUrl = this.oauth2Client.generateAuthUrl({
141 | access_type: 'offline',
142 | scope: ['https://www.googleapis.com/auth/calendar']
143 | });
144 |
145 | console.log(`Opening browser for authentication on port ${this.port}...`);
146 | await open(authorizeUrl);
147 |
148 | return true;
149 | } catch (error: unknown) {
150 | const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
151 | console.error('Authentication failed:', errorMessage);
152 | await this.stop();
153 | return false;
154 | }
155 | }
156 |
157 | public async stop(): Promise<void> {
158 | if (this.httpServer) {
159 | return new Promise((resolve) => {
160 | this.httpServer.close(() => {
161 | console.log('Auth server stopped');
162 | this.server = null;
163 | this.httpServer = null;
164 | resolve();
165 | });
166 | });
167 | }
168 | }
169 | }
170 |
171 | // For backwards compatibility with npm run auth
172 | if (import.meta.url === new URL(import.meta.resolve('./auth-server.js')).href) {
173 | const oauth2Client = new OAuth2Client();
174 | const authServer = new AuthServer(oauth2Client);
175 | authServer.start().catch(console.error);
176 | }
```
--------------------------------------------------------------------------------
/llm/example_server.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3 | import {
4 | CallToolRequestSchema,
5 | ListToolsRequestSchema,
6 | } from "@modelcontextprotocol/sdk/types.js";
7 | import { z } from "zod";
8 |
9 | const NWS_API_BASE = "https://api.weather.gov";
10 | const USER_AGENT = "weather-app/1.0";
11 |
12 | // Define Zod schemas for validation
13 | const AlertsArgumentsSchema = z.object({
14 | state: z.string().length(2),
15 | });
16 |
17 | const ForecastArgumentsSchema = z.object({
18 | latitude: z.number().min(-90).max(90),
19 | longitude: z.number().min(-180).max(180),
20 | });
21 |
22 | // Create server instance
23 | const server = new Server(
24 | {
25 | name: "weather",
26 | version: "1.0.0",
27 | },
28 | {
29 | capabilities: {
30 | tools: {},
31 | },
32 | }
33 | );
34 |
35 | // List available tools
36 | server.setRequestHandler(ListToolsRequestSchema, async () => {
37 | return {
38 | tools: [
39 | {
40 | name: "get-alerts",
41 | description: "Get weather alerts for a state",
42 | inputSchema: {
43 | type: "object",
44 | properties: {
45 | state: {
46 | type: "string",
47 | description: "Two-letter state code (e.g. CA, NY)",
48 | },
49 | },
50 | required: ["state"],
51 | },
52 | },
53 | {
54 | name: "get-forecast",
55 | description: "Get weather forecast for a location",
56 | inputSchema: {
57 | type: "object",
58 | properties: {
59 | latitude: {
60 | type: "number",
61 | description: "Latitude of the location",
62 | },
63 | longitude: {
64 | type: "number",
65 | description: "Longitude of the location",
66 | },
67 | },
68 | required: ["latitude", "longitude"],
69 | },
70 | },
71 | ],
72 | };
73 | });
74 |
75 | // Helper function for making NWS API requests
76 | async function makeNWSRequest<T>(url: string): Promise<T | null> {
77 | const headers = {
78 | "User-Agent": USER_AGENT,
79 | Accept: "application/geo+json",
80 | };
81 |
82 | try {
83 | const response = await fetch(url, { headers });
84 | if (!response.ok) {
85 | throw new Error(`HTTP error! status: ${response.status}`);
86 | }
87 | return (await response.json()) as T;
88 | } catch (error) {
89 | console.error("Error making NWS request:", error);
90 | return null;
91 | }
92 | }
93 |
94 | interface AlertFeature {
95 | properties: {
96 | event?: string;
97 | areaDesc?: string;
98 | severity?: string;
99 | status?: string;
100 | headline?: string;
101 | };
102 | }
103 |
104 | // Format alert data
105 | function formatAlert(feature: AlertFeature): string {
106 | const props = feature.properties;
107 | return [
108 | `Event: ${props.event || "Unknown"}`,
109 | `Area: ${props.areaDesc || "Unknown"}`,
110 | `Severity: ${props.severity || "Unknown"}`,
111 | `Status: ${props.status || "Unknown"}`,
112 | `Headline: ${props.headline || "No headline"}`,
113 | "---",
114 | ].join("\n");
115 | }
116 |
117 | interface ForecastPeriod {
118 | name?: string;
119 | temperature?: number;
120 | temperatureUnit?: string;
121 | windSpeed?: string;
122 | windDirection?: string;
123 | shortForecast?: string;
124 | }
125 |
126 | interface AlertsResponse {
127 | features: AlertFeature[];
128 | }
129 |
130 | interface PointsResponse {
131 | properties: {
132 | forecast?: string;
133 | };
134 | }
135 |
136 | interface ForecastResponse {
137 | properties: {
138 | periods: ForecastPeriod[];
139 | };
140 | }
141 |
142 | // Handle tool execution
143 | server.setRequestHandler(CallToolRequestSchema, async (request) => {
144 | const { name, arguments: args } = request.params;
145 |
146 | try {
147 | if (name === "get-alerts") {
148 | const { state } = AlertsArgumentsSchema.parse(args);
149 | const stateCode = state.toUpperCase();
150 |
151 | const alertsUrl = `${NWS_API_BASE}/alerts?area=${stateCode}`;
152 | const alertsData = await makeNWSRequest<AlertsResponse>(alertsUrl);
153 |
154 | if (!alertsData) {
155 | return {
156 | content: [
157 | {
158 | type: "text",
159 | text: "Failed to retrieve alerts data",
160 | },
161 | ],
162 | };
163 | }
164 |
165 | const features = alertsData.features || [];
166 | if (features.length === 0) {
167 | return {
168 | content: [
169 | {
170 | type: "text",
171 | text: `No active alerts for ${stateCode}`,
172 | },
173 | ],
174 | };
175 | }
176 |
177 | const formattedAlerts = features.map(formatAlert);
178 | const alertsText = `Active alerts for ${stateCode}:\n\n${formattedAlerts.join(
179 | "\n"
180 | )}`;
181 |
182 | return {
183 | content: [
184 | {
185 | type: "text",
186 | text: alertsText,
187 | },
188 | ],
189 | };
190 | } else if (name === "get-forecast") {
191 | const { latitude, longitude } = ForecastArgumentsSchema.parse(args);
192 |
193 | // Get grid point data
194 | const pointsUrl = `${NWS_API_BASE}/points/${latitude.toFixed(
195 | 4
196 | )},${longitude.toFixed(4)}`;
197 | const pointsData = await makeNWSRequest<PointsResponse>(pointsUrl);
198 |
199 | if (!pointsData) {
200 | return {
201 | content: [
202 | {
203 | type: "text",
204 | text: `Failed to retrieve grid point data for coordinates: ${latitude}, ${longitude}. This location may not be supported by the NWS API (only US locations are supported).`,
205 | },
206 | ],
207 | };
208 | }
209 |
210 | const forecastUrl = pointsData.properties?.forecast;
211 | if (!forecastUrl) {
212 | return {
213 | content: [
214 | {
215 | type: "text",
216 | text: "Failed to get forecast URL from grid point data",
217 | },
218 | ],
219 | };
220 | }
221 |
222 | // Get forecast data
223 | const forecastData = await makeNWSRequest<ForecastResponse>(forecastUrl);
224 | if (!forecastData) {
225 | return {
226 | content: [
227 | {
228 | type: "text",
229 | text: "Failed to retrieve forecast data",
230 | },
231 | ],
232 | };
233 | }
234 |
235 | const periods = forecastData.properties?.periods || [];
236 | if (periods.length === 0) {
237 | return {
238 | content: [
239 | {
240 | type: "text",
241 | text: "No forecast periods available",
242 | },
243 | ],
244 | };
245 | }
246 |
247 | // Format forecast periods
248 | const formattedForecast = periods.map((period: ForecastPeriod) =>
249 | [
250 | `${period.name || "Unknown"}:`,
251 | `Temperature: ${period.temperature || "Unknown"}°${
252 | period.temperatureUnit || "F"
253 | }`,
254 | `Wind: ${period.windSpeed || "Unknown"} ${
255 | period.windDirection || ""
256 | }`,
257 | `${period.shortForecast || "No forecast available"}`,
258 | "---",
259 | ].join("\n")
260 | );
261 |
262 | const forecastText = `Forecast for ${latitude}, ${longitude}:\n\n${formattedForecast.join(
263 | "\n"
264 | )}`;
265 |
266 | return {
267 | content: [
268 | {
269 | type: "text",
270 | text: forecastText,
271 | },
272 | ],
273 | };
274 | } else {
275 | throw new Error(`Unknown tool: ${name}`);
276 | }
277 | } catch (error) {
278 | if (error instanceof z.ZodError) {
279 | throw new Error(
280 | `Invalid arguments: ${error.errors
281 | .map((e) => `${e.path.join(".")}: ${e.message}`)
282 | .join(", ")}`
283 | );
284 | }
285 | throw error;
286 | }
287 | });
288 |
289 | // Start the server
290 | async function main() {
291 | const transport = new StdioServerTransport();
292 | await server.connect(transport);
293 | console.error("Weather MCP Server running on stdio");
294 | }
295 |
296 | main().catch((error) => {
297 | console.error("Fatal error in main():", error);
298 | process.exit(1);
299 | });
300 |
```
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3 | import { ListToolsRequestSchema, CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js";
4 | import { google } from 'googleapis';
5 | import { OAuth2Client } from 'google-auth-library';
6 | import * as fs from 'fs/promises';
7 | import * as path from 'path';
8 | import { z } from "zod";
9 | import { AuthServer } from './auth-server.js';
10 | import { TokenManager } from './token-manager.js';
11 |
12 | // Utility functions for date format conversion
13 | function parseBrazilianDate(dateString: string): Date {
14 | // Handle different Brazilian date formats
15 | // DD/MM/YYYY
16 | const dateRegex = /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/;
17 | // DD/MM/YYYY HH:MM
18 | const dateTimeRegex = /^(\d{1,2})\/(\d{1,2})\/(\d{4}) (\d{1,2}):(\d{1,2})$/;
19 |
20 | let date: Date;
21 |
22 | if (dateTimeRegex.test(dateString)) {
23 | const [_, day, month, year, hours, minutes] = dateTimeRegex.exec(dateString) || [];
24 | date = new Date(parseInt(year), parseInt(month) - 1, parseInt(day), parseInt(hours), parseInt(minutes));
25 | } else if (dateRegex.test(dateString)) {
26 | const [_, day, month, year] = dateRegex.exec(dateString) || [];
27 | date = new Date(parseInt(year), parseInt(month) - 1, parseInt(day));
28 | } else {
29 | // If not in Brazilian format, try parsing as ISO
30 | date = new Date(dateString);
31 | }
32 |
33 | if (isNaN(date.getTime())) {
34 | throw new Error(`Invalid date format: ${dateString}. Use DD/MM/YYYY or DD/MM/YYYY HH:MM`);
35 | }
36 |
37 | return date;
38 | }
39 |
40 | function formatToISOString(date: Date): string {
41 | return date.toISOString();
42 | }
43 |
44 | function formatToBrazilianDate(isoString: string): string {
45 | const date = new Date(isoString);
46 | return `${date.getDate().toString().padStart(2, '0')}/${(date.getMonth() + 1).toString().padStart(2, '0')}/${date.getFullYear()}`;
47 | }
48 |
49 | function formatToBrazilianDateTime(isoString: string): string {
50 | const date = new Date(isoString);
51 | return `${date.getDate().toString().padStart(2, '0')}/${(date.getMonth() + 1).toString().padStart(2, '0')}/${date.getFullYear()} ${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
52 | }
53 |
54 | interface CalendarListEntry {
55 | id?: string | null;
56 | summary?: string | null;
57 | }
58 |
59 | interface CalendarEvent {
60 | id?: string | null;
61 | summary?: string | null;
62 | start?: { dateTime?: string | null; date?: string | null; };
63 | end?: { dateTime?: string | null; date?: string | null; };
64 | location?: string | null;
65 | attendees?: CalendarEventAttendee[] | null;
66 | }
67 |
68 | interface CalendarEventAttendee {
69 | email?: string | null;
70 | responseStatus?: string | null;
71 | }
72 |
73 | // Define Zod schemas for validation
74 | const ListEventsArgumentsSchema = z.object({
75 | calendarId: z.string(),
76 | timeMin: z.string().optional(),
77 | timeMax: z.string().optional(),
78 | });
79 |
80 | const CreateEventArgumentsSchema = z.object({
81 | calendarId: z.string(),
82 | summary: z.string(),
83 | description: z.string().optional(),
84 | start: z.string(),
85 | end: z.string(),
86 | attendees: z.array(z.object({
87 | email: z.string()
88 | })).optional(),
89 | location: z.string().optional(),
90 | });
91 |
92 | const UpdateEventArgumentsSchema = z.object({
93 | calendarId: z.string(),
94 | eventId: z.string(),
95 | summary: z.string().optional(),
96 | description: z.string().optional(),
97 | start: z.string().optional(),
98 | end: z.string().optional(),
99 | attendees: z.array(z.object({
100 | email: z.string()
101 | })).optional(),
102 | location: z.string().optional(),
103 | });
104 |
105 | const DeleteEventArgumentsSchema = z.object({
106 | calendarId: z.string(),
107 | eventId: z.string(),
108 | });
109 |
110 | // Create server instance
111 | const server = new Server(
112 | {
113 | name: "google-calendar",
114 | version: "1.0.0",
115 | },
116 | {
117 | capabilities: {
118 | tools: {},
119 | },
120 | }
121 | );
122 |
123 | // Initialize OAuth2 client
124 | async function initializeOAuth2Client() {
125 | try {
126 | const keysContent = await fs.readFile(getKeysFilePath(), 'utf-8');
127 | const keys = JSON.parse(keysContent);
128 |
129 | const { client_id, client_secret, redirect_uris } = keys.installed;
130 |
131 | return new OAuth2Client({
132 | clientId: client_id,
133 | clientSecret: client_secret,
134 | redirectUri: redirect_uris[0]
135 | });
136 | } catch (error) {
137 | console.error("Error loading OAuth keys:", error);
138 | throw error;
139 | }
140 | }
141 |
142 | let oauth2Client: OAuth2Client;
143 | let tokenManager: TokenManager;
144 | let authServer: AuthServer;
145 |
146 | // Helper function to get secure token path
147 | function getSecureTokenPath(): string {
148 | const url = new URL(import.meta.url);
149 | const pathname = url.protocol === 'file:' ?
150 | // Remove leading slash on Windows paths
151 | url.pathname.replace(/^\/([A-Z]:)/, '$1') :
152 | url.pathname;
153 |
154 | return path.join(
155 | path.dirname(pathname),
156 | '../gcp-saved-tokens.json'
157 | );
158 | }
159 |
160 | // Helper function to load and refresh tokens
161 | async function loadSavedTokens(): Promise<boolean> {
162 | try {
163 | const tokenPath = getSecureTokenPath();
164 |
165 | if (!await fs.access(tokenPath).then(() => true).catch(() => false)) {
166 | console.error('No token file found');
167 | return false;
168 | }
169 |
170 | const tokens = JSON.parse(await fs.readFile(tokenPath, 'utf-8'));
171 |
172 | if (!tokens || typeof tokens !== 'object') {
173 | console.error('Invalid token format');
174 | return false;
175 | }
176 |
177 | oauth2Client.setCredentials(tokens);
178 |
179 | const expiryDate = tokens.expiry_date;
180 | const isExpired = expiryDate ? Date.now() >= (expiryDate - 5 * 60 * 1000) : true;
181 |
182 | if (isExpired && tokens.refresh_token) {
183 | try {
184 | const response = await oauth2Client.refreshAccessToken();
185 | const newTokens = response.credentials;
186 |
187 | if (!newTokens.access_token) {
188 | throw new Error('Received invalid tokens during refresh');
189 | }
190 |
191 | await fs.writeFile(tokenPath, JSON.stringify(newTokens, null, 2), { mode: 0o600 });
192 | oauth2Client.setCredentials(newTokens);
193 | } catch (refreshError) {
194 | console.error('Error refreshing auth token:', refreshError);
195 | return false;
196 | }
197 | }
198 |
199 | oauth2Client.on('tokens', async (newTokens) => {
200 | try {
201 | const currentTokens = JSON.parse(await fs.readFile(tokenPath, 'utf-8'));
202 | const updatedTokens = {
203 | ...currentTokens,
204 | ...newTokens,
205 | refresh_token: newTokens.refresh_token || currentTokens.refresh_token
206 | };
207 | await fs.writeFile(tokenPath, JSON.stringify(updatedTokens, null, 2), { mode: 0o600 });
208 | } catch (error) {
209 | console.error('Error saving updated tokens:', error);
210 | }
211 | });
212 |
213 | return true;
214 | } catch (error) {
215 | console.error('Error loading tokens:', error);
216 | return false;
217 | }
218 | }
219 |
220 | // List available tools
221 | server.setRequestHandler(ListToolsRequestSchema, async () => {
222 | return {
223 | tools: [
224 | {
225 | name: "list-calendars",
226 | description: "List all available calendars",
227 | inputSchema: {
228 | type: "object",
229 | properties: {},
230 | required: [],
231 | },
232 | },
233 | {
234 | name: "list-events",
235 | description: "List events from a calendar",
236 | inputSchema: {
237 | type: "object",
238 | properties: {
239 | calendarId: {
240 | type: "string",
241 | description: "ID of the calendar to list events from",
242 | },
243 | timeMin: {
244 | type: "string",
245 | description: "Start time in ISO format or Brazilian format (DD/MM/YYYY or DD/MM/YYYY HH:MM) (optional)",
246 | },
247 | timeMax: {
248 | type: "string",
249 | description: "End time in ISO format or Brazilian format (DD/MM/YYYY or DD/MM/YYYY HH:MM) (optional)",
250 | },
251 | },
252 | required: ["calendarId"],
253 | },
254 | },
255 | {
256 | name: "create-event",
257 | description: "Create a new calendar event",
258 | inputSchema: {
259 | type: "object",
260 | properties: {
261 | calendarId: {
262 | type: "string",
263 | description: "ID of the calendar to create event in",
264 | },
265 | summary: {
266 | type: "string",
267 | description: "Title of the event",
268 | },
269 | description: {
270 | type: "string",
271 | description: "Description of the event",
272 | },
273 | start: {
274 | type: "string",
275 | description: "Start time in ISO format or Brazilian format (DD/MM/YYYY or DD/MM/YYYY HH:MM). For all-day events, use DD/MM/YYYY format.",
276 | },
277 | end: {
278 | type: "string",
279 | description: "End time in ISO format or Brazilian format (DD/MM/YYYY or DD/MM/YYYY HH:MM). For all-day events, use DD/MM/YYYY format.",
280 | },
281 | location: {
282 | type: "string",
283 | description: "Location of the event",
284 | },
285 | attendees: {
286 | type: "array",
287 | description: "List of attendees",
288 | items: {
289 | type: "object",
290 | properties: {
291 | email: {
292 | type: "string",
293 | description: "Email address of the attendee"
294 | }
295 | },
296 | required: ["email"]
297 | }
298 | }
299 | },
300 | required: ["calendarId", "summary", "start", "end"],
301 | },
302 | },
303 | {
304 | name: "update-event",
305 | description: "Update an existing calendar event",
306 | inputSchema: {
307 | type: "object",
308 | properties: {
309 | calendarId: {
310 | type: "string",
311 | description: "ID of the calendar containing the event",
312 | },
313 | eventId: {
314 | type: "string",
315 | description: "ID of the event to update",
316 | },
317 | summary: {
318 | type: "string",
319 | description: "New title of the event",
320 | },
321 | description: {
322 | type: "string",
323 | description: "New description of the event",
324 | },
325 | start: {
326 | type: "string",
327 | description: "New start time in ISO format or Brazilian format (DD/MM/YYYY or DD/MM/YYYY HH:MM). For all-day events, use DD/MM/YYYY format.",
328 | },
329 | end: {
330 | type: "string",
331 | description: "New end time in ISO format or Brazilian format (DD/MM/YYYY or DD/MM/YYYY HH:MM). For all-day events, use DD/MM/YYYY format.",
332 | },
333 | location: {
334 | type: "string",
335 | description: "New location of the event",
336 | },
337 | attendees: {
338 | type: "array",
339 | description: "List of attendees",
340 | items: {
341 | type: "object",
342 | properties: {
343 | email: {
344 | type: "string",
345 | description: "Email address of the attendee"
346 | }
347 | },
348 | required: ["email"]
349 | }
350 | }
351 | },
352 | required: ["calendarId", "eventId"],
353 | },
354 | },
355 | {
356 | name: "delete-event",
357 | description: "Delete a calendar event",
358 | inputSchema: {
359 | type: "object",
360 | properties: {
361 | calendarId: {
362 | type: "string",
363 | description: "ID of the calendar containing the event",
364 | },
365 | eventId: {
366 | type: "string",
367 | description: "ID of the event to delete",
368 | },
369 | },
370 | required: ["calendarId", "eventId"],
371 | },
372 | },
373 | ],
374 | };
375 | });
376 |
377 | server.setRequestHandler(CallToolRequestSchema, async (request) => {
378 | const { name, arguments: args } = request.params;
379 |
380 | // Check authentication before processing any request
381 | if (!await tokenManager.validateTokens()) {
382 | const port = authServer ? 3000 : null;
383 | const authMessage = port
384 | ? `Authentication required. Please visit http://localhost:${port} to authenticate with Google Calendar. If this port is unavailable, the server will try ports 3001-3004.`
385 | : 'Authentication required. Please run "npm run auth" to authenticate with Google Calendar.';
386 | throw new Error(authMessage);
387 | }
388 |
389 | const calendar = google.calendar({ version: 'v3', auth: oauth2Client });
390 |
391 | try {
392 | switch (name) {
393 | case "list-calendars": {
394 | const response = await calendar.calendarList.list();
395 | const calendars = response.data.items || [];
396 | return {
397 | content: [{
398 | type: "text",
399 | text: calendars.map((cal: CalendarListEntry) =>
400 | `${cal.summary || 'Untitled'} (${cal.id || 'no-id'})`).join('\n')
401 | }]
402 | };
403 | }
404 |
405 | case "list-events": {
406 | const validArgs = ListEventsArgumentsSchema.parse(args);
407 |
408 | // Convert Brazilian date format to ISO if provided
409 | let timeMin = validArgs.timeMin;
410 | let timeMax = validArgs.timeMax;
411 |
412 | if (timeMin) {
413 | try {
414 | timeMin = formatToISOString(parseBrazilianDate(timeMin));
415 | } catch (e) {
416 | // If parsing fails, assume it's already in ISO format
417 | }
418 | }
419 |
420 | if (timeMax) {
421 | try {
422 | timeMax = formatToISOString(parseBrazilianDate(timeMax));
423 | } catch (e) {
424 | // If parsing fails, assume it's already in ISO format
425 | }
426 | }
427 |
428 | const response = await calendar.events.list({
429 | calendarId: validArgs.calendarId,
430 | timeMin: timeMin,
431 | timeMax: timeMax,
432 | singleEvents: true,
433 | orderBy: 'startTime',
434 | });
435 |
436 | const events = response.data.items || [];
437 | return {
438 | content: [{
439 | type: "text",
440 | text: events.map((event: CalendarEvent) => {
441 | const attendeeList = event.attendees
442 | ? `\nAttendees: ${event.attendees.map((a: CalendarEventAttendee) =>
443 | `${a.email || 'no-email'} (${a.responseStatus || 'unknown'})`).join(', ')}`
444 | : '';
445 | const locationInfo = event.location ? `\nLocation: ${event.location}` : '';
446 |
447 | // Format dates in Brazilian format
448 | let startDate = event.start?.dateTime || event.start?.date || 'unspecified';
449 | let endDate = event.end?.dateTime || event.end?.date || 'unspecified';
450 |
451 | if (startDate !== 'unspecified') {
452 | startDate = event.start?.dateTime ?
453 | formatToBrazilianDateTime(startDate) :
454 | formatToBrazilianDate(startDate);
455 | }
456 |
457 | if (endDate !== 'unspecified') {
458 | endDate = event.end?.dateTime ?
459 | formatToBrazilianDateTime(endDate) :
460 | formatToBrazilianDate(endDate);
461 | }
462 |
463 | return `${event.summary || 'Untitled'} (${event.id || 'no-id'})${locationInfo}\nInício: ${startDate}\nFim: ${endDate}${attendeeList}\n`;
464 | }).join('\n')
465 | }]
466 | };
467 | }
468 |
469 | case "create-event": {
470 | const validArgs = CreateEventArgumentsSchema.parse(args);
471 |
472 | // Convert Brazilian date format to ISO
473 | let startDateTime = validArgs.start;
474 | let endDateTime = validArgs.end;
475 | let isAllDay = false;
476 |
477 | // Check if this is an all-day event (just date without time)
478 | const dateOnlyRegex = /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/;
479 | if (dateOnlyRegex.test(startDateTime) && dateOnlyRegex.test(endDateTime)) {
480 | isAllDay = true;
481 |
482 | // Parse start date
483 | const [_, startDay, startMonth, startYear] = dateOnlyRegex.exec(startDateTime) || [];
484 | const startDate = new Date(parseInt(startYear), parseInt(startMonth) - 1, parseInt(startDay));
485 |
486 | // Parse end date and add 1 day (Google Calendar requires end date to be exclusive)
487 | const [__, endDay, endMonth, endYear] = dateOnlyRegex.exec(endDateTime) || [];
488 | const endDate = new Date(parseInt(endYear), parseInt(endMonth) - 1, parseInt(endDay));
489 | endDate.setDate(endDate.getDate() + 1);
490 |
491 | // Format as YYYY-MM-DD for Google Calendar API
492 | startDateTime = `${startDate.getFullYear()}-${(startDate.getMonth() + 1).toString().padStart(2, '0')}-${startDate.getDate().toString().padStart(2, '0')}`;
493 | endDateTime = `${endDate.getFullYear()}-${(endDate.getMonth() + 1).toString().padStart(2, '0')}-${endDate.getDate().toString().padStart(2, '0')}`;
494 | } else {
495 | // Not an all-day event, convert to ISO format
496 | try {
497 | startDateTime = formatToISOString(parseBrazilianDate(startDateTime));
498 | } catch (e) {
499 | // If parsing fails, assume it's already in ISO format
500 | }
501 |
502 | try {
503 | endDateTime = formatToISOString(parseBrazilianDate(endDateTime));
504 | } catch (e) {
505 | // If parsing fails, assume it's already in ISO format
506 | }
507 | }
508 |
509 | const event = await calendar.events.insert({
510 | calendarId: validArgs.calendarId,
511 | requestBody: {
512 | summary: validArgs.summary,
513 | description: validArgs.description,
514 | start: isAllDay ? { date: startDateTime } : { dateTime: startDateTime },
515 | end: isAllDay ? { date: endDateTime } : { dateTime: endDateTime },
516 | attendees: validArgs.attendees,
517 | location: validArgs.location,
518 | },
519 | }).then(response => response.data);
520 |
521 | return {
522 | content: [{
523 | type: "text",
524 | text: `Evento criado: ${event.summary} (${event.id})`
525 | }]
526 | };
527 | }
528 |
529 | case "update-event": {
530 | const validArgs = UpdateEventArgumentsSchema.parse(args);
531 |
532 | // Convert Brazilian date format to ISO if provided
533 | let startDateTime = validArgs.start;
534 | let endDateTime = validArgs.end;
535 | let startIsAllDay = false;
536 | let endIsAllDay = false;
537 |
538 | // Check if this is an all-day event update
539 | const dateOnlyRegex = /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/;
540 |
541 | if (startDateTime && dateOnlyRegex.test(startDateTime)) {
542 | startIsAllDay = true;
543 | // Parse start date
544 | const [_, startDay, startMonth, startYear] = dateOnlyRegex.exec(startDateTime) || [];
545 | const startDate = new Date(parseInt(startYear), parseInt(startMonth) - 1, parseInt(startDay));
546 |
547 | // Format as YYYY-MM-DD for Google Calendar API
548 | startDateTime = `${startDate.getFullYear()}-${(startDate.getMonth() + 1).toString().padStart(2, '0')}-${startDate.getDate().toString().padStart(2, '0')}`;
549 | } else if (startDateTime) {
550 | try {
551 | startDateTime = formatToISOString(parseBrazilianDate(startDateTime));
552 | } catch (e) {
553 | // If parsing fails, assume it's already in ISO format
554 | }
555 | }
556 |
557 | if (endDateTime && dateOnlyRegex.test(endDateTime)) {
558 | endIsAllDay = true;
559 | // Parse end date and add 1 day (Google Calendar requires end date to be exclusive)
560 | const [_, endDay, endMonth, endYear] = dateOnlyRegex.exec(endDateTime) || [];
561 | const endDate = new Date(parseInt(endYear), parseInt(endMonth) - 1, parseInt(endDay));
562 | endDate.setDate(endDate.getDate() + 1);
563 |
564 | // Format as YYYY-MM-DD for Google Calendar API
565 | endDateTime = `${endDate.getFullYear()}-${(endDate.getMonth() + 1).toString().padStart(2, '0')}-${endDate.getDate().toString().padStart(2, '0')}`;
566 | } else if (endDateTime) {
567 | try {
568 | endDateTime = formatToISOString(parseBrazilianDate(endDateTime));
569 | } catch (e) {
570 | // If parsing fails, assume it's already in ISO format
571 | }
572 | }
573 |
574 | // Prepare the request body
575 | const requestBody: any = {
576 | summary: validArgs.summary,
577 | description: validArgs.description,
578 | attendees: validArgs.attendees,
579 | location: validArgs.location,
580 | };
581 |
582 | // Add start and end times if provided
583 | if (startDateTime) {
584 | requestBody.start = startIsAllDay ? { date: startDateTime } : { dateTime: startDateTime };
585 | }
586 |
587 | if (endDateTime) {
588 | requestBody.end = endIsAllDay ? { date: endDateTime } : { dateTime: endDateTime };
589 | }
590 |
591 | const event = await calendar.events.patch({
592 | calendarId: validArgs.calendarId,
593 | eventId: validArgs.eventId,
594 | requestBody: requestBody,
595 | }).then(response => response.data);
596 |
597 | return {
598 | content: [{
599 | type: "text",
600 | text: `Evento atualizado: ${event.summary} (${event.id})`
601 | }]
602 | };
603 | }
604 |
605 | case "delete-event": {
606 | const validArgs = DeleteEventArgumentsSchema.parse(args);
607 | await calendar.events.delete({
608 | calendarId: validArgs.calendarId,
609 | eventId: validArgs.eventId,
610 | });
611 |
612 | return {
613 | content: [{
614 | type: "text",
615 | text: `Evento excluído com sucesso`
616 | }]
617 | };
618 | }
619 |
620 | default:
621 | throw new Error(`Unknown tool: ${name}`);
622 | }
623 | } catch (error) {
624 | console.error('Error processing request:', error);
625 | throw error;
626 | }
627 | });
628 |
629 | function getKeysFilePath(): string {
630 | const url = new URL(import.meta.url);
631 | const pathname = url.protocol === 'file:' ?
632 | // Remove leading slash on Windows paths
633 | url.pathname.replace(/^\/([A-Z]:)/, '$1') :
634 | url.pathname;
635 |
636 | const relativePath = path.join(
637 | path.dirname(pathname),
638 | '../gcp-oauth.keys.json'
639 | );
640 | const absolutePath = path.resolve(relativePath);
641 | return absolutePath;
642 | }
643 |
644 | // Start the server
645 | async function main() {
646 | try {
647 | oauth2Client = await initializeOAuth2Client();
648 | tokenManager = new TokenManager(oauth2Client);
649 | authServer = new AuthServer(oauth2Client);
650 |
651 | // Start auth server if needed
652 | if (!await tokenManager.loadSavedTokens()) {
653 | console.log('No valid tokens found, starting auth server...');
654 | const success = await authServer.start();
655 | if (!success) {
656 | console.error('Failed to start auth server');
657 | process.exit(1);
658 | }
659 | }
660 |
661 | const transport = new StdioServerTransport();
662 | await server.connect(transport);
663 | console.error("Google Calendar MCP Server running on stdio");
664 |
665 | // Handle cleanup
666 | process.on('SIGINT', cleanup);
667 | process.on('SIGTERM', cleanup);
668 | } catch (error) {
669 | console.error("Server startup failed:", error);
670 | process.exit(1);
671 | }
672 | }
673 |
674 | async function cleanup() {
675 | console.log('Cleaning up...');
676 | if (authServer) {
677 | await authServer.stop();
678 | }
679 | if (tokenManager) {
680 | tokenManager.clearTokens();
681 | }
682 | process.exit(0);
683 | }
684 |
685 | main().catch((error) => {
686 | console.error("Fatal error:", error);
687 | process.exit(1);
688 | });
```