Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,15 @@ npm run dev # Watch mode with auto-reload
| `sheets_unmerge_cells` | Unmerge previously merged cells in a range | `spreadsheetId`, `range` |
| `sheets_add_conditional_formatting` | Add a conditional formatting rule (gradient or boolean) to a range | `spreadsheetId`, `range`, `rule` |

### Native Tables

| Tool | Description | Key Parameters |
|------|-------------|----------------|
| `sheets_add_table` | Create a native Google Sheets table with typed columns and optional dropdown values | `spreadsheetId`, `sheetName`, `range`, `name`, `columns` |
| `sheets_update_table` | Update an existing native Google Sheets table by table ID using an explicit field mask | `spreadsheetId`, `tableId`, `fields`, `name`, `range`, `columns` |
| `sheets_delete_table` | Delete a native Google Sheets table by table ID | `spreadsheetId`, `tableId` |
| `sheets_get_tables` | Read native tables for a spreadsheet or a specific sheet | `spreadsheetId`, `sheetName` |

### Charts

| Tool | Description | Key Parameters |
Expand Down
8 changes: 8 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ const toolHandlers = new Map<string, (input: any) => Promise<any>>([
['sheets_merge_cells', tools.mergeCellsHandler],
['sheets_unmerge_cells', tools.unmergeCellsHandler],
['sheets_add_conditional_formatting', tools.addConditionalFormattingHandler],
['sheets_add_table', tools.addTableHandler],
['sheets_update_table', tools.updateTableHandler],
['sheets_delete_table', tools.deleteTableHandler],
['sheets_get_tables', tools.getTablesHandler],
// Batch operations
['sheets_batch_delete_sheets', tools.handleBatchDeleteSheets],
['sheets_batch_format_cells', tools.handleBatchFormatCells],
Expand Down Expand Up @@ -110,6 +114,10 @@ const allTools = [
tools.mergeCellsTool,
tools.unmergeCellsTool,
tools.addConditionalFormattingTool,
tools.addTableTool,
tools.updateTableTool,
tools.deleteTableTool,
tools.getTablesTool,
// Batch operations
tools.batchDeleteSheetsTool,
tools.batchFormatCellsTool,
Expand Down
132 changes: 132 additions & 0 deletions src/tools/add-table.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { Tool } from '@modelcontextprotocol/sdk/types.js';
import { z } from 'zod';
import { sheets_v4 } from 'googleapis';
import { getAuthenticatedClient } from '../utils/google-auth.js';
import { handleError } from '../utils/error-handler.js';
import { formatToolResponse } from '../utils/formatters.js';
import { ToolResponse } from '../types/tools.js';
import { getSheetId, parseRange, findSheetOrThrow } from '../utils/range-helpers.js';
import {
TABLE_COLUMN_TYPES,
buildTableColumnProperties,
ensureNoOverlappingTable,
formatTableForResponse,
resolveTableRangeInput,
validateColumnCount,
validateTableGridRange,
} from '../utils/table-helpers.js';

const columnSchema = z.object({
name: z.string().min(1),
columnType: z.enum(TABLE_COLUMN_TYPES),
dropdownValues: z.array(z.string()).optional(),
});

const addTableInputSchema = z.object({
spreadsheetId: z.string().min(1),
sheetName: z.string().min(1),
range: z.string().min(1),
name: z.string().min(1),
columns: z.array(columnSchema).min(1),
});

export const addTableTool: Tool = {
name: 'sheets_add_table',
description:
'Create a native Google Sheets table with typed columns and optional dropdown values',
inputSchema: {
type: 'object',
properties: {
spreadsheetId: {
type: 'string',
description: 'The ID of the spreadsheet (found in the URL after /d/)',
},
sheetName: {
type: 'string',
description: 'Name of the sheet (tab) where the table will be created',
},
range: {
type: 'string',
description: 'A1 notation range for the table, e.g. "A1:D20" or "Sheet1!A1:D20"',
},
name: {
type: 'string',
description: 'Unique table name within the spreadsheet',
},
columns: {
type: 'array',
description: 'Column definitions for the table',
items: {
type: 'object',
properties: {
name: { type: 'string', description: 'Column name' },
columnType: {
type: 'string',
enum: [...TABLE_COLUMN_TYPES],
description: 'Google Sheets table column type',
},
dropdownValues: {
type: 'array',
items: { type: 'string' },
description: 'Allowed values for DROPDOWN columns',
},
},
required: ['name', 'columnType'],
},
},
},
required: ['spreadsheetId', 'sheetName', 'range', 'name', 'columns'],
},
};

export async function addTableHandler(input: any): Promise<ToolResponse> {
try {
const validatedInput = addTableInputSchema.parse(input);
const sheets = await getAuthenticatedClient();

const { sheetName, range } = resolveTableRangeInput(
validatedInput.sheetName,
validatedInput.range
);
const sheetId = await getSheetId(sheets, validatedInput.spreadsheetId, sheetName);
const gridRange = parseRange(range, sheetId);

validateTableGridRange(gridRange, `${sheetName}!${range}`);
validateColumnCount(gridRange, validatedInput.columns, `${sheetName}!${range}`);

const metadataResponse = await sheets.spreadsheets.get({
spreadsheetId: validatedInput.spreadsheetId,
fields: 'sheets.properties.title,sheets.properties.sheetId,sheets.tables',
});
const sheetData = findSheetOrThrow(metadataResponse.data.sheets ?? [], sheetName);
ensureNoOverlappingTable(sheetData.tables ?? [], gridRange, sheetName);

const table: sheets_v4.Schema$Table = {
name: validatedInput.name,
range: gridRange,
columnProperties: buildTableColumnProperties(validatedInput.columns),
};

const response = await sheets.spreadsheets.batchUpdate({
spreadsheetId: validatedInput.spreadsheetId,
requestBody: {
requests: [
{
addTable: {
table,
},
},
],
},
});

const addedTable = response.data.replies?.[0]?.addTable?.table ?? table;

return formatToolResponse(`Successfully created table "${validatedInput.name}"`, {
spreadsheetId: response.data.spreadsheetId,
table: formatTableForResponse(addedTable, sheetName),
});
} catch (error) {
return handleError(error);
}
}
70 changes: 70 additions & 0 deletions src/tools/delete-table.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { Tool } from '@modelcontextprotocol/sdk/types.js';
import { z } from 'zod';
import { sheets_v4 } from 'googleapis';
import { getAuthenticatedClient } from '../utils/google-auth.js';
import { handleError } from '../utils/error-handler.js';
import { formatToolResponse } from '../utils/formatters.js';
import { ToolResponse } from '../types/tools.js';

const deleteTableInputSchema = z.object({
spreadsheetId: z.string().min(1),
tableId: z.string().min(1),
});

export const deleteTableTool: Tool = {
name: 'sheets_delete_table',
description: 'Delete a native Google Sheets table by tableId',
inputSchema: {
type: 'object',
properties: {
spreadsheetId: {
type: 'string',
description: 'The ID of the spreadsheet (found in the URL after /d/)',
},
tableId: {
type: 'string',
description: 'The ID of the table to delete',
},
},
required: ['spreadsheetId', 'tableId'],
},
};

export async function deleteTableHandler(input: any): Promise<ToolResponse> {
try {
const validatedInput = deleteTableInputSchema.parse(input);
const sheets = await getAuthenticatedClient();

const metadataResponse = await sheets.spreadsheets.get({
spreadsheetId: validatedInput.spreadsheetId,
fields: 'sheets.tables.tableId',
});
const tableExists = ((metadataResponse.data.sheets ?? []) as sheets_v4.Schema$Sheet[])
.flatMap((sheet) => sheet.tables ?? [])
.some((table) => table.tableId === validatedInput.tableId);

if (!tableExists) {
throw new Error(`Table "${validatedInput.tableId}" not found`);
}

const response = await sheets.spreadsheets.batchUpdate({
spreadsheetId: validatedInput.spreadsheetId,
requestBody: {
requests: [
{
deleteTable: {
tableId: validatedInput.tableId,
},
},
],
},
});

return formatToolResponse(`Successfully deleted table "${validatedInput.tableId}"`, {
spreadsheetId: response.data.spreadsheetId,
tableId: validatedInput.tableId,
});
} catch (error) {
return handleError(error);
}
}
75 changes: 75 additions & 0 deletions src/tools/get-tables.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { Tool } from '@modelcontextprotocol/sdk/types.js';
import { z } from 'zod';
import { sheets_v4 } from 'googleapis';
import { getAuthenticatedClient } from '../utils/google-auth.js';
import { handleError } from '../utils/error-handler.js';
import { formatSuccessResponse } from '../utils/formatters.js';
import { ToolResponse } from '../types/tools.js';
import { findSheetOrThrow } from '../utils/range-helpers.js';
import { formatTableForResponse } from '../utils/table-helpers.js';

const getTablesInputSchema = z.object({
spreadsheetId: z.string().min(1),
sheetName: z.string().min(1).optional(),
});

export const getTablesTool: Tool = {
name: 'sheets_get_tables',
description: 'Read native Google Sheets tables for a spreadsheet or a specific sheet',
inputSchema: {
type: 'object',
properties: {
spreadsheetId: {
type: 'string',
description: 'The ID of the spreadsheet (found in the URL after /d/)',
},
sheetName: {
type: 'string',
description: 'Optional name of the sheet (tab) to inspect',
},
},
required: ['spreadsheetId'],
},
};

export async function getTablesHandler(input: any): Promise<ToolResponse> {
try {
const validatedInput = getTablesInputSchema.parse(input);
const sheets = await getAuthenticatedClient();

const response = await sheets.spreadsheets.get({
spreadsheetId: validatedInput.spreadsheetId,
fields: 'sheets.properties.title,sheets.properties.sheetId,sheets.tables',
});

const spreadsheetSheets = (response.data.sheets ?? []) as sheets_v4.Schema$Sheet[];
const targetSheets = validatedInput.sheetName
? [findSheetOrThrow(spreadsheetSheets, validatedInput.sheetName)]
: spreadsheetSheets;

const tables = targetSheets.flatMap((sheet) => {
const sheetName = sheet.properties?.title ?? undefined;
return (sheet.tables ?? []).map((table) => ({
sheetName: sheetName ?? null,
sheetId: sheet.properties?.sheetId ?? null,
...formatTableForResponse(table, sheetName),
}));
});

const context = validatedInput.sheetName
? ` in sheet "${validatedInput.sheetName}"`
: ' in spreadsheet';

return formatSuccessResponse(
{
spreadsheetId: validatedInput.spreadsheetId,
sheetName: validatedInput.sheetName,
tableCount: tables.length,
tables,
},
`Found ${tables.length} table(s)${context}`
);
} catch (error) {
return handleError(error);
}
}
4 changes: 4 additions & 0 deletions src/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ export * from './format-cells.js';
export * from './update-borders.js';
export * from './merge-cells.js';
export * from './conditional-formatting.js';
export * from './add-table.js';
export * from './update-table.js';
export * from './delete-table.js';
export * from './get-tables.js';
export * from './check-access.js';

// Batch operations
Expand Down
Loading
Loading