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 | ```