Skip to content
Open
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
4 changes: 4 additions & 0 deletions docs/feature-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,10 @@ When a feature group is disabled:
- `sheets.getRange`
- `sheets.getMetadata`

### `sheets.write`

- `sheets.appendRows`

### `time.read`

- `time.getCurrentDate`
Expand Down
1 change: 1 addition & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
85 changes: 85 additions & 0 deletions workspace-server/src/__tests__/services/SheetsService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ describe('SheetsService', () => {
get: jest.fn(),
values: {
get: jest.fn(),
append: jest.fn(),
},
},
};
Expand Down Expand Up @@ -370,4 +371,88 @@ describe('SheetsService', () => {
expect(response.error).toBe('Metadata Error');
});
});

describe('appendRows', () => {
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.appendRows({
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.appendRows({
spreadsheetId: 'https://docs.google.com/spreadsheets/d/1A2b-_C3dEfGhIjKlMnOpQr/edit',
range: 'Sheet1!A1',
values: [['value']],
});

expect(mockSheetsAPI.spreadsheets.values.append).toHaveBeenCalledWith(
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'),
);

const result = await sheetsService.appendRows({
spreadsheetId: 'error-id',
range: 'Sheet1!A1',
values: [['data']],
});
const response = JSON.parse(result.content[0].text);

expect(result.isError).toBe(true);
expect(response.error).toBe('Append Error');
});
});
});
2 changes: 1 addition & 1 deletion workspace-server/src/features/feature-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ export const FEATURE_GROUPS: readonly FeatureGroup[] = [
service: 'sheets',
group: 'write',
scopes: scopes('spreadsheets'),
tools: [],
tools: ['sheets.appendRows'],
defaultEnabled: false,
},

Expand Down
23 changes: 23 additions & 0 deletions workspace-server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,29 @@ async function main() {
sheetsService.getMetadata,
);

registerTool(
'sheets.appendRows',
{
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.union([z.string(), z.number(), z.boolean(), z.null()])))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

values: [] currently passes validation and forwards an empty requestBody.values to the API — a wasted no-op call. A .min(1) guard rejects it at the tool boundary with a clear message: z.array(z.array(z.union([...]))).min(1). (Don't enforce rectangular rows — ragged rows are legal in Sheets, where shorter rows just leave trailing cells untouched.)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added .min(1) on the outer array. Left inner rows unconstrained as suggested — ragged rows are valid in Sheets.

.min(1)
.describe(
'A 2D array of values to append. Each inner array is a row (e.g., [["col1", "col2"], ["val1", "val2"]]).',
),
},
},
sheetsService.appendRows,
);

registerTool(
'drive.search',
{
Expand Down
76 changes: 76 additions & 0 deletions workspace-server/src/services/SheetsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,82 @@ export class SheetsService {
}
};

public appendRows = async ({
spreadsheetId,
range,
values,
}: {
spreadsheetId: string;
range: string;
values: (string | number | boolean | null)[][];
}) => {
logToFile(
`[SheetsService] Starting appendRows 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,
},
});

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: updates.updatedRange,
updatedRows: updates.updatedRows,
updatedColumns: updates.updatedColumns,
updatedCells: updates.updatedCells,
}),
},
],
};
} catch (error) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This error result isn't flagged with isError: true, so the MCP client treats the call as a success and has to string-parse the JSON body to notice the error key. DriveService (handleError) and DocsService already set isError: true on failures. Note this is consistent with the other Sheets methods (getText/getRange/getMetadata all omit it), so it's a pre-existing pattern rather than something this PR introduces — but since appendRows is a write operation where a false "success" is more costly, it's worth adding here (ideally to all four in one pass):

return {
  isError: true,
  content: [{ type: 'text' as const, text: JSON.stringify({ error: errorMessage }) }],
};

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added isError: true to the catch block. Agreed it's more impactful here given this is a write operation.

const errorMessage =
error instanceof Error ? error.message : String(error);
logToFile(
`[SheetsService] Error during sheets.appendRows: ${errorMessage}`,
);
return {
isError: true,
content: [
{
type: 'text' as const,
text: JSON.stringify({ error: errorMessage }),
},
],
};
}
};

public getMetadata = async ({ spreadsheetId }: { spreadsheetId: string }) => {
logToFile(
`[SheetsService] Starting getMetadata for spreadsheet: ${spreadsheetId}`,
Expand Down