From 7ecb606a5ff2fe530084168e0aa8877d4ce5565c Mon Sep 17 00:00:00 2001 From: Ceres Rohana Date: Wed, 22 Apr 2026 13:32:55 +0100 Subject: [PATCH 1/5] 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/5] 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/5] 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/5] 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: [ From 85e4604f2c89e226e2e38bba8c0fbf2f8789f2b7 Mon Sep 17 00:00:00 2001 From: Ceres Rohana Date: Sun, 14 Jun 2026 18:52:27 -0300 Subject: [PATCH 5/5] Address review comments on sheets.appendRows - Handle phantom success: return isError when response.data.updates is undefined - Add isError: true to catch block for consistency with Drive/Docs services - Add .min(1) guard on values array in index.ts to reject empty appends - Use realistic spreadsheet ID in URL extraction test - Add test for missing updates metadata (isError path) - Add isError assertion to error path test - Add sheets.appendRows to docs/index.md - Add sheets.write section to docs/feature-configuration.md --- docs/feature-configuration.md | 4 +++ docs/index.md | 1 + .../__tests__/services/SheetsService.test.ts | 21 ++++++++++++-- workspace-server/src/index.ts | 1 + .../src/services/SheetsService.ts | 28 ++++++++++++++++--- 5 files changed, 49 insertions(+), 6 deletions(-) diff --git a/docs/feature-configuration.md b/docs/feature-configuration.md index 3e9aed6..71e4948 100644 --- a/docs/feature-configuration.md +++ b/docs/feature-configuration.md @@ -197,6 +197,10 @@ When a feature group is disabled: - `sheets.getRange` - `sheets.getMetadata` +### `sheets.write` + +- `sheets.appendRows` + ### `time.read` - `time.getCurrentDate` diff --git a/docs/index.md b/docs/index.md index 7d7b622..93823f4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -40,6 +40,7 @@ The extension provides the following tools: - `sheets.getRange`: Gets values from a specific range in a Google Sheets spreadsheet. - `sheets.getMetadata`: Gets metadata about a Google Sheets spreadsheet. +- `sheets.appendRows`: Appends rows to a Google Sheets spreadsheet. ### Google Drive diff --git a/workspace-server/src/__tests__/services/SheetsService.test.ts b/workspace-server/src/__tests__/services/SheetsService.test.ts index b3c4f8d..170c0e9 100644 --- a/workspace-server/src/__tests__/services/SheetsService.test.ts +++ b/workspace-server/src/__tests__/services/SheetsService.test.ts @@ -413,16 +413,32 @@ describe('SheetsService', () => { }); await sheetsService.appendRows({ - spreadsheetId: 'https://docs.google.com/spreadsheets/d/abc123/edit', + spreadsheetId: 'https://docs.google.com/spreadsheets/d/1A2b-_C3dEfGhIjKlMnOpQr/edit', range: 'Sheet1!A1', values: [['value']], }); expect(mockSheetsAPI.spreadsheets.values.append).toHaveBeenCalledWith( - expect.objectContaining({ spreadsheetId: 'abc123' }), + expect.objectContaining({ spreadsheetId: '1A2b-_C3dEfGhIjKlMnOpQr' }), ); }); + it('should return isError when updates metadata is missing', async () => { + mockSheetsAPI.spreadsheets.values.append.mockResolvedValue({ + data: {}, + }); + + const result = await sheetsService.appendRows({ + spreadsheetId: 'test-id', + range: 'Sheet1!A1', + values: [['value']], + }); + const response = JSON.parse(result.content[0].text); + + expect(result.isError).toBe(true); + expect(response.error).toMatch(/no update information/); + }); + it('should handle errors gracefully', async () => { mockSheetsAPI.spreadsheets.values.append.mockRejectedValue( new Error('Append Error'), @@ -435,6 +451,7 @@ describe('SheetsService', () => { }); const response = JSON.parse(result.content[0].text); + expect(result.isError).toBe(true); expect(response.error).toBe('Append Error'); }); }); diff --git a/workspace-server/src/index.ts b/workspace-server/src/index.ts index 302048d..de405ec 100644 --- a/workspace-server/src/index.ts +++ b/workspace-server/src/index.ts @@ -519,6 +519,7 @@ async function main() { ), values: z .array(z.array(z.union([z.string(), z.number(), z.boolean(), z.null()]))) + .min(1) .describe( 'A 2D array of values to append. Each inner array is a row (e.g., [["col1", "col2"], ["val1", "val2"]]).', ), diff --git a/workspace-server/src/services/SheetsService.ts b/workspace-server/src/services/SheetsService.ts index 739d271..ae36334 100644 --- a/workspace-server/src/services/SheetsService.ts +++ b/workspace-server/src/services/SheetsService.ts @@ -219,16 +219,35 @@ export class SheetsService { }, }); + const updates = response.data.updates; + if (!updates) { + logToFile( + `[SheetsService] appendRows returned no update metadata for: ${id}`, + ); + return { + isError: true, + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + error: + 'Append returned no update information; rows may not have been written.', + }), + }, + ], + }; + } + logToFile(`[SheetsService] Finished appendRows 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, + updatedRange: updates.updatedRange, + updatedRows: updates.updatedRows, + updatedColumns: updates.updatedColumns, + updatedCells: updates.updatedCells, }), }, ], @@ -240,6 +259,7 @@ export class SheetsService { `[SheetsService] Error during sheets.appendRows: ${errorMessage}`, ); return { + isError: true, content: [ { type: 'text' as const,