#
tokens: 58321/50000 1/18 files (page 3/3)
lines: on (toggle) GitHub
raw markdown copy reset
This is page 3 of 3. Use http://codebase.md/a-bonus/google-docs-mcp?lines=true&page={x} to view the full context.

# Directory Structure

```
├── .DS_Store
├── .gitignore
├── .repomix
│   └── bundles.json
├── assets
│   └── google.docs.mcp.1.gif
├── claude.md
├── docs
│   └── index.html
├── google docs mcp.mp4
├── index.js
├── LICENSE
├── package-lock.json
├── package.json
├── pages
│   └── pages.md
├── README.md
├── repomix-output.txt.xml
├── SAMPLE_TASKS.md
├── src
│   ├── auth.ts
│   ├── backup
│   │   ├── auth.ts.bak
│   │   └── server.ts.bak
│   ├── googleDocsApiHelpers.ts
│   ├── server.ts
│   └── types.ts
├── tests
│   ├── helpers.test.js
│   └── types.test.js
├── tsconfig.json
└── vscode.md
```

# Files

--------------------------------------------------------------------------------
/repomix-output.txt.xml:
--------------------------------------------------------------------------------

```
   1 | This file is a merged representation of the entire codebase, combined into a single document by Repomix.
   2 | 
   3 | ================================================================
   4 | File Summary
   5 | ================================================================
   6 | 
   7 | Purpose:
   8 | --------
   9 | This file contains a packed representation of the entire repository's contents.
  10 | It is designed to be easily consumable by AI systems for analysis, code review,
  11 | or other automated processes.
  12 | 
  13 | File Format:
  14 | ------------
  15 | The content is organized as follows:
  16 | 1. This summary section
  17 | 2. Repository information
  18 | 3. Directory structure
  19 | 4. Multiple file entries, each consisting of:
  20 |   a. A separator line (================)
  21 |   b. The file path (File: path/to/file)
  22 |   c. Another separator line
  23 |   d. The full contents of the file
  24 |   e. A blank line
  25 | 
  26 | Usage Guidelines:
  27 | -----------------
  28 | - This file should be treated as read-only. Any changes should be made to the
  29 |   original repository files, not this packed version.
  30 | - When processing this file, use the file path to distinguish
  31 |   between different files in the repository.
  32 | - Be aware that this file may contain sensitive information. Handle it with
  33 |   the same level of security as you would the original repository.
  34 | 
  35 | Notes:
  36 | ------
  37 | - Some files may have been excluded based on .gitignore rules and Repomix's configuration
  38 | - Binary files are not included in this packed representation. Please refer to the Repository Structure section for a complete list of file paths, including binary files
  39 | - Files matching patterns in .gitignore are excluded
  40 | - Files matching default ignore patterns are excluded
  41 | 
  42 | Additional Info:
  43 | ----------------
  44 | 
  45 | ================================================================
  46 | Directory Structure
  47 | ================================================================
  48 | .repomix/
  49 |   bundles.json
  50 | docs/
  51 |   index.html
  52 | src/
  53 |   auth.ts
  54 |   googleDocsApiHelpers.ts
  55 |   server.ts
  56 |   types.ts
  57 | tests/
  58 |   helpers.test.js
  59 |   types.test.js
  60 | .gitignore
  61 | claude.md
  62 | LICENSE
  63 | package.json
  64 | README.md
  65 | SAMPLE_TASKS.md
  66 | tsconfig.json
  67 | vscode.md
  68 | 
  69 | ================================================================
  70 | Files
  71 | ================================================================
  72 | 
  73 | ================
  74 | File: .repomix/bundles.json
  75 | ================
  76 | {
  77 |   "bundles": {}
  78 | }
  79 | 
  80 | ================
  81 | File: docs/index.html
  82 | ================
  83 | <!DOCTYPE html>
  84 | <html lang="en">
  85 | <head>
  86 |     <meta charset="UTF-8">
  87 |     <meta name="viewport" content="width=device-width, initial-scale=1.0">
  88 |     <title>FastMCP Google Docs Server Docs</title>
  89 |     <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
  90 |     <style>
  91 |         body { font-family: sans-serif; line-height: 1.6; padding: 20px; }
  92 |         pre { background-color: #f4f4f4; padding: 10px; border-radius: 5px; overflow-x: auto; }
  93 |         code { font-family: monospace; }
  94 |         h1, h2, h3 { border-bottom: 1px solid #eee; padding-bottom: 5px; margin-top: 20px; }
  95 |     </style>
  96 | </head>
  97 | <body>
  98 |     <div id="content"></div>
  99 | 
 100 |     <script type="text/markdown" id="markdown-content">
 101 | # FastMCP Google Docs Server
 102 | 
 103 | Connect Claude Desktop (or other MCP clients) to your Google Docs!
 104 | 
 105 | This server uses the Model Context Protocol (MCP) and the `fastmcp` library to provide tools for reading and appending text to Google Documents. It acts as a bridge, allowing AI assistants like Claude to interact with your documents programmatically.
 106 | 
 107 | **Features:**
 108 | 
 109 | - **Read Documents:** Provides a `readGoogleDoc` tool to fetch the text content of a specified Google Doc.
 110 | - **Append to Documents:** Provides an `appendToGoogleDoc` tool to add text to the end of a specified Google Doc.
 111 | - **Google Authentication:** Handles the OAuth 2.0 flow to securely authorize access to your Google Account.
 112 | - **MCP Compliant:** Designed for use with MCP clients like Claude Desktop.
 113 | 
 114 | ---
 115 | 
 116 | ## Prerequisites
 117 | 
 118 | Before you start, make sure you have:
 119 | 
 120 | 1.  **Node.js and npm:** A recent version of Node.js (which includes npm) installed on your computer. You can download it from [nodejs.org](https://nodejs.org/). (Version 18 or higher recommended).
 121 | 2.  **Git:** Required for cloning this repository. ([Download Git](https://git-scm.com/downloads)).
 122 | 3.  **A Google Account:** The account that owns or has access to the Google Docs you want to interact with.
 123 | 4.  **Command Line Familiarity:** Basic comfort using a terminal or command prompt (like Terminal on macOS/Linux, or Command Prompt/PowerShell on Windows).
 124 | 5.  **Claude Desktop (Optional):** If your goal is to connect this server to Claude, you'll need the Claude Desktop application installed.
 125 | 
 126 | ---
 127 | 
 128 | ## Setup Instructions
 129 | 
 130 | Follow these steps carefully to get your own instance of the server running.
 131 | 
 132 | ### Step 1: Google Cloud Project & Credentials (The Important Bit!)
 133 | 
 134 | This server needs permission to talk to Google APIs on your behalf. You'll create special "keys" (credentials) that only your server will use.
 135 | 
 136 | 1.  **Go to Google Cloud Console:** Open your web browser and go to the [Google Cloud Console](https://console.cloud.google.com/). You might need to log in with your Google Account.
 137 | 2.  **Create or Select a Project:**
 138 |     - If you don't have a project, click the project dropdown near the top and select "NEW PROJECT". Give it a name (e.g., "My MCP Docs Server") and click "CREATE".
 139 |     - If you have existing projects, you can select one or create a new one.
 140 | 3.  **Enable APIs:** You need to turn on the specific Google services this server uses.
 141 |     - In the search bar at the top, type "APIs & Services" and select "Library".
 142 |     - Search for "**Google Docs API**" and click on it. Then click the "**ENABLE**" button.
 143 |     - Search for "**Google Drive API**" and click on it. Then click the "**ENABLE**" button (this is often needed for finding files or permissions).
 144 | 4.  **Configure OAuth Consent Screen:** This screen tells users (usually just you) what your app wants permission for.
 145 |     - On the left menu, click "APIs & Services" -> "**OAuth consent screen**".
 146 |     - Choose User Type: Select "**External**" and click "CREATE".
 147 |     - Fill in App Information:
 148 |       - **App name:** Give it a name users will see (e.g., "Claude Docs MCP Access").
 149 |       - **User support email:** Select your email address.
 150 |       - **Developer contact information:** Enter your email address.
 151 |     - Click "**SAVE AND CONTINUE**".
 152 |     - **Scopes:** Click "**ADD OR REMOVE SCOPES**". Search for and add the following scopes:
 153 |       - `https://www.googleapis.com/auth/documents` (Allows reading/writing docs)
 154 |       - `https://www.googleapis.com/auth/drive.file` (Allows access to specific files opened/created by the app)
 155 |       - Click "**UPDATE**".
 156 |     - Click "**SAVE AND CONTINUE**".
 157 |     - **Test Users:** Click "**ADD USERS**". Enter the same Google email address you are logged in with. Click "**ADD**". This allows _you_ to use the app while it's in "testing" mode.
 158 |     - Click "**SAVE AND CONTINUE**". Review the summary and click "**BACK TO DASHBOARD**".
 159 | 5.  **Create Credentials (The Keys!):**
 160 |     - On the left menu, click "APIs & Services" -> "**Credentials**".
 161 |     - Click "**+ CREATE CREDENTIALS**" at the top and choose "**OAuth client ID**".
 162 |     - **Application type:** Select "**Desktop app**" from the dropdown.
 163 |     - **Name:** Give it a name (e.g., "MCP Docs Desktop Client").
 164 |     - Click "**CREATE**".
 165 | 6.  **⬇️ DOWNLOAD THE CREDENTIALS FILE:** A box will pop up showing your Client ID. Click the "**DOWNLOAD JSON**" button.
 166 |     - Save this file. It will likely be named something like `client_secret_....json`.
 167 |     - **IMPORTANT:** Rename the downloaded file to exactly `credentials.json`.
 168 | 7.  ⚠️ **SECURITY WARNING:** Treat this `credentials.json` file like a password! Do not share it publicly, and **never commit it to GitHub.** Anyone with this file could potentially pretend to be _your application_ (though they'd still need user consent to access data).
 169 | 
 170 | ### Step 2: Get the Server Code
 171 | 
 172 | 1.  **Clone the Repository:** Open your terminal/command prompt and run:
 173 |     ```bash
 174 |     git clone https://github.com/a-bonus/google-docs-mcp.git mcp-googledocs-server
 175 |     ```
 176 | 2.  **Navigate into Directory:**
 177 |     ```bash
 178 |     cd mcp-googledocs-server
 179 |     ```
 180 | 3.  **Place Credentials:** Move or copy the `credentials.json` file you downloaded and renamed (from Step 1.6) directly into this `mcp-googledocs-server` folder.
 181 | 
 182 | ### Step 3: Install Dependencies
 183 | 
 184 | Your server needs some helper libraries specified in the `package.json` file.
 185 | 
 186 | 1.  In your terminal (make sure you are inside the `mcp-googledocs-server` directory), run:
 187 |     ```bash
 188 |     npm install
 189 |     ```
 190 |     This will download and install all the necessary packages into a `node_modules` folder.
 191 | 
 192 | ### Step 4: Build the Server Code
 193 | 
 194 | The server is written in TypeScript (`.ts`), but we need to compile it into JavaScript (`.js`) that Node.js can run directly.
 195 | 
 196 | 1.  In your terminal, run:
 197 |     ```bash
 198 |     npm run build
 199 |     ```
 200 |     This uses the TypeScript compiler (`tsc`) to create a `dist` folder containing the compiled JavaScript files.
 201 | 
 202 | ### Step 5: First Run & Google Authorization (One Time Only)
 203 | 
 204 | Now you need to run the server once manually to grant it permission to access your Google account data. This will create a `token.json` file that saves your permission grant.
 205 | 
 206 | 1.  In your terminal, run the _compiled_ server using `node`:
 207 |     ```bash
 208 |     node ./dist/server.js
 209 |     ```
 210 | 2.  **Watch the Terminal:** The script will print:
 211 |     - Status messages (like "Attempting to authorize...").
 212 |     - An "Authorize this app by visiting this url:" message followed by a long `https://accounts.google.com/...` URL.
 213 | 3.  **Authorize in Browser:**
 214 |     - Copy the entire long URL from the terminal.
 215 |     - Paste the URL into your web browser and press Enter.
 216 |     - Log in with the **same Google account** you added as a Test User in Step 1.4.
 217 |     - Google will show a screen asking for permission for your app ("Claude Docs MCP Access" or similar) to access Google Docs/Drive. Review and click "**Allow**" or "**Grant**".
 218 | 4.  **Get the Authorization Code:**
 219 |     - After clicking Allow, your browser will likely try to redirect to `http://localhost` and show a **"This site can't be reached" error**. **THIS IS NORMAL!**
 220 |     - Look **carefully** at the URL in your browser's address bar. It will look like `http://localhost/?code=4/0Axxxxxxxxxxxxxx&scope=...`
 221 |     - Copy the long string of characters **between `code=` and the `&scope` part**. This is your single-use authorization code.
 222 | 5.  **Paste Code into Terminal:** Go back to your terminal where the script is waiting ("Enter the code from that page here:"). Paste the code you just copied.
 223 | 6.  **Press Enter.**
 224 | 7.  **Success!** The script should print:
 225 |     - "Authentication successful!"
 226 |     - "Token stored to .../token.json"
 227 |     - It will then finish starting and likely print "Awaiting MCP client connection via stdio..." or similar, and then exit (or you can press `Ctrl+C` to stop it).
 228 | 8.  ✅ **Check:** You should now see a new file named `token.json` in your `mcp-googledocs-server` folder.
 229 | 9.  ⚠️ **SECURITY WARNING:** This `token.json` file contains the key that allows the server to access your Google account _without_ asking again. Protect it like a password. **Do not commit it to GitHub.** The included `.gitignore` file should prevent this automatically.
 230 | 
 231 | ### Step 6: Configure Claude Desktop (Optional)
 232 | 
 233 | If you want to use this server with Claude Desktop, you need to tell Claude how to run it.
 234 | 
 235 | 1.  **Find Your Absolute Path:** You need the full path to the server code.
 236 |     - In your terminal, make sure you are still inside the `mcp-googledocs-server` directory.
 237 |     - Run the `pwd` command (on macOS/Linux) or `cd` (on Windows, just displays the path).
 238 |     - Copy the full path (e.g., `/Users/yourname/projects/mcp-googledocs-server` or `C:\Users\yourname\projects\mcp-googledocs-server`).
 239 | 2.  **Locate `mcp_config.json`:** Find Claude's configuration file:
 240 |     - **macOS:** `~/Library/Application Support/Claude/mcp_config.json` (You might need to use Finder's "Go" -> "Go to Folder..." menu and paste `~/Library/Application Support/Claude/`)
 241 |     - **Windows:** `%APPDATA%\Claude\mcp_config.json` (Paste `%APPDATA%\Claude` into File Explorer's address bar)
 242 |     - **Linux:** `~/.config/Claude/mcp_config.json`
 243 |     - _If the `Claude` folder or `mcp_config.json` file doesn't exist, create them._
 244 | 3.  **Edit `mcp_config.json`:** Open the file in a text editor. Add or modify the `mcpServers` section like this, **replacing `/PATH/TO/YOUR/CLONED/REPO` with the actual absolute path you copied in Step 6.1**:
 245 | 
 246 |     ```json
 247 |     {
 248 |       "mcpServers": {
 249 |         "google-docs-mcp": {
 250 |           "command": "node",
 251 |           "args": [
 252 |             "/PATH/TO/YOUR/CLONED/REPO/mcp-googledocs-server/dist/server.js"
 253 |           ],
 254 |           "env": {}
 255 |         }
 256 |         // Add commas here if you have other servers defined
 257 |       }
 258 |       // Other Claude settings might be here
 259 |     }
 260 |     ```
 261 | 
 262 |     - **Make sure the path in `"args"` is correct and absolute!**
 263 |     - If the file already existed, carefully merge this entry into the existing `mcpServers` object. Ensure the JSON is valid (check commas!).
 264 | 
 265 | 4.  **Save `mcp_config.json`.**
 266 | 5.  **Restart Claude Desktop:** Close Claude completely and reopen it.
 267 | 
 268 | ---
 269 | 
 270 | ## Usage with Claude Desktop
 271 | 
 272 | Once configured, you should be able to use the tools in your chats with Claude:
 273 | 
 274 | - "Use the `google-docs-mcp` server to read the document with ID `YOUR_GOOGLE_DOC_ID`."
 275 | - "Can you get the content of Google Doc `YOUR_GOOGLE_DOC_ID`?"
 276 | - "Append 'This was added by Claude!' to document `YOUR_GOOGLE_DOC_ID` using the `google-docs-mcp` tool."
 277 | 
 278 | Remember to replace `YOUR_GOOGLE_DOC_ID` with the actual ID from a Google Doc's URL (the long string between `/d/` and `/edit`).
 279 | 
 280 | Claude will automatically launch your server in the background when needed using the command you provided. You do **not** need to run `node ./dist/server.js` manually anymore.
 281 | 
 282 | ---
 283 | 
 284 | ## Security & Token Storage
 285 | 
 286 | - **`.gitignore`:** This repository includes a `.gitignore` file which should prevent you from accidentally committing your sensitive `credentials.json` and `token.json` files. **Do not remove these lines from `.gitignore`**.
 287 | - **Token Storage:** This server stores the Google authorization token (`token.json`) directly in the project folder for simplicity during setup. In production or more security-sensitive environments, consider storing this token more securely, such as using system keychains, encrypted files, or dedicated secret management services.
 288 | 
 289 | ---
 290 | 
 291 | ## Troubleshooting
 292 | 
 293 | - **Claude shows "Failed" or "Could not attach":**
 294 |   - Double-check the absolute path in `mcp_config.json`.
 295 |   - Ensure you ran `npm run build` successfully and the `dist` folder exists.
 296 |   - Try running the command from `mcp_config.json` manually in your terminal: `node /PATH/TO/YOUR/CLONED/REPO/mcp-googledocs-server/dist/server.js`. Look for any errors printed.
 297 |   - Check the Claude Desktop logs (see the official MCP debugging guide).
 298 |   - Make sure all `console.log` status messages in the server code were changed to `console.error`.
 299 | - **Google Authorization Errors:**
 300 |   - Ensure you enabled the correct APIs (Docs, Drive).
 301 |   - Make sure you added your email as a Test User on the OAuth Consent Screen.
 302 |   - Verify the `credentials.json` file is correctly placed in the project root.
 303 | 
 304 | ---
 305 | 
 306 | ## License
 307 | 
 308 | This project is licensed under the MIT License - see the `LICENSE` file for details. (Note: You should add a `LICENSE` file containing the MIT License text to your repository).
 309 | 
 310 | ---
 311 | 
 312 | ================
 313 | File: src/auth.ts
 314 | ================
 315 | // src/auth.ts
 316 | import { google } from 'googleapis';
 317 | import { OAuth2Client } from 'google-auth-library';
 318 | import * as fs from 'fs/promises';
 319 | import * as path from 'path';
 320 | import * as readline from 'readline/promises';
 321 | import { fileURLToPath } from 'url';
 322 | 
 323 | // --- Calculate paths relative to this script file (ESM way) ---
 324 | const __filename = fileURLToPath(import.meta.url);
 325 | const __dirname = path.dirname(__filename);
 326 | const projectRootDir = path.resolve(__dirname, '..');
 327 | 
 328 | const TOKEN_PATH = path.join(projectRootDir, 'token.json');
 329 | const CREDENTIALS_PATH = path.join(projectRootDir, 'credentials.json');
 330 | // --- End of path calculation ---
 331 | 
 332 | const SCOPES = [
 333 |   'https://www.googleapis.com/auth/documents',
 334 |   'https://www.googleapis.com/auth/drive' // Full Drive access for listing, searching, and document discovery
 335 | ];
 336 | 
 337 | async function loadSavedCredentialsIfExist(): Promise<OAuth2Client | null> {
 338 |   try {
 339 |     const content = await fs.readFile(TOKEN_PATH);
 340 |     const credentials = JSON.parse(content.toString());
 341 |     const { client_secret, client_id, redirect_uris } = await loadClientSecrets();
 342 |     const client = new google.auth.OAuth2(client_id, client_secret, redirect_uris?.[0]);
 343 |     client.setCredentials(credentials);
 344 |     return client;
 345 |   } catch (err) {
 346 |     return null;
 347 |   }
 348 | }
 349 | 
 350 | async function loadClientSecrets() {
 351 |   const content = await fs.readFile(CREDENTIALS_PATH);
 352 |   const keys = JSON.parse(content.toString());
 353 |   const key = keys.installed || keys.web;
 354 |    if (!key) throw new Error("Could not find client secrets in credentials.json.");
 355 |   return {
 356 |       client_id: key.client_id,
 357 |       client_secret: key.client_secret,
 358 |       redirect_uris: key.redirect_uris
 359 |   };
 360 | }
 361 | 
 362 | async function saveCredentials(client: OAuth2Client): Promise<void> {
 363 |   const { client_secret, client_id } = await loadClientSecrets();
 364 |   const payload = JSON.stringify({
 365 |     type: 'authorized_user',
 366 |     client_id: client_id,
 367 |     client_secret: client_secret,
 368 |     refresh_token: client.credentials.refresh_token,
 369 |   });
 370 |   await fs.writeFile(TOKEN_PATH, payload);
 371 |   console.error('Token stored to', TOKEN_PATH);
 372 | }
 373 | 
 374 | async function authenticate(): Promise<OAuth2Client> {
 375 |   const { client_secret, client_id, redirect_uris } = await loadClientSecrets();
 376 |   const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uris?.[0]);
 377 | 
 378 |   const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
 379 | 
 380 |   const authorizeUrl = oAuth2Client.generateAuthUrl({
 381 |     access_type: 'offline',
 382 |     scope: SCOPES.join(' '),
 383 |   });
 384 | 
 385 |   console.error('Authorize this app by visiting this url:', authorizeUrl);
 386 |   const code = await rl.question('Enter the code from that page here: ');
 387 |   rl.close();
 388 | 
 389 |   try {
 390 |     const { tokens } = await oAuth2Client.getToken(code);
 391 |     oAuth2Client.setCredentials(tokens);
 392 |     if (tokens.refresh_token) { // Save only if we got a refresh token
 393 |          await saveCredentials(oAuth2Client);
 394 |     } else {
 395 |          console.error("Did not receive refresh token. Token might expire.");
 396 |     }
 397 |     console.error('Authentication successful!');
 398 |     return oAuth2Client;
 399 |   } catch (err) {
 400 |     console.error('Error retrieving access token', err);
 401 |     throw new Error('Authentication failed');
 402 |   }
 403 | }
 404 | 
 405 | export async function authorize(): Promise<OAuth2Client> {
 406 |   let client = await loadSavedCredentialsIfExist();
 407 |   if (client) {
 408 |     // Optional: Add token refresh logic here if needed, though library often handles it.
 409 |     console.error('Using saved credentials.');
 410 |     return client;
 411 |   }
 412 |   console.error('Starting authentication flow...');
 413 |   client = await authenticate();
 414 |   return client;
 415 | }
 416 | 
 417 | ================
 418 | File: src/googleDocsApiHelpers.ts
 419 | ================
 420 | // src/googleDocsApiHelpers.ts
 421 | import { google, docs_v1 } from 'googleapis';
 422 | import { OAuth2Client } from 'google-auth-library';
 423 | import { UserError } from 'fastmcp';
 424 | import { TextStyleArgs, ParagraphStyleArgs, hexToRgbColor, NotImplementedError } from './types.js';
 425 | 
 426 | type Docs = docs_v1.Docs; // Alias for convenience
 427 | 
 428 | // --- Constants ---
 429 | const MAX_BATCH_UPDATE_REQUESTS = 50; // Google API limits batch size
 430 | 
 431 | // --- Core Helper to Execute Batch Updates ---
 432 | export async function executeBatchUpdate(docs: Docs, documentId: string, requests: docs_v1.Schema$Request[]): Promise<docs_v1.Schema$BatchUpdateDocumentResponse> {
 433 | if (!requests || requests.length === 0) {
 434 | // console.warn("executeBatchUpdate called with no requests.");
 435 | return {}; // Nothing to do
 436 | }
 437 | 
 438 |     // TODO: Consider splitting large request arrays into multiple batches if needed
 439 |     if (requests.length > MAX_BATCH_UPDATE_REQUESTS) {
 440 |          console.warn(`Attempting batch update with ${requests.length} requests, exceeding typical limits. May fail.`);
 441 |     }
 442 | 
 443 |     try {
 444 |         const response = await docs.documents.batchUpdate({
 445 |             documentId: documentId,
 446 |             requestBody: { requests },
 447 |         });
 448 |         return response.data;
 449 |     } catch (error: any) {
 450 |         console.error(`Google API batchUpdate Error for doc ${documentId}:`, error.response?.data || error.message);
 451 |         // Translate common API errors to UserErrors
 452 |         if (error.code === 400 && error.message.includes('Invalid requests')) {
 453 |              // Try to extract more specific info if available
 454 |              const details = error.response?.data?.error?.details;
 455 |              let detailMsg = '';
 456 |              if (details && Array.isArray(details)) {
 457 |                  detailMsg = details.map(d => d.description || JSON.stringify(d)).join('; ');
 458 |              }
 459 |             throw new UserError(`Invalid request sent to Google Docs API. Details: ${detailMsg || error.message}`);
 460 |         }
 461 |         if (error.code === 404) throw new UserError(`Document not found (ID: ${documentId}). Check the ID.`);
 462 |         if (error.code === 403) throw new UserError(`Permission denied for document (ID: ${documentId}). Ensure the authenticated user has edit access.`);
 463 |         // Generic internal error for others
 464 |         throw new Error(`Google API Error (${error.code}): ${error.message}`);
 465 |     }
 466 | 
 467 | }
 468 | 
 469 | // --- Text Finding Helper ---
 470 | // This improved version is more robust in handling various text structure scenarios
 471 | export async function findTextRange(docs: Docs, documentId: string, textToFind: string, instance: number = 1): Promise<{ startIndex: number; endIndex: number } | null> {
 472 | try {
 473 |     // Request more detailed information about the document structure
 474 |     const res = await docs.documents.get({
 475 |         documentId,
 476 |         // Request more fields to handle various container types (not just paragraphs)
 477 |         fields: 'body(content(paragraph(elements(startIndex,endIndex,textRun(content))),table,sectionBreak,tableOfContents,startIndex,endIndex))',
 478 |     });
 479 | 
 480 |     if (!res.data.body?.content) {
 481 |         console.warn(`No content found in document ${documentId}`);
 482 |         return null;
 483 |     }
 484 | 
 485 |     // More robust text collection and index tracking
 486 |     let fullText = '';
 487 |     const segments: { text: string, start: number, end: number }[] = [];
 488 |     
 489 |     // Process all content elements, including structural ones
 490 |     const collectTextFromContent = (content: any[]) => {
 491 |         content.forEach(element => {
 492 |             // Handle paragraph elements
 493 |             if (element.paragraph?.elements) {
 494 |                 element.paragraph.elements.forEach((pe: any) => {
 495 |                     if (pe.textRun?.content && pe.startIndex !== undefined && pe.endIndex !== undefined) {
 496 |                         const content = pe.textRun.content;
 497 |                         fullText += content;
 498 |                         segments.push({ 
 499 |                             text: content, 
 500 |                             start: pe.startIndex, 
 501 |                             end: pe.endIndex 
 502 |                         });
 503 |                     }
 504 |                 });
 505 |             }
 506 |             
 507 |             // Handle table elements - this is simplified and might need expansion
 508 |             if (element.table && element.table.tableRows) {
 509 |                 element.table.tableRows.forEach((row: any) => {
 510 |                     if (row.tableCells) {
 511 |                         row.tableCells.forEach((cell: any) => {
 512 |                             if (cell.content) {
 513 |                                 collectTextFromContent(cell.content);
 514 |                             }
 515 |                         });
 516 |                     }
 517 |                 });
 518 |             }
 519 |             
 520 |             // Add handling for other structural elements as needed
 521 |         });
 522 |     };
 523 |     
 524 |     collectTextFromContent(res.data.body.content);
 525 |     
 526 |     // Sort segments by starting position to ensure correct ordering
 527 |     segments.sort((a, b) => a.start - b.start);
 528 |     
 529 |     console.log(`Document ${documentId} contains ${segments.length} text segments and ${fullText.length} characters in total.`);
 530 |     
 531 |     // Find the specified instance of the text
 532 |     let startIndex = -1;
 533 |     let endIndex = -1;
 534 |     let foundCount = 0;
 535 |     let searchStartIndex = 0;
 536 | 
 537 |     while (foundCount < instance) {
 538 |         const currentIndex = fullText.indexOf(textToFind, searchStartIndex);
 539 |         if (currentIndex === -1) {
 540 |             console.log(`Search text "${textToFind}" not found for instance ${foundCount + 1} (requested: ${instance})`);
 541 |             break;
 542 |         }
 543 | 
 544 |         foundCount++;
 545 |         console.log(`Found instance ${foundCount} of "${textToFind}" at position ${currentIndex} in full text`);
 546 |         
 547 |         if (foundCount === instance) {
 548 |             const targetStartInFullText = currentIndex;
 549 |             const targetEndInFullText = currentIndex + textToFind.length;
 550 |             let currentPosInFullText = 0;
 551 |             
 552 |             console.log(`Target text range in full text: ${targetStartInFullText}-${targetEndInFullText}`);
 553 | 
 554 |             for (const seg of segments) {
 555 |                 const segStartInFullText = currentPosInFullText;
 556 |                 const segTextLength = seg.text.length;
 557 |                 const segEndInFullText = segStartInFullText + segTextLength;
 558 | 
 559 |                 // Map from reconstructed text position to actual document indices
 560 |                 if (startIndex === -1 && targetStartInFullText >= segStartInFullText && targetStartInFullText < segEndInFullText) {
 561 |                     startIndex = seg.start + (targetStartInFullText - segStartInFullText);
 562 |                     console.log(`Mapped start to segment ${seg.start}-${seg.end}, position ${startIndex}`);
 563 |                 }
 564 |                 
 565 |                 if (targetEndInFullText > segStartInFullText && targetEndInFullText <= segEndInFullText) {
 566 |                     endIndex = seg.start + (targetEndInFullText - segStartInFullText);
 567 |                     console.log(`Mapped end to segment ${seg.start}-${seg.end}, position ${endIndex}`);
 568 |                     break;
 569 |                 }
 570 |                 
 571 |                 currentPosInFullText = segEndInFullText;
 572 |             }
 573 | 
 574 |             if (startIndex === -1 || endIndex === -1) {
 575 |                 console.warn(`Failed to map text "${textToFind}" instance ${instance} to actual document indices`);
 576 |                 // Reset and try next occurrence
 577 |                 startIndex = -1; 
 578 |                 endIndex = -1;
 579 |                 searchStartIndex = currentIndex + 1;
 580 |                 foundCount--;
 581 |                 continue;
 582 |             }
 583 |             
 584 |             console.log(`Successfully mapped "${textToFind}" to document range ${startIndex}-${endIndex}`);
 585 |             return { startIndex, endIndex };
 586 |         }
 587 |         
 588 |         // Prepare for next search iteration
 589 |         searchStartIndex = currentIndex + 1;
 590 |     }
 591 | 
 592 |     console.warn(`Could not find instance ${instance} of text "${textToFind}" in document ${documentId}`);
 593 |     return null; // Instance not found or mapping failed for all attempts
 594 | } catch (error: any) {
 595 |     console.error(`Error finding text "${textToFind}" in doc ${documentId}: ${error.message || 'Unknown error'}`);
 596 |     if (error.code === 404) throw new UserError(`Document not found while searching text (ID: ${documentId}).`);
 597 |     if (error.code === 403) throw new UserError(`Permission denied while searching text in doc ${documentId}.`);
 598 |     throw new Error(`Failed to retrieve doc for text searching: ${error.message || 'Unknown error'}`);
 599 | }
 600 | }
 601 | 
 602 | // --- Paragraph Boundary Helper ---
 603 | // Enhanced version to handle document structural elements more robustly
 604 | export async function getParagraphRange(docs: Docs, documentId: string, indexWithin: number): Promise<{ startIndex: number; endIndex: number } | null> {
 605 | try {
 606 |     console.log(`Finding paragraph containing index ${indexWithin} in document ${documentId}`);
 607 |     
 608 |     // Request more detailed document structure to handle nested elements
 609 |     const res = await docs.documents.get({
 610 |         documentId,
 611 |         // Request more comprehensive structure information
 612 |         fields: 'body(content(startIndex,endIndex,paragraph,table,sectionBreak,tableOfContents))',
 613 |     });
 614 | 
 615 |     if (!res.data.body?.content) {
 616 |         console.warn(`No content found in document ${documentId}`);
 617 |         return null;
 618 |     }
 619 | 
 620 |     // Find paragraph containing the index
 621 |     // We'll look at all structural elements recursively
 622 |     const findParagraphInContent = (content: any[]): { startIndex: number; endIndex: number } | null => {
 623 |         for (const element of content) {
 624 |             // Check if we have element boundaries defined
 625 |             if (element.startIndex !== undefined && element.endIndex !== undefined) {
 626 |                 // Check if index is within this element's range first
 627 |                 if (indexWithin >= element.startIndex && indexWithin < element.endIndex) {
 628 |                     // If it's a paragraph, we've found our target
 629 |                     if (element.paragraph) {
 630 |                         console.log(`Found paragraph containing index ${indexWithin}, range: ${element.startIndex}-${element.endIndex}`);
 631 |                         return { 
 632 |                             startIndex: element.startIndex, 
 633 |                             endIndex: element.endIndex 
 634 |                         };
 635 |                     }
 636 |                     
 637 |                     // If it's a table, we need to check cells recursively
 638 |                     if (element.table && element.table.tableRows) {
 639 |                         console.log(`Index ${indexWithin} is within a table, searching cells...`);
 640 |                         for (const row of element.table.tableRows) {
 641 |                             if (row.tableCells) {
 642 |                                 for (const cell of row.tableCells) {
 643 |                                     if (cell.content) {
 644 |                                         const result = findParagraphInContent(cell.content);
 645 |                                         if (result) return result;
 646 |                                     }
 647 |                                 }
 648 |                             }
 649 |                         }
 650 |                     }
 651 |                     
 652 |                     // For other structural elements, we didn't find a paragraph
 653 |                     // but we know the index is within this element
 654 |                     console.warn(`Index ${indexWithin} is within element (${element.startIndex}-${element.endIndex}) but not in a paragraph`);
 655 |                 }
 656 |             }
 657 |         }
 658 |         
 659 |         return null;
 660 |     };
 661 | 
 662 |     const paragraphRange = findParagraphInContent(res.data.body.content);
 663 |     
 664 |     if (!paragraphRange) {
 665 |         console.warn(`Could not find paragraph containing index ${indexWithin}`);
 666 |     } else {
 667 |         console.log(`Returning paragraph range: ${paragraphRange.startIndex}-${paragraphRange.endIndex}`);
 668 |     }
 669 |     
 670 |     return paragraphRange;
 671 | 
 672 | } catch (error: any) {
 673 |     console.error(`Error getting paragraph range for index ${indexWithin} in doc ${documentId}: ${error.message || 'Unknown error'}`);
 674 |     if (error.code === 404) throw new UserError(`Document not found while finding paragraph (ID: ${documentId}).`);
 675 |     if (error.code === 403) throw new UserError(`Permission denied while accessing doc ${documentId}.`);
 676 |     throw new Error(`Failed to find paragraph: ${error.message || 'Unknown error'}`);
 677 | }
 678 | }
 679 | 
 680 | // --- Style Request Builders ---
 681 | 
 682 | export function buildUpdateTextStyleRequest(
 683 | startIndex: number,
 684 | endIndex: number,
 685 | style: TextStyleArgs
 686 | ): { request: docs_v1.Schema$Request, fields: string[] } | null {
 687 |     const textStyle: docs_v1.Schema$TextStyle = {};
 688 | const fieldsToUpdate: string[] = [];
 689 | 
 690 |     if (style.bold !== undefined) { textStyle.bold = style.bold; fieldsToUpdate.push('bold'); }
 691 |     if (style.italic !== undefined) { textStyle.italic = style.italic; fieldsToUpdate.push('italic'); }
 692 |     if (style.underline !== undefined) { textStyle.underline = style.underline; fieldsToUpdate.push('underline'); }
 693 |     if (style.strikethrough !== undefined) { textStyle.strikethrough = style.strikethrough; fieldsToUpdate.push('strikethrough'); }
 694 |     if (style.fontSize !== undefined) { textStyle.fontSize = { magnitude: style.fontSize, unit: 'PT' }; fieldsToUpdate.push('fontSize'); }
 695 |     if (style.fontFamily !== undefined) { textStyle.weightedFontFamily = { fontFamily: style.fontFamily }; fieldsToUpdate.push('weightedFontFamily'); }
 696 |     if (style.foregroundColor !== undefined) {
 697 |         const rgbColor = hexToRgbColor(style.foregroundColor);
 698 |         if (!rgbColor) throw new UserError(`Invalid foreground hex color format: ${style.foregroundColor}`);
 699 |         textStyle.foregroundColor = { color: { rgbColor: rgbColor } }; fieldsToUpdate.push('foregroundColor');
 700 |     }
 701 |      if (style.backgroundColor !== undefined) {
 702 |         const rgbColor = hexToRgbColor(style.backgroundColor);
 703 |         if (!rgbColor) throw new UserError(`Invalid background hex color format: ${style.backgroundColor}`);
 704 |         textStyle.backgroundColor = { color: { rgbColor: rgbColor } }; fieldsToUpdate.push('backgroundColor');
 705 |     }
 706 |     if (style.linkUrl !== undefined) {
 707 |         textStyle.link = { url: style.linkUrl }; fieldsToUpdate.push('link');
 708 |     }
 709 |     // TODO: Handle clearing formatting
 710 | 
 711 |     if (fieldsToUpdate.length === 0) return null; // No styles to apply
 712 | 
 713 |     const request: docs_v1.Schema$Request = {
 714 |         updateTextStyle: {
 715 |             range: { startIndex, endIndex },
 716 |             textStyle: textStyle,
 717 |             fields: fieldsToUpdate.join(','),
 718 |         }
 719 |     };
 720 |     return { request, fields: fieldsToUpdate };
 721 | 
 722 | }
 723 | 
 724 | export function buildUpdateParagraphStyleRequest(
 725 | startIndex: number,
 726 | endIndex: number,
 727 | style: ParagraphStyleArgs
 728 | ): { request: docs_v1.Schema$Request, fields: string[] } | null {
 729 |     // Create style object and track which fields to update
 730 |     const paragraphStyle: docs_v1.Schema$ParagraphStyle = {};
 731 |     const fieldsToUpdate: string[] = [];
 732 | 
 733 |     console.log(`Building paragraph style request for range ${startIndex}-${endIndex} with options:`, style);
 734 | 
 735 |     // Process alignment option (LEFT, CENTER, RIGHT, JUSTIFIED)
 736 |     if (style.alignment !== undefined) { 
 737 |         paragraphStyle.alignment = style.alignment; 
 738 |         fieldsToUpdate.push('alignment'); 
 739 |         console.log(`Setting alignment to ${style.alignment}`);
 740 |     }
 741 |     
 742 |     // Process indentation options
 743 |     if (style.indentStart !== undefined) { 
 744 |         paragraphStyle.indentStart = { magnitude: style.indentStart, unit: 'PT' }; 
 745 |         fieldsToUpdate.push('indentStart'); 
 746 |         console.log(`Setting left indent to ${style.indentStart}pt`);
 747 |     }
 748 |     
 749 |     if (style.indentEnd !== undefined) { 
 750 |         paragraphStyle.indentEnd = { magnitude: style.indentEnd, unit: 'PT' }; 
 751 |         fieldsToUpdate.push('indentEnd'); 
 752 |         console.log(`Setting right indent to ${style.indentEnd}pt`);
 753 |     }
 754 |     
 755 |     // Process spacing options
 756 |     if (style.spaceAbove !== undefined) { 
 757 |         paragraphStyle.spaceAbove = { magnitude: style.spaceAbove, unit: 'PT' }; 
 758 |         fieldsToUpdate.push('spaceAbove'); 
 759 |         console.log(`Setting space above to ${style.spaceAbove}pt`);
 760 |     }
 761 |     
 762 |     if (style.spaceBelow !== undefined) { 
 763 |         paragraphStyle.spaceBelow = { magnitude: style.spaceBelow, unit: 'PT' }; 
 764 |         fieldsToUpdate.push('spaceBelow'); 
 765 |         console.log(`Setting space below to ${style.spaceBelow}pt`);
 766 |     }
 767 |     
 768 |     // Process named style types (headings, etc.)
 769 |     if (style.namedStyleType !== undefined) { 
 770 |         paragraphStyle.namedStyleType = style.namedStyleType; 
 771 |         fieldsToUpdate.push('namedStyleType'); 
 772 |         console.log(`Setting named style to ${style.namedStyleType}`);
 773 |     }
 774 |     
 775 |     // Process page break control
 776 |     if (style.keepWithNext !== undefined) { 
 777 |         paragraphStyle.keepWithNext = style.keepWithNext; 
 778 |         fieldsToUpdate.push('keepWithNext'); 
 779 |         console.log(`Setting keepWithNext to ${style.keepWithNext}`);
 780 |     }
 781 | 
 782 |     // Verify we have styles to apply
 783 |     if (fieldsToUpdate.length === 0) {
 784 |         console.warn("No paragraph styling options were provided");
 785 |         return null; // No styles to apply
 786 |     }
 787 | 
 788 |     // Build the request object
 789 |     const request: docs_v1.Schema$Request = {
 790 |         updateParagraphStyle: {
 791 |             range: { startIndex, endIndex },
 792 |             paragraphStyle: paragraphStyle,
 793 |             fields: fieldsToUpdate.join(','),
 794 |         }
 795 |     };
 796 |     
 797 |     console.log(`Created paragraph style request with fields: ${fieldsToUpdate.join(', ')}`);
 798 |     return { request, fields: fieldsToUpdate };
 799 | }
 800 | 
 801 | // --- Specific Feature Helpers ---
 802 | 
 803 | export async function createTable(docs: Docs, documentId: string, rows: number, columns: number, index: number): Promise<docs_v1.Schema$BatchUpdateDocumentResponse> {
 804 |     if (rows < 1 || columns < 1) {
 805 |         throw new UserError("Table must have at least 1 row and 1 column.");
 806 |     }
 807 |     const request: docs_v1.Schema$Request = {
 808 | insertTable: {
 809 | location: { index },
 810 | rows: rows,
 811 | columns: columns,
 812 | }
 813 | };
 814 | return executeBatchUpdate(docs, documentId, [request]);
 815 | }
 816 | 
 817 | export async function insertText(docs: Docs, documentId: string, text: string, index: number): Promise<docs_v1.Schema$BatchUpdateDocumentResponse> {
 818 |     if (!text) return {}; // Nothing to insert
 819 |     const request: docs_v1.Schema$Request = {
 820 | insertText: {
 821 | location: { index },
 822 | text: text,
 823 | }
 824 | };
 825 | return executeBatchUpdate(docs, documentId, [request]);
 826 | }
 827 | 
 828 | // --- Complex / Stubbed Helpers ---
 829 | 
 830 | export async function findParagraphsMatchingStyle(
 831 | docs: Docs,
 832 | documentId: string,
 833 | styleCriteria: any // Define a proper type for criteria (e.g., { fontFamily: 'Arial', bold: true })
 834 | ): Promise<{ startIndex: number; endIndex: number }[]> {
 835 | // TODO: Implement logic
 836 | // 1. Get document content with paragraph elements and their styles.
 837 | // 2. Iterate through paragraphs.
 838 | // 3. For each paragraph, check if its computed style matches the criteria.
 839 | // 4. Return ranges of matching paragraphs.
 840 | console.warn("findParagraphsMatchingStyle is not implemented.");
 841 | throw new NotImplementedError("Finding paragraphs by style criteria is not yet implemented.");
 842 | // return [];
 843 | }
 844 | 
 845 | export async function detectAndFormatLists(
 846 | docs: Docs,
 847 | documentId: string,
 848 | startIndex?: number,
 849 | endIndex?: number
 850 | ): Promise<docs_v1.Schema$BatchUpdateDocumentResponse> {
 851 | // TODO: Implement complex logic
 852 | // 1. Get document content (paragraphs, text runs) in the specified range (or whole doc).
 853 | // 2. Iterate through paragraphs.
 854 | // 3. Identify sequences of paragraphs starting with list-like markers (e.g., "-", "*", "1.", "a)").
 855 | // 4. Determine nesting levels based on indentation or marker patterns.
 856 | // 5. Generate CreateParagraphBulletsRequests for the identified sequences.
 857 | // 6. Potentially delete the original marker text.
 858 | // 7. Execute the batch update.
 859 | console.warn("detectAndFormatLists is not implemented.");
 860 | throw new NotImplementedError("Automatic list detection and formatting is not yet implemented.");
 861 | // return {};
 862 | }
 863 | 
 864 | export async function addCommentHelper(docs: Docs, documentId: string, text: string, startIndex: number, endIndex: number): Promise<void> {
 865 | // NOTE: Adding comments typically requires the Google Drive API v3 and different scopes!
 866 | // 'https://www.googleapis.com/auth/drive' or more specific comment scopes.
 867 | // This helper is a placeholder assuming Drive API client (`drive`) is available and authorized.
 868 | /*
 869 | const drive = google.drive({version: 'v3', auth: authClient}); // Assuming authClient is available
 870 | await drive.comments.create({
 871 | fileId: documentId,
 872 | requestBody: {
 873 | content: text,
 874 | anchor: JSON.stringify({ // Anchor format might need verification
 875 | 'type': 'workbook#textAnchor', // Or appropriate type for Docs
 876 | 'refs': [{
 877 | 'docRevisionId': 'head', // Or specific revision
 878 | 'range': {
 879 | 'start': startIndex,
 880 | 'end': endIndex,
 881 | }
 882 | }]
 883 | })
 884 | },
 885 | fields: 'id'
 886 | });
 887 | */
 888 | console.warn("addCommentHelper requires Google Drive API and is not implemented.");
 889 | throw new NotImplementedError("Adding comments requires Drive API setup and is not yet implemented.");
 890 | }
 891 | 
 892 | ================
 893 | File: src/server.ts
 894 | ================
 895 | // src/server.ts
 896 | import { FastMCP, UserError } from 'fastmcp';
 897 | import { z } from 'zod';
 898 | import { google, docs_v1, drive_v3 } from 'googleapis';
 899 | import { authorize } from './auth.js';
 900 | import { OAuth2Client } from 'google-auth-library';
 901 | 
 902 | // Import types and helpers
 903 | import {
 904 | DocumentIdParameter,
 905 | RangeParameters,
 906 | OptionalRangeParameters,
 907 | TextFindParameter,
 908 | TextStyleParameters,
 909 | TextStyleArgs,
 910 | ParagraphStyleParameters,
 911 | ParagraphStyleArgs,
 912 | ApplyTextStyleToolParameters, ApplyTextStyleToolArgs,
 913 | ApplyParagraphStyleToolParameters, ApplyParagraphStyleToolArgs,
 914 | NotImplementedError
 915 | } from './types.js';
 916 | import * as GDocsHelpers from './googleDocsApiHelpers.js';
 917 | 
 918 | let authClient: OAuth2Client | null = null;
 919 | let googleDocs: docs_v1.Docs | null = null;
 920 | let googleDrive: drive_v3.Drive | null = null;
 921 | 
 922 | // --- Initialization ---
 923 | async function initializeGoogleClient() {
 924 | if (googleDocs && googleDrive) return { authClient, googleDocs, googleDrive };
 925 | if (!authClient) { // Check authClient instead of googleDocs to allow re-attempt
 926 | try {
 927 | console.error("Attempting to authorize Google API client...");
 928 | const client = await authorize();
 929 | authClient = client; // Assign client here
 930 | googleDocs = google.docs({ version: 'v1', auth: authClient });
 931 | googleDrive = google.drive({ version: 'v3', auth: authClient });
 932 | console.error("Google API client authorized successfully.");
 933 | } catch (error) {
 934 | console.error("FATAL: Failed to initialize Google API client:", error);
 935 | authClient = null; // Reset on failure
 936 | googleDocs = null;
 937 | googleDrive = null;
 938 | // Decide if server should exit or just fail tools
 939 | throw new Error("Google client initialization failed. Cannot start server tools.");
 940 | }
 941 | }
 942 | // Ensure googleDocs and googleDrive are set if authClient is valid
 943 | if (authClient && !googleDocs) {
 944 | googleDocs = google.docs({ version: 'v1', auth: authClient });
 945 | }
 946 | if (authClient && !googleDrive) {
 947 | googleDrive = google.drive({ version: 'v3', auth: authClient });
 948 | }
 949 | 
 950 | if (!googleDocs || !googleDrive) {
 951 | throw new Error("Google Docs and Drive clients could not be initialized.");
 952 | }
 953 | 
 954 | return { authClient, googleDocs, googleDrive };
 955 | }
 956 | 
 957 | // Set up process-level unhandled error/rejection handlers to prevent crashes
 958 | process.on('uncaughtException', (error) => {
 959 |   console.error('Uncaught Exception:', error);
 960 |   // Don't exit process, just log the error and continue
 961 |   // This will catch timeout errors that might otherwise crash the server
 962 | });
 963 | 
 964 | process.on('unhandledRejection', (reason, promise) => {
 965 |   console.error('Unhandled Promise Rejection:', reason);
 966 |   // Don't exit process, just log the error and continue
 967 | });
 968 | 
 969 | const server = new FastMCP({
 970 |   name: 'Ultimate Google Docs MCP Server',
 971 |   version: '1.0.0'
 972 | });
 973 | 
 974 | // --- Helper to get Docs client within tools ---
 975 | async function getDocsClient() {
 976 | const { googleDocs: docs } = await initializeGoogleClient();
 977 | if (!docs) {
 978 | throw new UserError("Google Docs client is not initialized. Authentication might have failed during startup or lost connection.");
 979 | }
 980 | return docs;
 981 | }
 982 | 
 983 | // --- Helper to get Drive client within tools ---
 984 | async function getDriveClient() {
 985 | const { googleDrive: drive } = await initializeGoogleClient();
 986 | if (!drive) {
 987 | throw new UserError("Google Drive client is not initialized. Authentication might have failed during startup or lost connection.");
 988 | }
 989 | return drive;
 990 | }
 991 | 
 992 | // === TOOL DEFINITIONS ===
 993 | 
 994 | // --- Foundational Tools ---
 995 | 
 996 | server.addTool({
 997 | name: 'readGoogleDoc',
 998 | description: 'Reads the content of a specific Google Document, optionally returning structured data.',
 999 | parameters: DocumentIdParameter.extend({
1000 | format: z.enum(['text', 'json', 'markdown']).optional().default('text')
1001 | .describe("Output format: 'text' (plain text, possibly truncated), 'json' (raw API structure, complex), 'markdown' (experimental conversion).")
1002 | }),
1003 | execute: async (args, { log }) => {
1004 | const docs = await getDocsClient();
1005 | log.info(`Reading Google Doc: ${args.documentId}, Format: ${args.format}`);
1006 | 
1007 |     try {
1008 |         const fields = args.format === 'json' || args.format === 'markdown'
1009 |             ? '*' // Get everything for structure analysis
1010 |             : 'body(content(paragraph(elements(textRun(content)))))'; // Just text content
1011 | 
1012 |         const res = await docs.documents.get({
1013 |             documentId: args.documentId,
1014 |             fields: fields,
1015 |         });
1016 |         log.info(`Fetched doc: ${args.documentId}`);
1017 | 
1018 |         if (args.format === 'json') {
1019 |             return JSON.stringify(res.data, null, 2); // Return raw structure
1020 |         }
1021 | 
1022 |         if (args.format === 'markdown') {
1023 |             // TODO: Implement Markdown conversion logic (complex)
1024 |             log.warn("Markdown conversion is not implemented yet.");
1025 |              throw new NotImplementedError("Markdown output format is not yet implemented.");
1026 |             // return convertDocsJsonToMarkdown(res.data);
1027 |         }
1028 | 
1029 |         // Default: Text format
1030 |         let textContent = '';
1031 |         res.data.body?.content?.forEach(element => {
1032 |             element.paragraph?.elements?.forEach(pe => {
1033 |             textContent += pe.textRun?.content || '';
1034 |             });
1035 |         });
1036 | 
1037 |         if (!textContent.trim()) return "Document found, but appears empty.";
1038 | 
1039 |         // Basic truncation for text mode
1040 |         const maxLength = 4000; // Increased limit
1041 |         const truncatedContent = textContent.length > maxLength ? textContent.substring(0, maxLength) + `... [truncated ${textContent.length} chars]` : textContent;
1042 |         return `Content:\n---\n${truncatedContent}`;
1043 | 
1044 |     } catch (error: any) {
1045 |          log.error(`Error reading doc ${args.documentId}: ${error.message || error}`);
1046 |          // Handle errors thrown by helpers or API directly
1047 |          if (error instanceof UserError) throw error;
1048 |          if (error instanceof NotImplementedError) throw error;
1049 |          // Generic fallback for API errors not caught by helpers
1050 |           if (error.code === 404) throw new UserError(`Doc not found (ID: ${args.documentId}).`);
1051 |           if (error.code === 403) throw new UserError(`Permission denied for doc (ID: ${args.documentId}).`);
1052 |          throw new UserError(`Failed to read doc: ${error.message || 'Unknown error'}`);
1053 |     }
1054 | 
1055 | },
1056 | });
1057 | 
1058 | server.addTool({
1059 | name: 'appendToGoogleDoc',
1060 | description: 'Appends text to the very end of a specific Google Document.',
1061 | parameters: DocumentIdParameter.extend({
1062 | textToAppend: z.string().min(1).describe('The text to add to the end.'),
1063 | addNewlineIfNeeded: z.boolean().optional().default(true).describe("Automatically add a newline before the appended text if the doc doesn't end with one."),
1064 | }),
1065 | execute: async (args, { log }) => {
1066 | const docs = await getDocsClient();
1067 | log.info(`Appending to Google Doc: ${args.documentId}`);
1068 | 
1069 |     try {
1070 |         // Get the current end index
1071 |         const docInfo = await docs.documents.get({ documentId: args.documentId, fields: 'body(content(endIndex)),documentStyle(pageSize)' }); // Need content for endIndex
1072 |         let endIndex = 1;
1073 |         let lastCharIsNewline = false;
1074 |         if (docInfo.data.body?.content) {
1075 |             const lastElement = docInfo.data.body.content[docInfo.data.body.content.length - 1];
1076 |              if (lastElement?.endIndex) {
1077 |                 endIndex = lastElement.endIndex -1; // Insert *before* the final newline of the doc typically
1078 |                 // Crude check for last character (better check would involve reading last text run)
1079 |                  // const lastTextRun = ... find last text run ...
1080 |                  // if (lastTextRun?.content?.endsWith('\n')) lastCharIsNewline = true;
1081 |             }
1082 |         }
1083 |         // Simpler approach: Always assume insertion is needed unless explicitly told not to add newline
1084 |         const textToInsert = (args.addNewlineIfNeeded && endIndex > 1 ? '\n' : '') + args.textToAppend;
1085 | 
1086 |         if (!textToInsert) return "Nothing to append.";
1087 | 
1088 |         const request: docs_v1.Schema$Request = { insertText: { location: { index: endIndex }, text: textToInsert } };
1089 |         await GDocsHelpers.executeBatchUpdate(docs, args.documentId, [request]);
1090 | 
1091 |         log.info(`Successfully appended to doc: ${args.documentId}`);
1092 |         return `Successfully appended text to document ${args.documentId}.`;
1093 |     } catch (error: any) {
1094 |          log.error(`Error appending to doc ${args.documentId}: ${error.message || error}`);
1095 |          if (error instanceof UserError) throw error;
1096 |          if (error instanceof NotImplementedError) throw error;
1097 |          throw new UserError(`Failed to append to doc: ${error.message || 'Unknown error'}`);
1098 |     }
1099 | 
1100 | },
1101 | });
1102 | 
1103 | server.addTool({
1104 | name: 'insertText',
1105 | description: 'Inserts text at a specific index within the document body.',
1106 | parameters: DocumentIdParameter.extend({
1107 | textToInsert: z.string().min(1).describe('The text to insert.'),
1108 | index: z.number().int().min(1).describe('The index (1-based) where the text should be inserted.'),
1109 | }),
1110 | execute: async (args, { log }) => {
1111 | const docs = await getDocsClient();
1112 | log.info(`Inserting text in doc ${args.documentId} at index ${args.index}`);
1113 | try {
1114 | await GDocsHelpers.insertText(docs, args.documentId, args.textToInsert, args.index);
1115 | return `Successfully inserted text at index ${args.index}.`;
1116 | } catch (error: any) {
1117 | log.error(`Error inserting text in doc ${args.documentId}: ${error.message || error}`);
1118 | if (error instanceof UserError) throw error;
1119 | throw new UserError(`Failed to insert text: ${error.message || 'Unknown error'}`);
1120 | }
1121 | }
1122 | });
1123 | 
1124 | server.addTool({
1125 | name: 'deleteRange',
1126 | description: 'Deletes content within a specified range (start index inclusive, end index exclusive).',
1127 | parameters: DocumentIdParameter.extend({
1128 |   startIndex: z.number().int().min(1).describe('The starting index of the text range (inclusive, starts from 1).'),
1129 |   endIndex: z.number().int().min(1).describe('The ending index of the text range (exclusive).')
1130 | }).refine(data => data.endIndex > data.startIndex, {
1131 |   message: "endIndex must be greater than startIndex",
1132 |   path: ["endIndex"],
1133 | }),
1134 | execute: async (args, { log }) => {
1135 | const docs = await getDocsClient();
1136 | log.info(`Deleting range ${args.startIndex}-${args.endIndex} in doc ${args.documentId}`);
1137 | if (args.endIndex <= args.startIndex) {
1138 | throw new UserError("End index must be greater than start index for deletion.");
1139 | }
1140 | try {
1141 | const request: docs_v1.Schema$Request = {
1142 |                 deleteContentRange: {
1143 |                     range: { startIndex: args.startIndex, endIndex: args.endIndex }
1144 |                 }
1145 |             };
1146 |             await GDocsHelpers.executeBatchUpdate(docs, args.documentId, [request]);
1147 |             return `Successfully deleted content in range ${args.startIndex}-${args.endIndex}.`;
1148 |         } catch (error: any) {
1149 |             log.error(`Error deleting range in doc ${args.documentId}: ${error.message || error}`);
1150 |             if (error instanceof UserError) throw error;
1151 |             throw new UserError(`Failed to delete range: ${error.message || 'Unknown error'}`);
1152 | }
1153 | }
1154 | });
1155 | 
1156 | // --- Advanced Formatting & Styling Tools ---
1157 | 
1158 | server.addTool({
1159 | name: 'applyTextStyle',
1160 | description: 'Applies character-level formatting (bold, color, font, etc.) to a specific range or found text.',
1161 | parameters: ApplyTextStyleToolParameters,
1162 | execute: async (args: ApplyTextStyleToolArgs, { log }) => {
1163 | const docs = await getDocsClient();
1164 | let { startIndex, endIndex } = args.target as any; // Will be updated if target is text
1165 | 
1166 |         log.info(`Applying text style in doc ${args.documentId}. Target: ${JSON.stringify(args.target)}, Style: ${JSON.stringify(args.style)}`);
1167 | 
1168 |         try {
1169 |             // Determine target range
1170 |             if ('textToFind' in args.target) {
1171 |                 const range = await GDocsHelpers.findTextRange(docs, args.documentId, args.target.textToFind, args.target.matchInstance);
1172 |                 if (!range) {
1173 |                     throw new UserError(`Could not find instance ${args.target.matchInstance} of text "${args.target.textToFind}".`);
1174 |                 }
1175 |                 startIndex = range.startIndex;
1176 |                 endIndex = range.endIndex;
1177 |                 log.info(`Found text "${args.target.textToFind}" (instance ${args.target.matchInstance}) at range ${startIndex}-${endIndex}`);
1178 |             }
1179 | 
1180 |             if (startIndex === undefined || endIndex === undefined) {
1181 |                  throw new UserError("Target range could not be determined.");
1182 |             }
1183 |              if (endIndex <= startIndex) {
1184 |                  throw new UserError("End index must be greater than start index for styling.");
1185 |             }
1186 | 
1187 |             // Build the request
1188 |             const requestInfo = GDocsHelpers.buildUpdateTextStyleRequest(startIndex, endIndex, args.style);
1189 |             if (!requestInfo) {
1190 |                  return "No valid text styling options were provided.";
1191 |             }
1192 | 
1193 |             await GDocsHelpers.executeBatchUpdate(docs, args.documentId, [requestInfo.request]);
1194 |             return `Successfully applied text style (${requestInfo.fields.join(', ')}) to range ${startIndex}-${endIndex}.`;
1195 | 
1196 |         } catch (error: any) {
1197 |             log.error(`Error applying text style in doc ${args.documentId}: ${error.message || error}`);
1198 |             if (error instanceof UserError) throw error;
1199 |             if (error instanceof NotImplementedError) throw error; // Should not happen here
1200 |             throw new UserError(`Failed to apply text style: ${error.message || 'Unknown error'}`);
1201 |         }
1202 |     }
1203 | 
1204 | });
1205 | 
1206 | server.addTool({
1207 | name: 'applyParagraphStyle',
1208 | description: 'Applies paragraph-level formatting (alignment, spacing, named styles like Heading 1) to the paragraph(s) containing specific text, an index, or a range.',
1209 | parameters: ApplyParagraphStyleToolParameters,
1210 | execute: async (args: ApplyParagraphStyleToolArgs, { log }) => {
1211 | const docs = await getDocsClient();
1212 | let startIndex: number | undefined;
1213 | let endIndex: number | undefined;
1214 | 
1215 |         log.info(`Applying paragraph style to document ${args.documentId}`);
1216 |         log.info(`Style options: ${JSON.stringify(args.style)}`);
1217 |         log.info(`Target specification: ${JSON.stringify(args.target)}`);
1218 | 
1219 |         try {
1220 |             // STEP 1: Determine the target paragraph's range based on the targeting method
1221 |             if ('textToFind' in args.target) {
1222 |                 // Find the text first
1223 |                 log.info(`Finding text "${args.target.textToFind}" (instance ${args.target.matchInstance || 1})`);
1224 |                 const textRange = await GDocsHelpers.findTextRange(
1225 |                     docs,
1226 |                     args.documentId,
1227 |                     args.target.textToFind,
1228 |                     args.target.matchInstance || 1
1229 |                 );
1230 | 
1231 |                 if (!textRange) {
1232 |                     throw new UserError(`Could not find "${args.target.textToFind}" in the document.`);
1233 |                 }
1234 | 
1235 |                 log.info(`Found text at range ${textRange.startIndex}-${textRange.endIndex}, now locating containing paragraph`);
1236 | 
1237 |                 // Then find the paragraph containing this text
1238 |                 const paragraphRange = await GDocsHelpers.getParagraphRange(
1239 |                     docs,
1240 |                     args.documentId,
1241 |                     textRange.startIndex
1242 |                 );
1243 | 
1244 |                 if (!paragraphRange) {
1245 |                     throw new UserError(`Found the text but could not determine the paragraph boundaries.`);
1246 |                 }
1247 | 
1248 |                 startIndex = paragraphRange.startIndex;
1249 |                 endIndex = paragraphRange.endIndex;
1250 |                 log.info(`Text is contained within paragraph at range ${startIndex}-${endIndex}`);
1251 | 
1252 |             } else if ('indexWithinParagraph' in args.target) {
1253 |                 // Find paragraph containing the specified index
1254 |                 log.info(`Finding paragraph containing index ${args.target.indexWithinParagraph}`);
1255 |                 const paragraphRange = await GDocsHelpers.getParagraphRange(
1256 |                     docs,
1257 |                     args.documentId,
1258 |                     args.target.indexWithinParagraph
1259 |                 );
1260 | 
1261 |                 if (!paragraphRange) {
1262 |                     throw new UserError(`Could not find paragraph containing index ${args.target.indexWithinParagraph}.`);
1263 |                 }
1264 | 
1265 |                 startIndex = paragraphRange.startIndex;
1266 |                 endIndex = paragraphRange.endIndex;
1267 |                 log.info(`Located paragraph at range ${startIndex}-${endIndex}`);
1268 | 
1269 |             } else if ('startIndex' in args.target && 'endIndex' in args.target) {
1270 |                 // Use directly provided range
1271 |                 startIndex = args.target.startIndex;
1272 |                 endIndex = args.target.endIndex;
1273 |                 log.info(`Using provided paragraph range ${startIndex}-${endIndex}`);
1274 |             }
1275 | 
1276 |             // Verify that we have a valid range
1277 |             if (startIndex === undefined || endIndex === undefined) {
1278 |                 throw new UserError("Could not determine target paragraph range from the provided information.");
1279 |             }
1280 | 
1281 |             if (endIndex <= startIndex) {
1282 |                 throw new UserError(`Invalid paragraph range: end index (${endIndex}) must be greater than start index (${startIndex}).`);
1283 |             }
1284 | 
1285 |             // STEP 2: Build and apply the paragraph style request
1286 |             log.info(`Building paragraph style request for range ${startIndex}-${endIndex}`);
1287 |             const requestInfo = GDocsHelpers.buildUpdateParagraphStyleRequest(startIndex, endIndex, args.style);
1288 | 
1289 |             if (!requestInfo) {
1290 |                 return "No valid paragraph styling options were provided.";
1291 |             }
1292 | 
1293 |             log.info(`Applying styles: ${requestInfo.fields.join(', ')}`);
1294 |             await GDocsHelpers.executeBatchUpdate(docs, args.documentId, [requestInfo.request]);
1295 | 
1296 |             return `Successfully applied paragraph styles (${requestInfo.fields.join(', ')}) to the paragraph.`;
1297 | 
1298 |         } catch (error: any) {
1299 |             // Detailed error logging
1300 |             log.error(`Error applying paragraph style in doc ${args.documentId}:`);
1301 |             log.error(error.stack || error.message || error);
1302 | 
1303 |             if (error instanceof UserError) throw error;
1304 |             if (error instanceof NotImplementedError) throw error;
1305 | 
1306 |             // Provide a more helpful error message
1307 |             throw new UserError(`Failed to apply paragraph style: ${error.message || 'Unknown error'}`);
1308 |         }
1309 |     }
1310 | });
1311 | 
1312 | // --- Structure & Content Tools ---
1313 | 
1314 | server.addTool({
1315 | name: 'insertTable',
1316 | description: 'Inserts a new table with the specified dimensions at a given index.',
1317 | parameters: DocumentIdParameter.extend({
1318 | rows: z.number().int().min(1).describe('Number of rows for the new table.'),
1319 | columns: z.number().int().min(1).describe('Number of columns for the new table.'),
1320 | index: z.number().int().min(1).describe('The index (1-based) where the table should be inserted.'),
1321 | }),
1322 | execute: async (args, { log }) => {
1323 | const docs = await getDocsClient();
1324 | log.info(`Inserting ${args.rows}x${args.columns} table in doc ${args.documentId} at index ${args.index}`);
1325 | try {
1326 | await GDocsHelpers.createTable(docs, args.documentId, args.rows, args.columns, args.index);
1327 | // The API response contains info about the created table, but might be too complex to return here.
1328 | return `Successfully inserted a ${args.rows}x${args.columns} table at index ${args.index}.`;
1329 | } catch (error: any) {
1330 | log.error(`Error inserting table in doc ${args.documentId}: ${error.message || error}`);
1331 | if (error instanceof UserError) throw error;
1332 | throw new UserError(`Failed to insert table: ${error.message || 'Unknown error'}`);
1333 | }
1334 | }
1335 | });
1336 | 
1337 | server.addTool({
1338 | name: 'editTableCell',
1339 | description: 'Edits the content and/or basic style of a specific table cell. Requires knowing table start index.',
1340 | parameters: DocumentIdParameter.extend({
1341 | tableStartIndex: z.number().int().min(1).describe("The starting index of the TABLE element itself (tricky to find, may require reading structure first)."),
1342 | rowIndex: z.number().int().min(0).describe("Row index (0-based)."),
1343 | columnIndex: z.number().int().min(0).describe("Column index (0-based)."),
1344 | textContent: z.string().optional().describe("Optional: New text content for the cell. Replaces existing content."),
1345 | // Combine basic styles for simplicity here. More advanced cell styling might need separate tools.
1346 | textStyle: TextStyleParameters.optional().describe("Optional: Text styles to apply."),
1347 | paragraphStyle: ParagraphStyleParameters.optional().describe("Optional: Paragraph styles (like alignment) to apply."),
1348 | // cellBackgroundColor: z.string().optional()... // Cell-specific styles are complex
1349 | }),
1350 | execute: async (args, { log }) => {
1351 | const docs = await getDocsClient();
1352 | log.info(`Editing cell (${args.rowIndex}, ${args.columnIndex}) in table starting at ${args.tableStartIndex}, doc ${args.documentId}`);
1353 | 
1354 |         // TODO: Implement complex logic
1355 |         // 1. Find the cell's content range based on tableStartIndex, rowIndex, columnIndex. This is NON-TRIVIAL.
1356 |         //    Requires getting the document, finding the table element, iterating through rows/cells to calculate indices.
1357 |         // 2. If textContent is provided, generate a DeleteContentRange request for the cell's current content.
1358 |         // 3. Generate an InsertText request for the new textContent at the cell's start index.
1359 |         // 4. If textStyle is provided, generate UpdateTextStyle requests for the new text range.
1360 |         // 5. If paragraphStyle is provided, generate UpdateParagraphStyle requests for the cell's paragraph range.
1361 |         // 6. Execute batch update.
1362 | 
1363 |         log.error("editTableCell is not implemented due to complexity of finding cell indices.");
1364 |         throw new NotImplementedError("Editing table cells is complex and not yet implemented.");
1365 |         // return `Edit request for cell (${args.rowIndex}, ${args.columnIndex}) submitted (Not Implemented).`;
1366 |     }
1367 | 
1368 | });
1369 | 
1370 | server.addTool({
1371 | name: 'insertPageBreak',
1372 | description: 'Inserts a page break at the specified index.',
1373 | parameters: DocumentIdParameter.extend({
1374 | index: z.number().int().min(1).describe('The index (1-based) where the page break should be inserted.'),
1375 | }),
1376 | execute: async (args, { log }) => {
1377 | const docs = await getDocsClient();
1378 | log.info(`Inserting page break in doc ${args.documentId} at index ${args.index}`);
1379 | try {
1380 | const request: docs_v1.Schema$Request = {
1381 | insertPageBreak: {
1382 | location: { index: args.index }
1383 | }
1384 | };
1385 | await GDocsHelpers.executeBatchUpdate(docs, args.documentId, [request]);
1386 | return `Successfully inserted page break at index ${args.index}.`;
1387 | } catch (error: any) {
1388 | log.error(`Error inserting page break in doc ${args.documentId}: ${error.message || error}`);
1389 | if (error instanceof UserError) throw error;
1390 | throw new UserError(`Failed to insert page break: ${error.message || 'Unknown error'}`);
1391 | }
1392 | }
1393 | });
1394 | 
1395 | // --- Intelligent Assistance Tools (Examples/Stubs) ---
1396 | 
1397 | server.addTool({
1398 | name: 'fixListFormatting',
1399 | description: 'EXPERIMENTAL: Attempts to detect paragraphs that look like lists (e.g., starting with -, *, 1.) and convert them to proper Google Docs bulleted or numbered lists. Best used on specific sections.',
1400 | parameters: DocumentIdParameter.extend({
1401 | // Optional range to limit the scope, otherwise scans whole doc (potentially slow/risky)
1402 | range: OptionalRangeParameters.optional().describe("Optional: Limit the fixing process to a specific range.")
1403 | }),
1404 | execute: async (args, { log }) => {
1405 | const docs = await getDocsClient();
1406 | log.warn(`Executing EXPERIMENTAL fixListFormatting for doc ${args.documentId}. Range: ${JSON.stringify(args.range)}`);
1407 | try {
1408 | await GDocsHelpers.detectAndFormatLists(docs, args.documentId, args.range?.startIndex, args.range?.endIndex);
1409 | return `Attempted to fix list formatting. Please review the document for accuracy.`;
1410 | } catch (error: any) {
1411 | log.error(`Error fixing list formatting in doc ${args.documentId}: ${error.message || error}`);
1412 | if (error instanceof UserError) throw error;
1413 | if (error instanceof NotImplementedError) throw error; // Expected if helper not implemented
1414 | throw new UserError(`Failed to fix list formatting: ${error.message || 'Unknown error'}`);
1415 | }
1416 | }
1417 | });
1418 | 
1419 | server.addTool({
1420 | name: 'addComment',
1421 | description: 'Adds a comment anchored to a specific text range. REQUIRES DRIVE API SCOPES/SETUP.',
1422 | parameters: DocumentIdParameter.extend({
1423 |   startIndex: z.number().int().min(1).describe('The starting index of the text range (inclusive, starts from 1).'),
1424 |   endIndex: z.number().int().min(1).describe('The ending index of the text range (exclusive).'),
1425 |   commentText: z.string().min(1).describe("The content of the comment."),
1426 | }).refine(data => data.endIndex > data.startIndex, {
1427 |   message: "endIndex must be greater than startIndex",
1428 |   path: ["endIndex"],
1429 | }),
1430 | execute: async (args, { log }) => {
1431 | log.info(`Attempting to add comment "${args.commentText}" to range ${args.startIndex}-${args.endIndex} in doc ${args.documentId}`);
1432 | // Requires Drive API client and appropriate scopes.
1433 | // const { authClient } = await initializeGoogleClient(); // Get auth client if needed
1434 | // if (!authClient) throw new UserError("Authentication client not available for Drive API.");
1435 | try {
1436 | // await GDocsHelpers.addCommentHelper(driveClient, args.documentId, args.commentText, args.startIndex, args.endIndex);
1437 | log.error("addComment requires Drive API setup which is not implemented.");
1438 | throw new NotImplementedError("Adding comments requires Drive API setup and is not yet implemented in this server.");
1439 | // return `Comment added to range ${args.startIndex}-${args.endIndex}.`;
1440 | } catch (error: any) {
1441 | log.error(`Error adding comment in doc ${args.documentId}: ${error.message || error}`);
1442 | if (error instanceof UserError) throw error;
1443 | if (error instanceof NotImplementedError) throw error;
1444 | throw new UserError(`Failed to add comment: ${error.message || 'Unknown error'}`);
1445 | }
1446 | }
1447 | });
1448 | 
1449 | // --- Add Stubs for other advanced features ---
1450 | // (findElement, getDocumentMetadata, replaceText, list management, image handling, section breaks, footnotes, etc.)
1451 | // Example Stub:
1452 | server.addTool({
1453 | name: 'findElement',
1454 | description: 'Finds elements (paragraphs, tables, etc.) based on various criteria. (Not Implemented)',
1455 | parameters: DocumentIdParameter.extend({
1456 | // Define complex query parameters...
1457 | textQuery: z.string().optional(),
1458 | elementType: z.enum(['paragraph', 'table', 'list', 'image']).optional(),
1459 | // styleQuery...
1460 | }),
1461 | execute: async (args, { log }) => {
1462 | log.warn("findElement tool called but is not implemented.");
1463 | throw new NotImplementedError("Finding elements by complex criteria is not yet implemented.");
1464 | }
1465 | });
1466 | 
1467 | // --- Preserve the existing formatMatchingText tool for backward compatibility ---
1468 | server.addTool({
1469 | name: 'formatMatchingText',
1470 | description: 'Finds specific text within a Google Document and applies character formatting (bold, italics, color, etc.) to the specified instance.',
1471 | parameters: z.object({
1472 |   documentId: z.string().describe('The ID of the Google Document.'),
1473 |   textToFind: z.string().min(1).describe('The exact text string to find and format.'),
1474 |   matchInstance: z.number().int().min(1).optional().default(1).describe('Which instance of the text to format (1st, 2nd, etc.). Defaults to 1.'),
1475 |   // Re-use optional Formatting Parameters (SHARED)
1476 |   bold: z.boolean().optional().describe('Apply bold formatting.'),
1477 |   italic: z.boolean().optional().describe('Apply italic formatting.'),
1478 |   underline: z.boolean().optional().describe('Apply underline formatting.'),
1479 |   strikethrough: z.boolean().optional().describe('Apply strikethrough formatting.'),
1480 |   fontSize: z.number().min(1).optional().describe('Set font size (in points, e.g., 12).'),
1481 |   fontFamily: z.string().optional().describe('Set font family (e.g., "Arial", "Times New Roman").'),
1482 |   foregroundColor: z.string()
1483 |     .refine((color) => /^#?([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/.test(color), {
1484 |       message: "Invalid hex color format (e.g., #FF0000 or #F00)"
1485 |     })
1486 |     .optional()
1487 |     .describe('Set text color using hex format (e.g., "#FF0000").'),
1488 |   backgroundColor: z.string()
1489 |     .refine((color) => /^#?([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/.test(color), {
1490 |       message: "Invalid hex color format (e.g., #00FF00 or #0F0)"
1491 |     })
1492 |     .optional()
1493 |     .describe('Set text background color using hex format (e.g., "#FFFF00").'),
1494 |   linkUrl: z.string().url().optional().describe('Make the text a hyperlink pointing to this URL.')
1495 | })
1496 | .refine(data => Object.keys(data).some(key => !['documentId', 'textToFind', 'matchInstance'].includes(key) && data[key as keyof typeof data] !== undefined), {
1497 |     message: "At least one formatting option (bold, italic, fontSize, etc.) must be provided."
1498 | }),
1499 | execute: async (args, { log }) => {
1500 |   // Adapt to use the new applyTextStyle implementation under the hood
1501 |   const docs = await getDocsClient();
1502 |   log.info(`Using formatMatchingText (legacy) for doc ${args.documentId}, target: "${args.textToFind}" (instance ${args.matchInstance})`);
1503 | 
1504 |   try {
1505 |     // Extract the style parameters
1506 |     const styleParams: TextStyleArgs = {};
1507 |     if (args.bold !== undefined) styleParams.bold = args.bold;
1508 |     if (args.italic !== undefined) styleParams.italic = args.italic;
1509 |     if (args.underline !== undefined) styleParams.underline = args.underline;
1510 |     if (args.strikethrough !== undefined) styleParams.strikethrough = args.strikethrough;
1511 |     if (args.fontSize !== undefined) styleParams.fontSize = args.fontSize;
1512 |     if (args.fontFamily !== undefined) styleParams.fontFamily = args.fontFamily;
1513 |     if (args.foregroundColor !== undefined) styleParams.foregroundColor = args.foregroundColor;
1514 |     if (args.backgroundColor !== undefined) styleParams.backgroundColor = args.backgroundColor;
1515 |     if (args.linkUrl !== undefined) styleParams.linkUrl = args.linkUrl;
1516 | 
1517 |     // Find the text range
1518 |     const range = await GDocsHelpers.findTextRange(docs, args.documentId, args.textToFind, args.matchInstance);
1519 |     if (!range) {
1520 |       throw new UserError(`Could not find instance ${args.matchInstance} of text "${args.textToFind}".`);
1521 |     }
1522 | 
1523 |     // Build and execute the request
1524 |     const requestInfo = GDocsHelpers.buildUpdateTextStyleRequest(range.startIndex, range.endIndex, styleParams);
1525 |     if (!requestInfo) {
1526 |       return "No valid text styling options were provided.";
1527 |     }
1528 | 
1529 |     await GDocsHelpers.executeBatchUpdate(docs, args.documentId, [requestInfo.request]);
1530 |     return `Successfully applied formatting to instance ${args.matchInstance} of "${args.textToFind}".`;
1531 |   } catch (error: any) {
1532 |     log.error(`Error in formatMatchingText for doc ${args.documentId}: ${error.message || error}`);
1533 |     if (error instanceof UserError) throw error;
1534 |     throw new UserError(`Failed to format text: ${error.message || 'Unknown error'}`);
1535 |   }
1536 | }
1537 | });
1538 | 
1539 | // === GOOGLE DRIVE TOOLS ===
1540 | 
1541 | server.addTool({
1542 | name: 'listGoogleDocs',
1543 | description: 'Lists Google Documents from your Google Drive with optional filtering.',
1544 | parameters: z.object({
1545 |   maxResults: z.number().int().min(1).max(100).optional().default(20).describe('Maximum number of documents to return (1-100).'),
1546 |   query: z.string().optional().describe('Search query to filter documents by name or content.'),
1547 |   orderBy: z.enum(['name', 'modifiedTime', 'createdTime']).optional().default('modifiedTime').describe('Sort order for results.'),
1548 | }),
1549 | execute: async (args, { log }) => {
1550 | const drive = await getDriveClient();
1551 | log.info(`Listing Google Docs. Query: ${args.query || 'none'}, Max: ${args.maxResults}, Order: ${args.orderBy}`);
1552 | 
1553 | try {
1554 |   // Build the query string for Google Drive API
1555 |   let queryString = "mimeType='application/vnd.google-apps.document' and trashed=false";
1556 |   if (args.query) {
1557 |     queryString += ` and (name contains '${args.query}' or fullText contains '${args.query}')`;
1558 |   }
1559 | 
1560 |   const response = await drive.files.list({
1561 |     q: queryString,
1562 |     pageSize: args.maxResults,
1563 |     orderBy: args.orderBy === 'name' ? 'name' : args.orderBy,
1564 |     fields: 'files(id,name,modifiedTime,createdTime,size,webViewLink,owners(displayName,emailAddress))',
1565 |   });
1566 | 
1567 |   const files = response.data.files || [];
1568 | 
1569 |   if (files.length === 0) {
1570 |     return "No Google Docs found matching your criteria.";
1571 |   }
1572 | 
1573 |   let result = `Found ${files.length} Google Document(s):\n\n`;
1574 |   files.forEach((file, index) => {
1575 |     const modifiedDate = file.modifiedTime ? new Date(file.modifiedTime).toLocaleDateString() : 'Unknown';
1576 |     const owner = file.owners?.[0]?.displayName || 'Unknown';
1577 |     result += `${index + 1}. **${file.name}**\n`;
1578 |     result += `   ID: ${file.id}\n`;
1579 |     result += `   Modified: ${modifiedDate}\n`;
1580 |     result += `   Owner: ${owner}\n`;
1581 |     result += `   Link: ${file.webViewLink}\n\n`;
1582 |   });
1583 | 
1584 |   return result;
1585 | } catch (error: any) {
1586 |   log.error(`Error listing Google Docs: ${error.message || error}`);
1587 |   if (error.code === 403) throw new UserError("Permission denied. Make sure you have granted Google Drive access to the application.");
1588 |   throw new UserError(`Failed to list documents: ${error.message || 'Unknown error'}`);
1589 | }
1590 | }
1591 | });
1592 | 
1593 | server.addTool({
1594 | name: 'searchGoogleDocs',
1595 | description: 'Searches for Google Documents by name, content, or other criteria.',
1596 | parameters: z.object({
1597 |   searchQuery: z.string().min(1).describe('Search term to find in document names or content.'),
1598 |   searchIn: z.enum(['name', 'content', 'both']).optional().default('both').describe('Where to search: document names, content, or both.'),
1599 |   maxResults: z.number().int().min(1).max(50).optional().default(10).describe('Maximum number of results to return.'),
1600 |   modifiedAfter: z.string().optional().describe('Only return documents modified after this date (ISO 8601 format, e.g., "2024-01-01").'),
1601 | }),
1602 | execute: async (args, { log }) => {
1603 | const drive = await getDriveClient();
1604 | log.info(`Searching Google Docs for: "${args.searchQuery}" in ${args.searchIn}`);
1605 | 
1606 | try {
1607 |   let queryString = "mimeType='application/vnd.google-apps.document' and trashed=false";
1608 | 
1609 |   // Add search criteria
1610 |   if (args.searchIn === 'name') {
1611 |     queryString += ` and name contains '${args.searchQuery}'`;
1612 |   } else if (args.searchIn === 'content') {
1613 |     queryString += ` and fullText contains '${args.searchQuery}'`;
1614 |   } else {
1615 |     queryString += ` and (name contains '${args.searchQuery}' or fullText contains '${args.searchQuery}')`;
1616 |   }
1617 | 
1618 |   // Add date filter if provided
1619 |   if (args.modifiedAfter) {
1620 |     queryString += ` and modifiedTime > '${args.modifiedAfter}'`;
1621 |   }
1622 | 
1623 |   const response = await drive.files.list({
1624 |     q: queryString,
1625 |     pageSize: args.maxResults,
1626 |     orderBy: 'modifiedTime desc',
1627 |     fields: 'files(id,name,modifiedTime,createdTime,webViewLink,owners(displayName),parents)',
1628 |   });
1629 | 
1630 |   const files = response.data.files || [];
1631 | 
1632 |   if (files.length === 0) {
1633 |     return `No Google Docs found containing "${args.searchQuery}".`;
1634 |   }
1635 | 
1636 |   let result = `Found ${files.length} document(s) matching "${args.searchQuery}":\n\n`;
1637 |   files.forEach((file, index) => {
1638 |     const modifiedDate = file.modifiedTime ? new Date(file.modifiedTime).toLocaleDateString() : 'Unknown';
1639 |     const owner = file.owners?.[0]?.displayName || 'Unknown';
1640 |     result += `${index + 1}. **${file.name}**\n`;
1641 |     result += `   ID: ${file.id}\n`;
1642 |     result += `   Modified: ${modifiedDate}\n`;
1643 |     result += `   Owner: ${owner}\n`;
1644 |     result += `   Link: ${file.webViewLink}\n\n`;
1645 |   });
1646 | 
1647 |   return result;
1648 | } catch (error: any) {
1649 |   log.error(`Error searching Google Docs: ${error.message || error}`);
1650 |   if (error.code === 403) throw new UserError("Permission denied. Make sure you have granted Google Drive access to the application.");
1651 |   throw new UserError(`Failed to search documents: ${error.message || 'Unknown error'}`);
1652 | }
1653 | }
1654 | });
1655 | 
1656 | server.addTool({
1657 | name: 'getRecentGoogleDocs',
1658 | description: 'Gets the most recently modified Google Documents.',
1659 | parameters: z.object({
1660 |   maxResults: z.number().int().min(1).max(50).optional().default(10).describe('Maximum number of recent documents to return.'),
1661 |   daysBack: z.number().int().min(1).max(365).optional().default(30).describe('Only show documents modified within this many days.'),
1662 | }),
1663 | execute: async (args, { log }) => {
1664 | const drive = await getDriveClient();
1665 | log.info(`Getting recent Google Docs: ${args.maxResults} results, ${args.daysBack} days back`);
1666 | 
1667 | try {
1668 |   const cutoffDate = new Date();
1669 |   cutoffDate.setDate(cutoffDate.getDate() - args.daysBack);
1670 |   const cutoffDateStr = cutoffDate.toISOString();
1671 | 
1672 |   const queryString = `mimeType='application/vnd.google-apps.document' and trashed=false and modifiedTime > '${cutoffDateStr}'`;
1673 | 
1674 |   const response = await drive.files.list({
1675 |     q: queryString,
1676 |     pageSize: args.maxResults,
1677 |     orderBy: 'modifiedTime desc',
1678 |     fields: 'files(id,name,modifiedTime,createdTime,webViewLink,owners(displayName),lastModifyingUser(displayName))',
1679 |   });
1680 | 
1681 |   const files = response.data.files || [];
1682 | 
1683 |   if (files.length === 0) {
1684 |     return `No Google Docs found that were modified in the last ${args.daysBack} days.`;
1685 |   }
1686 | 
1687 |   let result = `${files.length} recently modified Google Document(s) (last ${args.daysBack} days):\n\n`;
1688 |   files.forEach((file, index) => {
1689 |     const modifiedDate = file.modifiedTime ? new Date(file.modifiedTime).toLocaleString() : 'Unknown';
1690 |     const lastModifier = file.lastModifyingUser?.displayName || 'Unknown';
1691 |     const owner = file.owners?.[0]?.displayName || 'Unknown';
1692 | 
1693 |     result += `${index + 1}. **${file.name}**\n`;
1694 |     result += `   ID: ${file.id}\n`;
1695 |     result += `   Last Modified: ${modifiedDate} by ${lastModifier}\n`;
1696 |     result += `   Owner: ${owner}\n`;
1697 |     result += `   Link: ${file.webViewLink}\n\n`;
1698 |   });
1699 | 
1700 |   return result;
1701 | } catch (error: any) {
1702 |   log.error(`Error getting recent Google Docs: ${error.message || error}`);
1703 |   if (error.code === 403) throw new UserError("Permission denied. Make sure you have granted Google Drive access to the application.");
1704 |   throw new UserError(`Failed to get recent documents: ${error.message || 'Unknown error'}`);
1705 | }
1706 | }
1707 | });
1708 | 
1709 | server.addTool({
1710 | name: 'getDocumentInfo',
1711 | description: 'Gets detailed information about a specific Google Document.',
1712 | parameters: DocumentIdParameter,
1713 | execute: async (args, { log }) => {
1714 | const drive = await getDriveClient();
1715 | log.info(`Getting info for document: ${args.documentId}`);
1716 | 
1717 | try {
1718 |   const response = await drive.files.get({
1719 |     fileId: args.documentId,
1720 |     fields: 'id,name,description,mimeType,size,createdTime,modifiedTime,webViewLink,alternateLink,owners(displayName,emailAddress),lastModifyingUser(displayName,emailAddress),shared,permissions(role,type,emailAddress),parents,version',
1721 |   });
1722 | 
1723 |   const file = response.data;
1724 | 
1725 |   if (!file) {
1726 |     throw new UserError(`Document with ID ${args.documentId} not found.`);
1727 |   }
1728 | 
1729 |   const createdDate = file.createdTime ? new Date(file.createdTime).toLocaleString() : 'Unknown';
1730 |   const modifiedDate = file.modifiedTime ? new Date(file.modifiedTime).toLocaleString() : 'Unknown';
1731 |   const owner = file.owners?.[0];
1732 |   const lastModifier = file.lastModifyingUser;
1733 | 
1734 |   let result = `**Document Information:**\n\n`;
1735 |   result += `**Name:** ${file.name}\n`;
1736 |   result += `**ID:** ${file.id}\n`;
1737 |   result += `**Type:** Google Document\n`;
1738 |   result += `**Created:** ${createdDate}\n`;
1739 |   result += `**Last Modified:** ${modifiedDate}\n`;
1740 | 
1741 |   if (owner) {
1742 |     result += `**Owner:** ${owner.displayName} (${owner.emailAddress})\n`;
1743 |   }
1744 | 
1745 |   if (lastModifier) {
1746 |     result += `**Last Modified By:** ${lastModifier.displayName} (${lastModifier.emailAddress})\n`;
1747 |   }
1748 | 
1749 |   result += `**Shared:** ${file.shared ? 'Yes' : 'No'}\n`;
1750 |   result += `**View Link:** ${file.webViewLink}\n`;
1751 | 
1752 |   if (file.description) {
1753 |     result += `**Description:** ${file.description}\n`;
1754 |   }
1755 | 
1756 |   return result;
1757 | } catch (error: any) {
1758 |   log.error(`Error getting document info: ${error.message || error}`);
1759 |   if (error.code === 404) throw new UserError(`Document not found (ID: ${args.documentId}).`);
1760 |   if (error.code === 403) throw new UserError("Permission denied. Make sure you have access to this document.");
1761 |   throw new UserError(`Failed to get document info: ${error.message || 'Unknown error'}`);
1762 | }
1763 | }
1764 | });
1765 | 
1766 | // === GOOGLE DRIVE FILE MANAGEMENT TOOLS ===
1767 | 
1768 | // --- Folder Management Tools ---
1769 | 
1770 | server.addTool({
1771 | name: 'createFolder',
1772 | description: 'Creates a new folder in Google Drive.',
1773 | parameters: z.object({
1774 |   name: z.string().min(1).describe('Name for the new folder.'),
1775 |   parentFolderId: z.string().optional().describe('Parent folder ID. If not provided, creates folder in Drive root.'),
1776 | }),
1777 | execute: async (args, { log }) => {
1778 | const drive = await getDriveClient();
1779 | log.info(`Creating folder "${args.name}" ${args.parentFolderId ? `in parent ${args.parentFolderId}` : 'in root'}`);
1780 | 
1781 | try {
1782 |   const folderMetadata: drive_v3.Schema$File = {
1783 |     name: args.name,
1784 |     mimeType: 'application/vnd.google-apps.folder',
1785 |   };
1786 | 
1787 |   if (args.parentFolderId) {
1788 |     folderMetadata.parents = [args.parentFolderId];
1789 |   }
1790 | 
1791 |   const response = await drive.files.create({
1792 |     requestBody: folderMetadata,
1793 |     fields: 'id,name,parents,webViewLink',
1794 |   });
1795 | 
1796 |   const folder = response.data;
1797 |   return `Successfully created folder "${folder.name}" (ID: ${folder.id})\nLink: ${folder.webViewLink}`;
1798 | } catch (error: any) {
1799 |   log.error(`Error creating folder: ${error.message || error}`);
1800 |   if (error.code === 404) throw new UserError("Parent folder not found. Check the parent folder ID.");
1801 |   if (error.code === 403) throw new UserError("Permission denied. Make sure you have write access to the parent folder.");
1802 |   throw new UserError(`Failed to create folder: ${error.message || 'Unknown error'}`);
1803 | }
1804 | }
1805 | });
1806 | 
1807 | server.addTool({
1808 | name: 'listFolderContents',
1809 | description: 'Lists the contents of a specific folder in Google Drive.',
1810 | parameters: z.object({
1811 |   folderId: z.string().describe('ID of the folder to list contents of. Use "root" for the root Drive folder.'),
1812 |   includeSubfolders: z.boolean().optional().default(true).describe('Whether to include subfolders in results.'),
1813 |   includeFiles: z.boolean().optional().default(true).describe('Whether to include files in results.'),
1814 |   maxResults: z.number().int().min(1).max(100).optional().default(50).describe('Maximum number of items to return.'),
1815 | }),
1816 | execute: async (args, { log }) => {
1817 | const drive = await getDriveClient();
1818 | log.info(`Listing contents of folder: ${args.folderId}`);
1819 | 
1820 | try {
1821 |   let queryString = `'${args.folderId}' in parents and trashed=false`;
1822 | 
1823 |   // Filter by type if specified
1824 |   if (!args.includeSubfolders && !args.includeFiles) {
1825 |     throw new UserError("At least one of includeSubfolders or includeFiles must be true.");
1826 |   }
1827 | 
1828 |   if (!args.includeSubfolders) {
1829 |     queryString += ` and mimeType!='application/vnd.google-apps.folder'`;
1830 |   } else if (!args.includeFiles) {
1831 |     queryString += ` and mimeType='application/vnd.google-apps.folder'`;
1832 |   }
1833 | 
1834 |   const response = await drive.files.list({
1835 |     q: queryString,
1836 |     pageSize: args.maxResults,
1837 |     orderBy: 'folder,name',
1838 |     fields: 'files(id,name,mimeType,size,modifiedTime,webViewLink,owners(displayName))',
1839 |   });
1840 | 
1841 |   const items = response.data.files || [];
1842 | 
1843 |   if (items.length === 0) {
1844 |     return "The folder is empty or you don't have permission to view its contents.";
1845 |   }
1846 | 
1847 |   let result = `Contents of folder (${items.length} item${items.length !== 1 ? 's' : ''}):\n\n`;
1848 | 
1849 |   // Separate folders and files
1850 |   const folders = items.filter(item => item.mimeType === 'application/vnd.google-apps.folder');
1851 |   const files = items.filter(item => item.mimeType !== 'application/vnd.google-apps.folder');
1852 | 
1853 |   // List folders first
1854 |   if (folders.length > 0 && args.includeSubfolders) {
1855 |     result += `**Folders (${folders.length}):**\n`;
1856 |     folders.forEach(folder => {
1857 |       result += `📁 ${folder.name} (ID: ${folder.id})\n`;
1858 |     });
1859 |     result += '\n';
1860 |   }
1861 | 
1862 |   // Then list files
1863 |   if (files.length > 0 && args.includeFiles) {
1864 |     result += `**Files (${files.length}):\n`;
1865 |     files.forEach(file => {
1866 |       const fileType = file.mimeType === 'application/vnd.google-apps.document' ? '📄' :
1867 |                       file.mimeType === 'application/vnd.google-apps.spreadsheet' ? '📊' :
1868 |                       file.mimeType === 'application/vnd.google-apps.presentation' ? '📈' : '📎';
1869 |       const modifiedDate = file.modifiedTime ? new Date(file.modifiedTime).toLocaleDateString() : 'Unknown';
1870 |       const owner = file.owners?.[0]?.displayName || 'Unknown';
1871 | 
1872 |       result += `${fileType} ${file.name}\n`;
1873 |       result += `   ID: ${file.id}\n`;
1874 |       result += `   Modified: ${modifiedDate} by ${owner}\n`;
1875 |       result += `   Link: ${file.webViewLink}\n\n`;
1876 |     });
1877 |   }
1878 | 
1879 |   return result;
1880 | } catch (error: any) {
1881 |   log.error(`Error listing folder contents: ${error.message || error}`);
1882 |   if (error.code === 404) throw new UserError("Folder not found. Check the folder ID.");
1883 |   if (error.code === 403) throw new UserError("Permission denied. Make sure you have access to this folder.");
1884 |   throw new UserError(`Failed to list folder contents: ${error.message || 'Unknown error'}`);
1885 | }
1886 | }
1887 | });
1888 | 
1889 | server.addTool({
1890 | name: 'getFolderInfo',
1891 | description: 'Gets detailed information about a specific folder in Google Drive.',
1892 | parameters: z.object({
1893 |   folderId: z.string().describe('ID of the folder to get information about.'),
1894 | }),
1895 | execute: async (args, { log }) => {
1896 | const drive = await getDriveClient();
1897 | log.info(`Getting folder info: ${args.folderId}`);
1898 | 
1899 | try {
1900 |   const response = await drive.files.get({
1901 |     fileId: args.folderId,
1902 |     fields: 'id,name,description,createdTime,modifiedTime,webViewLink,owners(displayName,emailAddress),lastModifyingUser(displayName),shared,parents',
1903 |   });
1904 | 
1905 |   const folder = response.data;
1906 | 
1907 |   if (folder.mimeType !== 'application/vnd.google-apps.folder') {
1908 |     throw new UserError("The specified ID does not belong to a folder.");
1909 |   }
1910 | 
1911 |   const createdDate = folder.createdTime ? new Date(folder.createdTime).toLocaleString() : 'Unknown';
1912 |   const modifiedDate = folder.modifiedTime ? new Date(folder.modifiedTime).toLocaleString() : 'Unknown';
1913 |   const owner = folder.owners?.[0];
1914 |   const lastModifier = folder.lastModifyingUser;
1915 | 
1916 |   let result = `**Folder Information:**\n\n`;
1917 |   result += `**Name:** ${folder.name}\n`;
1918 |   result += `**ID:** ${folder.id}\n`;
1919 |   result += `**Created:** ${createdDate}\n`;
1920 |   result += `**Last Modified:** ${modifiedDate}\n`;
1921 | 
1922 |   if (owner) {
1923 |     result += `**Owner:** ${owner.displayName} (${owner.emailAddress})\n`;
1924 |   }
1925 | 
1926 |   if (lastModifier) {
1927 |     result += `**Last Modified By:** ${lastModifier.displayName}\n`;
1928 |   }
1929 | 
1930 |   result += `**Shared:** ${folder.shared ? 'Yes' : 'No'}\n`;
1931 |   result += `**View Link:** ${folder.webViewLink}\n`;
1932 | 
1933 |   if (folder.description) {
1934 |     result += `**Description:** ${folder.description}\n`;
1935 |   }
1936 | 
1937 |   if (folder.parents && folder.parents.length > 0) {
1938 |     result += `**Parent Folder ID:** ${folder.parents[0]}\n`;
1939 |   }
1940 | 
1941 |   return result;
1942 | } catch (error: any) {
1943 |   log.error(`Error getting folder info: ${error.message || error}`);
1944 |   if (error.code === 404) throw new UserError(`Folder not found (ID: ${args.folderId}).`);
1945 |   if (error.code === 403) throw new UserError("Permission denied. Make sure you have access to this folder.");
1946 |   throw new UserError(`Failed to get folder info: ${error.message || 'Unknown error'}`);
1947 | }
1948 | }
1949 | });
1950 | 
1951 | // --- File Operation Tools ---
1952 | 
1953 | server.addTool({
1954 | name: 'moveFile',
1955 | description: 'Moves a file or folder to a different location in Google Drive.',
1956 | parameters: z.object({
1957 |   fileId: z.string().describe('ID of the file or folder to move.'),
1958 |   newParentId: z.string().describe('ID of the destination folder. Use "root" for Drive root.'),
1959 |   removeFromAllParents: z.boolean().optional().default(false).describe('If true, removes from all current parents. If false, adds to new parent while keeping existing parents.'),
1960 | }),
1961 | execute: async (args, { log }) => {
1962 | const drive = await getDriveClient();
1963 | log.info(`Moving file ${args.fileId} to folder ${args.newParentId}`);
1964 | 
1965 | try {
1966 |   // First get the current parents
1967 |   const fileInfo = await drive.files.get({
1968 |     fileId: args.fileId,
1969 |     fields: 'name,parents',
1970 |   });
1971 | 
1972 |   const fileName = fileInfo.data.name;
1973 |   const currentParents = fileInfo.data.parents || [];
1974 | 
1975 |   let updateParams: any = {
1976 |     fileId: args.fileId,
1977 |     addParents: args.newParentId,
1978 |     fields: 'id,name,parents',
1979 |   };
1980 | 
1981 |   if (args.removeFromAllParents && currentParents.length > 0) {
1982 |     updateParams.removeParents = currentParents.join(',');
1983 |   }
1984 | 
1985 |   const response = await drive.files.update(updateParams);
1986 | 
1987 |   const action = args.removeFromAllParents ? 'moved' : 'copied';
1988 |   return `Successfully ${action} "${fileName}" to new location.\nFile ID: ${response.data.id}`;
1989 | } catch (error: any) {
1990 |   log.error(`Error moving file: ${error.message || error}`);
1991 |   if (error.code === 404) throw new UserError("File or destination folder not found. Check the IDs.");
1992 |   if (error.code === 403) throw new UserError("Permission denied. Make sure you have write access to both source and destination.");
1993 |   throw new UserError(`Failed to move file: ${error.message || 'Unknown error'}`);
1994 | }
1995 | }
1996 | });
1997 | 
1998 | server.addTool({
1999 | name: 'copyFile',
2000 | description: 'Creates a copy of a Google Drive file or document.',
2001 | parameters: z.object({
2002 |   fileId: z.string().describe('ID of the file to copy.'),
2003 |   newName: z.string().optional().describe('Name for the copied file. If not provided, will use "Copy of [original name]".'),
2004 |   parentFolderId: z.string().optional().describe('ID of folder where copy should be placed. If not provided, places in same location as original.'),
2005 | }),
2006 | execute: async (args, { log }) => {
2007 | const drive = await getDriveClient();
2008 | log.info(`Copying file ${args.fileId} ${args.newName ? `as "${args.newName}"` : ''}`);
2009 | 
2010 | try {
2011 |   // Get original file info
2012 |   const originalFile = await drive.files.get({
2013 |     fileId: args.fileId,
2014 |     fields: 'name,parents',
2015 |   });
2016 | 
2017 |   const copyMetadata: drive_v3.Schema$File = {
2018 |     name: args.newName || `Copy of ${originalFile.data.name}`,
2019 |   };
2020 | 
2021 |   if (args.parentFolderId) {
2022 |     copyMetadata.parents = [args.parentFolderId];
2023 |   } else if (originalFile.data.parents) {
2024 |     copyMetadata.parents = originalFile.data.parents;
2025 |   }
2026 | 
2027 |   const response = await drive.files.copy({
2028 |     fileId: args.fileId,
2029 |     requestBody: copyMetadata,
2030 |     fields: 'id,name,webViewLink',
2031 |   });
2032 | 
2033 |   const copiedFile = response.data;
2034 |   return `Successfully created copy "${copiedFile.name}" (ID: ${copiedFile.id})\nLink: ${copiedFile.webViewLink}`;
2035 | } catch (error: any) {
2036 |   log.error(`Error copying file: ${error.message || error}`);
2037 |   if (error.code === 404) throw new UserError("Original file or destination folder not found. Check the IDs.");
2038 |   if (error.code === 403) throw new UserError("Permission denied. Make sure you have read access to the original file and write access to the destination.");
2039 |   throw new UserError(`Failed to copy file: ${error.message || 'Unknown error'}`);
2040 | }
2041 | }
2042 | });
2043 | 
2044 | server.addTool({
2045 | name: 'renameFile',
2046 | description: 'Renames a file or folder in Google Drive.',
2047 | parameters: z.object({
2048 |   fileId: z.string().describe('ID of the file or folder to rename.'),
2049 |   newName: z.string().min(1).describe('New name for the file or folder.'),
2050 | }),
2051 | execute: async (args, { log }) => {
2052 | const drive = await getDriveClient();
2053 | log.info(`Renaming file ${args.fileId} to "${args.newName}"`);
2054 | 
2055 | try {
2056 |   const response = await drive.files.update({
2057 |     fileId: args.fileId,
2058 |     requestBody: {
2059 |       name: args.newName,
2060 |     },
2061 |     fields: 'id,name,webViewLink',
2062 |   });
2063 | 
2064 |   const file = response.data;
2065 |   return `Successfully renamed to "${file.name}" (ID: ${file.id})\nLink: ${file.webViewLink}`;
2066 | } catch (error: any) {
2067 |   log.error(`Error renaming file: ${error.message || error}`);
2068 |   if (error.code === 404) throw new UserError("File not found. Check the file ID.");
2069 |   if (error.code === 403) throw new UserError("Permission denied. Make sure you have write access to this file.");
2070 |   throw new UserError(`Failed to rename file: ${error.message || 'Unknown error'}`);
2071 | }
2072 | }
2073 | });
2074 | 
2075 | server.addTool({
2076 | name: 'deleteFile',
2077 | description: 'Permanently deletes a file or folder from Google Drive.',
2078 | parameters: z.object({
2079 |   fileId: z.string().describe('ID of the file or folder to delete.'),
2080 |   skipTrash: z.boolean().optional().default(false).describe('If true, permanently deletes the file. If false, moves to trash (can be restored).'),
2081 | }),
2082 | execute: async (args, { log }) => {
2083 | const drive = await getDriveClient();
2084 | log.info(`Deleting file ${args.fileId} ${args.skipTrash ? '(permanent)' : '(to trash)'}`);
2085 | 
2086 | try {
2087 |   // Get file info before deletion
2088 |   const fileInfo = await drive.files.get({
2089 |     fileId: args.fileId,
2090 |     fields: 'name,mimeType',
2091 |   });
2092 | 
2093 |   const fileName = fileInfo.data.name;
2094 |   const isFolder = fileInfo.data.mimeType === 'application/vnd.google-apps.folder';
2095 | 
2096 |   if (args.skipTrash) {
2097 |     await drive.files.delete({
2098 |       fileId: args.fileId,
2099 |     });
2100 |     return `Permanently deleted ${isFolder ? 'folder' : 'file'} "${fileName}".`;
2101 |   } else {
2102 |     await drive.files.update({
2103 |       fileId: args.fileId,
2104 |       requestBody: {
2105 |         trashed: true,
2106 |       },
2107 |     });
2108 |     return `Moved ${isFolder ? 'folder' : 'file'} "${fileName}" to trash. It can be restored from the trash.`;
2109 |   }
2110 | } catch (error: any) {
2111 |   log.error(`Error deleting file: ${error.message || error}`);
2112 |   if (error.code === 404) throw new UserError("File not found. Check the file ID.");
2113 |   if (error.code === 403) throw new UserError("Permission denied. Make sure you have delete access to this file.");
2114 |   throw new UserError(`Failed to delete file: ${error.message || 'Unknown error'}`);
2115 | }
2116 | }
2117 | });
2118 | 
2119 | // --- Document Creation Tools ---
2120 | 
2121 | server.addTool({
2122 | name: 'createDocument',
2123 | description: 'Creates a new Google Document.',
2124 | parameters: z.object({
2125 |   title: z.string().min(1).describe('Title for the new document.'),
2126 |   parentFolderId: z.string().optional().describe('ID of folder where document should be created. If not provided, creates in Drive root.'),
2127 |   initialContent: z.string().optional().describe('Initial text content to add to the document.'),
2128 | }),
2129 | execute: async (args, { log }) => {
2130 | const drive = await getDriveClient();
2131 | log.info(`Creating new document "${args.title}"`);
2132 | 
2133 | try {
2134 |   const documentMetadata: drive_v3.Schema$File = {
2135 |     name: args.title,
2136 |     mimeType: 'application/vnd.google-apps.document',
2137 |   };
2138 | 
2139 |   if (args.parentFolderId) {
2140 |     documentMetadata.parents = [args.parentFolderId];
2141 |   }
2142 | 
2143 |   const response = await drive.files.create({
2144 |     requestBody: documentMetadata,
2145 |     fields: 'id,name,webViewLink',
2146 |   });
2147 | 
2148 |   const document = response.data;
2149 |   let result = `Successfully created document "${document.name}" (ID: ${document.id})\nView Link: ${document.webViewLink}`;
2150 | 
2151 |   // Add initial content if provided
2152 |   if (args.initialContent) {
2153 |     try {
2154 |       const docs = await getDocsClient();
2155 |       await docs.documents.batchUpdate({
2156 |         documentId: document.id!,
2157 |         requestBody: {
2158 |           requests: [{
2159 |             insertText: {
2160 |               location: { index: 1 },
2161 |               text: args.initialContent,
2162 |             },
2163 |           }],
2164 |         },
2165 |       });
2166 |       result += `\n\nInitial content added to document.`;
2167 |     } catch (contentError: any) {
2168 |       log.warn(`Document created but failed to add initial content: ${contentError.message}`);
2169 |       result += `\n\nDocument created but failed to add initial content. You can add content manually.`;
2170 |     }
2171 |   }
2172 | 
2173 |   return result;
2174 | } catch (error: any) {
2175 |   log.error(`Error creating document: ${error.message || error}`);
2176 |   if (error.code === 404) throw new UserError("Parent folder not found. Check the folder ID.");
2177 |   if (error.code === 403) throw new UserError("Permission denied. Make sure you have write access to the destination folder.");
2178 |   throw new UserError(`Failed to create document: ${error.message || 'Unknown error'}`);
2179 | }
2180 | }
2181 | });
2182 | 
2183 | server.addTool({
2184 | name: 'createFromTemplate',
2185 | description: 'Creates a new Google Document from an existing document template.',
2186 | parameters: z.object({
2187 |   templateId: z.string().describe('ID of the template document to copy from.'),
2188 |   newTitle: z.string().min(1).describe('Title for the new document.'),
2189 |   parentFolderId: z.string().optional().describe('ID of folder where document should be created. If not provided, creates in Drive root.'),
2190 |   replacements: z.record(z.string()).optional().describe('Key-value pairs for text replacements in the template (e.g., {"{{NAME}}": "John Doe", "{{DATE}}": "2024-01-01"}).'),
2191 | }),
2192 | execute: async (args, { log }) => {
2193 | const drive = await getDriveClient();
2194 | log.info(`Creating document from template ${args.templateId} with title "${args.newTitle}"`);
2195 | 
2196 | try {
2197 |   // First copy the template
2198 |   const copyMetadata: drive_v3.Schema$File = {
2199 |     name: args.newTitle,
2200 |   };
2201 | 
2202 |   if (args.parentFolderId) {
2203 |     copyMetadata.parents = [args.parentFolderId];
2204 |   }
2205 | 
2206 |   const response = await drive.files.copy({
2207 |     fileId: args.templateId,
2208 |     requestBody: copyMetadata,
2209 |     fields: 'id,name,webViewLink',
2210 |   });
2211 | 
2212 |   const document = response.data;
2213 |   let result = `Successfully created document "${document.name}" from template (ID: ${document.id})\nView Link: ${document.webViewLink}`;
2214 | 
2215 |   // Apply text replacements if provided
2216 |   if (args.replacements && Object.keys(args.replacements).length > 0) {
2217 |     try {
2218 |       const docs = await getDocsClient();
2219 |       const requests: docs_v1.Schema$Request[] = [];
2220 | 
2221 |       // Create replace requests for each replacement
2222 |       for (const [searchText, replaceText] of Object.entries(args.replacements)) {
2223 |         requests.push({
2224 |           replaceAllText: {
2225 |             containsText: {
2226 |               text: searchText,
2227 |               matchCase: false,
2228 |             },
2229 |             replaceText: replaceText,
2230 |           },
2231 |         });
2232 |       }
2233 | 
2234 |       if (requests.length > 0) {
2235 |         await docs.documents.batchUpdate({
2236 |           documentId: document.id!,
2237 |           requestBody: { requests },
2238 |         });
2239 | 
2240 |         const replacementCount = Object.keys(args.replacements).length;
2241 |         result += `\n\nApplied ${replacementCount} text replacement${replacementCount !== 1 ? 's' : ''} to the document.`;
2242 |       }
2243 |     } catch (replacementError: any) {
2244 |       log.warn(`Document created but failed to apply replacements: ${replacementError.message}`);
2245 |       result += `\n\nDocument created but failed to apply text replacements. You can make changes manually.`;
2246 |     }
2247 |   }
2248 | 
2249 |   return result;
2250 | } catch (error: any) {
2251 |   log.error(`Error creating document from template: ${error.message || error}`);
2252 |   if (error.code === 404) throw new UserError("Template document or parent folder not found. Check the IDs.");
2253 |   if (error.code === 403) throw new UserError("Permission denied. Make sure you have read access to the template and write access to the destination folder.");
2254 |   throw new UserError(`Failed to create document from template: ${error.message || 'Unknown error'}`);
2255 | }
2256 | }
2257 | });
2258 | 
2259 | // --- Server Startup ---
2260 | async function startServer() {
2261 | try {
2262 | await initializeGoogleClient(); // Authorize BEFORE starting listeners
2263 | console.error("Starting Ultimate Google Docs MCP server...");
2264 | 
2265 |       // Using stdio as before
2266 |       const configToUse = {
2267 |           transportType: "stdio" as const,
2268 |       };
2269 | 
2270 |       // Start the server with proper error handling
2271 |       server.start(configToUse);
2272 |       console.error(`MCP Server running using ${configToUse.transportType}. Awaiting client connection...`);
2273 | 
2274 |       // Log that error handling has been enabled
2275 |       console.error('Process-level error handling configured to prevent crashes from timeout errors.');
2276 | 
2277 | } catch(startError: any) {
2278 | console.error("FATAL: Server failed to start:", startError.message || startError);
2279 | process.exit(1);
2280 | }
2281 | }
2282 | 
2283 | startServer(); // Removed .catch here, let errors propagate if startup fails critically
2284 | 
2285 | ================
2286 | File: src/types.ts
2287 | ================
2288 | // src/types.ts
2289 | import { z } from 'zod';
2290 | import { docs_v1 } from 'googleapis';
2291 | 
2292 | // --- Helper function for hex color validation ---
2293 | export const hexColorRegex = /^#?([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/;
2294 | export const validateHexColor = (color: string) => hexColorRegex.test(color);
2295 | 
2296 | // --- Helper function for Hex to RGB conversion ---
2297 | export function hexToRgbColor(hex: string): docs_v1.Schema$RgbColor | null {
2298 | if (!hex) return null;
2299 | let hexClean = hex.startsWith('#') ? hex.slice(1) : hex;
2300 | 
2301 | if (hexClean.length === 3) {
2302 | hexClean = hexClean[0] + hexClean[0] + hexClean[1] + hexClean[1] + hexClean[2] + hexClean[2];
2303 | }
2304 | if (hexClean.length !== 6) return null;
2305 | const bigint = parseInt(hexClean, 16);
2306 | if (isNaN(bigint)) return null;
2307 | 
2308 | const r = ((bigint >> 16) & 255) / 255;
2309 | const g = ((bigint >> 8) & 255) / 255;
2310 | const b = (bigint & 255) / 255;
2311 | 
2312 | return { red: r, green: g, blue: b };
2313 | }
2314 | 
2315 | // --- Zod Schema Fragments for Reusability ---
2316 | 
2317 | export const DocumentIdParameter = z.object({
2318 | documentId: z.string().describe('The ID of the Google Document (from the URL).'),
2319 | });
2320 | 
2321 | export const RangeParameters = z.object({
2322 | startIndex: z.number().int().min(1).describe('The starting index of the text range (inclusive, starts from 1).'),
2323 | endIndex: z.number().int().min(1).describe('The ending index of the text range (exclusive).'),
2324 | }).refine(data => data.endIndex > data.startIndex, {
2325 | message: "endIndex must be greater than startIndex",
2326 | path: ["endIndex"],
2327 | });
2328 | 
2329 | export const OptionalRangeParameters = z.object({
2330 | startIndex: z.number().int().min(1).optional().describe('Optional: The starting index of the text range (inclusive, starts from 1). If omitted, might apply to a found element or whole paragraph.'),
2331 | endIndex: z.number().int().min(1).optional().describe('Optional: The ending index of the text range (exclusive). If omitted, might apply to a found element or whole paragraph.'),
2332 | }).refine(data => !data.startIndex || !data.endIndex || data.endIndex > data.startIndex, {
2333 | message: "If both startIndex and endIndex are provided, endIndex must be greater than startIndex",
2334 | path: ["endIndex"],
2335 | });
2336 | 
2337 | export const TextFindParameter = z.object({
2338 | textToFind: z.string().min(1).describe('The exact text string to locate.'),
2339 | matchInstance: z.number().int().min(1).optional().default(1).describe('Which instance of the text to target (1st, 2nd, etc.). Defaults to 1.'),
2340 | });
2341 | 
2342 | // --- Style Parameter Schemas ---
2343 | 
2344 | export const TextStyleParameters = z.object({
2345 | bold: z.boolean().optional().describe('Apply bold formatting.'),
2346 | italic: z.boolean().optional().describe('Apply italic formatting.'),
2347 | underline: z.boolean().optional().describe('Apply underline formatting.'),
2348 | strikethrough: z.boolean().optional().describe('Apply strikethrough formatting.'),
2349 | fontSize: z.number().min(1).optional().describe('Set font size (in points, e.g., 12).'),
2350 | fontFamily: z.string().optional().describe('Set font family (e.g., "Arial", "Times New Roman").'),
2351 | foregroundColor: z.string()
2352 | .refine(validateHexColor, { message: "Invalid hex color format (e.g., #FF0000 or #F00)" })
2353 | .optional()
2354 | .describe('Set text color using hex format (e.g., "#FF0000").'),
2355 | backgroundColor: z.string()
2356 | .refine(validateHexColor, { message: "Invalid hex color format (e.g., #00FF00 or #0F0)" })
2357 | .optional()
2358 | .describe('Set text background color using hex format (e.g., "#FFFF00").'),
2359 | linkUrl: z.string().url().optional().describe('Make the text a hyperlink pointing to this URL.'),
2360 | // clearDirectFormatting: z.boolean().optional().describe('If true, attempts to clear all direct text formatting within the range before applying new styles.') // Harder to implement perfectly
2361 | }).describe("Parameters for character-level text formatting.");
2362 | 
2363 | // Subset of TextStyle used for passing to helpers
2364 | export type TextStyleArgs = z.infer<typeof TextStyleParameters>;
2365 | 
2366 | export const ParagraphStyleParameters = z.object({
2367 | alignment: z.enum(['LEFT', 'CENTER', 'RIGHT', 'JUSTIFIED']).optional().describe('Paragraph alignment.'),
2368 | indentStart: z.number().min(0).optional().describe('Left indentation in points.'),
2369 | indentEnd: z.number().min(0).optional().describe('Right indentation in points.'),
2370 | spaceAbove: z.number().min(0).optional().describe('Space before the paragraph in points.'),
2371 | spaceBelow: z.number().min(0).optional().describe('Space after the paragraph in points.'),
2372 | namedStyleType: z.enum([
2373 | 'NORMAL_TEXT', 'TITLE', 'SUBTITLE',
2374 | 'HEADING_1', 'HEADING_2', 'HEADING_3', 'HEADING_4', 'HEADING_5', 'HEADING_6'
2375 | ]).optional().describe('Apply a built-in named paragraph style (e.g., HEADING_1).'),
2376 | keepWithNext: z.boolean().optional().describe('Keep this paragraph together with the next one on the same page.'),
2377 | // Borders are more complex, might need separate objects/tools
2378 | // clearDirectFormatting: z.boolean().optional().describe('If true, attempts to clear all direct paragraph formatting within the range before applying new styles.') // Harder to implement perfectly
2379 | }).describe("Parameters for paragraph-level formatting.");
2380 | 
2381 | // Subset of ParagraphStyle used for passing to helpers
2382 | export type ParagraphStyleArgs = z.infer<typeof ParagraphStyleParameters>;
2383 | 
2384 | // --- Combination Schemas for Tools ---
2385 | 
2386 | export const ApplyTextStyleToolParameters = DocumentIdParameter.extend({
2387 | // Target EITHER by range OR by finding text
2388 | target: z.union([
2389 | RangeParameters,
2390 | TextFindParameter
2391 | ]).describe("Specify the target range either by start/end indices or by finding specific text."),
2392 | style: TextStyleParameters.refine(
2393 | styleArgs => Object.values(styleArgs).some(v => v !== undefined),
2394 | { message: "At least one text style option must be provided." }
2395 | ).describe("The text styling to apply.")
2396 | });
2397 | export type ApplyTextStyleToolArgs = z.infer<typeof ApplyTextStyleToolParameters>;
2398 | 
2399 | export const ApplyParagraphStyleToolParameters = DocumentIdParameter.extend({
2400 | // Target EITHER by range OR by finding text (tool logic needs to find paragraph boundaries)
2401 | target: z.union([
2402 | RangeParameters, // User provides paragraph start/end (less likely)
2403 | TextFindParameter.extend({
2404 | applyToContainingParagraph: z.literal(true).default(true).describe("Must be true. Indicates the style applies to the whole paragraph containing the found text.")
2405 | }),
2406 | z.object({ // Target by specific index within the paragraph
2407 | indexWithinParagraph: z.number().int().min(1).describe("An index located anywhere within the target paragraph.")
2408 | })
2409 | ]).describe("Specify the target paragraph either by start/end indices, by finding text within it, or by providing an index within it."),
2410 | style: ParagraphStyleParameters.refine(
2411 | styleArgs => Object.values(styleArgs).some(v => v !== undefined),
2412 | { message: "At least one paragraph style option must be provided." }
2413 | ).describe("The paragraph styling to apply.")
2414 | });
2415 | export type ApplyParagraphStyleToolArgs = z.infer<typeof ApplyParagraphStyleToolParameters>;
2416 | 
2417 | // --- Error Class ---
2418 | // Use FastMCP's UserError for client-facing issues
2419 | // Define a custom error for internal issues if needed
2420 | export class NotImplementedError extends Error {
2421 | constructor(message = "This feature is not yet implemented.") {
2422 | super(message);
2423 | this.name = "NotImplementedError";
2424 | }
2425 | }
2426 | 
2427 | ================
2428 | File: tests/helpers.test.js
2429 | ================
2430 | // tests/helpers.test.js
2431 | import { findTextRange } from '../dist/googleDocsApiHelpers.js';
2432 | import assert from 'node:assert';
2433 | import { describe, it, mock } from 'node:test';
2434 | 
2435 | describe('Text Range Finding', () => {
2436 |   // Test hypothesis 1: Text range finding works correctly
2437 |   
2438 |   describe('findTextRange', () => {
2439 |     it('should find text within a single text run correctly', async () => {
2440 |       // Mock the docs.documents.get method to return a predefined structure
2441 |       const mockDocs = {
2442 |         documents: {
2443 |           get: mock.fn(async () => ({
2444 |             data: {
2445 |               body: {
2446 |                 content: [
2447 |                   {
2448 |                     paragraph: {
2449 |                       elements: [
2450 |                         {
2451 |                           startIndex: 1,
2452 |                           endIndex: 25,
2453 |                           textRun: {
2454 |                             content: 'This is a test sentence.'
2455 |                           }
2456 |                         }
2457 |                       ]
2458 |                     }
2459 |                   }
2460 |                 ]
2461 |               }
2462 |             }
2463 |           }))
2464 |         }
2465 |       };
2466 | 
2467 |       // Test finding "test" in the sample text
2468 |       const result = await findTextRange(mockDocs, 'doc123', 'test', 1);
2469 |       assert.deepStrictEqual(result, { startIndex: 11, endIndex: 15 });
2470 |       
2471 |       // Verify the docs.documents.get was called with the right parameters
2472 |       assert.strictEqual(mockDocs.documents.get.mock.calls.length, 1);
2473 |       assert.deepStrictEqual(
2474 |         mockDocs.documents.get.mock.calls[0].arguments[0], 
2475 |         {
2476 |           documentId: 'doc123',
2477 |           fields: 'body(content(paragraph(elements(startIndex,endIndex,textRun(content)))))'
2478 |         }
2479 |       );
2480 |     });
2481 |     
2482 |     it('should find the nth instance of text correctly', async () => {
2483 |       // Mock with a document that has repeated text
2484 |       const mockDocs = {
2485 |         documents: {
2486 |           get: mock.fn(async () => ({
2487 |             data: {
2488 |               body: {
2489 |                 content: [
2490 |                   {
2491 |                     paragraph: {
2492 |                       elements: [
2493 |                         {
2494 |                           startIndex: 1,
2495 |                           endIndex: 41,
2496 |                           textRun: {
2497 |                             content: 'Test test test. This is a test sentence.'
2498 |                           }
2499 |                         }
2500 |                       ]
2501 |                     }
2502 |                   }
2503 |                 ]
2504 |               }
2505 |             }
2506 |           }))
2507 |         }
2508 |       };
2509 | 
2510 |       // Find the 3rd instance of "test"
2511 |       const result = await findTextRange(mockDocs, 'doc123', 'test', 3);
2512 |       assert.deepStrictEqual(result, { startIndex: 27, endIndex: 31 });
2513 |     });
2514 | 
2515 |     it('should return null if text is not found', async () => {
2516 |       const mockDocs = {
2517 |         documents: {
2518 |           get: mock.fn(async () => ({
2519 |             data: {
2520 |               body: {
2521 |                 content: [
2522 |                   {
2523 |                     paragraph: {
2524 |                       elements: [
2525 |                         {
2526 |                           startIndex: 1,
2527 |                           endIndex: 25,
2528 |                           textRun: {
2529 |                             content: 'This is a sample sentence.'
2530 |                           }
2531 |                         }
2532 |                       ]
2533 |                     }
2534 |                   }
2535 |                 ]
2536 |               }
2537 |             }
2538 |           }))
2539 |         }
2540 |       };
2541 | 
2542 |       // Try to find text that doesn't exist
2543 |       const result = await findTextRange(mockDocs, 'doc123', 'test', 1);
2544 |       assert.strictEqual(result, null);
2545 |     });
2546 | 
2547 |     it('should handle text spanning multiple text runs', async () => {
2548 |       const mockDocs = {
2549 |         documents: {
2550 |           get: mock.fn(async () => ({
2551 |             data: {
2552 |               body: {
2553 |                 content: [
2554 |                   {
2555 |                     paragraph: {
2556 |                       elements: [
2557 |                         {
2558 |                           startIndex: 1,
2559 |                           endIndex: 6,
2560 |                           textRun: {
2561 |                             content: 'This '
2562 |                           }
2563 |                         },
2564 |                         {
2565 |                           startIndex: 6,
2566 |                           endIndex: 11,
2567 |                           textRun: {
2568 |                             content: 'is a '
2569 |                           }
2570 |                         },
2571 |                         {
2572 |                           startIndex: 11,
2573 |                           endIndex: 20,
2574 |                           textRun: {
2575 |                             content: 'test case'
2576 |                           }
2577 |                         }
2578 |                       ]
2579 |                     }
2580 |                   }
2581 |                 ]
2582 |               }
2583 |             }
2584 |           }))
2585 |         }
2586 |       };
2587 | 
2588 |       // Find text that spans runs: "a test"
2589 |       const result = await findTextRange(mockDocs, 'doc123', 'a test', 1);
2590 |       assert.deepStrictEqual(result, { startIndex: 9, endIndex: 15 });
2591 |     });
2592 |   });
2593 | });
2594 | 
2595 | ================
2596 | File: tests/types.test.js
2597 | ================
2598 | // tests/types.test.js
2599 | import { hexToRgbColor, validateHexColor } from '../dist/types.js';
2600 | import assert from 'node:assert';
2601 | import { describe, it } from 'node:test';
2602 | 
2603 | describe('Color Validation and Conversion', () => {
2604 |   // Test hypothesis 3: Hex color validation and conversion
2605 | 
2606 |   describe('validateHexColor', () => {
2607 |     it('should validate correct hex colors with hash', () => {
2608 |       assert.strictEqual(validateHexColor('#FF0000'), true); // 6 digits red
2609 |       assert.strictEqual(validateHexColor('#F00'), true);    // 3 digits red
2610 |       assert.strictEqual(validateHexColor('#00FF00'), true); // 6 digits green
2611 |       assert.strictEqual(validateHexColor('#0F0'), true);    // 3 digits green
2612 |     });
2613 | 
2614 |     it('should validate correct hex colors without hash', () => {
2615 |       assert.strictEqual(validateHexColor('FF0000'), true);  // 6 digits red
2616 |       assert.strictEqual(validateHexColor('F00'), true);     // 3 digits red
2617 |       assert.strictEqual(validateHexColor('00FF00'), true);  // 6 digits green
2618 |       assert.strictEqual(validateHexColor('0F0'), true);     // 3 digits green
2619 |     });
2620 | 
2621 |     it('should reject invalid hex colors', () => {
2622 |       assert.strictEqual(validateHexColor(''), false);        // Empty
2623 |       assert.strictEqual(validateHexColor('#XYZ'), false);    // Invalid characters
2624 |       assert.strictEqual(validateHexColor('#12345'), false);  // Invalid length (5)
2625 |       assert.strictEqual(validateHexColor('#1234567'), false);// Invalid length (7)
2626 |       assert.strictEqual(validateHexColor('invalid'), false); // Not a hex color
2627 |       assert.strictEqual(validateHexColor('#12'), false);     // Too short
2628 |     });
2629 |   });
2630 | 
2631 |   describe('hexToRgbColor', () => {
2632 |     it('should convert 6-digit hex colors with hash correctly', () => {
2633 |       const result = hexToRgbColor('#FF0000');
2634 |       assert.deepStrictEqual(result, { red: 1, green: 0, blue: 0 }); // Red
2635 |       
2636 |       const resultGreen = hexToRgbColor('#00FF00');
2637 |       assert.deepStrictEqual(resultGreen, { red: 0, green: 1, blue: 0 }); // Green
2638 |       
2639 |       const resultBlue = hexToRgbColor('#0000FF');
2640 |       assert.deepStrictEqual(resultBlue, { red: 0, green: 0, blue: 1 }); // Blue
2641 |       
2642 |       const resultPurple = hexToRgbColor('#800080');
2643 |       assert.deepStrictEqual(resultPurple, { red: 0.5019607843137255, green: 0, blue: 0.5019607843137255 }); // Purple
2644 |     });
2645 | 
2646 |     it('should convert 3-digit hex colors correctly', () => {
2647 |       const result = hexToRgbColor('#F00');
2648 |       assert.deepStrictEqual(result, { red: 1, green: 0, blue: 0 }); // Red from shorthand
2649 |       
2650 |       const resultWhite = hexToRgbColor('#FFF');
2651 |       assert.deepStrictEqual(resultWhite, { red: 1, green: 1, blue: 1 }); // White from shorthand
2652 |     });
2653 | 
2654 |     it('should convert hex colors without hash correctly', () => {
2655 |       const result = hexToRgbColor('FF0000');
2656 |       assert.deepStrictEqual(result, { red: 1, green: 0, blue: 0 }); // Red without hash
2657 |     });
2658 | 
2659 |     it('should return null for invalid hex colors', () => {
2660 |       assert.strictEqual(hexToRgbColor(''), null);        // Empty
2661 |       assert.strictEqual(hexToRgbColor('#XYZ'), null);    // Invalid characters
2662 |       assert.strictEqual(hexToRgbColor('#12345'), null);  // Invalid length
2663 |       assert.strictEqual(hexToRgbColor('invalid'), null); // Not a hex color
2664 |     });
2665 |   });
2666 | });
2667 | 
2668 | ================
2669 | File: .gitignore
2670 | ================
2671 | # Node dependencies
2672 | node_modules/
2673 | dist/
2674 | 
2675 | # Credentials / Tokens
2676 | credentials.json
2677 | token.json
2678 | 
2679 | # Log files
2680 | *.log
2681 | 
2682 | # Environment variables (if you use a .env file later)
2683 | .env*
2684 | 
2685 | ================
2686 | File: claude.md
2687 | ================
2688 | Okay, this is a significant undertaking. Building the "absolute best" server involves adding many complex features. I will provide:
2689 | The full code for a substantially enhanced server.ts and the necessary new helper files (googleDocsApiHelpers.ts, types.ts). This code will include implementations for several key advanced features discussed and well-defined stubs for others, indicating where further development would go.
2690 | Step-by-step instructions on how to integrate this new code into your existing project structure.
2691 | Disclaimer: This code is extensive and demonstrates the structure and approach for advanced features. It includes implementations for key examples but leaves many others as stubs (// TODO: Implement... or throwing NotImplementedError). Thorough testing and potentially significant refinement would be needed for production use, especially for complex logic like fixListFormatting or robust index handling across multiple edits. Error handling is included but could be further enhanced.
2692 | Phase 1: The Code
2693 | 
2694 | 1. New File: src/types.ts
2695 |    // src/types.ts
2696 |    import { z } from 'zod';
2697 |    import { docs_v1 } from 'googleapis';
2698 | 
2699 | // --- Helper function for hex color validation ---
2700 | export const hexColorRegex = /^#?([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/;
2701 | export const validateHexColor = (color: string) => hexColorRegex.test(color);
2702 | 
2703 | // --- Helper function for Hex to RGB conversion ---
2704 | export function hexToRgbColor(hex: string): docs_v1.Schema$RgbColor | null {
2705 | if (!hex) return null;
2706 | let hexClean = hex.startsWith('#') ? hex.slice(1) : hex;
2707 | 
2708 | if (hexClean.length === 3) {
2709 | hexClean = hexClean[0] + hexClean[0] + hexClean[1] + hexClean[1] + hexClean[2] + hexClean[2];
2710 | }
2711 | if (hexClean.length !== 6) return null;
2712 | const bigint = parseInt(hexClean, 16);
2713 | if (isNaN(bigint)) return null;
2714 | 
2715 | const r = ((bigint >> 16) & 255) / 255;
2716 | const g = ((bigint >> 8) & 255) / 255;
2717 | const b = (bigint & 255) / 255;
2718 | 
2719 | return { red: r, green: g, blue: b };
2720 | }
2721 | 
2722 | // --- Zod Schema Fragments for Reusability ---
2723 | 
2724 | export const DocumentIdParameter = z.object({
2725 | documentId: z.string().describe('The ID of the Google Document (from the URL).'),
2726 | });
2727 | 
2728 | export const RangeParameters = z.object({
2729 | startIndex: z.number().int().min(1).describe('The starting index of the text range (inclusive, starts from 1).'),
2730 | endIndex: z.number().int().min(1).describe('The ending index of the text range (exclusive).'),
2731 | }).refine(data => data.endIndex > data.startIndex, {
2732 | message: "endIndex must be greater than startIndex",
2733 | path: ["endIndex"],
2734 | });
2735 | 
2736 | export const OptionalRangeParameters = z.object({
2737 | startIndex: z.number().int().min(1).optional().describe('Optional: The starting index of the text range (inclusive, starts from 1). If omitted, might apply to a found element or whole paragraph.'),
2738 | endIndex: z.number().int().min(1).optional().describe('Optional: The ending index of the text range (exclusive). If omitted, might apply to a found element or whole paragraph.'),
2739 | }).refine(data => !data.startIndex || !data.endIndex || data.endIndex > data.startIndex, {
2740 | message: "If both startIndex and endIndex are provided, endIndex must be greater than startIndex",
2741 | path: ["endIndex"],
2742 | });
2743 | 
2744 | export const TextFindParameter = z.object({
2745 | textToFind: z.string().min(1).describe('The exact text string to locate.'),
2746 | matchInstance: z.number().int().min(1).optional().default(1).describe('Which instance of the text to target (1st, 2nd, etc.). Defaults to 1.'),
2747 | });
2748 | 
2749 | // --- Style Parameter Schemas ---
2750 | 
2751 | export const TextStyleParameters = z.object({
2752 | bold: z.boolean().optional().describe('Apply bold formatting.'),
2753 | italic: z.boolean().optional().describe('Apply italic formatting.'),
2754 | underline: z.boolean().optional().describe('Apply underline formatting.'),
2755 | strikethrough: z.boolean().optional().describe('Apply strikethrough formatting.'),
2756 | fontSize: z.number().min(1).optional().describe('Set font size (in points, e.g., 12).'),
2757 | fontFamily: z.string().optional().describe('Set font family (e.g., "Arial", "Times New Roman").'),
2758 | foregroundColor: z.string()
2759 | .refine(validateHexColor, { message: "Invalid hex color format (e.g., #FF0000 or #F00)" })
2760 | .optional()
2761 | .describe('Set text color using hex format (e.g., "#FF0000").'),
2762 | backgroundColor: z.string()
2763 | .refine(validateHexColor, { message: "Invalid hex color format (e.g., #00FF00 or #0F0)" })
2764 | .optional()
2765 | .describe('Set text background color using hex format (e.g., "#FFFF00").'),
2766 | linkUrl: z.string().url().optional().describe('Make the text a hyperlink pointing to this URL.'),
2767 | // clearDirectFormatting: z.boolean().optional().describe('If true, attempts to clear all direct text formatting within the range before applying new styles.') // Harder to implement perfectly
2768 | }).describe("Parameters for character-level text formatting.");
2769 | 
2770 | // Subset of TextStyle used for passing to helpers
2771 | export type TextStyleArgs = z.infer<typeof TextStyleParameters>;
2772 | 
2773 | export const ParagraphStyleParameters = z.object({
2774 | alignment: z.enum(['LEFT', 'CENTER', 'RIGHT', 'JUSTIFIED']).optional().describe('Paragraph alignment.'),
2775 | indentStart: z.number().min(0).optional().describe('Left indentation in points.'),
2776 | indentEnd: z.number().min(0).optional().describe('Right indentation in points.'),
2777 | spaceAbove: z.number().min(0).optional().describe('Space before the paragraph in points.'),
2778 | spaceBelow: z.number().min(0).optional().describe('Space after the paragraph in points.'),
2779 | namedStyleType: z.enum([
2780 | 'NORMAL_TEXT', 'TITLE', 'SUBTITLE',
2781 | 'HEADING_1', 'HEADING_2', 'HEADING_3', 'HEADING_4', 'HEADING_5', 'HEADING_6'
2782 | ]).optional().describe('Apply a built-in named paragraph style (e.g., HEADING_1).'),
2783 | keepWithNext: z.boolean().optional().describe('Keep this paragraph together with the next one on the same page.'),
2784 | // Borders are more complex, might need separate objects/tools
2785 | // clearDirectFormatting: z.boolean().optional().describe('If true, attempts to clear all direct paragraph formatting within the range before applying new styles.') // Harder to implement perfectly
2786 | }).describe("Parameters for paragraph-level formatting.");
2787 | 
2788 | // Subset of ParagraphStyle used for passing to helpers
2789 | export type ParagraphStyleArgs = z.infer<typeof ParagraphStyleParameters>;
2790 | 
2791 | // --- Combination Schemas for Tools ---
2792 | 
2793 | export const ApplyTextStyleToolParameters = DocumentIdParameter.extend({
2794 | // Target EITHER by range OR by finding text
2795 | target: z.union([
2796 | RangeParameters,
2797 | TextFindParameter
2798 | ]).describe("Specify the target range either by start/end indices or by finding specific text."),
2799 | style: TextStyleParameters.refine(
2800 | styleArgs => Object.values(styleArgs).some(v => v !== undefined),
2801 | { message: "At least one text style option must be provided." }
2802 | ).describe("The text styling to apply.")
2803 | });
2804 | export type ApplyTextStyleToolArgs = z.infer<typeof ApplyTextStyleToolParameters>;
2805 | 
2806 | export const ApplyParagraphStyleToolParameters = DocumentIdParameter.extend({
2807 | // Target EITHER by range OR by finding text (tool logic needs to find paragraph boundaries)
2808 | target: z.union([
2809 | RangeParameters, // User provides paragraph start/end (less likely)
2810 | TextFindParameter.extend({
2811 | applyToContainingParagraph: z.literal(true).default(true).describe("Must be true. Indicates the style applies to the whole paragraph containing the found text.")
2812 | }),
2813 | z.object({ // Target by specific index within the paragraph
2814 | indexWithinParagraph: z.number().int().min(1).describe("An index located anywhere within the target paragraph.")
2815 | })
2816 | ]).describe("Specify the target paragraph either by start/end indices, by finding text within it, or by providing an index within it."),
2817 | style: ParagraphStyleParameters.refine(
2818 | styleArgs => Object.values(styleArgs).some(v => v !== undefined),
2819 | { message: "At least one paragraph style option must be provided." }
2820 | ).describe("The paragraph styling to apply.")
2821 | });
2822 | export type ApplyParagraphStyleToolArgs = z.infer<typeof ApplyParagraphStyleToolParameters>;
2823 | 
2824 | // --- Error Class ---
2825 | // Use FastMCP's UserError for client-facing issues
2826 | // Define a custom error for internal issues if needed
2827 | export class NotImplementedError extends Error {
2828 | constructor(message = "This feature is not yet implemented.") {
2829 | super(message);
2830 | this.name = "NotImplementedError";
2831 | }
2832 | }
2833 | Use code with caution.
2834 | TypeScript 2. New File: src/googleDocsApiHelpers.ts
2835 | // src/googleDocsApiHelpers.ts
2836 | import { google, docs_v1 } from 'googleapis';
2837 | import { OAuth2Client } from 'google-auth-library';
2838 | import { UserError } from 'fastmcp';
2839 | import { TextStyleArgs, ParagraphStyleArgs, hexToRgbColor, NotImplementedError } from './types.js';
2840 | 
2841 | type Docs = docs_v1.Docs; // Alias for convenience
2842 | 
2843 | // --- Constants ---
2844 | const MAX_BATCH_UPDATE_REQUESTS = 50; // Google API limits batch size
2845 | 
2846 | // --- Core Helper to Execute Batch Updates ---
2847 | export async function executeBatchUpdate(docs: Docs, documentId: string, requests: docs_v1.Schema$Request[]): Promise<docs_v1.Schema$BatchUpdateDocumentResponse> {
2848 | if (!requests || requests.length === 0) {
2849 | // console.warn("executeBatchUpdate called with no requests.");
2850 | return {}; // Nothing to do
2851 | }
2852 | 
2853 |     // TODO: Consider splitting large request arrays into multiple batches if needed
2854 |     if (requests.length > MAX_BATCH_UPDATE_REQUESTS) {
2855 |          console.warn(`Attempting batch update with ${requests.length} requests, exceeding typical limits. May fail.`);
2856 |     }
2857 | 
2858 |     try {
2859 |         const response = await docs.documents.batchUpdate({
2860 |             documentId: documentId,
2861 |             requestBody: { requests },
2862 |         });
2863 |         return response.data;
2864 |     } catch (error: any) {
2865 |         console.error(`Google API batchUpdate Error for doc ${documentId}:`, error.response?.data || error.message);
2866 |         // Translate common API errors to UserErrors
2867 |         if (error.code === 400 && error.message.includes('Invalid requests')) {
2868 |              // Try to extract more specific info if available
2869 |              const details = error.response?.data?.error?.details;
2870 |              let detailMsg = '';
2871 |              if (details && Array.isArray(details)) {
2872 |                  detailMsg = details.map(d => d.description || JSON.stringify(d)).join('; ');
2873 |              }
2874 |             throw new UserError(`Invalid request sent to Google Docs API. Details: ${detailMsg || error.message}`);
2875 |         }
2876 |         if (error.code === 404) throw new UserError(`Document not found (ID: ${documentId}). Check the ID.`);
2877 |         if (error.code === 403) throw new UserError(`Permission denied for document (ID: ${documentId}). Ensure the authenticated user has edit access.`);
2878 |         // Generic internal error for others
2879 |         throw new Error(`Google API Error (${error.code}): ${error.message}`);
2880 |     }
2881 | 
2882 | }
2883 | 
2884 | // --- Text Finding Helper ---
2885 | // NOTE: This is a simplified version. A robust version needs to handle
2886 | // text spanning multiple TextRuns, pagination, tables etc.
2887 | export async function findTextRange(docs: Docs, documentId: string, textToFind: string, instance: number = 1): Promise<{ startIndex: number; endIndex: number } | null> {
2888 | try {
2889 | const res = await docs.documents.get({
2890 | documentId,
2891 | fields: 'body(content(paragraph(elements(startIndex,endIndex,textRun(content)))))',
2892 | });
2893 | 
2894 |         if (!res.data.body?.content) return null;
2895 | 
2896 |         let fullText = '';
2897 |         const segments: { text: string, start: number, end: number }[] = [];
2898 |         res.data.body.content.forEach(element => {
2899 |             element.paragraph?.elements?.forEach(pe => {
2900 |                 if (pe.textRun?.content && pe.startIndex && pe.endIndex) {
2901 |                     const content = pe.textRun.content;
2902 |                     fullText += content;
2903 |                     segments.push({ text: content, start: pe.startIndex, end: pe.endIndex });
2904 |                 }
2905 |             });
2906 |         });
2907 | 
2908 |         let startIndex = -1;
2909 |         let endIndex = -1;
2910 |         let foundCount = 0;
2911 |         let searchStartIndex = 0;
2912 | 
2913 |         while (foundCount < instance) {
2914 |             const currentIndex = fullText.indexOf(textToFind, searchStartIndex);
2915 |             if (currentIndex === -1) break;
2916 | 
2917 |             foundCount++;
2918 |             if (foundCount === instance) {
2919 |                 const targetStartInFullText = currentIndex;
2920 |                 const targetEndInFullText = currentIndex + textToFind.length;
2921 |                 let currentPosInFullText = 0;
2922 | 
2923 |                 for (const seg of segments) {
2924 |                     const segStartInFullText = currentPosInFullText;
2925 |                     const segTextLength = seg.text.length;
2926 |                     const segEndInFullText = segStartInFullText + segTextLength;
2927 | 
2928 |                     if (startIndex === -1 && targetStartInFullText >= segStartInFullText && targetStartInFullText < segEndInFullText) {
2929 |                         startIndex = seg.start + (targetStartInFullText - segStartInFullText);
2930 |                     }
2931 |                     if (targetEndInFullText > segStartInFullText && targetEndInFullText <= segEndInFullText) {
2932 |                         endIndex = seg.start + (targetEndInFullText - segStartInFullText);
2933 |                         break;
2934 |                     }
2935 |                     currentPosInFullText = segEndInFullText;
2936 |                 }
2937 | 
2938 |                 if (startIndex === -1 || endIndex === -1) { // Mapping failed for this instance
2939 |                     startIndex = -1; endIndex = -1;
2940 |                     // Continue searching from *after* this failed mapping attempt
2941 |                     searchStartIndex = currentIndex + 1;
2942 |                     foundCount--; // Decrement count as this instance wasn't successfully mapped
2943 |                     continue;
2944 |                 }
2945 |                 // Successfully mapped
2946 |                  return { startIndex, endIndex };
2947 |             }
2948 |              // Prepare for next search iteration
2949 |             searchStartIndex = currentIndex + 1;
2950 |         }
2951 | 
2952 |         return null; // Instance not found or mapping failed for all attempts
2953 |     } catch (error: any) {
2954 |         console.error(`Error finding text "${textToFind}" in doc ${documentId}: ${error.message}`);
2955 |          if (error.code === 404) throw new UserError(`Document not found while searching text (ID: ${documentId}).`);
2956 |          if (error.code === 403) throw new UserError(`Permission denied while searching text in doc (ID: ${documentId}).`);
2957 |         throw new Error(`Failed to retrieve doc for text searching: ${error.message}`);
2958 |     }
2959 | 
2960 | }
2961 | 
2962 | // --- Paragraph Boundary Helper ---
2963 | // Finds the paragraph containing a given index. Very simplified.
2964 | // A robust version needs to understand structural elements better.
2965 | export async function getParagraphRange(docs: Docs, documentId: string, indexWithin: number): Promise<{ startIndex: number; endIndex: number } | null> {
2966 | try {
2967 | const res = await docs.documents.get({
2968 | documentId,
2969 | // Request paragraph elements and their ranges
2970 | fields: 'body(content(startIndex,endIndex,paragraph))',
2971 | });
2972 | 
2973 |         if (!res.data.body?.content) return null;
2974 | 
2975 |         for (const element of res.data.body.content) {
2976 |             if (element.paragraph && element.startIndex && element.endIndex) {
2977 |                 // Check if the provided index falls within this paragraph element's range
2978 |                 // API ranges are typically [startIndex, endIndex)
2979 |                 if (indexWithin >= element.startIndex && indexWithin < element.endIndex) {
2980 |                     return { startIndex: element.startIndex, endIndex: element.endIndex };
2981 |                 }
2982 |             }
2983 |         }
2984 |         return null; // Index not found within any paragraph element
2985 | 
2986 |     } catch (error: any) {
2987 |         console.error(`Error getting paragraph range for index ${indexWithin} in doc ${documentId}: ${error.message}`);
2988 |          if (error.code === 404) throw new UserError(`Document not found while finding paragraph range (ID: ${documentId}).`);
2989 |          if (error.code === 403) throw new UserError(`Permission denied while finding paragraph range in doc (ID: ${documentId}).`);
2990 |         throw new Error(`Failed to retrieve doc for paragraph range finding: ${error.message}`);
2991 |     }
2992 | 
2993 | }
2994 | 
2995 | // --- Style Request Builders ---
2996 | 
2997 | export function buildUpdateTextStyleRequest(
2998 | startIndex: number,
2999 | endIndex: number,
3000 | style: TextStyleArgs
3001 | ): { request: docs_v1.Schema$Request, fields: string[] } | null {
3002 |     const textStyle: docs_v1.Schema$TextStyle = {};
3003 | const fieldsToUpdate: string[] = [];
3004 | 
3005 |     if (style.bold !== undefined) { textStyle.bold = style.bold; fieldsToUpdate.push('bold'); }
3006 |     if (style.italic !== undefined) { textStyle.italic = style.italic; fieldsToUpdate.push('italic'); }
3007 |     if (style.underline !== undefined) { textStyle.underline = style.underline; fieldsToUpdate.push('underline'); }
3008 |     if (style.strikethrough !== undefined) { textStyle.strikethrough = style.strikethrough; fieldsToUpdate.push('strikethrough'); }
3009 |     if (style.fontSize !== undefined) { textStyle.fontSize = { magnitude: style.fontSize, unit: 'PT' }; fieldsToUpdate.push('fontSize'); }
3010 |     if (style.fontFamily !== undefined) { textStyle.weightedFontFamily = { fontFamily: style.fontFamily }; fieldsToUpdate.push('weightedFontFamily'); }
3011 |     if (style.foregroundColor !== undefined) {
3012 |         const rgbColor = hexToRgbColor(style.foregroundColor);
3013 |         if (!rgbColor) throw new UserError(`Invalid foreground hex color format: ${style.foregroundColor}`);
3014 |         textStyle.foregroundColor = { color: { rgbColor: rgbColor } }; fieldsToUpdate.push('foregroundColor');
3015 |     }
3016 |      if (style.backgroundColor !== undefined) {
3017 |         const rgbColor = hexToRgbColor(style.backgroundColor);
3018 |         if (!rgbColor) throw new UserError(`Invalid background hex color format: ${style.backgroundColor}`);
3019 |         textStyle.backgroundColor = { color: { rgbColor: rgbColor } }; fieldsToUpdate.push('backgroundColor');
3020 |     }
3021 |     if (style.linkUrl !== undefined) {
3022 |         textStyle.link = { url: style.linkUrl }; fieldsToUpdate.push('link');
3023 |     }
3024 |     // TODO: Handle clearing formatting
3025 | 
3026 |     if (fieldsToUpdate.length === 0) return null; // No styles to apply
3027 | 
3028 |     const request: docs_v1.Schema$Request = {
3029 |         updateTextStyle: {
3030 |             range: { startIndex, endIndex },
3031 |             textStyle: textStyle,
3032 |             fields: fieldsToUpdate.join(','),
3033 |         }
3034 |     };
3035 |     return { request, fields: fieldsToUpdate };
3036 | 
3037 | }
3038 | 
3039 | export function buildUpdateParagraphStyleRequest(
3040 | startIndex: number,
3041 | endIndex: number,
3042 | style: ParagraphStyleArgs
3043 | ): { request: docs_v1.Schema$Request, fields: string[] } | null {
3044 |     const paragraphStyle: docs_v1.Schema$ParagraphStyle = {};
3045 | const fieldsToUpdate: string[] = [];
3046 | 
3047 |     if (style.alignment !== undefined) { paragraphStyle.alignment = style.alignment; fieldsToUpdate.push('alignment'); }
3048 |     if (style.indentStart !== undefined) { paragraphStyle.indentStart = { magnitude: style.indentStart, unit: 'PT' }; fieldsToUpdate.push('indentStart'); }
3049 |     if (style.indentEnd !== undefined) { paragraphStyle.indentEnd = { magnitude: style.indentEnd, unit: 'PT' }; fieldsToUpdate.push('indentEnd'); }
3050 |     if (style.spaceAbove !== undefined) { paragraphStyle.spaceAbove = { magnitude: style.spaceAbove, unit: 'PT' }; fieldsToUpdate.push('spaceAbove'); }
3051 |     if (style.spaceBelow !== undefined) { paragraphStyle.spaceBelow = { magnitude: style.spaceBelow, unit: 'PT' }; fieldsToUpdate.push('spaceBelow'); }
3052 |     if (style.namedStyleType !== undefined) { paragraphStyle.namedStyleType = style.namedStyleType; fieldsToUpdate.push('namedStyleType'); }
3053 |     if (style.keepWithNext !== undefined) { paragraphStyle.keepWithNext = style.keepWithNext; fieldsToUpdate.push('keepWithNext'); }
3054 |      // TODO: Handle borders, clearing formatting
3055 | 
3056 |     if (fieldsToUpdate.length === 0) return null; // No styles to apply
3057 | 
3058 |      const request: docs_v1.Schema$Request = {
3059 |         updateParagraphStyle: {
3060 |             range: { startIndex, endIndex },
3061 |             paragraphStyle: paragraphStyle,
3062 |             fields: fieldsToUpdate.join(','),
3063 |         }
3064 |     };
3065 |      return { request, fields: fieldsToUpdate };
3066 | 
3067 | }
3068 | 
3069 | // --- Specific Feature Helpers ---
3070 | 
3071 | export async function createTable(docs: Docs, documentId: string, rows: number, columns: number, index: number): Promise<docs_v1.Schema$BatchUpdateDocumentResponse> {
3072 |     if (rows < 1 || columns < 1) {
3073 |         throw new UserError("Table must have at least 1 row and 1 column.");
3074 |     }
3075 |     const request: docs_v1.Schema$Request = {
3076 | insertTable: {
3077 | location: { index },
3078 | rows: rows,
3079 | columns: columns,
3080 | }
3081 | };
3082 | return executeBatchUpdate(docs, documentId, [request]);
3083 | }
3084 | 
3085 | export async function insertText(docs: Docs, documentId: string, text: string, index: number): Promise<docs_v1.Schema$BatchUpdateDocumentResponse> {
3086 |     if (!text) return {}; // Nothing to insert
3087 |     const request: docs_v1.Schema$Request = {
3088 | insertText: {
3089 | location: { index },
3090 | text: text,
3091 | }
3092 | };
3093 | return executeBatchUpdate(docs, documentId, [request]);
3094 | }
3095 | 
3096 | // --- Complex / Stubbed Helpers ---
3097 | 
3098 | export async function findParagraphsMatchingStyle(
3099 | docs: Docs,
3100 | documentId: string,
3101 | styleCriteria: any // Define a proper type for criteria (e.g., { fontFamily: 'Arial', bold: true })
3102 | ): Promise<{ startIndex: number; endIndex: number }[]> {
3103 | // TODO: Implement logic
3104 | // 1. Get document content with paragraph elements and their styles.
3105 | // 2. Iterate through paragraphs.
3106 | // 3. For each paragraph, check if its computed style matches the criteria.
3107 | // 4. Return ranges of matching paragraphs.
3108 | console.warn("findParagraphsMatchingStyle is not implemented.");
3109 | throw new NotImplementedError("Finding paragraphs by style criteria is not yet implemented.");
3110 | // return [];
3111 | }
3112 | 
3113 | export async function detectAndFormatLists(
3114 | docs: Docs,
3115 | documentId: string,
3116 | startIndex?: number,
3117 | endIndex?: number
3118 | ): Promise<docs_v1.Schema$BatchUpdateDocumentResponse> {
3119 | // TODO: Implement complex logic
3120 | // 1. Get document content (paragraphs, text runs) in the specified range (or whole doc).
3121 | // 2. Iterate through paragraphs.
3122 | // 3. Identify sequences of paragraphs starting with list-like markers (e.g., "-", "\*", "1.", "a)").
3123 | // 4. Determine nesting levels based on indentation or marker patterns.
3124 | // 5. Generate CreateParagraphBulletsRequests for the identified sequences.
3125 | // 6. Potentially delete the original marker text.
3126 | // 7. Execute the batch update.
3127 | console.warn("detectAndFormatLists is not implemented.");
3128 | throw new NotImplementedError("Automatic list detection and formatting is not yet implemented.");
3129 | // return {};
3130 | }
3131 | 
3132 | export async function addCommentHelper(docs: Docs, documentId: string, text: string, startIndex: number, endIndex: number): Promise<void> {
3133 | // NOTE: Adding comments typically requires the Google Drive API v3 and different scopes!
3134 | // 'https://www.googleapis.com/auth/drive' or more specific comment scopes.
3135 | // This helper is a placeholder assuming Drive API client (`drive`) is available and authorized.
3136 | /_
3137 | const drive = google.drive({version: 'v3', auth: authClient}); // Assuming authClient is available
3138 | await drive.comments.create({
3139 | fileId: documentId,
3140 | requestBody: {
3141 | content: text,
3142 | anchor: JSON.stringify({ // Anchor format might need verification
3143 | 'type': 'workbook#textAnchor', // Or appropriate type for Docs
3144 | 'refs': [{
3145 | 'docRevisionId': 'head', // Or specific revision
3146 | 'range': {
3147 | 'start': startIndex,
3148 | 'end': endIndex,
3149 | }
3150 | }]
3151 | })
3152 | },
3153 | fields: 'id'
3154 | });
3155 | _/
3156 | console.warn("addCommentHelper requires Google Drive API and is not implemented.");
3157 | throw new NotImplementedError("Adding comments requires Drive API setup and is not yet implemented.");
3158 | }
3159 | 
3160 | // Add more helpers as needed...
3161 | Use code with caution.
3162 | TypeScript 3. Updated File: src/server.ts (Replace the entire content with this)
3163 | // src/server.ts
3164 | import { FastMCP, UserError } from 'fastmcp';
3165 | import { z } from 'zod';
3166 | import { google, docs_v1 } from 'googleapis';
3167 | import { authorize } from './auth.js';
3168 | import { OAuth2Client } from 'google-auth-library';
3169 | 
3170 | // Import types and helpers
3171 | import {
3172 | DocumentIdParameter,
3173 | RangeParameters,
3174 | OptionalRangeParameters,
3175 | TextFindParameter,
3176 | TextStyleParameters,
3177 | TextStyleArgs,
3178 | ParagraphStyleParameters,
3179 | ParagraphStyleArgs,
3180 | ApplyTextStyleToolParameters, ApplyTextStyleToolArgs,
3181 | ApplyParagraphStyleToolParameters, ApplyParagraphStyleToolArgs,
3182 | NotImplementedError
3183 | } from './types.js';
3184 | import \* as GDocsHelpers from './googleDocsApiHelpers.js';
3185 | 
3186 | let authClient: OAuth2Client | null = null;
3187 | let googleDocs: docs_v1.Docs | null = null;
3188 | 
3189 | // --- Initialization ---
3190 | async function initializeGoogleClient() {
3191 | if (googleDocs) return { authClient, googleDocs };
3192 | if (!authClient) { // Check authClient instead of googleDocs to allow re-attempt
3193 | try {
3194 | console.error("Attempting to authorize Google API client...");
3195 | const client = await authorize();
3196 | authClient = client; // Assign client here
3197 | googleDocs = google.docs({ version: 'v1', auth: authClient });
3198 | console.error("Google API client authorized successfully.");
3199 | } catch (error) {
3200 | console.error("FATAL: Failed to initialize Google API client:", error);
3201 | authClient = null; // Reset on failure
3202 | googleDocs = null;
3203 | // Decide if server should exit or just fail tools
3204 | throw new Error("Google client initialization failed. Cannot start server tools.");
3205 | }
3206 | }
3207 | // Ensure googleDocs is set if authClient is valid
3208 | if (authClient && !googleDocs) {
3209 | googleDocs = google.docs({ version: 'v1', auth: authClient });
3210 | }
3211 | 
3212 | if (!googleDocs) {
3213 | throw new Error("Google Docs client could not be initialized.");
3214 | }
3215 | 
3216 | return { authClient, googleDocs };
3217 | }
3218 | 
3219 | const server = new FastMCP({
3220 | name: 'Ultimate Google Docs MCP Server',
3221 | version: '2.0.0', // Version bump!
3222 | description: 'Provides advanced tools for reading, editing, formatting, and managing Google Documents.'
3223 | });
3224 | 
3225 | // --- Helper to get Docs client within tools ---
3226 | async function getDocsClient() {
3227 | const { googleDocs: docs } = await initializeGoogleClient();
3228 | if (!docs) {
3229 | throw new UserError("Google Docs client is not initialized. Authentication might have failed during startup or lost connection.");
3230 | }
3231 | return docs;
3232 | }
3233 | 
3234 | // === TOOL DEFINITIONS ===
3235 | 
3236 | // --- Foundational Tools ---
3237 | 
3238 | server.addTool({
3239 | name: 'readGoogleDoc',
3240 | description: 'Reads the content of a specific Google Document, optionally returning structured data.',
3241 | parameters: DocumentIdParameter.extend({
3242 | format: z.enum(['text', 'json', 'markdown']).optional().default('text')
3243 | .describe("Output format: 'text' (plain text, possibly truncated), 'json' (raw API structure, complex), 'markdown' (experimental conversion).")
3244 | }),
3245 | execute: async (args, { log }) => {
3246 | const docs = await getDocsClient();
3247 | log.info(`Reading Google Doc: ${args.documentId}, Format: ${args.format}`);
3248 | 
3249 |     try {
3250 |         const fields = args.format === 'json' || args.format === 'markdown'
3251 |             ? '*' // Get everything for structure analysis
3252 |             : 'body(content(paragraph(elements(textRun(content)))))'; // Just text content
3253 | 
3254 |         const res = await docs.documents.get({
3255 |             documentId: args.documentId,
3256 |             fields: fields,
3257 |         });
3258 |         log.info(`Fetched doc: ${args.documentId}`);
3259 | 
3260 |         if (args.format === 'json') {
3261 |             return JSON.stringify(res.data, null, 2); // Return raw structure
3262 |         }
3263 | 
3264 |         if (args.format === 'markdown') {
3265 |             // TODO: Implement Markdown conversion logic (complex)
3266 |             log.warn("Markdown conversion is not implemented yet.");
3267 |              throw new NotImplementedError("Markdown output format is not yet implemented.");
3268 |             // return convertDocsJsonToMarkdown(res.data);
3269 |         }
3270 | 
3271 |         // Default: Text format
3272 |         let textContent = '';
3273 |         res.data.body?.content?.forEach(element => {
3274 |             element.paragraph?.elements?.forEach(pe => {
3275 |             textContent += pe.textRun?.content || '';
3276 |             });
3277 |         });
3278 | 
3279 |         if (!textContent.trim()) return "Document found, but appears empty.";
3280 | 
3281 |         // Basic truncation for text mode
3282 |         const maxLength = 4000; // Increased limit
3283 |         const truncatedContent = textContent.length > maxLength ? textContent.substring(0, maxLength) + `... [truncated ${textContent.length} chars]` : textContent;
3284 |         return `Content:\n---\n${truncatedContent}`;
3285 | 
3286 |     } catch (error: any) {
3287 |          log.error(`Error reading doc ${args.documentId}: ${error.message || error}`);
3288 |          // Handle errors thrown by helpers or API directly
3289 |          if (error instanceof UserError) throw error;
3290 |          if (error instanceof NotImplementedError) throw error;
3291 |          // Generic fallback for API errors not caught by helpers
3292 |           if (error.code === 404) throw new UserError(`Doc not found (ID: ${args.documentId}).`);
3293 |           if (error.code === 403) throw new UserError(`Permission denied for doc (ID: ${args.documentId}).`);
3294 |          throw new UserError(`Failed to read doc: ${error.message || 'Unknown error'}`);
3295 |     }
3296 | 
3297 | },
3298 | });
3299 | 
3300 | server.addTool({
3301 | name: 'appendToGoogleDoc',
3302 | description: 'Appends text to the very end of a specific Google Document.',
3303 | parameters: DocumentIdParameter.extend({
3304 | textToAppend: z.string().min(1).describe('The text to add to the end.'),
3305 | addNewlineIfNeeded: z.boolean().optional().default(true).describe("Automatically add a newline before the appended text if the doc doesn't end with one."),
3306 | }),
3307 | execute: async (args, { log }) => {
3308 | const docs = await getDocsClient();
3309 | log.info(`Appending to Google Doc: ${args.documentId}`);
3310 | 
3311 |     try {
3312 |         // Get the current end index
3313 |         const docInfo = await docs.documents.get({ documentId: args.documentId, fields: 'body(content(endIndex)),documentStyle(pageSize)' }); // Need content for endIndex
3314 |         let endIndex = 1;
3315 |         let lastCharIsNewline = false;
3316 |         if (docInfo.data.body?.content) {
3317 |             const lastElement = docInfo.data.body.content[docInfo.data.body.content.length - 1];
3318 |              if (lastElement?.endIndex) {
3319 |                 endIndex = lastElement.endIndex -1; // Insert *before* the final newline of the doc typically
3320 |                 // Crude check for last character (better check would involve reading last text run)
3321 |                  // const lastTextRun = ... find last text run ...
3322 |                  // if (lastTextRun?.content?.endsWith('\n')) lastCharIsNewline = true;
3323 |             }
3324 |         }
3325 |         // Simpler approach: Always assume insertion is needed unless explicitly told not to add newline
3326 |         const textToInsert = (args.addNewlineIfNeeded && endIndex > 1 ? '\n' : '') + args.textToAppend;
3327 | 
3328 |         if (!textToInsert) return "Nothing to append.";
3329 | 
3330 |         const request: docs_v1.Schema$Request = { insertText: { location: { index: endIndex }, text: textToInsert } };
3331 |         await GDocsHelpers.executeBatchUpdate(docs, args.documentId, [request]);
3332 | 
3333 |         log.info(`Successfully appended to doc: ${args.documentId}`);
3334 |         return `Successfully appended text to document ${args.documentId}.`;
3335 |     } catch (error: any) {
3336 |          log.error(`Error appending to doc ${args.documentId}: ${error.message || error}`);
3337 |          if (error instanceof UserError) throw error;
3338 |          if (error instanceof NotImplementedError) throw error;
3339 |          throw new UserError(`Failed to append to doc: ${error.message || 'Unknown error'}`);
3340 |     }
3341 | 
3342 | },
3343 | });
3344 | 
3345 | server.addTool({
3346 | name: 'insertText',
3347 | description: 'Inserts text at a specific index within the document body.',
3348 | parameters: DocumentIdParameter.extend({
3349 | textToInsert: z.string().min(1).describe('The text to insert.'),
3350 | index: z.number().int().min(1).describe('The index (1-based) where the text should be inserted.'),
3351 | }),
3352 | execute: async (args, { log }) => {
3353 | const docs = await getDocsClient();
3354 | log.info(`Inserting text in doc ${args.documentId} at index ${args.index}`);
3355 | try {
3356 | await GDocsHelpers.insertText(docs, args.documentId, args.textToInsert, args.index);
3357 | return `Successfully inserted text at index ${args.index}.`;
3358 | } catch (error: any) {
3359 | log.error(`Error inserting text in doc ${args.documentId}: ${error.message || error}`);
3360 | if (error instanceof UserError) throw error;
3361 | throw new UserError(`Failed to insert text: ${error.message || 'Unknown error'}`);
3362 | }
3363 | }
3364 | });
3365 | 
3366 | server.addTool({
3367 | name: 'deleteRange',
3368 | description: 'Deletes content within a specified range (start index inclusive, end index exclusive).',
3369 | parameters: DocumentIdParameter.extend(RangeParameters.shape), // Use shape to avoid refine conflict if needed
3370 | execute: async (args, { log }) => {
3371 | const docs = await getDocsClient();
3372 | log.info(`Deleting range ${args.startIndex}-${args.endIndex} in doc ${args.documentId}`);
3373 | if (args.endIndex <= args.startIndex) {
3374 | throw new UserError("End index must be greater than start index for deletion.");
3375 | }
3376 | try {
3377 | const request: docs_v1.Schema$Request = {
3378 |                 deleteContentRange: {
3379 |                     range: { startIndex: args.startIndex, endIndex: args.endIndex }
3380 |                 }
3381 |             };
3382 |             await GDocsHelpers.executeBatchUpdate(docs, args.documentId, [request]);
3383 |             return `Successfully deleted content in range ${args.startIndex}-${args.endIndex}.`;
3384 |         } catch (error: any) {
3385 |             log.error(`Error deleting range in doc ${args.documentId}: ${error.message || error}`);
3386 |             if (error instanceof UserError) throw error;
3387 |             throw new UserError(`Failed to delete range: ${error.message || 'Unknown error'}`);
3388 | }
3389 | }
3390 | });
3391 | 
3392 | // --- Advanced Formatting & Styling Tools ---
3393 | 
3394 | server.addTool({
3395 | name: 'applyTextStyle',
3396 | description: 'Applies character-level formatting (bold, color, font, etc.) to a specific range or found text.',
3397 | parameters: ApplyTextStyleToolParameters,
3398 | execute: async (args: ApplyTextStyleToolArgs, { log }) => {
3399 | const docs = await getDocsClient();
3400 | let { startIndex, endIndex } = args.target as any; // Will be updated if target is text
3401 | 
3402 |         log.info(`Applying text style in doc ${args.documentId}. Target: ${JSON.stringify(args.target)}, Style: ${JSON.stringify(args.style)}`);
3403 | 
3404 |         try {
3405 |             // Determine target range
3406 |             if ('textToFind' in args.target) {
3407 |                 const range = await GDocsHelpers.findTextRange(docs, args.documentId, args.target.textToFind, args.target.matchInstance);
3408 |                 if (!range) {
3409 |                     throw new UserError(`Could not find instance ${args.target.matchInstance} of text "${args.target.textToFind}".`);
3410 |                 }
3411 |                 startIndex = range.startIndex;
3412 |                 endIndex = range.endIndex;
3413 |                 log.info(`Found text "${args.target.textToFind}" (instance ${args.target.matchInstance}) at range ${startIndex}-${endIndex}`);
3414 |             }
3415 | 
3416 |             if (startIndex === undefined || endIndex === undefined) {
3417 |                  throw new UserError("Target range could not be determined.");
3418 |             }
3419 |              if (endIndex <= startIndex) {
3420 |                  throw new UserError("End index must be greater than start index for styling.");
3421 |             }
3422 | 
3423 |             // Build the request
3424 |             const requestInfo = GDocsHelpers.buildUpdateTextStyleRequest(startIndex, endIndex, args.style);
3425 |             if (!requestInfo) {
3426 |                  return "No valid text styling options were provided.";
3427 |             }
3428 | 
3429 |             await GDocsHelpers.executeBatchUpdate(docs, args.documentId, [requestInfo.request]);
3430 |             return `Successfully applied text style (${requestInfo.fields.join(', ')}) to range ${startIndex}-${endIndex}.`;
3431 | 
3432 |         } catch (error: any) {
3433 |             log.error(`Error applying text style in doc ${args.documentId}: ${error.message || error}`);
3434 |             if (error instanceof UserError) throw error;
3435 |             if (error instanceof NotImplementedError) throw error; // Should not happen here
3436 |             throw new UserError(`Failed to apply text style: ${error.message || 'Unknown error'}`);
3437 |         }
3438 |     }
3439 | 
3440 | });
3441 | 
3442 | server.addTool({
3443 | name: 'applyParagraphStyle',
3444 | description: 'Applies paragraph-level formatting (alignment, spacing, named styles like Heading 1) to the paragraph(s) containing specific text, an index, or a range.',
3445 | parameters: ApplyParagraphStyleToolParameters,
3446 | execute: async (args: ApplyParagraphStyleToolArgs, { log }) => {
3447 | const docs = await getDocsClient();
3448 | let { startIndex, endIndex } = args.target as any; // Will be updated
3449 | 
3450 |         log.info(`Applying paragraph style in doc ${args.documentId}. Target: ${JSON.stringify(args.target)}, Style: ${JSON.stringify(args.style)}`);
3451 | 
3452 |         try {
3453 |              // Determine target paragraph range
3454 |              let targetIndexForLookup: number | undefined;
3455 | 
3456 |              if ('textToFind' in args.target) {
3457 |                 const range = await GDocsHelpers.findTextRange(docs, args.documentId, args.target.textToFind, args.target.matchInstance);
3458 |                 if (!range) {
3459 |                     throw new UserError(`Could not find instance ${args.target.matchInstance} of text "${args.target.textToFind}" to locate paragraph.`);
3460 |                 }
3461 |                 targetIndexForLookup = range.startIndex; // Use the start index of found text
3462 |                 log.info(`Found text "${args.target.textToFind}" at index ${targetIndexForLookup} to locate paragraph.`);
3463 |             } else if ('indexWithinParagraph' in args.target) {
3464 |                  targetIndexForLookup = args.target.indexWithinParagraph;
3465 |             } else if ('startIndex' in args.target && 'endIndex' in args.target) {
3466 |                  // User provided a range, assume it's the paragraph range
3467 |                  startIndex = args.target.startIndex;
3468 |                  endIndex = args.target.endIndex;
3469 |                  log.info(`Using provided range ${startIndex}-${endIndex} for paragraph style.`);
3470 |             }
3471 | 
3472 |             // If we need to find the paragraph boundaries based on an index within it
3473 |             if (targetIndexForLookup !== undefined && (startIndex === undefined || endIndex === undefined)) {
3474 |                  const paragraphRange = await GDocsHelpers.getParagraphRange(docs, args.documentId, targetIndexForLookup);
3475 |                  if (!paragraphRange) {
3476 |                      throw new UserError(`Could not determine paragraph boundaries containing index ${targetIndexForLookup}.`);
3477 |                  }
3478 |                  startIndex = paragraphRange.startIndex;
3479 |                  endIndex = paragraphRange.endIndex;
3480 |                  log.info(`Determined paragraph range as ${startIndex}-${endIndex} based on index ${targetIndexForLookup}.`);
3481 |             }
3482 | 
3483 | 
3484 |             if (startIndex === undefined || endIndex === undefined) {
3485 |                  throw new UserError("Target paragraph range could not be determined.");
3486 |             }
3487 |              if (endIndex <= startIndex) {
3488 |                  throw new UserError("Paragraph end index must be greater than start index for styling.");
3489 |             }
3490 | 
3491 |             // Build the request
3492 |             const requestInfo = GDocsHelpers.buildUpdateParagraphStyleRequest(startIndex, endIndex, args.style);
3493 |              if (!requestInfo) {
3494 |                  return "No valid paragraph styling options were provided.";
3495 |             }
3496 | 
3497 |             await GDocsHelpers.executeBatchUpdate(docs, args.documentId, [requestInfo.request]);
3498 |             return `Successfully applied paragraph style (${requestInfo.fields.join(', ')}) to range ${startIndex}-${endIndex}.`;
3499 | 
3500 |         } catch (error: any) {
3501 |             log.error(`Error applying paragraph style in doc ${args.documentId}: ${error.message || error}`);
3502 |             if (error instanceof UserError) throw error;
3503 |             if (error instanceof NotImplementedError) throw error;
3504 |             throw new UserError(`Failed to apply paragraph style: ${error.message || 'Unknown error'}`);
3505 |         }
3506 |     }
3507 | 
3508 | });
3509 | 
3510 | // --- Structure & Content Tools ---
3511 | 
3512 | server.addTool({
3513 | name: 'insertTable',
3514 | description: 'Inserts a new table with the specified dimensions at a given index.',
3515 | parameters: DocumentIdParameter.extend({
3516 | rows: z.number().int().min(1).describe('Number of rows for the new table.'),
3517 | columns: z.number().int().min(1).describe('Number of columns for the new table.'),
3518 | index: z.number().int().min(1).describe('The index (1-based) where the table should be inserted.'),
3519 | }),
3520 | execute: async (args, { log }) => {
3521 | const docs = await getDocsClient();
3522 | log.info(`Inserting ${args.rows}x${args.columns} table in doc ${args.documentId} at index ${args.index}`);
3523 | try {
3524 | await GDocsHelpers.createTable(docs, args.documentId, args.rows, args.columns, args.index);
3525 | // The API response contains info about the created table, but might be too complex to return here.
3526 | return `Successfully inserted a ${args.rows}x${args.columns} table at index ${args.index}.`;
3527 | } catch (error: any) {
3528 | log.error(`Error inserting table in doc ${args.documentId}: ${error.message || error}`);
3529 | if (error instanceof UserError) throw error;
3530 | throw new UserError(`Failed to insert table: ${error.message || 'Unknown error'}`);
3531 | }
3532 | }
3533 | });
3534 | 
3535 | server.addTool({
3536 | name: 'editTableCell',
3537 | description: 'Edits the content and/or basic style of a specific table cell. Requires knowing table start index.',
3538 | parameters: DocumentIdParameter.extend({
3539 | tableStartIndex: z.number().int().min(1).describe("The starting index of the TABLE element itself (tricky to find, may require reading structure first)."),
3540 | rowIndex: z.number().int().min(0).describe("Row index (0-based)."),
3541 | columnIndex: z.number().int().min(0).describe("Column index (0-based)."),
3542 | textContent: z.string().optional().describe("Optional: New text content for the cell. Replaces existing content."),
3543 | // Combine basic styles for simplicity here. More advanced cell styling might need separate tools.
3544 | textStyle: TextStyleParameters.optional().describe("Optional: Text styles to apply."),
3545 | paragraphStyle: ParagraphStyleParameters.optional().describe("Optional: Paragraph styles (like alignment) to apply."),
3546 | // cellBackgroundColor: z.string().optional()... // Cell-specific styles are complex
3547 | }),
3548 | execute: async (args, { log }) => {
3549 | const docs = await getDocsClient();
3550 | log.info(`Editing cell (${args.rowIndex}, ${args.columnIndex}) in table starting at ${args.tableStartIndex}, doc ${args.documentId}`);
3551 | 
3552 |         // TODO: Implement complex logic
3553 |         // 1. Find the cell's content range based on tableStartIndex, rowIndex, columnIndex. This is NON-TRIVIAL.
3554 |         //    Requires getting the document, finding the table element, iterating through rows/cells to calculate indices.
3555 |         // 2. If textContent is provided, generate a DeleteContentRange request for the cell's current content.
3556 |         // 3. Generate an InsertText request for the new textContent at the cell's start index.
3557 |         // 4. If textStyle is provided, generate UpdateTextStyle requests for the new text range.
3558 |         // 5. If paragraphStyle is provided, generate UpdateParagraphStyle requests for the cell's paragraph range.
3559 |         // 6. Execute batch update.
3560 | 
3561 |         log.error("editTableCell is not implemented due to complexity of finding cell indices.");
3562 |         throw new NotImplementedError("Editing table cells is complex and not yet implemented.");
3563 |         // return `Edit request for cell (${args.rowIndex}, ${args.columnIndex}) submitted (Not Implemented).`;
3564 |     }
3565 | 
3566 | });
3567 | 
3568 | server.addTool({
3569 | name: 'insertPageBreak',
3570 | description: 'Inserts a page break at the specified index.',
3571 | parameters: DocumentIdParameter.extend({
3572 | index: z.number().int().min(1).describe('The index (1-based) where the page break should be inserted.'),
3573 | }),
3574 | execute: async (args, { log }) => {
3575 | const docs = await getDocsClient();
3576 | log.info(`Inserting page break in doc ${args.documentId} at index ${args.index}`);
3577 | try {
3578 | const request: docs_v1.Schema$Request = {
3579 | insertPageBreak: {
3580 | location: { index: args.index }
3581 | }
3582 | };
3583 | await GDocsHelpers.executeBatchUpdate(docs, args.documentId, [request]);
3584 | return `Successfully inserted page break at index ${args.index}.`;
3585 | } catch (error: any) {
3586 | log.error(`Error inserting page break in doc ${args.documentId}: ${error.message || error}`);
3587 | if (error instanceof UserError) throw error;
3588 | throw new UserError(`Failed to insert page break: ${error.message || 'Unknown error'}`);
3589 | }
3590 | }
3591 | });
3592 | 
3593 | // --- Intelligent Assistance Tools (Examples/Stubs) ---
3594 | 
3595 | server.addTool({
3596 | name: 'fixListFormatting',
3597 | description: 'EXPERIMENTAL: Attempts to detect paragraphs that look like lists (e.g., starting with -, \*, 1.) and convert them to proper Google Docs bulleted or numbered lists. Best used on specific sections.',
3598 | parameters: DocumentIdParameter.extend({
3599 | // Optional range to limit the scope, otherwise scans whole doc (potentially slow/risky)
3600 | range: OptionalRangeParameters.optional().describe("Optional: Limit the fixing process to a specific range.")
3601 | }),
3602 | execute: async (args, { log }) => {
3603 | const docs = await getDocsClient();
3604 | log.warn(`Executing EXPERIMENTAL fixListFormatting for doc ${args.documentId}. Range: ${JSON.stringify(args.range)}`);
3605 | try {
3606 | await GDocsHelpers.detectAndFormatLists(docs, args.documentId, args.range?.startIndex, args.range?.endIndex);
3607 | return `Attempted to fix list formatting. Please review the document for accuracy.`;
3608 | } catch (error: any) {
3609 | log.error(`Error fixing list formatting in doc ${args.documentId}: ${error.message || error}`);
3610 | if (error instanceof UserError) throw error;
3611 | if (error instanceof NotImplementedError) throw error; // Expected if helper not implemented
3612 | throw new UserError(`Failed to fix list formatting: ${error.message || 'Unknown error'}`);
3613 | }
3614 | }
3615 | });
3616 | 
3617 | server.addTool({
3618 | name: 'addComment',
3619 | description: 'Adds a comment anchored to a specific text range. REQUIRES DRIVE API SCOPES/SETUP.',
3620 | parameters: DocumentIdParameter.extend(RangeParameters.shape).extend({
3621 | commentText: z.string().min(1).describe("The content of the comment."),
3622 | }),
3623 | execute: async (args, { log }) => {
3624 | log.info(`Attempting to add comment "${args.commentText}" to range ${args.startIndex}-${args.endIndex} in doc ${args.documentId}`);
3625 | // Requires Drive API client and appropriate scopes.
3626 | // const { authClient } = await initializeGoogleClient(); // Get auth client if needed
3627 | // if (!authClient) throw new UserError("Authentication client not available for Drive API.");
3628 | try {
3629 | // await GDocsHelpers.addCommentHelper(driveClient, args.documentId, args.commentText, args.startIndex, args.endIndex);
3630 | log.error("addComment requires Drive API setup which is not implemented.");
3631 | throw new NotImplementedError("Adding comments requires Drive API setup and is not yet implemented in this server.");
3632 | // return `Comment added to range ${args.startIndex}-${args.endIndex}.`;
3633 | } catch (error: any) {
3634 | log.error(`Error adding comment in doc ${args.documentId}: ${error.message || error}`);
3635 | if (error instanceof UserError) throw error;
3636 | if (error instanceof NotImplementedError) throw error;
3637 | throw new UserError(`Failed to add comment: ${error.message || 'Unknown error'}`);
3638 | }
3639 | }
3640 | });
3641 | 
3642 | // --- Add Stubs for other advanced features ---
3643 | // (findElement, getDocumentMetadata, replaceText, list management, image handling, section breaks, footnotes, etc.)
3644 | // Example Stub:
3645 | server.addTool({
3646 | name: 'findElement',
3647 | description: 'Finds elements (paragraphs, tables, etc.) based on various criteria. (Not Implemented)',
3648 | parameters: DocumentIdParameter.extend({
3649 | // Define complex query parameters...
3650 | textQuery: z.string().optional(),
3651 | elementType: z.enum(['paragraph', 'table', 'list', 'image']).optional(),
3652 | // styleQuery...
3653 | }),
3654 | execute: async (args, { log }) => {
3655 | log.warn("findElement tool called but is not implemented.");
3656 | throw new NotImplementedError("Finding elements by complex criteria is not yet implemented.");
3657 | }
3658 | });
3659 | 
3660 | // --- Server Startup ---
3661 | async function startServer() {
3662 | try {
3663 | await initializeGoogleClient(); // Authorize BEFORE starting listeners
3664 | console.error("Starting Ultimate Google Docs MCP server...");
3665 | 
3666 |       // Using stdio as before
3667 |       const configToUse = {
3668 |           transportType: "stdio" as const,
3669 |       };
3670 |       server.start(configToUse);
3671 |       console.error(`MCP Server running using ${configToUse.transportType}. Awaiting client connection...`);
3672 | 
3673 | } catch(startError: any) {
3674 | console.error("FATAL: Server failed to start:", startError.message || startError);
3675 | process.exit(1);
3676 | }
3677 | }
3678 | 
3679 | startServer(); // Removed .catch here, let errors propagate if startup fails critically
3680 | Use code with caution.
3681 | TypeScript
3682 | Phase 2: Step-by-Step Update Instructions
3683 | Create src/types.ts:
3684 | In your project's src directory, create a new file named types.ts.
3685 | Copy the entire content from Phase 1, Section 1 (src/types.ts) into this new file. Save it.
3686 | Create src/googleDocsApiHelpers.ts:
3687 | In your project's src directory, create another new file named googleDocsApiHelpers.ts.
3688 | Copy the entire content from Phase 1, Section 2 (src/googleDocsApiHelpers.ts) into this new file. Save it.
3689 | Replace src/server.ts:
3690 | Open your existing src/server.ts file.
3691 | Delete all of its current content.
3692 | Copy the entire content from Phase 1, Section 3 (Updated File: src/server.ts) into the now empty src/server.ts file. Save it.
3693 | Check src/auth.ts:
3694 | No changes are strictly required unless you plan to implement features needing the Drive API (like comments). For now, leave it as is. If you add Drive features later, you'll need to add Drive API scopes (like https://www.googleapis.com/auth/drive) to the SCOPES array in auth.ts and potentially re-authorize (delete token.json and run the server once).
3695 | Install Dependencies (If any were added):
3696 | Open your terminal in the project root directory.
3697 | Run npm install. (In this case, no new dependencies were added, but it's good practice).
3698 | Build the Code:
3699 | In your terminal, run the build command:
3700 | npm run build
3701 | Use code with caution.
3702 | Bash
3703 | This should compile the new .ts files into JavaScript in the dist directory. Check for any compilation errors in the terminal output. Fix them if necessary (typos, import issues, etc.).
3704 | Update mcp_config.json (Optional - Check Path):
3705 | The command to run the server likely hasn't changed (node /path/to/your/project/dist/server.js). Double-check that the path in your Claude Desktop mcp_config.json still correctly points to the compiled dist/server.js file.
3706 | Re-authorize (If Scopes Changed):
3707 | If you did change scopes in auth.ts (not required by the code provided), you must delete the token.json file in your project root.
3708 | Run the server manually once to go through the Google authorization flow again:
3709 | node ./dist/server.js
3710 | Use code with caution.
3711 | Bash
3712 | Follow the on-screen instructions to authorize in your browser and paste the code back into the terminal.
3713 | Update Documentation (README.md / docs/index.html):
3714 | This is crucial! Your documentation is now outdated.
3715 | Edit your README.md and/or docs/index.html.
3716 | Remove descriptions of old/removed tools (like the original formatText).
3717 | Add detailed descriptions and usage examples for the new tools (applyTextStyle, applyParagraphStyle, insertTable, insertText, deleteRange, fixListFormatting, addComment, etc.). Explain their parameters clearly.
3718 | Mention which tools are experimental or not fully implemented.
3719 | Test Thoroughly:
3720 | Restart Claude Desktop (if using it).
3721 | Start testing the new tools one by one with specific prompts.
3722 | Begin with simple cases (e.g., applying bold using applyTextStyle with text finding).
3723 | Test edge cases (text not found, invalid indices, invalid hex colors).
3724 | Test the tools that rely on helpers (e.g., applyParagraphStyle which uses getParagraphRange and findTextRange).
3725 | Expect the unimplemented tools to return the "Not Implemented" error.
3726 | Monitor the terminal where Claude Desktop runs the server (or run it manually) for error messages (console.error logs).
3727 | You now have the code structure and implementation examples for a significantly more powerful Google Docs MCP server. Remember that the unimplemented features and complex helpers will require further development effort. Good luck!
3728 | 
3729 | ================
3730 | File: LICENSE
3731 | ================
3732 | https://opensource.org/license/MIT
3733 | 
3734 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
3735 | 
3736 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
3737 | 
3738 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
3739 | 
3740 | ================
3741 | File: package.json
3742 | ================
3743 | {
3744 |   "name": "mcp-googledocs-server",
3745 |   "version": "1.0.0",
3746 |   "type": "module",
3747 |   "main": "index.js",
3748 |   "scripts": {
3749 |     "test": "node --test tests/",
3750 |     "build": "tsc"
3751 |   },
3752 |   "keywords": [],
3753 |   "author": "",
3754 |   "license": "ISC",
3755 |   "description": "",
3756 |   "dependencies": {
3757 |     "fastmcp": "^1.21.0",
3758 |     "google-auth-library": "^9.15.1",
3759 |     "googleapis": "^148.0.0",
3760 |     "zod": "^3.24.2"
3761 |   },
3762 |   "devDependencies": {
3763 |     "@types/node": "^22.14.1",
3764 |     "tsx": "^4.19.3",
3765 |     "typescript": "^5.8.3"
3766 |   }
3767 | }
3768 | 
3769 | ================
3770 | File: README.md
3771 | ================
3772 | # Ultimate Google Docs & Drive MCP Server
3773 | 
3774 | ![Demo Animation](assets/google.docs.mcp.1.gif)
3775 | 
3776 | Connect Claude Desktop (or other MCP clients) to your Google Docs and Google Drive!
3777 | 
3778 | > 🔥 **Check out [15 powerful tasks](SAMPLE_TASKS.md) you can accomplish with this enhanced server!**
3779 | > 📁 **NEW:** Complete Google Drive file management capabilities!
3780 | 
3781 | This comprehensive server uses the Model Context Protocol (MCP) and the `fastmcp` library to provide tools for reading, writing, formatting, structuring Google Documents, and managing your entire Google Drive. It acts as a powerful bridge, allowing AI assistants like Claude to interact with your documents and files programmatically with advanced capabilities.
3782 | 
3783 | **Features:**
3784 | 
3785 | ### Document Access & Editing
3786 | - **Read Documents:** Read content with `readGoogleDoc` (plain text, JSON structure, or markdown)
3787 | - **Append to Documents:** Add text to documents with `appendToGoogleDoc`
3788 | - **Insert Text:** Place text at specific positions with `insertText`
3789 | - **Delete Content:** Remove content from a document with `deleteRange`
3790 | 
3791 | ### Formatting & Styling
3792 | - **Text Formatting:** Apply rich styling with `applyTextStyle` (bold, italic, colors, etc.)
3793 | - **Paragraph Formatting:** Control paragraph layout with `applyParagraphStyle` (alignment, spacing, etc.)
3794 | - **Find & Format:** Format by text content using `formatMatchingText` (legacy support)
3795 | 
3796 | ### Document Structure
3797 | - **Tables:** Create tables with `insertTable`
3798 | - **Page Breaks:** Insert page breaks with `insertPageBreak`
3799 | - **Experimental Features:** Tools like `fixListFormatting` for automatic list detection
3800 | 
3801 | ### 🆕 Google Drive File Management
3802 | - **Document Discovery:** Find and list documents with `listGoogleDocs`, `searchGoogleDocs`, `getRecentGoogleDocs`
3803 | - **Document Information:** Get detailed metadata with `getDocumentInfo`
3804 | - **Folder Management:** Create folders (`createFolder`), list contents (`listFolderContents`), get info (`getFolderInfo`)
3805 | - **File Operations:** Move (`moveFile`), copy (`copyFile`), rename (`renameFile`), delete (`deleteFile`)
3806 | - **Document Creation:** Create new docs (`createDocument`) or from templates (`createFromTemplate`)
3807 | 
3808 | ### Integration
3809 | - **Google Authentication:** Secure OAuth 2.0 authentication with full Drive access
3810 | - **MCP Compliant:** Designed for use with Claude and other MCP clients
3811 | - **VS Code Integration:** [Setup guide](vscode.md) for VS Code MCP extension
3812 | 
3813 | ---
3814 | 
3815 | ## Prerequisites
3816 | 
3817 | Before you start, make sure you have:
3818 | 
3819 | 1.  **Node.js and npm:** A recent version of Node.js (which includes npm) installed on your computer. You can download it from [nodejs.org](https://nodejs.org/). (Version 18 or higher recommended).
3820 | 2.  **Git:** Required for cloning this repository. ([Download Git](https://git-scm.com/downloads)).
3821 | 3.  **A Google Account:** The account that owns or has access to the Google Docs you want to interact with.
3822 | 4.  **Command Line Familiarity:** Basic comfort using a terminal or command prompt (like Terminal on macOS/Linux, or Command Prompt/PowerShell on Windows).
3823 | 5.  **Claude Desktop (Optional):** If your goal is to connect this server to Claude, you'll need the Claude Desktop application installed.
3824 | 
3825 | ---
3826 | 
3827 | ## Setup Instructions
3828 | 
3829 | Follow these steps carefully to get your own instance of the server running.
3830 | 
3831 | ### Step 1: Google Cloud Project & Credentials (The Important Bit!)
3832 | 
3833 | This server needs permission to talk to Google APIs on your behalf. You'll create special "keys" (credentials) that only your server will use.
3834 | 
3835 | 1.  **Go to Google Cloud Console:** Open your web browser and go to the [Google Cloud Console](https://console.cloud.google.com/). You might need to log in with your Google Account.
3836 | 2.  **Create or Select a Project:**
3837 |     - If you don't have a project, click the project dropdown near the top and select "NEW PROJECT". Give it a name (e.g., "My MCP Docs Server") and click "CREATE".
3838 |     - If you have existing projects, you can select one or create a new one.
3839 | 3.  **Enable APIs:** You need to turn on the specific Google services this server uses.
3840 |     - In the search bar at the top, type "APIs & Services" and select "Library".
3841 |     - Search for "**Google Docs API**" and click on it. Then click the "**ENABLE**" button.
3842 |     - Search for "**Google Drive API**" and click on it. Then click the "**ENABLE**" button (this is often needed for finding files or permissions).
3843 | 4.  **Configure OAuth Consent Screen:** This screen tells users (usually just you) what your app wants permission for.
3844 |     - On the left menu, click "APIs & Services" -> "**OAuth consent screen**".
3845 |     - Choose User Type: Select "**External**" and click "CREATE".
3846 |     - Fill in App Information:
3847 |       - **App name:** Give it a name users will see (e.g., "Claude Docs MCP Access").
3848 |       - **User support email:** Select your email address.
3849 |       - **Developer contact information:** Enter your email address.
3850 |     - Click "**SAVE AND CONTINUE**".
3851 |     - **Scopes:** Click "**ADD OR REMOVE SCOPES**". Search for and add the following scopes:
3852 |       - `https://www.googleapis.com/auth/documents` (Allows reading/writing docs)
3853 |       - `https://www.googleapis.com/auth/drive.file` (Allows access to specific files opened/created by the app)
3854 |       - Click "**UPDATE**".
3855 |     - Click "**SAVE AND CONTINUE**".
3856 |     - **Test Users:** Click "**ADD USERS**". Enter the same Google email address you are logged in with. Click "**ADD**". This allows _you_ to use the app while it's in "testing" mode.
3857 |     - Click "**SAVE AND CONTINUE**". Review the summary and click "**BACK TO DASHBOARD**".
3858 | 5.  **Create Credentials (The Keys!):**
3859 |     - On the left menu, click "APIs & Services" -> "**Credentials**".
3860 |     - Click "**+ CREATE CREDENTIALS**" at the top and choose "**OAuth client ID**".
3861 |     - **Application type:** Select "**Desktop app**" from the dropdown.
3862 |     - **Name:** Give it a name (e.g., "MCP Docs Desktop Client").
3863 |     - Click "**CREATE**".
3864 | 6.  **⬇️ DOWNLOAD THE CREDENTIALS FILE:** A box will pop up showing your Client ID. Click the "**DOWNLOAD JSON**" button.
3865 |     - Save this file. It will likely be named something like `client_secret_....json`.
3866 |     - **IMPORTANT:** Rename the downloaded file to exactly `credentials.json`.
3867 | 7.  ⚠️ **SECURITY WARNING:** Treat this `credentials.json` file like a password! Do not share it publicly, and **never commit it to GitHub.** Anyone with this file could potentially pretend to be _your application_ (though they'd still need user consent to access data).
3868 | 
3869 | ### Step 2: Get the Server Code
3870 | 
3871 | 1.  **Clone the Repository:** Open your terminal/command prompt and run:
3872 |     ```bash
3873 |     git clone https://github.com/a-bonus/google-docs-mcp.git mcp-googledocs-server
3874 |     ```
3875 | 2.  **Navigate into Directory:**
3876 |     ```bash
3877 |     cd mcp-googledocs-server
3878 |     ```
3879 | 3.  **Place Credentials:** Move or copy the `credentials.json` file you downloaded and renamed (from Step 1.6) directly into this `mcp-googledocs-server` folder.
3880 | 
3881 | ### Step 3: Install Dependencies
3882 | 
3883 | Your server needs some helper libraries specified in the `package.json` file.
3884 | 
3885 | 1.  In your terminal (make sure you are inside the `mcp-googledocs-server` directory), run:
3886 |     ```bash
3887 |     npm install
3888 |     ```
3889 |     This will download and install all the necessary packages into a `node_modules` folder.
3890 | 
3891 | ### Step 4: Build the Server Code
3892 | 
3893 | The server is written in TypeScript (`.ts`), but we need to compile it into JavaScript (`.js`) that Node.js can run directly.
3894 | 
3895 | 1.  In your terminal, run:
3896 |     ```bash
3897 |     npm run build
3898 |     ```
3899 |     This uses the TypeScript compiler (`tsc`) to create a `dist` folder containing the compiled JavaScript files.
3900 | 
3901 | ### Step 5: First Run & Google Authorization (One Time Only)
3902 | 
3903 | Now you need to run the server once manually to grant it permission to access your Google account data. This will create a `token.json` file that saves your permission grant.
3904 | 
3905 | 1.  In your terminal, run the _compiled_ server using `node`:
3906 |     ```bash
3907 |     node ./dist/server.js
3908 |     ```
3909 | 2.  **Watch the Terminal:** The script will print:
3910 |     - Status messages (like "Attempting to authorize...").
3911 |     - An "Authorize this app by visiting this url:" message followed by a long `https://accounts.google.com/...` URL.
3912 | 3.  **Authorize in Browser:**
3913 |     - Copy the entire long URL from the terminal.
3914 |     - Paste the URL into your web browser and press Enter.
3915 |     - Log in with the **same Google account** you added as a Test User in Step 1.4.
3916 |     - Google will show a screen asking for permission for your app ("Claude Docs MCP Access" or similar) to access Google Docs/Drive. Review and click "**Allow**" or "**Grant**".
3917 | 4.  **Get the Authorization Code:**
3918 |     - After clicking Allow, your browser will likely try to redirect to `http://localhost` and show a **"This site can't be reached" error**. **THIS IS NORMAL!**
3919 |     - Look **carefully** at the URL in your browser's address bar. It will look like `http://localhost/?code=4/0Axxxxxxxxxxxxxx&scope=...`
3920 |     - Copy the long string of characters **between `code=` and the `&scope` part**. This is your single-use authorization code.
3921 | 5.  **Paste Code into Terminal:** Go back to your terminal where the script is waiting ("Enter the code from that page here:"). Paste the code you just copied.
3922 | 6.  **Press Enter.**
3923 | 7.  **Success!** The script should print:
3924 |     - "Authentication successful!"
3925 |     - "Token stored to .../token.json"
3926 |     - It will then finish starting and likely print "Awaiting MCP client connection via stdio..." or similar, and then exit (or you can press `Ctrl+C` to stop it).
3927 | 8.  ✅ **Check:** You should now see a new file named `token.json` in your `mcp-googledocs-server` folder.
3928 | 9.  ⚠️ **SECURITY WARNING:** This `token.json` file contains the key that allows the server to access your Google account _without_ asking again. Protect it like a password. **Do not commit it to GitHub.** The included `.gitignore` file should prevent this automatically.
3929 | 
3930 | ### Step 6: Configure Claude Desktop (Optional)
3931 | 
3932 | If you want to use this server with Claude Desktop, you need to tell Claude how to run it.
3933 | 
3934 | 1.  **Find Your Absolute Path:** You need the full path to the server code.
3935 |     - In your terminal, make sure you are still inside the `mcp-googledocs-server` directory.
3936 |     - Run the `pwd` command (on macOS/Linux) or `cd` (on Windows, just displays the path).
3937 |     - Copy the full path (e.g., `/Users/yourname/projects/mcp-googledocs-server` or `C:\Users\yourname\projects\mcp-googledocs-server`).
3938 | 2.  **Locate `mcp_config.json`:** Find Claude's configuration file:
3939 |     - **macOS:** `~/Library/Application Support/Claude/mcp_config.json` (You might need to use Finder's "Go" -> "Go to Folder..." menu and paste `~/Library/Application Support/Claude/`)
3940 |     - **Windows:** `%APPDATA%\Claude\mcp_config.json` (Paste `%APPDATA%\Claude` into File Explorer's address bar)
3941 |     - **Linux:** `~/.config/Claude/mcp_config.json`
3942 |     - _If the `Claude` folder or `mcp_config.json` file doesn't exist, create them._
3943 | 3.  **Edit `mcp_config.json`:** Open the file in a text editor. Add or modify the `mcpServers` section like this, **replacing `/PATH/TO/YOUR/CLONED/REPO` with the actual absolute path you copied in Step 6.1**:
3944 | 
3945 |     ```json
3946 |     {
3947 |       "mcpServers": {
3948 |         "google-docs-mcp": {
3949 |           "command": "node",
3950 |           "args": [
3951 |             "/PATH/TO/YOUR/CLONED/REPO/mcp-googledocs-server/dist/server.js"
3952 |           ],
3953 |           "env": {}
3954 |         }
3955 |         // Add commas here if you have other servers defined
3956 |       }
3957 |       // Other Claude settings might be here
3958 |     }
3959 |     ```
3960 | 
3961 |     - **Make sure the path in `"args"` is correct and absolute!**
3962 |     - If the file already existed, carefully merge this entry into the existing `mcpServers` object. Ensure the JSON is valid (check commas!).
3963 | 
3964 | 4.  **Save `mcp_config.json`.**
3965 | 5.  **Restart Claude Desktop:** Close Claude completely and reopen it.
3966 | 
3967 | ---
3968 | 
3969 | ## Usage with Claude Desktop
3970 | 
3971 | Once configured, you should be able to use the tools in your chats with Claude:
3972 | 
3973 | - "Use the `google-docs-mcp` server to read the document with ID `YOUR_GOOGLE_DOC_ID`."
3974 | - "Can you get the content of Google Doc `YOUR_GOOGLE_DOC_ID`?"
3975 | - "Append 'This was added by Claude!' to document `YOUR_GOOGLE_DOC_ID` using the `google-docs-mcp` tool."
3976 | 
3977 | ### Advanced Usage Examples:
3978 | - **Text Styling**: "Use `applyTextStyle` to make the text 'Important Section' bold and red (#FF0000) in document `YOUR_GOOGLE_DOC_ID`."
3979 | - **Paragraph Styling**: "Use `applyParagraphStyle` to center-align the paragraph containing 'Title Here' in document `YOUR_GOOGLE_DOC_ID`."
3980 | - **Table Creation**: "Insert a 3x4 table at index 500 in document `YOUR_GOOGLE_DOC_ID` using the `insertTable` tool."
3981 | - **Legacy Formatting**: "Use `formatMatchingText` to find the second instance of 'Project Alpha' and make it blue (#0000FF) in doc `YOUR_GOOGLE_DOC_ID`."
3982 | 
3983 | Remember to replace `YOUR_GOOGLE_DOC_ID` with the actual ID from a Google Doc's URL (the long string between `/d/` and `/edit`).
3984 | 
3985 | Claude will automatically launch your server in the background when needed using the command you provided. You do **not** need to run `node ./dist/server.js` manually anymore.
3986 | 
3987 | ---
3988 | 
3989 | ## Security & Token Storage
3990 | 
3991 | - **`.gitignore`:** This repository includes a `.gitignore` file which should prevent you from accidentally committing your sensitive `credentials.json` and `token.json` files. **Do not remove these lines from `.gitignore`**.
3992 | - **Token Storage:** This server stores the Google authorization token (`token.json`) directly in the project folder for simplicity during setup. In production or more security-sensitive environments, consider storing this token more securely, such as using system keychains, encrypted files, or dedicated secret management services.
3993 | 
3994 | ---
3995 | 
3996 | ## Troubleshooting
3997 | 
3998 | - **Claude shows "Failed" or "Could not attach":**
3999 |   - Double-check the absolute path in `mcp_config.json`.
4000 |   - Ensure you ran `npm run build` successfully and the `dist` folder exists.
4001 |   - Try running the command from `mcp_config.json` manually in your terminal: `node /PATH/TO/YOUR/CLONED/REPO/mcp-googledocs-server/dist/server.js`. Look for any errors printed.
4002 |   - Check the Claude Desktop logs (see the official MCP debugging guide).
4003 |   - Make sure all `console.log` status messages in the server code were changed to `console.error`.
4004 | - **Google Authorization Errors:**
4005 |   - Ensure you enabled the correct APIs (Docs, Drive).
4006 |   - Make sure you added your email as a Test User on the OAuth Consent Screen.
4007 |   - Verify the `credentials.json` file is correctly placed in the project root.
4008 | 
4009 | ---
4010 | 
4011 | ## License
4012 | 
4013 | This project is licensed under the MIT License - see the `LICENSE` file for details. (Note: You should add a `LICENSE` file containing the MIT License text to your repository).
4014 | 
4015 | ================
4016 | File: SAMPLE_TASKS.md
4017 | ================
4018 | # 15 Powerful Tasks with the Ultimate Google Docs & Drive MCP Server
4019 | 
4020 | This document showcases practical examples of what you can accomplish with the enhanced Google Docs & Drive MCP Server. These examples demonstrate how AI assistants like Claude can perform sophisticated document formatting, structuring, and file management tasks through the MCP interface.
4021 | 
4022 | ## Document Formatting & Structure Tasks
4023 | 
4024 | ## 1. Create and Format a Document Header
4025 | 
4026 | ```
4027 | Task: "Create a professional document header for my project proposal."
4028 | 
4029 | Steps:
4030 | 1. Insert the title "Project Proposal: AI Integration Strategy" at the beginning of the document
4031 | 2. Apply Heading 1 style to the title using applyParagraphStyle
4032 | 3. Add a horizontal line below the title
4033 | 4. Insert the date and author information
4034 | 5. Apply a subtle background color to the header section
4035 | ```
4036 | 
4037 | ## 2. Generate and Format a Table of Contents
4038 | 
4039 | ```
4040 | Task: "Create a table of contents for my document based on its headings."
4041 | 
4042 | Steps:
4043 | 1. Find all text with Heading styles (1-3) using findParagraphsMatchingStyle
4044 | 2. Create a "Table of Contents" section at the beginning of the document
4045 | 3. Insert each heading with appropriate indentation based on its level
4046 | 4. Format the TOC entries with page numbers and dotted lines
4047 | 5. Apply consistent styling to the entire TOC
4048 | ```
4049 | 
4050 | ## 3. Structure a Document with Consistent Formatting
4051 | 
4052 | ```
4053 | Task: "Apply consistent formatting throughout my document based on content type."
4054 | 
4055 | Steps:
4056 | 1. Format all section headings with applyParagraphStyle (Heading styles, alignment)
4057 | 2. Style all bullet points with consistent indentation and formatting
4058 | 3. Format code samples with monospace font and background color
4059 | 4. Apply consistent paragraph spacing throughout the document
4060 | 5. Format all hyperlinks with a consistent color and underline style
4061 | ```
4062 | 
4063 | ## 4. Create a Professional Table for Data Presentation
4064 | 
4065 | ```
4066 | Task: "Create a formatted comparison table of product features."
4067 | 
4068 | Steps:
4069 | 1. Insert a table with insertTable (5 rows x 4 columns)
4070 | 2. Add header row with product names
4071 | 3. Add feature rows with consistent formatting
4072 | 4. Apply alternating row background colors for readability
4073 | 5. Format the header row with bold text and background color
4074 | 6. Align numeric columns to the right
4075 | ```
4076 | 
4077 | ## 5. Prepare a Document for Formal Submission
4078 | 
4079 | ```
4080 | Task: "Format my research paper according to academic guidelines."
4081 | 
4082 | Steps:
4083 | 1. Set the title with centered alignment and appropriate font size
4084 | 2. Format all headings according to the required style guide
4085 | 3. Apply double spacing to the main text
4086 | 4. Insert page numbers with appropriate format
4087 | 5. Format citations consistently
4088 | 6. Apply indentation to block quotes
4089 | 7. Format the bibliography section
4090 | ```
4091 | 
4092 | ## 6. Create an Executive Summary with Highlights
4093 | 
4094 | ```
4095 | Task: "Create an executive summary that emphasizes key points from my report."
4096 | 
4097 | Steps:
4098 | 1. Insert a page break and create an "Executive Summary" section
4099 | 2. Extract and format key points from the document
4100 | 3. Apply bullet points for clarity
4101 | 4. Highlight critical figures or statistics in bold
4102 | 5. Use color to emphasize particularly important points
4103 | 6. Format the summary with appropriate spacing and margins
4104 | ```
4105 | 
4106 | ## 7. Format a Document for Different Audiences
4107 | 
4108 | ```
4109 | Task: "Create two versions of my presentation - one technical and one for executives."
4110 | 
4111 | Steps:
4112 | 1. Duplicate the document content
4113 | 2. For the technical version:
4114 |    - Add detailed technical sections
4115 |    - Include code examples with monospace formatting
4116 |    - Use technical terminology
4117 | 3. For the executive version:
4118 |    - Emphasize business impact with bold and color
4119 |    - Simplify technical concepts
4120 |    - Add executive summary
4121 |    - Use more visual formatting elements
4122 | ```
4123 | 
4124 | ## 8. Create a Response Form with Structured Fields
4125 | 
4126 | ```
4127 | Task: "Create a form-like document with fields for respondents to complete."
4128 | 
4129 | Steps:
4130 | 1. Create section headers for different parts of the form
4131 | 2. Insert tables for structured response areas
4132 | 3. Add form fields with clear instructions
4133 | 4. Use formatting to distinguish between instructions and response areas
4134 | 5. Add checkbox lists using special characters with consistent formatting
4135 | 6. Apply consistent spacing and alignment throughout
4136 | ```
4137 | 
4138 | ## 9. Format a Document with Multi-Level Lists
4139 | 
4140 | ```
4141 | Task: "Create a project plan with properly formatted nested task lists."
4142 | 
4143 | Steps:
4144 | 1. Insert the project title and apply Heading 1 style
4145 | 2. Create main project phases with Heading 2 style
4146 | 3. For each phase, create a properly formatted numbered list of tasks
4147 | 4. Create sub-tasks with indented, properly formatted sub-lists
4148 | 5. Apply consistent formatting to all list levels
4149 | 6. Format task owners' names in bold
4150 | 7. Format dates and deadlines with a consistent style
4151 | ```
4152 | 
4153 | ## 10. Prepare a Document with Advanced Layout
4154 | 
4155 | ```
4156 | Task: "Create a newsletter-style document with columns and sections."
4157 | 
4158 | Steps:
4159 | 1. Create a bold, centered title for the newsletter
4160 | 2. Insert a horizontal line separator
4161 | 3. Create differently formatted sections for:
4162 |    - Main article (left-aligned paragraphs)
4163 |    - Sidebar content (indented, smaller text)
4164 |    - Highlighted quotes (centered, italic)
4165 | 4. Insert and format images with captions
4166 | 5. Add a formatted footer with contact information
4167 | 6. Apply consistent spacing between sections
4168 | ```
4169 | 
4170 | These examples demonstrate the power and flexibility of the enhanced Google Docs & Drive MCP Server, showcasing how AI assistants can help with sophisticated document formatting, structuring, and comprehensive file management tasks.
4171 | 
4172 | ## Google Drive Management Tasks
4173 | 
4174 | ## 11. Organize Project Files Automatically
4175 | 
4176 | ```
4177 | Task: "Set up a complete project structure and organize existing files."
4178 | 
4179 | Steps:
4180 | 1. Create a main project folder using createFolder
4181 | 2. Create subfolders for different aspects (Documents, Templates, Archive)
4182 | 3. Search for project-related documents using searchGoogleDocs
4183 | 4. Move relevant documents to appropriate subfolders with moveFile
4184 | 5. Create a project index document listing all resources
4185 | 6. Format the index with links to all project documents
4186 | ```
4187 | 
4188 | ## 12. Create Document Templates and Generate Reports
4189 | 
4190 | ```
4191 | Task: "Set up a template system and generate standardized reports."
4192 | 
4193 | Steps:
4194 | 1. Create a Templates folder using createFolder
4195 | 2. Create template documents with placeholder text ({{DATE}}, {{NAME}}, etc.)
4196 | 3. Use createFromTemplate to generate new reports from templates
4197 | 4. Apply text replacements to customize each report
4198 | 5. Organize generated reports in appropriate folders
4199 | 6. Create a tracking document listing all generated reports
4200 | ```
4201 | 
4202 | ## 13. Archive and Clean Up Old Documents
4203 | 
4204 | ```
4205 | Task: "Archive outdated documents and organize current files."
4206 | 
4207 | Steps:
4208 | 1. Create an Archive folder for old documents using createFolder
4209 | 2. Use getRecentGoogleDocs to find documents older than 90 days
4210 | 3. Review and move old documents to Archive using moveFile
4211 | 4. Delete unnecessary duplicate files using deleteFile
4212 | 5. Rename documents with consistent naming conventions using renameFile
4213 | 6. Create an archive index document for reference
4214 | ```
4215 | 
4216 | ## 14. Duplicate and Distribute Document Sets
4217 | 
4218 | ```
4219 | Task: "Create personalized versions of documents for different teams."
4220 | 
4221 | Steps:
4222 | 1. Create team-specific folders using createFolder
4223 | 2. Copy master documents to each team folder using copyFile
4224 | 3. Rename copied documents with team-specific names using renameFile
4225 | 4. Customize document content for each team using text replacement
4226 | 5. Apply team-specific formatting and branding
4227 | 6. Create distribution tracking documents
4228 | ```
4229 | 
4230 | ## 15. Comprehensive File Management and Reporting
4231 | 
4232 | ```
4233 | Task: "Generate a complete inventory and management report of all documents."
4234 | 
4235 | Steps:
4236 | 1. Use listFolderContents to catalog all folders and their contents
4237 | 2. Use getDocumentInfo to gather detailed metadata for each document
4238 | 3. Create a master inventory document with all file information
4239 | 4. Format the inventory as a searchable table with columns for:
4240 |    - Document name and ID
4241 |    - Creation and modification dates
4242 |    - Owner and last modifier
4243 |    - Folder location
4244 |    - File size and sharing status
4245 | 5. Add summary statistics and organization recommendations
4246 | 6. Set up automated folder structures for better organization
4247 | ```
4248 | 
4249 | ================
4250 | File: tsconfig.json
4251 | ================
4252 | // tsconfig.json
4253 | {
4254 |     "compilerOptions": {
4255 |       "target": "ES2022",
4256 |       "module": "NodeNext",
4257 |       "moduleResolution": "NodeNext",
4258 |       "outDir": "./dist",
4259 |       "rootDir": "./src",
4260 |       "strict": true,
4261 |       "esModuleInterop": true,
4262 |       "skipLibCheck": true,
4263 |       "forceConsistentCasingInFileNames": true,
4264 |       "resolveJsonModule": true
4265 |     },
4266 |     "include": ["src/**/*"],
4267 |     "exclude": ["node_modules"]
4268 |   }
4269 | 
4270 | ================
4271 | File: vscode.md
4272 | ================
4273 | # VS Code Integration Guide
4274 | 
4275 | This guide shows you how to integrate the Ultimate Google Docs & Drive MCP Server with VS Code using the MCP extension.
4276 | 
4277 | ## Prerequisites
4278 | 
4279 | Before setting up VS Code integration, make sure you have:
4280 | 
4281 | 1. **Completed the main setup** - Follow the [README.md](README.md) setup instructions first
4282 | 2. **VS Code installed** - Download from [code.visualstudio.com](https://code.visualstudio.com/)
4283 | 3. **Working MCP server** - Verify your server works with Claude Desktop first
4284 | 
4285 | ## Installation
4286 | 
4287 | ### Step 1: Install the MCP Extension
4288 | 
4289 | 1. Open VS Code
4290 | 2. Go to Extensions (Ctrl+Shift+X / Cmd+Shift+X)
4291 | 3. Search for "MCP" or "Model Context Protocol"
4292 | 4. Install the official MCP extension
4293 | 
4294 | ### Step 2: Configure the MCP Server
4295 | 
4296 | 1. Open VS Code Settings (Ctrl+, / Cmd+,)
4297 | 2. Search for "MCP" in settings
4298 | 3. Find "MCP: Servers" configuration
4299 | 4. Add a new server configuration:
4300 | 
4301 | ```json
4302 | {
4303 |   "google-docs-drive": {
4304 |     "command": "node",
4305 |     "args": ["${workspaceFolder}/dist/server.js"],
4306 |     "env": {
4307 |       "NODE_ENV": "production"
4308 |     }
4309 |   }
4310 | }
4311 | ```
4312 | 
4313 | ### Step 3: Verify Configuration
4314 | 
4315 | 1. Open the Command Palette (Ctrl+Shift+P / Cmd+Shift+P)
4316 | 2. Type "MCP: Restart Servers" and run it
4317 | 3. Check the Output panel and select "MCP" from the dropdown
4318 | 4. You should see your server connecting successfully
4319 | 
4320 | ## Usage
4321 | 
4322 | Once configured, you can use the MCP server with AI assistants in VS Code:
4323 | 
4324 | ### Document Operations
4325 | 
4326 | ```
4327 | "List my recent Google Docs from the last 7 days"
4328 | "Read the content of document ID: 1ABC..."
4329 | "Create a new document called 'Project Notes' in my Work folder"
4330 | "Search for documents containing 'meeting notes'"
4331 | ```
4332 | 
4333 | ### File Management
4334 | 
4335 | ```
4336 | "Show me the contents of my root Drive folder"
4337 | "Create a folder called 'Project X' in folder ID: 1DEF..."
4338 | "Move document ID: 1GHI... to the Project X folder"
4339 | "Copy my template document and rename it to 'New Report'"
4340 | ```
4341 | 
4342 | ### Document Editing
4343 | 
4344 | ```
4345 | "Add a heading 'Summary' to the beginning of document ID: 1JKL..."
4346 | "Format all text containing 'important' as bold in my document"
4347 | "Insert a table with 3 columns and 5 rows at the end of the document"
4348 | "Apply paragraph formatting to make all headings centered"
4349 | ```
4350 | 
4351 | ## Troubleshooting
4352 | 
4353 | ### Server Not Starting
4354 | 
4355 | 1. **Check the path** - Ensure the absolute path in your configuration is correct
4356 | 2. **Verify build** - Run `npm run build` in your project directory
4357 | 3. **Check permissions** - Ensure `token.json` and `credentials.json` exist and are readable
4358 | 
4359 | ### Authentication Issues
4360 | 
4361 | 1. **Re-authorize** - Delete `token.json` and run the server manually once:
4362 |    ```bash
4363 |    cd /path/to/your/google-docs-mcp
4364 |    node dist/server.js
4365 |    ```
4366 | 2. **Follow the authorization flow** again
4367 | 3. **Restart VS Code** after successful authorization
4368 | 
4369 | ### Tool Not Found Errors
4370 | 
4371 | 1. **Restart MCP servers** using Command Palette
4372 | 2. **Check server logs** in VS Code Output panel (MCP channel)
4373 | 3. **Verify server version** - Ensure you're running v2.0.0 or later
4374 | 
4375 | ## Available Tools
4376 | 
4377 | The server provides these tools in VS Code:
4378 | 
4379 | ### Document Discovery
4380 | - `listGoogleDocs` - List documents with filtering
4381 | - `searchGoogleDocs` - Search by name/content
4382 | - `getRecentGoogleDocs` - Get recently modified docs
4383 | - `getDocumentInfo` - Get detailed document metadata
4384 | 
4385 | ### Document Editing
4386 | - `readGoogleDoc` - Read document content
4387 | - `appendToGoogleDoc` - Add text to end
4388 | - `insertText` - Insert at specific position
4389 | - `deleteRange` - Remove content
4390 | - `applyTextStyle` - Format text (bold, italic, colors)
4391 | - `applyParagraphStyle` - Format paragraphs (alignment, spacing)
4392 | - `formatMatchingText` - Find and format text
4393 | - `insertTable` - Create tables
4394 | - `insertPageBreak` - Add page breaks
4395 | 
4396 | ### File Management
4397 | - `createFolder` - Create new folders
4398 | - `listFolderContents` - List folder contents
4399 | - `getFolderInfo` - Get folder metadata
4400 | - `moveFile` - Move files/folders
4401 | - `copyFile` - Copy files/folders
4402 | - `renameFile` - Rename files/folders
4403 | - `deleteFile` - Delete files/folders
4404 | - `createDocument` - Create new documents
4405 | - `createFromTemplate` - Create from templates
4406 | 
4407 | ## Tips for Better Integration
4408 | 
4409 | 1. **Use specific document IDs** - More reliable than document names
4410 | 2. **Combine operations** - Create and format documents in single requests
4411 | 3. **Check tool results** - Review what was actually done before proceeding
4412 | 4. **Use templates** - Create template documents for consistent formatting
4413 | 
4414 | ## Security Notes
4415 | 
4416 | - The server uses OAuth 2.0 for secure authentication
4417 | - Credentials are stored locally in `token.json` and `credentials.json`
4418 | - Never share these files or commit them to version control
4419 | - The server only has access to your Google Drive, not other Google services
4420 | 
4421 | ## Example Workflows
4422 | 
4423 | ### Create a Formatted Report
4424 | 
4425 | ```
4426 | 1. "Create a new document called 'Monthly Report' in my Reports folder"
4427 | 2. "Add the title 'Monthly Performance Report' as a centered Heading 1"
4428 | 3. "Insert a table with 4 columns and 6 rows for the data"
4429 | 4. "Add section headings for Executive Summary, Key Metrics, and Action Items"
4430 | ```
4431 | 
4432 | ### Organize Project Documents
4433 | 
4434 | ```
4435 | 1. "Create a folder called 'Q1 Project' in my Work folder"
4436 | 2. "Search for all documents containing 'Q1' in the title"
4437 | 3. "Move the found documents to the Q1 Project folder"
4438 | 4. "Create a new document called 'Q1 Project Overview' in that folder"
4439 | ```
4440 | 
4441 | This integration brings the full power of Google Docs and Drive management directly into your VS Code workflow!
4442 | 
4443 | 
4444 | 
4445 | ================================================================
4446 | End of Codebase
4447 | ================================================================
4448 | 
```
Page 3/3FirstPrevNextLast