gdocs-mcp 0.5.1 → 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,16 +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
10
  const DEFAULT_PAGE_WIDTH = 612;
9
11
  const DEFAULT_MARGIN = 72;
10
- const DEFAULT_FONT_FAMILY = 'Arial';
11
- const DEFAULT_FONT_SIZE = 11;
12
12
  const DEFAULT_LINE_SPACING = 115;
13
- const PT_PER_CHAR = 6;
14
- const PADDING_PER_COL = 20;
13
+ const MIN_COL_WIDTH_PT = 60;
14
+ const PT_PER_CHAR_HEADER = 7;
15
15
  export const WriteTableSchema = z.object({
16
16
  documentId: z.string().describe('The Google Doc document ID'),
17
17
  index: z.number().describe('Position in the document to insert the table. Must be a body-level index, not inside an existing table.'),
@@ -23,14 +23,6 @@ export const WriteTableSchema = z.object({
23
23
  cellPadding: z.number().optional(),
24
24
  }).optional().describe('Optional style overrides. Falls back to active preset, then defaults.'),
25
25
  });
26
- function hexToRgb(hex) {
27
- const h = hex.replace('#', '');
28
- return {
29
- red: parseInt(h.substring(0, 2), 16) / 255,
30
- green: parseInt(h.substring(2, 4), 16) / 255,
31
- blue: parseInt(h.substring(4, 6), 16) / 255,
32
- };
33
- }
34
26
  export async function writeTable(args) {
35
27
  const auth = getAuthClient();
36
28
  const docs = google.docs({ version: 'v1', auth });
@@ -208,39 +200,51 @@ export async function writeTable(args) {
208
200
  fields: 'paddingTop,paddingBottom,paddingLeft,paddingRight',
209
201
  },
210
202
  });
211
- // FIX 2 + 4: Content-aware column widths that sum to full usable page width
212
- const maxLengths = new Array(colCount).fill(0);
213
- 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) {
214
207
  for (let c = 0; c < colCount; c++) {
215
- maxLengths[c] = Math.max(maxLengths[c], (row[c] || '').length);
208
+ maxBodyLengths[c] = Math.max(maxBodyLengths[c], (row[c] || '').length);
216
209
  }
217
210
  }
218
- const totalLen = maxLengths.reduce((a, b) => a + b, 0);
219
- if (totalLen > 0) {
220
- // Calculate raw proportional widths
221
- const rawWidths = maxLengths.map(l => {
222
- const contentWidth = l * PT_PER_CHAR + PADDING_PER_COL;
223
- return Math.max(contentWidth, 50); // minimum 50pt per column
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;
224
231
  });
225
- // Scale all widths proportionally to sum to exactly usableWidth
226
- const rawTotal = rawWidths.reduce((a, b) => a + b, 0);
227
- const scaledWidths = rawWidths.map(w => Math.round((w / rawTotal) * usableWidth));
228
- // Adjust rounding error on last column
229
- const scaledTotal = scaledWidths.reduce((a, b) => a + b, 0);
230
- scaledWidths[scaledWidths.length - 1] += usableWidth - scaledTotal;
231
- for (let c = 0; c < colCount; c++) {
232
- styleRequests.push({
233
- updateTableColumnProperties: {
234
- tableStartLocation: { index: table2.startIndex },
235
- columnIndices: [c],
236
- tableColumnProperties: {
237
- widthType: 'FIXED_WIDTH',
238
- width: { magnitude: scaledWidths[c], unit: 'PT' },
239
- },
240
- fields: 'widthType,width',
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' },
241
244
  },
242
- });
243
- }
245
+ fields: 'widthType,width',
246
+ },
247
+ });
244
248
  }
245
249
  if (styleRequests.length > 0) {
246
250
  await docs.documents.batchUpdate({
@@ -257,6 +261,7 @@ export async function writeTable(args) {
257
261
  cols: colCount,
258
262
  tableStartIndex: table2.startIndex,
259
263
  requestCount: requests.length + styleRequests.length,
264
+ ...(widthWarning ? { warning: widthWarning } : {}),
260
265
  _apiMs: apiMs,
261
266
  };
262
267
  }
@@ -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.1",
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",