gdocs-mcp 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # gdocs-mcp
2
2
 
3
- Open-source MCP server for Google Docs and Sheets. Give Claude (or any MCP-compatible AI) the ability to read, create, edit, and search your Google Docs — with OAuth tokens that never leave your machine.
3
+ Open-source MCP server for Google Docs and Sheets. Give Claude (or any MCP-compatible AI) the ability to read, create, edit, search, and style your Google Docs — with OAuth tokens that never leave your machine.
4
4
 
5
5
  **Open-source. Self-hosted. Your tokens never leave your machine.**
6
6
 
@@ -46,7 +46,7 @@ Restart Claude Desktop. Ask Claude: *"List my recent Google Docs"* to verify.
46
46
  claude mcp add gdocs -- npx gdocs-mcp
47
47
  ```
48
48
 
49
- ## Tools
49
+ ## Tools (16)
50
50
 
51
51
  ### Google Docs
52
52
 
@@ -69,21 +69,88 @@ claude mcp add gdocs -- npx gdocs-mcp
69
69
  |------|-------------|
70
70
  | `get_charts` | List all charts in a spreadsheet with IDs and specs |
71
71
 
72
+ ### Style Presets
73
+
74
+ Define how your documents look once, apply everywhere.
75
+
76
+ | Tool | Description |
77
+ |------|-------------|
78
+ | `extract_document_styles` | Extract styles from a reference Google Doc and save as a reusable preset |
79
+ | `apply_style_preset` | Apply a style preset to any document — fonts, colors, spacing, tables |
80
+ | `list_style_presets` | List all available presets (4 built-in + your custom presets) |
81
+ | `set_active_preset` | Set the default preset for new documents |
82
+ | `delete_style_preset` | Delete a custom preset |
83
+
84
+ #### Built-in Presets
85
+
86
+ | Preset | Font | Headings | Body |
87
+ |--------|------|----------|------|
88
+ | `clean` | Inter | Dark charcoal, 22/16/13pt | 11pt, 1.5x spacing |
89
+ | `corporate` | Arial | Dark grey, conservative | 11pt, 1.4x spacing |
90
+ | `classic` | Georgia + Garamond | Dark blue, serif | 11pt, 1.4x spacing |
91
+ | `minimal` | Roboto | Black, light weight | 10.5pt, 1.5x spacing |
92
+
93
+ #### Extract + Apply Workflow
94
+
95
+ The fastest way to use presets: point to a Google Doc that already looks the way you want.
96
+
97
+ ```
98
+ You: "Extract styles from my brand doc and save as 'brand'"
99
+ → extract_document_styles(documentId, saveAs: "brand")
100
+
101
+ You: "Apply brand styles to this report"
102
+ → apply_style_preset(documentId, presetName: "brand")
103
+ ```
104
+
105
+ One-step capture, one-step apply. No JSON editing needed.
106
+
107
+ #### Style Properties
108
+
109
+ Presets support every text and paragraph property the Google Docs API exposes:
110
+
111
+ **Text:** font family, font size, bold, italic, underline, strikethrough, small caps, text color, background color, baseline offset
112
+
113
+ **Paragraph:** alignment (left/center/right/justified), line spacing, space above/below, first line indent, start/end indent, keep lines together, keep with next, direction (LTR/RTL), paragraph borders (top/bottom/left/right)
114
+
115
+ **Document:** page margins (top/bottom/left/right), page size (width/height)
116
+
117
+ **Tables:** header row background and text color, bold headers, border color and width, cell padding, alternating row backgrounds
118
+
119
+ Only properties you specify are applied. Omitted properties are left unchanged. Config is stored at `~/.gdocs-mcp/styles.json`.
120
+
121
+ ## Limitations
122
+
123
+ These are Google Docs API limitations, not gdocs-mcp limitations:
124
+
125
+ - **Table of Contents styling** is not controllable — TOC entries mirror heading styles
126
+ - **Custom named styles** cannot be created — only the 9 built-in types (Title, Subtitle, Heading 1-6, Normal Text)
127
+ - **Conditional formatting** does not exist in Google Docs
128
+ - **Multi-column layout** is not exposed via the API
129
+ - **Bullet/numbered list glyph types** are per-paragraph, not configurable via style presets
130
+ - **Table header detection** assumes row 0 is the header — multi-row headers are not detected
131
+
72
132
  ## Security
73
133
 
74
- - OAuth tokens are stored locally at `~/.gdocs-mcp/token.json` with `600` permissions
75
- - Credentials are stored at `~/.gdocs-mcp/credentials.json` with `600` permissions
134
+ - OAuth tokens stored locally at `~/.gdocs-mcp/token.json` with `600` permissions
135
+ - Credentials stored at `~/.gdocs-mcp/credentials.json` with `600` permissions
136
+ - Style presets stored at `~/.gdocs-mcp/styles.json`
76
137
  - No telemetry, no data collection, no third-party token storage
77
138
  - Tokens refresh automatically; if refresh fails, run `npx gdocs-mcp auth` again
78
139
 
79
140
  ## Configuration
80
141
 
81
- By default, credentials and tokens are stored in `~/.gdocs-mcp/`. Override with:
142
+ Credentials and tokens are stored in `~/.gdocs-mcp/` by default. Override credentials path with:
82
143
 
83
144
  ```bash
84
145
  GDOCS_CREDENTIALS=/path/to/credentials.json npx gdocs-mcp
