gdocs-mcp 0.4.2 → 0.5.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/dist/index.js CHANGED
@@ -30,7 +30,13 @@ import { DeleteDocumentSchema, deleteDocument } from './tools/delete-document.js
30
30
  import { GetCommentsSchema, getComments } from './tools/get-comments.js';
31
31
  import { AddCommentSchema, addComment } from './tools/add-comment.js';
32
32
  import { ReadDocumentStructureSchema, readDocumentStructure } from './tools/read-document-structure.js';
33
- const VERSION = '0.4.2';
33
+ import { InsertTextSchema, insertText } from './tools/insert-text.js';
34
+ import { DeleteContentRangeSchema, deleteContentRange } from './tools/delete-content-range.js';
35
+ import { InsertTableRowSchema, insertTableRow, DeleteTableRowSchema, deleteTableRow, InsertTableColumnSchema, insertTableColumn, DeleteTableColumnSchema, deleteTableColumn } from './tools/table-row-column-ops.js';
36
+ import { DeleteHeaderFooterSchema, deleteHeaderFooter } from './tools/delete-header-footer.js';
37
+ import { CreateFootnoteSchema, createFootnote } from './tools/create-footnote.js';
38
+ import { WriteTableSchema, writeTable } from './tools/write-table.js';
39
+ const VERSION = '0.5.0';
34
40
  const QUIET = process.env.GDOCS_MCP_QUIET === '1';
