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
68 changes: 68 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,71 @@ 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/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.appendRows({
spreadsheetId: 'error-id',
range: 'Sheet1!A1',
values: [['data']],
});
const response = JSON.parse(result.content[0].text);

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
22 changes: 22 additions & 0 deletions workspace-server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,28 @@ 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.)

.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
56 changes: 56 additions & 0 deletions workspace-server/src/services/SheetsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,62 @@ 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,
},
});

logToFile(`[SheetsService] Finished appendRows for spreadsheet: ${id}`);
return {
content: [
{
type: 'text' as const,
text: JSON.stringify({
updatedRange: response.data.updates?.updatedRange,
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.

The four fields are read with response.data.updates?.… optional chaining. If updates is undefined — which the API can return on a 200 where nothing was written — JSON.stringify drops all four undefined fields and the tool returns {}, plus the "Finished appendRows" log line, which is indistinguishable from a real success. For a write tool, a phantom success can mean silently lost data with no signal to the caller. Suggest treating a missing updates as a failure rather than masking it:

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.',
    }) }],
  };
}
// then read updates.updatedRange, etc. directly (no `?.`)

updatedRows: response.data.updates?.updatedRows,
updatedColumns: response.data.updates?.updatedColumns,
updatedCells: response.data.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 }) }],
};

const errorMessage =
error instanceof Error ? error.message : String(error);
logToFile(
`[SheetsService] Error during sheets.appendRows: ${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}`,
Expand Down