Skip to main content
Glama
server.ts•86.3 kB
// src/server.ts import { FastMCP, UserError } from 'fastmcp'; import { z } from 'zod'; import { google, docs_v1, drive_v3 } from 'googleapis'; import { OAuth2Client } from 'google-auth-library'; // Import types and helpers import { DocumentIdParameter, RangeParameters, OptionalRangeParameters, TextFindParameter, TextStyleParameters, TextStyleArgs, ParagraphStyleParameters, ParagraphStyleArgs, ApplyTextStyleToolParameters, ApplyTextStyleToolArgs, ApplyParagraphStyleToolParameters, ApplyParagraphStyleToolArgs, NotImplementedError } from './types.js'; import * as GDocsHelpers from './googleDocsApiHelpers.js'; // --- Platform OAuth Integration --- // Creates a Google Docs client with the provided access token (per-request) function createDocsClient(accessToken: string): docs_v1.Docs { const auth = new OAuth2Client(); auth.setCredentials({ access_token: accessToken }); return google.docs({ version: 'v1', auth }); } // Creates a Google Drive client with the provided access token (per-request) function createDriveClient(accessToken: string): drive_v3.Drive { const auth = new OAuth2Client(); auth.setCredentials({ access_token: accessToken }); return google.drive({ version: 'v3', auth }); } // Set up process-level unhandled error/rejection handlers to prevent crashes process.on('uncaughtException', (error) => { console.error('Uncaught Exception:', error); // Don't exit process, just log the error and continue // This will catch timeout errors that might otherwise crash the server }); process.on('unhandledRejection', (reason, promise) => { console.error('Unhandled Promise Rejection:', reason); // Don't exit process, just log the error and continue }); const server = new FastMCP({ name: 'Ultimate Google Docs MCP Server', version: '1.0.0' }); // Platform-compatible: No global client initialization needed // Each tool call receives accessToken and creates its own authenticated client // === HELPER FUNCTIONS === /** * Converts Google Docs JSON structure to Markdown format */ function convertDocsJsonToMarkdown(docData: any): string { let markdown = ''; if (!docData.body?.content) { return 'Document appears to be empty.'; } docData.body.content.forEach((element: any) => { if (element.paragraph) { markdown += convertParagraphToMarkdown(element.paragraph); } else if (element.table) { markdown += convertTableToMarkdown(element.table); } else if (element.sectionBreak) { markdown += '\n---\n\n'; // Section break as horizontal rule } }); return markdown.trim(); } /** * Converts a paragraph element to markdown */ function convertParagraphToMarkdown(paragraph: any): string { let text = ''; let isHeading = false; let headingLevel = 0; let isList = false; let listType = ''; // Check paragraph style for headings and lists if (paragraph.paragraphStyle?.namedStyleType) { const styleType = paragraph.paragraphStyle.namedStyleType; if (styleType.startsWith('HEADING_')) { isHeading = true; headingLevel = parseInt(styleType.replace('HEADING_', '')); } else if (styleType === 'TITLE') { isHeading = true; headingLevel = 1; } else if (styleType === 'SUBTITLE') { isHeading = true; headingLevel = 2; } } // Check for bullet lists if (paragraph.bullet) { isList = true; listType = paragraph.bullet.listId ? 'bullet' : 'bullet'; } // Process text elements if (paragraph.elements) { paragraph.elements.forEach((element: any) => { if (element.textRun) { text += convertTextRunToMarkdown(element.textRun); } }); } // Format based on style if (isHeading && text.trim()) { const hashes = '#'.repeat(Math.min(headingLevel, 6)); return `${hashes} ${text.trim()}\n\n`; } else if (isList && text.trim()) { return `- ${text.trim()}\n`; } else if (text.trim()) { return `${text.trim()}\n\n`; } return '\n'; // Empty paragraph } /** * Converts a text run to markdown with formatting */ function convertTextRunToMarkdown(textRun: any): string { let text = textRun.content || ''; if (textRun.textStyle) { const style = textRun.textStyle; // Apply formatting if (style.bold && style.italic) { text = `***${text}***`; } else if (style.bold) { text = `**${text}**`; } else if (style.italic) { text = `*${text}*`; } if (style.underline && !style.link) { // Markdown doesn't have native underline, use HTML text = `<u>${text}</u>`; } if (style.strikethrough) { text = `~~${text}~~`; } if (style.link?.url) { text = `[${text}](${style.link.url})`; } } return text; } /** * Converts a table to markdown format */ function convertTableToMarkdown(table: any): string { if (!table.tableRows || table.tableRows.length === 0) { return ''; } let markdown = '\n'; let isFirstRow = true; table.tableRows.forEach((row: any) => { if (!row.tableCells) return; let rowText = '|'; row.tableCells.forEach((cell: any) => { let cellText = ''; if (cell.content) { cell.content.forEach((element: any) => { if (element.paragraph?.elements) { element.paragraph.elements.forEach((pe: any) => { if (pe.textRun?.content) { cellText += pe.textRun.content.replace(/\n/g, ' ').trim(); } }); } }); } rowText += ` ${cellText} |`; }); markdown += rowText + '\n'; // Add header separator after first row if (isFirstRow) { let separator = '|'; for (let i = 0; i < row.tableCells.length; i++) { separator += ' --- |'; } markdown += separator + '\n'; isFirstRow = false; } }); return markdown + '\n'; } // === TOOL DEFINITIONS === // --- Foundational Tools --- server.addTool({ name: 'readGoogleDoc', description: 'Reads the content of a specific Google Document, optionally returning structured data.', parameters: DocumentIdParameter.extend({ format: z.enum(['text', 'json', 'markdown']).optional().default('text') .describe("Output format: 'text' (plain text), 'json' (raw API structure, complex), 'markdown' (experimental conversion)."), 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.'), tabId: z.string().optional().describe('The ID of the specific tab to read. If not specified, reads the first tab (or legacy document.body for documents without tabs).') }), execute: async (args, { log }) => { const docs = createDocsClient(args.accessToken); log.info(`Reading Google Doc: ${args.documentId}, Format: ${args.format}${args.tabId ? `, Tab: ${args.tabId}` : ''}`); try { // Determine if we need tabs content const needsTabsContent = !!args.tabId; const fields = args.format === 'json' || args.format === 'markdown' ? '*' // Get everything for structure analysis : 'body(content(paragraph(elements(textRun(content)))))'; // Just text content const res = await docs.documents.get({ documentId: args.documentId, includeTabsContent: needsTabsContent, fields: needsTabsContent ? '*' : fields, // Get full document if using tabs }); log.info(`Fetched doc: ${args.documentId}${args.tabId ? ` (tab: ${args.tabId})` : ''}`); // If tabId is specified, find the specific tab let contentSource: any; if (args.tabId) { const targetTab = GDocsHelpers.findTabById(res.data, args.tabId); if (!targetTab) { throw new UserError(`Tab with ID "${args.tabId}" not found in document.`); } if (!targetTab.documentTab) { throw new UserError(`Tab "${args.tabId}" does not have content (may not be a document tab).`); } contentSource = { body: targetTab.documentTab.body }; log.info(`Using content from tab: ${targetTab.tabProperties?.title || 'Untitled'}`); } else { // Use the document body (backward compatible) contentSource = res.data; } if (args.format === 'json') { const jsonContent = JSON.stringify(contentSource, null, 2); // Apply length limit to JSON if specified if (args.maxLength && jsonContent.length > args.maxLength) { return jsonContent.substring(0, args.maxLength) + `\n... [JSON truncated: ${jsonContent.length} total chars]`; } return jsonContent; } if (args.format === 'markdown') { const markdownContent = convertDocsJsonToMarkdown(contentSource); const totalLength = markdownContent.length; log.info(`Generated markdown: ${totalLength} characters`); // Apply length limit to markdown if specified if (args.maxLength && totalLength > args.maxLength) { const truncatedContent = markdownContent.substring(0, args.maxLength); 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.]`; } return markdownContent; } // Default: Text format - extract all text content let textContent = ''; let elementCount = 0; // Process all content elements from contentSource contentSource.body?.content?.forEach((element: any) => { elementCount++; // Handle paragraphs if (element.paragraph?.elements) { element.paragraph.elements.forEach((pe: any) => { if (pe.textRun?.content) { textContent += pe.textRun.content; } }); } // Handle tables if (element.table?.tableRows) { element.table.tableRows.forEach((row: any) => { row.tableCells?.forEach((cell: any) => { cell.content?.forEach((cellElement: any) => { cellElement.paragraph?.elements?.forEach((pe: any) => { if (pe.textRun?.content) { textContent += pe.textRun.content; } }); }); }); }); } }); if (!textContent.trim()) return "Document found, but appears empty."; const totalLength = textContent.length; log.info(`Document contains ${totalLength} characters across ${elementCount} elements`); log.info(`maxLength parameter: ${args.maxLength || 'not specified'}`); // Apply length limit only if specified if (args.maxLength && totalLength > args.maxLength) { const truncatedContent = textContent.substring(0, args.maxLength); log.info(`Truncating content from ${totalLength} to ${args.maxLength} characters`); 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.]`; } // Return full content const fullResponse = `Content (${totalLength} characters):\n---\n${textContent}`; const responseLength = fullResponse.length; log.info(`Returning full content: ${responseLength} characters in response (${totalLength} content + ${responseLength - totalLength} metadata)`); return fullResponse; } catch (error: any) { log.error(`Error reading doc ${args.documentId}: ${error.message || error}`); // Handle errors thrown by helpers or API directly if (error instanceof UserError) throw error; if (error instanceof NotImplementedError) throw error; // Generic fallback for API errors not caught by helpers if (error.code === 404) throw new UserError(`Doc not found (ID: ${args.documentId}).`); if (error.code === 403) throw new UserError(`Permission denied for doc (ID: ${args.documentId}).`); throw new UserError(`Failed to read doc: ${error.message || 'Unknown error'}`); } }, }); server.addTool({ name: 'listDocumentTabs', description: 'Lists all tabs in a Google Document, including their hierarchy, IDs, and structure.', parameters: DocumentIdParameter.extend({ includeContent: z.boolean().optional().default(false) .describe('Whether to include a content summary for each tab (character count).') }), execute: async (args, { log }) => { const docs = createDocsClient(args.accessToken); log.info(`Listing tabs for document: ${args.documentId}`); try { // Get document with tabs structure const res = await docs.documents.get({ documentId: args.documentId, includeTabsContent: true, // Only get essential fields for tab listing fields: args.includeContent ? 'title,tabs' // Get all tab data if we need content summary : 'title,tabs(tabProperties,childTabs)' // Otherwise just structure }); const docTitle = res.data.title || 'Untitled Document'; // Get all tabs in a flat list with hierarchy info const allTabs = GDocsHelpers.getAllTabs(res.data); if (allTabs.length === 0) { // Shouldn't happen with new structure, but handle edge case return `Document "${docTitle}" appears to have no tabs (unexpected).`; } // Check if it's a single-tab or multi-tab document const isSingleTab = allTabs.length === 1; // Format the output let result = `**Document:** "${docTitle}"\n`; result += `**Total tabs:** ${allTabs.length}`; result += isSingleTab ? ' (single-tab document)\n\n' : '\n\n'; if (!isSingleTab) { result += `**Tab Structure:**\n`; result += `${'─'.repeat(50)}\n\n`; } allTabs.forEach((tab: GDocsHelpers.TabWithLevel, index: number) => { const level = tab.level; const tabProperties = tab.tabProperties || {}; const indent = ' '.repeat(level); // For single tab documents, show simplified info if (isSingleTab) { result += `**Default Tab:**\n`; result += `- Tab ID: ${tabProperties.tabId || 'Unknown'}\n`; result += `- Title: ${tabProperties.title || '(Untitled)'}\n`; } else { // For multi-tab documents, show hierarchy const prefix = level > 0 ? '└─ ' : ''; result += `${indent}${prefix}**Tab ${index + 1}:** "${tabProperties.title || 'Untitled Tab'}"\n`; result += `${indent} - ID: ${tabProperties.tabId || 'Unknown'}\n`; result += `${indent} - Index: ${tabProperties.index !== undefined ? tabProperties.index : 'N/A'}\n`; if (tabProperties.parentTabId) { result += `${indent} - Parent Tab ID: ${tabProperties.parentTabId}\n`; } } // Optionally include content summary if (args.includeContent && tab.documentTab) { const textLength = GDocsHelpers.getTabTextLength(tab.documentTab); const contentInfo = textLength > 0 ? `${textLength.toLocaleString()} characters` : 'Empty'; result += `${indent} - Content: ${contentInfo}\n`; } if (!isSingleTab) { result += '\n'; } }); // Add usage hint for multi-tab documents if (!isSingleTab) { result += `\nšŸ’” **Tip:** Use tab IDs with other tools to target specific tabs.`; } return result; } catch (error: any) { log.error(`Error listing tabs for doc ${args.documentId}: ${error.message || error}`); if (error.code === 404) throw new UserError(`Document not found (ID: ${args.documentId}).`); if (error.code === 403) throw new UserError(`Permission denied for document (ID: ${args.documentId}).`); throw new UserError(`Failed to list tabs: ${error.message || 'Unknown error'}`); } } }); server.addTool({ name: 'appendToGoogleDoc', description: 'Appends text to the very end of a specific Google Document or tab.', parameters: DocumentIdParameter.extend({ textToAppend: z.string().min(1).describe('The text to add to the end.'), addNewlineIfNeeded: z.boolean().optional().default(true).describe("Automatically add a newline before the appended text if the doc doesn't end with one."), tabId: z.string().optional().describe('The ID of the specific tab to append to. If not specified, appends to the first tab (or legacy document.body for documents without tabs).') }), execute: async (args, { log }) => { const docs = createDocsClient(args.accessToken); log.info(`Appending to Google Doc: ${args.documentId}${args.tabId ? ` (tab: ${args.tabId})` : ''}`); try { // Determine if we need tabs content const needsTabsContent = !!args.tabId; // Get the current end index const docInfo = await docs.documents.get({ documentId: args.documentId, includeTabsContent: needsTabsContent, fields: needsTabsContent ? 'tabs' : 'body(content(endIndex)),documentStyle(pageSize)' }); let endIndex = 1; let bodyContent: any; // If tabId is specified, find the specific tab if (args.tabId) { const targetTab = GDocsHelpers.findTabById(docInfo.data, args.tabId); if (!targetTab) { throw new UserError(`Tab with ID "${args.tabId}" not found in document.`); } if (!targetTab.documentTab) { throw new UserError(`Tab "${args.tabId}" does not have content (may not be a document tab).`); } bodyContent = targetTab.documentTab.body?.content; } else { bodyContent = docInfo.data.body?.content; } if (bodyContent) { const lastElement = bodyContent[bodyContent.length - 1]; if (lastElement?.endIndex) { endIndex = lastElement.endIndex - 1; // Insert *before* the final newline of the doc typically } } // Simpler approach: Always assume insertion is needed unless explicitly told not to add newline const textToInsert = (args.addNewlineIfNeeded && endIndex > 1 ? '\n' : '') + args.textToAppend; if (!textToInsert) return "Nothing to append."; const location: any = { index: endIndex }; if (args.tabId) { location.tabId = args.tabId; } const request: docs_v1.Schema$Request = { insertText: { location, text: textToInsert } }; await GDocsHelpers.executeBatchUpdate(docs, args.documentId, [request]); log.info(`Successfully appended to doc: ${args.documentId}${args.tabId ? ` (tab: ${args.tabId})` : ''}`); return `Successfully appended text to ${args.tabId ? `tab ${args.tabId} in ` : ''}document ${args.documentId}.`; } catch (error: any) { log.error(`Error appending to doc ${args.documentId}: ${error.message || error}`); if (error instanceof UserError) throw error; if (error instanceof NotImplementedError) throw error; throw new UserError(`Failed to append to doc: ${error.message || 'Unknown error'}`); } }, }); server.addTool({ name: 'insertText', description: 'Inserts text at a specific index within the document body or a specific tab.', parameters: DocumentIdParameter.extend({ textToInsert: z.string().min(1).describe('The text to insert.'), index: z.number().int().min(1).describe('The index (1-based) where the text should be inserted.'), tabId: z.string().optional().describe('The ID of the specific tab to insert into. If not specified, inserts into the first tab (or legacy document.body for documents without tabs).') }), execute: async (args, { log }) => { const docs = createDocsClient(args.accessToken); log.info(`Inserting text in doc ${args.documentId} at index ${args.index}${args.tabId ? ` (tab: ${args.tabId})` : ''}`); try { if (args.tabId) { // For tab-specific inserts, we need to verify the tab exists first const docInfo = await docs.documents.get({ documentId: args.documentId, includeTabsContent: true, fields: 'tabs(tabProperties,documentTab)' }); const targetTab = GDocsHelpers.findTabById(docInfo.data, args.tabId); if (!targetTab) { throw new UserError(`Tab with ID "${args.tabId}" not found in document.`); } if (!targetTab.documentTab) { throw new UserError(`Tab "${args.tabId}" does not have content (may not be a document tab).`); } // Insert with tabId const location: any = { index: args.index, tabId: args.tabId }; const request: docs_v1.Schema$Request = { insertText: { location, text: args.textToInsert } }; await GDocsHelpers.executeBatchUpdate(docs, args.documentId, [request]); } else { // Use existing helper for backward compatibility await GDocsHelpers.insertText(docs, args.documentId, args.textToInsert, args.index); } return `Successfully inserted text at index ${args.index}${args.tabId ? ` in tab ${args.tabId}` : ''}.`; } catch (error: any) { log.error(`Error inserting text in doc ${args.documentId}: ${error.message || error}`); if (error instanceof UserError) throw error; throw new UserError(`Failed to insert text: ${error.message || 'Unknown error'}`); } } }); server.addTool({ name: 'deleteRange', description: 'Deletes content within a specified range (start index inclusive, end index exclusive) from the document or a specific tab.', parameters: DocumentIdParameter.extend({ startIndex: z.number().int().min(1).describe('The starting index of the text range (inclusive, starts from 1).'), endIndex: z.number().int().min(1).describe('The ending index of the text range (exclusive).'), tabId: z.string().optional().describe('The ID of the specific tab to delete from. If not specified, deletes from the first tab (or legacy document.body for documents without tabs).') }).refine(data => data.endIndex > data.startIndex, { message: "endIndex must be greater than startIndex", path: ["endIndex"], }), execute: async (args, { log }) => { const docs = createDocsClient(args.accessToken); log.info(`Deleting range ${args.startIndex}-${args.endIndex} in doc ${args.documentId}${args.tabId ? ` (tab: ${args.tabId})` : ''}`); if (args.endIndex <= args.startIndex) { throw new UserError("End index must be greater than start index for deletion."); } try { // If tabId is specified, verify the tab exists if (args.tabId) { const docInfo = await docs.documents.get({ documentId: args.documentId, includeTabsContent: true, fields: 'tabs(tabProperties,documentTab)' }); const targetTab = GDocsHelpers.findTabById(docInfo.data, args.tabId); if (!targetTab) { throw new UserError(`Tab with ID "${args.tabId}" not found in document.`); } if (!targetTab.documentTab) { throw new UserError(`Tab "${args.tabId}" does not have content (may not be a document tab).`); } } const range: any = { startIndex: args.startIndex, endIndex: args.endIndex }; if (args.tabId) { range.tabId = args.tabId; } const request: docs_v1.Schema$Request = { deleteContentRange: { range } }; await GDocsHelpers.executeBatchUpdate(docs, args.documentId, [request]); return `Successfully deleted content in range ${args.startIndex}-${args.endIndex}${args.tabId ? ` in tab ${args.tabId}` : ''}.`; } catch (error: any) { log.error(`Error deleting range in doc ${args.documentId}: ${error.message || error}`); if (error instanceof UserError) throw error; throw new UserError(`Failed to delete range: ${error.message || 'Unknown error'}`); } } }); // --- Advanced Formatting & Styling Tools --- server.addTool({ name: 'applyTextStyle', description: 'Applies character-level formatting (bold, color, font, etc.) to a specific range or found text.', parameters: ApplyTextStyleToolParameters, execute: async (args: ApplyTextStyleToolArgs, { log }) => { const docs = createDocsClient(args.accessToken); let { startIndex, endIndex } = args.target as any; // Will be updated if target is text log.info(`Applying text style in doc ${args.documentId}. Target: ${JSON.stringify(args.target)}, Style: ${JSON.stringify(args.style)}`); try { // Determine target range if ('textToFind' in args.target) { const range = await GDocsHelpers.findTextRange(docs, args.documentId, args.target.textToFind, args.target.matchInstance); if (!range) { throw new UserError(`Could not find instance ${args.target.matchInstance} of text "${args.target.textToFind}".`); } startIndex = range.startIndex; endIndex = range.endIndex; log.info(`Found text "${args.target.textToFind}" (instance ${args.target.matchInstance}) at range ${startIndex}-${endIndex}`); } if (startIndex === undefined || endIndex === undefined) { throw new UserError("Target range could not be determined."); } if (endIndex <= startIndex) { throw new UserError("End index must be greater than start index for styling."); } // Build the request const requestInfo = GDocsHelpers.buildUpdateTextStyleRequest(startIndex, endIndex, args.style); if (!requestInfo) { return "No valid text styling options were provided."; } await GDocsHelpers.executeBatchUpdate(docs, args.documentId, [requestInfo.request]); return `Successfully applied text style (${requestInfo.fields.join(', ')}) to range ${startIndex}-${endIndex}.`; } catch (error: any) { log.error(`Error applying text style in doc ${args.documentId}: ${error.message || error}`); if (error instanceof UserError) throw error; if (error instanceof NotImplementedError) throw error; // Should not happen here throw new UserError(`Failed to apply text style: ${error.message || 'Unknown error'}`); } } }); server.addTool({ name: 'applyParagraphStyle', description: 'Applies paragraph-level formatting (alignment, spacing, named styles like Heading 1) to the paragraph(s) containing specific text, an index, or a range.', parameters: ApplyParagraphStyleToolParameters, execute: async (args: ApplyParagraphStyleToolArgs, { log }) => { const docs = createDocsClient(args.accessToken); let startIndex: number | undefined; let endIndex: number | undefined; log.info(`Applying paragraph style to document ${args.documentId}`); log.info(`Style options: ${JSON.stringify(args.style)}`); log.info(`Target specification: ${JSON.stringify(args.target)}`); try { // STEP 1: Determine the target paragraph's range based on the targeting method if ('textToFind' in args.target) { // Find the text first log.info(`Finding text "${args.target.textToFind}" (instance ${args.target.matchInstance || 1})`); const textRange = await GDocsHelpers.findTextRange( docs, args.documentId, args.target.textToFind, args.target.matchInstance || 1 ); if (!textRange) { throw new UserError(`Could not find "${args.target.textToFind}" in the document.`); } log.info(`Found text at range ${textRange.startIndex}-${textRange.endIndex}, now locating containing paragraph`); // Then find the paragraph containing this text const paragraphRange = await GDocsHelpers.getParagraphRange( docs, args.documentId, textRange.startIndex ); if (!paragraphRange) { throw new UserError(`Found the text but could not determine the paragraph boundaries.`); } startIndex = paragraphRange.startIndex; endIndex = paragraphRange.endIndex; log.info(`Text is contained within paragraph at range ${startIndex}-${endIndex}`); } else if ('indexWithinParagraph' in args.target) { // Find paragraph containing the specified index log.info(`Finding paragraph containing index ${args.target.indexWithinParagraph}`); const paragraphRange = await GDocsHelpers.getParagraphRange( docs, args.documentId, args.target.indexWithinParagraph ); if (!paragraphRange) { throw new UserError(`Could not find paragraph containing index ${args.target.indexWithinParagraph}.`); } startIndex = paragraphRange.startIndex; endIndex = paragraphRange.endIndex; log.info(`Located paragraph at range ${startIndex}-${endIndex}`); } else if ('startIndex' in args.target && 'endIndex' in args.target) { // Use directly provided range startIndex = args.target.startIndex; endIndex = args.target.endIndex; log.info(`Using provided paragraph range ${startIndex}-${endIndex}`); } // Verify that we have a valid range if (startIndex === undefined || endIndex === undefined) { throw new UserError("Could not determine target paragraph range from the provided information."); } if (endIndex <= startIndex) { throw new UserError(`Invalid paragraph range: end index (${endIndex}) must be greater than start index (${startIndex}).`); } // STEP 2: Build and apply the paragraph style request log.info(`Building paragraph style request for range ${startIndex}-${endIndex}`); const requestInfo = GDocsHelpers.buildUpdateParagraphStyleRequest(startIndex, endIndex, args.style); if (!requestInfo) { return "No valid paragraph styling options were provided."; } log.info(`Applying styles: ${requestInfo.fields.join(', ')}`); await GDocsHelpers.executeBatchUpdate(docs, args.documentId, [requestInfo.request]); return `Successfully applied paragraph styles (${requestInfo.fields.join(', ')}) to the paragraph.`; } catch (error: any) { // Detailed error logging log.error(`Error applying paragraph style in doc ${args.documentId}:`); log.error(error.stack || error.message || error); if (error instanceof UserError) throw error; if (error instanceof NotImplementedError) throw error; // Provide a more helpful error message throw new UserError(`Failed to apply paragraph style: ${error.message || 'Unknown error'}`); } } }); // --- Structure & Content Tools --- server.addTool({ name: 'insertTable', description: 'Inserts a new table with the specified dimensions at a given index.', parameters: DocumentIdParameter.extend({ rows: z.number().int().min(1).describe('Number of rows for the new table.'), columns: z.number().int().min(1).describe('Number of columns for the new table.'), index: z.number().int().min(1).describe('The index (1-based) where the table should be inserted.'), }), execute: async (args, { log }) => { const docs = createDocsClient(args.accessToken); log.info(`Inserting ${args.rows}x${args.columns} table in doc ${args.documentId} at index ${args.index}`); try { await GDocsHelpers.createTable(docs, args.documentId, args.rows, args.columns, args.index); // The API response contains info about the created table, but might be too complex to return here. return `Successfully inserted a ${args.rows}x${args.columns} table at index ${args.index}.`; } catch (error: any) { log.error(`Error inserting table in doc ${args.documentId}: ${error.message || error}`); if (error instanceof UserError) throw error; throw new UserError(`Failed to insert table: ${error.message || 'Unknown error'}`); } } }); server.addTool({ name: 'editTableCell', description: 'Edits the content and/or basic style of a specific table cell. Requires knowing table start index.', parameters: DocumentIdParameter.extend({ tableStartIndex: z.number().int().min(1).describe("The starting index of the TABLE element itself (tricky to find, may require reading structure first)."), rowIndex: z.number().int().min(0).describe("Row index (0-based)."), columnIndex: z.number().int().min(0).describe("Column index (0-based)."), textContent: z.string().optional().describe("Optional: New text content for the cell. Replaces existing content."), // Combine basic styles for simplicity here. More advanced cell styling might need separate tools. textStyle: TextStyleParameters.optional().describe("Optional: Text styles to apply."), paragraphStyle: ParagraphStyleParameters.optional().describe("Optional: Paragraph styles (like alignment) to apply."), // cellBackgroundColor: z.string().optional()... // Cell-specific styles are complex }), execute: async (args, { log }) => { const docs = createDocsClient(args.accessToken); log.info(`Editing cell (${args.rowIndex}, ${args.columnIndex}) in table starting at ${args.tableStartIndex}, doc ${args.documentId}`); // TODO: Implement complex logic // 1. Find the cell's content range based on tableStartIndex, rowIndex, columnIndex. This is NON-TRIVIAL. // Requires getting the document, finding the table element, iterating through rows/cells to calculate indices. // 2. If textContent is provided, generate a DeleteContentRange request for the cell's current content. // 3. Generate an InsertText request for the new textContent at the cell's start index. // 4. If textStyle is provided, generate UpdateTextStyle requests for the new text range. // 5. If paragraphStyle is provided, generate UpdateParagraphStyle requests for the cell's paragraph range. // 6. Execute batch update. log.error("editTableCell is not implemented due to complexity of finding cell indices."); throw new NotImplementedError("Editing table cells is complex and not yet implemented."); // return `Edit request for cell (${args.rowIndex}, ${args.columnIndex}) submitted (Not Implemented).`; } }); server.addTool({ name: 'insertPageBreak', description: 'Inserts a page break at the specified index.', parameters: DocumentIdParameter.extend({ index: z.number().int().min(1).describe('The index (1-based) where the page break should be inserted.'), }), execute: async (args, { log }) => { const docs = createDocsClient(args.accessToken); log.info(`Inserting page break in doc ${args.documentId} at index ${args.index}`); try { const request: docs_v1.Schema$Request = { insertPageBreak: { location: { index: args.index } } }; await GDocsHelpers.executeBatchUpdate(docs, args.documentId, [request]); return `Successfully inserted page break at index ${args.index}.`; } catch (error: any) { log.error(`Error inserting page break in doc ${args.documentId}: ${error.message || error}`); if (error instanceof UserError) throw error; throw new UserError(`Failed to insert page break: ${error.message || 'Unknown error'}`); } } }); // --- Image Insertion Tools --- server.addTool({ name: 'insertImageFromUrl', description: 'Inserts an inline image into a Google Document from a publicly accessible URL.', parameters: DocumentIdParameter.extend({ imageUrl: z.string().url().describe('Publicly accessible URL to the image (must be http:// or https://).'), index: z.number().int().min(1).describe('The index (1-based) where the image should be inserted.'), width: z.number().min(1).optional().describe('Optional: Width of the image in points.'), height: z.number().min(1).optional().describe('Optional: Height of the image in points.'), }), execute: async (args, { log }) => { const docs = createDocsClient(args.accessToken); log.info(`Inserting image from URL ${args.imageUrl} at index ${args.index} in doc ${args.documentId}`); try { await GDocsHelpers.insertInlineImage( docs, args.documentId, args.imageUrl, args.index, args.width, args.height ); let sizeInfo = ''; if (args.width && args.height) { sizeInfo = ` with size ${args.width}x${args.height}pt`; } return `Successfully inserted image from URL at index ${args.index}${sizeInfo}.`; } catch (error: any) { log.error(`Error inserting image in doc ${args.documentId}: ${error.message || error}`); if (error instanceof UserError) throw error; throw new UserError(`Failed to insert image: ${error.message || 'Unknown error'}`); } } }); server.addTool({ name: 'insertLocalImage', 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).', parameters: DocumentIdParameter.extend({ localImagePath: z.string().describe('Absolute path to the local image file (supports .jpg, .jpeg, .png, .gif, .bmp, .webp, .svg).'), index: z.number().int().min(1).describe('The index (1-based) where the image should be inserted in the document.'), width: z.number().min(1).optional().describe('Optional: Width of the image in points.'), height: z.number().min(1).optional().describe('Optional: Height of the image in points.'), 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.'), }), execute: async (args, { log }) => { const docs = createDocsClient(args.accessToken); const drive = createDriveClient(args.accessToken); log.info(`Uploading local image ${args.localImagePath} and inserting at index ${args.index} in doc ${args.documentId}`); try { // Get the document's parent folder if requested let parentFolderId: string | undefined; if (args.uploadToSameFolder) { try { const docInfo = await drive.files.get({ fileId: args.documentId, fields: 'parents' }); if (docInfo.data.parents && docInfo.data.parents.length > 0) { parentFolderId = docInfo.data.parents[0]; log.info(`Will upload image to document's parent folder: ${parentFolderId}`); } } catch (folderError) { log.warn(`Could not determine document's parent folder, using Drive root: ${folderError}`); } } // Upload the image to Drive log.info(`Uploading image to Drive...`); const imageUrl = await GDocsHelpers.uploadImageToDrive( drive, args.localImagePath, parentFolderId ); log.info(`Image uploaded successfully, public URL: ${imageUrl}`); // Insert the image into the document await GDocsHelpers.insertInlineImage( docs, args.documentId, imageUrl, args.index, args.width, args.height ); let sizeInfo = ''; if (args.width && args.height) { sizeInfo = ` with size ${args.width}x${args.height}pt`; } return `Successfully uploaded image to Drive and inserted it at index ${args.index}${sizeInfo}.\nImage URL: ${imageUrl}`; } catch (error: any) { log.error(`Error uploading/inserting local image in doc ${args.documentId}: ${error.message || error}`); if (error instanceof UserError) throw error; throw new UserError(`Failed to upload/insert local image: ${error.message || 'Unknown error'}`); } } }); // --- Intelligent Assistance Tools (Examples/Stubs) --- server.addTool({ name: 'fixListFormatting', 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.', parameters: DocumentIdParameter.extend({ // Optional range to limit the scope, otherwise scans whole doc (potentially slow/risky) range: OptionalRangeParameters.optional().describe("Optional: Limit the fixing process to a specific range.") }), execute: async (args, { log }) => { const docs = createDocsClient(args.accessToken); log.warn(`Executing EXPERIMENTAL fixListFormatting for doc ${args.documentId}. Range: ${JSON.stringify(args.range)}`); try { await GDocsHelpers.detectAndFormatLists(docs, args.documentId, args.range?.startIndex, args.range?.endIndex); return `Attempted to fix list formatting. Please review the document for accuracy.`; } catch (error: any) { log.error(`Error fixing list formatting in doc ${args.documentId}: ${error.message || error}`); if (error instanceof UserError) throw error; if (error instanceof NotImplementedError) throw error; // Expected if helper not implemented throw new UserError(`Failed to fix list formatting: ${error.message || 'Unknown error'}`); } } }); // === COMMENT TOOLS === server.addTool({ name: 'listComments', description: 'Lists all comments in a Google Document.', parameters: DocumentIdParameter, execute: async (args, { log }) => { log.info(`Listing comments for document ${args.documentId}`); const docsClient = createDocsClient(args.accessToken); const driveClient = createDriveClient(args.accessToken); try { // First get the document to have context const doc = await docsClient.documents.get({ documentId: args.documentId }); // Use Drive API v3 with proper fields to get quoted content const response = await driveClient.comments.list({ fileId: args.documentId, fields: 'comments(id,content,quotedFileContent,author,createdTime,resolved)', pageSize: 100 }); const comments = response.data.comments || []; if (comments.length === 0) { return 'No comments found in this document.'; } // Format comments for display const formattedComments = comments.map((comment: any, index: number) => { const replies = comment.replies?.length || 0; const status = comment.resolved ? ' [RESOLVED]' : ''; const author = comment.author?.displayName || 'Unknown'; const date = comment.createdTime ? new Date(comment.createdTime).toLocaleDateString() : 'Unknown date'; // Get the actual quoted text content const quotedText = comment.quotedFileContent?.value || 'No quoted text'; const anchor = quotedText !== 'No quoted text' ? ` (anchored to: "${quotedText.substring(0, 100)}${quotedText.length > 100 ? '...' : ''}")` : ''; let result = `\n${index + 1}. **${author}** (${date})${status}${anchor}\n ${comment.content}`; if (replies > 0) { result += `\n └─ ${replies} ${replies === 1 ? 'reply' : 'replies'}`; } result += `\n Comment ID: ${comment.id}`; return result; }).join('\n'); return `Found ${comments.length} comment${comments.length === 1 ? '' : 's'}:\n${formattedComments}`; } catch (error: any) { log.error(`Error listing comments: ${error.message || error}`); throw new UserError(`Failed to list comments: ${error.message || 'Unknown error'}`); } } }); server.addTool({ name: 'getComment', description: 'Gets a specific comment with its full thread of replies.', parameters: DocumentIdParameter.extend({ commentId: z.string().describe('The ID of the comment to retrieve') }), execute: async (args, { log }) => { log.info(`Getting comment ${args.commentId} from document ${args.documentId}`); try { const driveClient = createDriveClient(args.accessToken); const response = await driveClient.comments.get({ fileId: args.documentId, commentId: args.commentId, fields: 'id,content,quotedFileContent,author,createdTime,resolved,replies(id,content,author,createdTime)' }); const comment = response.data; const author = comment.author?.displayName || 'Unknown'; const date = comment.createdTime ? new Date(comment.createdTime).toLocaleDateString() : 'Unknown date'; const status = comment.resolved ? ' [RESOLVED]' : ''; const quotedText = comment.quotedFileContent?.value || 'No quoted text'; const anchor = quotedText !== 'No quoted text' ? `\nAnchored to: "${quotedText}"` : ''; let result = `**${author}** (${date})${status}${anchor}\n${comment.content}`; // Add replies if any if (comment.replies && comment.replies.length > 0) { result += '\n\n**Replies:**'; comment.replies.forEach((reply: any, index: number) => { const replyAuthor = reply.author?.displayName || 'Unknown'; const replyDate = reply.createdTime ? new Date(reply.createdTime).toLocaleDateString() : 'Unknown date'; result += `\n${index + 1}. **${replyAuthor}** (${replyDate})\n ${reply.content}`; }); } return result; } catch (error: any) { log.error(`Error getting comment: ${error.message || error}`); throw new UserError(`Failed to get comment: ${error.message || 'Unknown error'}`); } } }); server.addTool({ name: 'addComment', description: 'Adds a comment anchored to a specific text range in the document.', parameters: DocumentIdParameter.extend({ startIndex: z.number().int().min(1).describe('The starting index of the text range (inclusive, starts from 1).'), endIndex: z.number().int().min(1).describe('The ending index of the text range (exclusive).'), commentText: z.string().min(1).describe('The content of the comment.'), }).refine(data => data.endIndex > data.startIndex, { message: 'endIndex must be greater than startIndex', path: ['endIndex'], }), execute: async (args, { log }) => { log.info(`Adding comment to range ${args.startIndex}-${args.endIndex} in doc ${args.documentId}`); try { // First, get the text content that will be quoted const docsClient = createDocsClient(args.accessToken); const doc = await docsClient.documents.get({ documentId: args.documentId }); // Extract the quoted text from the document let quotedText = ''; const content = doc.data.body?.content || []; for (const element of content) { if (element.paragraph) { const elements = element.paragraph.elements || []; for (const textElement of elements) { if (textElement.textRun) { const elementStart = textElement.startIndex || 0; const elementEnd = textElement.endIndex || 0; // Check if this element overlaps with our range if (elementEnd > args.startIndex && elementStart < args.endIndex) { const text = textElement.textRun.content || ''; const startOffset = Math.max(0, args.startIndex - elementStart); const endOffset = Math.min(text.length, args.endIndex - elementStart); quotedText += text.substring(startOffset, endOffset); } } } } } // Use Drive API v3 for comments const driveClient2 = createDriveClient(args.accessToken); const response = await driveClient2.comments.create({ fileId: args.documentId, fields: 'id,content,quotedFileContent,author,createdTime,resolved', requestBody: { content: args.commentText, quotedFileContent: { value: quotedText, mimeType: 'text/html' }, anchor: JSON.stringify({ r: args.documentId, a: [{ txt: { o: args.startIndex - 1, // Drive API uses 0-based indexing l: args.endIndex - args.startIndex, ml: args.endIndex - args.startIndex } }] }) } }); return `Comment added successfully. Comment ID: ${response.data.id}`; } catch (error: any) { log.error(`Error adding comment: ${error.message || error}`); throw new UserError(`Failed to add comment: ${error.message || 'Unknown error'}`); } } }); server.addTool({ name: 'replyToComment', description: 'Adds a reply to an existing comment.', parameters: DocumentIdParameter.extend({ commentId: z.string().describe('The ID of the comment to reply to'), replyText: z.string().min(1).describe('The content of the reply') }), execute: async (args, { log }) => { log.info(`Adding reply to comment ${args.commentId} in doc ${args.documentId}`); try { const driveClient = createDriveClient(args.accessToken); const response = await driveClient.replies.create({ fileId: args.documentId, commentId: args.commentId, fields: 'id,content,author,createdTime', requestBody: { content: args.replyText } }); return `Reply added successfully. Reply ID: ${response.data.id}`; } catch (error: any) { log.error(`Error adding reply: ${error.message || error}`); throw new UserError(`Failed to add reply: ${error.message || 'Unknown error'}`); } } }); server.addTool({ name: 'resolveComment', description: 'Marks a comment as resolved.', parameters: DocumentIdParameter.extend({ commentId: z.string().describe('The ID of the comment to resolve') }), execute: async (args, { log }) => { log.info(`Resolving comment ${args.commentId} in doc ${args.documentId}`); try { const driveClient = createDriveClient(args.accessToken); await driveClient.comments.update({ fileId: args.documentId, commentId: args.commentId, requestBody: { resolved: true } }); return `Comment ${args.commentId} has been resolved.`; } catch (error: any) { log.error(`Error resolving comment: ${error.message || error}`); throw new UserError(`Failed to resolve comment: ${error.message || 'Unknown error'}`); } } }); server.addTool({ name: 'deleteComment', description: 'Deletes a comment from the document.', parameters: DocumentIdParameter.extend({ commentId: z.string().describe('The ID of the comment to delete') }), execute: async (args, { log }) => { log.info(`Deleting comment ${args.commentId} from doc ${args.documentId}`); try { const driveClient = createDriveClient(args.accessToken); await driveClient.comments.delete({ fileId: args.documentId, commentId: args.commentId }); return `Comment ${args.commentId} has been deleted.`; } catch (error: any) { log.error(`Error deleting comment: ${error.message || error}`); throw new UserError(`Failed to delete comment: ${error.message || 'Unknown error'}`); } } }); // --- Add Stubs for other advanced features --- // (findElement, getDocumentMetadata, replaceText, list management, image handling, section breaks, footnotes, etc.) // Example Stub: server.addTool({ name: 'findElement', description: 'Finds elements (paragraphs, tables, etc.) based on various criteria. (Not Implemented)', parameters: DocumentIdParameter.extend({ // Define complex query parameters... textQuery: z.string().optional(), elementType: z.enum(['paragraph', 'table', 'list', 'image']).optional(), // styleQuery... }), execute: async (args, { log }) => { log.warn("findElement tool called but is not implemented."); throw new NotImplementedError("Finding elements by complex criteria is not yet implemented."); } }); // --- Preserve the existing formatMatchingText tool for backward compatibility --- server.addTool({ name: 'formatMatchingText', description: 'Finds specific text within a Google Document and applies character formatting (bold, italics, color, etc.) to the specified instance.', parameters: z.object({ accessToken: z.string().describe('OAuth access token from platform'), documentId: z.string().describe('The ID of the Google Document.'), textToFind: z.string().min(1).describe('The exact text string to find and format.'), matchInstance: z.number().int().min(1).optional().default(1).describe('Which instance of the text to format (1st, 2nd, etc.). Defaults to 1.'), // Re-use optional Formatting Parameters (SHARED) bold: z.boolean().optional().describe('Apply bold formatting.'), italic: z.boolean().optional().describe('Apply italic formatting.'), underline: z.boolean().optional().describe('Apply underline formatting.'), strikethrough: z.boolean().optional().describe('Apply strikethrough formatting.'), fontSize: z.number().min(1).optional().describe('Set font size (in points, e.g., 12).'), fontFamily: z.string().optional().describe('Set font family (e.g., "Arial", "Times New Roman").'), foregroundColor: z.string() .refine((color) => /^#?([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/.test(color), { message: "Invalid hex color format (e.g., #FF0000 or #F00)" }) .optional() .describe('Set text color using hex format (e.g., "#FF0000").'), backgroundColor: z.string() .refine((color) => /^#?([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/.test(color), { message: "Invalid hex color format (e.g., #00FF00 or #0F0)" }) .optional() .describe('Set text background color using hex format (e.g., "#FFFF00").'), linkUrl: z.string().url().optional().describe('Make the text a hyperlink pointing to this URL.') }) .refine(data => Object.keys(data).some(key => !['documentId', 'textToFind', 'matchInstance'].includes(key) && data[key as keyof typeof data] !== undefined), { message: "At least one formatting option (bold, italic, fontSize, etc.) must be provided." }), execute: async (args, { log }) => { // Adapt to use the new applyTextStyle implementation under the hood const docs = createDocsClient(args.accessToken); log.info(`Using formatMatchingText (legacy) for doc ${args.documentId}, target: "${args.textToFind}" (instance ${args.matchInstance})`); try { // Extract the style parameters const styleParams: TextStyleArgs = {}; if (args.bold !== undefined) styleParams.bold = args.bold; if (args.italic !== undefined) styleParams.italic = args.italic; if (args.underline !== undefined) styleParams.underline = args.underline; if (args.strikethrough !== undefined) styleParams.strikethrough = args.strikethrough; if (args.fontSize !== undefined) styleParams.fontSize = args.fontSize; if (args.fontFamily !== undefined) styleParams.fontFamily = args.fontFamily; if (args.foregroundColor !== undefined) styleParams.foregroundColor = args.foregroundColor; if (args.backgroundColor !== undefined) styleParams.backgroundColor = args.backgroundColor; if (args.linkUrl !== undefined) styleParams.linkUrl = args.linkUrl; // Find the text range const range = await GDocsHelpers.findTextRange(docs, args.documentId, args.textToFind, args.matchInstance); if (!range) { throw new UserError(`Could not find instance ${args.matchInstance} of text "${args.textToFind}".`); } // Build and execute the request const requestInfo = GDocsHelpers.buildUpdateTextStyleRequest(range.startIndex, range.endIndex, styleParams); if (!requestInfo) { return "No valid text styling options were provided."; } await GDocsHelpers.executeBatchUpdate(docs, args.documentId, [requestInfo.request]); return `Successfully applied formatting to instance ${args.matchInstance} of "${args.textToFind}".`; } catch (error: any) { log.error(`Error in formatMatchingText for doc ${args.documentId}: ${error.message || error}`); if (error instanceof UserError) throw error; throw new UserError(`Failed to format text: ${error.message || 'Unknown error'}`); } } }); // === GOOGLE DRIVE TOOLS === server.addTool({ name: 'listGoogleDocs', description: 'Lists Google Documents from your Google Drive with optional filtering.', parameters: z.object({ accessToken: z.string().describe('OAuth access token from platform'), maxResults: z.number().int().min(1).max(100).optional().default(20).describe('Maximum number of documents to return (1-100).'), query: z.string().optional().describe('Search query to filter documents by name or content.'), orderBy: z.enum(['name', 'modifiedTime', 'createdTime']).optional().default('modifiedTime').describe('Sort order for results.'), }), execute: async (args, { log }) => { const drive = createDriveClient(args.accessToken); log.info(`Listing Google Docs. Query: ${args.query || 'none'}, Max: ${args.maxResults}, Order: ${args.orderBy}`); try { // Build the query string for Google Drive API let queryString = "mimeType='application/vnd.google-apps.document' and trashed=false"; if (args.query) { queryString += ` and (name contains '${args.query}' or fullText contains '${args.query}')`; } const response = await drive.files.list({ q: queryString, pageSize: args.maxResults, orderBy: args.orderBy === 'name' ? 'name' : args.orderBy, fields: 'files(id,name,modifiedTime,createdTime,size,webViewLink,owners(displayName,emailAddress))', }); const files = response.data.files || []; if (files.length === 0) { return "No Google Docs found matching your criteria."; } let result = `Found ${files.length} Google Document(s):\n\n`; files.forEach((file, index) => { const modifiedDate = file.modifiedTime ? new Date(file.modifiedTime).toLocaleDateString() : 'Unknown'; const owner = file.owners?.[0]?.displayName || 'Unknown'; result += `${index + 1}. **${file.name}**\n`; result += ` ID: ${file.id}\n`; result += ` Modified: ${modifiedDate}\n`; result += ` Owner: ${owner}\n`; result += ` Link: ${file.webViewLink}\n\n`; }); return result; } catch (error: any) { log.error(`Error listing Google Docs: ${error.message || error}`); if (error.code === 403) throw new UserError("Permission denied. Make sure you have granted Google Drive access to the application."); throw new UserError(`Failed to list documents: ${error.message || 'Unknown error'}`); } } }); server.addTool({ name: 'searchGoogleDocs', description: 'Searches for Google Documents by name, content, or other criteria.', parameters: z.object({ accessToken: z.string().describe('OAuth access token from platform'), searchQuery: z.string().min(1).describe('Search term to find in document names or content.'), searchIn: z.enum(['name', 'content', 'both']).optional().default('both').describe('Where to search: document names, content, or both.'), maxResults: z.number().int().min(1).max(50).optional().default(10).describe('Maximum number of results to return.'), modifiedAfter: z.string().optional().describe('Only return documents modified after this date (ISO 8601 format, e.g., "2024-01-01").'), }), execute: async (args, { log }) => { const drive = createDriveClient(args.accessToken); log.info(`Searching Google Docs for: "${args.searchQuery}" in ${args.searchIn}`); try { let queryString = "mimeType='application/vnd.google-apps.document' and trashed=false"; // Add search criteria if (args.searchIn === 'name') { queryString += ` and name contains '${args.searchQuery}'`; } else if (args.searchIn === 'content') { queryString += ` and fullText contains '${args.searchQuery}'`; } else { queryString += ` and (name contains '${args.searchQuery}' or fullText contains '${args.searchQuery}')`; } // Add date filter if provided if (args.modifiedAfter) { queryString += ` and modifiedTime > '${args.modifiedAfter}'`; } const response = await drive.files.list({ q: queryString, pageSize: args.maxResults, orderBy: 'modifiedTime desc', fields: 'files(id,name,modifiedTime,createdTime,webViewLink,owners(displayName),parents)', }); const files = response.data.files || []; if (files.length === 0) { return `No Google Docs found containing "${args.searchQuery}".`; } let result = `Found ${files.length} document(s) matching "${args.searchQuery}":\n\n`; files.forEach((file, index) => { const modifiedDate = file.modifiedTime ? new Date(file.modifiedTime).toLocaleDateString() : 'Unknown'; const owner = file.owners?.[0]?.displayName || 'Unknown'; result += `${index + 1}. **${file.name}**\n`; result += ` ID: ${file.id}\n`; result += ` Modified: ${modifiedDate}\n`; result += ` Owner: ${owner}\n`; result += ` Link: ${file.webViewLink}\n\n`; }); return result; } catch (error: any) { log.error(`Error searching Google Docs: ${error.message || error}`); if (error.code === 403) throw new UserError("Permission denied. Make sure you have granted Google Drive access to the application."); throw new UserError(`Failed to search documents: ${error.message || 'Unknown error'}`); } } }); server.addTool({ name: 'getRecentGoogleDocs', description: 'Gets the most recently modified Google Documents.', parameters: z.object({ accessToken: z.string().describe('OAuth access token from platform'), maxResults: z.number().int().min(1).max(50).optional().default(10).describe('Maximum number of recent documents to return.'), daysBack: z.number().int().min(1).max(365).optional().default(30).describe('Only show documents modified within this many days.'), }), execute: async (args, { log }) => { const drive = createDriveClient(args.accessToken); log.info(`Getting recent Google Docs: ${args.maxResults} results, ${args.daysBack} days back`); try { const cutoffDate = new Date(); cutoffDate.setDate(cutoffDate.getDate() - args.daysBack); const cutoffDateStr = cutoffDate.toISOString(); const queryString = `mimeType='application/vnd.google-apps.document' and trashed=false and modifiedTime > '${cutoffDateStr}'`; const response = await drive.files.list({ q: queryString, pageSize: args.maxResults, orderBy: 'modifiedTime desc', fields: 'files(id,name,modifiedTime,createdTime,webViewLink,owners(displayName),lastModifyingUser(displayName))', }); const files = response.data.files || []; if (files.length === 0) { return `No Google Docs found that were modified in the last ${args.daysBack} days.`; } let result = `${files.length} recently modified Google Document(s) (last ${args.daysBack} days):\n\n`; files.forEach((file, index) => { const modifiedDate = file.modifiedTime ? new Date(file.modifiedTime).toLocaleString() : 'Unknown'; const lastModifier = file.lastModifyingUser?.displayName || 'Unknown'; const owner = file.owners?.[0]?.displayName || 'Unknown'; result += `${index + 1}. **${file.name}**\n`; result += ` ID: ${file.id}\n`; result += ` Last Modified: ${modifiedDate} by ${lastModifier}\n`; result += ` Owner: ${owner}\n`; result += ` Link: ${file.webViewLink}\n\n`; }); return result; } catch (error: any) { log.error(`Error getting recent Google Docs: ${error.message || error}`); if (error.code === 403) throw new UserError("Permission denied. Make sure you have granted Google Drive access to the application."); throw new UserError(`Failed to get recent documents: ${error.message || 'Unknown error'}`); } } }); server.addTool({ name: 'getDocumentInfo', description: 'Gets detailed information about a specific Google Document.', parameters: DocumentIdParameter, execute: async (args, { log }) => { const drive = createDriveClient(args.accessToken); log.info(`Getting info for document: ${args.documentId}`); try { const response = await drive.files.get({ fileId: args.documentId, fields: 'id,name,description,mimeType,size,createdTime,modifiedTime,webViewLink,alternateLink,owners(displayName,emailAddress),lastModifyingUser(displayName,emailAddress),shared,permissions(role,type,emailAddress),parents,version', }); const file = response.data; if (!file) { throw new UserError(`Document with ID ${args.documentId} not found.`); } const createdDate = file.createdTime ? new Date(file.createdTime).toLocaleString() : 'Unknown'; const modifiedDate = file.modifiedTime ? new Date(file.modifiedTime).toLocaleString() : 'Unknown'; const owner = file.owners?.[0]; const lastModifier = file.lastModifyingUser; let result = `**Document Information:**\n\n`; result += `**Name:** ${file.name}\n`; result += `**ID:** ${file.id}\n`; result += `**Type:** Google Document\n`; result += `**Created:** ${createdDate}\n`; result += `**Last Modified:** ${modifiedDate}\n`; if (owner) { result += `**Owner:** ${owner.displayName} (${owner.emailAddress})\n`; } if (lastModifier) { result += `**Last Modified By:** ${lastModifier.displayName} (${lastModifier.emailAddress})\n`; } result += `**Shared:** ${file.shared ? 'Yes' : 'No'}\n`; result += `**View Link:** ${file.webViewLink}\n`; if (file.description) { result += `**Description:** ${file.description}\n`; } return result; } catch (error: any) { log.error(`Error getting document info: ${error.message || error}`); if (error.code === 404) throw new UserError(`Document not found (ID: ${args.documentId}).`); if (error.code === 403) throw new UserError("Permission denied. Make sure you have access to this document."); throw new UserError(`Failed to get document info: ${error.message || 'Unknown error'}`); } } }); // === GOOGLE DRIVE FILE MANAGEMENT TOOLS === // --- Folder Management Tools --- server.addTool({ name: 'createFolder', description: 'Creates a new folder in Google Drive.', parameters: z.object({ accessToken: z.string().describe('OAuth access token from platform'), name: z.string().min(1).describe('Name for the new folder.'), parentFolderId: z.string().optional().describe('Parent folder ID. If not provided, creates folder in Drive root.'), }), execute: async (args, { log }) => { const drive = createDriveClient(args.accessToken); log.info(`Creating folder "${args.name}" ${args.parentFolderId ? `in parent ${args.parentFolderId}` : 'in root'}`); try { const folderMetadata: drive_v3.Schema$File = { name: args.name, mimeType: 'application/vnd.google-apps.folder', }; if (args.parentFolderId) { folderMetadata.parents = [args.parentFolderId]; } const response = await drive.files.create({ requestBody: folderMetadata, fields: 'id,name,parents,webViewLink', }); const folder = response.data; return `Successfully created folder "${folder.name}" (ID: ${folder.id})\nLink: ${folder.webViewLink}`; } catch (error: any) { log.error(`Error creating folder: ${error.message || error}`); if (error.code === 404) throw new UserError("Parent folder not found. Check the parent folder ID."); if (error.code === 403) throw new UserError("Permission denied. Make sure you have write access to the parent folder."); throw new UserError(`Failed to create folder: ${error.message || 'Unknown error'}`); } } }); server.addTool({ name: 'listFolderContents', description: 'Lists the contents of a specific folder in Google Drive.', parameters: z.object({ accessToken: z.string().describe('OAuth access token from platform'), folderId: z.string().describe('ID of the folder to list contents of. Use "root" for the root Drive folder.'), includeSubfolders: z.boolean().optional().default(true).describe('Whether to include subfolders in results.'), includeFiles: z.boolean().optional().default(true).describe('Whether to include files in results.'), maxResults: z.number().int().min(1).max(100).optional().default(50).describe('Maximum number of items to return.'), }), execute: async (args, { log }) => { const drive = createDriveClient(args.accessToken); log.info(`Listing contents of folder: ${args.folderId}`); try { let queryString = `'${args.folderId}' in parents and trashed=false`; // Filter by type if specified if (!args.includeSubfolders && !args.includeFiles) { throw new UserError("At least one of includeSubfolders or includeFiles must be true."); } if (!args.includeSubfolders) { queryString += ` and mimeType!='application/vnd.google-apps.folder'`; } else if (!args.includeFiles) { queryString += ` and mimeType='application/vnd.google-apps.folder'`; } const response = await drive.files.list({ q: queryString, pageSize: args.maxResults, orderBy: 'folder,name', fields: 'files(id,name,mimeType,size,modifiedTime,webViewLink,owners(displayName))', }); const items = response.data.files || []; if (items.length === 0) { return "The folder is empty or you don't have permission to view its contents."; } let result = `Contents of folder (${items.length} item${items.length !== 1 ? 's' : ''}):\n\n`; // Separate folders and files const folders = items.filter(item => item.mimeType === 'application/vnd.google-apps.folder'); const files = items.filter(item => item.mimeType !== 'application/vnd.google-apps.folder'); // List folders first if (folders.length > 0 && args.includeSubfolders) { result += `**Folders (${folders.length}):**\n`; folders.forEach(folder => { result += `šŸ“ ${folder.name} (ID: ${folder.id})\n`; }); result += '\n'; } // Then list files if (files.length > 0 && args.includeFiles) { result += `**Files (${files.length}):\n`; files.forEach(file => { const fileType = file.mimeType === 'application/vnd.google-apps.document' ? 'šŸ“„' : file.mimeType === 'application/vnd.google-apps.spreadsheet' ? 'šŸ“Š' : file.mimeType === 'application/vnd.google-apps.presentation' ? 'šŸ“ˆ' : 'šŸ“Ž'; const modifiedDate = file.modifiedTime ? new Date(file.modifiedTime).toLocaleDateString() : 'Unknown'; const owner = file.owners?.[0]?.displayName || 'Unknown'; result += `${fileType} ${file.name}\n`; result += ` ID: ${file.id}\n`; result += ` Modified: ${modifiedDate} by ${owner}\n`; result += ` Link: ${file.webViewLink}\n\n`; }); } return result; } catch (error: any) { log.error(`Error listing folder contents: ${error.message || error}`); if (error.code === 404) throw new UserError("Folder not found. Check the folder ID."); if (error.code === 403) throw new UserError("Permission denied. Make sure you have access to this folder."); throw new UserError(`Failed to list folder contents: ${error.message || 'Unknown error'}`); } } }); server.addTool({ name: 'getFolderInfo', description: 'Gets detailed information about a specific folder in Google Drive.', parameters: z.object({ accessToken: z.string().describe('OAuth access token from platform'), folderId: z.string().describe('ID of the folder to get information about.'), }), execute: async (args, { log }) => { const drive = createDriveClient(args.accessToken); log.info(`Getting folder info: ${args.folderId}`); try { const response = await drive.files.get({ fileId: args.folderId, fields: 'id,name,description,createdTime,modifiedTime,webViewLink,owners(displayName,emailAddress),lastModifyingUser(displayName),shared,parents', }); const folder = response.data; if (folder.mimeType !== 'application/vnd.google-apps.folder') { throw new UserError("The specified ID does not belong to a folder."); } const createdDate = folder.createdTime ? new Date(folder.createdTime).toLocaleString() : 'Unknown'; const modifiedDate = folder.modifiedTime ? new Date(folder.modifiedTime).toLocaleString() : 'Unknown'; const owner = folder.owners?.[0]; const lastModifier = folder.lastModifyingUser; let result = `**Folder Information:**\n\n`; result += `**Name:** ${folder.name}\n`; result += `**ID:** ${folder.id}\n`; result += `**Created:** ${createdDate}\n`; result += `**Last Modified:** ${modifiedDate}\n`; if (owner) { result += `**Owner:** ${owner.displayName} (${owner.emailAddress})\n`; } if (lastModifier) { result += `**Last Modified By:** ${lastModifier.displayName}\n`; } result += `**Shared:** ${folder.shared ? 'Yes' : 'No'}\n`; result += `**View Link:** ${folder.webViewLink}\n`; if (folder.description) { result += `**Description:** ${folder.description}\n`; } if (folder.parents && folder.parents.length > 0) { result += `**Parent Folder ID:** ${folder.parents[0]}\n`; } return result; } catch (error: any) { log.error(`Error getting folder info: ${error.message || error}`); if (error.code === 404) throw new UserError(`Folder not found (ID: ${args.folderId}).`); if (error.code === 403) throw new UserError("Permission denied. Make sure you have access to this folder."); throw new UserError(`Failed to get folder info: ${error.message || 'Unknown error'}`); } } }); // --- File Operation Tools --- server.addTool({ name: 'moveFile', description: 'Moves a file or folder to a different location in Google Drive.', parameters: z.object({ accessToken: z.string().describe('OAuth access token from platform'), fileId: z.string().describe('ID of the file or folder to move.'), newParentId: z.string().describe('ID of the destination folder. Use "root" for Drive root.'), removeFromAllParents: z.boolean().optional().default(false).describe('If true, removes from all current parents. If false, adds to new parent while keeping existing parents.'), }), execute: async (args, { log }) => { const drive = createDriveClient(args.accessToken); log.info(`Moving file ${args.fileId} to folder ${args.newParentId}`); try { // First get the current parents const fileInfo = await drive.files.get({ fileId: args.fileId, fields: 'name,parents', }); const fileName = fileInfo.data.name; const currentParents = fileInfo.data.parents || []; let updateParams: any = { fileId: args.fileId, addParents: args.newParentId, fields: 'id,name,parents', }; if (args.removeFromAllParents && currentParents.length > 0) { updateParams.removeParents = currentParents.join(','); } const response = await drive.files.update(updateParams); const action = args.removeFromAllParents ? 'moved' : 'copied'; return `Successfully ${action} "${fileName}" to new location.\nFile ID: ${response.data.id}`; } catch (error: any) { log.error(`Error moving file: ${error.message || error}`); if (error.code === 404) throw new UserError("File or destination folder not found. Check the IDs."); if (error.code === 403) throw new UserError("Permission denied. Make sure you have write access to both source and destination."); throw new UserError(`Failed to move file: ${error.message || 'Unknown error'}`); } } }); server.addTool({ name: 'copyFile', description: 'Creates a copy of a Google Drive file or document.', parameters: z.object({ accessToken: z.string().describe('OAuth access token from platform'), fileId: z.string().describe('ID of the file to copy.'), newName: z.string().optional().describe('Name for the copied file. If not provided, will use "Copy of [original name]".'), parentFolderId: z.string().optional().describe('ID of folder where copy should be placed. If not provided, places in same location as original.'), }), execute: async (args, { log }) => { const drive = createDriveClient(args.accessToken); log.info(`Copying file ${args.fileId} ${args.newName ? `as "${args.newName}"` : ''}`); try { // Get original file info const originalFile = await drive.files.get({ fileId: args.fileId, fields: 'name,parents', }); const copyMetadata: drive_v3.Schema$File = { name: args.newName || `Copy of ${originalFile.data.name}`, }; if (args.parentFolderId) { copyMetadata.parents = [args.parentFolderId]; } else if (originalFile.data.parents) { copyMetadata.parents = originalFile.data.parents; } const response = await drive.files.copy({ fileId: args.fileId, requestBody: copyMetadata, fields: 'id,name,webViewLink', }); const copiedFile = response.data; return `Successfully created copy "${copiedFile.name}" (ID: ${copiedFile.id})\nLink: ${copiedFile.webViewLink}`; } catch (error: any) { log.error(`Error copying file: ${error.message || error}`); if (error.code === 404) throw new UserError("Original file or destination folder not found. Check the IDs."); 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."); throw new UserError(`Failed to copy file: ${error.message || 'Unknown error'}`); } } }); server.addTool({ name: 'renameFile', description: 'Renames a file or folder in Google Drive.', parameters: z.object({ accessToken: z.string().describe('OAuth access token from platform'), fileId: z.string().describe('ID of the file or folder to rename.'), newName: z.string().min(1).describe('New name for the file or folder.'), }), execute: async (args, { log }) => { const drive = createDriveClient(args.accessToken); log.info(`Renaming file ${args.fileId} to "${args.newName}"`); try { const response = await drive.files.update({ fileId: args.fileId, requestBody: { name: args.newName, }, fields: 'id,name,webViewLink', }); const file = response.data; return `Successfully renamed to "${file.name}" (ID: ${file.id})\nLink: ${file.webViewLink}`; } catch (error: any) { log.error(`Error renaming file: ${error.message || error}`); if (error.code === 404) throw new UserError("File not found. Check the file ID."); if (error.code === 403) throw new UserError("Permission denied. Make sure you have write access to this file."); throw new UserError(`Failed to rename file: ${error.message || 'Unknown error'}`); } } }); server.addTool({ name: 'deleteFile', description: 'Permanently deletes a file or folder from Google Drive.', parameters: z.object({ accessToken: z.string().describe('OAuth access token from platform'), fileId: z.string().describe('ID of the file or folder to delete.'), skipTrash: z.boolean().optional().default(false).describe('If true, permanently deletes the file. If false, moves to trash (can be restored).'), }), execute: async (args, { log }) => { const drive = createDriveClient(args.accessToken); log.info(`Deleting file ${args.fileId} ${args.skipTrash ? '(permanent)' : '(to trash)'}`); try { // Get file info before deletion const fileInfo = await drive.files.get({ fileId: args.fileId, fields: 'name,mimeType', }); const fileName = fileInfo.data.name; const isFolder = fileInfo.data.mimeType === 'application/vnd.google-apps.folder'; if (args.skipTrash) { await drive.files.delete({ fileId: args.fileId, }); return `Permanently deleted ${isFolder ? 'folder' : 'file'} "${fileName}".`; } else { await drive.files.update({ fileId: args.fileId, requestBody: { trashed: true, }, }); return `Moved ${isFolder ? 'folder' : 'file'} "${fileName}" to trash. It can be restored from the trash.`; } } catch (error: any) { log.error(`Error deleting file: ${error.message || error}`); if (error.code === 404) throw new UserError("File not found. Check the file ID."); if (error.code === 403) throw new UserError("Permission denied. Make sure you have delete access to this file."); throw new UserError(`Failed to delete file: ${error.message || 'Unknown error'}`); } } }); // --- Document Creation Tools --- server.addTool({ name: 'createDocument', description: 'Creates a new Google Document.', parameters: z.object({ accessToken: z.string().describe('OAuth access token from platform'), title: z.string().min(1).describe('Title for the new document.'), parentFolderId: z.string().optional().describe('ID of folder where document should be created. If not provided, creates in Drive root.'), initialContent: z.string().optional().describe('Initial text content to add to the document.'), }), execute: async (args, { log }) => { const drive = createDriveClient(args.accessToken); log.info(`Creating new document "${args.title}"`); try { const documentMetadata: drive_v3.Schema$File = { name: args.title, mimeType: 'application/vnd.google-apps.document', }; if (args.parentFolderId) { documentMetadata.parents = [args.parentFolderId]; } const response = await drive.files.create({ requestBody: documentMetadata, fields: 'id,name,webViewLink', }); const document = response.data; let result = `Successfully created document "${document.name}" (ID: ${document.id})\nView Link: ${document.webViewLink}`; // Add initial content if provided if (args.initialContent) { try { const docs = createDocsClient(args.accessToken); await docs.documents.batchUpdate({ documentId: document.id!, requestBody: { requests: [{ insertText: { location: { index: 1 }, text: args.initialContent, }, }], }, }); result += `\n\nInitial content added to document.`; } catch (contentError: any) { log.warn(`Document created but failed to add initial content: ${contentError.message}`); result += `\n\nDocument created but failed to add initial content. You can add content manually.`; } } return result; } catch (error: any) { log.error(`Error creating document: ${error.message || error}`); if (error.code === 404) throw new UserError("Parent folder not found. Check the folder ID."); if (error.code === 403) throw new UserError("Permission denied. Make sure you have write access to the destination folder."); throw new UserError(`Failed to create document: ${error.message || 'Unknown error'}`); } } }); server.addTool({ name: 'createFromTemplate', description: 'Creates a new Google Document from an existing document template.', parameters: z.object({ accessToken: z.string().describe('OAuth access token from platform'), templateId: z.string().describe('ID of the template document to copy from.'), newTitle: z.string().min(1).describe('Title for the new document.'), parentFolderId: z.string().optional().describe('ID of folder where document should be created. If not provided, creates in Drive root.'), 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"}).'), }), execute: async (args, { log }) => { const drive = createDriveClient(args.accessToken); log.info(`Creating document from template ${args.templateId} with title "${args.newTitle}"`); try { // First copy the template const copyMetadata: drive_v3.Schema$File = { name: args.newTitle, }; if (args.parentFolderId) { copyMetadata.parents = [args.parentFolderId]; } const response = await drive.files.copy({ fileId: args.templateId, requestBody: copyMetadata, fields: 'id,name,webViewLink', }); const document = response.data; let result = `Successfully created document "${document.name}" from template (ID: ${document.id})\nView Link: ${document.webViewLink}`; // Apply text replacements if provided if (args.replacements && Object.keys(args.replacements).length > 0) { try { const docs = createDocsClient(args.accessToken); const requests: docs_v1.Schema$Request[] = []; // Create replace requests for each replacement for (const [searchText, replaceText] of Object.entries(args.replacements)) { requests.push({ replaceAllText: { containsText: { text: searchText, matchCase: false, }, replaceText: replaceText, }, }); } if (requests.length > 0) { await docs.documents.batchUpdate({ documentId: document.id!, requestBody: { requests }, }); const replacementCount = Object.keys(args.replacements).length; result += `\n\nApplied ${replacementCount} text replacement${replacementCount !== 1 ? 's' : ''} to the document.`; } } catch (replacementError: any) { log.warn(`Document created but failed to apply replacements: ${replacementError.message}`); result += `\n\nDocument created but failed to apply text replacements. You can make changes manually.`; } } return result; } catch (error: any) { log.error(`Error creating document from template: ${error.message || error}`); if (error.code === 404) throw new UserError("Template document or parent folder not found. Check the IDs."); 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."); throw new UserError(`Failed to create document from template: ${error.message || 'Unknown error'}`); } } }); // --- Server Startup --- async function startServer() { try { // Platform OAuth: No initialization needed - tokens provided per request console.error("Starting Ultimate Google Docs MCP server..."); // Using stdio as before const configToUse = { transportType: "stdio" as const, }; // Start the server with proper error handling server.start(configToUse); console.error(`MCP Server running using ${configToUse.transportType}. Awaiting client connection...`); // Log that error handling has been enabled console.error('Process-level error handling configured to prevent crashes from timeout errors.'); } catch(startError: any) { console.error("FATAL: Server failed to start:", startError.message || startError); process.exit(1); } } startServer(); // Removed .catch here, let errors propagate if startup fails critically

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/oregpt/Agenticledger_MCP_DocsOnly'

If you have feedback or need assistance with the MCP directory API, please join our Discord server