35
41
  const server = new McpServer({
36
42
  name: 'gdocs-mcp',
@@ -128,6 +134,17 @@ registerTool('get_comments', 'List comments on a document with author, content,
128
134
  registerTool('add_comment', 'Add a comment anchored to specific text in the document. Anchors to first occurrence of quotedText.', AddCommentSchema, addComment);
129
135
  // v0.4.2 structure tool
130
136
  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
+ // 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('delete_content_range', 'Delete a range of content. Supports preview mode (dry-run) to see what would be deleted before executing.', DeleteContentRangeSchema, deleteContentRange);
140
+ registerTool('insert_table_row', 'Insert a row into an existing table. Zero-indexed.', InsertTableRowSchema, insertTableRow);
141
+ registerTool('delete_table_row', 'Delete a row from an existing table. Zero-indexed.', DeleteTableRowSchema, deleteTableRow);
142
+ registerTool('insert_table_column', 'Insert a column into an existing table. Zero-indexed.', InsertTableColumnSchema, insertTableColumn);
143
+ registerTool('delete_table_column', 'Delete a column from an existing table. Zero-indexed.', DeleteTableColumnSchema, deleteTableColumn);
144
+ registerTool('delete_header_footer', 'Delete a header or footer. Supports preview mode.', DeleteHeaderFooterSchema, deleteHeaderFooter);
145
+ registerTool('create_footnote', 'Create a footnote at a specific position.', CreateFootnoteSchema, createFootnote);
146
+ // v0.5 differentiator
147
+ 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);
131
148
  async function main() {
132
149
  const transport = new StdioServerTransport();
133
150
  await server.connect(transport);
@@ -0,0 +1,11 @@
1
+ import { z } from 'zod';
2
+ export declare const CreateFootnoteSchema: z.ZodObject<{
3
+ documentId: z.ZodString;
4
+ index: z.ZodNumber;
5
+ }, z.core.$strip>;
6
+ export declare function createFootnote(args: z.infer<typeof CreateFootnoteSchema>): Promise<{
7
+ documentId: string;
8
+ footnoteId: string | null | undefined;
9
+ index: number;
10
+ _apiMs: number;
11
+ }>;
@@ -0,0 +1,25 @@
1
+ import { google } from 'googleapis';
2
+ import { z } from 'zod';
3
+ import { getAuthClient } from '../auth/google-auth.js';
4
+ export const CreateFootnoteSchema = z.object({
5
+ documentId: z.string().describe('The Google Doc document ID'),
6
+ index: z.number().describe('Position in the document to insert the footnote reference'),
7
+ });
8
+ export async function createFootnote(args) {
9
+ const auth = getAuthClient();
10
+ const docs = google.docs({ version: 'v1', auth });
11
+ const apiStart = performance.now();
12
+ const res = await docs.documents.batchUpdate({
13
+ documentId: args.documentId,
14
+ requestBody: {
15
+ requests: [{ createFootnote: { location: { index: args.index } } }],
16
+ },
17
+ });
18
+ const footnoteId = res.data.replies?.[0]?.createFootnote?.footnoteId;
19
+ return {
20
+ documentId: args.documentId,
21
+ footnoteId,
22
+ index: args.index,
23
+ _apiMs: Math.round(performance.now() - apiStart),
24
+ };
25
+ }
@@ -0,0 +1,26 @@
1
+ import { z } from 'zod';
2
+ export declare const DeleteContentRangeSchema: z.ZodObject<{
3
+ documentId: z.ZodString;
4
+ startIndex: z.ZodNumber;
5
+ endIndex: z.ZodNumber;
6
+ preview: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
7
+ }, z.core.$strip>;
8
+ export declare function deleteContentRange(args: z.infer<typeof DeleteContentRangeSchema>): Promise<{
9
+ documentId: string;
10
+ preview: boolean;
11
+ startIndex: number;
12
+ endIndex: number;
13
+ affectedElements: number;
14
+ affectedText: string;
15
+ _apiMs: number;
16
+ deleted?: undefined;
17
+ } | {
18
+ documentId: string;
19
+ preview: boolean;
20
+ deleted: boolean;
21
+ startIndex: number;
22
+ endIndex: number;
23
+ affectedElements: number;
24
+ _apiMs: number;
25
+ affectedText?: undefined;
26
+ }>;
@@ -0,0 +1,69 @@
1
+ import { google } from 'googleapis';
2
+ import { z } from 'zod';
3
+ import { getAuthClient } from '../auth/google-auth.js';
4
+ export const DeleteContentRangeSchema = z.object({
5
+ documentId: z.string().describe('The Google Doc document ID'),
6
+ startIndex: z.number().describe('Start index of the range to delete'),
7
+ endIndex: z.number().describe('End index of the range to delete (exclusive)'),
8
+ preview: z.boolean().optional().default(false).describe('If true, returns what would be deleted without executing. Use for dry-run safety checks.'),
9
+ });
10
+ export async function deleteContentRange(args) {
11
+ if (args.startIndex >= args.endIndex) {
12
+ throw new Error(`Invalid range: startIndex (${args.startIndex}) must be less than endIndex (${args.endIndex})`);
13
+ }
14
+ const auth = getAuthClient();
15
+ const docs = google.docs({ version: 'v1', auth });
16
+ const apiStart = performance.now();
17
+ // Read document to validate range and get preview
18
+ const doc = await docs.documents.get({ documentId: args.documentId });
19
+ const content = doc.data.body?.content || [];
20
+ const lastIndex = content[content.length - 1]?.endIndex || 0;
21
+ if (args.endIndex > lastIndex) {
22
+ throw new Error(`Range exceeds document length. Document ends at index ${lastIndex}, but endIndex is ${args.endIndex}`);
23
+ }
24
+ // Build preview: count elements and collect text in the range
25
+ let affectedText = '';
26
+ let elementCount = 0;
27
+ for (const element of content) {
28
+ const elStart = element.startIndex ?? 0;
29
+ const elEnd = element.endIndex ?? 0;
30
+ if (elStart >= args.startIndex && elEnd <= args.endIndex) {
31
+ elementCount++;
32
+ if (element.paragraph) {
33
+ const text = element.paragraph.elements
34
+ ?.map((el) => el.textRun?.content || '').join('') || '';
35
+ affectedText += text;
36
+ }
37
+ }
38
+ }
39
+ if (args.preview) {
40
+ const apiMs = Math.round(performance.now() - apiStart);
41
+ return {
42
+ documentId: args.documentId,
43
+ preview: true,
44
+ startIndex: args.startIndex,
45
+ endIndex: args.endIndex,
46
+ affectedElements: elementCount,
47
+ affectedText: affectedText.length > 200
48
+ ? affectedText.substring(0, 200) + '...'
49
+ : affectedText,
50
+ _apiMs: apiMs,
51
+ };
52
+ }
53
+ await docs.documents.batchUpdate({
54
+ documentId: args.documentId,
55
+ requestBody: {
56
+ requests: [{ deleteContentRange: { range: { startIndex: args.startIndex, endIndex: args.endIndex } } }],
57
+ },
58
+ });
59
+ const apiMs = Math.round(performance.now() - apiStart);
60
+ return {
61
+ documentId: args.documentId,
62
+ preview: false,
63
+ deleted: true,
64
+ startIndex: args.startIndex,
65
+ endIndex: args.endIndex,
66
+ affectedElements: elementCount,
67
+ _apiMs: apiMs,
68
+ };
69
+ }
@@ -0,0 +1,26 @@
1
+ import { z } from 'zod';
2
+ export declare const DeleteHeaderFooterSchema: z.ZodObject<{
3
+ documentId: z.ZodString;
4
+ section: z.ZodEnum<{
5
+ header: "header";
6
+ footer: "footer";
7
+ }>;
8
+ preview: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
9
+ }, z.core.$strip>;
10
+ export declare function deleteHeaderFooter(args: z.infer<typeof DeleteHeaderFooterSchema>): Promise<{
11
+ documentId: string;
12
+ section: "header" | "footer";
13
+ preview: boolean;
14
+ sectionId: string;
15
+ contentPreview: string;
16
+ _apiMs: number;
17
+ deleted?: undefined;
18
+ } | {
19
+ documentId: string;
20
+ section: "header" | "footer";
21
+ deleted: boolean;
22
+ _apiMs: number;
23
+ preview?: undefined;
24
+ sectionId?: undefined;
25
+ contentPreview?: undefined;
26
+ }>;
@@ -0,0 +1,45 @@
1
+ import { google } from 'googleapis';
2
+ import { z } from 'zod';
3
+ import { getAuthClient } from '../auth/google-auth.js';
4
+ export const DeleteHeaderFooterSchema = z.object({
5
+ documentId: z.string().describe('The Google Doc document ID'),
6
+ section: z.enum(['header', 'footer']).describe('Which section to delete: "header" or "footer"'),
7
+ preview: z.boolean().optional().default(false).describe('If true, returns what would be deleted without executing.'),
8
+ });
9
+ export async function deleteHeaderFooter(args) {
10
+ const auth = getAuthClient();
11
+ const docs = google.docs({ version: 'v1', auth });
12
+ const apiStart = performance.now();
13
+ const doc = await docs.documents.get({ documentId: args.documentId });
14
+ const isHeader = args.section === 'header';
15
+ const sections = isHeader ? doc.data.headers : doc.data.footers;
16
+ if (!sections || Object.keys(sections).length === 0) {
17
+ throw new Error(`No ${args.section} exists in this document.`);
18
+ }
19
+ const sectionId = Object.keys(sections)[0];
20
+ if (args.preview) {
21
+ const content = sections[sectionId]?.content || [];
22
+ const text = content.map((p) => p.paragraph?.elements?.map((e) => e.textRun?.content || '').join('')).join('').trim();
23
+ return {
24
+ documentId: args.documentId,
25
+ section: args.section,
26
+ preview: true,
27
+ sectionId,
28
+ contentPreview: text.length > 200 ? text.substring(0, 200) + '...' : text,
29
+ _apiMs: Math.round(performance.now() - apiStart),
30
+ };
31
+ }
32
+ const requestType = isHeader ? 'deleteHeader' : 'deleteFooter';
33
+ await docs.documents.batchUpdate({
34
+ documentId: args.documentId,
35
+ requestBody: {
36
+ requests: [{ [requestType]: { [isHeader ? 'headerId' : 'footerId']: sectionId } }],
37
+ },
38
+ });
39
+ return {
40
+ documentId: args.documentId,
41
+ section: args.section,
42
+ deleted: true,
43
+ _apiMs: Math.round(performance.now() - apiStart),
44
+ };
45
+ }
@@ -0,0 +1,14 @@
1
+ import { z } from 'zod';
2
+ export declare const InsertTextSchema: z.ZodObject<{
3
+ documentId: z.ZodString;
4
+ text: z.ZodString;
5
+ index: z.ZodOptional<z.ZodNumber>;
6
+ appendToEnd: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
7
+ }, z.core.$strip>;
8
+ export declare function insertText(args: z.infer<typeof InsertTextSchema>): Promise<{
9
+ documentId: string;
10
+ insertedAt: number;
11
+ textLength: 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 InsertTextSchema = z.object({
5
+ documentId: z.string().describe('The Google Doc document ID'),
6
+ text: z.string().describe('Text to insert'),
7
+ index: z.number().optional().describe('Position to insert at. Required unless appendToEnd is true.'),
8
+ appendToEnd: z.boolean().optional().default(false).describe('Append text to the end of the document body. No need to know the last index.'),
9
+ });
10
+ export async function insertText(args) {
11
+ const auth = getAuthClient();
12
+ const docs = google.docs({ version: 'v1', auth });
13
+ const apiStart = performance.now();
14
+ let insertIndex = args.index;
15
+ if (args.appendToEnd) {
16
+ const doc = await docs.documents.get({ documentId: args.documentId, fields: 'body.content(endIndex)' });
17
+ const content = doc.data.body?.content || [];
18
+ const lastElement = content[content.length - 1];
19
+ insertIndex = (lastElement?.endIndex || 1) - 1;
20
+ }
21
+ if (insertIndex === undefined) {
22
+ throw new Error('Either index or appendToEnd: true is required.');
23
+ }
24
+ await docs.documents.batchUpdate({
25
+ documentId: args.documentId,
26
+ requestBody: {
27
+ requests: [{ insertText: { location: { index: insertIndex }, text: args.text } }],
28
+ },
29
+ });
30
+ const apiMs = Math.round(performance.now() - apiStart);
31
+ return {
32
+ documentId: args.documentId,
33
+ insertedAt: insertIndex,
34
+ textLength: args.text.length,
35
+ endIndex: insertIndex + args.text.length,
36
+ _apiMs: apiMs,
37
+ };
38
+ }
@@ -0,0 +1,47 @@
1
+ import { z } from 'zod';
2
+ export declare const InsertTableRowSchema: z.ZodObject<{
3
+ documentId: z.ZodString;
4
+ tableStartIndex: z.ZodNumber;
5
+ rowIndex: z.ZodNumber;
6
+ insertBelow: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
7
+ }, z.core.$strip>;
8
+ export declare function insertTableRow(args: z.infer<typeof InsertTableRowSchema>): Promise<{
9
+ documentId: string;
10
+ inserted: boolean;
11
+ rowIndex: number;
12
+ _apiMs: number;
13
+ }>;
14
+ export declare const DeleteTableRowSchema: z.ZodObject<{
15
+ documentId: z.ZodString;
16
+ tableStartIndex: z.ZodNumber;
17
+ rowIndex: z.ZodNumber;
18
+ }, z.core.$strip>;
19
+ export declare function deleteTableRow(args: z.infer<typeof DeleteTableRowSchema>): Promise<{
20
+ documentId: string;
21
+ deleted: boolean;
22
+ rowIndex: number;
23
+ _apiMs: number;
24
+ }>;
25
+ export declare const InsertTableColumnSchema: z.ZodObject<{
26
+ documentId: z.ZodString;
27
+ tableStartIndex: z.ZodNumber;
28
+ columnIndex: z.ZodNumber;
29
+ insertRight: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
30
+ }, z.core.$strip>;
31
+ export declare function insertTableColumn(args: z.infer<typeof InsertTableColumnSchema>): Promise<{
32
+ documentId: string;
33
+ inserted: boolean;
34
+ columnIndex: number;
35
+ _apiMs: number;
36
+ }>;
37
+ export declare const DeleteTableColumnSchema: z.ZodObject<{
38
+ documentId: z.ZodString;
39
+ tableStartIndex: z.ZodNumber;
40
+ columnIndex: z.ZodNumber;
41
+ }, z.core.$strip>;
42
+ export declare function deleteTableColumn(args: z.infer<typeof DeleteTableColumnSchema>): Promise<{
43
+ documentId: string;
44
+ deleted: boolean;
45
+ columnIndex: number;
46
+ _apiMs: number;
47
+ }>;
@@ -0,0 +1,107 @@
1
+ import { google } from 'googleapis';
2
+ import { z } from 'zod';
3
+ import { getAuthClient } from '../auth/google-auth.js';
4
+ export const InsertTableRowSchema = z.object({
5
+ documentId: z.string().describe('The Google Doc document ID'),
6
+ tableStartIndex: z.number().describe('Start index of the table (from read_document_structure)'),
7
+ rowIndex: z.number().describe('Zero-indexed row position. New row is inserted below this row.'),
8
+ insertBelow: z.boolean().optional().default(true).describe('Insert below the specified row (default: true). Set false to insert above.'),
9
+ });
10
+ export async function insertTableRow(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
+ insertTableRow: {
19
+ tableCellLocation: {
20
+ tableStartLocation: { index: args.tableStartIndex },
21
+ rowIndex: args.rowIndex,
22
+ columnIndex: 0,
23
+ },
24
+ insertBelow: args.insertBelow,
25
+ },
26
+ }],
27
+ },
28
+ });
29
+ return { documentId: args.documentId, inserted: true, rowIndex: args.rowIndex, _apiMs: Math.round(performance.now() - apiStart) };
30
+ }
31
+ export const DeleteTableRowSchema = z.object({
32
+ documentId: z.string().describe('The Google Doc document ID'),
33
+ tableStartIndex: z.number().describe('Start index of the table'),
34
+ rowIndex: z.number().describe('Zero-indexed row to delete'),
35
+ });
36
+ export async function deleteTableRow(args) {
37
+ const auth = getAuthClient();
38
+ const docs = google.docs({ version: 'v1', auth });
39
+ const apiStart = performance.now();
40
+ await docs.documents.batchUpdate({
41
+ documentId: args.documentId,
42
+ requestBody: {
43
+ requests: [{
44
+ deleteTableRow: {
45
+ tableCellLocation: {
46
+ tableStartLocation: { index: args.tableStartIndex },
47
+ rowIndex: args.rowIndex,
48
+ columnIndex: 0,
49
+ },
50
+ },
51
+ }],
52
+ },
53
+ });
54
+ return { documentId: args.documentId, deleted: true, rowIndex: args.rowIndex, _apiMs: Math.round(performance.now() - apiStart) };
55
+ }
56
+ export const InsertTableColumnSchema = z.object({
57
+ documentId: z.string().describe('The Google Doc document ID'),
58
+ tableStartIndex: z.number().describe('Start index of the table'),
59
+ columnIndex: z.number().describe('Zero-indexed column position. New column is inserted to the right.'),
60
+ insertRight: z.boolean().optional().default(true).describe('Insert to the right (default: true). Set false to insert left.'),
61
+ });
62
+ export async function insertTableColumn(args) {
63
+ const auth = getAuthClient();
64
+ const docs = google.docs({ version: 'v1', auth });
65
+ const apiStart = performance.now();
66
+ await docs.documents.batchUpdate({
67
+ documentId: args.documentId,
68
+ requestBody: {
69
+ requests: [{
70
+ insertTableColumn: {
71
+ tableCellLocation: {
72
+ tableStartLocation: { index: args.tableStartIndex },
73
+ rowIndex: 0,
74
+ columnIndex: args.columnIndex,
75
+ },
76
+ insertRight: args.insertRight,
77
+ },
78
+ }],
79
+ },
80
+ });
81
+ return { documentId: args.documentId, inserted: true, columnIndex: args.columnIndex, _apiMs: Math.round(performance.now() - apiStart) };
82
+ }
83
+ export const DeleteTableColumnSchema = z.object({
84
+ documentId: z.string().describe('The Google Doc document ID'),
85
+ tableStartIndex: z.number().describe('Start index of the table'),
86
+ columnIndex: z.number().describe('Zero-indexed column to delete'),
87
+ });
88
+ export async function deleteTableColumn(args) {
89
+ const auth = getAuthClient();
90
+ const docs = google.docs({ version: 'v1', auth });
91
+ const apiStart = performance.now();
92
+ await docs.documents.batchUpdate({
93
+ documentId: args.documentId,
94
+ requestBody: {
95
+ requests: [{
96
+ deleteTableColumn: {
97
+ tableCellLocation: {
98
+ tableStartLocation: { index: args.tableStartIndex },
99
+ rowIndex: 0,
100
+ columnIndex: args.columnIndex,
101
+ },
102
+ },
103
+ }],
104
+ },
105
+ });
106
+ return { documentId: args.documentId, deleted: true, columnIndex: args.columnIndex, _apiMs: Math.round(performance.now() - apiStart) };
107
+ }
@@ -0,0 +1,33 @@
1
+ import { z } from 'zod';
2
+ export declare const WriteTableSchema: z.ZodObject<{
3
+ documentId: z.ZodString;
4
+ index: z.ZodNumber;
5
+ headers: z.ZodArray<z.ZodString>;
6
+ rows: z.ZodArray<z.ZodArray<z.ZodString>>;
7
+ style: z.ZodOptional<z.ZodObject<{
8
+ headerBackground: z.ZodOptional<z.ZodString>;
9
+ headerTextColor: z.ZodOptional<z.ZodString>;
10
+ cellPadding: z.ZodOptional<z.ZodNumber>;
11
+ }, z.core.$strip>>;
12
+ }, z.core.$strip>;
13
+ export declare function writeTable(args: z.infer<typeof WriteTableSchema>): Promise<{
14
+ documentId: string;
15
+ tableCreated: boolean;
16
+ styled: boolean;
17
+ reason: string;
18
+ rows?: undefined;
19
+ cols?: undefined;
20
+ tableStartIndex?: undefined;
21
+ requestCount?: undefined;
22
+ _apiMs?: undefined;
23
+ } | {
24
+ documentId: string;
25
+ tableCreated: boolean;
26
+ styled: boolean;
27
+ rows: number;
28
+ cols: number;
29
+ tableStartIndex: any;
30
+ requestCount: number;
31
+ _apiMs: number;
32
+ reason?: undefined;
33
+ }>;
@@ -0,0 +1,249 @@
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
+ const DEFAULT_HEADER_BG = { red: 0.043, green: 0.325, blue: 0.58 };
6
+ const DEFAULT_HEADER_TEXT = { red: 0.95, green: 0.95, blue: 0.95 };
7
+ 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;
12
+ export const WriteTableSchema = z.object({
13
+ documentId: z.string().describe('The Google Doc document ID'),
14
+ index: z.number().describe('Position in the document to insert the table. Must be a body-level index, not inside an existing table.'),
15
+ headers: z.array(z.string()).min(1).max(20).describe('Column header labels'),
16
+ rows: z.array(z.array(z.string())).max(19).describe('Table data rows. Each row is an array of cell values matching the header count.'),
17
+ style: z.object({
18
+ headerBackground: z.string().optional(),
19
+ headerTextColor: z.string().optional(),
20
+ cellPadding: z.number().optional(),
21
+ }).optional().describe('Optional style overrides. Falls back to active preset, then defaults.'),
22
+ });
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
+ export async function writeTable(args) {
32
+ const auth = getAuthClient();
33
+ const docs = google.docs({ version: 'v1', auth });
34
+ const apiStart = performance.now();
35
+ const colCount = args.headers.length;
36
+ const rowCount = args.rows.length + 1; // headers + data rows
37
+ if (colCount > 20 || rowCount > 20) {
38
+ throw new Error(`Table exceeds Google Docs limit of 20x20. Requested: ${rowCount} rows x ${colCount} columns.`);
39
+ }
40
+ // Validate row lengths match header count
41
+ for (let i = 0; i < args.rows.length; i++) {
42
+ if (args.rows[i].length !== colCount) {
43
+ throw new Error(`Row ${i} has ${args.rows[i].length} cells but expected ${colCount} (matching headers).`);
44
+ }
45
+ }
46
+ // Step 1: Insert empty table
47
+ await docs.documents.batchUpdate({
48
+ documentId: args.documentId,
49
+ requestBody: {
50
+ requests: [{ insertTable: { location: { index: args.index }, rows: rowCount, columns: colCount } }],
51
+ },
52
+ });
53
+ // Step 2: Re-read to get cell indices + validate
54
+ const doc = await docs.documents.get({ documentId: args.documentId });
55
+ const content = doc.data.body?.content || [];
56
+ let table = null;
57
+ for (const element of content) {
58
+ if (element.table && element.startIndex != null && element.startIndex >= args.index) {
59
+ table = element;
60
+ break;
61
+ }
62
+ }
63
+ if (!table) {
64
+ throw new Error('Table insertion failed: could not find the inserted table.');
65
+ }
66
+ if (table.table.rows !== rowCount || table.table.columns !== colCount) {
67
+ throw new Error(`Table dimension mismatch after insert. Expected ${rowCount}x${colCount}, found ${table.table.rows}x${table.table.columns}. ` +
68
+ 'Document may have been modified concurrently.');
69
+ }
70
+ const tableStartIndex = table.startIndex;
71
+ // Build all cell data: [row][col] = { insertIndex, text }
72
+ const allData = [args.headers, ...args.rows];
73
+ const cellInserts = [];
74
+ for (let r = 0; r < table.table.tableRows.length; r++) {
75
+ for (let c = 0; c < table.table.tableRows[r].tableCells.length; c++) {
76
+ const cell = table.table.tableRows[r].tableCells[c];
77
+ const insertAt = cell.content[0].startIndex;
78
+ cellInserts.push({ index: insertAt, text: allData[r][c] });
79
+ }
80
+ }
81
+ // Step 3: Build all requests — insert text (reverse order), then style
82
+ const requests = [];
83
+ // Insert text in reverse order to avoid index shifts
84
+ const sortedInserts = [...cellInserts].sort((a, b) => b.index - a.index);
85
+ for (const { index, text } of sortedInserts) {
86
+ if (text) {
87
+ requests.push({ insertText: { location: { index }, text } });
88
+ }
89
+ }
90
+ // Execute text insertion first (indices shift after this)
91
+ if (requests.length > 0) {
92
+ await docs.documents.batchUpdate({
93
+ documentId: args.documentId,
94
+ requestBody: { requests },
95
+ });
96
+ }
97
+ // Step 4: Re-read again for styling (indices changed after text insertion)
98
+ const doc2 = await docs.documents.get({ documentId: args.documentId });
99
+ let table2 = null;
100
+ for (const element of doc2.data.body?.content || []) {
101
+ if (element.table && element.startIndex === tableStartIndex) {
102
+ table2 = element;
103
+ break;
104
+ }
105
+ }
106
+ // Find nearest table if exact match failed (startIndex may have shifted)
107
+ if (!table2) {
108
+ for (const element of doc2.data.body?.content || []) {
109
+ if (element.table && element.startIndex != null && element.startIndex >= args.index) {
110
+ table2 = element;
111
+ break;
112
+ }
113
+ }
114
+ }
115
+ if (!table2) {
116
+ return { documentId: args.documentId, tableCreated: true, styled: false, reason: 'Could not re-locate table for styling' };
117
+ }
118
+ // Resolve style
119
+ const resolvedStyle = resolveStyle(args.style);
120
+ 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
+ styleRequests.push({
128
+ updateTextStyle: {
129
+ range: { startIndex: start, endIndex: end },
130
+ textStyle: {
131
+ bold: true,
132
+ foregroundColor: { color: { rgbColor: resolvedStyle.headerTextRgb } },
133
+ },
134
+ fields: 'bold,foregroundColor',
135
+ },
136
+ });
137
+ }
138
+ }
139
+ // Header row background
140
+ styleRequests.push({
141
+ updateTableCellStyle: {
142
+ tableRange: {
143
+ tableCellLocation: {
144
+ tableStartLocation: { index: table2.startIndex },
145
+ rowIndex: 0,
146
+ columnIndex: 0,
147
+ },
148
+ rowSpan: 1,
149
+ columnSpan: colCount,
150
+ },
151
+ tableCellStyle: {
152
+ backgroundColor: { color: { rgbColor: resolvedStyle.headerBgRgb } },
153
+ },
154
+ fields: 'backgroundColor',
155
+ },
156
+ });
157
+ // Cell padding for all cells
158
+ const paddingMag = resolvedStyle.cellPadding;
159
+ styleRequests.push({
160
+ updateTableCellStyle: {
161
+ tableRange: {
162
+ tableCellLocation: {
163
+ tableStartLocation: { index: table2.startIndex },
164
+ rowIndex: 0,
165
+ columnIndex: 0,
166
+ },
167
+ rowSpan: rowCount,
168
+ columnSpan: colCount,
169
+ },
170
+ tableCellStyle: {
171
+ paddingTop: { magnitude: paddingMag, unit: 'PT' },
172
+ paddingBottom: { magnitude: paddingMag, unit: 'PT' },
173
+ paddingLeft: { magnitude: paddingMag, unit: 'PT' },
174
+ paddingRight: { magnitude: paddingMag, unit: 'PT' },
175
+ },
176
+ fields: 'paddingTop,paddingBottom,paddingLeft,paddingRight',
177
+ },
178
+ });
179
+ // Content-aware column widths
180
+ const maxLengths = new Array(colCount).fill(0);
181
+ for (const row of allData) {
182
+ for (let c = 0; c < colCount; c++) {
183
+ maxLengths[c] = Math.max(maxLengths[c], (row[c] || '').length);
184
+ }
185
+ }
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',
201
+ },
202
+ });
203
+ }
204
+ }
205
+ // Apply all styling
206
+ if (styleRequests.length > 0) {
207
+ await docs.documents.batchUpdate({
208
+ documentId: args.documentId,
209
+ requestBody: { requests: styleRequests },
210
+ });
211
+ }
212
+ const apiMs = Math.round(performance.now() - apiStart);
213
+ return {
214
+ documentId: args.documentId,
215
+ tableCreated: true,
216
+ styled: true,
217
+ rows: rowCount,
218
+ cols: colCount,
219
+ tableStartIndex: table2.startIndex,
220
+ requestCount: requests.length + styleRequests.length,
221
+ _apiMs: apiMs,
222
+ };
223
+ }
224
+ function resolveStyle(explicit) {
225
+ let headerBgRgb = DEFAULT_HEADER_BG;
226
+ let headerTextRgb = DEFAULT_HEADER_TEXT;
227
+ let cellPadding = DEFAULT_PADDING;
228
+ // Try active preset
229
+ try {
230
+ const { preset } = getPreset();
231
+ if (preset.table?.headerBackground)
232
+ headerBgRgb = hexToRgb(preset.table.headerBackground);
233
+ if (preset.table?.headerTextColor)
234
+ headerTextRgb = hexToRgb(preset.table.headerTextColor);
235
+ if (preset.table?.cellPadding)
236
+ cellPadding = preset.table.cellPadding;
237
+ }
238
+ catch {
239
+ // No active preset, use defaults
240
+ }
241
+ // Explicit overrides
242
+ if (explicit?.headerBackground)
243
+ headerBgRgb = hexToRgb(explicit.headerBackground);
244
+ if (explicit?.headerTextColor)
245
+ headerTextRgb = hexToRgb(explicit.headerTextColor);
246
+ if (explicit?.cellPadding !== undefined)
247
+ cellPadding = explicit.cellPadding;
248
+ return { headerBgRgb, headerTextRgb, cellPadding };
249
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdocs-mcp",
3
- "version": "0.4.2",
3
+ "version": "0.5.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",