gdocs-mcp 0.4.0 → 0.4.1

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, search, and style 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, style, export, and collaborate on 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,9 +46,9 @@ 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 (16)
49
+ ## Tools (28)
50
50
 
51
- ### Google Docs
51
+ ### Google Docs — Read & Write
52
52
 
53
53
  | Tool | Description |
54
54
  |------|-------------|
@@ -63,11 +63,29 @@ claude mcp add gdocs -- npx gdocs-mcp
63
63
  | `update_document` | Raw batchUpdate with 35+ request types |
64
64
  | `unmerge_table_cells` | Unmerge previously merged table cells |
65
65
 
66
- ### Google Sheets
66
+ ### Content Insertion
67
67
 
68
68
  | Tool | Description |
69
69
  |------|-------------|
70
- | `get_charts` | List all charts in a spreadsheet with IDs and specs |
70
+ | `insert_image` | Insert an image from a URL with optional width/height sizing |
71
+ | `insert_table` | Create a table with specified rows (1-20) and columns (1-20) |
72
+ | `insert_page_break` | Insert a page break at a specific position |
73
+ | `insert_link` | Add a hyperlink to an existing text range |
74
+
75
+ ### Document Management
76
+
77
+ | Tool | Description |
78
+ |------|-------------|
79
+ | `export_document` | Export as PDF, DOCX, TXT, or HTML. Save to file or return base64 |
80
+ | `copy_document` | Duplicate a doc with a new title. Great for templates |
81
+ | `delete_document` | Move to trash. Requires exact title confirmation to prevent accidents |
82
+
83
+ ### Comments
84
+
85
+ | Tool | Description |
86
+ |------|-------------|
87
+ | `get_comments` | List comments with author, content, resolved status, and replies |
88
+ | `add_comment` | Add a comment anchored to specific text in the document |
71
89
 
72
90
  ### Style Presets
73
91
 
@@ -81,7 +99,28 @@ Define how your documents look once, apply everywhere.
81
99
  | `set_active_preset` | Set the default preset for new documents |
82
100
  | `delete_style_preset` | Delete a custom preset |
83
101
 
84
- #### Built-in Presets
102
+ ### Formatting
103
+
104
+ | Tool | Description |
105
+ |------|-------------|
106
+ | `update_header_footer` | Create or update header/footer content and styling |
107
+ | `format_list` | Apply bullet, numbered, or remove list formatting (6 glyph presets) |
108
+
109
+ ### Google Sheets
110
+
111
+ | Tool | Description |
112
+ |------|-------------|
113
+ | `get_charts` | List all charts in a spreadsheet with IDs and specs |
114
+
115
+ ### Automation
116
+
117
+ | Tool | Description |
118
+ |------|-------------|
119
+ | `execute_script` | Run a function in a deployed Google Apps Script project |
120
+
121
+ ## Style Presets
122
+
123
+ ### Built-in Presets
85
124
 
86
125
  | Preset | Font | Headings | Body |
87
126
  |--------|------|----------|------|
@@ -90,9 +129,9 @@ Define how your documents look once, apply everywhere.
90
129
  | `classic` | Georgia + Garamond | Dark blue, serif | 11pt, 1.4x spacing |
91
130
  | `minimal` | Roboto | Black, light weight | 10.5pt, 1.5x spacing |
92
131
 
93
- #### Extract + Apply Workflow
132
+ ### Extract + Apply Workflow
94
133
 
95
- The fastest way to use presets: point to a Google Doc that already looks the way you want.
134
+ Point to a Google Doc that already looks the way you want:
96
135
 
