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 }), + }, + ], + }; + } + }; }