From 7ecb606a5ff2fe530084168e0aa8877d4ce5565c Mon Sep 17 00:00:00 2001 From: Ceres Rohana Date: Wed, 22 Apr 2026 13:32:55 +0100 Subject: [PATCH 1/4] feat(sheets): add sheets.appendRow write tool Implements the ability to append rows to a Google Sheets spreadsheet via the MCP sheets.appendRow tool. The tool is gated behind the sheets write feature group (disabled by default, scoped to spreadsheets OAuth scope). --- .../__tests__/services/SheetsService.test.ts | 68 +++++++++++++++++++ .../src/features/feature-config.ts | 2 +- workspace-server/src/index.ts | 22 ++++++ .../src/services/SheetsService.ts | 56 +++++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) diff --git a/workspace-server/src/__tests__/services/SheetsService.test.ts b/workspace-server/src/__tests__/services/SheetsService.test.ts index 50c1ff4..1b85a62 100644 --- a/workspace-server/src/__tests__/services/SheetsService.test.ts +++ b/workspace-server/src/__tests__/services/SheetsService.test.ts @@ -40,6 +40,7 @@ describe('SheetsService', () => { get: jest.fn(), values: { get: jest.fn(), + append: jest.fn(), }, }, }; @@ -370,4 +371,71 @@ describe('SheetsService', () => { expect(response.error).toBe('Metadata Error'); }); }); + + describe('appendRow', () => { + it('should append rows and return update info', async () => { + const mockResponse = { + data: { + updates: { + updatedRange: 'Sheet1!A3:B3', + updatedRows: 1, + updatedColumns: 2, + updatedCells: 2, + }, + }, + }; + + mockSheetsAPI.spreadsheets.values.append.mockResolvedValue(mockResponse); + + const result = await sheetsService.appendRow({ + spreadsheetId: 'test-id', + range: 'Sheet1!A1', + values: [['foo', 'bar']], + }); + const response = JSON.parse(result.content[0].text); + + expect(mockSheetsAPI.spreadsheets.values.append).toHaveBeenCalledWith({ + spreadsheetId: 'test-id', + range: 'Sheet1!A1', + valueInputOption: 'USER_ENTERED', + requestBody: { values: [['foo', 'bar']] }, + }); + + expect(response.updatedRange).toBe('Sheet1!A3:B3'); + expect(response.updatedRows).toBe(1); + expect(response.updatedColumns).toBe(2); + expect(response.updatedCells).toBe(2); + }); + + it('should extract spreadsheet ID from URL', async () => { + mockSheetsAPI.spreadsheets.values.append.mockResolvedValue({ + data: { updates: { updatedRange: 'Sheet1!A2:A2', updatedRows: 1, updatedColumns: 1, updatedCells: 1 } }, + }); + + await sheetsService.appendRow({ + spreadsheetId: 'https://docs.google.com/spreadsheets/d/abc123/edit', + range: 'Sheet1!A1', + values: [['value']], + }); + + expect(mockSheetsAPI.spreadsheets.values.append).toHaveBeenCalledWith( + expect.objectContaining({ spreadsheetId: 'abc123' }), + ); + }); + + it('should handle errors gracefully', async () => { + mockSheetsAPI.spreadsheets.values.append.mockRejectedValue( + new Error('Append Error'), + ); + + const result = await sheetsService.appendRow({ + spreadsheetId: 'error-id', + range: 'Sheet1!A1', + values: [['data']], + }); + const response = JSON.parse(result.content[0].text); + + expect(response.error).toBe('Append Error'); + }); + }); }); diff --git a/workspace-server/src/features/feature-config.ts b/workspace-server/src/features/feature-config.ts index e3a2cb1..3a811f7 100644 --- a/workspace-server/src/features/feature-config.ts +++ b/workspace-server/src/features/feature-config.ts @@ -226,7 +226,7 @@ export const FEATURE_GROUPS: readonly FeatureGroup[] = [ service: 'sheets', group: 'write', scopes: scopes('spreadsheets'), - tools: [], + tools: ['sheets.appendRow'], defaultEnabled: false, }, diff --git a/workspace-server/src/index.ts b/workspace-server/src/index.ts index 59a01ce..f2a24c3 100644 --- a/workspace-server/src/index.ts +++ b/workspace-server/src/index.ts @@ -505,6 +505,28 @@ async function main() { sheetsService.getMetadata, ); + registerTool( + 'sheets.appendRow', + { + description: + 'Appends rows to a Google Sheets spreadsheet after the last row with data in the given range.', + inputSchema: { + spreadsheetId: z.string().describe('The ID or URL of the spreadsheet.'), + range: z + .string() + .describe( + 'The A1 notation range to append to (e.g., "Sheet1!A1"). Data is appended after the last row with data in this range.', + ), + values: z + .array(z.array(z.string())) + .describe( + 'A 2D array of values to append. Each inner array is a row (e.g., [["col1", "col2"], ["val1", "val2"]]).', + ), + }, + }, + sheetsService.appendRow, + ); + registerTool( 'drive.search', { diff --git a/workspace-server/src/services/SheetsService.ts b/workspace-server/src/services/SheetsService.ts index 636f03c..6984fdd 100644 --- a/workspace-server/src/services/SheetsService.ts +++ b/workspace-server/src/services/SheetsService.ts @@ -194,6 +194,62 @@ export class SheetsService { } }; + public appendRow = async ({ + spreadsheetId, + range, + values, + }: { + spreadsheetId: string; + range: string; + values: string[][]; + }) => { + logToFile( + `[SheetsService] Starting appendRow for spreadsheet: ${spreadsheetId}, range: ${range}`, + ); + try { + const id = extractDocId(spreadsheetId) || spreadsheetId; + + const sheets = await this.getSheetsClient(); + const response = await sheets.spreadsheets.values.append({ + spreadsheetId: id, + range: range, + valueInputOption: 'USER_ENTERED', + requestBody: { + values: values, + }, + }); + + logToFile(`[SheetsService] Finished appendRow for spreadsheet: ${id}`); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + updatedRange: response.data.updates?.updatedRange, + updatedRows: response.data.updates?.updatedRows, + updatedColumns: response.data.updates?.updatedColumns, + updatedCells: response.data.updates?.updatedCells, + }), + }, + ], + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logToFile( + `[SheetsService] Error during sheets.appendRow: ${errorMessage}`, + ); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ error: errorMessage }), + }, + ], + }; + } + }; + public getMetadata = async ({ spreadsheetId }: { spreadsheetId: string }) => { logToFile( `[SheetsService] Starting getMetadata for spreadsheet: ${spreadsheetId}`, From 4760bdb9e12f38f147c1682905ea62b156ccbbbe Mon Sep 17 00:00:00 2001 From: Ceres Rohana Date: Wed, 22 Apr 2026 09:48:57 -0300 Subject: [PATCH 2/4] Apply suggestion from @gemini-code-assist[bot] Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- workspace-server/src/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/workspace-server/src/index.ts b/workspace-server/src/index.ts index f2a24c3..302048d 100644 --- a/workspace-server/src/index.ts +++ b/workspace-server/src/index.ts @@ -506,7 +506,7 @@ async function main() { ); registerTool( - 'sheets.appendRow', + 'sheets.appendRows', { description: 'Appends rows to a Google Sheets spreadsheet after the last row with data in the given range.', @@ -518,13 +518,13 @@ async function main() { 'The A1 notation range to append to (e.g., "Sheet1!A1"). Data is appended after the last row with data in this range.', ), values: z - .array(z.array(z.string())) + .array(z.array(z.union([z.string(), z.number(), z.boolean(), z.null()]))) .describe( 'A 2D array of values to append. Each inner array is a row (e.g., [["col1", "col2"], ["val1", "val2"]]).', ), }, }, - sheetsService.appendRow, + sheetsService.appendRows, ); registerTool( From 7f0efe40b93f4c7c9aa04033f37c983f107ed8a1 Mon Sep 17 00:00:00 2001 From: Ceres Rohana Date: Wed, 22 Apr 2026 09:49:12 -0300 Subject: [PATCH 3/4] Apply suggestion from @gemini-code-assist[bot] Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- workspace-server/src/features/feature-config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/workspace-server/src/features/feature-config.ts b/workspace-server/src/features/feature-config.ts index 3a811f7..56afe30 100644 --- a/workspace-server/src/features/feature-config.ts +++ b/workspace-server/src/features/feature-config.ts @@ -226,7 +226,7 @@ export const FEATURE_GROUPS: readonly FeatureGroup[] = [ service: 'sheets', group: 'write', scopes: scopes('spreadsheets'), - tools: ['sheets.appendRow'], + tools: ['sheets.appendRows'], defaultEnabled: false, }, From e9ec351df6b99f64b83f9e7f1823185ca96519ba Mon Sep 17 00:00:00 2001 From: Ceres Rohana Date: Wed, 22 Apr 2026 13:52:02 +0100 Subject: [PATCH 4/4] refactor(sheets): rename appendRow to appendRows, widen values type Incorporates review feedback: plural name better reflects that multiple rows can be appended at once, and values now accepts numbers, booleans, and nulls in addition to strings. --- .../src/__tests__/services/SheetsService.test.ts | 8 ++++---- workspace-server/src/services/SheetsService.ts | 10 +++++----- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/workspace-server/src/__tests__/services/SheetsService.test.ts b/workspace-server/src/__tests__/services/SheetsService.test.ts index 1b85a62..b3c4f8d 100644 --- a/workspace-server/src/__tests__/services/SheetsService.test.ts +++ b/workspace-server/src/__tests__/services/SheetsService.test.ts @@ -372,7 +372,7 @@ describe('SheetsService', () => { }); }); - describe('appendRow', () => { + describe('appendRows', () => { it('should append rows and return update info', async () => { const mockResponse = { data: { @@ -387,7 +387,7 @@ describe('SheetsService', () => { mockSheetsAPI.spreadsheets.values.append.mockResolvedValue(mockResponse); - const result = await sheetsService.appendRow({ + const result = await sheetsService.appendRows({ spreadsheetId: 'test-id', range: 'Sheet1!A1', values: [['foo', 'bar']], @@ -412,7 +412,7 @@ describe('SheetsService', () => { data: { updates: { updatedRange: 'Sheet1!A2:A2', updatedRows: 1, updatedColumns: 1, updatedCells: 1 } }, }); - await sheetsService.appendRow({ + await sheetsService.appendRows({ spreadsheetId: 'https://docs.google.com/spreadsheets/d/abc123/edit', range: 'Sheet1!A1', values: [['value']], @@ -428,7 +428,7 @@ describe('SheetsService', () => { new Error('Append Error'), ); - const result = await sheetsService.appendRow({ + const result = await sheetsService.appendRows({ spreadsheetId: 'error-id', range: 'Sheet1!A1', values: [['data']], diff --git a/workspace-server/src/services/SheetsService.ts b/workspace-server/src/services/SheetsService.ts index 6984fdd..739d271 100644 --- a/workspace-server/src/services/SheetsService.ts +++ b/workspace-server/src/services/SheetsService.ts @@ -194,17 +194,17 @@ export class SheetsService { } }; - public appendRow = async ({ + public appendRows = async ({ spreadsheetId, range, values, }: { spreadsheetId: string; range: string; - values: string[][]; + values: (string | number | boolean | null)[][]; }) => { logToFile( - `[SheetsService] Starting appendRow for spreadsheet: ${spreadsheetId}, range: ${range}`, + `[SheetsService] Starting appendRows for spreadsheet: ${spreadsheetId}, range: ${range}`, ); try { const id = extractDocId(spreadsheetId) || spreadsheetId; @@ -219,7 +219,7 @@ export class SheetsService { }, }); - logToFile(`[SheetsService] Finished appendRow for spreadsheet: ${id}`); + logToFile(`[SheetsService] Finished appendRows for spreadsheet: ${id}`); return { content: [ { @@ -237,7 +237,7 @@ export class SheetsService { const errorMessage = error instanceof Error ? error.message : String(error); logToFile( - `[SheetsService] Error during sheets.appendRow: ${errorMessage}`, + `[SheetsService] Error during sheets.appendRows: ${errorMessage}`, ); return { content: [