google-docs-mcp / src /tools /sheets /addConditionalFormatting.ts
iFightDucks's picture
Initial HF Space deploy: a-bonus/google-docs-mcp with HF metadata
7dc28be
import type { FastMCP } from 'fastmcp';
import { UserError } from 'fastmcp';
import { z } from 'zod';
import { getSheetsClient } from '../../clients.js';
import * as SheetsHelpers from '../../googleSheetsApiHelpers.js';
const ONE_VALUE_CONDITIONS = [
'NUMBER_GREATER',
'NUMBER_GREATER_THAN_EQ',
'NUMBER_LESS',
'NUMBER_LESS_THAN_EQ',
'NUMBER_EQ',
'NUMBER_NOT_EQ',
'CUSTOM_FORMULA',
] as const;
const TWO_VALUE_CONDITIONS = ['NUMBER_BETWEEN', 'NUMBER_NOT_BETWEEN'] as const;
const NO_VALUE_CONDITIONS = ['BLANK', 'NOT_BLANK'] as const;
const ALL_CONDITION_TYPES = [
...ONE_VALUE_CONDITIONS,
...TWO_VALUE_CONDITIONS,
...NO_VALUE_CONDITIONS,
] as const;
export function register(server: FastMCP) {
server.addTool({
name: 'addConditionalFormatting',
description:
'Adds a conditional formatting rule to one or more ranges in a spreadsheet. Applies a format (background color, bold, text color, etc.) when cells meet a specified condition. Use CUSTOM_FORMULA for complex conditions like "=$A1>$B1". Note: each call appends a new rule — use deleteConditionalFormatting to remove existing rules before re-adding.',
parameters: z
.object({
spreadsheetId: z
.string()
.describe(
'The spreadsheet ID — the long string between /d/ and /edit in a Google Sheets URL.'
),
sheetName: z
.string()
.optional()
.describe('Name of the sheet/tab. Defaults to the first sheet if not provided.'),
ranges: z
.array(z.string())
.min(1)
.describe(
'One or more A1 notation ranges the rule applies to (e.g., ["B2:S68"] or ["A1:A10", "C1:C10"]).'
),
conditionType: z
.enum(ALL_CONDITION_TYPES)
.describe(
'The condition type. ' +
'NUMBER_* types compare cell values numerically. ' +
'CUSTOM_FORMULA evaluates a formula (e.g., "=$A1>10"). ' +
'BLANK/NOT_BLANK check whether a cell is empty.'
),
conditionValues: z
.array(z.string())
.optional()
.describe(
'Values for the condition. ' +
'Omit or pass [] for BLANK and NOT_BLANK. ' +
'Pass one value for all NUMBER_* single-operand types and CUSTOM_FORMULA. ' +
'Pass two values for NUMBER_BETWEEN and NUMBER_NOT_BETWEEN (lower bound first).'
),
backgroundColor: z
.string()
.optional()
.describe('Cell background color as hex (e.g., "#FF9900").'),
bold: z.boolean().optional().describe('Apply bold text formatting.'),
italic: z.boolean().optional().describe('Apply italic text formatting.'),
strikethrough: z.boolean().optional().describe('Apply strikethrough text formatting.'),
underline: z.boolean().optional().describe('Apply underline text formatting.'),
foregroundColor: z
.string()
.optional()
.describe('Text (foreground) color as hex (e.g., "#FF0000").'),
fontSize: z.number().min(1).optional().describe('Font size in points.'),
})
.refine(
(data) =>
data.backgroundColor !== undefined ||
data.bold !== undefined ||
data.italic !== undefined ||
data.strikethrough !== undefined ||
data.underline !== undefined ||
data.foregroundColor !== undefined ||
data.fontSize !== undefined,
{ message: 'At least one formatting option must be provided.' }
)
.refine(
(data) => {
const values = data.conditionValues ?? [];
if ((NO_VALUE_CONDITIONS as readonly string[]).includes(data.conditionType)) {
return values.length === 0;
}
if ((TWO_VALUE_CONDITIONS as readonly string[]).includes(data.conditionType)) {
return values.length === 2;
}
return values.length === 1;
},
(data) => {
if ((NO_VALUE_CONDITIONS as readonly string[]).includes(data.conditionType)) {
return { message: `${data.conditionType} does not accept condition values.` };
}
if ((TWO_VALUE_CONDITIONS as readonly string[]).includes(data.conditionType)) {
return { message: `${data.conditionType} requires exactly two condition values.` };
}
return { message: `${data.conditionType} requires exactly one condition value.` };
}
),
execute: async (args, { log }) => {
const sheets = await getSheetsClient();
log.info(`Adding conditional format rule to spreadsheet ${args.spreadsheetId}`);
try {
const sheetId = await SheetsHelpers.resolveSheetId(
sheets,
args.spreadsheetId,
args.sheetName
);
const gridRanges = args.ranges.map((r) => SheetsHelpers.parseA1ToGridRange(r, sheetId));
const conditionValues = (args.conditionValues ?? []).map((v) => ({
userEnteredValue: v,
}));
const format: Record<string, unknown> = {};
if (args.backgroundColor) {
const rgb = SheetsHelpers.hexToRgb(args.backgroundColor);
if (!rgb) throw new UserError(`Invalid background color: "${args.backgroundColor}".`);
format.backgroundColor = rgb;
}
const hasTextFormat =
args.bold !== undefined ||
args.italic !== undefined ||
args.strikethrough !== undefined ||
args.underline !== undefined ||
args.fontSize !== undefined ||
args.foregroundColor !== undefined;
if (hasTextFormat) {
const textFormat: Record<string, unknown> = {};
if (args.bold !== undefined) textFormat.bold = args.bold;
if (args.italic !== undefined) textFormat.italic = args.italic;
if (args.strikethrough !== undefined) textFormat.strikethrough = args.strikethrough;
if (args.underline !== undefined) textFormat.underline = args.underline;
if (args.fontSize !== undefined) textFormat.fontSize = args.fontSize;
if (args.foregroundColor) {
const rgb = SheetsHelpers.hexToRgb(args.foregroundColor);
if (!rgb) throw new UserError(`Invalid foreground color: "${args.foregroundColor}".`);
textFormat.foregroundColor = rgb;
}
format.textFormat = textFormat;
}
await SheetsHelpers.addConditionalFormatRule(
sheets,
args.spreadsheetId,
gridRanges,
args.conditionType,
conditionValues,
format
);
return `Successfully added conditional formatting rule to ${args.ranges.join(', ')}.`;
} catch (error: any) {
log.error(`Error adding conditional format rule: ${error.message || error}`);
if (error instanceof UserError) throw error;
throw new UserError(
`Failed to add conditional formatting: ${error.message || 'Unknown error'}`
);
}
},
});
}