@brainwavesio/google-docs-mcp 1.0.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/.repomix/bundles.json +3 -0
- package/LICENSE +7 -0
- package/README.md +588 -0
- package/SAMPLE_TASKS.md +230 -0
- package/assets/google.docs.mcp.1.gif +0 -0
- package/claude.md +46 -0
- package/docs/index.html +228 -0
- package/google docs mcp.mp4 +0 -0
- package/index.js +6 -0
- package/package.json +40 -0
- package/pages/pages.md +0 -0
- package/repomix-output.txt.xml +4447 -0
- package/src/auth.ts +228 -0
- package/src/backup/auth.ts.bak +101 -0
- package/src/backup/server.ts.bak +481 -0
- package/src/googleDocsApiHelpers.ts +710 -0
- package/src/googleSheetsApiHelpers.ts +427 -0
- package/src/server.ts +2494 -0
- package/src/types.ts +136 -0
- package/tests/helpers.test.js +164 -0
- package/tests/types.test.js +69 -0
- package/tsconfig.json +17 -0
- package/vscode.md +168 -0
|
@@ -0,0 +1,481 @@
|
|
|
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
|
+
});
|