Skip to main content
Glama

Claude MCP x Google Docs

by a-bonus
claude.md51.1 kB
Okay, this is a significant undertaking. Building the "absolute best" server involves adding many complex features. I will provide: The full code for a substantially enhanced server.ts and the necessary new helper files (googleDocsApiHelpers.ts, types.ts). This code will include implementations for several key advanced features discussed and well-defined stubs for others, indicating where further development would go. Step-by-step instructions on how to integrate this new code into your existing project structure. Disclaimer: This code is extensive and demonstrates the structure and approach for advanced features. It includes implementations for key examples but leaves many others as stubs (// TODO: Implement... or throwing NotImplementedError). Thorough testing and potentially significant refinement would be needed for production use, especially for complex logic like fixListFormatting or robust index handling across multiple edits. Error handling is included but could be further enhanced. Phase 1: The Code 1. New File: src/types.ts // src/types.ts import { z } from 'zod'; import { docs_v1 } from 'googleapis'; // --- Helper function for hex color validation --- export const hexColorRegex = /^#?([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/; export const validateHexColor = (color: string) => hexColorRegex.test(color); // --- Helper function for Hex to RGB conversion --- export function hexToRgbColor(hex: string): docs_v1.Schema$RgbColor | null { if (!hex) return null; let hexClean = hex.startsWith('#') ? hex.slice(1) : hex; if (hexClean.length === 3) { hexClean = hexClean[0] + hexClean[0] + hexClean[1] + hexClean[1] + hexClean[2] + hexClean[2]; } if (hexClean.length !== 6) return null; const bigint = parseInt(hexClean, 16); if (isNaN(bigint)) return null; const r = ((bigint >> 16) & 255) / 255; const g = ((bigint >> 8) & 255) / 255; const b = (bigint & 255) / 255; return { red: r, green: g, blue: b }; } // --- Zod Schema Fragments for Reusability --- export const DocumentIdParameter = z.object({ documentId: z.string().describe('The ID of the Google Document (from the URL).'), }); export const RangeParameters = z.object({ 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).'), }).refine(data => data.endIndex > data.startIndex, { message: "endIndex must be greater than startIndex", path: ["endIndex"], }); export const OptionalRangeParameters = z.object({ startIndex: z.number().int().min(1).optional().describe('Optional: The starting index of the text range (inclusive, starts from 1). If omitted, might apply to a found element or whole paragraph.'), endIndex: z.number().int().min(1).optional().describe('Optional: The ending index of the text range (exclusive). If omitted, might apply to a found element or whole paragraph.'), }).refine(data => !data.startIndex || !data.endIndex || data.endIndex > data.startIndex, { message: "If both startIndex and endIndex are provided, endIndex must be greater than startIndex", path: ["endIndex"], }); export const TextFindParameter = z.object({ textToFind: z.string().min(1).describe('The exact text string to locate.'), matchInstance: z.number().int().min(1).optional().default(1).describe('Which instance of the text to target (1st, 2nd, etc.). Defaults to 1.'), }); // --- Style Parameter Schemas --- export const TextStyleParameters = z.object({ 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(validateHexColor, { 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(validateHexColor, { 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.'), // clearDirectFormatting: z.boolean().optional().describe('If true, attempts to clear all direct text formatting within the range before applying new styles.') // Harder to implement perfectly }).describe("Parameters for character-level text formatting."); // Subset of TextStyle used for passing to helpers export type TextStyleArgs = z.infer<typeof TextStyleParameters>; export const ParagraphStyleParameters = z.object({ alignment: z.enum(['LEFT', 'CENTER', 'RIGHT', 'JUSTIFIED']).optional().describe('Paragraph alignment.'), indentStart: z.number().min(0).optional().describe('Left indentation in points.'), indentEnd: z.number().min(0).optional().describe('Right indentation in points.'), spaceAbove: z.number().min(0).optional().describe('Space before the paragraph in points.'), spaceBelow: z.number().min(0).optional().describe('Space after the paragraph in points.'), namedStyleType: z.enum([ 'NORMAL_TEXT', 'TITLE', 'SUBTITLE', 'HEADING_1', 'HEADING_2', 'HEADING_3', 'HEADING_4', 'HEADING_5', 'HEADING_6' ]).optional().describe('Apply a built-in named paragraph style (e.g., HEADING_1).'), keepWithNext: z.boolean().optional().describe('Keep this paragraph together with the next one on the same page.'), // Borders are more complex, might need separate objects/tools // clearDirectFormatting: z.boolean().optional().describe('If true, attempts to clear all direct paragraph formatting within the range before applying new styles.') // Harder to implement perfectly }).describe("Parameters for paragraph-level formatting."); // Subset of ParagraphStyle used for passing to helpers export type ParagraphStyleArgs = z.infer<typeof ParagraphStyleParameters>; // --- Combination Schemas for Tools --- export const ApplyTextStyleToolParameters = DocumentIdParameter.extend({ // Target EITHER by range OR by finding text target: z.union([ RangeParameters, TextFindParameter ]).describe("Specify the target range either by start/end indices or by finding specific text."), style: TextStyleParameters.refine( styleArgs => Object.values(styleArgs).some(v => v !== undefined), { message: "At least one text style option must be provided." } ).describe("The text styling to apply.") }); export type ApplyTextStyleToolArgs = z.infer<typeof ApplyTextStyleToolParameters>; export const ApplyParagraphStyleToolParameters = DocumentIdParameter.extend({ // Target EITHER by range OR by finding text (tool logic needs to find paragraph boundaries) target: z.union([ RangeParameters, // User provides paragraph start/end (less likely) TextFindParameter.extend({ applyToContainingParagraph: z.literal(true).default(true).describe("Must be true. Indicates the style applies to the whole paragraph containing the found text.") }), z.object({ // Target by specific index within the paragraph indexWithinParagraph: z.number().int().min(1).describe("An index located anywhere within the target paragraph.") }) ]).describe("Specify the target paragraph either by start/end indices, by finding text within it, or by providing an index within it."), style: ParagraphStyleParameters.refine( styleArgs => Object.values(styleArgs).some(v => v !== undefined), { message: "At least one paragraph style option must be provided." } ).describe("The paragraph styling to apply.") }); export type ApplyParagraphStyleToolArgs = z.infer<typeof ApplyParagraphStyleToolParameters>; // --- Error Class --- // Use FastMCP's UserError for client-facing issues // Define a custom error for internal issues if needed export class NotImplementedError extends Error { constructor(message = "This feature is not yet implemented.") { super(message); this.name = "NotImplementedError"; } } Use code with caution. TypeScript 2. New File: src/googleDocsApiHelpers.ts // src/googleDocsApiHelpers.ts import { google, docs_v1 } from 'googleapis'; import { OAuth2Client } from 'google-auth-library'; import { UserError } from 'fastmcp'; import { TextStyleArgs, ParagraphStyleArgs, hexToRgbColor, NotImplementedError } from './types.js'; type Docs = docs_v1.Docs; // Alias for convenience // --- Constants --- const MAX_BATCH_UPDATE_REQUESTS = 50; // Google API limits batch size // --- Core Helper to Execute Batch Updates --- export async function executeBatchUpdate(docs: Docs, documentId: string, requests: docs_v1.Schema$Request[]): Promise<docs_v1.Schema$BatchUpdateDocumentResponse> { if (!requests || requests.length === 0) { // console.warn("executeBatchUpdate called with no requests."); return {}; // Nothing to do } // TODO: Consider splitting large request arrays into multiple batches if needed if (requests.length > MAX_BATCH_UPDATE_REQUESTS) { console.warn(`Attempting batch update with ${requests.length} requests, exceeding typical limits. May fail.`); } try { const response = await docs.documents.batchUpdate({ documentId: documentId, requestBody: { requests }, }); return response.data; } catch (error: any) { console.error(`Google API batchUpdate Error for doc ${documentId}:`, error.response?.data || error.message); // Translate common API errors to UserErrors if (error.code === 400 && error.message.includes('Invalid requests')) { // Try to extract more specific info if available const details = error.response?.data?.error?.details; let detailMsg = ''; if (details && Array.isArray(details)) { detailMsg = details.map(d => d.description || JSON.stringify(d)).join('; '); } throw new UserError(`Invalid request sent to Google Docs API. Details: ${detailMsg || error.message}`); } if (error.code === 404) throw new UserError(`Document not found (ID: ${documentId}). Check the ID.`); if (error.code === 403) throw new UserError(`Permission denied for document (ID: ${documentId}). Ensure the authenticated user has edit access.`); // Generic internal error for others throw new Error(`Google API Error (${error.code}): ${error.message}`); } } // --- Text Finding Helper --- // NOTE: This is a simplified version. A robust version needs to handle // text spanning multiple TextRuns, pagination, tables etc. export async function findTextRange(docs: Docs, documentId: string, textToFind: string, instance: number = 1): Promise<{ startIndex: number; endIndex: number } | null> { try { const res = await docs.documents.get({ documentId, fields: 'body(content(paragraph(elements(startIndex,endIndex,textRun(content)))))', }); if (!res.data.body?.content) return null; let fullText = ''; const segments: { text: string, start: number, end: number }[] = []; res.data.body.content.forEach(element => { element.paragraph?.elements?.forEach(pe => { if (pe.textRun?.content && pe.startIndex && pe.endIndex) { const content = pe.textRun.content; fullText += content; segments.push({ text: content, start: pe.startIndex, end: pe.endIndex }); } }); }); let startIndex = -1; let endIndex = -1; let foundCount = 0; let searchStartIndex = 0; while (foundCount < instance) { const currentIndex = fullText.indexOf(textToFind, searchStartIndex); if (currentIndex === -1) break; foundCount++; if (foundCount === instance) { const targetStartInFullText = currentIndex; const targetEndInFullText = currentIndex + textToFind.length; let currentPosInFullText = 0; for (const seg of segments) { const segStartInFullText = currentPosInFullText; const segTextLength = seg.text.length; const segEndInFullText = segStartInFullText + segTextLength; if (startIndex === -1 && targetStartInFullText >= segStartInFullText && targetStartInFullText < segEndInFullText) { startIndex = seg.start + (targetStartInFullText - segStartInFullText); } if (targetEndInFullText > segStartInFullText && targetEndInFullText <= segEndInFullText) { endIndex = seg.start + (targetEndInFullText - segStartInFullText); break; } currentPosInFullText = segEndInFullText; } if (startIndex === -1 || endIndex === -1) { // Mapping failed for this instance startIndex = -1; endIndex = -1; // Continue searching from *after* this failed mapping attempt searchStartIndex = currentIndex + 1; foundCount--; // Decrement count as this instance wasn't successfully mapped continue; } // Successfully mapped return { startIndex, endIndex }; } // Prepare for next search iteration searchStartIndex = currentIndex + 1; } return null; // Instance not found or mapping failed for all attempts } catch (error: any) { console.error(`Error finding text "${textToFind}" in doc ${documentId}: ${error.message}`); if (error.code === 404) throw new UserError(`Document not found while searching text (ID: ${documentId}).`); if (error.code === 403) throw new UserError(`Permission denied while searching text in doc (ID: ${documentId}).`); throw new Error(`Failed to retrieve doc for text searching: ${error.message}`); } } // --- Paragraph Boundary Helper --- // Finds the paragraph containing a given index. Very simplified. // A robust version needs to understand structural elements better. export async function getParagraphRange(docs: Docs, documentId: string, indexWithin: number): Promise<{ startIndex: number; endIndex: number } | null> { try { const res = await docs.documents.get({ documentId, // Request paragraph elements and their ranges fields: 'body(content(startIndex,endIndex,paragraph))', }); if (!res.data.body?.content) return null; for (const element of res.data.body.content) { if (element.paragraph && element.startIndex && element.endIndex) { // Check if the provided index falls within this paragraph element's range // API ranges are typically [startIndex, endIndex) if (indexWithin >= element.startIndex && indexWithin < element.endIndex) { return { startIndex: element.startIndex, endIndex: element.endIndex }; } } } return null; // Index not found within any paragraph element } catch (error: any) { console.error(`Error getting paragraph range for index ${indexWithin} in doc ${documentId}: ${error.message}`); if (error.code === 404) throw new UserError(`Document not found while finding paragraph range (ID: ${documentId}).`); if (error.code === 403) throw new UserError(`Permission denied while finding paragraph range in doc (ID: ${documentId}).`); throw new Error(`Failed to retrieve doc for paragraph range finding: ${error.message}`); } } // --- Style Request Builders --- export function buildUpdateTextStyleRequest( startIndex: number, endIndex: number, style: TextStyleArgs ): { request: docs_v1.Schema$Request, fields: string[] } | null { const textStyle: docs_v1.Schema$TextStyle = {}; const fieldsToUpdate: string[] = []; if (style.bold !== undefined) { textStyle.bold = style.bold; fieldsToUpdate.push('bold'); } if (style.italic !== undefined) { textStyle.italic = style.italic; fieldsToUpdate.push('italic'); } if (style.underline !== undefined) { textStyle.underline = style.underline; fieldsToUpdate.push('underline'); } if (style.strikethrough !== undefined) { textStyle.strikethrough = style.strikethrough; fieldsToUpdate.push('strikethrough'); } if (style.fontSize !== undefined) { textStyle.fontSize = { magnitude: style.fontSize, unit: 'PT' }; fieldsToUpdate.push('fontSize'); } if (style.fontFamily !== undefined) { textStyle.weightedFontFamily = { fontFamily: style.fontFamily }; fieldsToUpdate.push('weightedFontFamily'); } if (style.foregroundColor !== undefined) { const rgbColor = hexToRgbColor(style.foregroundColor); if (!rgbColor) throw new UserError(`Invalid foreground hex color format: ${style.foregroundColor}`); textStyle.foregroundColor = { color: { rgbColor: rgbColor } }; fieldsToUpdate.push('foregroundColor'); } if (style.backgroundColor !== undefined) { const rgbColor = hexToRgbColor(style.backgroundColor); if (!rgbColor) throw new UserError(`Invalid background hex color format: ${style.backgroundColor}`); textStyle.backgroundColor = { color: { rgbColor: rgbColor } }; fieldsToUpdate.push('backgroundColor'); } if (style.linkUrl !== undefined) { textStyle.link = { url: style.linkUrl }; fieldsToUpdate.push('link'); } // TODO: Handle clearing formatting if (fieldsToUpdate.length === 0) return null; // No styles to apply const request: docs_v1.Schema$Request = { updateTextStyle: { range: { startIndex, endIndex }, textStyle: textStyle, fields: fieldsToUpdate.join(','), } }; return { request, fields: fieldsToUpdate }; } export function buildUpdateParagraphStyleRequest( startIndex: number, endIndex: number, style: ParagraphStyleArgs ): { request: docs_v1.Schema$Request, fields: string[] } | null { const paragraphStyle: docs_v1.Schema$ParagraphStyle = {}; const fieldsToUpdate: string[] = []; if (style.alignment !== undefined) { paragraphStyle.alignment = style.alignment; fieldsToUpdate.push('alignment'); } if (style.indentStart !== undefined) { paragraphStyle.indentStart = { magnitude: style.indentStart, unit: 'PT' }; fieldsToUpdate.push('indentStart'); } if (style.indentEnd !== undefined) { paragraphStyle.indentEnd = { magnitude: style.indentEnd, unit: 'PT' }; fieldsToUpdate.push('indentEnd'); } if (style.spaceAbove !== undefined) { paragraphStyle.spaceAbove = { magnitude: style.spaceAbove, unit: 'PT' }; fieldsToUpdate.push('spaceAbove'); } if (style.spaceBelow !== undefined) { paragraphStyle.spaceBelow = { magnitude: style.spaceBelow, unit: 'PT' }; fieldsToUpdate.push('spaceBelow'); } if (style.namedStyleType !== undefined) { paragraphStyle.namedStyleType = style.namedStyleType; fieldsToUpdate.push('namedStyleType'); } if (style.keepWithNext !== undefined) { paragraphStyle.keepWithNext = style.keepWithNext; fieldsToUpdate.push('keepWithNext'); } // TODO: Handle borders, clearing formatting if (fieldsToUpdate.length === 0) return null; // No styles to apply const request: docs_v1.Schema$Request = { updateParagraphStyle: { range: { startIndex, endIndex }, paragraphStyle: paragraphStyle, fields: fieldsToUpdate.join(','), } }; return { request, fields: fieldsToUpdate }; } // --- Specific Feature Helpers --- export async function createTable(docs: Docs, documentId: string, rows: number, columns: number, index: number): Promise<docs_v1.Schema$BatchUpdateDocumentResponse> { if (rows < 1 || columns < 1) { throw new UserError("Table must have at least 1 row and 1 column."); } const request: docs_v1.Schema$Request = { insertTable: { location: { index }, rows: rows, columns: columns, } }; return executeBatchUpdate(docs, documentId, [request]); } export async function insertText(docs: Docs, documentId: string, text: string, index: number): Promise<docs_v1.Schema$BatchUpdateDocumentResponse> { if (!text) return {}; // Nothing to insert const request: docs_v1.Schema$Request = { insertText: { location: { index }, text: text, } }; return executeBatchUpdate(docs, documentId, [request]); } // --- Complex / Stubbed Helpers --- export async function findParagraphsMatchingStyle( docs: Docs, documentId: string, styleCriteria: any // Define a proper type for criteria (e.g., { fontFamily: 'Arial', bold: true }) ): Promise<{ startIndex: number; endIndex: number }[]> { // TODO: Implement logic // 1. Get document content with paragraph elements and their styles. // 2. Iterate through paragraphs. // 3. For each paragraph, check if its computed style matches the criteria. // 4. Return ranges of matching paragraphs. console.warn("findParagraphsMatchingStyle is not implemented."); throw new NotImplementedError("Finding paragraphs by style criteria is not yet implemented."); // return []; } export async function detectAndFormatLists( docs: Docs, documentId: string, startIndex?: number, endIndex?: number ): Promise<docs_v1.Schema$BatchUpdateDocumentResponse> { // TODO: Implement complex logic // 1. Get document content (paragraphs, text runs) in the specified range (or whole doc). // 2. Iterate through paragraphs. // 3. Identify sequences of paragraphs starting with list-like markers (e.g., "-", "\*", "1.", "a)"). // 4. Determine nesting levels based on indentation or marker patterns. // 5. Generate CreateParagraphBulletsRequests for the identified sequences. // 6. Potentially delete the original marker text. // 7. Execute the batch update. console.warn("detectAndFormatLists is not implemented."); throw new NotImplementedError("Automatic list detection and formatting is not yet implemented."); // return {}; } export async function addCommentHelper(docs: Docs, documentId: string, text: string, startIndex: number, endIndex: number): Promise<void> { // NOTE: Adding comments typically requires the Google Drive API v3 and different scopes! // 'https://www.googleapis.com/auth/drive' or more specific comment scopes. // This helper is a placeholder assuming Drive API client (`drive`) is available and authorized. /_ const drive = google.drive({version: 'v3', auth: authClient}); // Assuming authClient is available await drive.comments.create({ fileId: documentId, requestBody: { content: text, anchor: JSON.stringify({ // Anchor format might need verification 'type': 'workbook#textAnchor', // Or appropriate type for Docs 'refs': [{ 'docRevisionId': 'head', // Or specific revision 'range': { 'start': startIndex, 'end': endIndex, } }] }) }, fields: 'id' }); _/ console.warn("addCommentHelper requires Google Drive API and is not implemented."); throw new NotImplementedError("Adding comments requires Drive API setup and is not yet implemented."); } // Add more helpers as needed... Use code with caution. TypeScript 3. Updated File: src/server.ts (Replace the entire content with this) // src/server.ts import { FastMCP, UserError } from 'fastmcp'; import { z } from 'zod'; import { google, docs_v1 } from 'googleapis'; import { authorize } from './auth.js'; 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'; let authClient: OAuth2Client | null = null; let googleDocs: docs_v1.Docs | null = null; // --- Initialization --- async function initializeGoogleClient() { if (googleDocs) return { authClient, googleDocs }; if (!authClient) { // Check authClient instead of googleDocs to allow re-attempt try { console.error("Attempting to authorize Google API client..."); const client = await authorize(); authClient = client; // Assign client here googleDocs = google.docs({ version: 'v1', auth: authClient }); console.error("Google API client authorized successfully."); } catch (error) { console.error("FATAL: Failed to initialize Google API client:", error); authClient = null; // Reset on failure googleDocs = null; // Decide if server should exit or just fail tools throw new Error("Google client initialization failed. Cannot start server tools."); } } // Ensure googleDocs is set if authClient is valid if (authClient && !googleDocs) { googleDocs = google.docs({ version: 'v1', auth: authClient }); } if (!googleDocs) { throw new Error("Google Docs client could not be initialized."); } return { authClient, googleDocs }; } const server = new FastMCP({ name: 'Ultimate Google Docs MCP Server', version: '2.0.0', // Version bump! description: 'Provides advanced tools for reading, editing, formatting, and managing Google Documents.' }); // --- Helper to get Docs client within tools --- async function getDocsClient() { const { googleDocs: docs } = await initializeGoogleClient(); if (!docs) { throw new UserError("Google Docs client is not initialized. Authentication might have failed during startup or lost connection."); } return docs; } // === 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, possibly truncated), 'json' (raw API structure, complex), 'markdown' (experimental conversion).") }), execute: async (args, { log }) => { const docs = await getDocsClient(); log.info(`Reading Google Doc: ${args.documentId}, Format: ${args.format}`); try { 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, fields: fields, }); log.info(`Fetched doc: ${args.documentId}`); if (args.format === 'json') { return JSON.stringify(res.data, null, 2); // Return raw structure } if (args.format === 'markdown') { // TODO: Implement Markdown conversion logic (complex) log.warn("Markdown conversion is not implemented yet."); throw new NotImplementedError("Markdown output format is not yet implemented."); // return convertDocsJsonToMarkdown(res.data); } // Default: Text format let textContent = ''; res.data.body?.content?.forEach(element => { element.paragraph?.elements?.forEach(pe => { textContent += pe.textRun?.content || ''; }); }); if (!textContent.trim()) return "Document found, but appears empty."; // Basic truncation for text mode const maxLength = 4000; // Increased limit const truncatedContent = textContent.length > maxLength ? textContent.substring(0, maxLength) + `... [truncated ${textContent.length} chars]` : textContent; return `Content:\n---\n${truncatedContent}`; } 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: 'appendToGoogleDoc', description: 'Appends text to the very end of a specific Google Document.', 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."), }), execute: async (args, { log }) => { const docs = await getDocsClient(); log.info(`Appending to Google Doc: ${args.documentId}`); try { // Get the current end index const docInfo = await docs.documents.get({ documentId: args.documentId, fields: 'body(content(endIndex)),documentStyle(pageSize)' }); // Need content for endIndex let endIndex = 1; let lastCharIsNewline = false; if (docInfo.data.body?.content) { const lastElement = docInfo.data.body.content[docInfo.data.body.content.length - 1]; if (lastElement?.endIndex) { endIndex = lastElement.endIndex -1; // Insert *before* the final newline of the doc typically // Crude check for last character (better check would involve reading last text run) // const lastTextRun = ... find last text run ... // if (lastTextRun?.content?.endsWith('\n')) lastCharIsNewline = true; } } // 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 request: docs_v1.Schema$Request = { insertText: { location: { index: endIndex }, text: textToInsert } }; await GDocsHelpers.executeBatchUpdate(docs, args.documentId, [request]); log.info(`Successfully appended to doc: ${args.documentId}`); return `Successfully appended text to 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.', 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.'), }), execute: async (args, { log }) => { const docs = await getDocsClient(); log.info(`Inserting text in doc ${args.documentId} at index ${args.index}`); try { await GDocsHelpers.insertText(docs, args.documentId, args.textToInsert, args.index); return `Successfully inserted text at index ${args.index}.`; } 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).', parameters: DocumentIdParameter.extend(RangeParameters.shape), // Use shape to avoid refine conflict if needed execute: async (args, { log }) => { const docs = await getDocsClient(); log.info(`Deleting range ${args.startIndex}-${args.endIndex} in doc ${args.documentId}`); if (args.endIndex <= args.startIndex) { throw new UserError("End index must be greater than start index for deletion."); } try { const request: docs_v1.Schema$Request = { deleteContentRange: { range: { startIndex: args.startIndex, endIndex: args.endIndex } } }; await GDocsHelpers.executeBatchUpdate(docs, args.documentId, [request]); return `Successfully deleted content in range ${args.startIndex}-${args.endIndex}.`; } 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 = await getDocsClient(); 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 = await getDocsClient(); let { startIndex, endIndex } = args.target as any; // Will be updated log.info(`Applying paragraph style in doc ${args.documentId}. Target: ${JSON.stringify(args.target)}, Style: ${JSON.stringify(args.style)}`); try { // Determine target paragraph range let targetIndexForLookup: number | undefined; 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}" to locate paragraph.`); } targetIndexForLookup = range.startIndex; // Use the start index of found text log.info(`Found text "${args.target.textToFind}" at index ${targetIndexForLookup} to locate paragraph.`); } else if ('indexWithinParagraph' in args.target) { targetIndexForLookup = args.target.indexWithinParagraph; } else if ('startIndex' in args.target && 'endIndex' in args.target) { // User provided a range, assume it's the paragraph range startIndex = args.target.startIndex; endIndex = args.target.endIndex; log.info(`Using provided range ${startIndex}-${endIndex} for paragraph style.`); } // If we need to find the paragraph boundaries based on an index within it if (targetIndexForLookup !== undefined && (startIndex === undefined || endIndex === undefined)) { const paragraphRange = await GDocsHelpers.getParagraphRange(docs, args.documentId, targetIndexForLookup); if (!paragraphRange) { throw new UserError(`Could not determine paragraph boundaries containing index ${targetIndexForLookup}.`); } startIndex = paragraphRange.startIndex; endIndex = paragraphRange.endIndex; log.info(`Determined paragraph range as ${startIndex}-${endIndex} based on index ${targetIndexForLookup}.`); } if (startIndex === undefined || endIndex === undefined) { throw new UserError("Target paragraph range could not be determined."); } if (endIndex <= startIndex) { throw new UserError("Paragraph end index must be greater than start index for styling."); } // Build the request const requestInfo = GDocsHelpers.buildUpdateParagraphStyleRequest(startIndex, endIndex, args.style); if (!requestInfo) { return "No valid paragraph styling options were provided."; } await GDocsHelpers.executeBatchUpdate(docs, args.documentId, [requestInfo.request]); return `Successfully applied paragraph style (${requestInfo.fields.join(', ')}) to range ${startIndex}-${endIndex}.`; } catch (error: any) { log.error(`Error applying paragraph style in doc ${args.documentId}: ${error.message || error}`); if (error instanceof UserError) throw error; if (error instanceof NotImplementedError) throw error; 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 = await getDocsClient(); 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 = await getDocsClient(); 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 = await getDocsClient(); 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'}`); } } }); // --- 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 = await getDocsClient(); 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'}`); } } }); server.addTool({ name: 'addComment', description: 'Adds a comment anchored to a specific text range. REQUIRES DRIVE API SCOPES/SETUP.', parameters: DocumentIdParameter.extend(RangeParameters.shape).extend({ commentText: z.string().min(1).describe("The content of the comment."), }), execute: async (args, { log }) => { log.info(`Attempting to add comment "${args.commentText}" to range ${args.startIndex}-${args.endIndex} in doc ${args.documentId}`); // Requires Drive API client and appropriate scopes. // const { authClient } = await initializeGoogleClient(); // Get auth client if needed // if (!authClient) throw new UserError("Authentication client not available for Drive API."); try { // await GDocsHelpers.addCommentHelper(driveClient, args.documentId, args.commentText, args.startIndex, args.endIndex); log.error("addComment requires Drive API setup which is not implemented."); throw new NotImplementedError("Adding comments requires Drive API setup and is not yet implemented in this server."); // return `Comment added to range ${args.startIndex}-${args.endIndex}.`; } catch (error: any) { log.error(`Error adding comment in doc ${args.documentId}: ${error.message || error}`); if (error instanceof UserError) throw error; if (error instanceof NotImplementedError) throw error; throw new UserError(`Failed to add 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."); } }); // --- Server Startup --- async function startServer() { try { await initializeGoogleClient(); // Authorize BEFORE starting listeners console.error("Starting Ultimate Google Docs MCP server..."); // Using stdio as before const configToUse = { transportType: "stdio" as const, }; server.start(configToUse); console.error(`MCP Server running using ${configToUse.transportType}. Awaiting client connection...`); } 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 Use code with caution. TypeScript Phase 2: Step-by-Step Update Instructions Create src/types.ts: In your project's src directory, create a new file named types.ts. Copy the entire content from Phase 1, Section 1 (src/types.ts) into this new file. Save it. Create src/googleDocsApiHelpers.ts: In your project's src directory, create another new file named googleDocsApiHelpers.ts. Copy the entire content from Phase 1, Section 2 (src/googleDocsApiHelpers.ts) into this new file. Save it. Replace src/server.ts: Open your existing src/server.ts file. Delete all of its current content. Copy the entire content from Phase 1, Section 3 (Updated File: src/server.ts) into the now empty src/server.ts file. Save it. Check src/auth.ts: No changes are strictly required unless you plan to implement features needing the Drive API (like comments). For now, leave it as is. If you add Drive features later, you'll need to add Drive API scopes (like https://www.googleapis.com/auth/drive) to the SCOPES array in auth.ts and potentially re-authorize (delete token.json and run the server once). Install Dependencies (If any were added): Open your terminal in the project root directory. Run npm install. (In this case, no new dependencies were added, but it's good practice). Build the Code: In your terminal, run the build command: npm run build Use code with caution. Bash This should compile the new .ts files into JavaScript in the dist directory. Check for any compilation errors in the terminal output. Fix them if necessary (typos, import issues, etc.). Update mcp_config.json (Optional - Check Path): The command to run the server likely hasn't changed (node /path/to/your/project/dist/server.js). Double-check that the path in your Claude Desktop mcp_config.json still correctly points to the compiled dist/server.js file. Re-authorize (If Scopes Changed): If you did change scopes in auth.ts (not required by the code provided), you must delete the token.json file in your project root. Run the server manually once to go through the Google authorization flow again: node ./dist/server.js Use code with caution. Bash Follow the on-screen instructions to authorize in your browser and paste the code back into the terminal. Update Documentation (README.md / docs/index.html): This is crucial! Your documentation is now outdated. Edit your README.md and/or docs/index.html. Remove descriptions of old/removed tools (like the original formatText). Add detailed descriptions and usage examples for the new tools (applyTextStyle, applyParagraphStyle, insertTable, insertText, deleteRange, fixListFormatting, addComment, etc.). Explain their parameters clearly. Mention which tools are experimental or not fully implemented. Test Thoroughly: Restart Claude Desktop (if using it). Start testing the new tools one by one with specific prompts. Begin with simple cases (e.g., applying bold using applyTextStyle with text finding). Test edge cases (text not found, invalid indices, invalid hex colors). Test the tools that rely on helpers (e.g., applyParagraphStyle which uses getParagraphRange and findTextRange). Expect the unimplemented tools to return the "Not Implemented" error. Monitor the terminal where Claude Desktop runs the server (or run it manually) for error messages (console.error logs). You now have the code structure and implementation examples for a significantly more powerful Google Docs MCP server. Remember that the unimplemented features and complex helpers will require further development effort. Good luck!

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/a-bonus/google-docs-mcp'

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