gdocs-mcp 0.5.0 → 0.5.2

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/dist/index.js CHANGED
@@ -36,7 +36,8 @@ import { InsertTableRowSchema, insertTableRow, DeleteTableRowSchema, deleteTable
36
36
  import { DeleteHeaderFooterSchema, deleteHeaderFooter } from './tools/delete-header-footer.js';
37
37
  import { CreateFootnoteSchema, createFootnote } from './tools/create-footnote.js';
38
38
  import { WriteTableSchema, writeTable } from './tools/write-table.js';
39
- const VERSION = '0.5.0';
39
+ import { UpdateTableStyleSchema, updateTableStyle } from './tools/update-table-style.js';
40
+ const VERSION = '0.5.2';
40
41
  const QUIET = process.env.GDOCS_MCP_QUIET === '1';
41
42
  const server = new McpServer({
42
43
  name: 'gdocs-mcp',
@@ -45,7 +46,7 @@ const server = new McpServer({
45
46
  function formatError(err) {
46
47
  if (err instanceof Error) {
47
48
  const message = err.message;
48
- if (message.includes('not found') || message.includes('404')) {
49
+ if (message.includes('404') || (message.includes('not found') && !message.includes('Preset') && !message.includes('preset'))) {
49
50
  return 'Document not found. Check the document ID and ensure it is shared with your account.';
50
51
  }
51
52
  if (message.includes('UNAUTHENTICATED') || message.includes('invalid_grant')) {
@@ -135,7 +136,7 @@ registerTool('add_comment', 'Add a comment anchored to specific text in the docu
135
136
  // v0.4.2 structure tool
136
137
  registerTool('read_document_structure', 'Return the structural outline of a Google Doc: paragraphs with indices, heading levels, text previews, tables with row/col counts, list metadata. Lightweight map for targeted edits.', ReadDocumentStructureSchema, readDocumentStructure);
137
138
  // v0.5 gap closure tools
138
- registerTool('insert_text', 'Insert text at a specific index or append to end of document.', InsertTextSchema, insertText);
139
+ registerTool('insert_text', 'Insert text at a specific index or append to end of document. Resets formatting to NORMAL_TEXT defaults by default. Pass resetFormatting: false to inherit formatting from the insertion point.', InsertTextSchema, insertText);
139
140
  registerTool('delete_content_range', 'Delete a range of content. Supports preview mode (dry-run) to see what would be deleted before executing.', DeleteContentRangeSchema, deleteContentRange);
140
141
  registerTool('insert_table_row', 'Insert a row into an existing table. Zero-indexed.', InsertTableRowSchema, insertTableRow);
141
142
  registerTool('delete_table_row', 'Delete a row from an existing table. Zero-indexed.', DeleteTableRowSchema, deleteTableRow);
@@ -145,6 +146,7 @@ registerTool('delete_header_footer', 'Delete a header or footer. Supports previe
145
146
  registerTool('create_footnote', 'Create a footnote at a specific position.', CreateFootnoteSchema, createFootnote);
146
147
  // v0.5 differentiator
147
148
  registerTool('write_table', 'Create a fully populated, styled table in one call. Inserts table, fills cells, applies header styling, sets content-aware column widths. Not safe for concurrent editing.', WriteTableSchema, writeTable);
149
+ registerTool('update_table_style', 'Identify and style a table by context (nearby heading, position, or dimensions) instead of raw index. Supports preset application and individual style overrides. Returns table metadata when no styling params are provided.', UpdateTableStyleSchema, updateTableStyle);
148
150
  async function main() {
149
151
  const transport = new StdioServerTransport();
150
152
  await server.connect(transport);
@@ -1,14 +1,4 @@
1
- function hexToRgb(hex) {
2
- const h = hex.replace('#', '');
3
- return {
4
- red: parseInt(h.substring(0, 2), 16) / 255,
5
- green: parseInt(h.substring(2, 4), 16) / 255,
6
- blue: parseInt(h.substring(4, 6), 16) / 255,
7
- };
8
- }
9
- function toColor(hex) {
10
- return { color: { rgbColor: hexToRgb(hex) } };
11
- }
1
+ import { toColor } from '../utils/color.js';
12
2
  function toDimension(magnitude, unit = 'PT') {
13
3
  return { magnitude, unit };
14
4
  }
@@ -3,6 +3,7 @@ import { z } from 'zod';
3
3
  import { getAuthClient } from '../auth/google-auth.js';
4
4
  import { getPreset } from '../presets/config.js';
5
5
  import { buildDocumentStyleRequest, buildTableStyleRequests } from '../presets/converter.js';
6
+ import { toColor } from '../utils/color.js';
6
7
  export const ApplyStylePresetSchema = z.object({
7
8
  documentId: z.string().describe('The Google Doc document ID'),
8
9
  presetName: z.string().optional().describe('Name of the preset to apply. If omitted, uses the active preset from styles.json.'),
@@ -23,9 +24,20 @@ export async function applyStylePreset(args) {
23
24
  if (paragraphs.length === 0)
24
25
  continue;
25
26
  for (const para of paragraphs) {
26
- const textStyleReq = buildTextStyleRequest(config, para.startIndex, para.endIndex);
27
- if (textStyleReq)
28
- requests.push(textStyleReq);
27
+ if (para.inTable && para.textRuns && para.textRuns.length > 0) {
28
+ for (const run of para.textRuns) {
29
+ if (run.startIndex >= run.endIndex)
30
+ continue;
31
+ const textStyleReq = buildTextStyleRequest(config, run.startIndex, run.endIndex);
32
+ if (textStyleReq)
33
+ requests.push(textStyleReq);
34
+ }
35
+ }
36
+ else {
37
+ const textStyleReq = buildTextStyleRequest(config, para.startIndex, para.endIndex);
38
+ if (textStyleReq)
39
+ requests.push(textStyleReq);
40
+ }
29
41
  const paraStyleReq = buildParagraphStyleRequest(config, para.startIndex, para.endIndex);
30
42
  if (paraStyleReq)
31
43
  requests.push(paraStyleReq);
@@ -58,17 +70,6 @@ export async function applyStylePreset(args) {
58
70
  requestCount: requests.length,
59
71
  };
60
72
  }
61
- function hexToRgb(hex) {
62
- const h = hex.replace('#', '');
63
- return {
64
- red: parseInt(h.substring(0, 2), 16) / 255,
65
- green: parseInt(h.substring(2, 4), 16) / 255,
66
- blue: parseInt(h.substring(4, 6), 16) / 255,
67
- };
68
- }
69
- function toColor(hex) {
70
- return { color: { rgbColor: hexToRgb(hex) } };
71
- }
72
73
  function toDimension(magnitude) {
73
74
  return { magnitude, unit: 'PT' };
74
75
  }
@@ -180,16 +181,41 @@ function buildParagraphStyleRequest(config, startIndex, endIndex) {
180
181
  }
181
182
  function groupParagraphsByStyle(content) {
182
183
  const groups = {};
183
- for (const element of content) {
184
- if (!element.paragraph)
185
- continue;
186
- const styleType = element.paragraph.paragraphStyle?.namedStyleType || 'NORMAL_TEXT';
184
+ const addParagraph = (paragraph, startIndex, endIndex, inTable) => {
185
+ const styleType = paragraph.paragraphStyle?.namedStyleType || 'NORMAL_TEXT';
187
186
  if (!groups[styleType])
188
187
  groups[styleType] = [];
189
- groups[styleType].push({
190
- startIndex: element.startIndex || 0,
191
- endIndex: element.endIndex || 0,
192
- });
188
+ const range = {
189
+ startIndex: startIndex || 0,
190
+ endIndex: endIndex || 0,
191
+ inTable,
192
+ };
193
+ if (inTable) {
194
+ const textRuns = [];
195
+ for (const el of paragraph.elements || []) {
196
+ if (el.textRun && el.startIndex < el.endIndex) {
197
+ textRuns.push({ startIndex: el.startIndex, endIndex: el.endIndex });
198
+ }
199
+ }
200
+ range.textRuns = textRuns;
201
+ }
202
+ groups[styleType].push(range);
203
+ };
204
+ for (const element of content) {
205
+ if (element.paragraph) {
206
+ addParagraph(element.paragraph, element.startIndex, element.endIndex, false);
207
+ }
208
+ if (element.table) {
209
+ for (const row of element.table.tableRows || []) {
210
+ for (const cell of row.tableCells || []) {
211
+ for (const cellElement of cell.content || []) {
212
+ if (cellElement.paragraph) {
213
+ addParagraph(cellElement.paragraph, cellElement.startIndex, cellElement.endIndex, true);
214
+ }
215
+ }
216
+ }
217
+ }
218
+ }
193
219
  }
194
220
  return groups;
195
221
  }
@@ -4,6 +4,14 @@ export declare const InsertTextSchema: z.ZodObject<{
4
4
  text: z.ZodString;
5
5
  index: z.ZodOptional<z.ZodNumber>;
6
6
  appendToEnd: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
7
+ resetFormatting: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
8
+ style: z.ZodOptional<z.ZodObject<{
9
+ fontFamily: z.ZodOptional<z.ZodString>;
10
+ fontSize: z.ZodOptional<z.ZodNumber>;
11
+ bold: z.ZodOptional<z.ZodBoolean>;
12
+ italic: z.ZodOptional<z.ZodBoolean>;
13
+ color: z.ZodOptional<z.ZodString>;
14
+ }, z.core.$strip>>;
7
15
  }, z.core.$strip>;
8
16
  export declare function insertText(args: z.infer<typeof InsertTextSchema>): Promise<{
9
17
  documentId: string;
@@ -1,11 +1,21 @@
1
1
  import { google } from 'googleapis';
2
2
  import { z } from 'zod';
3
3
  import { getAuthClient } from '../auth/google-auth.js';
4
+ import { hexToRgb } from '../utils/color.js';
5
+ import { getDefaultTextStyle } from '../utils/defaults.js';
4
6
  export const InsertTextSchema = z.object({
5
7
  documentId: z.string().describe('The Google Doc document ID'),
6
8
  text: z.string().describe('Text to insert'),
7
9
  index: z.number().optional().describe('Position to insert at. Required unless appendToEnd is true.'),
8
10
  appendToEnd: z.boolean().optional().default(false).describe('Append text to the end of the document body. No need to know the last index.'),
11
+ resetFormatting: z.boolean().optional().default(true).describe('Reset text formatting to NORMAL_TEXT defaults after insertion. Set to false to inherit formatting from the insertion point.'),
12
+ style: z.object({
13
+ fontFamily: z.string().optional(),
14
+ fontSize: z.number().optional(),
15
+ bold: z.boolean().optional(),
16
+ italic: z.boolean().optional(),
17
+ color: z.string().optional(),
18
+ }).optional().describe('Explicit style overrides. Each property overrides the preset default. Only applies when resetFormatting is true.'),
9
19
  });
10
20
  export async function insertText(args) {
11
21
  const auth = getAuthClient();
@@ -21,11 +31,34 @@ export async function insertText(args) {
21
31
  if (insertIndex === undefined) {
22
32
  throw new Error('Either index or appendToEnd: true is required.');
23
33
  }
34
+ const requests = [
35
+ { insertText: { location: { index: insertIndex }, text: args.text } },
36
+ ];
37
+ if (args.resetFormatting !== false) {
38
+ const defaults = getDefaultTextStyle();
39
+ const explicitStyle = args.style ?? {};
40
+ const filtered = Object.fromEntries(Object.entries(explicitStyle).filter(([, v]) => v !== undefined));
41
+ const merged = { ...defaults, ...filtered };
42
+ requests.push({
43
+ updateTextStyle: {
44
+ range: {
45
+ startIndex: insertIndex,
46
+ endIndex: insertIndex + args.text.length,
47
+ },
48
+ textStyle: {
49
+ weightedFontFamily: { fontFamily: merged.fontFamily },
50
+ fontSize: { magnitude: merged.fontSize, unit: 'PT' },
51
+ bold: merged.bold,
52
+ italic: merged.italic,
53
+ foregroundColor: { color: { rgbColor: hexToRgb(merged.color) } },
54
+ },
55
+ fields: 'weightedFontFamily,fontSize,bold,italic,foregroundColor',
56
+ },
57
+ });
58
+ }
24
59
  await docs.documents.batchUpdate({
25
60
  documentId: args.documentId,
26
- requestBody: {
27
- requests: [{ insertText: { location: { index: insertIndex }, text: args.text } }],
28
- },
61
+ requestBody: { requests },
29
62
  });
30
63
  const apiMs = Math.round(performance.now() - apiStart);
31
64
  return {
@@ -0,0 +1,60 @@
1
+ import { z } from 'zod';
2
+ export declare const UpdateTableStyleSchema: z.ZodObject<{
3
+ documentId: z.ZodString;
4
+ nearHeading: z.ZodOptional<z.ZodString>;
5
+ tableIndex: z.ZodOptional<z.ZodNumber>;
6
+ dimensions: z.ZodOptional<z.ZodObject<{
7
+ rows: z.ZodNumber;
8
+ columns: z.ZodNumber;
9
+ }, z.core.$strip>>;
10
+ preset: z.ZodOptional<z.ZodString>;
11
+ headerBackground: z.ZodOptional<z.ZodString>;
12
+ headerTextColor: z.ZodOptional<z.ZodString>;
13
+ borderColor: z.ZodOptional<z.ZodString>;
14
+ borderWidth: z.ZodOptional<z.ZodNumber>;
15
+ alternatingRowColor: z.ZodOptional<z.ZodString>;
16
+ cellPadding: z.ZodOptional<z.ZodNumber>;
17
+ }, z.core.$strip>;
18
+ export declare function updateTableStyle(args: z.infer<typeof UpdateTableStyleSchema>): Promise<{
19
+ documentId: string;
20
+ table: {
21
+ startIndex: number;
22
+ rows: number;
23
+ cols: number;
24
+ nearestHeading: string | null;
25
+ tablePosition: number;
26
+ };
27
+ mode: string;
28
+ _apiMs: number;
29
+ applied?: undefined;
30
+ reason?: undefined;
31
+ requestCount?: undefined;
32
+ } | {
33
+ documentId: string;
34
+ table: {
35
+ startIndex: number;
36
+ rows: number;
37
+ cols: number;
38
+ nearestHeading: string | null;
39
+ tablePosition: number;
40
+ };
41
+ applied: boolean;
42
+ reason: string;
43
+ _apiMs: number;
44
+ mode?: undefined;
45
+ requestCount?: undefined;
46
+ } | {
47
+ documentId: string;
48
+ table: {
49
+ startIndex: number;
50
+ rows: number;
51
+ cols: number;
52
+ nearestHeading: string | null;
53
+ tablePosition: number;
54
+ };
55
+ applied: boolean;
56
+ requestCount: number;
57
+ _apiMs: number;
58
+ mode?: undefined;
59
+ reason?: undefined;
60
+ }>;
@@ -0,0 +1,204 @@
1
+ import { google } from 'googleapis';
2
+ import { z } from 'zod';
3
+ import { getAuthClient } from '../auth/google-auth.js';
4
+ import { getPreset } from '../presets/config.js';
5
+ import { buildTableStyleRequests } from '../presets/converter.js';
6
+ export const UpdateTableStyleSchema = z.object({
7
+ documentId: z.string().describe('The Google Doc document ID'),
8
+ // Identification (at least one required — validated in handler)
9
+ nearHeading: z.string().optional().describe('Find the first table after a heading containing this text (case-insensitive substring match). Only top-level body headings count.'),
10
+ tableIndex: z.number().optional().describe('Select the nth table in the document (0-indexed).'),
11
+ dimensions: z.object({
12
+ rows: z.number(),
13
+ columns: z.number(),
14
+ }).optional().describe('Match a table by exact row and column count.'),
15
+ // Styling (all optional)
16
+ preset: z.string().optional().describe('Apply a named style preset\'s table styles.'),
17
+ headerBackground: z.string().optional().describe('Hex color for header row background.'),
18
+ headerTextColor: z.string().optional().describe('Hex color for header text.'),
19
+ borderColor: z.string().optional().describe('Hex color for all cell borders.'),
20
+ borderWidth: z.number().optional().describe('Border width in PT.'),
21
+ alternatingRowColor: z.string().optional().describe('Hex color for alternating (even) row backgrounds.'),
22
+ cellPadding: z.number().optional().describe('Cell padding in PT.'),
23
+ });
24
+ function extractHeadingText(paragraph) {
25
+ const styleType = paragraph.paragraphStyle?.namedStyleType || '';
26
+ const isHeading = styleType.startsWith('HEADING_') || styleType === 'TITLE' || styleType === 'SUBTITLE';
27
+ if (!isHeading)
28
+ return null;
29
+ const parts = [];
30
+ for (const el of paragraph.elements || []) {
31
+ if (el.textRun?.content) {
32
+ parts.push(el.textRun.content);
33
+ }
34
+ }
35
+ return parts.join('').trim() || null;
36
+ }
37
+ function buildTableCandidates(content) {
38
+ const candidates = [];
39
+ let lastHeadingText = null;
40
+ let tablePosition = 0;
41
+ for (const element of content) {
42
+ if (element.paragraph) {
43
+ const headingText = extractHeadingText(element.paragraph);
44
+ if (headingText !== null) {
45
+ lastHeadingText = headingText;
46
+ }
47
+ }
48
+ if (element.table) {
49
+ candidates.push({
50
+ startIndex: element.startIndex,
51
+ rows: element.table.rows || 0,
52
+ cols: element.table.columns || 0,
53
+ nearestHeading: lastHeadingText,
54
+ tablePosition,
55
+ });
56
+ tablePosition++;
57
+ }
58
+ }
59
+ return candidates;
60
+ }
61
+ function formatCandidateList(candidates) {
62
+ return candidates
63
+ .map(c => ` [${c.tablePosition}] ${c.rows}x${c.cols} at index ${c.startIndex}${c.nearestHeading ? ` (after heading: "${c.nearestHeading}")` : ''}`)
64
+ .join('\n');
65
+ }
66
+ export async function updateTableStyle(args) {
67
+ // Validate that at least one identification param is provided
68
+ if (args.nearHeading === undefined && args.tableIndex === undefined && args.dimensions === undefined) {
69
+ throw new Error('At least one identification parameter required: nearHeading, tableIndex, or dimensions');
70
+ }
71
+ const auth = getAuthClient();
72
+ const docs = google.docs({ version: 'v1', auth });
73
+ const apiStart = performance.now();
74
+ const doc = await docs.documents.get({ documentId: args.documentId });
75
+ const content = doc.data.body?.content || [];
76
+ const allCandidates = buildTableCandidates(content);
77
+ if (allCandidates.length === 0) {
78
+ throw new Error('Document contains no tables.');
79
+ }
80
+ // Filter by identification params (intersect all provided)
81
+ let filtered = [...allCandidates];
82
+ if (args.nearHeading !== undefined) {
83
+ const needle = args.nearHeading.toLowerCase();
84
+ // Find the last heading that contains the search text
85
+ let matchedHeading = null;
86
+ for (const candidate of allCandidates) {
87
+ if (candidate.nearestHeading && candidate.nearestHeading.toLowerCase().includes(needle)) {
88
+ matchedHeading = candidate.nearestHeading;
89
+ }
90
+ }
91
+ if (matchedHeading === null) {
92
+ throw new Error(`No table found near a heading containing "${args.nearHeading}".\n` +
93
+ `Available tables:\n${formatCandidateList(allCandidates)}`);
94
+ }
95
+ // Return only the first table whose nearestHeading matches
96
+ const firstMatch = filtered.find(c => c.nearestHeading !== null && c.nearestHeading.toLowerCase().includes(needle));
97
+ filtered = firstMatch ? [firstMatch] : [];
98
+ }
99
+ if (args.tableIndex !== undefined) {
100
+ filtered = filtered.filter(c => c.tablePosition === args.tableIndex);
101
+ }
102
+ if (args.dimensions !== undefined) {
103
+ filtered = filtered.filter(c => c.rows === args.dimensions.rows && c.cols === args.dimensions.columns);
104
+ }
105
+ // Handle match results
106
+ if (filtered.length === 0) {
107
+ throw new Error(`No table matches the provided criteria.\n` +
108
+ `Available tables:\n${formatCandidateList(allCandidates)}`);
109
+ }
110
+ if (filtered.length > 1) {
111
+ throw new Error(`Multiple tables match the provided criteria. Narrow your search.\n` +
112
+ `Matching tables:\n${formatCandidateList(filtered)}`);
113
+ }
114
+ const matched = filtered[0];
115
+ // Check if any styling was requested
116
+ const hasStyling = args.preset !== undefined ||
117
+ args.headerBackground !== undefined ||
118
+ args.headerTextColor !== undefined ||
119
+ args.borderColor !== undefined ||
120
+ args.borderWidth !== undefined ||
121
+ args.alternatingRowColor !== undefined ||
122
+ args.cellPadding !== undefined;
123
+ if (!hasStyling) {
124
+ const apiMs = Math.round(performance.now() - apiStart);
125
+ return {
126
+ documentId: args.documentId,
127
+ table: {
128
+ startIndex: matched.startIndex,
129
+ rows: matched.rows,
130
+ cols: matched.cols,
131
+ nearestHeading: matched.nearestHeading,
132
+ tablePosition: matched.tablePosition,
133
+ },
134
+ mode: 'read-only',
135
+ _apiMs: apiMs,
136
+ };
137
+ }
138
+ // Build TableStyleConfig: start from preset if provided, then override with explicit params
139
+ let tableConfig = {};
140
+ if (args.preset !== undefined) {
141
+ const { preset } = getPreset(args.preset);
142
+ if (preset.table) {
143
+ tableConfig = { ...preset.table };
144
+ }
145
+ }
146
+ if (args.headerBackground !== undefined) {
147
+ tableConfig = { ...tableConfig, headerBackground: args.headerBackground };
148
+ }
149
+ if (args.headerTextColor !== undefined) {
150
+ tableConfig = { ...tableConfig, headerTextColor: args.headerTextColor };
151
+ }
152
+ if (args.borderColor !== undefined) {
153
+ tableConfig = { ...tableConfig, borderColor: args.borderColor };
154
+ }
155
+ if (args.borderWidth !== undefined) {
156
+ tableConfig = { ...tableConfig, borderWidth: args.borderWidth };
157
+ }
158
+ if (args.alternatingRowColor !== undefined) {
159
+ tableConfig = { ...tableConfig, alternateRowBackground: args.alternatingRowColor };
160
+ }
161
+ if (args.cellPadding !== undefined) {
162
+ tableConfig = { ...tableConfig, cellPadding: args.cellPadding };
163
+ }
164
+ const tableForRequests = [{
165
+ startIndex: matched.startIndex,
166
+ rows: matched.rows,
167
+ cols: matched.cols,
168
+ }];
169
+ const requests = buildTableStyleRequests(tableConfig, tableForRequests);
170
+ if (requests.length === 0) {
171
+ const apiMs = Math.round(performance.now() - apiStart);
172
+ return {
173
+ documentId: args.documentId,
174
+ table: {
175
+ startIndex: matched.startIndex,
176
+ rows: matched.rows,
177
+ cols: matched.cols,
178
+ nearestHeading: matched.nearestHeading,
179
+ tablePosition: matched.tablePosition,
180
+ },
181
+ applied: false,
182
+ reason: 'Style config produced no requests',
183
+ _apiMs: apiMs,
184
+ };
185
+ }
186
+ await docs.documents.batchUpdate({
187
+ documentId: args.documentId,
188
+ requestBody: { requests },
189
+ });
190
+ const apiMs = Math.round(performance.now() - apiStart);
191
+ return {
192
+ documentId: args.documentId,
193
+ table: {
194
+ startIndex: matched.startIndex,
195
+ rows: matched.rows,
196
+ cols: matched.cols,
197
+ nearestHeading: matched.nearestHeading,
198
+ tablePosition: matched.tablePosition,
199
+ },
200
+ applied: true,
201
+ requestCount: requests.length,
202
+ _apiMs: apiMs,
203
+ };
204
+ }
@@ -15,12 +15,9 @@ export declare function writeTable(args: z.infer<typeof WriteTableSchema>): Prom
15
15
  tableCreated: boolean;
16
16
  styled: boolean;
17
17
  reason: string;
18
- rows?: undefined;
19
- cols?: undefined;
20
- tableStartIndex?: undefined;
21
- requestCount?: undefined;
22
- _apiMs?: undefined;
23
18
  } | {
19
+ _apiMs: number;
20
+ warning?: string | undefined;
24
21
  documentId: string;
25
22
  tableCreated: boolean;
26
23
  styled: boolean;
@@ -28,6 +25,5 @@ export declare function writeTable(args: z.infer<typeof WriteTableSchema>): Prom
28
25
  cols: number;
29
26
  tableStartIndex: any;
30
27
  requestCount: number;
31
- _apiMs: number;
32
28
  reason?: undefined;
33
29
  }>;
@@ -2,13 +2,16 @@ import { google } from 'googleapis';
2
2
  import { z } from 'zod';
3
3
  import { getAuthClient } from '../auth/google-auth.js';
4
4
  import { getPreset } from '../presets/config.js';
5
+ import { hexToRgb } from '../utils/color.js';
6
+ import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE } from '../utils/defaults.js';
5
7
  const DEFAULT_HEADER_BG = { red: 0.043, green: 0.325, blue: 0.58 };
6
8
  const DEFAULT_HEADER_TEXT = { red: 0.95, green: 0.95, blue: 0.95 };
7
9
  const DEFAULT_PADDING = 5;
8
- const USABLE_PAGE_WIDTH = 468; // 612 - 72 - 72 (letter, 1in margins)
9
- const MAX_COL_RATIO = 0.35;
10
- const PT_PER_CHAR = 6;
11
- const PADDING_PER_COL = 20;
10
+ const DEFAULT_PAGE_WIDTH = 612;
11
+ const DEFAULT_MARGIN = 72;
12
+ const DEFAULT_LINE_SPACING = 115;
13
+ const MIN_COL_WIDTH_PT = 60;
14
+ const PT_PER_CHAR_HEADER = 7;
12
15
  export const WriteTableSchema = z.object({
13
16
  documentId: z.string().describe('The Google Doc document ID'),
14
17
  index: z.number().describe('Position in the document to insert the table. Must be a body-level index, not inside an existing table.'),
@@ -20,29 +23,36 @@ export const WriteTableSchema = z.object({
20
23
  cellPadding: z.number().optional(),
21
24
  }).optional().describe('Optional style overrides. Falls back to active preset, then defaults.'),
22
25
  });
23
- function hexToRgb(hex) {
24
- const h = hex.replace('#', '');
25
- return {
26
- red: parseInt(h.substring(0, 2), 16) / 255,
27
- green: parseInt(h.substring(2, 4), 16) / 255,
28
- blue: parseInt(h.substring(4, 6), 16) / 255,
29
- };
30
- }
31
26
  export async function writeTable(args) {
32
27
  const auth = getAuthClient();
33
28
  const docs = google.docs({ version: 'v1', auth });
34
29
  const apiStart = performance.now();
35
30
  const colCount = args.headers.length;
36
- const rowCount = args.rows.length + 1; // headers + data rows
31
+ const rowCount = args.rows.length + 1;
37
32
  if (colCount > 20 || rowCount > 20) {
38
33
  throw new Error(`Table exceeds Google Docs limit of 20x20. Requested: ${rowCount} rows x ${colCount} columns.`);
39
34
  }
40
- // Validate row lengths match header count
41
35
  for (let i = 0; i < args.rows.length; i++) {
42
36
  if (args.rows[i].length !== colCount) {
43
37
  throw new Error(`Row ${i} has ${args.rows[i].length} cells but expected ${colCount} (matching headers).`);
44
38
  }
45
39
  }
40
+ // Step 0: Read document style for actual page dimensions + body font
41
+ const docMeta = await docs.documents.get({
42
+ documentId: args.documentId,
43
+ fields: 'documentStyle,namedStyles',
44
+ });
45
+ const docStyle = docMeta.data.documentStyle || {};
46
+ const pageWidth = docStyle.pageSize?.width?.magnitude ?? DEFAULT_PAGE_WIDTH;
47
+ const marginLeft = docStyle.marginLeft?.magnitude ?? DEFAULT_MARGIN;
48
+ const marginRight = docStyle.marginRight?.magnitude ?? DEFAULT_MARGIN;
49
+ const usableWidth = pageWidth - marginLeft - marginRight;
50
+ // Get body font from named styles
51
+ const namedStyles = docMeta.data.namedStyles?.styles || [];
52
+ const normalTextStyle = namedStyles.find((s) => s.namedStyleType === 'NORMAL_TEXT');
53
+ const bodyFontFamily = normalTextStyle?.textStyle?.weightedFontFamily?.fontFamily ?? DEFAULT_FONT_FAMILY;
54
+ const bodyFontSize = normalTextStyle?.textStyle?.fontSize?.magnitude ?? DEFAULT_FONT_SIZE;
55
+ const bodyLineSpacing = normalTextStyle?.paragraphStyle?.lineSpacing ?? DEFAULT_LINE_SPACING;
46
56
  // Step 1: Insert empty table
47
57
  await docs.documents.batchUpdate({
48
58
  documentId: args.documentId,
@@ -68,7 +78,7 @@ export async function writeTable(args) {
68
78
  'Document may have been modified concurrently.');
69
79
  }
70
80
  const tableStartIndex = table.startIndex;
71
- // Build all cell data: [row][col] = { insertIndex, text }
81
+ // Build all cell data
72
82
  const allData = [args.headers, ...args.rows];
73
83
  const cellInserts = [];
74
84
  for (let r = 0; r < table.table.tableRows.length; r++) {
@@ -78,23 +88,21 @@ export async function writeTable(args) {
78
88
  cellInserts.push({ index: insertAt, text: allData[r][c] });
79
89
  }
80
90
  }
81
- // Step 3: Build all requests — insert text (reverse order), then style
91
+ // Step 3: Insert text in reverse order
82
92
  const requests = [];
83
- // Insert text in reverse order to avoid index shifts
84
93
  const sortedInserts = [...cellInserts].sort((a, b) => b.index - a.index);
85
94
  for (const { index, text } of sortedInserts) {
86
95
  if (text) {
87
96
  requests.push({ insertText: { location: { index }, text } });
88
97
  }
89
98
  }
90
- // Execute text insertion first (indices shift after this)
91
99
  if (requests.length > 0) {
92
100
  await docs.documents.batchUpdate({
93
101
  documentId: args.documentId,
94
102
  requestBody: { requests },
95
103
  });
96
104
  }
97
- // Step 4: Re-read again for styling (indices changed after text insertion)
105
+ // Step 4: Re-read for styling
98
106
  const doc2 = await docs.documents.get({ documentId: args.documentId });
99
107
  let table2 = null;
100
108
  for (const element of doc2.data.body?.content || []) {
@@ -103,7 +111,6 @@ export async function writeTable(args) {
103
111
  break;
104
112
  }
105
113
  }
106
- // Find nearest table if exact match failed (startIndex may have shifted)
107
114
  if (!table2) {
108
115
  for (const element of doc2.data.body?.content || []) {
109
116
  if (element.table && element.startIndex != null && element.startIndex >= args.index) {
@@ -115,25 +122,42 @@ export async function writeTable(args) {
115
122
  if (!table2) {
116
123
  return { documentId: args.documentId, tableCreated: true, styled: false, reason: 'Could not re-locate table for styling' };
117
124
  }
118
- // Resolve style
119
125
  const resolvedStyle = resolveStyle(args.style);
120
126
  const styleRequests = [];
121
- // Bold header text + white color
122
- const headerRow = table2.table.tableRows[0];
123
- for (const cell of headerRow.tableCells) {
124
- const start = cell.content[0].startIndex;
125
- const end = cell.content[cell.content.length - 1].endIndex - 1;
126
- if (end > start) {
127
+ // FIX 1 + 3: Apply explicit font to ALL cells (header + body)
128
+ for (let r = 0; r < table2.table.tableRows.length; r++) {
129
+ for (const cell of table2.table.tableRows[r].tableCells) {
130
+ const start = cell.content[0].startIndex;
131
+ const end = cell.content[cell.content.length - 1].endIndex - 1;
132
+ if (end <= start)
133
+ continue;
134
+ const isHeader = r === 0;
127
135
  styleRequests.push({
128
136
  updateTextStyle: {
129
137
  range: { startIndex: start, endIndex: end },
130
138
  textStyle: {
131
- bold: true,
132
- foregroundColor: { color: { rgbColor: resolvedStyle.headerTextRgb } },
139
+ weightedFontFamily: { fontFamily: bodyFontFamily },
140
+ fontSize: { magnitude: bodyFontSize, unit: 'PT' },
141
+ bold: isHeader,
142
+ foregroundColor: isHeader
143
+ ? { color: { rgbColor: resolvedStyle.headerTextRgb } }
144
+ : undefined,
133
145
  },
134
- fields: 'bold,foregroundColor',
146
+ fields: isHeader
147
+ ? 'weightedFontFamily,fontSize,bold,foregroundColor'
148
+ : 'weightedFontFamily,fontSize,bold',
135
149
  },
136
150
  });
151
+ // Apply line spacing to body cells
152
+ if (!isHeader) {
153
+ styleRequests.push({
154
+ updateParagraphStyle: {
155
+ range: { startIndex: start, endIndex: end },
156
+ paragraphStyle: { lineSpacing: bodyLineSpacing },
157
+ fields: 'lineSpacing',
158
+ },
159
+ });
160
+ }
137
161
  }
138
162
  }
139
163
  // Header row background
@@ -176,33 +200,52 @@ export async function writeTable(args) {
176
200
  fields: 'paddingTop,paddingBottom,paddingLeft,paddingRight',
177
201
  },
178
202
  });
179
- // Content-aware column widths
180
- const maxLengths = new Array(colCount).fill(0);
181
- for (const row of allData) {
203
+ // Column widths: header-aware floors + proportional allocation
204
+ const headerLengths = args.headers.map(h => h.length);
205
+ const maxBodyLengths = new Array(colCount).fill(0);
206
+ for (const row of args.rows) {
182
207
  for (let c = 0; c < colCount; c++) {
183
- maxLengths[c] = Math.max(maxLengths[c], (row[c] || '').length);
208
+ maxBodyLengths[c] = Math.max(maxBodyLengths[c], (row[c] || '').length);
184
209
  }
185
210
  }
186
- const totalLen = maxLengths.reduce((a, b) => a + b, 0);
187
- if (totalLen > 0) {
188
- for (let c = 0; c < colCount; c++) {
189
- const contentWidth = maxLengths[c] * PT_PER_CHAR + PADDING_PER_COL;
190
- const maxWidth = USABLE_PAGE_WIDTH * MAX_COL_RATIO;
191
- const width = Math.min(Math.max(contentWidth, 50), maxWidth);
192
- styleRequests.push({
193
- updateTableColumnProperties: {
194
- tableStartLocation: { index: table2.startIndex },
195
- columnIndices: [c],
196
- tableColumnProperties: {
197
- widthType: 'FIXED_WIDTH',
198
- width: { magnitude: width, unit: 'PT' },
199
- },
200
- fields: 'widthType,width',
211
+ // Floor per column: max(MIN_COL_WIDTH_PT, headerChars * PT_PER_CHAR_HEADER)
212
+ const floors = headerLengths.map(len => Math.max(MIN_COL_WIDTH_PT, len * PT_PER_CHAR_HEADER));
213
+ const totalFloors = floors.reduce((a, b) => a + b, 0);
214
+ let columnWidths;
215
+ let widthWarning;
216
+ if (totalFloors > usableWidth) {
217
+ // Floors exceed page width -- equal distribution fallback
218
+ const equalWidth = Math.round(usableWidth / colCount);
219
+ columnWidths = new Array(colCount).fill(equalWidth);
220
+ widthWarning = `Table has ${colCount} columns; minimum widths cannot be satisfied, content may wrap`;
221
+ }
222
+ else {
223
+ // Proportional allocation of remaining space
224
+ const remaining = usableWidth - totalFloors;
225
+ const totalBodyLen = maxBodyLengths.reduce((a, b) => a + b, 0);
226
+ columnWidths = floors.map((floor, c) => {
227
+ const proportional = totalBodyLen > 0
228
+ ? Math.round(remaining * (maxBodyLengths[c] / totalBodyLen))
229
+ : Math.round(remaining / colCount);
230
+ return floor + proportional;
231
+ });
232
+ }
233
+ // Adjust rounding error on last column
234
+ const widthTotal = columnWidths.reduce((a, b) => a + b, 0);
235
+ columnWidths[columnWidths.length - 1] += Math.round(usableWidth) - widthTotal;
236
+ for (let c = 0; c < colCount; c++) {
237
+ styleRequests.push({
238
+ updateTableColumnProperties: {
239
+ tableStartLocation: { index: table2.startIndex },
240
+ columnIndices: [c],
241
+ tableColumnProperties: {
242
+ widthType: 'FIXED_WIDTH',
243
+ width: { magnitude: columnWidths[c], unit: 'PT' },
201
244
  },
202
- });
203
- }
245
+ fields: 'widthType,width',
246
+ },
247
+ });
204
248
  }
205
- // Apply all styling
206
249
  if (styleRequests.length > 0) {
207
250
  await docs.documents.batchUpdate({
208
251
  documentId: args.documentId,
@@ -218,6 +261,7 @@ export async function writeTable(args) {
218
261
  cols: colCount,
219
262
  tableStartIndex: table2.startIndex,
220
263
  requestCount: requests.length + styleRequests.length,
264
+ ...(widthWarning ? { warning: widthWarning } : {}),
221
265
  _apiMs: apiMs,
222
266
  };
223
267
  }
@@ -225,7 +269,6 @@ function resolveStyle(explicit) {
225
269
  let headerBgRgb = DEFAULT_HEADER_BG;
226
270
  let headerTextRgb = DEFAULT_HEADER_TEXT;
227
271
  let cellPadding = DEFAULT_PADDING;
228
- // Try active preset
229
272
  try {
230
273
  const { preset } = getPreset();
231
274
  if (preset.table?.headerBackground)
@@ -238,7 +281,6 @@ function resolveStyle(explicit) {
238
281
  catch {
239
282
  // No active preset, use defaults
240
283
  }
241
- // Explicit overrides
242
284
  if (explicit?.headerBackground)
243
285
  headerBgRgb = hexToRgb(explicit.headerBackground);
244
286
  if (explicit?.headerTextColor)
@@ -0,0 +1,14 @@
1
+ export declare function hexToRgb(hex: string): {
2
+ red: number;
3
+ green: number;
4
+ blue: number;
5
+ };
6
+ export declare function toColor(hex: string): {
7
+ color: {
8
+ rgbColor: {
9
+ red: number;
10
+ green: number;
11
+ blue: number;
12
+ };
13
+ };
14
+ };
@@ -0,0 +1,14 @@
1
+ export function hexToRgb(hex) {
2
+ const h = hex.replace('#', '');
3
+ if (h.length !== 6 || !/^[0-9a-fA-F]{6}$/.test(h)) {
4
+ throw new Error(`Invalid hex color '${hex}'. Use 6-character hex format (e.g., '#FF0000').`);
5
+ }
6
+ return {
7
+ red: parseInt(h.substring(0, 2), 16) / 255,
8
+ green: parseInt(h.substring(2, 4), 16) / 255,
9
+ blue: parseInt(h.substring(4, 6), 16) / 255,
10
+ };
11
+ }
12
+ export function toColor(hex) {
13
+ return { color: { rgbColor: hexToRgb(hex) } };
14
+ }
@@ -0,0 +1,11 @@
1
+ export declare const DEFAULT_FONT_FAMILY = "Arial";
2
+ export declare const DEFAULT_FONT_SIZE = 11;
3
+ export declare const DEFAULT_FONT_COLOR = "#000000";
4
+ export interface TextStyleDefaults {
5
+ fontFamily: string;
6
+ fontSize: number;
7
+ bold: boolean;
8
+ italic: boolean;
9
+ color: string;
10
+ }
11
+ export declare function getDefaultTextStyle(): TextStyleDefaults;
@@ -0,0 +1,29 @@
1
+ import { getPreset } from '../presets/config.js';
2
+ export const DEFAULT_FONT_FAMILY = 'Arial';
3
+ export const DEFAULT_FONT_SIZE = 11;
4
+ export const DEFAULT_FONT_COLOR = '#000000';
5
+ export function getDefaultTextStyle() {
6
+ try {
7
+ const { preset } = getPreset();
8
+ const normalText = preset.styles?.NORMAL_TEXT;
9
+ if (normalText) {
10
+ return {
11
+ fontFamily: normalText.fontFamily ?? DEFAULT_FONT_FAMILY,
12
+ fontSize: normalText.fontSize ?? DEFAULT_FONT_SIZE,
13
+ bold: normalText.bold ?? false,
14
+ italic: normalText.italic ?? false,
15
+ color: normalText.color ?? DEFAULT_FONT_COLOR,
16
+ };
17
+ }
18
+ }
19
+ catch {
20
+ // No active preset, use hardcoded defaults
21
+ }
22
+ return {
23
+ fontFamily: DEFAULT_FONT_FAMILY,
24
+ fontSize: DEFAULT_FONT_SIZE,
25
+ bold: false,
26
+ italic: false,
27
+ color: DEFAULT_FONT_COLOR,
28
+ };
29
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdocs-mcp",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
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",