From 0bc595e8796a9a923d75c7f0da0a369988dd17ec Mon Sep 17 00:00:00 2001 From: Tommy Nguyen Date: Mon, 2 Mar 2026 11:15:06 -0800 Subject: [PATCH 1/3] feat(calendar): support Meet links and attachments in event tools Signed-off-by: Tommy Nguyen --- .../services/CalendarService.test.ts | 248 ++++++++++++++++++ workspace-server/src/index.ts | 26 ++ .../src/services/CalendarService.ts | 84 +++++- 3 files changed, 351 insertions(+), 7 deletions(-) diff --git a/workspace-server/src/__tests__/services/CalendarService.test.ts b/workspace-server/src/__tests__/services/CalendarService.test.ts index 994f272c..462c63b3 100644 --- a/workspace-server/src/__tests__/services/CalendarService.test.ts +++ b/workspace-server/src/__tests__/services/CalendarService.test.ts @@ -1305,4 +1305,252 @@ describe('CalendarService', () => { }); }); }); + + describe('createEvent with Google Meet', () => { + beforeEach(async () => { + mockCalendarAPI.calendarList.list.mockResolvedValue({ + data: { + items: [{ id: 'primary', primary: true }], + }, + }); + }); + + it('should create an event with a Google Meet link', async () => { + const mockCreatedEvent = { + id: 'event123', + summary: 'Meeting with Meet', + conferenceData: { + conferenceId: 'meet-id', + entryPoints: [{ uri: 'https://meet.google.com/abc-defg-hij' }], + }, + }; + + mockCalendarAPI.events.insert.mockResolvedValue({ + data: mockCreatedEvent, + }); + + const result = await calendarService.createEvent({ + calendarId: 'primary', + summary: 'Meeting with Meet', + start: { dateTime: '2024-01-15T10:00:00-07:00' }, + end: { dateTime: '2024-01-15T11:00:00-07:00' }, + addGoogleMeet: true, + }); + + expect(mockCalendarAPI.events.insert).toHaveBeenCalledWith( + expect.objectContaining({ + calendarId: 'primary', + conferenceDataVersion: 1, + requestBody: expect.objectContaining({ + summary: 'Meeting with Meet', + conferenceData: expect.objectContaining({ + createRequest: expect.objectContaining({ + conferenceSolutionKey: { type: 'hangoutsMeet' }, + }), + }), + }), + }), + ); + + expect(JSON.parse(result.content[0].text)).toEqual(mockCreatedEvent); + }); + + it('should not include conferenceData when addGoogleMeet is false', async () => { + const mockCreatedEvent = { id: 'event123', summary: 'No Meet' }; + mockCalendarAPI.events.insert.mockResolvedValue({ + data: mockCreatedEvent, + }); + + await calendarService.createEvent({ + calendarId: 'primary', + summary: 'No Meet', + start: { dateTime: '2024-01-15T10:00:00-07:00' }, + end: { dateTime: '2024-01-15T11:00:00-07:00' }, + addGoogleMeet: false, + }); + + const callArgs = mockCalendarAPI.events.insert.mock.calls[0][0]; + expect(callArgs.conferenceDataVersion).toBeUndefined(); + expect(callArgs.requestBody.conferenceData).toBeUndefined(); + }); + }); + + describe('createEvent with attachments', () => { + beforeEach(async () => { + mockCalendarAPI.calendarList.list.mockResolvedValue({ + data: { + items: [{ id: 'primary', primary: true }], + }, + }); + }); + + it('should create an event with file attachments', async () => { + const mockCreatedEvent = { + id: 'event123', + summary: 'Meeting with Docs', + attachments: [ + { + fileUrl: 'https://drive.google.com/open?id=file123', + title: 'Agenda', + }, + ], + }; + + mockCalendarAPI.events.insert.mockResolvedValue({ + data: mockCreatedEvent, + }); + + const result = await calendarService.createEvent({ + calendarId: 'primary', + summary: 'Meeting with Docs', + start: { dateTime: '2024-01-15T10:00:00-07:00' }, + end: { dateTime: '2024-01-15T11:00:00-07:00' }, + attachments: [ + { + fileUrl: 'https://drive.google.com/open?id=file123', + title: 'Agenda', + mimeType: 'application/vnd.google-apps.document', + }, + ], + }); + + expect(mockCalendarAPI.events.insert).toHaveBeenCalledWith( + expect.objectContaining({ + supportsAttachments: true, + requestBody: expect.objectContaining({ + attachments: [ + { + fileUrl: 'https://drive.google.com/open?id=file123', + title: 'Agenda', + mimeType: 'application/vnd.google-apps.document', + }, + ], + }), + }), + ); + + expect(JSON.parse(result.content[0].text)).toEqual(mockCreatedEvent); + }); + + it('should create an event with both Google Meet and attachments', async () => { + const mockCreatedEvent = { id: 'event123' }; + mockCalendarAPI.events.insert.mockResolvedValue({ + data: mockCreatedEvent, + }); + + await calendarService.createEvent({ + calendarId: 'primary', + summary: 'Full Featured Meeting', + start: { dateTime: '2024-01-15T10:00:00-07:00' }, + end: { dateTime: '2024-01-15T11:00:00-07:00' }, + addGoogleMeet: true, + attachments: [ + { fileUrl: 'https://drive.google.com/open?id=file123' }, + ], + }); + + const callArgs = mockCalendarAPI.events.insert.mock.calls[0][0]; + expect(callArgs.conferenceDataVersion).toBe(1); + expect(callArgs.supportsAttachments).toBe(true); + expect(callArgs.requestBody.conferenceData).toBeDefined(); + expect(callArgs.requestBody.attachments).toBeDefined(); + }); + }); + + describe('updateEvent with Google Meet', () => { + beforeEach(async () => { + mockCalendarAPI.calendarList.list.mockResolvedValue({ + data: { + items: [{ id: 'primary', primary: true }], + }, + }); + }); + + it('should add Google Meet to an existing event', async () => { + const updatedEvent = { + id: 'event123', + conferenceData: { + conferenceId: 'meet-id', + entryPoints: [{ uri: 'https://meet.google.com/abc-defg-hij' }], + }, + }; + + mockCalendarAPI.events.update.mockResolvedValue({ data: updatedEvent }); + + const result = await calendarService.updateEvent({ + eventId: 'event123', + addGoogleMeet: true, + }); + + const callArgs = mockCalendarAPI.events.update.mock.calls[0][0]; + expect(callArgs.conferenceDataVersion).toBe(1); + expect(callArgs.requestBody.conferenceData).toBeDefined(); + expect( + callArgs.requestBody.conferenceData.createRequest + .conferenceSolutionKey.type, + ).toBe('hangoutsMeet'); + + expect(JSON.parse(result.content[0].text)).toEqual(updatedEvent); + }); + + it('should not include conferenceData when addGoogleMeet is false', async () => { + const updatedEvent = { id: 'event123', summary: 'No Meet' }; + mockCalendarAPI.events.update.mockResolvedValue({ data: updatedEvent }); + + await calendarService.updateEvent({ + eventId: 'event123', + summary: 'No Meet', + addGoogleMeet: false, + }); + + const callArgs = mockCalendarAPI.events.update.mock.calls[0][0]; + expect(callArgs.conferenceDataVersion).toBeUndefined(); + expect(callArgs.requestBody.conferenceData).toBeUndefined(); + }); + }); + + describe('updateEvent with attachments', () => { + beforeEach(async () => { + mockCalendarAPI.calendarList.list.mockResolvedValue({ + data: { + items: [{ id: 'primary', primary: true }], + }, + }); + }); + + it('should add attachments to an existing event', async () => { + const updatedEvent = { + id: 'event123', + attachments: [ + { + fileUrl: 'https://drive.google.com/open?id=file123', + title: 'Notes', + }, + ], + }; + + mockCalendarAPI.events.update.mockResolvedValue({ data: updatedEvent }); + + const result = await calendarService.updateEvent({ + eventId: 'event123', + attachments: [ + { + fileUrl: 'https://drive.google.com/open?id=file123', + title: 'Notes', + }, + ], + }); + + const callArgs = mockCalendarAPI.events.update.mock.calls[0][0]; + expect(callArgs.supportsAttachments).toBe(true); + expect(callArgs.requestBody.attachments).toEqual([ + expect.objectContaining({ + fileUrl: 'https://drive.google.com/open?id=file123', + title: 'Notes', + }), + ]); + + expect(JSON.parse(result.content[0].text)).toEqual(updatedEvent); + }); + }); }); diff --git a/workspace-server/src/index.ts b/workspace-server/src/index.ts index 3044460d..be0758f0 100644 --- a/workspace-server/src/index.ts +++ b/workspace-server/src/index.ts @@ -25,6 +25,30 @@ import { extractDocId } from './utils/IdUtils'; import { setLoggingEnabled } from './utils/logger'; import { applyToolNameNormalization } from './utils/tool-normalization'; +// Shared schemas for calendar event tools +const eventMeetAndAttachmentsSchema = { + addGoogleMeet: z + .boolean() + .optional() + .describe('Whether to create a Google Meet link for the event.'), + attachments: z + .array( + z.object({ + fileUrl: z.string().describe('Google Drive file URL.'), + title: z + .string() + .optional() + .describe('Display title for the attachment.'), + mimeType: z + .string() + .optional() + .describe('MIME type of the attachment.'), + }), + ) + .optional() + .describe('Google Drive file attachments.'), +}; + // Shared schemas for Gmail tools const emailComposeSchema = { to: z @@ -601,6 +625,7 @@ async function main() { .describe( 'Whether to send notifications to attendees. Defaults to "all" if attendees are provided, otherwise "none".', ), + ...eventMeetAndAttachmentsSchema, }, }, calendarService.createEvent, @@ -719,6 +744,7 @@ async function main() { .array(z.string()) .optional() .describe('The new list of attendees for the event.'), + ...eventMeetAndAttachmentsSchema, }, }, calendarService.updateEvent, diff --git a/workspace-server/src/services/CalendarService.ts b/workspace-server/src/services/CalendarService.ts index 831ba32c..54d4f02d 100644 --- a/workspace-server/src/services/CalendarService.ts +++ b/workspace-server/src/services/CalendarService.ts @@ -10,6 +10,12 @@ import { gaxiosOptions } from '../utils/GaxiosConfig'; import { iso8601DateTimeSchema, emailArraySchema } from '../utils/validation'; import { z } from 'zod'; +export interface EventAttachment { + fileUrl: string; + title?: string; + mimeType?: string; +} + export interface CreateEventInput { calendarId?: string; summary: string; @@ -18,6 +24,8 @@ export interface CreateEventInput { end: { dateTime: string }; attendees?: string[]; sendUpdates?: 'all' | 'externalOnly' | 'none'; + addGoogleMeet?: boolean; + attachments?: EventAttachment[]; } export interface ListEventsInput { @@ -45,6 +53,8 @@ export interface UpdateEventInput { start?: { dateTime: string }; end?: { dateTime: string }; attendees?: string[]; + addGoogleMeet?: boolean; + attachments?: EventAttachment[]; } export interface RespondToEventInput { @@ -67,6 +77,32 @@ export class CalendarService { constructor(private authManager: any) {} + /** Adds conferenceData and attachments to an event body and its API params. */ + private applyMeetAndAttachments( + event: calendar_v3.Schema$Event, + params: { conferenceDataVersion?: number; supportsAttachments?: boolean }, + addGoogleMeet?: boolean, + attachments?: EventAttachment[], + ): void { + if (addGoogleMeet) { + event.conferenceData = { + createRequest: { + requestId: `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`, + conferenceSolutionKey: { type: 'hangoutsMeet' }, + }, + }; + params.conferenceDataVersion = 1; + } + if (attachments && attachments.length > 0) { + event.attachments = attachments.map((a) => ({ + fileUrl: a.fileUrl, + title: a.title, + mimeType: a.mimeType, + })); + params.supportsAttachments = true; + } + } + private createValidationErrorResponse(error: unknown) { const errorMessage = error instanceof Error ? error.message : 'Validation failed'; @@ -168,6 +204,8 @@ export class CalendarService { end, attendees, sendUpdates, + addGoogleMeet, + attachments, } = input; // Validate datetime formats @@ -188,6 +226,9 @@ export class CalendarService { logToFile(`Event start: ${start.dateTime}`); logToFile(`Event end: ${end.dateTime}`); logToFile(`Event attendees: ${attendees?.join(', ')}`); + if (addGoogleMeet) logToFile('Adding Google Meet link'); + if (attachments?.length) + logToFile(`Attachments: ${attachments.length} file(s)`); // Determine sendUpdates value let finalSendUpdates = sendUpdates; @@ -199,19 +240,28 @@ export class CalendarService { } try { - const event = { + const event: calendar_v3.Schema$Event = { summary, description, start, end, attendees: attendees?.map((email) => ({ email })), }; + const calendar = await this.getCalendar(); - const res = await calendar.events.insert({ + const insertParams: calendar_v3.Params$Resource$Events$Insert = { calendarId: finalCalendarId, requestBody: event, sendUpdates: finalSendUpdates, - }); + }; + this.applyMeetAndAttachments( + event, + insertParams, + addGoogleMeet, + attachments, + ); + + const res = await calendar.events.insert(insertParams); logToFile(`Successfully created event: ${res.data.id}`); return { content: [ @@ -380,8 +430,17 @@ export class CalendarService { }; updateEvent = async (input: UpdateEventInput) => { - const { eventId, calendarId, summary, description, start, end, attendees } = - input; + const { + eventId, + calendarId, + summary, + description, + start, + end, + attendees, + addGoogleMeet, + attachments, + } = input; // Validate datetime formats if provided try { @@ -400,6 +459,9 @@ export class CalendarService { const finalCalendarId = calendarId || (await this.getPrimaryCalendarId()); logToFile(`Updating event ${eventId} in calendar: ${finalCalendarId}`); + if (addGoogleMeet) logToFile('Adding Google Meet link'); + if (attachments?.length) + logToFile(`Attachments: ${attachments.length} file(s)`); try { const calendar = await this.getCalendar(); @@ -413,11 +475,19 @@ export class CalendarService { if (attendees) requestBody.attendees = attendees.map((email) => ({ email })); - const res = await calendar.events.update({ + const updateParams: calendar_v3.Params$Resource$Events$Update = { calendarId: finalCalendarId, eventId, requestBody, - }); + }; + this.applyMeetAndAttachments( + requestBody, + updateParams, + addGoogleMeet, + attachments, + ); + + const res = await calendar.events.update(updateParams); logToFile(`Successfully updated event: ${res.data.id}`); return { From f8697562a21db3472bdd4217f51d64b01d504a9d Mon Sep 17 00:00:00 2001 From: Tommy Nguyen Date: Mon, 2 Mar 2026 11:18:47 -0800 Subject: [PATCH 2/3] feat(sheets): add write, create, and sheet management tools Signed-off-by: Tommy Nguyen --- .../__tests__/services/SheetsService.test.ts | 348 ++++++++++++++++++ workspace-server/src/index.ts | 130 ++++++- .../src/services/SheetsService.ts | 319 ++++++++++++++++ 3 files changed, 796 insertions(+), 1 deletion(-) diff --git a/workspace-server/src/__tests__/services/SheetsService.test.ts b/workspace-server/src/__tests__/services/SheetsService.test.ts index ac5fc7fd..450e755b 100644 --- a/workspace-server/src/__tests__/services/SheetsService.test.ts +++ b/workspace-server/src/__tests__/services/SheetsService.test.ts @@ -39,8 +39,13 @@ describe('SheetsService', () => { mockSheetsAPI = { spreadsheets: { get: jest.fn(), + create: jest.fn(), + batchUpdate: jest.fn(), values: { get: jest.fn(), + update: jest.fn(), + append: jest.fn(), + clear: jest.fn(), }, }, }; @@ -432,4 +437,347 @@ describe('SheetsService', () => { expect(response.error).toBe('Metadata Error'); }); }); + + describe('updateRange', () => { + it('should write values to a specific range', async () => { + const mockResponse = { + data: { + updatedRange: 'Sheet1!A1:B2', + updatedRows: 2, + updatedColumns: 2, + updatedCells: 4, + }, + }; + + mockSheetsAPI.spreadsheets.values.update.mockResolvedValue(mockResponse); + + const result = await sheetsService.updateRange({ + spreadsheetId: 'test-id', + range: 'Sheet1!A1:B2', + values: [ + ['A1', 'B1'], + ['A2', 'B2'], + ], + }); + + expect(mockSheetsAPI.spreadsheets.values.update).toHaveBeenCalledWith({ + spreadsheetId: 'test-id', + range: 'Sheet1!A1:B2', + valueInputOption: 'USER_ENTERED', + requestBody: { + values: [ + ['A1', 'B1'], + ['A2', 'B2'], + ], + }, + }); + + const response = JSON.parse(result.content[0].text); + expect(response.updatedRange).toBe('Sheet1!A1:B2'); + expect(response.updatedRows).toBe(2); + expect(response.updatedColumns).toBe(2); + expect(response.updatedCells).toBe(4); + }); + + it('should use RAW valueInputOption when specified', async () => { + const mockResponse = { + data: { + updatedRange: 'Sheet1!A1:A1', + updatedRows: 1, + updatedColumns: 1, + updatedCells: 1, + }, + }; + + mockSheetsAPI.spreadsheets.values.update.mockResolvedValue(mockResponse); + + await sheetsService.updateRange({ + spreadsheetId: 'test-id', + range: 'Sheet1!A1', + values: [['=SUM(B1:B10)']], + valueInputOption: 'RAW', + }); + + expect(mockSheetsAPI.spreadsheets.values.update).toHaveBeenCalledWith( + expect.objectContaining({ + valueInputOption: 'RAW', + }), + ); + }); + + it('should handle errors gracefully', async () => { + mockSheetsAPI.spreadsheets.values.update.mockRejectedValue( + new Error('Update Error'), + ); + + const result = await sheetsService.updateRange({ + spreadsheetId: 'error-id', + range: 'Sheet1!A1', + values: [['test']], + }); + + const response = JSON.parse(result.content[0].text); + expect(response.error).toBe('Update Error'); + }); + }); + + describe('appendRange', () => { + it('should append rows to a sheet', async () => { + const mockResponse = { + data: { + updates: { + updatedRange: 'Sheet1!A4:B5', + updatedRows: 2, + updatedColumns: 2, + updatedCells: 4, + }, + }, + }; + + mockSheetsAPI.spreadsheets.values.append.mockResolvedValue(mockResponse); + + const result = await sheetsService.appendRange({ + spreadsheetId: 'test-id', + range: 'Sheet1!A:B', + values: [ + ['NewRow1', 'Data1'], + ['NewRow2', 'Data2'], + ], + }); + + expect(mockSheetsAPI.spreadsheets.values.append).toHaveBeenCalledWith({ + spreadsheetId: 'test-id', + range: 'Sheet1!A:B', + valueInputOption: 'USER_ENTERED', + insertDataOption: 'INSERT_ROWS', + requestBody: { + values: [ + ['NewRow1', 'Data1'], + ['NewRow2', 'Data2'], + ], + }, + }); + + const response = JSON.parse(result.content[0].text); + expect(response.updates.updatedRange).toBe('Sheet1!A4:B5'); + expect(response.updates.updatedRows).toBe(2); + }); + + it('should handle errors gracefully', async () => { + mockSheetsAPI.spreadsheets.values.append.mockRejectedValue( + new Error('Append Error'), + ); + + const result = await sheetsService.appendRange({ + spreadsheetId: 'error-id', + range: 'Sheet1!A:B', + values: [['test']], + }); + + const response = JSON.parse(result.content[0].text); + expect(response.error).toBe('Append Error'); + }); + }); + + describe('clearRange', () => { + it('should clear values from a range', async () => { + const mockResponse = { + data: { + clearedRange: 'Sheet1!A1:D10', + }, + }; + + mockSheetsAPI.spreadsheets.values.clear.mockResolvedValue(mockResponse); + + const result = await sheetsService.clearRange({ + spreadsheetId: 'test-id', + range: 'Sheet1!A1:D10', + }); + + expect(mockSheetsAPI.spreadsheets.values.clear).toHaveBeenCalledWith({ + spreadsheetId: 'test-id', + range: 'Sheet1!A1:D10', + }); + + const response = JSON.parse(result.content[0].text); + expect(response.clearedRange).toBe('Sheet1!A1:D10'); + }); + + it('should handle errors gracefully', async () => { + mockSheetsAPI.spreadsheets.values.clear.mockRejectedValue( + new Error('Clear Error'), + ); + + const result = await sheetsService.clearRange({ + spreadsheetId: 'error-id', + range: 'Sheet1!A1:A1', + }); + + const response = JSON.parse(result.content[0].text); + expect(response.error).toBe('Clear Error'); + }); + }); + + describe('createSpreadsheet', () => { + it('should create a new spreadsheet', async () => { + const mockResponse = { + data: { + spreadsheetId: 'new-spreadsheet-id', + spreadsheetUrl: 'https://docs.google.com/spreadsheets/d/new-spreadsheet-id', + properties: { title: 'My New Sheet' }, + sheets: [ + { properties: { sheetId: 0, title: 'Sheet1' } }, + ], + }, + }; + + mockSheetsAPI.spreadsheets.create.mockResolvedValue(mockResponse); + + const result = await sheetsService.createSpreadsheet({ + title: 'My New Sheet', + }); + + expect(mockSheetsAPI.spreadsheets.create).toHaveBeenCalledWith({ + requestBody: { + properties: { title: 'My New Sheet' }, + sheets: undefined, + }, + }); + + const response = JSON.parse(result.content[0].text); + expect(response.spreadsheetId).toBe('new-spreadsheet-id'); + expect(response.title).toBe('My New Sheet'); + }); + + it('should create a spreadsheet with custom sheet titles', async () => { + const mockResponse = { + data: { + spreadsheetId: 'new-id', + spreadsheetUrl: 'https://docs.google.com/spreadsheets/d/new-id', + properties: { title: 'Budget' }, + sheets: [ + { properties: { sheetId: 0, title: 'Summary' } }, + { properties: { sheetId: 1, title: 'Data' } }, + ], + }, + }; + + mockSheetsAPI.spreadsheets.create.mockResolvedValue(mockResponse); + + const result = await sheetsService.createSpreadsheet({ + title: 'Budget', + sheetTitles: ['Summary', 'Data'], + }); + + expect(mockSheetsAPI.spreadsheets.create).toHaveBeenCalledWith({ + requestBody: { + properties: { title: 'Budget' }, + sheets: [ + { properties: { title: 'Summary' } }, + { properties: { title: 'Data' } }, + ], + }, + }); + + const response = JSON.parse(result.content[0].text); + expect(response.sheets).toHaveLength(2); + expect(response.sheets[0].title).toBe('Summary'); + }); + + it('should handle errors gracefully', async () => { + mockSheetsAPI.spreadsheets.create.mockRejectedValue( + new Error('Create Error'), + ); + + const result = await sheetsService.createSpreadsheet({ + title: 'Error Sheet', + }); + + const response = JSON.parse(result.content[0].text); + expect(response.error).toBe('Create Error'); + }); + }); + + describe('addSheet', () => { + it('should add a new sheet to a spreadsheet', async () => { + const mockResponse = { + data: { + replies: [ + { + addSheet: { + properties: { sheetId: 123, title: 'New Tab' }, + }, + }, + ], + }, + }; + + mockSheetsAPI.spreadsheets.batchUpdate.mockResolvedValue(mockResponse); + + const result = await sheetsService.addSheet({ + spreadsheetId: 'test-id', + title: 'New Tab', + }); + + expect(mockSheetsAPI.spreadsheets.batchUpdate).toHaveBeenCalledWith({ + spreadsheetId: 'test-id', + requestBody: { + requests: [{ addSheet: { properties: { title: 'New Tab' } } }], + }, + }); + + const response = JSON.parse(result.content[0].text); + expect(response.sheetId).toBe(123); + expect(response.title).toBe('New Tab'); + }); + + it('should handle errors gracefully', async () => { + mockSheetsAPI.spreadsheets.batchUpdate.mockRejectedValue( + new Error('AddSheet Error'), + ); + + const result = await sheetsService.addSheet({ + spreadsheetId: 'error-id', + title: 'Bad Tab', + }); + + const response = JSON.parse(result.content[0].text); + expect(response.error).toBe('AddSheet Error'); + }); + }); + + describe('deleteSheet', () => { + it('should delete a sheet from a spreadsheet', async () => { + mockSheetsAPI.spreadsheets.batchUpdate.mockResolvedValue({ data: {} }); + + const result = await sheetsService.deleteSheet({ + spreadsheetId: 'test-id', + sheetId: 456, + }); + + expect(mockSheetsAPI.spreadsheets.batchUpdate).toHaveBeenCalledWith({ + spreadsheetId: 'test-id', + requestBody: { + requests: [{ deleteSheet: { sheetId: 456 } }], + }, + }); + + const response = JSON.parse(result.content[0].text); + expect(response.message).toBe('Successfully deleted sheet 456'); + }); + + it('should handle errors gracefully', async () => { + mockSheetsAPI.spreadsheets.batchUpdate.mockRejectedValue( + new Error('DeleteSheet Error'), + ); + + const result = await sheetsService.deleteSheet({ + spreadsheetId: 'error-id', + sheetId: 999, + }); + + const response = JSON.parse(result.content[0].text); + expect(response.error).toBe('DeleteSheet Error'); + }); + }); }); diff --git a/workspace-server/src/index.ts b/workspace-server/src/index.ts index be0758f0..5601eeb7 100644 --- a/workspace-server/src/index.ts +++ b/workspace-server/src/index.ts @@ -81,7 +81,7 @@ const SCOPES = [ 'https://www.googleapis.com/auth/gmail.modify', 'https://www.googleapis.com/auth/directory.readonly', 'https://www.googleapis.com/auth/presentations.readonly', - 'https://www.googleapis.com/auth/spreadsheets.readonly', + 'https://www.googleapis.com/auth/spreadsheets', ]; // Dynamically import version from package.json @@ -523,6 +523,134 @@ async function main() { sheetsService.getMetadata, ); + server.registerTool( + 'sheets.updateRange', + { + description: + 'Writes values to a specific range in a Google Sheets spreadsheet.', + inputSchema: { + spreadsheetId: z.string().describe('The ID or URL of the spreadsheet.'), + range: z + .string() + .describe( + 'The A1 notation range to write to (e.g., "Sheet1!A1:B2").', + ), + values: z + .array( + z.array( + z.union([z.string(), z.number(), z.boolean(), z.null()]), + ), + ) + .describe( + 'The values to write, as a 2D array (rows x columns). Supports strings, numbers, booleans, and null.', + ), + valueInputOption: z + .enum(['RAW', 'USER_ENTERED']) + .optional() + .describe( + 'How to interpret the input values. RAW: values are stored as-is. USER_ENTERED: values are parsed as if typed into the UI (default: USER_ENTERED).', + ), + }, + }, + sheetsService.updateRange, + ); + + server.registerTool( + 'sheets.appendRange', + { + description: + 'Appends rows of values after the last row with data in a Google Sheets spreadsheet.', + inputSchema: { + spreadsheetId: z.string().describe('The ID or URL of the spreadsheet.'), + range: z + .string() + .describe( + 'The A1 notation range to search for data to append after (e.g., "Sheet1!A:E").', + ), + values: z + .array( + z.array( + z.union([z.string(), z.number(), z.boolean(), z.null()]), + ), + ) + .describe( + 'The rows to append, as a 2D array. Supports strings, numbers, booleans, and null.', + ), + valueInputOption: z + .enum(['RAW', 'USER_ENTERED']) + .optional() + .describe( + 'How to interpret the input values. RAW: values are stored as-is. USER_ENTERED: values are parsed as if typed into the UI (default: USER_ENTERED).', + ), + }, + }, + sheetsService.appendRange, + ); + + server.registerTool( + 'sheets.clearRange', + { + description: + 'Clears all values from a specific range in a Google Sheets spreadsheet.', + inputSchema: { + spreadsheetId: z.string().describe('The ID or URL of the spreadsheet.'), + range: z + .string() + .describe( + 'The A1 notation range to clear (e.g., "Sheet1!A1:B2").', + ), + }, + }, + sheetsService.clearRange, + ); + + server.registerTool( + 'sheets.createSpreadsheet', + { + description: 'Creates a new Google Sheets spreadsheet.', + inputSchema: { + title: z.string().describe('The title of the new spreadsheet.'), + sheetTitles: z + .array(z.string()) + .optional() + .describe( + 'Optional list of sheet/tab names to create. Defaults to a single "Sheet1" tab.', + ), + }, + }, + sheetsService.createSpreadsheet, + ); + + server.registerTool( + 'sheets.addSheet', + { + description: + 'Adds a new sheet (tab) to an existing Google Sheets spreadsheet.', + inputSchema: { + spreadsheetId: z.string().describe('The ID or URL of the spreadsheet.'), + title: z.string().describe('The title of the new sheet/tab.'), + }, + }, + sheetsService.addSheet, + ); + + server.registerTool( + 'sheets.deleteSheet', + { + description: + 'Deletes a sheet (tab) from a Google Sheets spreadsheet by its numeric sheet ID.', + inputSchema: { + spreadsheetId: z.string().describe('The ID or URL of the spreadsheet.'), + sheetId: z + .number() + .describe( + 'The numeric ID of the sheet to delete. Use sheets.getMetadata to find sheet IDs.', + ), + }, + }, + sheetsService.deleteSheet, + ); + server.registerTool( 'drive.search', { diff --git a/workspace-server/src/services/SheetsService.ts b/workspace-server/src/services/SheetsService.ts index fb8df36f..6620e87e 100644 --- a/workspace-server/src/services/SheetsService.ts +++ b/workspace-server/src/services/SheetsService.ts @@ -258,6 +258,325 @@ export class SheetsService { } }; + public updateRange = async ({ + spreadsheetId, + range, + values, + valueInputOption = 'USER_ENTERED', + }: { + spreadsheetId: string; + range: string; + values: (string | number | boolean | null)[][]; + valueInputOption?: 'RAW' | 'USER_ENTERED'; + }) => { + logToFile( + `[SheetsService] Starting updateRange for spreadsheet: ${spreadsheetId}, range: ${range}`, + ); + try { + const id = extractDocId(spreadsheetId) || spreadsheetId; + + const sheets = await this.getSheetsClient(); + const response = await sheets.spreadsheets.values.update({ + spreadsheetId: id, + range, + valueInputOption, + requestBody: { values }, + }); + + logToFile(`[SheetsService] Finished updateRange for spreadsheet: ${id}`); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + updatedRange: response.data.updatedRange, + updatedRows: response.data.updatedRows, + updatedColumns: response.data.updatedColumns, + updatedCells: response.data.updatedCells, + }), + }, + ], + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logToFile( + `[SheetsService] Error during sheets.updateRange: ${errorMessage}`, + ); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ error: errorMessage }), + }, + ], + }; + } + }; + + public appendRange = async ({ + spreadsheetId, + range, + values, + valueInputOption = 'USER_ENTERED', + }: { + spreadsheetId: string; + range: string; + values: (string | number | boolean | null)[][]; + valueInputOption?: 'RAW' | 'USER_ENTERED'; + }) => { + logToFile( + `[SheetsService] Starting appendRange 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, + valueInputOption, + insertDataOption: 'INSERT_ROWS', + requestBody: { values }, + }); + + logToFile(`[SheetsService] Finished appendRange for spreadsheet: ${id}`); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + updates: { + 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.appendRange: ${errorMessage}`, + ); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ error: errorMessage }), + }, + ], + }; + } + }; + + public clearRange = async ({ + spreadsheetId, + range, + }: { + spreadsheetId: string; + range: string; + }) => { + logToFile( + `[SheetsService] Starting clearRange for spreadsheet: ${spreadsheetId}, range: ${range}`, + ); + try { + const id = extractDocId(spreadsheetId) || spreadsheetId; + + const sheets = await this.getSheetsClient(); + const response = await sheets.spreadsheets.values.clear({ + spreadsheetId: id, + range, + }); + + logToFile(`[SheetsService] Finished clearRange for spreadsheet: ${id}`); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + clearedRange: response.data.clearedRange, + }), + }, + ], + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logToFile( + `[SheetsService] Error during sheets.clearRange: ${errorMessage}`, + ); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ error: errorMessage }), + }, + ], + }; + } + }; + + public createSpreadsheet = async ({ + title, + sheetTitles, + }: { + title: string; + sheetTitles?: string[]; + }) => { + logToFile( + `[SheetsService] Starting createSpreadsheet with title: ${title}`, + ); + try { + const sheets = await this.getSheetsClient(); + const response = await sheets.spreadsheets.create({ + requestBody: { + properties: { title }, + sheets: sheetTitles?.map((t) => ({ properties: { title: t } })), + }, + }); + + logToFile( + `[SheetsService] Created spreadsheet: ${response.data.spreadsheetId}`, + ); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + spreadsheetId: response.data.spreadsheetId, + spreadsheetUrl: response.data.spreadsheetUrl, + title: response.data.properties?.title, + sheets: response.data.sheets?.map((s) => ({ + sheetId: s.properties?.sheetId, + title: s.properties?.title, + })), + }), + }, + ], + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logToFile( + `[SheetsService] Error during sheets.createSpreadsheet: ${errorMessage}`, + ); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ error: errorMessage }), + }, + ], + }; + } + }; + + public addSheet = async ({ + spreadsheetId, + title, + }: { + spreadsheetId: string; + title: string; + }) => { + logToFile( + `[SheetsService] Starting addSheet for spreadsheet: ${spreadsheetId}, title: ${title}`, + ); + try { + const id = extractDocId(spreadsheetId) || spreadsheetId; + + const sheets = await this.getSheetsClient(); + const response = await sheets.spreadsheets.batchUpdate({ + spreadsheetId: id, + requestBody: { + requests: [{ addSheet: { properties: { title } } }], + }, + }); + + const addedSheet = response.data.replies?.[0]?.addSheet; + logToFile(`[SheetsService] Added sheet to spreadsheet: ${id}`); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + sheetId: addedSheet?.properties?.sheetId, + title: addedSheet?.properties?.title, + }), + }, + ], + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logToFile( + `[SheetsService] Error during sheets.addSheet: ${errorMessage}`, + ); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ error: errorMessage }), + }, + ], + }; + } + }; + + public deleteSheet = async ({ + spreadsheetId, + sheetId, + }: { + spreadsheetId: string; + sheetId: number; + }) => { + logToFile( + `[SheetsService] Starting deleteSheet for spreadsheet: ${spreadsheetId}, sheetId: ${sheetId}`, + ); + try { + const id = extractDocId(spreadsheetId) || spreadsheetId; + + const sheets = await this.getSheetsClient(); + await sheets.spreadsheets.batchUpdate({ + spreadsheetId: id, + requestBody: { + requests: [{ deleteSheet: { sheetId } }], + }, + }); + + logToFile( + `[SheetsService] Deleted sheet ${sheetId} from spreadsheet: ${id}`, + ); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ + message: `Successfully deleted sheet ${sheetId}`, + }), + }, + ], + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + logToFile( + `[SheetsService] Error during sheets.deleteSheet: ${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 90b95a948259193a0d8e3df7eed8ba6e97237c68 Mon Sep 17 00:00:00 2001 From: Tommy Nguyen Date: Tue, 7 Apr 2026 08:50:40 -0700 Subject: [PATCH 3/3] fix(calendar): use patch for event updates and disable sheets.write by default Signed-off-by: Tommy Nguyen --- .../__tests__/features/feature-config.test.ts | 3 ++- .../services/CalendarService.test.ts | 26 +++++++++---------- .../src/features/feature-config.ts | 2 +- .../src/services/CalendarService.ts | 6 ++--- 4 files changed, 19 insertions(+), 18 deletions(-) diff --git a/workspace-server/src/__tests__/features/feature-config.test.ts b/workspace-server/src/__tests__/features/feature-config.test.ts index 6555f21c..f304664e 100644 --- a/workspace-server/src/__tests__/features/feature-config.test.ts +++ b/workspace-server/src/__tests__/features/feature-config.test.ts @@ -24,11 +24,12 @@ describe('feature-config', () => { expect(duplicates).toEqual([]); }); - it('should have slides.write, tasks.read, and tasks.write defaulted to OFF', () => { + it('should have slides.write, sheets.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'); }); diff --git a/workspace-server/src/__tests__/services/CalendarService.test.ts b/workspace-server/src/__tests__/services/CalendarService.test.ts index e01e2e2c..ccd0201d 100644 --- a/workspace-server/src/__tests__/services/CalendarService.test.ts +++ b/workspace-server/src/__tests__/services/CalendarService.test.ts @@ -857,7 +857,7 @@ describe('CalendarService', () => { attendees: [{ email: 'new@example.com' }], }; - mockCalendarAPI.events.update.mockResolvedValue({ data: updatedEvent }); + mockCalendarAPI.events.patch.mockResolvedValue({ data: updatedEvent }); const result = await calendarService.updateEvent({ eventId: 'event123', @@ -867,7 +867,7 @@ describe('CalendarService', () => { attendees: ['new@example.com'], }); - expect(mockCalendarAPI.events.update).toHaveBeenCalledWith({ + expect(mockCalendarAPI.events.patch).toHaveBeenCalledWith({ calendarId: 'primary', eventId: 'event123', requestBody: { @@ -889,14 +889,14 @@ describe('CalendarService', () => { description: 'New updated description', }; - mockCalendarAPI.events.update.mockResolvedValue({ data: updatedEvent }); + mockCalendarAPI.events.patch.mockResolvedValue({ data: updatedEvent }); const result = await calendarService.updateEvent({ eventId: 'event123', description: 'New updated description', }); - expect(mockCalendarAPI.events.update).toHaveBeenCalledWith({ + expect(mockCalendarAPI.events.patch).toHaveBeenCalledWith({ calendarId: 'primary', eventId: 'event123', requestBody: { @@ -910,7 +910,7 @@ describe('CalendarService', () => { it('should handle update errors', async () => { const apiError = new Error('Update failed'); - mockCalendarAPI.events.update.mockRejectedValue(apiError); + mockCalendarAPI.events.patch.mockRejectedValue(apiError); const result = await calendarService.updateEvent({ eventId: 'event123', @@ -927,14 +927,14 @@ describe('CalendarService', () => { summary: 'Updated Meeting Only', }; - mockCalendarAPI.events.update.mockResolvedValue({ data: updatedEvent }); + mockCalendarAPI.events.patch.mockResolvedValue({ data: updatedEvent }); await calendarService.updateEvent({ eventId: 'event123', summary: 'Updated Meeting Only', }); - expect(mockCalendarAPI.events.update).toHaveBeenCalledWith({ + expect(mockCalendarAPI.events.patch).toHaveBeenCalledWith({ calendarId: 'primary', eventId: 'event123', requestBody: { @@ -1495,14 +1495,14 @@ describe('CalendarService', () => { }, }; - mockCalendarAPI.events.update.mockResolvedValue({ data: updatedEvent }); + mockCalendarAPI.events.patch.mockResolvedValue({ data: updatedEvent }); const result = await calendarService.updateEvent({ eventId: 'event123', addGoogleMeet: true, }); - const callArgs = mockCalendarAPI.events.update.mock.calls[0][0]; + const callArgs = mockCalendarAPI.events.patch.mock.calls[0][0]; expect(callArgs.conferenceDataVersion).toBe(1); expect(callArgs.requestBody.conferenceData).toBeDefined(); expect( @@ -1515,7 +1515,7 @@ describe('CalendarService', () => { it('should not include conferenceData when addGoogleMeet is false', async () => { const updatedEvent = { id: 'event123', summary: 'No Meet' }; - mockCalendarAPI.events.update.mockResolvedValue({ data: updatedEvent }); + mockCalendarAPI.events.patch.mockResolvedValue({ data: updatedEvent }); await calendarService.updateEvent({ eventId: 'event123', @@ -1523,7 +1523,7 @@ describe('CalendarService', () => { addGoogleMeet: false, }); - const callArgs = mockCalendarAPI.events.update.mock.calls[0][0]; + const callArgs = mockCalendarAPI.events.patch.mock.calls[0][0]; expect(callArgs.conferenceDataVersion).toBeUndefined(); expect(callArgs.requestBody.conferenceData).toBeUndefined(); }); @@ -1541,7 +1541,7 @@ describe('CalendarService', () => { ], }; - mockCalendarAPI.events.update.mockResolvedValue({ data: updatedEvent }); + mockCalendarAPI.events.patch.mockResolvedValue({ data: updatedEvent }); const result = await calendarService.updateEvent({ eventId: 'event123', @@ -1553,7 +1553,7 @@ describe('CalendarService', () => { ], }); - const callArgs = mockCalendarAPI.events.update.mock.calls[0][0]; + const callArgs = mockCalendarAPI.events.patch.mock.calls[0][0]; expect(callArgs.supportsAttachments).toBe(true); expect(callArgs.requestBody.attachments).toEqual([ expect.objectContaining({ diff --git a/workspace-server/src/features/feature-config.ts b/workspace-server/src/features/feature-config.ts index 7cb8aa80..130aba1e 100644 --- a/workspace-server/src/features/feature-config.ts +++ b/workspace-server/src/features/feature-config.ts @@ -234,7 +234,7 @@ export const FEATURE_GROUPS: readonly FeatureGroup[] = [ 'sheets.addSheet', 'sheets.deleteSheet', ], - defaultEnabled: true, + defaultEnabled: false, }, // Time (no scopes needed) diff --git a/workspace-server/src/services/CalendarService.ts b/workspace-server/src/services/CalendarService.ts index b9c2a317..f8a01eb6 100644 --- a/workspace-server/src/services/CalendarService.ts +++ b/workspace-server/src/services/CalendarService.ts @@ -735,19 +735,19 @@ export class CalendarService { if (attendees) requestBody.attendees = attendees.map((email) => ({ email })); - const updateParams: calendar_v3.Params$Resource$Events$Update = { + const patchParams: calendar_v3.Params$Resource$Events$Patch = { calendarId: finalCalendarId, eventId, requestBody, }; this.applyMeetAndAttachments( requestBody, - updateParams, + patchParams, addGoogleMeet, attachments, ); - const res = await calendar.events.update(updateParams); + const res = await calendar.events.patch(patchParams); logToFile(`Successfully updated event: ${res.data.id}`); return {