google-docs-mcp / src /tools /sheets /insertChart.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';
export function register(server: FastMCP) {
server.addTool({
name: 'insertChart',
description:
'Inserts a chart into a Google Sheet. Supports bar, column, line, area, scatter, pie, donut, and treemap (hierarchical) chart types. ' +
'For treemap charts, the data must have a label column and a parent label column (use empty string for root nodes) plus a numeric size column. ' +
'Chart is placed as an overlay at the specified anchor cell.',
parameters: z.strictObject({
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 containing the data. Defaults to the first sheet.'),
chartType: z
.enum(['BAR', 'COLUMN', 'LINE', 'AREA', 'SCATTER', 'PIE', 'DONUT', 'TREEMAP'])
.describe('Chart type to create.'),
stackedType: z
.enum(['NOT_STACKED', 'STACKED', 'PERCENT_STACKED'])
.default('NOT_STACKED')
.describe(
'For bar/column/area charts: whether to stack series. NOT_STACKED = grouped, STACKED = absolute stacked, PERCENT_STACKED = 100% stacked.'
),
title: z.string().optional().describe('Chart title.'),
dataRange: z
.string()
.describe(
'A1 notation range of the data (e.g., "A1:E50"). Include the header row if present.'
),
headerRow: z
.boolean()
.default(true)
.describe('Whether the first row of the data range is a header row.'),
// Column indices for flexible data mapping
labelColumnIndex: z
.number()
.int()
.min(1)
.optional()
.describe(
'For pie/donut/treemap: 1-based column index for node labels (default: 1). For treemap this is the leaf/child label.'
),
parentColumnIndex: z
.number()
.int()
.min(1)
.optional()
.describe(
'For treemap: 1-based column index for parent node labels. Root nodes should have an empty string in this column.'
),
valueColumnIndex: z
.number()
.int()
.min(1)
.optional()
.describe(
'For pie/donut/treemap: 1-based column index for the numeric size/value (default: 2).'
),
// Chart position and size
anchorRow: z
.number()
.int()
.min(0)
.default(0)
.describe('Row index (0-based) of the anchor cell for chart placement.'),
anchorColumn: z
.number()
.int()
.min(0)
.default(6)
.describe('Column index (0-based) of the anchor cell for chart placement.'),
offsetXPixels: z
.number()
.int()
.default(0)
.describe('Horizontal offset in pixels from the anchor cell.'),
offsetYPixels: z
.number()
.int()
.default(0)
.describe('Vertical offset in pixels from the anchor cell.'),
widthPixels: z.number().int().default(600).describe('Chart width in pixels.'),
heightPixels: z.number().int().default(400).describe('Chart height in pixels.'),
}),
execute: async (args, { log }) => {
const sheets = await getSheetsClient();
log.info(`Inserting ${args.chartType} chart into spreadsheet ${args.spreadsheetId}`);
try {
const sheetId = await SheetsHelpers.resolveSheetId(
sheets,
args.spreadsheetId,
args.sheetName
);
const { a1Range } = SheetsHelpers.parseRange(args.dataRange);
const gridRange = SheetsHelpers.parseA1ToGridRange(a1Range, sheetId);
const startRow = gridRange.startRowIndex ?? 0;
const endRow = gridRange.endRowIndex ?? startRow + 1;
const startCol = gridRange.startColumnIndex ?? 0;
const endCol = gridRange.endColumnIndex ?? startCol + 1;
const dataStartRow = args.headerRow ? startRow + 1 : startRow;
const labelCol = startCol + (args.labelColumnIndex ? args.labelColumnIndex - 1 : 0);
const valueCol = startCol + (args.valueColumnIndex ? args.valueColumnIndex - 1 : 1);
const parentCol = startCol + (args.parentColumnIndex ? args.parentColumnIndex - 1 : 0);
const makeSourceRange = (colStart: number, colEnd: number, rowStart = dataStartRow) => ({
sources: [
{
sheetId,
startRowIndex: rowStart,
endRowIndex: endRow,
startColumnIndex: colStart,
endColumnIndex: colEnd,
},
],
});
let chartSpec: Record<string, unknown> = {};
if (args.chartType === 'PIE' || args.chartType === 'DONUT') {
chartSpec.pieChart = {
legendPosition: 'LABELED_LEGEND',
pieHole: args.chartType === 'DONUT' ? 0.5 : 0,
domain: {
data: { sourceRange: makeSourceRange(labelCol, labelCol + 1) },
},
series: {
data: { sourceRange: makeSourceRange(valueCol, valueCol + 1) },
},
};
} else if (args.chartType === 'TREEMAP') {
chartSpec.treemapChart = {
labels: {
sourceRange: makeSourceRange(labelCol, labelCol + 1),
},
parentLabels: {
sourceRange: makeSourceRange(parentCol, parentCol + 1),
},
sizeData: {
sourceRange: makeSourceRange(valueCol, valueCol + 1),
},
colorData: {
sourceRange: makeSourceRange(valueCol, valueCol + 1),
},
};
} else {
// Basic chart types: BAR, COLUMN, LINE, AREA, SCATTER
// Each series must be a separate entry with a single-column source range.
const seriesCount = endCol - startCol - 1;
const series = Array.from({ length: seriesCount }, (_, i) => ({
series: {
sourceRange: {
sources: [
{
sheetId,
startRowIndex: startRow, // include header so Sheets names the series automatically
endRowIndex: endRow,
startColumnIndex: startCol + 1 + i,
endColumnIndex: startCol + 2 + i,
},
],
},
},
targetAxis: 'LEFT_AXIS',
}));
chartSpec.basicChart = {
chartType: args.chartType,
stackedType: args.stackedType,
legendPosition: 'BOTTOM_LEGEND',
axis: [
{ position: 'BOTTOM_AXIS', title: '' },
{ position: 'LEFT_AXIS', title: '' },
],
domains: [
{
domain: {
sourceRange: {
sources: [
{
sheetId,
startRowIndex: startRow,
endRowIndex: endRow,
startColumnIndex: startCol,
endColumnIndex: startCol + 1,
},
],
},
},
reversed: false,
},
],
series,
headerCount: args.headerRow ? 1 : 0,
};
}
if (args.title) {
chartSpec.title = args.title;
}
const response = await sheets.spreadsheets.batchUpdate({
spreadsheetId: args.spreadsheetId,
requestBody: {
requests: [
{
addChart: {
chart: {
spec: chartSpec,
position: {
overlayPosition: {
anchorCell: {
sheetId,
rowIndex: args.anchorRow,
columnIndex: args.anchorColumn,
},
offsetXPixels: args.offsetXPixels,
offsetYPixels: args.offsetYPixels,
widthPixels: args.widthPixels,
heightPixels: args.heightPixels,
},
},
},
},
},
],
},
});
const chartId = response.data.replies?.[0]?.addChart?.chart?.chartId;
return `Chart created successfully${chartId ? ` (Chart ID: ${chartId})` : ''}.`;
} catch (error: any) {
log.error(`Error inserting chart: ${error.message || error}`);
if (error instanceof UserError) throw error;
throw new UserError(`Failed to insert chart: ${error.message || 'Unknown error'}`);
}
},
});
}