@brainwavesio/google-docs-mcp 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,481 +0,0 @@
1
- // src/server.ts
2
- import { FastMCP, UserError } from 'fastmcp';
3
- import { z } from 'zod';
4
- import { google, docs_v1 } from 'googleapis';
5
- import { authorize } from './auth.js';
6
- import { OAuth2Client } from 'google-auth-library';
7
-
8
- // --- Helper function for hex color validation (basic) ---
9
- const hexColorRegex = /^#?([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/;
10
- const validateHexColor = (color: string) => hexColorRegex.test(color);
11
-
12
- // --- Helper function for Hex to RGB conversion ---
13
- /**
14
- * Converts a hex color string to a Google Docs API RgbColor object.
15
- * @param hex - The hex color string (e.g., "#FF0000", "#F00", "FF0000").
16
- * @returns A Google Docs API RgbColor object or null if invalid.
17
- */
18
- function hexToRgbColor(hex: string): docs_v1.Schema$RgbColor | null {
19
- if (!hex) return null;
20
- let hexClean = hex.startsWith('#') ? hex.slice(1) : hex;
21
-
22
- // Expand shorthand form (e.g. "F00") to full form (e.g. "FF0000")
23
- if (hexClean.length === 3) {
24
- hexClean = hexClean[0] + hexClean[0] + hexClean[1] + hexClean[1] + hexClean[2] + hexClean[2];
25
- }
26
-
27
- if (hexClean.length !== 6) {
28
- return null; // Invalid length
29
- }
30
-
31
- const bigint = parseInt(hexClean, 16);
32
- if (isNaN(bigint)) {
33
- return null; // Invalid hex characters
34
- }
35
-
36
- // Extract RGB values and normalize to 0.0 - 1.0 range
37
- const r = ((bigint >> 16) & 255) / 255;
38
- const g = ((bigint >> 8) & 255) / 255;
39
- const b = (bigint & 255) / 255;
40
-
41
- return { red: r, green: g, blue: b };
42
- }
43
-
44
- // --- Zod Schema for the formatText tool ---
45
- // const FormatTextParameters = z.object({
46
- // documentId: z.string().describe('The ID of the Google Document.'),
47
- // startIndex: z.number().int().min(1).describe('The starting index of the text range (inclusive, starts from 1).'),
48
- // endIndex: z.number().int().min(1).describe('The ending index of the text range (inclusive).'),
49
- // // Optional Formatting Parameters (SHARED)
50
- // bold: z.boolean().optional().describe('Apply bold formatting.'),
51
- // italic: z.boolean().optional().describe('Apply italic formatting.'),
52
- // underline: z.boolean().optional().describe('Apply underline formatting.'),
53
- // strikethrough: z.boolean().optional().describe('Apply strikethrough formatting.'),
54
- // fontSize: z.number().min(1).optional().describe('Set font size (in points, e.g., 12).'),
55
- // fontFamily: z.string().optional().describe('Set font family (e.g., "Arial", "Times New Roman").'),
56
- // foregroundColor: z.string()
57
- // .refine(validateHexColor, { message: "Invalid hex color format (e.g., #FF0000 or #F00)" })
58
- // .optional()
59
- // .describe('Set text color using hex format (e.g., "#FF0000").'),
60
- // backgroundColor: z.string()
61
- // .refine(validateHexColor, { message: "Invalid hex color format (e.g., #00FF00 or #0F0)" })
62
- // .optional()
63
- // .describe('Set text background color using hex format (e.g., "#FFFF00").'),
64
- // linkUrl: z.string().url().optional().describe('Make the text a hyperlink pointing to this URL.')
65
- // })
66
- // .refine(data => data.endIndex >= data.startIndex, {
67
- // message: "endIndex must be greater than or equal to startIndex",
68
- // path: ["endIndex"],
69
- // })
70
- // .refine(data => Object.keys(data).some(key => !['documentId', 'startIndex', 'endIndex'].includes(key) && data[key as keyof typeof data] !== undefined), {
71
- // message: "At least one formatting option (bold, italic, fontSize, etc.) must be provided."
72
- // });
73
-
74
- // --- Define the TypeScript type based on the schema ---
75
- // type FormatTextArgs = z.infer<typeof FormatTextParameters>;
76
-
77
- // --- Zod Schema for the NEW formatMatchingText tool ---
78
- const FormatMatchingTextParameters = z.object({
79
- documentId: z.string().describe('The ID of the Google Document.'),
80
- textToFind: z.string().min(1).describe('The exact text string to find and format.'),
81
- matchInstance: z.number().int().min(1).optional().default(1).describe('Which instance of the text to format (1st, 2nd, etc.). Defaults to 1.'),
82
- // Re-use optional Formatting Parameters (SHARED)
83
- bold: z.boolean().optional().describe('Apply bold formatting.'),
84
- italic: z.boolean().optional().describe('Apply italic formatting.'),
85
- underline: z.boolean().optional().describe('Apply underline formatting.'),
86
- strikethrough: z.boolean().optional().describe('Apply strikethrough formatting.'),
87
- fontSize: z.number().min(1).optional().describe('Set font size (in points, e.g., 12).'),
88
- fontFamily: z.string().optional().describe('Set font family (e.g., "Arial", "Times New Roman").'),
89
- foregroundColor: z.string()
90
- .refine(validateHexColor, { message: "Invalid hex color format (e.g., #FF0000 or #F00)" })
91
- .optional()
92
- .describe('Set text color using hex format (e.g., "#FF0000").'),
93
- backgroundColor: z.string()
94
- .refine(validateHexColor, { message: "Invalid hex color format (e.g., #00FF00 or #0F0)" })
95
- .optional()
96
- .describe('Set text background color using hex format (e.g., "#FFFF00").'),
97
- linkUrl: z.string().url().optional().describe('Make the text a hyperlink pointing to this URL.')
98
- })
99
- .refine(data => Object.keys(data).some(key => !['documentId', 'textToFind', 'matchInstance'].includes(key) && data[key as keyof typeof data] !== undefined), {
100
- message: "At least one formatting option (bold, italic, fontSize, etc.) must be provided."
101
- });
102
-
103
- // --- Define the TypeScript type based on the new schema ---
104
- type FormatMatchingTextArgs = z.infer<typeof FormatMatchingTextParameters>;
105
-
106
- // --- Helper function to build TextStyle and fields mask (reusable) ---
107
- function buildTextStyleAndFields(args: Omit<FormatMatchingTextArgs, 'documentId' | 'textToFind' | 'matchInstance'>): { textStyle: docs_v1.Schema$TextStyle, fields: string[] } {
108
- const textStyle: docs_v1.Schema$TextStyle = {};
109
- const fieldsToUpdate: string[] = [];
110
-
111
- if (args.bold !== undefined) { textStyle.bold = args.bold; fieldsToUpdate.push('bold'); }
112
- if (args.italic !== undefined) { textStyle.italic = args.italic; fieldsToUpdate.push('italic'); }
113
- if (args.underline !== undefined) { textStyle.underline = args.underline; fieldsToUpdate.push('underline'); }
114
- if (args.strikethrough !== undefined) { textStyle.strikethrough = args.strikethrough; fieldsToUpdate.push('strikethrough'); }
115
- if (args.fontSize !== undefined) {
116
- textStyle.fontSize = { magnitude: args.fontSize, unit: 'PT' };
117
- fieldsToUpdate.push('fontSize');
118
- }
119
- if (args.fontFamily !== undefined) {
120
- textStyle.weightedFontFamily = { fontFamily: args.fontFamily };
121
- fieldsToUpdate.push('weightedFontFamily');
122
- }
123
- if (args.foregroundColor !== undefined) {
124
- const rgbColor = hexToRgbColor(args.foregroundColor);
125
- if (!rgbColor) throw new UserError(`Invalid foreground hex color format: ${args.foregroundColor}`);
126
- textStyle.foregroundColor = { color: { rgbColor: rgbColor } };
127
- fieldsToUpdate.push('foregroundColor');
128
- }
129
- if (args.backgroundColor !== undefined) {
130
- const rgbColor = hexToRgbColor(args.backgroundColor);
131
- if (!rgbColor) throw new UserError(`Invalid background hex color format: ${args.backgroundColor}`);
132
- textStyle.backgroundColor = { color: { rgbColor: rgbColor } };
133
- fieldsToUpdate.push('backgroundColor');
134
- }
135
- if (args.linkUrl !== undefined) {
136
- textStyle.link = { url: args.linkUrl };
137
- fieldsToUpdate.push('link');
138
- }
139
-
140
- if (fieldsToUpdate.length === 0) {
141
- // This should ideally be caught by Zod refine, but defensive check
142
- throw new UserError("No formatting options were specified.");
143
- }
144
-
145
- return { textStyle, fields: fieldsToUpdate };
146
- }
147
-
148
- let authClient: OAuth2Client | null = null;
149
- let googleDocs: docs_v1.Docs | null = null;
150
-
151
- async function initializeGoogleClient() {
152
- if (googleDocs) return { authClient, googleDocs };
153
- if (authClient === null && googleDocs === null) {
154
- try {
155
- console.error("Attempting to authorize Google API client...");
156
- const client = await authorize();
157
- if (client) {
158
- authClient = client;
159
- googleDocs = google.docs({ version: 'v1', auth: authClient });
160
- console.error("Google API client authorized successfully.");
161
- } else {
162
- console.error("FATAL: Authorization returned null or undefined client.");
163
- authClient = null;
164
- googleDocs = null;
165
- }
166
- } catch (error) {
167
- console.error("FATAL: Failed to initialize Google API client:", error);
168
- authClient = null;
169
- googleDocs = null;
170
- }
171
- }
172
- return { authClient, googleDocs };
173
- }
174
-
175
- const server = new FastMCP({
176
- name: 'Google Docs MCP Server',
177
- version: '1.0.0',
178
- });
179
-
180
- // Tool: Read Google Doc
181
- server.addTool({
182
- name: 'readGoogleDoc',
183
- description: 'Reads the content of a specific Google Document.',
184
- parameters: z.object({
185
- documentId: z.string().describe('The ID of the Google Document (from the URL).'),
186
- }),
187
- execute: async (args, { log }) => {
188
- const { googleDocs: docs } = await initializeGoogleClient();
189
- if (!docs) throw new UserError("Google Docs client not initialized.");
190
-
191
- log.info(`Reading Google Doc: ${args.documentId}`);
192
- try {
193
- const res = await docs.documents.get({
194
- documentId: args.documentId,
195
- fields: 'body(content)',
196
- });
197
- log.info(`Fetched doc: ${args.documentId}`);
198
-
199
- let textContent = '';
200
- res.data.body?.content?.forEach(element => {
201
- element.paragraph?.elements?.forEach(pe => {
202
- textContent += pe.textRun?.content || '';
203
- });
204
- });
205
-
206
- if (!textContent.trim()) return "Document found, but appears empty.";
207
-
208
- const maxLength = 2000;
209
- const truncatedContent = textContent.length > maxLength ? textContent.substring(0, maxLength) + '... [truncated]' : textContent;
210
- return `Content:\n---\n${truncatedContent}`;
211
- } catch (error: any) {
212
- log.error(`Error reading doc ${args.documentId}: ${error.message}`);
213
- if (error.code === 404) throw new UserError(`Doc not found (ID: ${args.documentId}).`);
214
- if (error.code === 403) throw new UserError(`Permission denied for doc (ID: ${args.documentId}).`);
215
- throw new UserError(`Failed to read doc: ${error.message}`);
216
- }
217
- },
218
- });
219
-
220
- // Tool: Append to Google Doc
221
- server.addTool({
222
- name: 'appendToGoogleDoc',
223
- description: 'Appends text to the end of a specific Google Document.',
224
- parameters: z.object({
225
- documentId: z.string().describe('The ID of the Google Document.'),
226
- textToAppend: z.string().describe('The text to add.'),
227
- }),
228
- execute: async (args, { log }) => {
229
- const { googleDocs: docs } = await initializeGoogleClient();
230
- if (!docs) throw new UserError("Google Docs client not initialized.");
231
-
232
- log.info(`Appending to Google Doc: ${args.documentId}`);
233
- try {
234
- const docInfo = await docs.documents.get({ documentId: args.documentId, fields: 'body(content)' });
235
- let endIndex = 1;
236
- if (docInfo.data.body?.content) {
237
- const lastElement = docInfo.data.body.content[docInfo.data.body.content.length - 1];
238
- if (lastElement?.endIndex) endIndex = lastElement.endIndex - 1;
239
- }
240
- const textToInsert = (endIndex > 1 && !args.textToAppend.startsWith('\n') ? '\n' : '') + args.textToAppend;
241
-
242
- await docs.documents.batchUpdate({
243
- documentId: args.documentId,
244
- requestBody: { requests: [{ insertText: { location: { index: endIndex }, text: textToInsert } }] },
245
- });
246
-
247
- log.info(`Successfully appended to doc: ${args.documentId}`);
248
- return `Successfully appended text to document ${args.documentId}.`;
249
- } catch (error: any) {
250
- log.error(`Error editing doc ${args.documentId}: ${error.message}`);
251
- if (error.code === 404) throw new UserError(`Doc not found (ID: ${args.documentId}).`);
252
- if (error.code === 403) throw new UserError(`Permission denied for doc (ID: ${args.documentId}).`);
253
- throw new UserError(`Failed to edit doc: ${error.message}`);
254
- }
255
- },
256
- });
257
-
258
- // --- Add the formatMatchingText tool ---
259
- server.addTool({
260
- name: 'formatMatchingText',
261
- description: 'Finds specific text within a Google Document and applies character formatting (bold, italics, color, etc.) to the specified instance.',
262
- parameters: FormatMatchingTextParameters, // Use the new Zod schema
263
- execute: async (args: FormatMatchingTextArgs, { log }) => {
264
- const { googleDocs: docs } = await initializeGoogleClient();
265
- if (!docs) {
266
- throw new UserError("Google Docs client is not initialized. Authentication might have failed.");
267
- }
268
-
269
- log.info(`Attempting to find text "${args.textToFind}" (instance ${args.matchInstance}) in doc: ${args.documentId} and format it.`);
270
-
271
- // 1. Get the document content to find the text range
272
- let docContent: docs_v1.Schema$Document;
273
- try {
274
- const res = await docs.documents.get({
275
- documentId: args.documentId,
276
- // Request fields needed to reconstruct text and find indices
277
- fields: 'body(content(paragraph(elements(startIndex,endIndex,textRun(content)))))',
278
- });
279
- docContent = res.data;
280
- if (!docContent.body?.content) {
281
- throw new UserError(`Document body or content is empty or inaccessible (ID: ${args.documentId}).`);
282
- }
283
- log.info(`Fetched doc content for searching: ${args.documentId}`);
284
- } catch (error: any) {
285
- log.error(`Error retrieving doc ${args.documentId} for search: ${error.message}`);
286
- if (error.code === 404) throw new UserError(`Doc not found (ID: ${args.documentId}).`);
287
- if (error.code === 403) throw new UserError(`Permission denied for doc (ID: ${args.documentId}).`);
288
- throw new UserError(`Failed to retrieve doc for searching: ${error.message}`);
289
- }
290
-
291
- // 2. Find the Nth instance of the text and its range
292
- let fullText = '';
293
- const textSegments: { text: string, start: number, end: number }[] = [];
294
- docContent.body.content.forEach(element => {
295
- element.paragraph?.elements?.forEach(pe => {
296
- if (pe.textRun?.content && pe.startIndex && pe.endIndex) {
297
- // Handle potential line breaks within content
298
- const content = pe.textRun.content;
299
- fullText += content;
300
- textSegments.push({
301
- text: content,
302
- start: pe.startIndex,
303
- end: pe.endIndex
304
- });
305
- }
306
- });
307
- });
308
-
309
- let startIndex = -1;
310
- let endIndex = -1;
311
- let foundCount = 0;
312
- let searchStartIndex = 0;
313
-
314
- while (foundCount < args.matchInstance) {
315
- const currentIndex = fullText.indexOf(args.textToFind, searchStartIndex);
316
- if (currentIndex === -1) {
317
- // Text not found anymore
318
- break;
319
- }
320
- foundCount++;
321
- if (foundCount === args.matchInstance) {
322
- // Found the start of the Nth match in the *reconstructed* string.
323
- // Map this back to the API's startIndex/endIndex.
324
- const targetStartInFullText = currentIndex;
325
- const targetEndInFullText = currentIndex + args.textToFind.length;
326
- let currentPosInFullText = 0;
327
-
328
- for (const seg of textSegments) {
329
- const segStartInFullText = currentPosInFullText;
330
- // Length of segment text might differ from index range if it contains newlines etc.
331
- const segTextLength = seg.text.length;
332
- const segEndInFullText = segStartInFullText + segTextLength;
333
-
334
- // Check if the target *starts* within this segment's text span
335
- if (startIndex === -1 && targetStartInFullText >= segStartInFullText && targetStartInFullText < segEndInFullText) {
336
- // Calculate the API start index relative to the segment's start index
337
- startIndex = seg.start + (targetStartInFullText - segStartInFullText);
338
- }
339
-
340
- // Check if the target *ends* within this segment's text span
341
- if (targetEndInFullText > segStartInFullText && targetEndInFullText <= segEndInFullText) {
342
- // Calculate the API end index relative to the segment's start index
343
- endIndex = seg.start + (targetEndInFullText - segStartInFullText);
344
- break; // Found the end, we have the full range
345
- }
346
-
347
- currentPosInFullText = segEndInFullText;
348
- }
349
-
350
- if (startIndex === -1 || endIndex === -1) {
351
- log.warn(`Could not accurately map indices for match ${foundCount} of "${args.textToFind}". Start found at ${targetStartInFullText}, End at ${targetEndInFullText}. Resetting.`);
352
- // Reset if we couldn't map indices correctly for this match
353
- startIndex = -1;
354
- endIndex = -1;
355
- // Don't break the outer loop, let it try searching again
356
- }
357
- }
358
- // Continue searching after the start of the current match to find subsequent occurrences
359
- searchStartIndex = currentIndex + 1;
360
- }
361
-
362
-
363
- if (startIndex === -1 || endIndex === -1) {
364
- throw new UserError(`Could not find instance ${args.matchInstance} of the text "${args.textToFind}" in document ${args.documentId}. Found ${foundCount} total instance(s).`);
365
- }
366
-
367
- log.info(`Found text "${args.textToFind}" (instance ${args.matchInstance}) at mapped range: ${startIndex}-${endIndex}`);
368
-
369
- // 3. Build the TextStyle object and fields mask
370
- const { textStyle, fields } = buildTextStyleAndFields(args);
371
-
372
-
373
- // 4. Build the UpdateTextStyleRequest
374
- const updateTextStyleRequest: docs_v1.Schema$UpdateTextStyleRequest = {
375
- range: {
376
- // API uses segmentId, but omitting it defaults to the document BODY
377
- startIndex: startIndex, // Use the calculated start index
378
- endIndex: endIndex, // Use the calculated end index
379
- },
380
- textStyle: textStyle,
381
- fields: fields.join(','), // Crucial: Tells API which fields to update
382
- };
383
-
384
- // 5. Send the batchUpdate request
385
- try {
386
- await docs.documents.batchUpdate({
387
- documentId: args.documentId,
388
- requestBody: {
389
- requests: [{ updateTextStyle: updateTextStyleRequest }],
390
- },
391
- });
392
- log.info(`Successfully formatted text in doc: ${args.documentId}, range: ${startIndex}-${endIndex}`);
393
- return `Successfully applied formatting to instance ${args.matchInstance} of "${args.textToFind}".`;
394
- } catch (error: any) {
395
- log.error(`Error formatting text in doc ${args.documentId}: ${error.message}`);
396
- // Consider more specific error handling based on API response if needed
397
- throw new UserError(`Failed to apply formatting: ${error.message}`);
398
- }
399
- },
400
- });
401
-
402
- // Tool: Format Text (existing, keep for index-based formatting if needed)
403
- // server.addTool({
404
- // name: 'formatText',
405
- // description: 'Applies character formatting (bold, italics, font size, color, link, etc.) to a specific text range in a Google Document using start/end indices.',
406
- // parameters: FormatTextParameters, // Use the original Zod schema
407
- // execute: async (args: FormatTextArgs, { log }) => {
408
- // const { googleDocs: docs } = await initializeGoogleClient();
409
- // if (!docs) {
410
- // throw new UserError("Google Docs client is not initialized. Authentication might have failed.");
411
- // }
412
- //
413
- // log.info(`Attempting to format text in doc: ${args.documentId}, range: ${args.startIndex}-${args.endIndex}`);
414
- //
415
- // // 1. Build the TextStyle object and fields mask
416
- // const { textStyle, fields } = buildTextStyleAndFields(args);
417
- //
418
- // // 2. Build the UpdateTextStyleRequest
419
- // const updateTextStyleRequest: docs_v1.Schema$UpdateTextStyleRequest = {
420
- // range: {
421
- // startIndex: args.startIndex,
422
- // endIndex: args.endIndex,
423
- // },
424
- // textStyle: textStyle,
425
- // fields: fields.join(','),
426
- // };
427
- //
428
- // // 3. Send the batchUpdate request
429
- // try {
430
- // await docs.documents.batchUpdate({
431
- // documentId: args.documentId,
432
- // requestBody: {
433
- // requests: [{ updateTextStyle: updateTextStyleRequest }],
434
- // },
435
- // });
436
- // log.info(`Successfully formatted text in doc: ${args.documentId}, range: ${args.startIndex}-${args.endIndex}`);
437
- // return `Successfully applied formatting to range ${args.startIndex}-${args.endIndex}.`;
438
- // } catch (error: any) {
439
- // log.error(`Error formatting text in doc ${args.documentId}: ${error.message}`);
440
- // if (error.code === 404) throw new UserError(`Doc not found (ID: ${args.documentId}).`);
441
- // if (error.code === 403) throw new UserError(`Permission denied for doc (ID: ${args.documentId}).`);
442
- // throw new UserError(`Failed to format text: ${error.message}`);
443
- // }
444
- // },
445
- // });
446
-
447
- // Start the Server (Modified to avoid server.config issue)
448
- async function startServer() {
449
- await initializeGoogleClient(); // Authorize before starting listeners
450
- console.error("Starting MCP server...");
451
- try {
452
- const configToUse = {
453
- // Choose one transport:
454
- transportType: "stdio" as const,
455
- // transportType: "sse" as const,
456
- // sse: { // <-- COMMENT OUT or DELETE SSE config
457
- // endpoint: "/sse" as const,
458
- // port: 8080,
459
- // },
460
- };
461
-
462
- server.start(configToUse); // Start the server with stdio config
463
-
464
- // Adjust logging (optional, but good practice)
465
- console.error(`MCP Server running using ${configToUse.transportType}.`);
466
- if (configToUse.transportType === 'stdio') {
467
- console.error("Awaiting MCP client connection via stdio...");
468
- }
469
- // Removed SSE-specific logging
470
-
471
- } catch(startError) {
472
- console.error("Error occurred during server.start():", startError);
473
- throw startError; // Re-throw to be caught by the outer catch
474
- }
475
- }
476
-
477
- // Call the modified startServer function
478
- startServer().catch(err => {
479
- console.error("Server failed to start:", err);
480
- process.exit(1);
481
- });