Skip to main content
Glama
update-doc-content.ts22.3 kB
import type { OAuth2Client } from "google-auth-library"; import type { docs_v1 } from "googleapis"; import { google } from "googleapis"; import { JSONPath } from "jsonpath-plus"; import { z } from "zod"; /** * Example usage (JSONPath approach - RECOMMENDED): * * 1. Get document structure: * gdrive_get_doc_contents(doc_id, format="json") * * 2. Insert a heading with bullet list after the first paragraph: * gdrive_update_doc_content({ * doc_id_or_url: doc_id, * operation: "insertAfter", * target: "$.body.content[1]", * content: [ * { type: "heading", level: 2, text: "Key Features" }, * { type: "bulletList", items: ["Feature 1", "Feature 2", "Feature 3"] } * ] * }) * * 3. For the next operation, use the UPDATED structure returned (don't re-fetch): * The tool returns the new document structure with updated indices. * * Raw API approach (advanced users): * gdrive_update_doc_content(doc_id, requests=[...batchUpdate requests...]) */ // Schema for JSONPath-based content items const contentItemSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal("heading"), level: z.number().min(1).max(6), text: z.string(), }), z.object({ type: z.literal("paragraph"), text: z.string(), }), z.object({ type: z.literal("bulletList"), items: z.array(z.string()), }), ]); // Schema for text style properties const textStyleSchema = z .object({ bold: z.boolean().optional(), italic: z.boolean().optional(), underline: z.boolean().optional(), strikethrough: z.boolean().optional(), fontSize: z.number().optional(), foregroundColor: z .object({ red: z.number().min(0).max(1).optional(), green: z.number().min(0).max(1).optional(), blue: z.number().min(0).max(1).optional(), }) .optional(), backgroundColor: z .object({ red: z.number().min(0).max(1).optional(), green: z.number().min(0).max(1).optional(), blue: z.number().min(0).max(1).optional(), }) .optional(), link: z .object({ url: z.string(), }) .optional(), }) .optional(); // Schema for paragraph style properties const paragraphStyleSchema = z .object({ headingLevel: z .enum([ "NORMAL_TEXT", "HEADING_1", "HEADING_2", "HEADING_3", "HEADING_4", "HEADING_5", "HEADING_6", ]) .optional(), alignment: z.enum(["START", "CENTER", "END", "JUSTIFIED"]).optional(), lineSpacing: z.number().optional(), direction: z.enum(["LEFT_TO_RIGHT", "RIGHT_TO_LEFT"]).optional(), indentStart: z.number().optional(), indentEnd: z.number().optional(), indentFirstLine: z.number().optional(), }) .optional(); // Schema for JSONPath-based operations const jsonPathOperationSchema = z.object({ doc_id_or_url: z .string() .describe( "Google Docs URL (e.g., https://docs.google.com/document/d/...) or document ID", ), operation: z.enum([ "insertAfter", "insertBefore", "replace", "delete", "updateTextStyle", "updateParagraphStyle", ]), target: z .string() .describe( "JSONPath expression to target element (e.g., '$.body.content[1]' for paragraph, '$.body.content[1].paragraph.elements[0]' for text element)", ), content: z.array(contentItemSchema).optional(), textStyle: textStyleSchema, paragraphStyle: paragraphStyleSchema, }); export const tool = { name: "gdrive_update_doc_content", description: "Add, modify, or delete content in a Google Doc. Use this tool to insert headings, paragraphs, bullet lists, or update text/paragraph formatting. WORKFLOW: (1) First operation: call gdrive_get_doc_contents with format='json' to get document structure, (2) Make ONE operation per tool call, (3) For subsequent operations: use the UPDATED DOCUMENT STRUCTURE returned by this tool (don't re-fetch with gdrive_get_doc_contents). OPERATIONS: insertAfter (add content after an element), insertBefore (add content before an element), replace (replace element with new content), delete (remove element), updateTextStyle (bold/italic/color on specific text), updateParagraphStyle (change heading level/alignment). Target elements using JSONPath (e.g., '$.body.content[1]' for 2nd paragraph). Returns updated document structure for next operation. IMPORTANT: Only ONE operation per call since indices shift after modifications. You can insert multiple content items (headings, paragraphs, lists) in a single operation.", inputSchema: z.union([ jsonPathOperationSchema, z.object({ doc_id_or_url: z .string() .describe( "Google Docs URL (e.g., https://docs.google.com/document/d/...) or document ID", ), requests: z .array(z.any()) .describe( "Array of Google Docs API batchUpdate request objects. Each request must specify exact indices from the document JSON structure. Common operations: insertText (add text at a specific index), deleteContentRange (remove content between indices), updateTextStyle (change formatting), updateParagraphStyle (modify paragraph properties), createParagraphBullets (convert paragraphs to bullet list), insertTable, replaceAllText, etc. See https://developers.google.com/docs/api/reference/rest/v1/documents/request", ), }), ]), annotations: { readOnlyHint: false, idempotentHint: false, destructiveHint: true, }, } as const; export const handler = async ( args: z.infer<typeof tool.inputSchema>, auth: OAuth2Client, ) => { try { // Check if this is a JSONPath-based operation if ("operation" in args && "target" in args) { const result = await handleJsonPathOperation(auth, args); return { content: [ { type: "text" as const, text: result, }, ], }; } // Otherwise, it's a raw API request if ("requests" in args) { const { doc_id_or_url, requests } = args; const result = await updateDocContent(auth, doc_id_or_url, requests); return { content: [ { type: "text" as const, text: result, }, ], }; } throw new Error( "Invalid arguments: must provide either operation/target or requests", ); } catch (error: unknown) { if (error instanceof Error) { return { content: [ { type: "text" as const, text: `Error: ${error.message}`, }, ], isError: true, }; } throw error; } }; const handleJsonPathOperation = async ( auth: OAuth2Client, args: z.infer<typeof jsonPathOperationSchema>, ): Promise<string> => { const { doc_id_or_url, operation, target, content } = args; const docId = extractDocId(doc_id_or_url); if (!docId) { throw new Error("Invalid document ID or URL"); } const docs = google.docs({ version: "v1", auth }); // First, fetch the document to get its structure const doc = await docs.documents.get({ documentId: docId }); if (!doc.data) { throw new Error("Failed to fetch document"); } // Use JSONPath to find the target element const results = JSONPath({ path: target, json: doc.data }); if (results.length === 0) { throw new Error(`No element found at JSONPath: ${target}`); } if (results.length > 1) { throw new Error( `Multiple elements found at JSONPath: ${target}. Path must be unique.`, ); } const targetElement = results[0]; // Build requests based on operation type let requests: docs_v1.Schema$Request[]; let operationDescription: string; if (operation === "updateTextStyle") { // Update text style: target must be a text element with startIndex/endIndex if (!args.textStyle) { throw new Error( "updateTextStyle operation requires textStyle to be provided", ); } if (!targetElement.startIndex || !targetElement.endIndex) { throw new Error( "Target element must have startIndex and endIndex for updateTextStyle operation", ); } // Build the textStyle object for the API const textStyleUpdate: docs_v1.Schema$TextStyle = {}; const fields: string[] = []; if (args.textStyle.bold !== undefined) { textStyleUpdate.bold = args.textStyle.bold; fields.push("bold"); } if (args.textStyle.italic !== undefined) { textStyleUpdate.italic = args.textStyle.italic; fields.push("italic"); } if (args.textStyle.underline !== undefined) { textStyleUpdate.underline = args.textStyle.underline; fields.push("underline"); } if (args.textStyle.strikethrough !== undefined) { textStyleUpdate.strikethrough = args.textStyle.strikethrough; fields.push("strikethrough"); } if (args.textStyle.fontSize !== undefined) { textStyleUpdate.fontSize = { magnitude: args.textStyle.fontSize, unit: "PT", }; fields.push("fontSize"); } if (args.textStyle.foregroundColor) { textStyleUpdate.foregroundColor = { color: { rgbColor: args.textStyle.foregroundColor }, }; fields.push("foregroundColor"); } if (args.textStyle.backgroundColor) { textStyleUpdate.backgroundColor = { color: { rgbColor: args.textStyle.backgroundColor }, }; fields.push("backgroundColor"); } if (args.textStyle.link) { textStyleUpdate.link = { url: args.textStyle.link.url }; fields.push("link"); } requests = [ { updateTextStyle: { range: { startIndex: targetElement.startIndex, endIndex: targetElement.endIndex, }, textStyle: textStyleUpdate, fields: fields.join(","), }, }, ]; operationDescription = `Updated text style from index ${targetElement.startIndex} to ${targetElement.endIndex}`; } else if (operation === "updateParagraphStyle") { // Update paragraph style: target must be a paragraph element if (!args.paragraphStyle) { throw new Error( "updateParagraphStyle operation requires paragraphStyle to be provided", ); } if (!targetElement.startIndex || !targetElement.endIndex) { throw new Error( "Target element must have startIndex and endIndex for updateParagraphStyle operation", ); } // Build the paragraphStyle object for the API const paragraphStyleUpdate: docs_v1.Schema$ParagraphStyle = {}; const fields: string[] = []; if (args.paragraphStyle.headingLevel) { paragraphStyleUpdate.namedStyleType = args.paragraphStyle.headingLevel; fields.push("namedStyleType"); } if (args.paragraphStyle.alignment) { paragraphStyleUpdate.alignment = args.paragraphStyle.alignment; fields.push("alignment"); } if (args.paragraphStyle.lineSpacing !== undefined) { paragraphStyleUpdate.lineSpacing = args.paragraphStyle.lineSpacing; fields.push("lineSpacing"); } if (args.paragraphStyle.direction) { paragraphStyleUpdate.direction = args.paragraphStyle.direction; fields.push("direction"); } if (args.paragraphStyle.indentStart !== undefined) { paragraphStyleUpdate.indentStart = { magnitude: args.paragraphStyle.indentStart, unit: "PT", }; fields.push("indentStart"); } if (args.paragraphStyle.indentEnd !== undefined) { paragraphStyleUpdate.indentEnd = { magnitude: args.paragraphStyle.indentEnd, unit: "PT", }; fields.push("indentEnd"); } if (args.paragraphStyle.indentFirstLine !== undefined) { paragraphStyleUpdate.indentFirstLine = { magnitude: args.paragraphStyle.indentFirstLine, unit: "PT", }; fields.push("indentFirstLine"); } requests = [ { updateParagraphStyle: { range: { startIndex: targetElement.startIndex, endIndex: targetElement.endIndex, }, paragraphStyle: paragraphStyleUpdate, fields: fields.join(","), }, }, ]; operationDescription = `Updated paragraph style from index ${targetElement.startIndex} to ${targetElement.endIndex}`; } else if (operation === "delete") { // Delete: remove the entire element if (!targetElement.startIndex || !targetElement.endIndex) { throw new Error( "Target element must have startIndex and endIndex for delete operation", ); } requests = [ { deleteContentRange: { range: { startIndex: targetElement.startIndex, endIndex: targetElement.endIndex, }, }, }, ]; operationDescription = `Deleted element from index ${targetElement.startIndex} to ${targetElement.endIndex}`; } else if (operation === "replace") { // Replace: delete the element and insert new content at the same position if (!content || content.length === 0) { throw new Error("Replace operation requires content to be provided"); } if (!targetElement.startIndex || !targetElement.endIndex) { throw new Error( "Target element must have startIndex and endIndex for replace operation", ); } // First delete the old content, then insert new content at the same position requests = [ { deleteContentRange: { range: { startIndex: targetElement.startIndex, endIndex: targetElement.endIndex, }, }, }, ...generateRequestsFromContent(content, targetElement.startIndex), ]; operationDescription = `Replaced element at index ${targetElement.startIndex}-${targetElement.endIndex}`; } else { // insertAfter or insertBefore if (!content || content.length === 0) { throw new Error(`${operation} operation requires content to be provided`); } let insertIndex: number; if (operation === "insertAfter") { if (!targetElement.endIndex) { throw new Error("Target element does not have an endIndex"); } insertIndex = targetElement.endIndex; } else { // insertBefore if (!targetElement.startIndex) { throw new Error("Target element does not have a startIndex"); } insertIndex = targetElement.startIndex; } requests = generateRequestsFromContent(content, insertIndex); operationDescription = `Insert index: ${insertIndex}`; } // Execute the batch update const response = await docs.documents.batchUpdate({ documentId: docId, requestBody: { requests, }, }); const replies = response.data.replies || []; const summary = buildUpdateSummary(requests, replies); // Fetch the updated document structure to return fresh JSONPath references const updatedDoc = await docs.documents.get({ documentId: docId }); return `Document updated successfully using JSONPath targeting. Target: ${target} Operation: ${operation} ${operationDescription} ${summary} UPDATED DOCUMENT STRUCTURE (use this for next operations): ${JSON.stringify(updatedDoc.data, null, 2)}`; }; const generateRequestsFromContent = ( content: z.infer<typeof contentItemSchema>[], startIndex: number, ): docs_v1.Schema$Request[] => { const requests: docs_v1.Schema$Request[] = []; let currentIndex = startIndex; for (const item of content) { if (item.type === "heading") { const text = `${item.text}\n`; // Insert text requests.push({ insertText: { location: { index: currentIndex }, text, }, }); // Apply heading style requests.push({ updateParagraphStyle: { range: { startIndex: currentIndex, endIndex: currentIndex + text.length, }, paragraphStyle: { namedStyleType: `HEADING_${item.level}`, }, fields: "namedStyleType", }, }); currentIndex += text.length; } else if (item.type === "paragraph") { const text = `${item.text}\n`; requests.push({ insertText: { location: { index: currentIndex }, text, }, }); currentIndex += text.length; } else if (item.type === "bulletList") { for (const bulletItem of item.items) { const text = `${bulletItem}\n`; const textStartIndex = currentIndex; const textEndIndex = currentIndex + text.length; // Insert text requests.push({ insertText: { location: { index: currentIndex }, text, }, }); // Reset to NORMAL_TEXT style (prevents inheriting heading styles) requests.push({ updateParagraphStyle: { range: { startIndex: textStartIndex, endIndex: textEndIndex, }, paragraphStyle: { namedStyleType: "NORMAL_TEXT", }, fields: "namedStyleType", }, }); // Apply bullet formatting requests.push({ createParagraphBullets: { range: { startIndex: textStartIndex, endIndex: textEndIndex, }, bulletPreset: "BULLET_DISC_CIRCLE_SQUARE", }, }); currentIndex += text.length; } } } return requests; }; const extractDocId = (input: string): string | null => { // Try to extract document ID from URL const urlMatch = input.match(/\/document\/d\/([a-zA-Z0-9-_]+)/); if (urlMatch) { return urlMatch[1] ?? null; } // If it's not a URL, assume it's already a document ID if (/^[a-zA-Z0-9-_]+$/.test(input)) { return input; } return null; }; const updateDocContent = async ( auth: OAuth2Client, docIdOrUrl: string, requests: docs_v1.Schema$Request[], ): Promise<string> => { const docId = extractDocId(docIdOrUrl); if (!docId) { throw new Error("Invalid document ID or URL"); } if (!requests || requests.length === 0) { throw new Error("No update requests provided"); } const docs = google.docs({ version: "v1", auth }); try { // Execute the batch update const response = await docs.documents.batchUpdate({ documentId: docId, requestBody: { requests, }, }); // Build a summary of what was updated const replies = response.data.replies || []; const summary = buildUpdateSummary(requests, replies); return `Document updated successfully.\n\n${summary}`; } catch (error: unknown) { if (error instanceof Error) { // Provide more helpful error messages if (error.message.includes("Invalid requests")) { throw new Error( `Invalid update request. Make sure you're using valid Google Docs API request objects. Error: ${error.message}`, ); } if (error.message.includes("Invalid range")) { throw new Error( `Invalid range in update request. Ensure the startIndex and endIndex are within the document bounds. Error: ${error.message}`, ); } throw new Error(`Failed to update document: ${error.message}`); } throw error; } }; const buildUpdateSummary = ( requests: docs_v1.Schema$Request[], _replies: docs_v1.Schema$Response[], ): string => { const operations: string[] = []; for (const request of requests) { if (!request) continue; // Describe each operation type if (request.insertText) { const text = request.insertText.text || ""; const preview = text.length > 50 ? `${text.substring(0, 50)}...` : text; operations.push( `- Inserted text at index ${request.insertText.location?.index}: "${preview}"`, ); } else if (request.deleteContentRange) { operations.push( `- Deleted content from index ${request.deleteContentRange.range?.startIndex} to ${request.deleteContentRange.range?.endIndex}`, ); } else if (request.updateTextStyle) { operations.push( `- Updated text style from index ${request.updateTextStyle.range?.startIndex} to ${request.updateTextStyle.range?.endIndex}`, ); } else if (request.updateParagraphStyle) { operations.push( `- Updated paragraph style from index ${request.updateParagraphStyle.range?.startIndex} to ${request.updateParagraphStyle.range?.endIndex}`, ); } else if (request.insertTable) { operations.push( `- Inserted table with ${request.insertTable.rows} rows and ${request.insertTable.columns} columns at index ${request.insertTable.location?.index}`, ); } else if (request.insertTableRow) { operations.push( `- Inserted table row at index ${request.insertTableRow.insertBelow ? "below" : "above"}`, ); } else if (request.insertTableColumn) { operations.push( `- Inserted table column at index ${request.insertTableColumn.insertRight ? "right" : "left"}`, ); } else if (request.deleteTableRow) { operations.push("- Deleted table row"); } else if (request.deleteTableColumn) { operations.push("- Deleted table column"); } else if (request.replaceAllText) { operations.push( `- Replaced all occurrences of "${request.replaceAllText.containsText?.text}" with "${request.replaceAllText.replaceText}"`, ); } else if (request.createParagraphBullets) { operations.push( `- Created paragraph bullets from index ${request.createParagraphBullets.range?.startIndex} to ${request.createParagraphBullets.range?.endIndex}`, ); } else if (request.deleteParagraphBullets) { operations.push( `- Deleted paragraph bullets from index ${request.deleteParagraphBullets.range?.startIndex} to ${request.deleteParagraphBullets.range?.endIndex}`, ); } else { // Generic description for other operation types const operationType = Object.keys(request)[0]; operations.push(`- Executed operation: ${operationType}`); } } return operations.length > 0 ? `Operations performed:\n${operations.join("\n")}` : "No operations described"; };

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/benjamine/gdrive-mcp'

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