gdocs-mcp 0.1.0 → 0.3.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.
@@ -0,0 +1,149 @@
1
+ import { google } from 'googleapis';
2
+ import { z } from 'zod';
3
+ import { getAuthClient } from '../auth/google-auth.js';
4
+ import { savePreset } from '../presets/config.js';
5
+ import { VALID_NAMED_STYLE_TYPES } from '../presets/types.js';
6
+ export const ExtractDocumentStylesSchema = z.object({
7
+ documentId: z.string().describe('The Google Doc document ID to extract styles from'),
8
+ saveAs: z.string().optional().describe('If provided, saves the extracted styles as a named preset in ~/.gdocs-mcp/styles.json'),
9
+ });
10
+ export async function extractDocumentStyles(args) {
11
+ const auth = getAuthClient();
12
+ const docs = google.docs({ version: 'v1', auth });
13
+ const doc = await docs.documents.get({ documentId: args.documentId });
14
+ const preset = {
15
+ document: extractDocumentStyle(doc.data.documentStyle),
16
+ styles: extractNamedStyles(doc.data.namedStyles),
17
+ table: extractTableStyles(doc.data.body?.content || []),
18
+ };
19
+ if (args.saveAs) {
20
+ savePreset(args.saveAs, preset);
21
+ }
22
+ return {
23
+ documentId: args.documentId,
24
+ title: doc.data.title,
25
+ preset,
26
+ savedAs: args.saveAs || null,
27
+ };
28
+ }
29
+ function extractDocumentStyle(docStyle) {
30
+ const config = {};
31
+ if (docStyle?.marginTop?.magnitude !== undefined)
32
+ config.marginTop = docStyle.marginTop.magnitude;
33
+ if (docStyle?.marginBottom?.magnitude !== undefined)
34
+ config.marginBottom = docStyle.marginBottom.magnitude;
35
+ if (docStyle?.marginLeft?.magnitude !== undefined)
36
+ config.marginLeft = docStyle.marginLeft.magnitude;
37
+ if (docStyle?.marginRight?.magnitude !== undefined)
38
+ config.marginRight = docStyle.marginRight.magnitude;
39
+ if (docStyle?.pageSize?.width?.magnitude !== undefined)
40
+ config.pageWidth = docStyle.pageSize.width.magnitude;
41
+ if (docStyle?.pageSize?.height?.magnitude !== undefined)
42
+ config.pageHeight = docStyle.pageSize.height.magnitude;
43
+ return config;
44
+ }
45
+ function extractNamedStyles(namedStyles) {
46
+ const result = {};
47
+ if (!namedStyles?.styles)
48
+ return result;
49
+ for (const style of namedStyles.styles) {
50
+ const type = style.namedStyleType;
51
+ if (!VALID_NAMED_STYLE_TYPES.includes(type))
52
+ continue;
53
+ const config = {};
54
+ const ts = style.textStyle || {};
55
+ const ps = style.paragraphStyle || {};
56
+ // Text style
57
+ if (ts.weightedFontFamily?.fontFamily)
58
+ config.fontFamily = ts.weightedFontFamily.fontFamily;
59
+ if (ts.fontSize?.magnitude !== undefined)
60
+ config.fontSize = ts.fontSize.magnitude;
61
+ if (ts.bold !== undefined)
62
+ config.bold = ts.bold;
63
+ if (ts.italic !== undefined)
64
+ config.italic = ts.italic;
65
+ if (ts.underline !== undefined)
66
+ config.underline = ts.underline;
67
+ if (ts.strikethrough !== undefined)
68
+ config.strikethrough = ts.strikethrough;
69
+ if (ts.smallCaps !== undefined)
70
+ config.smallCaps = ts.smallCaps;
71
+ if (ts.foregroundColor?.color?.rgbColor)
72
+ config.color = rgbToHex(ts.foregroundColor.color.rgbColor);
73
+ if (ts.backgroundColor?.color?.rgbColor)
74
+ config.backgroundColor = rgbToHex(ts.backgroundColor.color.rgbColor);
75
+ if (ts.baselineOffset)
76
+ config.baselineOffset = ts.baselineOffset;
77
+ // Paragraph style
78
+ if (ps.alignment)
79
+ config.alignment = ps.alignment;
80
+ if (ps.lineSpacing !== undefined)
81
+ config.lineSpacing = ps.lineSpacing;
82
+ if (ps.spaceAbove?.magnitude !== undefined)
83
+ config.spaceAbove = ps.spaceAbove.magnitude;
84
+ if (ps.spaceBelow?.magnitude !== undefined)
85
+ config.spaceBelow = ps.spaceBelow.magnitude;
86
+ if (ps.indentFirstLine?.magnitude !== undefined)
87
+ config.indentFirstLine = ps.indentFirstLine.magnitude;
88
+ if (ps.indentStart?.magnitude !== undefined)
89
+ config.indentStart = ps.indentStart.magnitude;
90
+ if (ps.indentEnd?.magnitude !== undefined)
91
+ config.indentEnd = ps.indentEnd.magnitude;
92
+ if (ps.keepLinesTogether !== undefined)
93
+ config.keepLinesTogether = ps.keepLinesTogether;
94
+ if (ps.keepWithNext !== undefined)
95
+ config.keepWithNext = ps.keepWithNext;
96
+ if (ps.direction)
97
+ config.direction = ps.direction;
98
+ if (Object.keys(config).length > 0) {
99
+ result[type] = config;
100
+ }
101
+ }
102
+ return result;
103
+ }
104
+ function extractTableStyles(content) {
105
+ // Find first table and extract its styling as the template
106
+ for (const element of content) {
107
+ if (!element.table)
108
+ continue;
109
+ const table = element.table;
110
+ const firstRow = table.tableRows?.[0];
111
+ if (!firstRow)
112
+ continue;
113
+ const config = {};
114
+ const firstCell = firstRow.tableCells?.[0];
115
+ if (firstCell?.tableCellStyle) {
116
+ const cs = firstCell.tableCellStyle;
117
+ if (cs.backgroundColor?.color?.rgbColor) {
118
+ config.headerBackground = rgbToHex(cs.backgroundColor.color.rgbColor);
119
+ }
120
+ if (cs.borderTop?.width?.magnitude !== undefined) {
121
+ config.borderWidth = cs.borderTop.width.magnitude;
122
+ }
123
+ if (cs.borderTop?.color?.color?.rgbColor) {
124
+ config.borderColor = rgbToHex(cs.borderTop.color.color.rgbColor);
125
+ }
126
+ if (cs.paddingTop?.magnitude !== undefined) {
127
+ config.cellPadding = cs.paddingTop.magnitude;
128
+ }
129
+ }
130
+ // Check second row for alternating background
131
+ if (table.tableRows?.length > 2) {
132
+ const thirdRow = table.tableRows[2];
133
+ const thirdCell = thirdRow?.tableCells?.[0];
134
+ if (thirdCell?.tableCellStyle?.backgroundColor?.color?.rgbColor) {
135
+ config.alternateRowBackground = rgbToHex(thirdCell.tableCellStyle.backgroundColor.color.rgbColor);
136
+ }
137
+ }
138
+ if (Object.keys(config).length > 0)
139
+ return config;
140
+ break;
141
+ }
142
+ return undefined;
143
+ }
144
+ function rgbToHex(rgb) {
145
+ const r = Math.round((rgb.red || 0) * 255);
146
+ const g = Math.round((rgb.green || 0) * 255);
147
+ const b = Math.round((rgb.blue || 0) * 255);
148
+ return '#' + [r, g, b].map(c => c.toString(16).padStart(2, '0')).join('');
149
+ }
@@ -0,0 +1,20 @@
1
+ import { z } from 'zod';
2
+ export declare const FormatListSchema: z.ZodObject<{
3
+ documentId: z.ZodString;
4
+ startIndex: z.ZodNumber;
5
+ endIndex: z.ZodNumber;
6
+ listType: z.ZodEnum<{
7
+ none: "none";
8
+ bullet: "bullet";
9
+ numbered: "numbered";
10
+ }>;
11
+ preset: z.ZodOptional<z.ZodString>;
12
+ }, z.core.$strip>;
13
+ export declare function formatList(args: z.infer<typeof FormatListSchema>): Promise<{
14
+ documentId: string;
15
+ listType: "none" | "bullet" | "numbered";
16
+ preset: string | null;
17
+ startIndex: number;
18
+ endIndex: number;
19
+ _apiMs: number;
20
+ }>;
@@ -0,0 +1,74 @@
1
+ import { google } from 'googleapis';
2
+ import { z } from 'zod';
3
+ import { getAuthClient } from '../auth/google-auth.js';
4
+ const BULLET_PRESETS = [
5
+ 'BULLET_DISC_CIRCLE_SQUARE',
6
+ 'BULLET_DIAMONDX_ARROW3D_SQUARE',
7
+ 'BULLET_CHECKBOX',
8
+ ];
9
+ const NUMBERED_PRESETS = [
10
+ 'NUMBERED_DECIMAL_ALPHA_ROMAN',
11
+ 'NUMBERED_DECIMAL_NESTED',
12
+ 'NUMBERED_UPPERALPHA_ALPHA_ROMAN',
13
+ ];
14
+ const ALL_PRESETS = [...BULLET_PRESETS, ...NUMBERED_PRESETS];
15
+ export const FormatListSchema = z.object({
16
+ documentId: z.string().describe('The Google Doc document ID'),
17
+ startIndex: z.number().describe('Start index of the paragraph range'),
18
+ endIndex: z.number().describe('End index of the paragraph range'),
19
+ listType: z.enum(['bullet', 'numbered', 'none']).describe('"bullet" for unordered, "numbered" for ordered, "none" to remove list formatting'),
20
+ preset: z.string().optional().describe('Bullet/number preset. Bullets: BULLET_DISC_CIRCLE_SQUARE (default), BULLET_DIAMONDX_ARROW3D_SQUARE, BULLET_CHECKBOX. ' +
21
+ 'Numbers: NUMBERED_DECIMAL_ALPHA_ROMAN (default), NUMBERED_DECIMAL_NESTED, NUMBERED_UPPERALPHA_ALPHA_ROMAN.'),
22
+ });
23
+ export async function formatList(args) {
24
+ const auth = getAuthClient();
25
+ const docs = google.docs({ version: 'v1', auth });
26
+ const apiStart = performance.now();
27
+ const requests = [];
28
+ if (args.listType === 'none') {
29
+ requests.push({
30
+ deleteParagraphBullets: {
31
+ range: { startIndex: args.startIndex, endIndex: args.endIndex },
32
+ },
33
+ });
34
+ }
35
+ else {
36
+ const defaultPreset = args.listType === 'bullet'
37
+ ? 'BULLET_DISC_CIRCLE_SQUARE'
38
+ : 'NUMBERED_DECIMAL_ALPHA_ROMAN';
39
+ const preset = args.preset || defaultPreset;
40
+ // Validate preset matches list type
41
+ if (args.listType === 'bullet' && !BULLET_PRESETS.includes(preset)) {
42
+ if (NUMBERED_PRESETS.includes(preset)) {
43
+ throw new Error(`Preset "${preset}" is a numbered preset. Use listType: "numbered" or pick a bullet preset: ${BULLET_PRESETS.join(', ')}`);
44
+ }
45
+ }
46
+ if (args.listType === 'numbered' && !NUMBERED_PRESETS.includes(preset)) {
47
+ if (BULLET_PRESETS.includes(preset)) {
48
+ throw new Error(`Preset "${preset}" is a bullet preset. Use listType: "bullet" or pick a numbered preset: ${NUMBERED_PRESETS.join(', ')}`);
49
+ }
50
+ }
51
+ if (!ALL_PRESETS.includes(preset)) {
52
+ throw new Error(`Unknown preset "${preset}". Available: ${ALL_PRESETS.join(', ')}`);
53
+ }
54
+ requests.push({
55
+ createParagraphBullets: {
56
+ range: { startIndex: args.startIndex, endIndex: args.endIndex },
57
+ bulletPreset: preset,
58
+ },
59
+ });
60
+ }
61
+ await docs.documents.batchUpdate({
62
+ documentId: args.documentId,
63
+ requestBody: { requests },
64
+ });
65
+ const apiMs = Math.round(performance.now() - apiStart);
66
+ return {
67
+ documentId: args.documentId,
68
+ listType: args.listType,
69
+ preset: args.listType === 'none' ? null : (args.preset || (args.listType === 'bullet' ? 'BULLET_DISC_CIRCLE_SQUARE' : 'NUMBERED_DECIMAL_ALPHA_ROMAN')),
70
+ startIndex: args.startIndex,
71
+ endIndex: args.endIndex,
72
+ _apiMs: apiMs,
73
+ };
74
+ }
@@ -0,0 +1,18 @@
1
+ import { z } from 'zod';
2
+ export declare const ListStylePresetsSchema: z.ZodObject<{}, z.core.$strip>;
3
+ export declare function listStylePresets(): Promise<{
4
+ activePreset: string;
5
+ presets: {
6
+ name: string;
7
+ source: "built-in" | "user";
8
+ isActive: boolean;
9
+ summary: {
10
+ bodyFont: string;
11
+ bodySize: string | number;
12
+ headingFont: string;
13
+ headingColor: string;
14
+ lineSpacing: string | number;
15
+ };
16
+ }[];
17
+ count: number;
18
+ }>;
@@ -0,0 +1,29 @@
1
+ import { z } from 'zod';
2
+ import { listAllPresets, getActivePresetName } from '../presets/config.js';
3
+ export const ListStylePresetsSchema = z.object({});
4
+ export async function listStylePresets() {
5
+ const presets = listAllPresets();
6
+ const activePreset = getActivePresetName();
7
+ const result = Object.entries(presets).map(([name, { source, preset }]) => {
8
+ const styles = preset.styles || {};
9
+ const normalText = styles.NORMAL_TEXT || {};
10
+ const heading1 = styles.HEADING_1 || {};
11
+ return {
12
+ name,
13
+ source,
14
+ isActive: name === activePreset,
15
+ summary: {
16
+ bodyFont: normalText.fontFamily || 'default',
17
+ bodySize: normalText.fontSize || 'default',
18
+ headingFont: heading1.fontFamily || normalText.fontFamily || 'default',
19
+ headingColor: heading1.color || 'default',
20
+ lineSpacing: normalText.lineSpacing || 'default',
21
+ },
22
+ };
23
+ });
24
+ return {
25
+ activePreset: activePreset || 'none',
26
+ presets: result,
27
+ count: result.length,
28
+ };
29
+ }
@@ -0,0 +1,8 @@
1
+ import { z } from 'zod';
2
+ export declare const SetActivePresetSchema: z.ZodObject<{
3
+ presetName: z.ZodString;
4
+ }, z.core.$strip>;
5
+ export declare function setActivePreset(args: z.infer<typeof SetActivePresetSchema>): Promise<{
6
+ activePreset: string;
7
+ message: string;
8
+ }>;
@@ -0,0 +1,12 @@
1
+ import { z } from 'zod';
2
+ import { setActivePreset as setActive } from '../presets/config.js';
3
+ export const SetActivePresetSchema = z.object({
4
+ presetName: z.string().describe('Name of the preset to set as active. The active preset auto-applies when creating new documents.'),
5
+ });
6
+ export async function setActivePreset(args) {
7
+ setActive(args.presetName);
8
+ return {
9
+ activePreset: args.presetName,
10
+ message: `Active preset set to "${args.presetName}". New documents will use this preset automatically.`,
11
+ };
12
+ }
@@ -0,0 +1,16 @@
1
+ import { z } from 'zod';
2
+ export declare const UpdateHeaderFooterSchema: z.ZodObject<{
3
+ documentId: z.ZodString;
4
+ section: z.ZodEnum<{
5
+ header: "header";
6
+ footer: "footer";
7
+ }>;
8
+ content: z.ZodOptional<z.ZodString>;
9
+ }, z.core.$strip>;
10
+ export declare function updateHeaderFooter(args: z.infer<typeof UpdateHeaderFooterSchema>): Promise<{
11
+ documentId: string;
12
+ section: "header" | "footer";
13
+ created: boolean;
14
+ contentInserted: boolean;
15
+ _apiMs: number;
16
+ }>;
@@ -0,0 +1,103 @@
1
+ import { google } from 'googleapis';
2
+ import { z } from 'zod';
3
+ import { getAuthClient } from '../auth/google-auth.js';
4
+ export const UpdateHeaderFooterSchema = z.object({
5
+ documentId: z.string().describe('The Google Doc document ID'),
6
+ section: z.enum(['header', 'footer']).describe('Which section to update: "header" or "footer"'),
7
+ content: z.string().optional().describe('Text content to insert. Replaces existing content. Use {{pageNumber}} to insert a page number field.'),
8
+ });
9
+ export async function updateHeaderFooter(args) {
10
+ const auth = getAuthClient();
11
+ const docs = google.docs({ version: 'v1', auth });
12
+ const apiStart = performance.now();
13
+ // Read document to check if header/footer exists
14
+ const doc = await docs.documents.get({ documentId: args.documentId });
15
+ const isHeader = args.section === 'header';
16
+ const existingId = isHeader
17
+ ? doc.data.headers && Object.keys(doc.data.headers)[0]
18
+ : doc.data.footers && Object.keys(doc.data.footers)[0];
19
+ const requests = [];
20
+ // Create header/footer if it doesn't exist
21
+ if (!existingId) {
22
+ requests.push(isHeader
23
+ ? { createHeader: { type: 'DEFAULT', sectionBreakLocation: { index: 0 } } }
24
+ : { createFooter: { type: 'DEFAULT', sectionBreakLocation: { index: 0 } } });
25
+ // Need to execute first to get the ID, then insert content
26
+ if (args.content) {
27
+ await docs.documents.batchUpdate({
28
+ documentId: args.documentId,
29
+ requestBody: { requests },
30
+ });
31
+ // Re-read to get the new ID
32
+ const updated = await docs.documents.get({ documentId: args.documentId });
33
+ const newId = isHeader
34
+ ? Object.keys(updated.data.headers || {})[0]
35
+ : Object.keys(updated.data.footers || {})[0];
36
+ if (newId && args.content) {
37
+ await insertContent(docs, args.documentId, newId, args.content, isHeader, updated.data);
38
+ }
39
+ const apiMs = Math.round(performance.now() - apiStart);
40
+ return {
41
+ documentId: args.documentId,
42
+ section: args.section,
43
+ created: true,
44
+ contentInserted: !!args.content,
45
+ _apiMs: apiMs,
46
+ };
47
+ }
48
+ await docs.documents.batchUpdate({
49
+ documentId: args.documentId,
50
+ requestBody: { requests },
51
+ });
52
+ const apiMs = Math.round(performance.now() - apiStart);
53
+ return {
54
+ documentId: args.documentId,
55
+ section: args.section,
56
+ created: true,
57
+ contentInserted: false,
58
+ _apiMs: apiMs,
59
+ };
60
+ }
61
+ // Header/footer exists — update content if provided
62
+ if (args.content) {
63
+ await insertContent(docs, args.documentId, existingId, args.content, isHeader, doc.data);
64
+ }
65
+ const apiMs = Math.round(performance.now() - apiStart);
66
+ return {
67
+ documentId: args.documentId,
68
+ section: args.section,
69
+ created: false,
70
+ contentInserted: !!args.content,
71
+ _apiMs: apiMs,
72
+ };
73
+ }
74
+ async function insertContent(docs, documentId, sectionId, content, isHeader, docData) {
75
+ const sectionData = isHeader
76
+ ? docData.headers?.[sectionId]
77
+ : docData.footers?.[sectionId];
78
+ if (!sectionData?.content)
79
+ return;
80
+ const sectionContent = sectionData.content;
81
+ const startIndex = sectionContent[0]?.startIndex || 0;
82
+ const endIndex = sectionContent[sectionContent.length - 1]?.endIndex || startIndex + 1;
83
+ const requests = [];
84
+ // Delete existing content
85
+ if (endIndex > startIndex + 1) {
86
+ requests.push({
87
+ deleteContentRange: {
88
+ range: { segmentId: sectionId, startIndex: startIndex, endIndex: endIndex - 1 },
89
+ },
90
+ });
91
+ }
92
+ // Insert new content
93
+ requests.push({
94
+ insertText: {
95
+ location: { segmentId: sectionId, index: startIndex },
96
+ text: content.replace('{{pageNumber}}', ''),
97
+ },
98
+ });
99
+ await docs.documents.batchUpdate({
100
+ documentId,
101
+ requestBody: { requests },
102
+ });
103
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdocs-mcp",
3
- "version": "0.1.0",
3
+ "version": "0.3.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",
@@ -30,7 +30,7 @@
30
30
  "license": "MIT",
31
31
  "repository": {
32
32
  "type": "git",
33
- "url": "https://github.com/apurwa-sudo/gdocs-mcp"
33
+ "url": "https://github.com/Apurwa/gdocs-mcp"
34
34
  },
35
35
  "engines": {
36
36
  "node": ">=18"