85
146
  ```
86
147
 
148
+ For faster startup, install globally instead of using npx:
149
+
150
+ ```bash
151
+ npm install -g gdocs-mcp
152
+ ```
153
+
87
154
  ## Requirements
88
155
 
89
156
  - Node.js 18+
@@ -3,6 +3,7 @@ declare const CONFIG_DIR: string;
3
3
  declare const TOKEN_PATH: string;
4
4
  declare const CREDENTIALS_PATH_DEFAULT: string;
5
5
  export declare function getAuthClient(): OAuth2Client;
6
+ export declare function isReadOnlyMode(): boolean;
6
7
  export declare function getConfigDir(): string;
7
8
  export declare function getTokenPath(): string;
8
9
  export declare function getScopes(): string[];
@@ -4,7 +4,13 @@ import path from 'path';
4
4
  const CONFIG_DIR = path.join(process.env.HOME || '~', '.gdocs-mcp');
5
5
  const TOKEN_PATH = path.join(CONFIG_DIR, 'token.json');
6
6
  const CREDENTIALS_PATH_DEFAULT = path.join(CONFIG_DIR, 'credentials.json');
7
- const SCOPES = [
7
+ const READONLY = process.env.GDOCS_MCP_READONLY === '1';
8
+ const FULL_SCOPES = [
9
+ 'https://www.googleapis.com/auth/documents',
10
+ 'https://www.googleapis.com/auth/spreadsheets.readonly',
11
+ 'https://www.googleapis.com/auth/drive',
12
+ ];
13
+ const READONLY_SCOPES = [
8
14
  'https://www.googleapis.com/auth/documents',
9
15
  'https://www.googleapis.com/auth/spreadsheets.readonly',
10
16
  'https://www.googleapis.com/auth/drive.readonly',
@@ -31,6 +37,16 @@ export function getAuthClient() {
31
37
  throw new Error('Auth required. Run: npx gdocs-mcp auth');
32
38
  }
33
39
  const token = JSON.parse(fs.readFileSync(TOKEN_PATH, 'utf8'));
40
+ // Check if token has sufficient scopes for full mode
41
+ if (!READONLY && token.scope) {
42
+ const grantedScopes = token.scope.split(' ');
43
+ const hasDriveWrite = grantedScopes.some((s) => s === 'https://www.googleapis.com/auth/drive');
44
+ if (!hasDriveWrite) {
45
+ throw new Error('Auth scopes insufficient. v0.4 requires expanded Drive access for export, copy, delete, and comments.\n' +
46
+ 'Run: npx gdocs-mcp auth\n' +
47
+ 'Or set GDOCS_MCP_READONLY=1 to use read-only mode with the original scope.');
48
+ }
49
+ }
34
50
  oAuth2Client.setCredentials(token);
35
51
  oAuth2Client.on('tokens', (newTokens) => {
36
52
  const existing = JSON.parse(fs.readFileSync(TOKEN_PATH, 'utf8'));
@@ -39,6 +55,9 @@ export function getAuthClient() {
39
55
  });
40
56
  return oAuth2Client;
41
57
  }
58
+ export function isReadOnlyMode() {
59
+ return READONLY;
60
+ }
42
61
  export function getConfigDir() {
43
62
  return CONFIG_DIR;
44
63
  }
@@ -46,6 +65,6 @@ export function getTokenPath() {
46
65
  return TOKEN_PATH;
47
66
  }
48
67
  export function getScopes() {
49
- return SCOPES;
68
+ return READONLY ? READONLY_SCOPES : FULL_SCOPES;
50
69
  }
51
70
  export { CONFIG_DIR, TOKEN_PATH, CREDENTIALS_PATH_DEFAULT };
package/dist/index.js CHANGED
@@ -17,9 +17,23 @@ import { ExtractDocumentStylesSchema, extractDocumentStyles } from './tools/extr
17
17
  import { ListStylePresetsSchema, listStylePresets } from './tools/list-style-presets.js';
18
18
  import { SetActivePresetSchema, setActivePreset } from './tools/set-active-preset.js';
19
19
  import { DeleteStylePresetSchema, deleteStylePreset } from './tools/delete-style-preset.js';
20
+ import { UpdateHeaderFooterSchema, updateHeaderFooter } from './tools/update-header-footer.js';
21
+ import { FormatListSchema, formatList } from './tools/format-list.js';
22
+ import { ExecuteScriptSchema, executeScript } from './tools/execute-script.js';
23
+ import { InsertImageSchema, insertImage } from './tools/insert-image.js';
24
+ import { InsertTableSchema, insertTable } from './tools/insert-table.js';
25
+ import { InsertPageBreakSchema, insertPageBreak } from './tools/insert-page-break.js';
26
+ import { InsertLinkSchema, insertLink } from './tools/insert-link.js';
27
+ import { ExportDocumentSchema, exportDocument } from './tools/export-document.js';
28
+ import { CopyDocumentSchema, copyDocument } from './tools/copy-document.js';
29
+ import { DeleteDocumentSchema, deleteDocument } from './tools/delete-document.js';
30
+ import { GetCommentsSchema, getComments } from './tools/get-comments.js';
31
+ import { AddCommentSchema, addComment } from './tools/add-comment.js';
32
+ const VERSION = '0.4.0';
33
+ const QUIET = process.env.GDOCS_MCP_QUIET === '1';
20
34
  const server = new McpServer({
21
35
  name: 'gdocs-mcp',
22
- version: '0.2.0',
36
+ version: VERSION,
23
37
  });
24
38
  function formatError(err) {
25
39
  if (err instanceof Error) {
@@ -36,19 +50,41 @@ function formatError(err) {
36
50
  if (message.includes('PERMISSION_DENIED') || message.includes('403')) {
37
51
  return 'Permission denied. Ensure the document is shared with your Google account.';
38
52
  }
53
+ if (message.includes('Apps Script API has not been used')) {
54
+ return 'Apps Script API is not enabled. Enable it at: https://console.cloud.google.com/apis/library/script.googleapis.com';
55
+ }
39
56
  return message;
40
57
  }
41
58
  return String(err);
42
59
  }
43
60
  function registerTool(name, description, schema, handler) {
44
61
  server.tool(name, description, schema.shape, async (args) => {
62
+ const startTime = performance.now();
45
63
  try {
46
64
  const result = await handler(args);
65
+ if (!QUIET) {
66
+ const totalMs = Math.round(performance.now() - startTime);
67
+ const apiMs = result?._apiMs;
68
+ const requestCount = result?.requestCount;
69
+ delete result?._apiMs;
70
+ let logParts = [`[gdocs-mcp] ${name}: ${totalMs}ms`];
71
+ if (apiMs !== undefined) {
72
+ logParts.push(`(api: ${apiMs}ms${requestCount ? `, ${requestCount} requests` : ''})`);
73
+ }
74
+ else if (totalMs < 10) {
75
+ logParts.push('(local)');
76
+ }
77
+ console.error(logParts.join(' '));
78
+ }
47
79
  return {
48
80
  content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
49
81
  };
50
82
  }
51
83
  catch (err) {
84
+ if (!QUIET) {
85
+ const totalMs = Math.round(performance.now() - startTime);
86
+ console.error(`[gdocs-mcp] ${name}: ${totalMs}ms (error)`);
87
+ }
52
88
  return {
53
89
  content: [{ type: 'text', text: formatError(err) }],
54
90
  isError: true,
@@ -70,15 +106,29 @@ registerTool('unmerge_table_cells', 'Unmerge previously merged cells in a docume
70
106
  // Google Sheets tools
71
107
  registerTool('get_charts', 'List all charts in a Google Sheets spreadsheet with IDs and specs', GetChartsSchema, getCharts);
72
108
  // Style preset tools
73
- registerTool('apply_style_preset', 'Apply a style preset to a document. Updates named styles (TITLE, HEADING_1-6, NORMAL_TEXT, SUBTITLE), document margins, and table formatting in a single API call.', ApplyStylePresetSchema, applyStylePreset);
74
- registerTool('extract_document_styles', 'Extract styles from a Google Doc and optionally save as a reusable preset. Returns font, size, color, spacing for all named styles, margins, and table formatting.', ExtractDocumentStylesSchema, extractDocumentStyles);
75
- registerTool('list_style_presets', 'List all available style presets (built-in: clean, corporate, classic, minimal + user-defined) with typography summaries.', ListStylePresetsSchema, listStylePresets);
76
- registerTool('set_active_preset', 'Set the active style preset. The active preset auto-applies when creating new documents.', SetActivePresetSchema, setActivePreset);
109
+ registerTool('apply_style_preset', 'Apply a style preset to a document. Styles all paragraphs by named type, updates margins, and formats tables.', ApplyStylePresetSchema, applyStylePreset);
110
+ registerTool('extract_document_styles', 'Extract styles from a Google Doc and optionally save as a reusable preset.', ExtractDocumentStylesSchema, extractDocumentStyles);
111
+ registerTool('list_style_presets', 'List all available style presets (built-in + user-defined) with typography summaries.', ListStylePresetsSchema, listStylePresets);
112
+ registerTool('set_active_preset', 'Set the active style preset. Auto-applies when creating new documents.', SetActivePresetSchema, setActivePreset);
77
113
  registerTool('delete_style_preset', 'Delete a user-defined style preset. Cannot delete built-in presets.', DeleteStylePresetSchema, deleteStylePreset);
114
+ // v0.3 tools
115
+ registerTool('update_header_footer', 'Create or update header/footer content and styling. Supports page number insertion.', UpdateHeaderFooterSchema, updateHeaderFooter);
116
+ registerTool('format_list', 'Apply bullet, numbered, or remove list formatting on a paragraph range. Supports 6 glyph presets.', FormatListSchema, formatList);
117
+ registerTool('execute_script', 'Execute a function in a deployed Google Apps Script project. Returns JSON result.', ExecuteScriptSchema, executeScript);
118
+ // v0.4 content tools
119
+ registerTool('insert_image', 'Insert an image from a URL at a specific position. Supports optional width/height sizing in PT.', InsertImageSchema, insertImage);
120
+ registerTool('insert_table', 'Create a table with specified rows (1-20) and columns (1-20) at a position.', InsertTableSchema, insertTable);
121
+ registerTool('insert_page_break', 'Insert a page break at a specific position.', InsertPageBreakSchema, insertPageBreak);
122
+ registerTool('insert_link', 'Add a hyperlink to an existing text range. The text must already exist in the document.', InsertLinkSchema, insertLink);
123
+ registerTool('export_document', 'Export a Google Doc as PDF, DOCX, TXT, or HTML. Saves to file or returns base64.', ExportDocumentSchema, exportDocument);
124
+ registerTool('copy_document', 'Create a copy of a Google Doc with a new title. Inherits content and formatting.', CopyDocumentSchema, copyDocument);
125
+ registerTool('delete_document', 'Move a Google Doc to trash. Requires confirmTitle matching the exact document title.', DeleteDocumentSchema, deleteDocument);
126
+ registerTool('get_comments', 'List comments on a document with author, content, resolved status, and replies.', GetCommentsSchema, getComments);
127
+ registerTool('add_comment', 'Add a comment anchored to specific text in the document. Anchors to first occurrence of quotedText.', AddCommentSchema, addComment);
78
128
  async function main() {
79
129
  const transport = new StdioServerTransport();
80
130
  await server.connect(transport);
81
- console.error('gdocs-mcp v0.2.0 started');
131
+ console.error(`gdocs-mcp v${VERSION} started`);
82
132
  }
83
133
  main().catch((err) => {
84
134
  console.error('Failed to start gdocs-mcp:', err);
@@ -0,0 +1,14 @@
1
+ import { z } from 'zod';
2
+ export declare const AddCommentSchema: z.ZodObject<{
3
+ documentId: z.ZodString;
4
+ content: z.ZodString;
5
+ quotedText: z.ZodString;
6
+ }, z.core.$strip>;
7
+ export declare function addComment(args: z.infer<typeof AddCommentSchema>): Promise<{
8
+ documentId: string;
9
+ commentId: string | null | undefined;
10
+ content: string | null | undefined;
11
+ quotedText: string | undefined;
12
+ createdTime: string | null | undefined;
13
+ _apiMs: number;
14
+ }>;
@@ -0,0 +1,36 @@
1
+ import { google } from 'googleapis';
2
+ import { z } from 'zod';
3
+ import { getAuthClient, isReadOnlyMode } from '../auth/google-auth.js';
4
+ export const AddCommentSchema = z.object({
5
+ documentId: z.string().describe('The Google Doc document ID'),
6
+ content: z.string().describe('The comment text'),
7
+ quotedText: z.string().describe('Text in the document to anchor the comment to. Google matches the first occurrence.'),
8
+ });
9
+ export async function addComment(args) {
10
+ if (isReadOnlyMode()) {
11
+ throw new Error('add_comment is not available in read-only mode. Remove GDOCS_MCP_READONLY=1 and re-auth.');
12
+ }
13
+ const auth = getAuthClient();
14
+ const drive = google.drive({ version: 'v3', auth });
15
+ const apiStart = performance.now();
16
+ const res = await drive.comments.create({
17
+ fileId: args.documentId,
18
+ fields: 'id,author(displayName),content,quotedFileContent,createdTime',
19
+ requestBody: {
20
+ content: args.content,
21
+ quotedFileContent: {
22
+ value: args.quotedText,
23
+ mimeType: 'text/html',
24
+ },
25
+ },
26
+ });
27
+ const apiMs = Math.round(performance.now() - apiStart);
28
+ return {
29
+ documentId: args.documentId,
30
+ commentId: res.data.id,
31
+ content: res.data.content,
32
+ quotedText: res.data.quotedFileContent?.value,
33
+ createdTime: res.data.createdTime,
34
+ _apiMs: apiMs,
35
+ };
36
+ }
@@ -0,0 +1,12 @@
1
+ import { z } from 'zod';
2
+ export declare const CopyDocumentSchema: z.ZodObject<{
3
+ documentId: z.ZodString;
4
+ newTitle: z.ZodString;
5
+ }, z.core.$strip>;
6
+ export declare function copyDocument(args: z.infer<typeof CopyDocumentSchema>): Promise<{
7
+ originalDocumentId: string;
8
+ newDocumentId: string | null | undefined;
9
+ title: string | null | undefined;
10
+ url: string | null | undefined;
11
+ _apiMs: number;
12
+ }>;
@@ -0,0 +1,28 @@
1
+ import { google } from 'googleapis';
2
+ import { z } from 'zod';
3
+ import { getAuthClient, isReadOnlyMode } from '../auth/google-auth.js';
4
+ export const CopyDocumentSchema = z.object({
5
+ documentId: z.string().describe('The Google Doc document ID to copy'),
6
+ newTitle: z.string().describe('Title for the copy'),
7
+ });
8
+ export async function copyDocument(args) {
9
+ if (isReadOnlyMode()) {
10
+ throw new Error('copy_document is not available in read-only mode. Remove GDOCS_MCP_READONLY=1 and re-auth.');
11
+ }
12
+ const auth = getAuthClient();
13
+ const drive = google.drive({ version: 'v3', auth });
14
+ const apiStart = performance.now();
15
+ const res = await drive.files.copy({
16
+ fileId: args.documentId,
17
+ requestBody: { name: args.newTitle },
18
+ fields: 'id, name, webViewLink',
19
+ });
20
+ const apiMs = Math.round(performance.now() - apiStart);
21
+ return {
22
+ originalDocumentId: args.documentId,
23
+ newDocumentId: res.data.id,
24
+ title: res.data.name,
25
+ url: res.data.webViewLink,
26
+ _apiMs: apiMs,
27
+ };
28
+ }
@@ -0,0 +1,12 @@
1
+ import { z } from 'zod';
2
+ export declare const DeleteDocumentSchema: z.ZodObject<{
3
+ documentId: z.ZodString;
4
+ confirmTitle: z.ZodString;
5
+ }, z.core.$strip>;
6
+ export declare function deleteDocument(args: z.infer<typeof DeleteDocumentSchema>): Promise<{
7
+ documentId: string;
8
+ title: string;
9
+ trashed: boolean;
10
+ message: string;
11
+ _apiMs: number;
12
+ }>;
@@ -0,0 +1,36 @@
1
+ import { google } from 'googleapis';
2
+ import { z } from 'zod';
3
+ import { getAuthClient, isReadOnlyMode } from '../auth/google-auth.js';
4
+ export const DeleteDocumentSchema = z.object({
5
+ documentId: z.string().describe('The Google Doc document ID to trash'),
6
+ confirmTitle: z.string().describe('Must match the exact document title to confirm deletion. This prevents accidental trashing.'),
7
+ });
8
+ export async function deleteDocument(args) {
9
+ if (isReadOnlyMode()) {
10
+ throw new Error('delete_document is not available in read-only mode. Remove GDOCS_MCP_READONLY=1 and re-auth.');
11
+ }
12
+ const auth = getAuthClient();
13
+ const drive = google.drive({ version: 'v3', auth });
14
+ const apiStart = performance.now();
15
+ // Get the actual document title
16
+ const file = await drive.files.get({
17
+ fileId: args.documentId,
18
+ fields: 'name',
19
+ });
20
+ const actualTitle = file.data.name;
21
+ if (args.confirmTitle !== actualTitle) {
22
+ throw new Error(`Confirmation failed. To trash this document, pass confirmTitle: "${actualTitle}"`);
23
+ }
24
+ await drive.files.update({
25
+ fileId: args.documentId,
26
+ requestBody: { trashed: true },
27
+ });
28
+ const apiMs = Math.round(performance.now() - apiStart);
29
+ return {
30
+ documentId: args.documentId,
31
+ title: actualTitle,
32
+ trashed: true,
33
+ message: 'Document moved to trash. It can be recovered from Google Drive trash.',
34
+ _apiMs: apiMs,
35
+ };
36
+ }
@@ -0,0 +1,12 @@
1
+ import { z } from 'zod';
2
+ export declare const ExecuteScriptSchema: z.ZodObject<{
3
+ scriptId: z.ZodString;
4
+ functionName: z.ZodString;
5
+ parameters: z.ZodOptional<z.ZodArray<z.ZodAny>>;
6
+ }, z.core.$strip>;
7
+ export declare function executeScript(args: z.infer<typeof ExecuteScriptSchema>): Promise<{
8
+ scriptId: string;
9
+ functionName: string;
10
+ result: any;
11
+ _apiMs: number;
12
+ }>;
@@ -0,0 +1,37 @@
1
+ import { google } from 'googleapis';
2
+ import { z } from 'zod';
3
+ import { getAuthClient } from '../auth/google-auth.js';
4
+ export const ExecuteScriptSchema = z.object({
5
+ scriptId: z.string().describe('The Apps Script project ID. Find it at script.google.com > Project Settings.'),
6
+ functionName: z.string().describe('Name of the function to execute'),
7
+ parameters: z.array(z.any()).optional().describe('Array of parameters to pass to the function. Only primitive types (string, number, boolean, array, plain object).'),
8
+ });
9
+ export async function executeScript(args) {
10
+ const auth = getAuthClient();
11
+ const script = google.script({ version: 'v1', auth });
12
+ const apiStart = performance.now();
13
+ const res = await script.scripts.run({
14
+ scriptId: args.scriptId,
15
+ requestBody: {
16
+ function: args.functionName,
17
+ parameters: args.parameters || [],
18
+ devMode: false,
19
+ },
20
+ });
21
+ const apiMs = Math.round(performance.now() - apiStart);
22
+ // Check for script execution errors
23
+ if (res.data.error) {
24
+ const details = res.data.error.details;
25
+ const scriptError = details?.find((d) => d.errorType);
26
+ const errorMessage = scriptError
27
+ ? `${scriptError.errorType}: ${scriptError.errorMessage}`
28
+ : res.data.error.message || 'Unknown script error';
29
+ throw new Error(errorMessage);
30
+ }
31
+ return {
32
+ scriptId: args.scriptId,
33
+ functionName: args.functionName,
34
+ result: res.data.response?.result ?? null,
35
+ _apiMs: apiMs,
36
+ };
37
+ }
@@ -0,0 +1,26 @@
1
+ import { z } from 'zod';
2
+ export declare const ExportDocumentSchema: z.ZodObject<{
3
+ documentId: z.ZodString;
4
+ format: z.ZodEnum<{
5
+ pdf: "pdf";
6
+ docx: "docx";
7
+ txt: "txt";
8
+ html: "html";
9
+ }>;
10
+ outputPath: z.ZodOptional<z.ZodString>;
11
+ }, z.core.$strip>;
12
+ export declare function exportDocument(args: z.infer<typeof ExportDocumentSchema>): Promise<{
13
+ documentId: string;
14
+ format: "pdf" | "docx" | "txt" | "html";
15
+ savedTo: string;
16
+ sizeBytes: number;
17
+ _apiMs: number;
18
+ content?: undefined;
19
+ } | {
20
+ documentId: string;
21
+ format: "pdf" | "docx" | "txt" | "html";
22
+ content: string;
23
+ sizeBytes: number;
24
+ _apiMs: number;
25
+ savedTo?: undefined;
26
+ }>;
@@ -0,0 +1,43 @@
1
+ import { google } from 'googleapis';
2
+ import { z } from 'zod';
3
+ import fs from 'fs';
4
+ import { getAuthClient, isReadOnlyMode } from '../auth/google-auth.js';
5
+ const MIME_TYPES = {
6
+ pdf: 'application/pdf',
7
+ docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
8
+ txt: 'text/plain',
9
+ html: 'text/html',
10
+ };
11
+ export const ExportDocumentSchema = z.object({
12
+ documentId: z.string().describe('The Google Doc document ID'),
13
+ format: z.enum(['pdf', 'docx', 'txt', 'html']).describe('Export format: pdf, docx, txt, or html'),
14
+ outputPath: z.string().optional().describe('Local file path to save the export. If omitted, returns base64-encoded content.'),
15
+ });
16
+ export async function exportDocument(args) {
17
+ if (isReadOnlyMode()) {
18
+ throw new Error('export_document is not available in read-only mode. Remove GDOCS_MCP_READONLY=1 and re-auth.');
19
+ }
20
+ const auth = getAuthClient();
21
+ const drive = google.drive({ version: 'v3', auth });
22
+ const apiStart = performance.now();
23
+ const res = await drive.files.export({ fileId: args.documentId, mimeType: MIME_TYPES[args.format] }, { responseType: 'arraybuffer' });
24
+ const buffer = Buffer.from(res.data);
25
+ const apiMs = Math.round(performance.now() - apiStart);
26
+ if (args.outputPath) {
27
+ fs.writeFileSync(args.outputPath, buffer);
28
+ return {
29
+ documentId: args.documentId,
30
+ format: args.format,
31
+ savedTo: args.outputPath,
32
+ sizeBytes: buffer.length,
33
+ _apiMs: apiMs,
34
+ };
35
+ }
36
+ return {
37
+ documentId: args.documentId,
38
+ format: args.format,
39
+ content: buffer.toString('base64'),
40
+ sizeBytes: buffer.length,
41
+ _apiMs: apiMs,
42
+ };
43
+ }
@@ -0,0 +1,20 @@
1
+ import { z } from 'zod';
2
+ export declare const FormatListSchema: z.ZodObject<{
3
+ documentId: z.ZodString;
4
+ startIndex: z.ZodNumber;
5
+ endIndex: z.ZodNumber;
6
+ listType: z.ZodEnum<{
7
+ none: "none";
8
+ bullet: "bullet";
9
+ numbered: "numbered";
10
+ }>;
11
+ preset: z.ZodOptional<z.ZodString>;
12
+ }, z.core.$strip>;
13
+ export declare function formatList(args: z.infer<typeof FormatListSchema>): Promise<{
14
+ documentId: string;
15
+ listType: "none" | "bullet" | "numbered";
16
+ preset: string | null;
17
+ startIndex: number;
18
+ endIndex: number;
19
+ _apiMs: number;
20
+ }>;
@@ -0,0 +1,74 @@
1
+ import { google } from 'googleapis';
2
+ import { z } from 'zod';
3
+ import { getAuthClient } from '../auth/google-auth.js';
4
+ const BULLET_PRESETS = [
5
+ 'BULLET_DISC_CIRCLE_SQUARE',
6
+ 'BULLET_DIAMONDX_ARROW3D_SQUARE',
7
+ 'BULLET_CHECKBOX',
8
+ ];
9
+ const NUMBERED_PRESETS = [
10
+ 'NUMBERED_DECIMAL_ALPHA_ROMAN',
11
+ 'NUMBERED_DECIMAL_NESTED',
12
+ 'NUMBERED_UPPERALPHA_ALPHA_ROMAN',
13
+ ];
14
+ const ALL_PRESETS = [...BULLET_PRESETS, ...NUMBERED_PRESETS];
15
+ export const FormatListSchema = z.object({
16
+ documentId: z.string().describe('The Google Doc document ID'),
17
+ startIndex: z.number().describe('Start index of the paragraph range'),
18
+ endIndex: z.number().describe('End index of the paragraph range'),
19
+ listType: z.enum(['bullet', 'numbered', 'none']).describe('"bullet" for unordered, "numbered" for ordered, "none" to remove list formatting'),
20
+ preset: z.string().optional().describe('Bullet/number preset. Bullets: BULLET_DISC_CIRCLE_SQUARE (default), BULLET_DIAMONDX_ARROW3D_SQUARE, BULLET_CHECKBOX. ' +
21
+ 'Numbers: NUMBERED_DECIMAL_ALPHA_ROMAN (default), NUMBERED_DECIMAL_NESTED, NUMBERED_UPPERALPHA_ALPHA_ROMAN.'),
22
+ });
23
+ export async function formatList(args) {
24
+ const auth = getAuthClient();
25
+ const docs = google.docs({ version: 'v1', auth });
26
+ const apiStart = performance.now();
27
+ const requests = [];
28
+ if (args.listType === 'none') {
29
+ requests.push({
30
+ deleteParagraphBullets: {
31
+ range: { startIndex: args.startIndex, endIndex: args.endIndex },
32
+ },
33
+ });
34
+ }
35
+ else {
36
+ const defaultPreset = args.listType === 'bullet'
37
+ ? 'BULLET_DISC_CIRCLE_SQUARE'
38
+ : 'NUMBERED_DECIMAL_ALPHA_ROMAN';
39
+ const preset = args.preset || defaultPreset;
40
+ // Validate preset matches list type
41
+ if (args.listType === 'bullet' && !BULLET_PRESETS.includes(preset)) {
42
+ if (NUMBERED_PRESETS.includes(preset)) {
43
+ throw new Error(`Preset "${preset}" is a numbered preset. Use listType: "numbered" or pick a bullet preset: ${BULLET_PRESETS.join(', ')}`);
44
+ }
45
+ }
46
+ if (args.listType === 'numbered' && !NUMBERED_PRESETS.includes(preset)) {
47
+ if (BULLET_PRESETS.includes(preset)) {
48
+ throw new Error(`Preset "${preset}" is a bullet preset. Use listType: "bullet" or pick a numbered preset: ${NUMBERED_PRESETS.join(', ')}`);
49
+ }
50
+ }
51
+ if (!ALL_PRESETS.includes(preset)) {
52
+ throw new Error(`Unknown preset "${preset}". Available: ${ALL_PRESETS.join(', ')}`);
53
+ }
54
+ requests.push({
55
+ createParagraphBullets: {
56
+ range: { startIndex: args.startIndex, endIndex: args.endIndex },
57
+ bulletPreset: preset,
58
+ },
59
+ });
60
+ }
61
+ await docs.documents.batchUpdate({
62
+ documentId: args.documentId,
63
+ requestBody: { requests },
64
+ });
65
+ const apiMs = Math.round(performance.now() - apiStart);
66
+ return {
67
+ documentId: args.documentId,
68
+ listType: args.listType,
69
+ preset: args.listType === 'none' ? null : (args.preset || (args.listType === 'bullet' ? 'BULLET_DISC_CIRCLE_SQUARE' : 'NUMBERED_DECIMAL_ALPHA_ROMAN')),
70
+ startIndex: args.startIndex,
71
+ endIndex: args.endIndex,
72
+ _apiMs: apiMs,
73
+ };
74
+ }
@@ -0,0 +1,25 @@
1
+ import { z } from 'zod';
2
+ export declare const GetCommentsSchema: z.ZodObject<{
3
+ documentId: z.ZodString;
4
+ includeResolved: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
5
+ }, z.core.$strip>;
6
+ export declare function getComments(args: z.infer<typeof GetCommentsSchema>): Promise<{
7
+ documentId: string;
8
+ comments: {
9
+ id: string | null | undefined;
10
+ author: string;
11
+ authorEmail: string | null | undefined;
12
+ content: string | null | undefined;
13
+ quotedText: string | null;
14
+ resolved: boolean;
15
+ createdTime: string | null | undefined;
16
+ modifiedTime: string | null | undefined;
17
+ replies: {
18
+ author: string;
19
+ content: string | null | undefined;
20
+ createdTime: string | null | undefined;
21
+ }[];
22
+ }[];
23
+ count: number;
24
+ _apiMs: number;
25
+ }>;
@@ -0,0 +1,44 @@
1
+ import { google } from 'googleapis';
2
+ import { z } from 'zod';
3
+ import { getAuthClient, isReadOnlyMode } from '../auth/google-auth.js';
4
+ export const GetCommentsSchema = z.object({
5
+ documentId: z.string().describe('The Google Doc document ID'),
6
+ includeResolved: z.boolean().optional().default(false).describe('Include resolved comments (default: false, only open comments)'),
7
+ });
8
+ export async function getComments(args) {
9
+ if (isReadOnlyMode()) {
10
+ throw new Error('get_comments is not available in read-only mode. Remove GDOCS_MCP_READONLY=1 and re-auth.');
11
+ }
12
+ const auth = getAuthClient();
13
+ const drive = google.drive({ version: 'v3', auth });
14
+ const apiStart = performance.now();
15
+ const res = await drive.comments.list({
16
+ fileId: args.documentId,
17
+ fields: 'comments(id,author(displayName,emailAddress),content,quotedFileContent,resolved,createdTime,modifiedTime,replies(author(displayName),content,createdTime))',
18
+ pageSize: 100,
19
+ });
20
+ const comments = (res.data.comments || [])
21
+ .filter(c => args.includeResolved || !c.resolved)
22
+ .map(c => ({
23
+ id: c.id,
24
+ author: c.author?.displayName || 'Unknown',
25
+ authorEmail: c.author?.emailAddress,
26
+ content: c.content,
27
+ quotedText: c.quotedFileContent?.value || null,
28
+ resolved: c.resolved || false,
29
+ createdTime: c.createdTime,
30
+ modifiedTime: c.modifiedTime,
31
+ replies: (c.replies || []).map(r => ({
32
+ author: r.author?.displayName || 'Unknown',
33
+ content: r.content,
34
+ createdTime: r.createdTime,
35
+ })),
36
+ }));
37
+ const apiMs = Math.round(performance.now() - apiStart);
38
+ return {
39
+ documentId: args.documentId,
40
+ comments,
41
+ count: comments.length,
42
+ _apiMs: apiMs,
43
+ };
44
+ }
@@ -0,0 +1,14 @@
1
+ import { z } from 'zod';
2
+ export declare const InsertImageSchema: z.ZodObject<{
3
+ documentId: z.ZodString;
4
+ uri: z.ZodString;
5
+ index: z.ZodNumber;
6
+ width: z.ZodOptional<z.ZodNumber>;
7
+ height: z.ZodOptional<z.ZodNumber>;
8
+ }, z.core.$strip>;
9
+ export declare function insertImage(args: z.infer<typeof InsertImageSchema>): Promise<{
10
+ documentId: string;
11
+ inlineObjectId: string | null | undefined;
12
+ index: number;
13
+ _apiMs: number;
14
+ }>;
@@ -0,0 +1,43 @@
1
+ import { google } from 'googleapis';
2
+ import { z } from 'zod';
3
+ import { getAuthClient } from '../auth/google-auth.js';
4
+ export const InsertImageSchema = z.object({
5
+ documentId: z.string().describe('The Google Doc document ID'),
6
+ uri: z.string().url().describe('Public URL of the image to insert'),
7
+ index: z.number().describe('Position in the document to insert the image'),
8
+ width: z.number().optional().describe('Image width in PT. If only width is set, height scales proportionally.'),
9
+ height: z.number().optional().describe('Image height in PT. If only height is set, width scales proportionally.'),
10
+ });
11
+ export async function insertImage(args) {
12
+ const auth = getAuthClient();
13
+ const docs = google.docs({ version: 'v1', auth });
14
+ const apiStart = performance.now();
15
+ const objectSize = {};
16
+ if (args.width !== undefined) {
17
+ objectSize.width = { magnitude: args.width, unit: 'PT' };
18
+ }
19
+ if (args.height !== undefined) {
20
+ objectSize.height = { magnitude: args.height, unit: 'PT' };
21
+ }
22
+ const request = {
23
+ insertInlineImage: {
24
+ location: { index: args.index },
25
+ uri: args.uri,
26
+ },
27
+ };
28
+ if (Object.keys(objectSize).length > 0) {
29
+ request.insertInlineImage.objectSize = objectSize;
30
+ }
31
+ const res = await docs.documents.batchUpdate({
32
+ documentId: args.documentId,
33
+ requestBody: { requests: [request] },
34
+ });
35
+ const inlineObjectId = res.data.replies?.[0]?.insertInlineImage?.objectId;
36
+ const apiMs = Math.round(performance.now() - apiStart);
37
+ return {
38
+ documentId: args.documentId,
39
+ inlineObjectId,
40
+ index: args.index,
41
+ _apiMs: apiMs,
42
+ };
43
+ }
@@ -0,0 +1,14 @@
1
+ import { z } from 'zod';
2
+ export declare const InsertLinkSchema: z.ZodObject<{
3
+ documentId: z.ZodString;
4
+ url: z.ZodString;
5
+ startIndex: z.ZodNumber;
6
+ endIndex: z.ZodNumber;
7
+ }, z.core.$strip>;
8
+ export declare function insertLink(args: z.infer<typeof InsertLinkSchema>): Promise<{
9
+ documentId: string;
10
+ url: string;
11
+ startIndex: number;
12
+ endIndex: number;
13
+ _apiMs: number;
14
+ }>;
@@ -0,0 +1,38 @@
1
+ import { google } from 'googleapis';
2
+ import { z } from 'zod';
3
+ import { getAuthClient } from '../auth/google-auth.js';
4
+ export const InsertLinkSchema = z.object({
5
+ documentId: z.string().describe('The Google Doc document ID'),
6
+ url: z.string().url().describe('The URL to link to'),
7
+ startIndex: z.number().describe('Start index of the text range to make into a link'),
8
+ endIndex: z.number().describe('End index of the text range to make into a link'),
9
+ });
10
+ export async function insertLink(args) {
11
+ const auth = getAuthClient();
12
+ const docs = google.docs({ version: 'v1', auth });
13
+ const apiStart = performance.now();
14
+ await docs.documents.batchUpdate({
15
+ documentId: args.documentId,
16
+ requestBody: {
17
+ requests: [
18
+ {
19
+ updateTextStyle: {
20
+ range: { startIndex: args.startIndex, endIndex: args.endIndex },
21
+ textStyle: {
22
+ link: { url: args.url },
23
+ },
24
+ fields: 'link',
25
+ },
26
+ },
27
+ ],
28
+ },
29
+ });
30
+ const apiMs = Math.round(performance.now() - apiStart);
31
+ return {
32
+ documentId: args.documentId,
33
+ url: args.url,
34
+ startIndex: args.startIndex,
35
+ endIndex: args.endIndex,
36
+ _apiMs: apiMs,
37
+ };
38
+ }
@@ -0,0 +1,10 @@
1
+ import { z } from 'zod';
2
+ export declare const InsertPageBreakSchema: z.ZodObject<{
3
+ documentId: z.ZodString;
4
+ index: z.ZodNumber;
5
+ }, z.core.$strip>;
6
+ export declare function insertPageBreak(args: z.infer<typeof InsertPageBreakSchema>): Promise<{
7
+ documentId: string;
8
+ index: number;
9
+ _apiMs: number;
10
+ }>;
@@ -0,0 +1,30 @@
1
+ import { google } from 'googleapis';
2
+ import { z } from 'zod';
3
+ import { getAuthClient } from '../auth/google-auth.js';
4
+ export const InsertPageBreakSchema = z.object({
5
+ documentId: z.string().describe('The Google Doc document ID'),
6
+ index: z.number().describe('Position in the document to insert the page break'),
7
+ });
8
+ export async function insertPageBreak(args) {
9
+ const auth = getAuthClient();
10
+ const docs = google.docs({ version: 'v1', auth });
11
+ const apiStart = performance.now();
12
+ await docs.documents.batchUpdate({
13
+ documentId: args.documentId,
14
+ requestBody: {
15
+ requests: [
16
+ {
17
+ insertPageBreak: {
18
+ location: { index: args.index },
19
+ },
20
+ },
21
+ ],
22
+ },
23
+ });
24
+ const apiMs = Math.round(performance.now() - apiStart);
25
+ return {
26
+ documentId: args.documentId,
27
+ index: args.index,
28
+ _apiMs: apiMs,
29
+ };
30
+ }
@@ -0,0 +1,14 @@
1
+ import { z } from 'zod';
2
+ export declare const InsertTableSchema: z.ZodObject<{
3
+ documentId: z.ZodString;
4
+ rows: z.ZodNumber;
5
+ columns: z.ZodNumber;
6
+ index: z.ZodNumber;
7
+ }, z.core.$strip>;
8
+ export declare function insertTable(args: z.infer<typeof InsertTableSchema>): Promise<{
9
+ documentId: string;
10
+ rows: number;
11
+ columns: number;
12
+ index: number;
13
+ _apiMs: number;
14
+ }>;
@@ -0,0 +1,36 @@
1
+ import { google } from 'googleapis';
2
+ import { z } from 'zod';
3
+ import { getAuthClient } from '../auth/google-auth.js';
4
+ export const InsertTableSchema = z.object({
5
+ documentId: z.string().describe('The Google Doc document ID'),
6
+ rows: z.number().min(1).max(20).describe('Number of rows (1-20)'),
7
+ columns: z.number().min(1).max(20).describe('Number of columns (1-20)'),
8
+ index: z.number().describe('Position in the document to insert the table'),
9
+ });
10
+ export async function insertTable(args) {
11
+ const auth = getAuthClient();
12
+ const docs = google.docs({ version: 'v1', auth });
13
+ const apiStart = performance.now();
14
+ await docs.documents.batchUpdate({
15
+ documentId: args.documentId,
16
+ requestBody: {
17
+ requests: [
18
+ {
19
+ insertTable: {
20
+ location: { index: args.index },
21
+ rows: args.rows,
22
+ columns: args.columns,
23
+ },
24
+ },
25
+ ],
26
+ },
27
+ });
28
+ const apiMs = Math.round(performance.now() - apiStart);
29
+ return {
30
+ documentId: args.documentId,
31
+ rows: args.rows,
32
+ columns: args.columns,
33
+ index: args.index,
34
+ _apiMs: apiMs,
35
+ };
36
+ }
@@ -0,0 +1,16 @@
1
+ import { z } from 'zod';
2
+ export declare const UpdateHeaderFooterSchema: z.ZodObject<{
3
+ documentId: z.ZodString;
4
+ section: z.ZodEnum<{
5
+ header: "header";
6
+ footer: "footer";
7
+ }>;
8
+ content: z.ZodOptional<z.ZodString>;
9
+ }, z.core.$strip>;
10
+ export declare function updateHeaderFooter(args: z.infer<typeof UpdateHeaderFooterSchema>): Promise<{
11
+ documentId: string;
12
+ section: "header" | "footer";
13
+ created: boolean;
14
+ contentInserted: boolean;
15
+ _apiMs: number;
16
+ }>;
@@ -0,0 +1,103 @@
1
+ import { google } from 'googleapis';
2
+ import { z } from 'zod';
3
+ import { getAuthClient } from '../auth/google-auth.js';
4
+ export const UpdateHeaderFooterSchema = z.object({
5
+ documentId: z.string().describe('The Google Doc document ID'),
6
+ section: z.enum(['header', 'footer']).describe('Which section to update: "header" or "footer"'),
7
+ content: z.string().optional().describe('Text content to insert. Replaces existing content. Use {{pageNumber}} to insert a page number field.'),
8
+ });
9
+ export async function updateHeaderFooter(args) {
10
+ const auth = getAuthClient();
11
+ const docs = google.docs({ version: 'v1', auth });
12
+ const apiStart = performance.now();
13
+ // Read document to check if header/footer exists
14
+ const doc = await docs.documents.get({ documentId: args.documentId });
15
+ const isHeader = args.section === 'header';
16
+ const existingId = isHeader
17
+ ? doc.data.headers && Object.keys(doc.data.headers)[0]
18
+ : doc.data.footers && Object.keys(doc.data.footers)[0];
19
+ const requests = [];
20
+ // Create header/footer if it doesn't exist
21
+ if (!existingId) {
22
+ requests.push(isHeader
23
+ ? { createHeader: { type: 'DEFAULT', sectionBreakLocation: { index: 0 } } }
24
+ : { createFooter: { type: 'DEFAULT', sectionBreakLocation: { index: 0 } } });
25
+ // Need to execute first to get the ID, then insert content
26
+ if (args.content) {
27
+ await docs.documents.batchUpdate({
28
+ documentId: args.documentId,
29
+ requestBody: { requests },
30
+ });
31
+ // Re-read to get the new ID
32
+ const updated = await docs.documents.get({ documentId: args.documentId });
33
+ const newId = isHeader
34
+ ? Object.keys(updated.data.headers || {})[0]
35
+ : Object.keys(updated.data.footers || {})[0];
36
+ if (newId && args.content) {
37
+ await insertContent(docs, args.documentId, newId, args.content, isHeader, updated.data);
38
+ }
39
+ const apiMs = Math.round(performance.now() - apiStart);
40
+ return {
41
+ documentId: args.documentId,
42
+ section: args.section,
43
+ created: true,
44
+ contentInserted: !!args.content,
45
+ _apiMs: apiMs,
46
+ };
47
+ }
48
+ await docs.documents.batchUpdate({
49
+ documentId: args.documentId,
50
+ requestBody: { requests },
51
+ });
52
+ const apiMs = Math.round(performance.now() - apiStart);
53
+ return {
54
+ documentId: args.documentId,
55
+ section: args.section,
56
+ created: true,
57
+ contentInserted: false,
58
+ _apiMs: apiMs,
59
+ };
60
+ }
61
+ // Header/footer exists — update content if provided
62
+ if (args.content) {
63
+ await insertContent(docs, args.documentId, existingId, args.content, isHeader, doc.data);
64
+ }
65
+ const apiMs = Math.round(performance.now() - apiStart);
66
+ return {
67
+ documentId: args.documentId,
68
+ section: args.section,
69
+ created: false,
70
+ contentInserted: !!args.content,
71
+ _apiMs: apiMs,
72
+ };
73
+ }
74
+ async function insertContent(docs, documentId, sectionId, content, isHeader, docData) {
75
+ const sectionData = isHeader
76
+ ? docData.headers?.[sectionId]
77
+ : docData.footers?.[sectionId];
78
+ if (!sectionData?.content)
79
+ return;
80
+ const sectionContent = sectionData.content;
81
+ const startIndex = sectionContent[0]?.startIndex || 0;
82
+ const endIndex = sectionContent[sectionContent.length - 1]?.endIndex || startIndex + 1;
83
+ const requests = [];
84
+ // Delete existing content
85
+ if (endIndex > startIndex + 1) {
86
+ requests.push({
87
+ deleteContentRange: {
88
+ range: { segmentId: sectionId, startIndex: startIndex, endIndex: endIndex - 1 },
89
+ },
90
+ });
91
+ }
92
+ // Insert new content
93
+ requests.push({
94
+ insertText: {
95
+ location: { segmentId: sectionId, index: startIndex },
96
+ text: content.replace('{{pageNumber}}', ''),
97
+ },
98
+ });
99
+ await docs.documents.batchUpdate({
100
+ documentId,
101
+ requestBody: { requests },
102
+ });
103
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdocs-mcp",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Open-source MCP server for Google Docs and Sheets. Self-hosted, local OAuth, no third-party token storage.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",