97
136
  ```
98
137
  You: "Extract styles from my brand doc and save as 'brand'"
@@ -104,19 +143,41 @@ You: "Apply brand styles to this report"
104
143
 
105
144
  One-step capture, one-step apply. No JSON editing needed.
106
145
 
107
- #### Style Properties
146
+ ### Style Properties
108
147
 
109
148
  Presets support every text and paragraph property the Google Docs API exposes:
110
149
 
111
150
  **Text:** font family, font size, bold, italic, underline, strikethrough, small caps, text color, background color, baseline offset
112
151
 
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)
152
+ **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
114
153
 
115
- **Document:** page margins (top/bottom/left/right), page size (width/height)
154
+ **Document:** page margins, page size
116
155
 
117
156
  **Tables:** header row background and text color, bold headers, border color and width, cell padding, alternating row backgrounds
118
157
 
119
- Only properties you specify are applied. Omitted properties are left unchanged. Config is stored at `~/.gdocs-mcp/styles.json`.
158
+ Only properties you specify are applied. Omitted properties are left unchanged. Config stored at `~/.gdocs-mcp/styles.json`.
159
+
160
+ ## Upgrading to v0.4
161
+
162
+ v0.4 expands the Drive scope from `drive.readonly` to `drive` (needed for export, copy, delete, and comments). You must re-authenticate:
163
+
164
+ ```bash
165
+ npx gdocs-mcp auth
166
+ ```
167
+
168
+ If you don't need the new tools and want to keep the old scope:
169
+
170
+ ```bash
171
+ GDOCS_MCP_READONLY=1 npx gdocs-mcp
172
+ ```
173
+
174
+ ### Scope Justification
175
+
176
+ | Scope | Tools |
177
+ |-------|-------|
178
+ | `documents` | read, create, replace, update, insert image/table/link/page break, style presets, header/footer, format list |
179
+ | `spreadsheets.readonly` | get_charts |
180
+ | `drive` | search, export, copy, delete, get/add comments |
120
181
 
121
182
  ## Limitations
122
183
 
@@ -126,8 +187,22 @@ These are Google Docs API limitations, not gdocs-mcp limitations:
126
187
  - **Custom named styles** cannot be created — only the 9 built-in types (Title, Subtitle, Heading 1-6, Normal Text)
127
188
  - **Conditional formatting** does not exist in Google Docs
128
189
  - **Multi-column layout** is not exposed via the API
129
- - **Bullet/numbered list glyph types** are per-paragraph, not configurable via style presets
130
190
  - **Table header detection** assumes row 0 is the header — multi-row headers are not detected
191
+ - **Suggested edits** (propose mode) are not yet supported
192
+ - **Comment replies** are not yet supported — only top-level comments
193
+ - **Image URLs** are passed directly to Google's API — the MCP server does not fetch them
194
+
195
+ ## Performance Logging
196
+
197
+ Every tool call logs latency to stderr:
198
+
199
+ ```
200
+ [gdocs-mcp] read_document: 342ms (api: 298ms)
201
+ [gdocs-mcp] apply_style_preset: 1247ms (api: 1102ms, 1593 requests)
202
+ [gdocs-mcp] list_style_presets: 2ms (local)
203
+ ```
204
+
205
+ Disable with `GDOCS_MCP_QUIET=1`.
131
206
 
132
207
  ## Security
133
208
 
@@ -136,16 +211,25 @@ These are Google Docs API limitations, not gdocs-mcp limitations:
136
211
  - Style presets stored at `~/.gdocs-mcp/styles.json`
137
212
  - No telemetry, no data collection, no third-party token storage
138
213
  - Tokens refresh automatically; if refresh fails, run `npx gdocs-mcp auth` again
214
+ - `delete_document` requires exact title match — not a boolean flag
139
215
 
140
216
  ## Configuration
141
217
 
142
- Credentials and tokens are stored in `~/.gdocs-mcp/` by default. Override credentials path with:
218
+ Credentials and tokens stored in `~/.gdocs-mcp/` by default. Override with:
143
219
 
144
220
  ```bash
145
221
  GDOCS_CREDENTIALS=/path/to/credentials.json npx gdocs-mcp
146
222
  ```
147
223
 
148
- For faster startup, install globally instead of using npx:
224
+ Environment variables:
225
+
226
+ | Variable | Effect |
227
+ |----------|--------|
228
+ | `GDOCS_CREDENTIALS` | Override credentials.json path |
229
+ | `GDOCS_MCP_READONLY` | Set to `1` to disable write tools (export, copy, delete, comments) |
230
+ | `GDOCS_MCP_QUIET` | Set to `1` to disable performance logging |
231
+
232
+ For faster startup, install globally:
149
233
 
150
234
  ```bash
151
235
  npm install -g gdocs-mcp
@@ -8,5 +8,7 @@ export declare function extractDocumentStyles(args: z.infer<typeof ExtractDocume
8
8
  documentId: string;
9
9
  title: string | null | undefined;
10
10
  preset: StylePreset;
11
+ warnings: string[] | undefined;
11
12
  savedAs: string | null;
13
+ _apiMs: number;
12
14
  }>;
