From 62bfd2989b4a36417e6d2869194f5aa85e01771d Mon Sep 17 00:00:00 2001 From: Trevor Mack Date: Fri, 23 Oct 2026 13:01:14 -0400 Subject: [PATCH] feat(sheets): add sheets.batchUpdate tool for formatting and structural edits Adds a general-purpose sheets.batchUpdate tool that passes an array of Google Sheets Request objects to spreadsheets.batchUpdate, enabling cell and text formatting (bold, colors, number formats, borders), column widths, wrapping, frozen rows, conditional formatting, merges, and sheet properties -- none of which the values-based tools can express. The sheets.write feature group was previously empty and default-OFF; it now contains sheets.batchUpdate and is default-ON, consistent with the other write groups (docs/drive/calendar/chat). Updates the two tests that asserted the old sheets.write=OFF defaults and adds SheetsService batchUpdate tests (success, invalid-JSON, non-array, API error). --- .../__tests__/features/feature-config.test.ts | 10 ++- .../features/feature-resolver.test.ts | 8 ++- .../__tests__/services/SheetsService.test.ts | 71 +++++++++++++++++++ .../src/features/feature-config.ts | 4 +- workspace-server/src/index.ts | 17 +++++ .../src/services/SheetsService.ts | 62 ++++++++++++++++ 6 files changed, 166 insertions(+), 6 deletions(-) diff --git a/workspace-server/src/__tests__/features/feature-config.test.ts b/workspace-server/src/__tests__/features/feature-config.test.ts index b4fe478f..54e96bbc 100644 --- a/workspace-server/src/__tests__/features/feature-config.test.ts +++ b/workspace-server/src/__tests__/features/feature-config.test.ts @@ -32,16 +32,22 @@ describe('feature-config', () => { expect(duplicates).toEqual([]); }); - it('should have slides.write, sheets.write, tasks.read, and tasks.write defaulted to OFF', () => { + it('should have slides.write, tasks.read, and tasks.write defaulted to OFF', () => { const offByDefault = FEATURE_GROUPS.filter((fg) => !fg.defaultEnabled).map( featureGroupKey, ); expect(offByDefault).toContain('slides.write'); - expect(offByDefault).toContain('sheets.write'); expect(offByDefault).toContain('tasks.read'); expect(offByDefault).toContain('tasks.write'); }); + it('should have sheets.write defaulted to ON', () => { + const onByDefault = FEATURE_GROUPS.filter((fg) => fg.defaultEnabled).map( + featureGroupKey, + ); + expect(onByDefault).toContain('sheets.write'); + }); + it('should have all default-ON services with at least one tool', () => { const defaultOnWithNoTools = FEATURE_GROUPS.filter( (fg) => fg.defaultEnabled && fg.tools.length === 0, diff --git a/workspace-server/src/__tests__/features/feature-resolver.test.ts b/workspace-server/src/__tests__/features/feature-resolver.test.ts index fa473f3c..2b755037 100644 --- a/workspace-server/src/__tests__/features/feature-resolver.test.ts +++ b/workspace-server/src/__tests__/features/feature-resolver.test.ts @@ -244,10 +244,14 @@ describe('resolveFeatures', () => { expect(requiredScopes).toContain(fullUrl('userinfo.profile')); }); - it('should still include readonly scopes for default-OFF write groups (slides, sheets)', () => { + it('should still include readonly scopes for default-OFF write groups (slides)', () => { const { requiredScopes } = resolveFeatures(); expect(requiredScopes).toContain(fullUrl('presentations.readonly')); - expect(requiredScopes).toContain(fullUrl('spreadsheets.readonly')); + }); + + it('should request the write scope for default-ON sheets.write', () => { + const { requiredScopes } = resolveFeatures(); + expect(requiredScopes).toContain(fullUrl('spreadsheets')); }); }); }); diff --git a/workspace-server/src/__tests__/services/SheetsService.test.ts b/workspace-server/src/__tests__/services/SheetsService.test.ts index 50c1ff4d..6422ff2e 100644 --- a/workspace-server/src/__tests__/services/SheetsService.test.ts +++ b/workspace-server/src/__tests__/services/SheetsService.test.ts @@ -38,6 +38,7 @@ describe('SheetsService', () => { mockSheetsAPI = { spreadsheets: { get: jest.fn(), + batchUpdate: jest.fn(), values: { get: jest.fn(), }, @@ -370,4 +371,74 @@ describe('SheetsService', () => { expect(response.error).toBe('Metadata Error'); }); }); + + describe('batchUpdate', () => { + it('should apply formatting requests via spreadsheets.batchUpdate', async () => { + mockSheetsAPI.spreadsheets.batchUpdate.mockResolvedValue({ + data: { + spreadsheetId: 'test-id', + replies: [{}], + }, + }); + + const requests = JSON.stringify([ + { + repeatCell: { + range: { sheetId: 0, startRowIndex: 0, endRowIndex: 1 }, + cell: { userEnteredFormat: { textFormat: { bold: true } } }, + fields: 'userEnteredFormat.textFormat.bold', + }, + }, + ]); + + const result = await sheetsService.batchUpdate({ + spreadsheetId: 'test-id', + requests, + }); + const response = JSON.parse(result.content[0].text); + + expect(mockSheetsAPI.spreadsheets.batchUpdate).toHaveBeenCalledWith({ + spreadsheetId: 'test-id', + requestBody: { requests: JSON.parse(requests) }, + }); + expect(response.spreadsheetId).toBe('test-id'); + expect(response.repliesCount).toBe(1); + }); + + it('should return an error when requests is not valid JSON', async () => { + const result = await sheetsService.batchUpdate({ + spreadsheetId: 'test-id', + requests: 'not json', + }); + const response = JSON.parse(result.content[0].text); + + expect(response.error).toContain('must be a valid JSON array'); + expect(mockSheetsAPI.spreadsheets.batchUpdate).not.toHaveBeenCalled(); + }); + + it('should return an error when requests is not a JSON array', async () => { + const result = await sheetsService.batchUpdate({ + spreadsheetId: 'test-id', + requests: '{"repeatCell":{}}', + }); + const response = JSON.parse(result.content[0].text); + + expect(response.error).toContain('must be a JSON array'); + expect(mockSheetsAPI.spreadsheets.batchUpdate).not.toHaveBeenCalled(); + }); + + it('should handle API errors gracefully', async () => { + mockSheetsAPI.spreadsheets.batchUpdate.mockRejectedValue( + new Error('BatchUpdate Error'), + ); + + const result = await sheetsService.batchUpdate({ + spreadsheetId: 'error-id', + requests: '[]', + }); + const response = JSON.parse(result.content[0].text); + + expect(response.error).toBe('BatchUpdate Error'); + }); + }); }); diff --git a/workspace-server/src/features/feature-config.ts b/workspace-server/src/features/feature-config.ts index dc9eef53..e0bca194 100644 --- a/workspace-server/src/features/feature-config.ts +++ b/workspace-server/src/features/feature-config.ts @@ -242,8 +242,8 @@ export const FEATURE_GROUPS: readonly FeatureGroup[] = [ service: 'sheets', group: 'write', scopes: scopes('spreadsheets'), - tools: [], - defaultEnabled: false, + tools: ['sheets.batchUpdate'], + defaultEnabled: true, }, // Time (no scopes needed) diff --git a/workspace-server/src/index.ts b/workspace-server/src/index.ts index e7a8077b..9ba8faf5 100644 --- a/workspace-server/src/index.ts +++ b/workspace-server/src/index.ts @@ -940,6 +940,23 @@ async function main() { sheetsService.getMetadata, ); + registerTool( + 'sheets.batchUpdate', + { + description: + 'Applies one or more batchUpdate requests to a Google Sheets spreadsheet. This is the general-purpose write operation for everything beyond cell values: cell/text formatting (bold, colors, number formats, borders), column widths, wrapping, frozen rows, conditional formatting, merges, and sheet properties. Use sheets.getMetadata first to obtain the numeric sheetId that most requests target.', + inputSchema: { + spreadsheetId: z.string().describe('The ID or URL of the spreadsheet.'), + requests: z + .string() + .describe( + 'A JSON string containing an array of Request objects conforming to the Google Sheets batchUpdate schema (https://developers.google.com/workspace/sheets/api/reference/rest/v4/spreadsheets/request#Request). Example (bold A1 on sheetId 0): \'[{"repeatCell":{"range":{"sheetId":0,"startRowIndex":0,"endRowIndex":1,"startColumnIndex":0,"endColumnIndex":1},"cell":{"userEnteredFormat":{"textFormat":{"bold":true}}},"fields":"userEnteredFormat.textFormat.bold"}}]\'.', + ), + }, + }, + sheetsService.batchUpdate, + ); + registerTool( 'drive.search', { diff --git a/workspace-server/src/services/SheetsService.ts b/workspace-server/src/services/SheetsService.ts index 636f03ca..51cf8610 100644 --- a/workspace-server/src/services/SheetsService.ts +++ b/workspace-server/src/services/SheetsService.ts @@ -246,4 +246,66 @@ export class SheetsService { }; } }; + + public batchUpdate = async ({ + spreadsheetId, + requests, + }: { + spreadsheetId: string; + requests: string; + }) => { + logToFile( + `[SheetsService] Starting batchUpdate for spreadsheet: ${spreadsheetId}`, + ); + try { + const id = extractDocId(spreadsheetId) || spreadsheetId; + + let parsedRequests: sheets_v4.Schema$Request[]; + try { + parsedRequests = JSON.parse(requests); + } catch (parseError) { + const message = + parseError instanceof Error ? parseError.message : String(parseError); + throw new Error(`"requests" must be a valid JSON array: ${message}`); + } + if (!Array.isArray(parsedRequests)) { + throw new Error('"requests" must be a JSON array of Request objects.'); + } + + const sheets = await this.getSheetsClient(); + const response = await sheets.spreadsheets.batchUpdate({ + spreadsheetId: id, + requestBody: { requests: parsedRequests }, + }); + + logToFile( + `[SheetsService] Finished batchUpdate for spreadsheet: ${id} (${parsedRequests.length} request(s))`, + ); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + spreadsheetId: response.data.spreadsheetId, + repliesCount: response.data.replies?.length ?? 0, + }), + }, + ], + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logToFile( + `[SheetsService] Error during sheets.batchUpdate: ${errorMessage}`, + ); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ error: errorMessage }), + }, + ], + }; + } + }; }