#
tokens: 24259/50000 1/18 files (page 2/3)
lines: on (toggle) GitHub
raw markdown copy reset
This is page 2 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

--------------------------------------------------------------------------------
/src/server.ts:
--------------------------------------------------------------------------------

```typescript
   1 | // src/server.ts
   2 | import { FastMCP, UserError } from 'fastmcp';
   3 | import { z } from 'zod';
   4 | import { google, docs_v1, drive_v3 } from 'googleapis';
   5 | import { authorize } from './auth.js';
   6 | import { OAuth2Client } from 'google-auth-library';
   7 | 
   8 | // Import types and helpers
   9 | import {
  10 | DocumentIdParameter,
  11 | RangeParameters,
  12 | OptionalRangeParameters,
  13 | TextFindParameter,
  14 | TextStyleParameters,
  15 | TextStyleArgs,
  16 | ParagraphStyleParameters,
  17 | ParagraphStyleArgs,
  18 | ApplyTextStyleToolParameters, ApplyTextStyleToolArgs,
  19 | ApplyParagraphStyleToolParameters, ApplyParagraphStyleToolArgs,
  20 | NotImplementedError
  21 | } from './types.js';
  22 | import * as GDocsHelpers from './googleDocsApiHelpers.js';
  23 | 
  24 | let authClient: OAuth2Client | null = null;
  25 | let googleDocs: docs_v1.Docs | null = null;
  26 | let googleDrive: drive_v3.Drive | null = null;
  27 | 
  28 | // --- Initialization ---
  29 | async function initializeGoogleClient() {
  30 | if (googleDocs && googleDrive) return { authClient, googleDocs, googleDrive };
  31 | if (!authClient) { // Check authClient instead of googleDocs to allow re-attempt
  32 | try {
  33 | console.error("Attempting to authorize Google API client...");
  34 | const client = await authorize();
  35 | authClient = client; // Assign client here
  36 | googleDocs = google.docs({ version: 'v1', auth: authClient });
  37 | googleDrive = google.drive({ version: 'v3', auth: authClient });
  38 | console.error("Google API client authorized successfully.");
  39 | } catch (error) {
  40 | console.error("FATAL: Failed to initialize Google API client:", error);
  41 | authClient = null; // Reset on failure
  42 | googleDocs = null;
  43 | googleDrive = null;
  44 | // Decide if server should exit or just fail tools
  45 | throw new Error("Google client initialization failed. Cannot start server tools.");
  46 | }
  47 | }
  48 | // Ensure googleDocs and googleDrive are set if authClient is valid
  49 | if (authClient && !googleDocs) {
  50 | googleDocs = google.docs({ version: 'v1', auth: authClient });
  51 | }
  52 | if (authClient && !googleDrive) {
  53 | googleDrive = google.drive({ version: 'v3', auth: authClient });
  54 | }
  55 | 
  56 | if (!googleDocs || !googleDrive) {
  57 | throw new Error("Google Docs and Drive clients could not be initialized.");
  58 | }
  59 | 
  60 | return { authClient, googleDocs, googleDrive };
  61 | }
  62 | 
  63 | // Set up process-level unhandled error/rejection handlers to prevent crashes
  64 | process.on('uncaughtException', (error) => {
  65 |   console.error('Uncaught Exception:', error);
  66 |   // Don't exit process, just log the error and continue
  67 |   // This will catch timeout errors that might otherwise crash the server
  68 | });
  69 | 
  70 | process.on('unhandledRejection', (reason, promise) => {
  71 |   console.error('Unhandled Promise Rejection:', reason);
  72 |   // Don't exit process, just log the error and continue
  73 | });
  74 | 
  75 | const server = new FastMCP({
  76 |   name: 'Ultimate Google Docs MCP Server',
  77 |   version: '1.0.0'
  78 | });
  79 | 
  80 | // --- Helper to get Docs client within tools ---
  81 | async function getDocsClient() {
  82 | const { googleDocs: docs } = await initializeGoogleClient();
  83 | if (!docs) {
  84 | throw new UserError("Google Docs client is not initialized. Authentication might have failed during startup or lost connection.");
  85 | }
  86 | return docs;
  87 | }
  88 | 
  89 | // --- Helper to get Drive client within tools ---
  90 | async function getDriveClient() {
  91 | const { googleDrive: drive } = await initializeGoogleClient();
  92 | if (!drive) {
  93 | throw new UserError("Google Drive client is not initialized. Authentication might have failed during startup or lost connection.");
  94 | }
  95 | return drive;
  96 | }
  97 | 
  98 | // === HELPER FUNCTIONS ===
  99 | 
 100 | /**
 101 |  * Converts Google Docs JSON structure to Markdown format
 102 |  */
 103 | function convertDocsJsonToMarkdown(docData: any): string {
 104 |     let markdown = '';
 105 |     
 106 |     if (!docData.body?.content) {
 107 |         return 'Document appears to be empty.';
 108 |     }
 109 |     
 110 |     docData.body.content.forEach((element: any) => {
 111 |         if (element.paragraph) {
 112 |             markdown += convertParagraphToMarkdown(element.paragraph);
 113 |         } else if (element.table) {
 114 |             markdown += convertTableToMarkdown(element.table);
 115 |         } else if (element.sectionBreak) {
 116 |             markdown += '\n---\n\n'; // Section break as horizontal rule
 117 |         }
 118 |     });
 119 |     
 120 |     return markdown.trim();
 121 | }
 122 | 
 123 | /**
 124 |  * Converts a paragraph element to markdown
 125 |  */
 126 | function convertParagraphToMarkdown(paragraph: any): string {
 127 |     let text = '';
 128 |     let isHeading = false;
 129 |     let headingLevel = 0;
 130 |     let isList = false;
 131 |     let listType = '';
 132 |     
 133 |     // Check paragraph style for headings and lists
 134 |     if (paragraph.paragraphStyle?.namedStyleType) {
 135 |         const styleType = paragraph.paragraphStyle.namedStyleType;
 136 |         if (styleType.startsWith('HEADING_')) {
 137 |             isHeading = true;
 138 |             headingLevel = parseInt(styleType.replace('HEADING_', ''));
 139 |         } else if (styleType === 'TITLE') {
 140 |             isHeading = true;
 141 |             headingLevel = 1;
 142 |         } else if (styleType === 'SUBTITLE') {
 143 |             isHeading = true;
 144 |             headingLevel = 2;
 145 |         }
 146 |     }
 147 |     
 148 |     // Check for bullet lists
 149 |     if (paragraph.bullet) {
 150 |         isList = true;
 151 |         listType = paragraph.bullet.listId ? 'bullet' : 'bullet';
 152 |     }
 153 |     
 154 |     // Process text elements
 155 |     if (paragraph.elements) {
 156 |         paragraph.elements.forEach((element: any) => {
 157 |             if (element.textRun) {
 158 |                 text += convertTextRunToMarkdown(element.textRun);
 159 |             }
 160 |         });
 161 |     }
 162 |     
 163 |     // Format based on style
 164 |     if (isHeading && text.trim()) {
 165 |         const hashes = '#'.repeat(Math.min(headingLevel, 6));
 166 |         return `${hashes} ${text.trim()}\n\n`;
 167 |     } else if (isList && text.trim()) {
 168 |         return `- ${text.trim()}\n`;
 169 |     } else if (text.trim()) {
 170 |         return `${text.trim()}\n\n`;
 171 |     }
 172 |     
 173 |     return '\n'; // Empty paragraph
 174 | }
 175 | 
 176 | /**
 177 |  * Converts a text run to markdown with formatting
 178 |  */
 179 | function convertTextRunToMarkdown(textRun: any): string {
 180 |     let text = textRun.content || '';
 181 |     
 182 |     if (textRun.textStyle) {
 183 |         const style = textRun.textStyle;
 184 |         
 185 |         // Apply formatting
 186 |         if (style.bold && style.italic) {
 187 |             text = `***${text}***`;
 188 |         } else if (style.bold) {
 189 |             text = `**${text}**`;
 190 |         } else if (style.italic) {
 191 |             text = `*${text}*`;
 192 |         }
 193 |         
 194 |         if (style.underline && !style.link) {
 195 |             // Markdown doesn't have native underline, use HTML
 196 |             text = `<u>${text}</u>`;
 197 |         }
 198 |         
 199 |         if (style.strikethrough) {
 200 |             text = `~~${text}~~`;
 201 |         }
 202 |         
 203 |         if (style.link?.url) {
 204 |             text = `[${text}](${style.link.url})`;
 205 |         }
 206 |     }
 207 |     
 208 |     return text;
 209 | }
 210 | 
 211 | /**
 212 |  * Converts a table to markdown format
 213 |  */
 214 | function convertTableToMarkdown(table: any): string {
 215 |     if (!table.tableRows || table.tableRows.length === 0) {
 216 |         return '';
 217 |     }
 218 |     
 219 |     let markdown = '\n';
 220 |     let isFirstRow = true;
 221 |     
 222 |     table.tableRows.forEach((row: any) => {
 223 |         if (!row.tableCells) return;
 224 |         
 225 |         let rowText = '|';
 226 |         row.tableCells.forEach((cell: any) => {
 227 |             let cellText = '';
 228 |             if (cell.content) {
 229 |                 cell.content.forEach((element: any) => {
 230 |                     if (element.paragraph?.elements) {
 231 |                         element.paragraph.elements.forEach((pe: any) => {
 232 |                             if (pe.textRun?.content) {
 233 |                                 cellText += pe.textRun.content.replace(/\n/g, ' ').trim();
 234 |                             }
 235 |                         });
 236 |                     }
 237 |                 });
 238 |             }
 239 |             rowText += ` ${cellText} |`;
 240 |         });
 241 |         
 242 |         markdown += rowText + '\n';
 243 |         
 244 |         // Add header separator after first row
 245 |         if (isFirstRow) {
 246 |             let separator = '|';
 247 |             for (let i = 0; i < row.tableCells.length; i++) {
 248 |                 separator += ' --- |';
 249 |             }
 250 |             markdown += separator + '\n';
 251 |             isFirstRow = false;
 252 |         }
 253 |     });
 254 |     
 255 |     return markdown + '\n';
 256 | }
 257 | 
 258 | // === TOOL DEFINITIONS ===
 259 | 
 260 | // --- Foundational Tools ---
 261 | 
 262 | server.addTool({
 263 | name: 'readGoogleDoc',
 264 | description: 'Reads the content of a specific Google Document, optionally returning structured data.',
 265 | parameters: DocumentIdParameter.extend({
 266 | format: z.enum(['text', 'json', 'markdown']).optional().default('text')
 267 | .describe("Output format: 'text' (plain text), 'json' (raw API structure, complex), 'markdown' (experimental conversion)."),
 268 | maxLength: z.number().optional().describe('Maximum character limit for text output. If not specified, returns full document content. Use this to limit very large documents.')
 269 | }),
 270 | execute: async (args, { log }) => {
 271 | const docs = await getDocsClient();
 272 | log.info(`Reading Google Doc: ${args.documentId}, Format: ${args.format}`);
 273 | 
 274 |     try {
 275 |         const fields = args.format === 'json' || args.format === 'markdown'
 276 |             ? '*' // Get everything for structure analysis
 277 |             : 'body(content(paragraph(elements(textRun(content)))))'; // Just text content
 278 | 
 279 |         const res = await docs.documents.get({
 280 |             documentId: args.documentId,
 281 |             fields: fields,
 282 |         });
 283 |         log.info(`Fetched doc: ${args.documentId}`);
 284 | 
 285 |         if (args.format === 'json') {
 286 |             const jsonContent = JSON.stringify(res.data, null, 2);
 287 |             // Apply length limit to JSON if specified
 288 |             if (args.maxLength && jsonContent.length > args.maxLength) {
 289 |                 return jsonContent.substring(0, args.maxLength) + `\n... [JSON truncated: ${jsonContent.length} total chars]`;
 290 |             }
 291 |             return jsonContent;
 292 |         }
 293 | 
 294 |         if (args.format === 'markdown') {
 295 |             const markdownContent = convertDocsJsonToMarkdown(res.data);
 296 |             const totalLength = markdownContent.length;
 297 |             log.info(`Generated markdown: ${totalLength} characters`);
 298 |             
 299 |             // Apply length limit to markdown if specified
 300 |             if (args.maxLength && totalLength > args.maxLength) {
 301 |                 const truncatedContent = markdownContent.substring(0, args.maxLength);
 302 |                 return `${truncatedContent}\n\n... [Markdown truncated to ${args.maxLength} chars of ${totalLength} total. Use maxLength parameter to adjust limit or remove it to get full content.]`;
 303 |             }
 304 |             
 305 |             return markdownContent;
 306 |         }
 307 | 
 308 |         // Default: Text format - extract all text content
 309 |         let textContent = '';
 310 |         let elementCount = 0;
 311 |         
 312 |         // Process all content elements
 313 |         res.data.body?.content?.forEach(element => {
 314 |             elementCount++;
 315 |             
 316 |             // Handle paragraphs
 317 |             if (element.paragraph?.elements) {
 318 |                 element.paragraph.elements.forEach(pe => {
 319 |                     if (pe.textRun?.content) {
 320 |                         textContent += pe.textRun.content;
 321 |                     }
 322 |                 });
 323 |             }
 324 |             
 325 |             // Handle tables
 326 |             if (element.table?.tableRows) {
 327 |                 element.table.tableRows.forEach(row => {
 328 |                     row.tableCells?.forEach(cell => {
 329 |                         cell.content?.forEach(cellElement => {
 330 |                             cellElement.paragraph?.elements?.forEach(pe => {
 331 |                                 if (pe.textRun?.content) {
 332 |                                     textContent += pe.textRun.content;
 333 |                                 }
 334 |                             });
 335 |                         });
 336 |                     });
 337 |                 });
 338 |             }
 339 |         });
 340 | 
 341 |         if (!textContent.trim()) return "Document found, but appears empty.";
 342 | 
 343 |         const totalLength = textContent.length;
 344 |         log.info(`Document contains ${totalLength} characters across ${elementCount} elements`);
 345 |         log.info(`maxLength parameter: ${args.maxLength || 'not specified'}`);
 346 | 
 347 |         // Apply length limit only if specified
 348 |         if (args.maxLength && totalLength > args.maxLength) {
 349 |             const truncatedContent = textContent.substring(0, args.maxLength);
 350 |             log.info(`Truncating content from ${totalLength} to ${args.maxLength} characters`);
 351 |             return `Content (truncated to ${args.maxLength} chars of ${totalLength} total):\n---\n${truncatedContent}\n\n... [Document continues for ${totalLength - args.maxLength} more characters. Use maxLength parameter to adjust limit or remove it to get full content.]`;
 352 |         }
 353 | 
 354 |         // Return full content
 355 |         const fullResponse = `Content (${totalLength} characters):\n---\n${textContent}`;
 356 |         const responseLength = fullResponse.length;
 357 |         log.info(`Returning full content: ${responseLength} characters in response (${totalLength} content + ${responseLength - totalLength} metadata)`);
 358 |         
 359 |         return fullResponse;
 360 | 
 361 |     } catch (error: any) {
 362 |          log.error(`Error reading doc ${args.documentId}: ${error.message || error}`);
 363 |          // Handle errors thrown by helpers or API directly
 364 |          if (error instanceof UserError) throw error;
 365 |          if (error instanceof NotImplementedError) throw error;
 366 |          // Generic fallback for API errors not caught by helpers
 367 |           if (error.code === 404) throw new UserError(`Doc not found (ID: ${args.documentId}).`);
 368 |           if (error.code === 403) throw new UserError(`Permission denied for doc (ID: ${args.documentId}).`);
 369 |          throw new UserError(`Failed to read doc: ${error.message || 'Unknown error'}`);
 370 |     }
 371 | 
 372 | },
 373 | });
 374 | 
 375 | server.addTool({
 376 | name: 'appendToGoogleDoc',
 377 | description: 'Appends text to the very end of a specific Google Document.',
 378 | parameters: DocumentIdParameter.extend({
 379 | textToAppend: z.string().min(1).describe('The text to add to the end.'),
 380 | addNewlineIfNeeded: z.boolean().optional().default(true).describe("Automatically add a newline before the appended text if the doc doesn't end with one."),
 381 | }),
 382 | execute: async (args, { log }) => {
 383 | const docs = await getDocsClient();
 384 | log.info(`Appending to Google Doc: ${args.documentId}`);
 385 | 
 386 |     try {
 387 |         // Get the current end index
 388 |         const docInfo = await docs.documents.get({ documentId: args.documentId, fields: 'body(content(endIndex)),documentStyle(pageSize)' }); // Need content for endIndex
 389 |         let endIndex = 1;
 390 |         let lastCharIsNewline = false;
 391 |         if (docInfo.data.body?.content) {
 392 |             const lastElement = docInfo.data.body.content[docInfo.data.body.content.length - 1];
 393 |              if (lastElement?.endIndex) {
 394 |                 endIndex = lastElement.endIndex -1; // Insert *before* the final newline of the doc typically
 395 |                 // Crude check for last character (better check would involve reading last text run)
 396 |                  // const lastTextRun = ... find last text run ...
 397 |                  // if (lastTextRun?.content?.endsWith('\n')) lastCharIsNewline = true;
 398 |             }
 399 |         }
 400 |         // Simpler approach: Always assume insertion is needed unless explicitly told not to add newline
 401 |         const textToInsert = (args.addNewlineIfNeeded && endIndex > 1 ? '\n' : '') + args.textToAppend;
 402 | 
 403 |         if (!textToInsert) return "Nothing to append.";
 404 | 
 405 |         const request: docs_v1.Schema$Request = { insertText: { location: { index: endIndex }, text: textToInsert } };
 406 |         await GDocsHelpers.executeBatchUpdate(docs, args.documentId, [request]);
 407 | 
 408 |         log.info(`Successfully appended to doc: ${args.documentId}`);
 409 |         return `Successfully appended text to document ${args.documentId}.`;
 410 |     } catch (error: any) {
 411 |          log.error(`Error appending to doc ${args.documentId}: ${error.message || error}`);
 412 |          if (error instanceof UserError) throw error;
 413 |          if (error instanceof NotImplementedError) throw error;
 414 |          throw new UserError(`Failed to append to doc: ${error.message || 'Unknown error'}`);
 415 |     }
 416 | 
 417 | },
 418 | });
 419 | 
 420 | server.addTool({
 421 | name: 'insertText',
 422 | description: 'Inserts text at a specific index within the document body.',
 423 | parameters: DocumentIdParameter.extend({
 424 | textToInsert: z.string().min(1).describe('The text to insert.'),
 425 | index: z.number().int().min(1).describe('The index (1-based) where the text should be inserted.'),
 426 | }),
 427 | execute: async (args, { log }) => {
 428 | const docs = await getDocsClient();
 429 | log.info(`Inserting text in doc ${args.documentId} at index ${args.index}`);
 430 | try {
 431 | await GDocsHelpers.insertText(docs, args.documentId, args.textToInsert, args.index);
 432 | return `Successfully inserted text at index ${args.index}.`;
 433 | } catch (error: any) {
 434 | log.error(`Error inserting text in doc ${args.documentId}: ${error.message || error}`);
 435 | if (error instanceof UserError) throw error;
 436 | throw new UserError(`Failed to insert text: ${error.message || 'Unknown error'}`);
 437 | }
 438 | }
 439 | });
 440 | 
 441 | server.addTool({
 442 | name: 'deleteRange',
 443 | description: 'Deletes content within a specified range (start index inclusive, end index exclusive).',
 444 | parameters: DocumentIdParameter.extend({
 445 |   startIndex: z.number().int().min(1).describe('The starting index of the text range (inclusive, starts from 1).'),
 446 |   endIndex: z.number().int().min(1).describe('The ending index of the text range (exclusive).')
 447 | }).refine(data => data.endIndex > data.startIndex, {
 448 |   message: "endIndex must be greater than startIndex",
 449 |   path: ["endIndex"],
 450 | }),
 451 | execute: async (args, { log }) => {
 452 | const docs = await getDocsClient();
 453 | log.info(`Deleting range ${args.startIndex}-${args.endIndex} in doc ${args.documentId}`);
 454 | if (args.endIndex <= args.startIndex) {
 455 | throw new UserError("End index must be greater than start index for deletion.");
 456 | }
 457 | try {
 458 | const request: docs_v1.Schema$Request = {
 459 |                 deleteContentRange: {
 460 |                     range: { startIndex: args.startIndex, endIndex: args.endIndex }
 461 |                 }
 462 |             };
 463 |             await GDocsHelpers.executeBatchUpdate(docs, args.documentId, [request]);
 464 |             return `Successfully deleted content in range ${args.startIndex}-${args.endIndex}.`;
 465 |         } catch (error: any) {
 466 |             log.error(`Error deleting range in doc ${args.documentId}: ${error.message || error}`);
 467 |             if (error instanceof UserError) throw error;
 468 |             throw new UserError(`Failed to delete range: ${error.message || 'Unknown error'}`);
 469 | }
 470 | }
 471 | });
 472 | 
 473 | // --- Advanced Formatting & Styling Tools ---
 474 | 
 475 | server.addTool({
 476 | name: 'applyTextStyle',
 477 | description: 'Applies character-level formatting (bold, color, font, etc.) to a specific range or found text.',
 478 | parameters: ApplyTextStyleToolParameters,
 479 | execute: async (args: ApplyTextStyleToolArgs, { log }) => {
 480 | const docs = await getDocsClient();
 481 | let { startIndex, endIndex } = args.target as any; // Will be updated if target is text
 482 | 
 483 |         log.info(`Applying text style in doc ${args.documentId}. Target: ${JSON.stringify(args.target)}, Style: ${JSON.stringify(args.style)}`);
 484 | 
 485 |         try {
 486 |             // Determine target range
 487 |             if ('textToFind' in args.target) {
 488 |                 const range = await GDocsHelpers.findTextRange(docs, args.documentId, args.target.textToFind, args.target.matchInstance);
 489 |                 if (!range) {
 490 |                     throw new UserError(`Could not find instance ${args.target.matchInstance} of text "${args.target.textToFind}".`);
 491 |                 }
 492 |                 startIndex = range.startIndex;
 493 |                 endIndex = range.endIndex;
 494 |                 log.info(`Found text "${args.target.textToFind}" (instance ${args.target.matchInstance}) at range ${startIndex}-${endIndex}`);
 495 |             }
 496 | 
 497 |             if (startIndex === undefined || endIndex === undefined) {
 498 |                  throw new UserError("Target range could not be determined.");
 499 |             }
 500 |              if (endIndex <= startIndex) {
 501 |                  throw new UserError("End index must be greater than start index for styling.");
 502 |             }
 503 | 
 504 |             // Build the request
 505 |             const requestInfo = GDocsHelpers.buildUpdateTextStyleRequest(startIndex, endIndex, args.style);
 506 |             if (!requestInfo) {
 507 |                  return "No valid text styling options were provided.";
 508 |             }
 509 | 
 510 |             await GDocsHelpers.executeBatchUpdate(docs, args.documentId, [requestInfo.request]);
 511 |             return `Successfully applied text style (${requestInfo.fields.join(', ')}) to range ${startIndex}-${endIndex}.`;
 512 | 
 513 |         } catch (error: any) {
 514 |             log.error(`Error applying text style in doc ${args.documentId}: ${error.message || error}`);
 515 |             if (error instanceof UserError) throw error;
 516 |             if (error instanceof NotImplementedError) throw error; // Should not happen here
 517 |             throw new UserError(`Failed to apply text style: ${error.message || 'Unknown error'}`);
 518 |         }
 519 |     }
 520 | 
 521 | });
 522 | 
 523 | server.addTool({
 524 | name: 'applyParagraphStyle',
 525 | description: 'Applies paragraph-level formatting (alignment, spacing, named styles like Heading 1) to the paragraph(s) containing specific text, an index, or a range.',
 526 | parameters: ApplyParagraphStyleToolParameters,
 527 | execute: async (args: ApplyParagraphStyleToolArgs, { log }) => {
 528 | const docs = await getDocsClient();
 529 | let startIndex: number | undefined;
 530 | let endIndex: number | undefined;
 531 | 
 532 |         log.info(`Applying paragraph style to document ${args.documentId}`);
 533 |         log.info(`Style options: ${JSON.stringify(args.style)}`);
 534 |         log.info(`Target specification: ${JSON.stringify(args.target)}`);
 535 | 
 536 |         try {
 537 |             // STEP 1: Determine the target paragraph's range based on the targeting method
 538 |             if ('textToFind' in args.target) {
 539 |                 // Find the text first
 540 |                 log.info(`Finding text "${args.target.textToFind}" (instance ${args.target.matchInstance || 1})`);
 541 |                 const textRange = await GDocsHelpers.findTextRange(
 542 |                     docs,
 543 |                     args.documentId,
 544 |                     args.target.textToFind,
 545 |                     args.target.matchInstance || 1
 546 |                 );
 547 | 
 548 |                 if (!textRange) {
 549 |                     throw new UserError(`Could not find "${args.target.textToFind}" in the document.`);
 550 |                 }
 551 | 
 552 |                 log.info(`Found text at range ${textRange.startIndex}-${textRange.endIndex}, now locating containing paragraph`);
 553 | 
 554 |                 // Then find the paragraph containing this text
 555 |                 const paragraphRange = await GDocsHelpers.getParagraphRange(
 556 |                     docs,
 557 |                     args.documentId,
 558 |                     textRange.startIndex
 559 |                 );
 560 | 
 561 |                 if (!paragraphRange) {
 562 |                     throw new UserError(`Found the text but could not determine the paragraph boundaries.`);
 563 |                 }
 564 | 
 565 |                 startIndex = paragraphRange.startIndex;
 566 |                 endIndex = paragraphRange.endIndex;
 567 |                 log.info(`Text is contained within paragraph at range ${startIndex}-${endIndex}`);
 568 | 
 569 |             } else if ('indexWithinParagraph' in args.target) {
 570 |                 // Find paragraph containing the specified index
 571 |                 log.info(`Finding paragraph containing index ${args.target.indexWithinParagraph}`);
 572 |                 const paragraphRange = await GDocsHelpers.getParagraphRange(
 573 |                     docs,
 574 |                     args.documentId,
 575 |                     args.target.indexWithinParagraph
 576 |                 );
 577 | 
 578 |                 if (!paragraphRange) {
 579 |                     throw new UserError(`Could not find paragraph containing index ${args.target.indexWithinParagraph}.`);
 580 |                 }
 581 | 
 582 |                 startIndex = paragraphRange.startIndex;
 583 |                 endIndex = paragraphRange.endIndex;
 584 |                 log.info(`Located paragraph at range ${startIndex}-${endIndex}`);
 585 | 
 586 |             } else if ('startIndex' in args.target && 'endIndex' in args.target) {
 587 |                 // Use directly provided range
 588 |                 startIndex = args.target.startIndex;
 589 |                 endIndex = args.target.endIndex;
 590 |                 log.info(`Using provided paragraph range ${startIndex}-${endIndex}`);
 591 |             }
 592 | 
 593 |             // Verify that we have a valid range
 594 |             if (startIndex === undefined || endIndex === undefined) {
 595 |                 throw new UserError("Could not determine target paragraph range from the provided information.");
 596 |             }
 597 | 
 598 |             if (endIndex <= startIndex) {
 599 |                 throw new UserError(`Invalid paragraph range: end index (${endIndex}) must be greater than start index (${startIndex}).`);
 600 |             }
 601 | 
 602 |             // STEP 2: Build and apply the paragraph style request
 603 |             log.info(`Building paragraph style request for range ${startIndex}-${endIndex}`);
 604 |             const requestInfo = GDocsHelpers.buildUpdateParagraphStyleRequest(startIndex, endIndex, args.style);
 605 | 
 606 |             if (!requestInfo) {
 607 |                 return "No valid paragraph styling options were provided.";
 608 |             }
 609 | 
 610 |             log.info(`Applying styles: ${requestInfo.fields.join(', ')}`);
 611 |             await GDocsHelpers.executeBatchUpdate(docs, args.documentId, [requestInfo.request]);
 612 | 
 613 |             return `Successfully applied paragraph styles (${requestInfo.fields.join(', ')}) to the paragraph.`;
 614 | 
 615 |         } catch (error: any) {
 616 |             // Detailed error logging
 617 |             log.error(`Error applying paragraph style in doc ${args.documentId}:`);
 618 |             log.error(error.stack || error.message || error);
 619 | 
 620 |             if (error instanceof UserError) throw error;
 621 |             if (error instanceof NotImplementedError) throw error;
 622 | 
 623 |             // Provide a more helpful error message
 624 |             throw new UserError(`Failed to apply paragraph style: ${error.message || 'Unknown error'}`);
 625 |         }
 626 |     }
 627 | });
 628 | 
 629 | // --- Structure & Content Tools ---
 630 | 
 631 | server.addTool({
 632 | name: 'insertTable',
 633 | description: 'Inserts a new table with the specified dimensions at a given index.',
 634 | parameters: DocumentIdParameter.extend({
 635 | rows: z.number().int().min(1).describe('Number of rows for the new table.'),
 636 | columns: z.number().int().min(1).describe('Number of columns for the new table.'),
 637 | index: z.number().int().min(1).describe('The index (1-based) where the table should be inserted.'),
 638 | }),
 639 | execute: async (args, { log }) => {
 640 | const docs = await getDocsClient();
 641 | log.info(`Inserting ${args.rows}x${args.columns} table in doc ${args.documentId} at index ${args.index}`);
 642 | try {
 643 | await GDocsHelpers.createTable(docs, args.documentId, args.rows, args.columns, args.index);
 644 | // The API response contains info about the created table, but might be too complex to return here.
 645 | return `Successfully inserted a ${args.rows}x${args.columns} table at index ${args.index}.`;
 646 | } catch (error: any) {
 647 | log.error(`Error inserting table in doc ${args.documentId}: ${error.message || error}`);
 648 | if (error instanceof UserError) throw error;
 649 | throw new UserError(`Failed to insert table: ${error.message || 'Unknown error'}`);
 650 | }
 651 | }
 652 | });
 653 | 
 654 | server.addTool({
 655 | name: 'editTableCell',
 656 | description: 'Edits the content and/or basic style of a specific table cell. Requires knowing table start index.',
 657 | parameters: DocumentIdParameter.extend({
 658 | tableStartIndex: z.number().int().min(1).describe("The starting index of the TABLE element itself (tricky to find, may require reading structure first)."),
 659 | rowIndex: z.number().int().min(0).describe("Row index (0-based)."),
 660 | columnIndex: z.number().int().min(0).describe("Column index (0-based)."),
 661 | textContent: z.string().optional().describe("Optional: New text content for the cell. Replaces existing content."),
 662 | // Combine basic styles for simplicity here. More advanced cell styling might need separate tools.
 663 | textStyle: TextStyleParameters.optional().describe("Optional: Text styles to apply."),
 664 | paragraphStyle: ParagraphStyleParameters.optional().describe("Optional: Paragraph styles (like alignment) to apply."),
 665 | // cellBackgroundColor: z.string().optional()... // Cell-specific styles are complex
 666 | }),
 667 | execute: async (args, { log }) => {
 668 | const docs = await getDocsClient();
 669 | log.info(`Editing cell (${args.rowIndex}, ${args.columnIndex}) in table starting at ${args.tableStartIndex}, doc ${args.documentId}`);
 670 | 
 671 |         // TODO: Implement complex logic
 672 |         // 1. Find the cell's content range based on tableStartIndex, rowIndex, columnIndex. This is NON-TRIVIAL.
 673 |         //    Requires getting the document, finding the table element, iterating through rows/cells to calculate indices.
 674 |         // 2. If textContent is provided, generate a DeleteContentRange request for the cell's current content.
 675 |         // 3. Generate an InsertText request for the new textContent at the cell's start index.
 676 |         // 4. If textStyle is provided, generate UpdateTextStyle requests for the new text range.
 677 |         // 5. If paragraphStyle is provided, generate UpdateParagraphStyle requests for the cell's paragraph range.
 678 |         // 6. Execute batch update.
 679 | 
 680 |         log.error("editTableCell is not implemented due to complexity of finding cell indices.");
 681 |         throw new NotImplementedError("Editing table cells is complex and not yet implemented.");
 682 |         // return `Edit request for cell (${args.rowIndex}, ${args.columnIndex}) submitted (Not Implemented).`;
 683 |     }
 684 | 
 685 | });
 686 | 
 687 | server.addTool({
 688 | name: 'insertPageBreak',
 689 | description: 'Inserts a page break at the specified index.',
 690 | parameters: DocumentIdParameter.extend({
 691 | index: z.number().int().min(1).describe('The index (1-based) where the page break should be inserted.'),
 692 | }),
 693 | execute: async (args, { log }) => {
 694 | const docs = await getDocsClient();
 695 | log.info(`Inserting page break in doc ${args.documentId} at index ${args.index}`);
 696 | try {
 697 | const request: docs_v1.Schema$Request = {
 698 | insertPageBreak: {
 699 | location: { index: args.index }
 700 | }
 701 | };
 702 | await GDocsHelpers.executeBatchUpdate(docs, args.documentId, [request]);
 703 | return `Successfully inserted page break at index ${args.index}.`;
 704 | } catch (error: any) {
 705 | log.error(`Error inserting page break in doc ${args.documentId}: ${error.message || error}`);
 706 | if (error instanceof UserError) throw error;
 707 | throw new UserError(`Failed to insert page break: ${error.message || 'Unknown error'}`);
 708 | }
 709 | }
 710 | });
 711 | 
 712 | // --- Image Insertion Tools ---
 713 | 
 714 | server.addTool({
 715 | name: 'insertImageFromUrl',
 716 | description: 'Inserts an inline image into a Google Document from a publicly accessible URL.',
 717 | parameters: DocumentIdParameter.extend({
 718 | imageUrl: z.string().url().describe('Publicly accessible URL to the image (must be http:// or https://).'),
 719 | index: z.number().int().min(1).describe('The index (1-based) where the image should be inserted.'),
 720 | width: z.number().min(1).optional().describe('Optional: Width of the image in points.'),
 721 | height: z.number().min(1).optional().describe('Optional: Height of the image in points.'),
 722 | }),
 723 | execute: async (args, { log }) => {
 724 | const docs = await getDocsClient();
 725 | log.info(`Inserting image from URL ${args.imageUrl} at index ${args.index} in doc ${args.documentId}`);
 726 | 
 727 | try {
 728 | await GDocsHelpers.insertInlineImage(
 729 | docs,
 730 | args.documentId,
 731 | args.imageUrl,
 732 | args.index,
 733 | args.width,
 734 | args.height
 735 | );
 736 | 
 737 | let sizeInfo = '';
 738 | if (args.width && args.height) {
 739 | sizeInfo = ` with size ${args.width}x${args.height}pt`;
 740 | }
 741 | 
 742 | return `Successfully inserted image from URL at index ${args.index}${sizeInfo}.`;
 743 | } catch (error: any) {
 744 | log.error(`Error inserting image in doc ${args.documentId}: ${error.message || error}`);
 745 | if (error instanceof UserError) throw error;
 746 | throw new UserError(`Failed to insert image: ${error.message || 'Unknown error'}`);
 747 | }
 748 | }
 749 | });
 750 | 
 751 | server.addTool({
 752 | name: 'insertLocalImage',
 753 | description: 'Uploads a local image file to Google Drive and inserts it into a Google Document. The image will be uploaded to the same folder as the document (or optionally to a specified folder).',
 754 | parameters: DocumentIdParameter.extend({
 755 | localImagePath: z.string().describe('Absolute path to the local image file (supports .jpg, .jpeg, .png, .gif, .bmp, .webp, .svg).'),
 756 | index: z.number().int().min(1).describe('The index (1-based) where the image should be inserted in the document.'),
 757 | width: z.number().min(1).optional().describe('Optional: Width of the image in points.'),
 758 | height: z.number().min(1).optional().describe('Optional: Height of the image in points.'),
 759 | uploadToSameFolder: z.boolean().optional().default(true).describe('If true, uploads the image to the same folder as the document. If false, uploads to Drive root.'),
 760 | }),
 761 | execute: async (args, { log }) => {
 762 | const docs = await getDocsClient();
 763 | const drive = await getDriveClient();
 764 | log.info(`Uploading local image ${args.localImagePath} and inserting at index ${args.index} in doc ${args.documentId}`);
 765 | 
 766 | try {
 767 | // Get the document's parent folder if requested
 768 | let parentFolderId: string | undefined;
 769 | if (args.uploadToSameFolder) {
 770 | try {
 771 | const docInfo = await drive.files.get({
 772 | fileId: args.documentId,
 773 | fields: 'parents'
 774 | });
 775 | if (docInfo.data.parents && docInfo.data.parents.length > 0) {
 776 | parentFolderId = docInfo.data.parents[0];
 777 | log.info(`Will upload image to document's parent folder: ${parentFolderId}`);
 778 | }
 779 | } catch (folderError) {
 780 | log.warn(`Could not determine document's parent folder, using Drive root: ${folderError}`);
 781 | }
 782 | }
 783 | 
 784 | // Upload the image to Drive
 785 | log.info(`Uploading image to Drive...`);
 786 | const imageUrl = await GDocsHelpers.uploadImageToDrive(
 787 | drive,
 788 | args.localImagePath,
 789 | parentFolderId
 790 | );
 791 | log.info(`Image uploaded successfully, public URL: ${imageUrl}`);
 792 | 
 793 | // Insert the image into the document
 794 | await GDocsHelpers.insertInlineImage(
 795 | docs,
 796 | args.documentId,
 797 | imageUrl,
 798 | args.index,
 799 | args.width,
 800 | args.height
 801 | );
 802 | 
 803 | let sizeInfo = '';
 804 | if (args.width && args.height) {
 805 | sizeInfo = ` with size ${args.width}x${args.height}pt`;
 806 | }
 807 | 
 808 | return `Successfully uploaded image to Drive and inserted it at index ${args.index}${sizeInfo}.\nImage URL: ${imageUrl}`;
 809 | } catch (error: any) {
 810 | log.error(`Error uploading/inserting local image in doc ${args.documentId}: ${error.message || error}`);
 811 | if (error instanceof UserError) throw error;
 812 | throw new UserError(`Failed to upload/insert local image: ${error.message || 'Unknown error'}`);
 813 | }
 814 | }
 815 | });
 816 | 
 817 | // --- Intelligent Assistance Tools (Examples/Stubs) ---
 818 | 
 819 | server.addTool({
 820 | name: 'fixListFormatting',
 821 | 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.',
 822 | parameters: DocumentIdParameter.extend({
 823 | // Optional range to limit the scope, otherwise scans whole doc (potentially slow/risky)
 824 | range: OptionalRangeParameters.optional().describe("Optional: Limit the fixing process to a specific range.")
 825 | }),
 826 | execute: async (args, { log }) => {
 827 | const docs = await getDocsClient();
 828 | log.warn(`Executing EXPERIMENTAL fixListFormatting for doc ${args.documentId}. Range: ${JSON.stringify(args.range)}`);
 829 | try {
 830 | await GDocsHelpers.detectAndFormatLists(docs, args.documentId, args.range?.startIndex, args.range?.endIndex);
 831 | return `Attempted to fix list formatting. Please review the document for accuracy.`;
 832 | } catch (error: any) {
 833 | log.error(`Error fixing list formatting in doc ${args.documentId}: ${error.message || error}`);
 834 | if (error instanceof UserError) throw error;
 835 | if (error instanceof NotImplementedError) throw error; // Expected if helper not implemented
 836 | throw new UserError(`Failed to fix list formatting: ${error.message || 'Unknown error'}`);
 837 | }
 838 | }
 839 | });
 840 | 
 841 | // === COMMENT TOOLS ===
 842 | 
 843 | server.addTool({
 844 |   name: 'listComments',
 845 |   description: 'Lists all comments in a Google Document.',
 846 |   parameters: DocumentIdParameter,
 847 |   execute: async (args, { log }) => {
 848 |     log.info(`Listing comments for document ${args.documentId}`);
 849 |     const docsClient = await getDocsClient();
 850 |     const driveClient = await getDriveClient();
 851 |     
 852 |     try {
 853 |       // First get the document to have context
 854 |       const doc = await docsClient.documents.get({ documentId: args.documentId });
 855 |       
 856 |       // Use Drive API v3 with proper fields to get quoted content
 857 |       const drive = google.drive({ version: 'v3', auth: authClient! });
 858 |       const response = await drive.comments.list({
 859 |         fileId: args.documentId,
 860 |         fields: 'comments(id,content,quotedFileContent,author,createdTime,resolved)',
 861 |         pageSize: 100
 862 |       });
 863 |       
 864 |       const comments = response.data.comments || [];
 865 |       
 866 |       if (comments.length === 0) {
 867 |         return 'No comments found in this document.';
 868 |       }
 869 |       
 870 |       // Format comments for display
 871 |       const formattedComments = comments.map((comment: any, index: number) => {
 872 |         const replies = comment.replies?.length || 0;
 873 |         const status = comment.resolved ? ' [RESOLVED]' : '';
 874 |         const author = comment.author?.displayName || 'Unknown';
 875 |         const date = comment.createdTime ? new Date(comment.createdTime).toLocaleDateString() : 'Unknown date';
 876 |         
 877 |         // Get the actual quoted text content
 878 |         const quotedText = comment.quotedFileContent?.value || 'No quoted text';
 879 |         const anchor = quotedText !== 'No quoted text' ? ` (anchored to: "${quotedText.substring(0, 100)}${quotedText.length > 100 ? '...' : ''}")` : '';
 880 |         
 881 |         let result = `\n${index + 1}. **${author}** (${date})${status}${anchor}\n   ${comment.content}`;
 882 |         
 883 |         if (replies > 0) {
 884 |           result += `\n   └─ ${replies} ${replies === 1 ? 'reply' : 'replies'}`;
 885 |         }
 886 |         
 887 |         result += `\n   Comment ID: ${comment.id}`;
 888 |         
 889 |         return result;
 890 |       }).join('\n');
 891 |       
 892 |       return `Found ${comments.length} comment${comments.length === 1 ? '' : 's'}:\n${formattedComments}`;
 893 |       
 894 |     } catch (error: any) {
 895 |       log.error(`Error listing comments: ${error.message || error}`);
 896 |       throw new UserError(`Failed to list comments: ${error.message || 'Unknown error'}`);
 897 |     }
 898 |   }
 899 | });
 900 | 
 901 | server.addTool({
 902 |   name: 'getComment',
 903 |   description: 'Gets a specific comment with its full thread of replies.',
 904 |   parameters: DocumentIdParameter.extend({
 905 |     commentId: z.string().describe('The ID of the comment to retrieve')
 906 |   }),
 907 |   execute: async (args, { log }) => {
 908 |     log.info(`Getting comment ${args.commentId} from document ${args.documentId}`);
 909 |     
 910 |     try {
 911 |       const drive = google.drive({ version: 'v3', auth: authClient! });
 912 |       const response = await drive.comments.get({
 913 |         fileId: args.documentId,
 914 |         commentId: args.commentId,
 915 |         fields: 'id,content,quotedFileContent,author,createdTime,resolved,replies(id,content,author,createdTime)'
 916 |       });
 917 |       
 918 |       const comment = response.data;
 919 |       const author = comment.author?.displayName || 'Unknown';
 920 |       const date = comment.createdTime ? new Date(comment.createdTime).toLocaleDateString() : 'Unknown date';
 921 |       const status = comment.resolved ? ' [RESOLVED]' : '';
 922 |       const quotedText = comment.quotedFileContent?.value || 'No quoted text';
 923 |       const anchor = quotedText !== 'No quoted text' ? `\nAnchored to: "${quotedText}"` : '';
 924 |       
 925 |       let result = `**${author}** (${date})${status}${anchor}\n${comment.content}`;
 926 |       
 927 |       // Add replies if any
 928 |       if (comment.replies && comment.replies.length > 0) {
 929 |         result += '\n\n**Replies:**';
 930 |         comment.replies.forEach((reply: any, index: number) => {
 931 |           const replyAuthor = reply.author?.displayName || 'Unknown';
 932 |           const replyDate = reply.createdTime ? new Date(reply.createdTime).toLocaleDateString() : 'Unknown date';
 933 |           result += `\n${index + 1}. **${replyAuthor}** (${replyDate})\n   ${reply.content}`;
 934 |         });
 935 |       }
 936 |       
 937 |       return result;
 938 |       
 939 |     } catch (error: any) {
 940 |       log.error(`Error getting comment: ${error.message || error}`);
 941 |       throw new UserError(`Failed to get comment: ${error.message || 'Unknown error'}`);
 942 |     }
 943 |   }
 944 | });
 945 | 
 946 | server.addTool({
 947 |   name: 'addComment',
 948 |   description: 'Adds a comment anchored to a specific text range in the document.',
 949 |   parameters: DocumentIdParameter.extend({
 950 |     startIndex: z.number().int().min(1).describe('The starting index of the text range (inclusive, starts from 1).'),
 951 |     endIndex: z.number().int().min(1).describe('The ending index of the text range (exclusive).'),
 952 |     commentText: z.string().min(1).describe('The content of the comment.'),
 953 |   }).refine(data => data.endIndex > data.startIndex, {
 954 |     message: 'endIndex must be greater than startIndex',
 955 |     path: ['endIndex'],
 956 |   }),
 957 |   execute: async (args, { log }) => {
 958 |     log.info(`Adding comment to range ${args.startIndex}-${args.endIndex} in doc ${args.documentId}`);
 959 |     
 960 |     try {
 961 |       // First, get the text content that will be quoted
 962 |       const docsClient = await getDocsClient();
 963 |       const doc = await docsClient.documents.get({ documentId: args.documentId });
 964 |       
 965 |       // Extract the quoted text from the document
 966 |       let quotedText = '';
 967 |       const content = doc.data.body?.content || [];
 968 |       
 969 |       for (const element of content) {
 970 |         if (element.paragraph) {
 971 |           const elements = element.paragraph.elements || [];
 972 |           for (const textElement of elements) {
 973 |             if (textElement.textRun) {
 974 |               const elementStart = textElement.startIndex || 0;
 975 |               const elementEnd = textElement.endIndex || 0;
 976 |               
 977 |               // Check if this element overlaps with our range
 978 |               if (elementEnd > args.startIndex && elementStart < args.endIndex) {
 979 |                 const text = textElement.textRun.content || '';
 980 |                 const startOffset = Math.max(0, args.startIndex - elementStart);
 981 |                 const endOffset = Math.min(text.length, args.endIndex - elementStart);
 982 |                 quotedText += text.substring(startOffset, endOffset);
 983 |               }
 984 |             }
 985 |           }
 986 |         }
 987 |       }
 988 |       
 989 |       // Use Drive API v3 for comments
 990 |       const drive = google.drive({ version: 'v3', auth: authClient! });
 991 |       
 992 |       const response = await drive.comments.create({
 993 |         fileId: args.documentId,
 994 |         fields: 'id,content,quotedFileContent,author,createdTime,resolved',
 995 |         requestBody: {
 996 |           content: args.commentText,
 997 |           quotedFileContent: {
 998 |             value: quotedText,
 999 |             mimeType: 'text/html'
1000 |           },
1001 |           anchor: JSON.stringify({
1002 |             r: args.documentId,
1003 |             a: [{
1004 |               txt: {
1005 |                 o: args.startIndex - 1,  // Drive API uses 0-based indexing
1006 |                 l: args.endIndex - args.startIndex,
1007 |                 ml: args.endIndex - args.startIndex
1008 |               }
1009 |             }]
1010 |           })
1011 |         }
1012 |       });
1013 |       
1014 |       return `Comment added successfully. Comment ID: ${response.data.id}`;
1015 |       
1016 |     } catch (error: any) {
1017 |       log.error(`Error adding comment: ${error.message || error}`);
1018 |       throw new UserError(`Failed to add comment: ${error.message || 'Unknown error'}`);
1019 |     }
1020 |   }
1021 | });
1022 | 
1023 | server.addTool({
1024 |   name: 'replyToComment',
1025 |   description: 'Adds a reply to an existing comment.',
1026 |   parameters: DocumentIdParameter.extend({
1027 |     commentId: z.string().describe('The ID of the comment to reply to'),
1028 |     replyText: z.string().min(1).describe('The content of the reply')
1029 |   }),
1030 |   execute: async (args, { log }) => {
1031 |     log.info(`Adding reply to comment ${args.commentId} in doc ${args.documentId}`);
1032 |     
1033 |     try {
1034 |       const drive = google.drive({ version: 'v3', auth: authClient! });
1035 |       
1036 |       const response = await drive.replies.create({
1037 |         fileId: args.documentId,
1038 |         commentId: args.commentId,
1039 |         fields: 'id,content,author,createdTime',
1040 |         requestBody: {
1041 |           content: args.replyText
1042 |         }
1043 |       });
1044 |       
1045 |       return `Reply added successfully. Reply ID: ${response.data.id}`;
1046 |       
1047 |     } catch (error: any) {
1048 |       log.error(`Error adding reply: ${error.message || error}`);
1049 |       throw new UserError(`Failed to add reply: ${error.message || 'Unknown error'}`);
1050 |     }
1051 |   }
1052 | });
1053 | 
1054 | server.addTool({
1055 |   name: 'resolveComment',
1056 |   description: 'Marks a comment as resolved.',
1057 |   parameters: DocumentIdParameter.extend({
1058 |     commentId: z.string().describe('The ID of the comment to resolve')
1059 |   }),
1060 |   execute: async (args, { log }) => {
1061 |     log.info(`Resolving comment ${args.commentId} in doc ${args.documentId}`);
1062 |     
1063 |     try {
1064 |       const drive = google.drive({ version: 'v3', auth: authClient! });
1065 |       
1066 |       await drive.comments.update({
1067 |         fileId: args.documentId,
1068 |         commentId: args.commentId,
1069 |         requestBody: {
1070 |           resolved: true
1071 |         }
1072 |       });
1073 |       
1074 |       return `Comment ${args.commentId} has been resolved.`;
1075 |       
1076 |     } catch (error: any) {
1077 |       log.error(`Error resolving comment: ${error.message || error}`);
1078 |       throw new UserError(`Failed to resolve comment: ${error.message || 'Unknown error'}`);
1079 |     }
1080 |   }
1081 | });
1082 | 
1083 | server.addTool({
1084 |   name: 'deleteComment',
1085 |   description: 'Deletes a comment from the document.',
1086 |   parameters: DocumentIdParameter.extend({
1087 |     commentId: z.string().describe('The ID of the comment to delete')
1088 |   }),
1089 |   execute: async (args, { log }) => {
1090 |     log.info(`Deleting comment ${args.commentId} from doc ${args.documentId}`);
1091 |     
1092 |     try {
1093 |       const drive = google.drive({ version: 'v3', auth: authClient! });
1094 |       
1095 |       await drive.comments.delete({
1096 |         fileId: args.documentId,
1097 |         commentId: args.commentId
1098 |       });
1099 |       
1100 |       return `Comment ${args.commentId} has been deleted.`;
1101 |       
1102 |     } catch (error: any) {
1103 |       log.error(`Error deleting comment: ${error.message || error}`);
1104 |       throw new UserError(`Failed to delete comment: ${error.message || 'Unknown error'}`);
1105 |     }
1106 |   }
1107 | });
1108 | 
1109 | // --- Add Stubs for other advanced features ---
1110 | // (findElement, getDocumentMetadata, replaceText, list management, image handling, section breaks, footnotes, etc.)
1111 | // Example Stub:
1112 | server.addTool({
1113 | name: 'findElement',
1114 | description: 'Finds elements (paragraphs, tables, etc.) based on various criteria. (Not Implemented)',
1115 | parameters: DocumentIdParameter.extend({
1116 | // Define complex query parameters...
1117 | textQuery: z.string().optional(),
1118 | elementType: z.enum(['paragraph', 'table', 'list', 'image']).optional(),
1119 | // styleQuery...
1120 | }),
1121 | execute: async (args, { log }) => {
1122 | log.warn("findElement tool called but is not implemented.");
1123 | throw new NotImplementedError("Finding elements by complex criteria is not yet implemented.");
1124 | }
1125 | });
1126 | 
1127 | // --- Preserve the existing formatMatchingText tool for backward compatibility ---
1128 | server.addTool({
1129 | name: 'formatMatchingText',
1130 | description: 'Finds specific text within a Google Document and applies character formatting (bold, italics, color, etc.) to the specified instance.',
1131 | parameters: z.object({
1132 |   documentId: z.string().describe('The ID of the Google Document.'),
1133 |   textToFind: z.string().min(1).describe('The exact text string to find and format.'),
1134 |   matchInstance: z.number().int().min(1).optional().default(1).describe('Which instance of the text to format (1st, 2nd, etc.). Defaults to 1.'),
1135 |   // Re-use optional Formatting Parameters (SHARED)
1136 |   bold: z.boolean().optional().describe('Apply bold formatting.'),
1137 |   italic: z.boolean().optional().describe('Apply italic formatting.'),
1138 |   underline: z.boolean().optional().describe('Apply underline formatting.'),
1139 |   strikethrough: z.boolean().optional().describe('Apply strikethrough formatting.'),
1140 |   fontSize: z.number().min(1).optional().describe('Set font size (in points, e.g., 12).'),
1141 |   fontFamily: z.string().optional().describe('Set font family (e.g., "Arial", "Times New Roman").'),
1142 |   foregroundColor: z.string()
1143 |     .refine((color) => /^#?([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/.test(color), {
1144 |       message: "Invalid hex color format (e.g., #FF0000 or #F00)"
1145 |     })
1146 |     .optional()
1147 |     .describe('Set text color using hex format (e.g., "#FF0000").'),
1148 |   backgroundColor: z.string()
1149 |     .refine((color) => /^#?([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/.test(color), {
1150 |       message: "Invalid hex color format (e.g., #00FF00 or #0F0)"
1151 |     })
1152 |     .optional()
1153 |     .describe('Set text background color using hex format (e.g., "#FFFF00").'),
1154 |   linkUrl: z.string().url().optional().describe('Make the text a hyperlink pointing to this URL.')
1155 | })
1156 | .refine(data => Object.keys(data).some(key => !['documentId', 'textToFind', 'matchInstance'].includes(key) && data[key as keyof typeof data] !== undefined), {
1157 |     message: "At least one formatting option (bold, italic, fontSize, etc.) must be provided."
1158 | }),
1159 | execute: async (args, { log }) => {
1160 |   // Adapt to use the new applyTextStyle implementation under the hood
1161 |   const docs = await getDocsClient();
1162 |   log.info(`Using formatMatchingText (legacy) for doc ${args.documentId}, target: "${args.textToFind}" (instance ${args.matchInstance})`);
1163 | 
1164 |   try {
1165 |     // Extract the style parameters
1166 |     const styleParams: TextStyleArgs = {};
1167 |     if (args.bold !== undefined) styleParams.bold = args.bold;
1168 |     if (args.italic !== undefined) styleParams.italic = args.italic;
1169 |     if (args.underline !== undefined) styleParams.underline = args.underline;
1170 |     if (args.strikethrough !== undefined) styleParams.strikethrough = args.strikethrough;
1171 |     if (args.fontSize !== undefined) styleParams.fontSize = args.fontSize;
1172 |     if (args.fontFamily !== undefined) styleParams.fontFamily = args.fontFamily;
1173 |     if (args.foregroundColor !== undefined) styleParams.foregroundColor = args.foregroundColor;
1174 |     if (args.backgroundColor !== undefined) styleParams.backgroundColor = args.backgroundColor;
1175 |     if (args.linkUrl !== undefined) styleParams.linkUrl = args.linkUrl;
1176 | 
1177 |     // Find the text range
1178 |     const range = await GDocsHelpers.findTextRange(docs, args.documentId, args.textToFind, args.matchInstance);
1179 |     if (!range) {
1180 |       throw new UserError(`Could not find instance ${args.matchInstance} of text "${args.textToFind}".`);
1181 |     }
1182 | 
1183 |     // Build and execute the request
1184 |     const requestInfo = GDocsHelpers.buildUpdateTextStyleRequest(range.startIndex, range.endIndex, styleParams);
1185 |     if (!requestInfo) {
1186 |       return "No valid text styling options were provided.";
1187 |     }
1188 | 
1189 |     await GDocsHelpers.executeBatchUpdate(docs, args.documentId, [requestInfo.request]);
1190 |     return `Successfully applied formatting to instance ${args.matchInstance} of "${args.textToFind}".`;
1191 |   } catch (error: any) {
1192 |     log.error(`Error in formatMatchingText for doc ${args.documentId}: ${error.message || error}`);
1193 |     if (error instanceof UserError) throw error;
1194 |     throw new UserError(`Failed to format text: ${error.message || 'Unknown error'}`);
1195 |   }
1196 | }
1197 | });
1198 | 
1199 | // === GOOGLE DRIVE TOOLS ===
1200 | 
1201 | server.addTool({
1202 | name: 'listGoogleDocs',
1203 | description: 'Lists Google Documents from your Google Drive with optional filtering.',
1204 | parameters: z.object({
1205 |   maxResults: z.number().int().min(1).max(100).optional().default(20).describe('Maximum number of documents to return (1-100).'),
1206 |   query: z.string().optional().describe('Search query to filter documents by name or content.'),
1207 |   orderBy: z.enum(['name', 'modifiedTime', 'createdTime']).optional().default('modifiedTime').describe('Sort order for results.'),
1208 | }),
1209 | execute: async (args, { log }) => {
1210 | const drive = await getDriveClient();
1211 | log.info(`Listing Google Docs. Query: ${args.query || 'none'}, Max: ${args.maxResults}, Order: ${args.orderBy}`);
1212 | 
1213 | try {
1214 |   // Build the query string for Google Drive API
1215 |   let queryString = "mimeType='application/vnd.google-apps.document' and trashed=false";
1216 |   if (args.query) {
1217 |     queryString += ` and (name contains '${args.query}' or fullText contains '${args.query}')`;
1218 |   }
1219 | 
1220 |   const response = await drive.files.list({
1221 |     q: queryString,
1222 |     pageSize: args.maxResults,
1223 |     orderBy: args.orderBy === 'name' ? 'name' : args.orderBy,
1224 |     fields: 'files(id,name,modifiedTime,createdTime,size,webViewLink,owners(displayName,emailAddress))',
1225 |   });
1226 | 
1227 |   const files = response.data.files || [];
1228 | 
1229 |   if (files.length === 0) {
1230 |     return "No Google Docs found matching your criteria.";
1231 |   }
1232 | 
1233 |   let result = `Found ${files.length} Google Document(s):\n\n`;
1234 |   files.forEach((file, index) => {
1235 |     const modifiedDate = file.modifiedTime ? new Date(file.modifiedTime).toLocaleDateString() : 'Unknown';
1236 |     const owner = file.owners?.[0]?.displayName || 'Unknown';
1237 |     result += `${index + 1}. **${file.name}**\n`;
1238 |     result += `   ID: ${file.id}\n`;
1239 |     result += `   Modified: ${modifiedDate}\n`;
1240 |     result += `   Owner: ${owner}\n`;
1241 |     result += `   Link: ${file.webViewLink}\n\n`;
1242 |   });
1243 | 
1244 |   return result;
1245 | } catch (error: any) {
1246 |   log.error(`Error listing Google Docs: ${error.message || error}`);
1247 |   if (error.code === 403) throw new UserError("Permission denied. Make sure you have granted Google Drive access to the application.");
1248 |   throw new UserError(`Failed to list documents: ${error.message || 'Unknown error'}`);
1249 | }
1250 | }
1251 | });
1252 | 
1253 | server.addTool({
1254 | name: 'searchGoogleDocs',
1255 | description: 'Searches for Google Documents by name, content, or other criteria.',
1256 | parameters: z.object({
1257 |   searchQuery: z.string().min(1).describe('Search term to find in document names or content.'),
1258 |   searchIn: z.enum(['name', 'content', 'both']).optional().default('both').describe('Where to search: document names, content, or both.'),
1259 |   maxResults: z.number().int().min(1).max(50).optional().default(10).describe('Maximum number of results to return.'),
1260 |   modifiedAfter: z.string().optional().describe('Only return documents modified after this date (ISO 8601 format, e.g., "2024-01-01").'),
1261 | }),
1262 | execute: async (args, { log }) => {
1263 | const drive = await getDriveClient();
1264 | log.info(`Searching Google Docs for: "${args.searchQuery}" in ${args.searchIn}`);
1265 | 
1266 | try {
1267 |   let queryString = "mimeType='application/vnd.google-apps.document' and trashed=false";
1268 | 
1269 |   // Add search criteria
1270 |   if (args.searchIn === 'name') {
1271 |     queryString += ` and name contains '${args.searchQuery}'`;
1272 |   } else if (args.searchIn === 'content') {
1273 |     queryString += ` and fullText contains '${args.searchQuery}'`;
1274 |   } else {
1275 |     queryString += ` and (name contains '${args.searchQuery}' or fullText contains '${args.searchQuery}')`;
1276 |   }
1277 | 
1278 |   // Add date filter if provided
1279 |   if (args.modifiedAfter) {
1280 |     queryString += ` and modifiedTime > '${args.modifiedAfter}'`;
1281 |   }
1282 | 
1283 |   const response = await drive.files.list({
1284 |     q: queryString,
1285 |     pageSize: args.maxResults,
1286 |     orderBy: 'modifiedTime desc',
1287 |     fields: 'files(id,name,modifiedTime,createdTime,webViewLink,owners(displayName),parents)',
1288 |   });
1289 | 
1290 |   const files = response.data.files || [];
1291 | 
1292 |   if (files.length === 0) {
1293 |     return `No Google Docs found containing "${args.searchQuery}".`;
1294 |   }
1295 | 
1296 |   let result = `Found ${files.length} document(s) matching "${args.searchQuery}":\n\n`;
1297 |   files.forEach((file, index) => {
1298 |     const modifiedDate = file.modifiedTime ? new Date(file.modifiedTime).toLocaleDateString() : 'Unknown';
1299 |     const owner = file.owners?.[0]?.displayName || 'Unknown';
1300 |     result += `${index + 1}. **${file.name}**\n`;
1301 |     result += `   ID: ${file.id}\n`;
1302 |     result += `   Modified: ${modifiedDate}\n`;
1303 |     result += `   Owner: ${owner}\n`;
1304 |     result += `   Link: ${file.webViewLink}\n\n`;
1305 |   });
1306 | 
1307 |   return result;
1308 | } catch (error: any) {
1309 |   log.error(`Error searching Google Docs: ${error.message || error}`);
1310 |   if (error.code === 403) throw new UserError("Permission denied. Make sure you have granted Google Drive access to the application.");
1311 |   throw new UserError(`Failed to search documents: ${error.message || 'Unknown error'}`);
1312 | }
1313 | }
1314 | });
1315 | 
1316 | server.addTool({
1317 | name: 'getRecentGoogleDocs',
1318 | description: 'Gets the most recently modified Google Documents.',
1319 | parameters: z.object({
1320 |   maxResults: z.number().int().min(1).max(50).optional().default(10).describe('Maximum number of recent documents to return.'),
1321 |   daysBack: z.number().int().min(1).max(365).optional().default(30).describe('Only show documents modified within this many days.'),
1322 | }),
1323 | execute: async (args, { log }) => {
1324 | const drive = await getDriveClient();
1325 | log.info(`Getting recent Google Docs: ${args.maxResults} results, ${args.daysBack} days back`);
1326 | 
1327 | try {
1328 |   const cutoffDate = new Date();
1329 |   cutoffDate.setDate(cutoffDate.getDate() - args.daysBack);
1330 |   const cutoffDateStr = cutoffDate.toISOString();
1331 | 
1332 |   const queryString = `mimeType='application/vnd.google-apps.document' and trashed=false and modifiedTime > '${cutoffDateStr}'`;
1333 | 
1334 |   const response = await drive.files.list({
1335 |     q: queryString,
1336 |     pageSize: args.maxResults,
1337 |     orderBy: 'modifiedTime desc',
1338 |     fields: 'files(id,name,modifiedTime,createdTime,webViewLink,owners(displayName),lastModifyingUser(displayName))',
1339 |   });
1340 | 
1341 |   const files = response.data.files || [];
1342 | 
1343 |   if (files.length === 0) {
1344 |     return `No Google Docs found that were modified in the last ${args.daysBack} days.`;
1345 |   }
1346 | 
1347 |   let result = `${files.length} recently modified Google Document(s) (last ${args.daysBack} days):\n\n`;
1348 |   files.forEach((file, index) => {
1349 |     const modifiedDate = file.modifiedTime ? new Date(file.modifiedTime).toLocaleString() : 'Unknown';
1350 |     const lastModifier = file.lastModifyingUser?.displayName || 'Unknown';
1351 |     const owner = file.owners?.[0]?.displayName || 'Unknown';
1352 | 
1353 |     result += `${index + 1}. **${file.name}**\n`;
1354 |     result += `   ID: ${file.id}\n`;
1355 |     result += `   Last Modified: ${modifiedDate} by ${lastModifier}\n`;
1356 |     result += `   Owner: ${owner}\n`;
1357 |     result += `   Link: ${file.webViewLink}\n\n`;
1358 |   });
1359 | 
1360 |   return result;
1361 | } catch (error: any) {
1362 |   log.error(`Error getting recent Google Docs: ${error.message || error}`);
1363 |   if (error.code === 403) throw new UserError("Permission denied. Make sure you have granted Google Drive access to the application.");
1364 |   throw new UserError(`Failed to get recent documents: ${error.message || 'Unknown error'}`);
1365 | }
1366 | }
1367 | });
1368 | 
1369 | server.addTool({
1370 | name: 'getDocumentInfo',
1371 | description: 'Gets detailed information about a specific Google Document.',
1372 | parameters: DocumentIdParameter,
1373 | execute: async (args, { log }) => {
1374 | const drive = await getDriveClient();
1375 | log.info(`Getting info for document: ${args.documentId}`);
1376 | 
1377 | try {
1378 |   const response = await drive.files.get({
1379 |     fileId: args.documentId,
1380 |     fields: 'id,name,description,mimeType,size,createdTime,modifiedTime,webViewLink,alternateLink,owners(displayName,emailAddress),lastModifyingUser(displayName,emailAddress),shared,permissions(role,type,emailAddress),parents,version',
1381 |   });
1382 | 
1383 |   const file = response.data;
1384 | 
1385 |   if (!file) {
1386 |     throw new UserError(`Document with ID ${args.documentId} not found.`);
1387 |   }
1388 | 
1389 |   const createdDate = file.createdTime ? new Date(file.createdTime).toLocaleString() : 'Unknown';
1390 |   const modifiedDate = file.modifiedTime ? new Date(file.modifiedTime).toLocaleString() : 'Unknown';
1391 |   const owner = file.owners?.[0];
1392 |   const lastModifier = file.lastModifyingUser;
1393 | 
1394 |   let result = `**Document Information:**\n\n`;
1395 |   result += `**Name:** ${file.name}\n`;
1396 |   result += `**ID:** ${file.id}\n`;
1397 |   result += `**Type:** Google Document\n`;
1398 |   result += `**Created:** ${createdDate}\n`;
1399 |   result += `**Last Modified:** ${modifiedDate}\n`;
1400 | 
1401 |   if (owner) {
1402 |     result += `**Owner:** ${owner.displayName} (${owner.emailAddress})\n`;
1403 |   }
1404 | 
1405 |   if (lastModifier) {
1406 |     result += `**Last Modified By:** ${lastModifier.displayName} (${lastModifier.emailAddress})\n`;
1407 |   }
1408 | 
1409 |   result += `**Shared:** ${file.shared ? 'Yes' : 'No'}\n`;
1410 |   result += `**View Link:** ${file.webViewLink}\n`;
1411 | 
1412 |   if (file.description) {
1413 |     result += `**Description:** ${file.description}\n`;
1414 |   }
1415 | 
1416 |   return result;
1417 | } catch (error: any) {
1418 |   log.error(`Error getting document info: ${error.message || error}`);
1419 |   if (error.code === 404) throw new UserError(`Document not found (ID: ${args.documentId}).`);
1420 |   if (error.code === 403) throw new UserError("Permission denied. Make sure you have access to this document.");
1421 |   throw new UserError(`Failed to get document info: ${error.message || 'Unknown error'}`);
1422 | }
1423 | }
1424 | });
1425 | 
1426 | // === GOOGLE DRIVE FILE MANAGEMENT TOOLS ===
1427 | 
1428 | // --- Folder Management Tools ---
1429 | 
1430 | server.addTool({
1431 | name: 'createFolder',
1432 | description: 'Creates a new folder in Google Drive.',
1433 | parameters: z.object({
1434 |   name: z.string().min(1).describe('Name for the new folder.'),
1435 |   parentFolderId: z.string().optional().describe('Parent folder ID. If not provided, creates folder in Drive root.'),
1436 | }),
1437 | execute: async (args, { log }) => {
1438 | const drive = await getDriveClient();
1439 | log.info(`Creating folder "${args.name}" ${args.parentFolderId ? `in parent ${args.parentFolderId}` : 'in root'}`);
1440 | 
1441 | try {
1442 |   const folderMetadata: drive_v3.Schema$File = {
1443 |     name: args.name,
1444 |     mimeType: 'application/vnd.google-apps.folder',
1445 |   };
1446 | 
1447 |   if (args.parentFolderId) {
1448 |     folderMetadata.parents = [args.parentFolderId];
1449 |   }
1450 | 
1451 |   const response = await drive.files.create({
1452 |     requestBody: folderMetadata,
1453 |     fields: 'id,name,parents,webViewLink',
1454 |   });
1455 | 
1456 |   const folder = response.data;
1457 |   return `Successfully created folder "${folder.name}" (ID: ${folder.id})\nLink: ${folder.webViewLink}`;
1458 | } catch (error: any) {
1459 |   log.error(`Error creating folder: ${error.message || error}`);
1460 |   if (error.code === 404) throw new UserError("Parent folder not found. Check the parent folder ID.");
1461 |   if (error.code === 403) throw new UserError("Permission denied. Make sure you have write access to the parent folder.");
1462 |   throw new UserError(`Failed to create folder: ${error.message || 'Unknown error'}`);
1463 | }
1464 | }
1465 | });
1466 | 
1467 | server.addTool({
1468 | name: 'listFolderContents',
1469 | description: 'Lists the contents of a specific folder in Google Drive.',
1470 | parameters: z.object({
1471 |   folderId: z.string().describe('ID of the folder to list contents of. Use "root" for the root Drive folder.'),
1472 |   includeSubfolders: z.boolean().optional().default(true).describe('Whether to include subfolders in results.'),
1473 |   includeFiles: z.boolean().optional().default(true).describe('Whether to include files in results.'),
1474 |   maxResults: z.number().int().min(1).max(100).optional().default(50).describe('Maximum number of items to return.'),
1475 | }),
1476 | execute: async (args, { log }) => {
1477 | const drive = await getDriveClient();
1478 | log.info(`Listing contents of folder: ${args.folderId}`);
1479 | 
1480 | try {
1481 |   let queryString = `'${args.folderId}' in parents and trashed=false`;
1482 | 
1483 |   // Filter by type if specified
1484 |   if (!args.includeSubfolders && !args.includeFiles) {
1485 |     throw new UserError("At least one of includeSubfolders or includeFiles must be true.");
1486 |   }
1487 | 
1488 |   if (!args.includeSubfolders) {
1489 |     queryString += ` and mimeType!='application/vnd.google-apps.folder'`;
1490 |   } else if (!args.includeFiles) {
1491 |     queryString += ` and mimeType='application/vnd.google-apps.folder'`;
1492 |   }
1493 | 
1494 |   const response = await drive.files.list({
1495 |     q: queryString,
1496 |     pageSize: args.maxResults,
1497 |     orderBy: 'folder,name',
1498 |     fields: 'files(id,name,mimeType,size,modifiedTime,webViewLink,owners(displayName))',
1499 |   });
1500 | 
1501 |   const items = response.data.files || [];
1502 | 
1503 |   if (items.length === 0) {
1504 |     return "The folder is empty or you don't have permission to view its contents.";
1505 |   }
1506 | 
1507 |   let result = `Contents of folder (${items.length} item${items.length !== 1 ? 's' : ''}):\n\n`;
1508 | 
1509 |   // Separate folders and files
1510 |   const folders = items.filter(item => item.mimeType === 'application/vnd.google-apps.folder');
1511 |   const files = items.filter(item => item.mimeType !== 'application/vnd.google-apps.folder');
1512 | 
1513 |   // List folders first
1514 |   if (folders.length > 0 && args.includeSubfolders) {
1515 |     result += `**Folders (${folders.length}):**\n`;
1516 |     folders.forEach(folder => {
1517 |       result += `📁 ${folder.name} (ID: ${folder.id})\n`;
1518 |     });
1519 |     result += '\n';
1520 |   }
1521 | 
1522 |   // Then list files
1523 |   if (files.length > 0 && args.includeFiles) {
1524 |     result += `**Files (${files.length}):\n`;
1525 |     files.forEach(file => {
1526 |       const fileType = file.mimeType === 'application/vnd.google-apps.document' ? '📄' :
1527 |                       file.mimeType === 'application/vnd.google-apps.spreadsheet' ? '📊' :
1528 |                       file.mimeType === 'application/vnd.google-apps.presentation' ? '📈' : '📎';
1529 |       const modifiedDate = file.modifiedTime ? new Date(file.modifiedTime).toLocaleDateString() : 'Unknown';
1530 |       const owner = file.owners?.[0]?.displayName || 'Unknown';
1531 | 
1532 |       result += `${fileType} ${file.name}\n`;
1533 |       result += `   ID: ${file.id}\n`;
1534 |       result += `   Modified: ${modifiedDate} by ${owner}\n`;
1535 |       result += `   Link: ${file.webViewLink}\n\n`;
1536 |     });
1537 |   }
1538 | 
1539 |   return result;
1540 | } catch (error: any) {
1541 |   log.error(`Error listing folder contents: ${error.message || error}`);
1542 |   if (error.code === 404) throw new UserError("Folder not found. Check the folder ID.");
1543 |   if (error.code === 403) throw new UserError("Permission denied. Make sure you have access to this folder.");
1544 |   throw new UserError(`Failed to list folder contents: ${error.message || 'Unknown error'}`);
1545 | }
1546 | }
1547 | });
1548 | 
1549 | server.addTool({
1550 | name: 'getFolderInfo',
1551 | description: 'Gets detailed information about a specific folder in Google Drive.',
1552 | parameters: z.object({
1553 |   folderId: z.string().describe('ID of the folder to get information about.'),
1554 | }),
1555 | execute: async (args, { log }) => {
1556 | const drive = await getDriveClient();
1557 | log.info(`Getting folder info: ${args.folderId}`);
1558 | 
1559 | try {
1560 |   const response = await drive.files.get({
1561 |     fileId: args.folderId,
1562 |     fields: 'id,name,description,createdTime,modifiedTime,webViewLink,owners(displayName,emailAddress),lastModifyingUser(displayName),shared,parents',
1563 |   });
1564 | 
1565 |   const folder = response.data;
1566 | 
1567 |   if (folder.mimeType !== 'application/vnd.google-apps.folder') {
1568 |     throw new UserError("The specified ID does not belong to a folder.");
1569 |   }
1570 | 
1571 |   const createdDate = folder.createdTime ? new Date(folder.createdTime).toLocaleString() : 'Unknown';
1572 |   const modifiedDate = folder.modifiedTime ? new Date(folder.modifiedTime).toLocaleString() : 'Unknown';
1573 |   const owner = folder.owners?.[0];
1574 |   const lastModifier = folder.lastModifyingUser;
1575 | 
1576 |   let result = `**Folder Information:**\n\n`;
1577 |   result += `**Name:** ${folder.name}\n`;
1578 |   result += `**ID:** ${folder.id}\n`;
1579 |   result += `**Created:** ${createdDate}\n`;
1580 |   result += `**Last Modified:** ${modifiedDate}\n`;
1581 | 
1582 |   if (owner) {
1583 |     result += `**Owner:** ${owner.displayName} (${owner.emailAddress})\n`;
1584 |   }
1585 | 
1586 |   if (lastModifier) {
1587 |     result += `**Last Modified By:** ${lastModifier.displayName}\n`;
1588 |   }
1589 | 
1590 |   result += `**Shared:** ${folder.shared ? 'Yes' : 'No'}\n`;
1591 |   result += `**View Link:** ${folder.webViewLink}\n`;
1592 | 
1593 |   if (folder.description) {
1594 |     result += `**Description:** ${folder.description}\n`;
1595 |   }
1596 | 
1597 |   if (folder.parents && folder.parents.length > 0) {
1598 |     result += `**Parent Folder ID:** ${folder.parents[0]}\n`;
1599 |   }
1600 | 
1601 |   return result;
1602 | } catch (error: any) {
1603 |   log.error(`Error getting folder info: ${error.message || error}`);
1604 |   if (error.code === 404) throw new UserError(`Folder not found (ID: ${args.folderId}).`);
1605 |   if (error.code === 403) throw new UserError("Permission denied. Make sure you have access to this folder.");
1606 |   throw new UserError(`Failed to get folder info: ${error.message || 'Unknown error'}`);
1607 | }
1608 | }
1609 | });
1610 | 
1611 | // --- File Operation Tools ---
1612 | 
1613 | server.addTool({
1614 | name: 'moveFile',
1615 | description: 'Moves a file or folder to a different location in Google Drive.',
1616 | parameters: z.object({
1617 |   fileId: z.string().describe('ID of the file or folder to move.'),
1618 |   newParentId: z.string().describe('ID of the destination folder. Use "root" for Drive root.'),
1619 |   removeFromAllParents: z.boolean().optional().default(false).describe('If true, removes from all current parents. If false, adds to new parent while keeping existing parents.'),
1620 | }),
1621 | execute: async (args, { log }) => {
1622 | const drive = await getDriveClient();
1623 | log.info(`Moving file ${args.fileId} to folder ${args.newParentId}`);
1624 | 
1625 | try {
1626 |   // First get the current parents
1627 |   const fileInfo = await drive.files.get({
1628 |     fileId: args.fileId,
1629 |     fields: 'name,parents',
1630 |   });
1631 | 
1632 |   const fileName = fileInfo.data.name;
1633 |   const currentParents = fileInfo.data.parents || [];
1634 | 
1635 |   let updateParams: any = {
1636 |     fileId: args.fileId,
1637 |     addParents: args.newParentId,
1638 |     fields: 'id,name,parents',
1639 |   };
1640 | 
1641 |   if (args.removeFromAllParents && currentParents.length > 0) {
1642 |     updateParams.removeParents = currentParents.join(',');
1643 |   }
1644 | 
1645 |   const response = await drive.files.update(updateParams);
1646 | 
1647 |   const action = args.removeFromAllParents ? 'moved' : 'copied';
1648 |   return `Successfully ${action} "${fileName}" to new location.\nFile ID: ${response.data.id}`;
1649 | } catch (error: any) {
1650 |   log.error(`Error moving file: ${error.message || error}`);
1651 |   if (error.code === 404) throw new UserError("File or destination folder not found. Check the IDs.");
1652 |   if (error.code === 403) throw new UserError("Permission denied. Make sure you have write access to both source and destination.");
1653 |   throw new UserError(`Failed to move file: ${error.message || 'Unknown error'}`);
1654 | }
1655 | }
1656 | });
1657 | 
1658 | server.addTool({
1659 | name: 'copyFile',
1660 | description: 'Creates a copy of a Google Drive file or document.',
1661 | parameters: z.object({
1662 |   fileId: z.string().describe('ID of the file to copy.'),
1663 |   newName: z.string().optional().describe('Name for the copied file. If not provided, will use "Copy of [original name]".'),
1664 |   parentFolderId: z.string().optional().describe('ID of folder where copy should be placed. If not provided, places in same location as original.'),
1665 | }),
1666 | execute: async (args, { log }) => {
1667 | const drive = await getDriveClient();
1668 | log.info(`Copying file ${args.fileId} ${args.newName ? `as "${args.newName}"` : ''}`);
1669 | 
1670 | try {
1671 |   // Get original file info
1672 |   const originalFile = await drive.files.get({
1673 |     fileId: args.fileId,
1674 |     fields: 'name,parents',
1675 |   });
1676 | 
1677 |   const copyMetadata: drive_v3.Schema$File = {
1678 |     name: args.newName || `Copy of ${originalFile.data.name}`,
1679 |   };
1680 | 
1681 |   if (args.parentFolderId) {
1682 |     copyMetadata.parents = [args.parentFolderId];
1683 |   } else if (originalFile.data.parents) {
1684 |     copyMetadata.parents = originalFile.data.parents;
1685 |   }
1686 | 
1687 |   const response = await drive.files.copy({
1688 |     fileId: args.fileId,
1689 |     requestBody: copyMetadata,
1690 |     fields: 'id,name,webViewLink',
1691 |   });
1692 | 
1693 |   const copiedFile = response.data;
1694 |   return `Successfully created copy "${copiedFile.name}" (ID: ${copiedFile.id})\nLink: ${copiedFile.webViewLink}`;
1695 | } catch (error: any) {
1696 |   log.error(`Error copying file: ${error.message || error}`);
1697 |   if (error.code === 404) throw new UserError("Original file or destination folder not found. Check the IDs.");
1698 |   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.");
1699 |   throw new UserError(`Failed to copy file: ${error.message || 'Unknown error'}`);
1700 | }
1701 | }
1702 | });
1703 | 
1704 | server.addTool({
1705 | name: 'renameFile',
1706 | description: 'Renames a file or folder in Google Drive.',
1707 | parameters: z.object({
1708 |   fileId: z.string().describe('ID of the file or folder to rename.'),
1709 |   newName: z.string().min(1).describe('New name for the file or folder.'),
1710 | }),
1711 | execute: async (args, { log }) => {
1712 | const drive = await getDriveClient();
1713 | log.info(`Renaming file ${args.fileId} to "${args.newName}"`);
1714 | 
1715 | try {
1716 |   const response = await drive.files.update({
1717 |     fileId: args.fileId,
1718 |     requestBody: {
1719 |       name: args.newName,
1720 |     },
1721 |     fields: 'id,name,webViewLink',
1722 |   });
1723 | 
1724 |   const file = response.data;
1725 |   return `Successfully renamed to "${file.name}" (ID: ${file.id})\nLink: ${file.webViewLink}`;
1726 | } catch (error: any) {
1727 |   log.error(`Error renaming file: ${error.message || error}`);
1728 |   if (error.code === 404) throw new UserError("File not found. Check the file ID.");
1729 |   if (error.code === 403) throw new UserError("Permission denied. Make sure you have write access to this file.");
1730 |   throw new UserError(`Failed to rename file: ${error.message || 'Unknown error'}`);
1731 | }
1732 | }
1733 | });
1734 | 
1735 | server.addTool({
1736 | name: 'deleteFile',
1737 | description: 'Permanently deletes a file or folder from Google Drive.',
1738 | parameters: z.object({
1739 |   fileId: z.string().describe('ID of the file or folder to delete.'),
1740 |   skipTrash: z.boolean().optional().default(false).describe('If true, permanently deletes the file. If false, moves to trash (can be restored).'),
1741 | }),
1742 | execute: async (args, { log }) => {
1743 | const drive = await getDriveClient();
1744 | log.info(`Deleting file ${args.fileId} ${args.skipTrash ? '(permanent)' : '(to trash)'}`);
1745 | 
1746 | try {
1747 |   // Get file info before deletion
1748 |   const fileInfo = await drive.files.get({
1749 |     fileId: args.fileId,
1750 |     fields: 'name,mimeType',
1751 |   });
1752 | 
1753 |   const fileName = fileInfo.data.name;
1754 |   const isFolder = fileInfo.data.mimeType === 'application/vnd.google-apps.folder';
1755 | 
1756 |   if (args.skipTrash) {
1757 |     await drive.files.delete({
1758 |       fileId: args.fileId,
1759 |     });
1760 |     return `Permanently deleted ${isFolder ? 'folder' : 'file'} "${fileName}".`;
1761 |   } else {
1762 |     await drive.files.update({
1763 |       fileId: args.fileId,
1764 |       requestBody: {
1765 |         trashed: true,
1766 |       },
1767 |     });
1768 |     return `Moved ${isFolder ? 'folder' : 'file'} "${fileName}" to trash. It can be restored from the trash.`;
1769 |   }
1770 | } catch (error: any) {
1771 |   log.error(`Error deleting file: ${error.message || error}`);
1772 |   if (error.code === 404) throw new UserError("File not found. Check the file ID.");
1773 |   if (error.code === 403) throw new UserError("Permission denied. Make sure you have delete access to this file.");
1774 |   throw new UserError(`Failed to delete file: ${error.message || 'Unknown error'}`);
1775 | }
1776 | }
1777 | });
1778 | 
1779 | // --- Document Creation Tools ---
1780 | 
1781 | server.addTool({
1782 | name: 'createDocument',
1783 | description: 'Creates a new Google Document.',
1784 | parameters: z.object({
1785 |   title: z.string().min(1).describe('Title for the new document.'),
1786 |   parentFolderId: z.string().optional().describe('ID of folder where document should be created. If not provided, creates in Drive root.'),
1787 |   initialContent: z.string().optional().describe('Initial text content to add to the document.'),
1788 | }),
1789 | execute: async (args, { log }) => {
1790 | const drive = await getDriveClient();
1791 | log.info(`Creating new document "${args.title}"`);
1792 | 
1793 | try {
1794 |   const documentMetadata: drive_v3.Schema$File = {
1795 |     name: args.title,
1796 |     mimeType: 'application/vnd.google-apps.document',
1797 |   };
1798 | 
1799 |   if (args.parentFolderId) {
1800 |     documentMetadata.parents = [args.parentFolderId];
1801 |   }
1802 | 
1803 |   const response = await drive.files.create({
1804 |     requestBody: documentMetadata,
1805 |     fields: 'id,name,webViewLink',
1806 |   });
1807 | 
1808 |   const document = response.data;
1809 |   let result = `Successfully created document "${document.name}" (ID: ${document.id})\nView Link: ${document.webViewLink}`;
1810 | 
1811 |   // Add initial content if provided
1812 |   if (args.initialContent) {
1813 |     try {
1814 |       const docs = await getDocsClient();
1815 |       await docs.documents.batchUpdate({
1816 |         documentId: document.id!,
1817 |         requestBody: {
1818 |           requests: [{
1819 |             insertText: {
1820 |               location: { index: 1 },
1821 |               text: args.initialContent,
1822 |             },
1823 |           }],
1824 |         },
1825 |       });
1826 |       result += `\n\nInitial content added to document.`;
1827 |     } catch (contentError: any) {
1828 |       log.warn(`Document created but failed to add initial content: ${contentError.message}`);
1829 |       result += `\n\nDocument created but failed to add initial content. You can add content manually.`;
1830 |     }
1831 |   }
1832 | 
1833 |   return result;
1834 | } catch (error: any) {
1835 |   log.error(`Error creating document: ${error.message || error}`);
1836 |   if (error.code === 404) throw new UserError("Parent folder not found. Check the folder ID.");
1837 |   if (error.code === 403) throw new UserError("Permission denied. Make sure you have write access to the destination folder.");
1838 |   throw new UserError(`Failed to create document: ${error.message || 'Unknown error'}`);
1839 | }
1840 | }
1841 | });
1842 | 
1843 | server.addTool({
1844 | name: 'createFromTemplate',
1845 | description: 'Creates a new Google Document from an existing document template.',
1846 | parameters: z.object({
1847 |   templateId: z.string().describe('ID of the template document to copy from.'),
1848 |   newTitle: z.string().min(1).describe('Title for the new document.'),
1849 |   parentFolderId: z.string().optional().describe('ID of folder where document should be created. If not provided, creates in Drive root.'),
1850 |   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"}).'),
1851 | }),
1852 | execute: async (args, { log }) => {
1853 | const drive = await getDriveClient();
1854 | log.info(`Creating document from template ${args.templateId} with title "${args.newTitle}"`);
1855 | 
1856 | try {
1857 |   // First copy the template
1858 |   const copyMetadata: drive_v3.Schema$File = {
1859 |     name: args.newTitle,
1860 |   };
1861 | 
1862 |   if (args.parentFolderId) {
1863 |     copyMetadata.parents = [args.parentFolderId];
1864 |   }
1865 | 
1866 |   const response = await drive.files.copy({
1867 |     fileId: args.templateId,
1868 |     requestBody: copyMetadata,
1869 |     fields: 'id,name,webViewLink',
1870 |   });
1871 | 
1872 |   const document = response.data;
1873 |   let result = `Successfully created document "${document.name}" from template (ID: ${document.id})\nView Link: ${document.webViewLink}`;
1874 | 
1875 |   // Apply text replacements if provided
1876 |   if (args.replacements && Object.keys(args.replacements).length > 0) {
1877 |     try {
1878 |       const docs = await getDocsClient();
1879 |       const requests: docs_v1.Schema$Request[] = [];
1880 | 
1881 |       // Create replace requests for each replacement
1882 |       for (const [searchText, replaceText] of Object.entries(args.replacements)) {
1883 |         requests.push({
1884 |           replaceAllText: {
1885 |             containsText: {
1886 |               text: searchText,
1887 |               matchCase: false,
1888 |             },
1889 |             replaceText: replaceText,
1890 |           },
1891 |         });
1892 |       }
1893 | 
1894 |       if (requests.length > 0) {
1895 |         await docs.documents.batchUpdate({
1896 |           documentId: document.id!,
1897 |           requestBody: { requests },
1898 |         });
1899 | 
1900 |         const replacementCount = Object.keys(args.replacements).length;
1901 |         result += `\n\nApplied ${replacementCount} text replacement${replacementCount !== 1 ? 's' : ''} to the document.`;
1902 |       }
1903 |     } catch (replacementError: any) {
1904 |       log.warn(`Document created but failed to apply replacements: ${replacementError.message}`);
1905 |       result += `\n\nDocument created but failed to apply text replacements. You can make changes manually.`;
1906 |     }
1907 |   }
1908 | 
1909 |   return result;
1910 | } catch (error: any) {
1911 |   log.error(`Error creating document from template: ${error.message || error}`);
1912 |   if (error.code === 404) throw new UserError("Template document or parent folder not found. Check the IDs.");
1913 |   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.");
1914 |   throw new UserError(`Failed to create document from template: ${error.message || 'Unknown error'}`);
1915 | }
1916 | }
1917 | });
1918 | 
1919 | // --- Server Startup ---
1920 | async function startServer() {
1921 | try {
1922 | await initializeGoogleClient(); // Authorize BEFORE starting listeners
1923 | console.error("Starting Ultimate Google Docs MCP server...");
1924 | 
1925 |       // Using stdio as before
1926 |       const configToUse = {
1927 |           transportType: "stdio" as const,
1928 |       };
1929 | 
1930 |       // Start the server with proper error handling
1931 |       server.start(configToUse);
1932 |       console.error(`MCP Server running using ${configToUse.transportType}. Awaiting client connection...`);
1933 | 
1934 |       // Log that error handling has been enabled
1935 |       console.error('Process-level error handling configured to prevent crashes from timeout errors.');
1936 | 
1937 | } catch(startError: any) {
1938 | console.error("FATAL: Server failed to start:", startError.message || startError);
1939 | process.exit(1);
1940 | }
1941 | }
1942 | 
1943 | startServer(); // Removed .catch here, let errors propagate if startup fails critically
1944 | 
```
Page 2/3FirstPrevNextLast