@@ -10,11 +10,23 @@ export const ExtractDocumentStylesSchema = z.object({
10
10
  export async function extractDocumentStyles(args) {
11
11
  const auth = getAuthClient();
12
12
  const docs = google.docs({ version: 'v1', auth });
13
+ const apiStart = performance.now();
13
14
  const doc = await docs.documents.get({ documentId: args.documentId });
15
+ const bodyContent = doc.data.body?.content || [];
16
+ // Step 1: Extract named style definitions (baseline)
17
+ const namedStyleDefs = extractNamedStyleDefinitions(doc.data.namedStyles);
18
+ // Step 2: Extract actual rendered styles from paragraphs (majority vote)
19
+ const renderedStyles = extractRenderedStyles(bodyContent);
20
+ // Step 3: Merge — rendered values take priority, collect warnings
21
+ const warnings = [];
22
+ const mergedStyles = mergeStyles(namedStyleDefs, renderedStyles, warnings);
23
+ // Step 4: Propagate inherited properties from NORMAL_TEXT to headings
24
+ propagateInheritedProperties(mergedStyles);
25
+ const apiMs = Math.round(performance.now() - apiStart);
14
26
  const preset = {
15
27
  document: extractDocumentStyle(doc.data.documentStyle),
16
- styles: extractNamedStyles(doc.data.namedStyles),
17
- table: extractTableStyles(doc.data.body?.content || []),
28
+ styles: mergedStyles,
29
+ table: extractTableStyles(bodyContent),
18
30
  };
19
31
  if (args.saveAs) {
20
32
  savePreset(args.saveAs, preset);
@@ -23,7 +35,9 @@ export async function extractDocumentStyles(args) {
23
35
  documentId: args.documentId,
24
36
  title: doc.data.title,
25
37
  preset,
38
+ warnings: warnings.length > 0 ? warnings : undefined,
26
39
  savedAs: args.saveAs || null,
40
+ _apiMs: apiMs,
27
41
  };
28
42
  }
29
43
  function extractDocumentStyle(docStyle) {
@@ -42,7 +56,51 @@ function extractDocumentStyle(docStyle) {
42
56
  config.pageHeight = docStyle.pageSize.height.magnitude;
43
57
  return config;
44
58
  }
45
- function extractNamedStyles(namedStyles) {
59
+ function extractStyleFromRaw(ts, ps) {
60
+ const config = {};
61
+ if (ts.weightedFontFamily?.fontFamily)
62
+ config.fontFamily = ts.weightedFontFamily.fontFamily;
63
+ if (ts.fontSize?.magnitude !== undefined)
64
+ config.fontSize = ts.fontSize.magnitude;
65
+ if (ts.bold !== undefined)
66
+ config.bold = ts.bold;
67
+ if (ts.italic !== undefined)
68
+ config.italic = ts.italic;
69
+ if (ts.underline !== undefined)
70
+ config.underline = ts.underline;
71
+ if (ts.strikethrough !== undefined)
72
+ config.strikethrough = ts.strikethrough;
73
+ if (ts.smallCaps !== undefined)
74
+ config.smallCaps = ts.smallCaps;
75
+ if (ts.foregroundColor?.color?.rgbColor)
76
+ config.color = rgbToHex(ts.foregroundColor.color.rgbColor);
77
+ if (ts.backgroundColor?.color?.rgbColor)
78
+ config.backgroundColor = rgbToHex(ts.backgroundColor.color.rgbColor);
79
+ if (ts.baselineOffset)
80
+ config.baselineOffset = ts.baselineOffset;
81
+ if (ps.alignment)
82
+ config.alignment = ps.alignment;
83
+ if (ps.lineSpacing !== undefined)
84
+ config.lineSpacing = ps.lineSpacing;
85
+ if (ps.spaceAbove?.magnitude !== undefined)
86
+ config.spaceAbove = ps.spaceAbove.magnitude;
87
+ if (ps.spaceBelow?.magnitude !== undefined)
88
+ config.spaceBelow = ps.spaceBelow.magnitude;
89
+ if (ps.indentFirstLine?.magnitude !== undefined)
90
+ config.indentFirstLine = ps.indentFirstLine.magnitude;
91
+ if (ps.indentStart?.magnitude !== undefined)
92
+ config.indentStart = ps.indentStart.magnitude;
93
+ if (ps.indentEnd?.magnitude !== undefined)
94
+ config.indentEnd = ps.indentEnd.magnitude;
95
+ if (ps.keepLinesTogether !== undefined)
96
+ config.keepLinesTogether = ps.keepLinesTogether;
97
+ if (ps.keepWithNext !== undefined)
98
+ config.keepWithNext = ps.keepWithNext;
99
+ if (ps.direction)
100
+ config.direction = ps.direction;
101
+ return config;
102
+ }
103
+ function extractNamedStyleDefinitions(namedStyles) {
46
104
  const result = {};
47
105
  if (!namedStyles?.styles)
48
106
  return result;
@@ -50,59 +108,122 @@ function extractNamedStyles(namedStyles) {
50
108
  const type = style.namedStyleType;
51
109
  if (!VALID_NAMED_STYLE_TYPES.includes(type))
52
110
  continue;
53
- const config = {};
54
- const ts = style.textStyle || {};
55
- const ps = style.paragraphStyle || {};
56
- // Text style
57
- if (ts.weightedFontFamily?.fontFamily)
58
- config.fontFamily = ts.weightedFontFamily.fontFamily;
59
- if (ts.fontSize?.magnitude !== undefined)
60
- config.fontSize = ts.fontSize.magnitude;
61
- if (ts.bold !== undefined)
62
- config.bold = ts.bold;
63
- if (ts.italic !== undefined)
64
- config.italic = ts.italic;
65
- if (ts.underline !== undefined)
66
- config.underline = ts.underline;
67
- if (ts.strikethrough !== undefined)
68
- config.strikethrough = ts.strikethrough;
69
- if (ts.smallCaps !== undefined)
70
- config.smallCaps = ts.smallCaps;
71
- if (ts.foregroundColor?.color?.rgbColor)
72
- config.color = rgbToHex(ts.foregroundColor.color.rgbColor);
73
- if (ts.backgroundColor?.color?.rgbColor)
74
- config.backgroundColor = rgbToHex(ts.backgroundColor.color.rgbColor);
75
- if (ts.baselineOffset)
76
- config.baselineOffset = ts.baselineOffset;
77
- // Paragraph style
78
- if (ps.alignment)
79
- config.alignment = ps.alignment;
80
- if (ps.lineSpacing !== undefined)
81
- config.lineSpacing = ps.lineSpacing;
82
- if (ps.spaceAbove?.magnitude !== undefined)
83
- config.spaceAbove = ps.spaceAbove.magnitude;
84
- if (ps.spaceBelow?.magnitude !== undefined)
85
- config.spaceBelow = ps.spaceBelow.magnitude;
86
- if (ps.indentFirstLine?.magnitude !== undefined)
87
- config.indentFirstLine = ps.indentFirstLine.magnitude;
88
- if (ps.indentStart?.magnitude !== undefined)
89
- config.indentStart = ps.indentStart.magnitude;
90
- if (ps.indentEnd?.magnitude !== undefined)
91
- config.indentEnd = ps.indentEnd.magnitude;
92
- if (ps.keepLinesTogether !== undefined)
93
- config.keepLinesTogether = ps.keepLinesTogether;
94
- if (ps.keepWithNext !== undefined)
95
- config.keepWithNext = ps.keepWithNext;
96
- if (ps.direction)
97
- config.direction = ps.direction;
111
+ const config = extractStyleFromRaw(style.textStyle || {}, style.paragraphStyle || {});
98
112
  if (Object.keys(config).length > 0) {
99
113
  result[type] = config;
100
114
  }
101
115
  }
102
116
  return result;
103
117
  }
118
+ // Majority vote: returns the most common value for a property across samples
119
+ function majorityVote(values) {
120
+ const counts = new Map();
121
+ for (const v of values) {
122
+ if (v === undefined)
123
+ continue;
124
+ const key = JSON.stringify(v);
125
+ const existing = counts.get(key);
126
+ if (existing) {
127
+ existing.count++;
128
+ }
129
+ else {
130
+ counts.set(key, { value: v, count: 1 });
131
+ }
132
+ }
133
+ let winner = undefined;
134
+ let maxCount = 0;
135
+ for (const { value, count } of counts.values()) {
136
+ if (count > maxCount) {
137
+ maxCount = count;
138
+ winner = value;
139
+ }
140
+ }
141
+ return winner;
142
+ }
143
+ function extractRenderedStyles(content) {
144
+ const result = {};
145
+ // Collect up to 10 paragraphs per style type
146
+ const samplesByType = {};
147
+ for (const element of content) {
148
+ if (!element.paragraph)
149
+ continue;
150
+ const styleType = element.paragraph.paragraphStyle?.namedStyleType;
151
+ if (!styleType || !VALID_NAMED_STYLE_TYPES.includes(styleType))
152
+ continue;
153
+ const text = element.paragraph.elements
154
+ ?.map((el) => el.textRun?.content || '').join('').trim();
155
+ if (!text)
156
+ continue;
157
+ if (!samplesByType[styleType])
158
+ samplesByType[styleType] = [];
159
+ if (samplesByType[styleType].length >= 10)
160
+ continue;
161
+ // Read paragraph-level text style from first text run
162
+ const firstRunTextStyle = element.paragraph.elements?.[0]?.textRun?.textStyle || {};
163
+ const paragraphStyle = element.paragraph.paragraphStyle || {};
164
+ const config = extractStyleFromRaw(firstRunTextStyle, paragraphStyle);
165
+ samplesByType[styleType].push(config);
166
+ }
167
+ // Majority vote per property across sampled paragraphs
168
+ const allProperties = [
169
+ 'fontFamily', 'fontSize', 'bold', 'italic', 'underline', 'strikethrough',
170
+ 'smallCaps', 'color', 'backgroundColor', 'baselineOffset',
171
+ 'alignment', 'lineSpacing', 'spaceAbove', 'spaceBelow',
172
+ 'indentFirstLine', 'indentStart', 'indentEnd',
173
+ 'keepLinesTogether', 'keepWithNext', 'direction',
174
+ ];
175
+ for (const [styleType, samples] of Object.entries(samplesByType)) {
176
+ const merged = {};
177
+ for (const prop of allProperties) {
178
+ const values = samples.map(s => s[prop]);
179
+ const winner = majorityVote(values);
180
+ if (winner !== undefined) {
181
+ ;
182
+ merged[prop] = winner;
183
+ }
184
+ }
185
+ if (Object.keys(merged).length > 0) {
186
+ result[styleType] = merged;
187
+ }
188
+ }
189
+ return result;
190
+ }
191
+ function mergeStyles(definitions, rendered, warnings) {
192
+ const merged = {};
193
+ const allTypes = new Set([...Object.keys(definitions), ...Object.keys(rendered)]);
194
+ for (const type of allTypes) {
195
+ const def = definitions[type] || {};
196
+ const ren = rendered[type] || {};
197
+ // Check for differences and log warnings
198
+ for (const [key, renValue] of Object.entries(ren)) {
199
+ const defValue = def[key];
200
+ if (defValue !== undefined && renValue !== undefined && JSON.stringify(defValue) !== JSON.stringify(renValue)) {
201
+ warnings.push(`${type}.${key}: definition says ${JSON.stringify(defValue)}, rendered shows ${JSON.stringify(renValue)} (using rendered)`);
202
+ }
203
+ }
204
+ // Rendered values override definitions
205
+ merged[type] = { ...def, ...ren };
206
+ }
207
+ return merged;
208
+ }
209
+ // Propagate fontFamily, color, bold, italic from NORMAL_TEXT to headings that don't specify them
210
+ function propagateInheritedProperties(styles) {
211
+ const normalText = styles['NORMAL_TEXT'];
212
+ if (!normalText)
213
+ return;
214
+ const propertiesToPropagate = ['fontFamily', 'color', 'bold', 'italic'];
215
+ for (const [type, config] of Object.entries(styles)) {
216
+ if (type === 'NORMAL_TEXT')
217
+ continue;
218
+ for (const prop of propertiesToPropagate) {
219
+ if (config[prop] === undefined && normalText[prop] !== undefined) {
220
+ ;
221
+ config[prop] = normalText[prop];
222
+ }
223
+ }
224
+ }
225
+ }
104
226
  function extractTableStyles(content) {
105
- // Find first table and extract its styling as the template
106
227
  for (const element of content) {
107
228
  if (!element.table)
108
229
  continue;
@@ -127,7 +248,17 @@ function extractTableStyles(content) {
127
248
  config.cellPadding = cs.paddingTop.magnitude;
128
249
  }
129
250
  }
130
- // Check second row for alternating background
251
+ // Check header text style
252
+ const headerTextRun = firstRow.tableCells?.[0]?.content?.[0]?.paragraph?.elements?.[0]?.textRun;
253
+ if (headerTextRun?.textStyle) {
254
+ const hts = headerTextRun.textStyle;
255
+ if (hts.bold !== undefined)
256
+ config.headerBold = hts.bold;
257
+ if (hts.foregroundColor?.color?.rgbColor) {
258
+ config.headerTextColor = rgbToHex(hts.foregroundColor.color.rgbColor);
259
+ }
260
+ }
261
+ // Check third row for alternating background
131
262
  if (table.tableRows?.length > 2) {
132
263
  const thirdRow = table.tableRows[2];
133
264
  const thirdCell = thirdRow?.tableCells?.[0];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdocs-mcp",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
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",