server.ts.bak•22.5 kB
// src/server.ts
import { FastMCP, UserError } from 'fastmcp';
import { z } from 'zod';
import { google, docs_v1 } from 'googleapis';
import { authorize } from './auth.js';
import { OAuth2Client } from 'google-auth-library';
// --- Helper function for hex color validation (basic) ---
const hexColorRegex = /^#?([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/;
const validateHexColor = (color: string) => hexColorRegex.test(color);
// --- Helper function for Hex to RGB conversion ---
/**
* Converts a hex color string to a Google Docs API RgbColor object.
* @param hex - The hex color string (e.g., "#FF0000", "#F00", "FF0000").
* @returns A Google Docs API RgbColor object or null if invalid.
*/
function hexToRgbColor(hex: string): docs_v1.Schema$RgbColor | null {
if (!hex) return null;
let hexClean = hex.startsWith('#') ? hex.slice(1) : hex;
// Expand shorthand form (e.g. "F00") to full form (e.g. "FF0000")
if (hexClean.length === 3) {
hexClean = hexClean[0] + hexClean[0] + hexClean[1] + hexClean[1] + hexClean[2] + hexClean[2];
}
if (hexClean.length !== 6) {
return null; // Invalid length
}
const bigint = parseInt(hexClean, 16);
if (isNaN(bigint)) {
return null; // Invalid hex characters
}
// Extract RGB values and normalize to 0.0 - 1.0 range
const r = ((bigint >> 16) & 255) / 255;
const g = ((bigint >> 8) & 255) / 255;
const b = (bigint & 255) / 255;
return { red: r, green: g, blue: b };
}
// --- Zod Schema for the formatText tool ---
// const FormatTextParameters = z.object({
// documentId: z.string().describe('The ID of the Google Document.'),
// startIndex: z.number().int().min(1).describe('The starting index of the text range (inclusive, starts from 1).'),
// endIndex: z.number().int().min(1).describe('The ending index of the text range (inclusive).'),
// // Optional Formatting Parameters (SHARED)
// bold: z.boolean().optional().describe('Apply bold formatting.'),
// italic: z.boolean().optional().describe('Apply italic formatting.'),
// underline: z.boolean().optional().describe('Apply underline formatting.'),
// strikethrough: z.boolean().optional().describe('Apply strikethrough formatting.'),
// fontSize: z.number().min(1).optional().describe('Set font size (in points, e.g., 12).'),
// fontFamily: z.string().optional().describe('Set font family (e.g., "Arial", "Times New Roman").'),
// foregroundColor: z.string()
// .refine(validateHexColor, { message: "Invalid hex color format (e.g., #FF0000 or #F00)" })
// .optional()
// .describe('Set text color using hex format (e.g., "#FF0000").'),
// backgroundColor: z.string()
// .refine(validateHexColor, { message: "Invalid hex color format (e.g., #00FF00 or #0F0)" })
// .optional()
// .describe('Set text background color using hex format (e.g., "#FFFF00").'),
// linkUrl: z.string().url().optional().describe('Make the text a hyperlink pointing to this URL.')
// })
// .refine(data => data.endIndex >= data.startIndex, {
// message: "endIndex must be greater than or equal to startIndex",
// path: ["endIndex"],
// })
// .refine(data => Object.keys(data).some(key => !['documentId', 'startIndex', 'endIndex'].includes(key) && data[key as keyof typeof data] !== undefined), {
// message: "At least one formatting option (bold, italic, fontSize, etc.) must be provided."
// });
// --- Define the TypeScript type based on the schema ---
// type FormatTextArgs = z.infer<typeof FormatTextParameters>;
// --- Zod Schema for the NEW formatMatchingText tool ---
const FormatMatchingTextParameters = z.object({
documentId: z.string().describe('The ID of the Google Document.'),
textToFind: z.string().min(1).describe('The exact text string to find and format.'),
matchInstance: z.number().int().min(1).optional().default(1).describe('Which instance of the text to format (1st, 2nd, etc.). Defaults to 1.'),
// Re-use optional Formatting Parameters (SHARED)
bold: z.boolean().optional().describe('Apply bold formatting.'),
italic: z.boolean().optional().describe('Apply italic formatting.'),
underline: z.boolean().optional().describe('Apply underline formatting.'),
strikethrough: z.boolean().optional().describe('Apply strikethrough formatting.'),
fontSize: z.number().min(1).optional().describe('Set font size (in points, e.g., 12).'),
fontFamily: z.string().optional().describe('Set font family (e.g., "Arial", "Times New Roman").'),
foregroundColor: z.string()
.refine(validateHexColor, { message: "Invalid hex color format (e.g., #FF0000 or #F00)" })
.optional()
.describe('Set text color using hex format (e.g., "#FF0000").'),
backgroundColor: z.string()
.refine(validateHexColor, { message: "Invalid hex color format (e.g., #00FF00 or #0F0)" })
.optional()
.describe('Set text background color using hex format (e.g., "#FFFF00").'),
linkUrl: z.string().url().optional().describe('Make the text a hyperlink pointing to this URL.')
})
.refine(data => Object.keys(data).some(key => !['documentId', 'textToFind', 'matchInstance'].includes(key) && data[key as keyof typeof data] !== undefined), {
message: "At least one formatting option (bold, italic, fontSize, etc.) must be provided."
});
// --- Define the TypeScript type based on the new schema ---
type FormatMatchingTextArgs = z.infer<typeof FormatMatchingTextParameters>;
// --- Helper function to build TextStyle and fields mask (reusable) ---
function buildTextStyleAndFields(args: Omit<FormatMatchingTextArgs, 'documentId' | 'textToFind' | 'matchInstance'>): { textStyle: docs_v1.Schema$TextStyle, fields: string[] } {
const textStyle: docs_v1.Schema$TextStyle = {};
const fieldsToUpdate: string[] = [];
if (args.bold !== undefined) { textStyle.bold = args.bold; fieldsToUpdate.push('bold'); }
if (args.italic !== undefined) { textStyle.italic = args.italic; fieldsToUpdate.push('italic'); }
if (args.underline !== undefined) { textStyle.underline = args.underline; fieldsToUpdate.push('underline'); }
if (args.strikethrough !== undefined) { textStyle.strikethrough = args.strikethrough; fieldsToUpdate.push('strikethrough'); }
if (args.fontSize !== undefined) {
textStyle.fontSize = { magnitude: args.fontSize, unit: 'PT' };
fieldsToUpdate.push('fontSize');
}
if (args.fontFamily !== undefined) {
textStyle.weightedFontFamily = { fontFamily: args.fontFamily };
fieldsToUpdate.push('weightedFontFamily');
}
if (args.foregroundColor !== undefined) {
const rgbColor = hexToRgbColor(args.foregroundColor);
if (!rgbColor) throw new UserError(`Invalid foreground hex color format: ${args.foregroundColor}`);
textStyle.foregroundColor = { color: { rgbColor: rgbColor } };
fieldsToUpdate.push('foregroundColor');
}
if (args.backgroundColor !== undefined) {
const rgbColor = hexToRgbColor(args.backgroundColor);
if (!rgbColor) throw new UserError(`Invalid background hex color format: ${args.backgroundColor}`);
textStyle.backgroundColor = { color: { rgbColor: rgbColor } };
fieldsToUpdate.push('backgroundColor');
}
if (args.linkUrl !== undefined) {
textStyle.link = { url: args.linkUrl };
fieldsToUpdate.push('link');
}
if (fieldsToUpdate.length === 0) {
// This should ideally be caught by Zod refine, but defensive check
throw new UserError("No formatting options were specified.");
}
return { textStyle, fields: fieldsToUpdate };
}
let authClient: OAuth2Client | null = null;
let googleDocs: docs_v1.Docs | null = null;
async function initializeGoogleClient() {
if (googleDocs) return { authClient, googleDocs };
if (authClient === null && googleDocs === null) {
try {
console.error("Attempting to authorize Google API client...");
const client = await authorize();
if (client) {
authClient = client;
googleDocs = google.docs({ version: 'v1', auth: authClient });
console.error("Google API client authorized successfully.");
} else {
console.error("FATAL: Authorization returned null or undefined client.");
authClient = null;
googleDocs = null;
}
} catch (error) {
console.error("FATAL: Failed to initialize Google API client:", error);
authClient = null;
googleDocs = null;
}
}
return { authClient, googleDocs };
}
const server = new FastMCP({
name: 'Google Docs MCP Server',
version: '1.0.0',
});
// Tool: Read Google Doc
server.addTool({
name: 'readGoogleDoc',
description: 'Reads the content of a specific Google Document.',
parameters: z.object({
documentId: z.string().describe('The ID of the Google Document (from the URL).'),
}),
execute: async (args, { log }) => {
const { googleDocs: docs } = await initializeGoogleClient();
if (!docs) throw new UserError("Google Docs client not initialized.");
log.info(`Reading Google Doc: ${args.documentId}`);
try {
const res = await docs.documents.get({
documentId: args.documentId,
fields: 'body(content)',
});
log.info(`Fetched doc: ${args.documentId}`);
let textContent = '';
res.data.body?.content?.forEach(element => {
element.paragraph?.elements?.forEach(pe => {
textContent += pe.textRun?.content || '';
});
});
if (!textContent.trim()) return "Document found, but appears empty.";
const maxLength = 2000;
const truncatedContent = textContent.length > maxLength ? textContent.substring(0, maxLength) + '... [truncated]' : textContent;
return `Content:\n---\n${truncatedContent}`;
} catch (error: any) {
log.error(`Error reading doc ${args.documentId}: ${error.message}`);
if (error.code === 404) throw new UserError(`Doc not found (ID: ${args.documentId}).`);
if (error.code === 403) throw new UserError(`Permission denied for doc (ID: ${args.documentId}).`);
throw new UserError(`Failed to read doc: ${error.message}`);
}
},
});
// Tool: Append to Google Doc
server.addTool({
name: 'appendToGoogleDoc',
description: 'Appends text to the end of a specific Google Document.',
parameters: z.object({
documentId: z.string().describe('The ID of the Google Document.'),
textToAppend: z.string().describe('The text to add.'),
}),
execute: async (args, { log }) => {
const { googleDocs: docs } = await initializeGoogleClient();
if (!docs) throw new UserError("Google Docs client not initialized.");
log.info(`Appending to Google Doc: ${args.documentId}`);
try {
const docInfo = await docs.documents.get({ documentId: args.documentId, fields: 'body(content)' });
let endIndex = 1;
if (docInfo.data.body?.content) {
const lastElement = docInfo.data.body.content[docInfo.data.body.content.length - 1];
if (lastElement?.endIndex) endIndex = lastElement.endIndex - 1;
}
const textToInsert = (endIndex > 1 && !args.textToAppend.startsWith('\n') ? '\n' : '') + args.textToAppend;
await docs.documents.batchUpdate({
documentId: args.documentId,
requestBody: { requests: [{ insertText: { location: { index: endIndex }, text: textToInsert } }] },
});
log.info(`Successfully appended to doc: ${args.documentId}`);
return `Successfully appended text to document ${args.documentId}.`;
} catch (error: any) {
log.error(`Error editing doc ${args.documentId}: ${error.message}`);
if (error.code === 404) throw new UserError(`Doc not found (ID: ${args.documentId}).`);
if (error.code === 403) throw new UserError(`Permission denied for doc (ID: ${args.documentId}).`);
throw new UserError(`Failed to edit doc: ${error.message}`);
}
},
});
// --- Add the formatMatchingText tool ---
server.addTool({
name: 'formatMatchingText',
description: 'Finds specific text within a Google Document and applies character formatting (bold, italics, color, etc.) to the specified instance.',
parameters: FormatMatchingTextParameters, // Use the new Zod schema
execute: async (args: FormatMatchingTextArgs, { log }) => {
const { googleDocs: docs } = await initializeGoogleClient();
if (!docs) {
throw new UserError("Google Docs client is not initialized. Authentication might have failed.");
}
log.info(`Attempting to find text "${args.textToFind}" (instance ${args.matchInstance}) in doc: ${args.documentId} and format it.`);
// 1. Get the document content to find the text range
let docContent: docs_v1.Schema$Document;
try {
const res = await docs.documents.get({
documentId: args.documentId,
// Request fields needed to reconstruct text and find indices
fields: 'body(content(paragraph(elements(startIndex,endIndex,textRun(content)))))',
});
docContent = res.data;
if (!docContent.body?.content) {
throw new UserError(`Document body or content is empty or inaccessible (ID: ${args.documentId}).`);
}
log.info(`Fetched doc content for searching: ${args.documentId}`);
} catch (error: any) {
log.error(`Error retrieving doc ${args.documentId} for search: ${error.message}`);
if (error.code === 404) throw new UserError(`Doc not found (ID: ${args.documentId}).`);
if (error.code === 403) throw new UserError(`Permission denied for doc (ID: ${args.documentId}).`);
throw new UserError(`Failed to retrieve doc for searching: ${error.message}`);
}
// 2. Find the Nth instance of the text and its range
let fullText = '';
const textSegments: { text: string, start: number, end: number }[] = [];
docContent.body.content.forEach(element => {
element.paragraph?.elements?.forEach(pe => {
if (pe.textRun?.content && pe.startIndex && pe.endIndex) {
// Handle potential line breaks within content
const content = pe.textRun.content;
fullText += content;
textSegments.push({
text: content,
start: pe.startIndex,
end: pe.endIndex
});
}
});
});
let startIndex = -1;
let endIndex = -1;
let foundCount = 0;
let searchStartIndex = 0;
while (foundCount < args.matchInstance) {
const currentIndex = fullText.indexOf(args.textToFind, searchStartIndex);
if (currentIndex === -1) {
// Text not found anymore
break;
}
foundCount++;
if (foundCount === args.matchInstance) {
// Found the start of the Nth match in the *reconstructed* string.
// Map this back to the API's startIndex/endIndex.
const targetStartInFullText = currentIndex;
const targetEndInFullText = currentIndex + args.textToFind.length;
let currentPosInFullText = 0;
for (const seg of textSegments) {
const segStartInFullText = currentPosInFullText;
// Length of segment text might differ from index range if it contains newlines etc.
const segTextLength = seg.text.length;
const segEndInFullText = segStartInFullText + segTextLength;
// Check if the target *starts* within this segment's text span
if (startIndex === -1 && targetStartInFullText >= segStartInFullText && targetStartInFullText < segEndInFullText) {
// Calculate the API start index relative to the segment's start index
startIndex = seg.start + (targetStartInFullText - segStartInFullText);
}
// Check if the target *ends* within this segment's text span
if (targetEndInFullText > segStartInFullText && targetEndInFullText <= segEndInFullText) {
// Calculate the API end index relative to the segment's start index
endIndex = seg.start + (targetEndInFullText - segStartInFullText);
break; // Found the end, we have the full range
}
currentPosInFullText = segEndInFullText;
}
if (startIndex === -1 || endIndex === -1) {
log.warn(`Could not accurately map indices for match ${foundCount} of "${args.textToFind}". Start found at ${targetStartInFullText}, End at ${targetEndInFullText}. Resetting.`);
// Reset if we couldn't map indices correctly for this match
startIndex = -1;
endIndex = -1;
// Don't break the outer loop, let it try searching again
}
}
// Continue searching after the start of the current match to find subsequent occurrences
searchStartIndex = currentIndex + 1;
}
if (startIndex === -1 || endIndex === -1) {
throw new UserError(`Could not find instance ${args.matchInstance} of the text "${args.textToFind}" in document ${args.documentId}. Found ${foundCount} total instance(s).`);
}
log.info(`Found text "${args.textToFind}" (instance ${args.matchInstance}) at mapped range: ${startIndex}-${endIndex}`);
// 3. Build the TextStyle object and fields mask
const { textStyle, fields } = buildTextStyleAndFields(args);
// 4. Build the UpdateTextStyleRequest
const updateTextStyleRequest: docs_v1.Schema$UpdateTextStyleRequest = {
range: {
// API uses segmentId, but omitting it defaults to the document BODY
startIndex: startIndex, // Use the calculated start index
endIndex: endIndex, // Use the calculated end index
},
textStyle: textStyle,
fields: fields.join(','), // Crucial: Tells API which fields to update
};
// 5. Send the batchUpdate request
try {
await docs.documents.batchUpdate({
documentId: args.documentId,
requestBody: {
requests: [{ updateTextStyle: updateTextStyleRequest }],
},
});
log.info(`Successfully formatted text in doc: ${args.documentId}, range: ${startIndex}-${endIndex}`);
return `Successfully applied formatting to instance ${args.matchInstance} of "${args.textToFind}".`;
} catch (error: any) {
log.error(`Error formatting text in doc ${args.documentId}: ${error.message}`);
// Consider more specific error handling based on API response if needed
throw new UserError(`Failed to apply formatting: ${error.message}`);
}
},
});
// Tool: Format Text (existing, keep for index-based formatting if needed)
// server.addTool({
// name: 'formatText',
// description: 'Applies character formatting (bold, italics, font size, color, link, etc.) to a specific text range in a Google Document using start/end indices.',
// parameters: FormatTextParameters, // Use the original Zod schema
// execute: async (args: FormatTextArgs, { log }) => {
// const { googleDocs: docs } = await initializeGoogleClient();
// if (!docs) {
// throw new UserError("Google Docs client is not initialized. Authentication might have failed.");
// }
//
// log.info(`Attempting to format text in doc: ${args.documentId}, range: ${args.startIndex}-${args.endIndex}`);
//
// // 1. Build the TextStyle object and fields mask
// const { textStyle, fields } = buildTextStyleAndFields(args);
//
// // 2. Build the UpdateTextStyleRequest
// const updateTextStyleRequest: docs_v1.Schema$UpdateTextStyleRequest = {
// range: {
// startIndex: args.startIndex,
// endIndex: args.endIndex,
// },
// textStyle: textStyle,
// fields: fields.join(','),
// };
//
// // 3. Send the batchUpdate request
// try {
// await docs.documents.batchUpdate({
// documentId: args.documentId,
// requestBody: {
// requests: [{ updateTextStyle: updateTextStyleRequest }],
// },
// });
// log.info(`Successfully formatted text in doc: ${args.documentId}, range: ${args.startIndex}-${args.endIndex}`);
// return `Successfully applied formatting to range ${args.startIndex}-${args.endIndex}.`;
// } catch (error: any) {
// log.error(`Error formatting text in doc ${args.documentId}: ${error.message}`);
// if (error.code === 404) throw new UserError(`Doc not found (ID: ${args.documentId}).`);
// if (error.code === 403) throw new UserError(`Permission denied for doc (ID: ${args.documentId}).`);
// throw new UserError(`Failed to format text: ${error.message}`);
// }
// },
// });
// Start the Server (Modified to avoid server.config issue)
async function startServer() {
await initializeGoogleClient(); // Authorize before starting listeners
console.error("Starting MCP server...");
try {
const configToUse = {
// Choose one transport:
transportType: "stdio" as const,
// transportType: "sse" as const,
// sse: { // <-- COMMENT OUT or DELETE SSE config
// endpoint: "/sse" as const,
// port: 8080,
// },
};
server.start(configToUse); // Start the server with stdio config
// Adjust logging (optional, but good practice)
console.error(`MCP Server running using ${configToUse.transportType}.`);
if (configToUse.transportType === 'stdio') {
console.error("Awaiting MCP client connection via stdio...");
}
// Removed SSE-specific logging
} catch(startError) {
console.error("Error occurred during server.start():", startError);
throw startError; // Re-throw to be caught by the outer catch
}
}
// Call the modified startServer function
startServer().catch(err => {
console.error("Server failed to start:", err);
process.exit(1);
});