diff --git a/workspace-server/WORKSPACE-Context.md b/workspace-server/WORKSPACE-Context.md index 0a32e3a..a470525 100644 --- a/workspace-server/WORKSPACE-Context.md +++ b/workspace-server/WORKSPACE-Context.md @@ -239,4 +239,11 @@ Choose output format based on use case: - Thread-aware messaging - Unread message filtering +### Google Tasks +- **Task List Selection**: If the user doesn't specify a task list, default to listing all task lists first to let them choose, or ask for clarification. +- **Task Creation**: When creating tasks, prompt for a due date if one isn't provided, as it's helpful for organization. +- **Completion**: Use `tasks.complete` for a simple "mark as done" action. Use `tasks.update` if you need to set other properties simultaneously. +- **Assigned Tasks**: To find tasks assigned from Google Docs or Chat, use `showAssigned=true` when listing tasks. +- **Timestamps**: Ensure due dates are in RFC 3339 format (e.g., `2024-01-15T12:00:00Z`). + Remember: This guide focuses on **how to think** about using these tools effectively. For specific parameter details, refer to the tool descriptions themselves. diff --git a/workspace-server/package.json b/workspace-server/package.json index 1b01c5e..2348f3d 100644 --- a/workspace-server/package.json +++ b/workspace-server/package.json @@ -11,7 +11,8 @@ "start": "ts-node src/index.ts", "clean": "rm -rf dist node_modules", "build": "node esbuild.config.js", - "build:auth-utils": "node esbuild.auth-utils.js" + "build:auth-utils": "node esbuild.auth-utils.js", + "typecheck": "npx tsc --noEmit" }, "keywords": [], "author": "Allen Hutchison", diff --git a/workspace-server/src/__tests__/services/TasksService.test.ts b/workspace-server/src/__tests__/services/TasksService.test.ts new file mode 100644 index 0000000..d39e9d0 --- /dev/null +++ b/workspace-server/src/__tests__/services/TasksService.test.ts @@ -0,0 +1,189 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { jest } from '@jest/globals'; +import { AuthManager } from '../../auth/AuthManager'; +import { TasksService } from '../../services/TasksService'; + +// Mock the AuthManager +const mockGetTasksClient = jest.fn<() => Promise>(); +const mockAuthManager = { + getTasksClient: mockGetTasksClient, +} as unknown as AuthManager; + +describe('TasksService', () => { + let tasksService: TasksService; + let mockTasksClient: any; + + beforeEach(() => { + mockTasksClient = { + tasklists: { + list: jest.fn(), + }, + tasks: { + list: jest.fn(), + insert: jest.fn(), + patch: jest.fn(), + delete: jest.fn(), + }, + }; + mockGetTasksClient.mockResolvedValue(mockTasksClient); + tasksService = new TasksService(mockAuthManager); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('listTaskLists', () => { + it('should list task lists', async () => { + const mockResponse = { + data: { + items: [{ id: 'list1', title: 'My Tasks' }], + }, + }; + mockTasksClient.tasklists.list.mockResolvedValue(mockResponse); + + const result = await tasksService.listTaskLists(); + + expect(mockTasksClient.tasklists.list).toHaveBeenCalledWith({ + maxResults: undefined, + pageToken: undefined, + }); + expect(result).toEqual({ + content: [{ type: 'text', text: JSON.stringify(mockResponse.data.items, null, 2) }], + }); + }); + + it('should pass pagination parameters', async () => { + const mockResponse = { data: { items: [] } }; + mockTasksClient.tasklists.list.mockResolvedValue(mockResponse); + + await tasksService.listTaskLists({ maxResults: 10, pageToken: 'token' }); + + expect(mockTasksClient.tasklists.list).toHaveBeenCalledWith({ + maxResults: 10, + pageToken: 'token', + }); + }); + }); + + describe('listTasks', () => { + it('should list tasks in a task list', async () => { + const mockResponse = { + data: { + items: [{ id: 'task1', title: 'Buy milk' }], + }, + }; + mockTasksClient.tasks.list.mockResolvedValue(mockResponse); + + const result = await tasksService.listTasks({ taskListId: 'list1', showAssigned: true }); + + expect(mockTasksClient.tasks.list).toHaveBeenCalledWith({ + tasklist: 'list1', + showCompleted: undefined, + showDeleted: undefined, + showHidden: undefined, + showAssigned: true, + maxResults: undefined, + pageToken: undefined, + dueMin: undefined, + dueMax: undefined, + }); + expect(result).toEqual({ + content: [{ type: 'text', text: JSON.stringify(mockResponse.data.items, null, 2) }], + }); + }); + }); + + describe('createTask', () => { + it('should create a task', async () => { + const mockResponse = { + data: { id: 'task1', title: 'New Task' }, + }; + mockTasksClient.tasks.insert.mockResolvedValue(mockResponse); + + const result = await tasksService.createTask({ + taskListId: 'list1', + title: 'New Task', + notes: 'Some notes', + }); + + expect(mockTasksClient.tasks.insert).toHaveBeenCalledWith({ + tasklist: 'list1', + requestBody: { + title: 'New Task', + notes: 'Some notes', + due: undefined, + }, + }); + expect(result).toEqual({ + content: [{ type: 'text', text: JSON.stringify(mockResponse.data, null, 2) }], + }); + }); + }); + + describe('updateTask', () => { + it('should update a task', async () => { + const mockResponse = { + data: { id: 'task1', title: 'Updated Task' }, + }; + mockTasksClient.tasks.patch.mockResolvedValue(mockResponse); + + const result = await tasksService.updateTask({ + taskListId: 'list1', + taskId: 'task1', + title: 'Updated Task', + }); + + expect(mockTasksClient.tasks.patch).toHaveBeenCalledWith({ + tasklist: 'list1', + task: 'task1', + requestBody: { + title: 'Updated Task', + }, + }); + expect(result).toEqual({ + content: [{ type: 'text', text: JSON.stringify(mockResponse.data, null, 2) }], + }); + }); + }); + + describe('completeTask', () => { + it('should mark a task as completed', async () => { + const mockResponse = { + data: { id: 'task1', title: 'Task 1', status: 'completed' } + }; + mockTasksClient.tasks.patch.mockResolvedValue(mockResponse); + + await tasksService.completeTask({ taskListId: 'list1', taskId: 'task1' }); + + expect(mockTasksClient.tasks.patch).toHaveBeenCalledWith({ + tasklist: 'list1', + task: 'task1', + requestBody: { + status: 'completed', + } + }); + }); + }); + + describe('deleteTask', () => { + it('should delete a task', async () => { + mockTasksClient.tasks.delete.mockResolvedValue({}); + + const result = await tasksService.deleteTask({ taskListId: 'list1', taskId: 'task1' }); + + expect(mockTasksClient.tasks.delete).toHaveBeenCalledWith({ + tasklist: 'list1', + task: 'task1', + }); + expect(result).toEqual({ + content: [{ type: 'text', text: 'Task task1 deleted successfully from list list1.' }], + }); + }); + }); +}); diff --git a/workspace-server/src/auth/AuthManager.ts b/workspace-server/src/auth/AuthManager.ts index acd7f76..cd0fca2 100644 --- a/workspace-server/src/auth/AuthManager.ts +++ b/workspace-server/src/auth/AuthManager.ts @@ -177,6 +177,11 @@ export class AuthManager { return this.client; } + public async getTasksClient() { + const client = await this.getAuthenticatedClient(); + return google.tasks({ version: 'v1', auth: client }); + } + public async clearAuth(): Promise { logToFile('Clearing authentication...'); this.client = null; diff --git a/workspace-server/src/index.ts b/workspace-server/src/index.ts index e610c8b..23b76f6 100644 --- a/workspace-server/src/index.ts +++ b/workspace-server/src/index.ts @@ -19,6 +19,7 @@ import { TimeService } from "./services/TimeService"; import { PeopleService } from "./services/PeopleService"; import { SlidesService } from "./services/SlidesService"; import { SheetsService } from "./services/SheetsService"; +import { TasksService } from "./services/TasksService"; import { GMAIL_SEARCH_MAX_RESULTS } from "./utils/constants"; import { extractDocId } from "./utils/IdUtils"; @@ -46,6 +47,7 @@ const SCOPES = [ '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/tasks', ]; // Dynamically import version from package.json @@ -70,6 +72,7 @@ async function main() { const timeService = new TimeService(); const slidesService = new SlidesService(authManager); const sheetsService = new SheetsService(authManager); + const tasksService = new TasksService(authManager); // 2. Create the server instance const server = new McpServer({ @@ -708,6 +711,92 @@ There are a list of system labels that can be modified on a message: peopleService.getMe ); + // Tasks tools + server.registerTool( + "tasks.listLists", + { + description: 'Lists the authenticated user\'s task lists.', + inputSchema: { + maxResults: z.number().optional().describe('Maximum number of task lists to return.'), + pageToken: z.string().optional().describe('Token for the next page of results.'), + } + }, + tasksService.listTaskLists + ); + + server.registerTool( + "tasks.list", + { + description: 'Lists tasks in a specific task list.', + inputSchema: { + taskListId: z.string().describe('The ID of the task list.'), + showCompleted: z.boolean().optional().describe('Whether to show completed tasks.'), + showDeleted: z.boolean().optional().describe('Whether to show deleted tasks.'), + showHidden: z.boolean().optional().describe('Whether to show hidden tasks.'), + showAssigned: z.boolean().optional().describe('Whether to show tasks assigned from Docs or Chat.'), + maxResults: z.number().optional().describe('Maximum number of tasks to return.'), + pageToken: z.string().optional().describe('Token for the next page of results.'), + dueMin: z.string().optional().describe('Lower bound for a task\'s due date (as a RFC 3339 timestamp).'), + dueMax: z.string().optional().describe('Upper bound for a task\'s due date (as a RFC 3339 timestamp).'), + } + }, + tasksService.listTasks + ); + + server.registerTool( + "tasks.create", + { + description: 'Creates a new task in the specified task list.', + inputSchema: { + taskListId: z.string().describe('The ID of the task list.'), + title: z.string().describe('The title of the task.'), + notes: z.string().optional().describe('Notes for the task.'), + due: z.string().optional().describe('The due date for the task (as a RFC 3339 timestamp).'), + } + }, + tasksService.createTask + ); + + server.registerTool( + "tasks.update", + { + description: 'Updates an existing task.', + inputSchema: { + taskListId: z.string().describe('The ID of the task list.'), + taskId: z.string().describe('The ID of the task to update.'), + title: z.string().optional().describe('The new title of the task.'), + notes: z.string().optional().describe('The new notes for the task.'), + status: z.enum(['needsAction', 'completed']).optional().describe('The new status of the task.'), + due: z.string().optional().describe('The new due date for the task (as a RFC 3339 timestamp).'), + } + }, + tasksService.updateTask + ); + + server.registerTool( + "tasks.complete", + { + description: 'Completes a task (convenience wrapper around update).', + inputSchema: { + taskListId: z.string().describe('The ID of the task list.'), + taskId: z.string().describe('The ID of the task to complete.'), + } + }, + tasksService.completeTask + ); + + server.registerTool( + "tasks.delete", + { + description: 'Deletes a task.', + inputSchema: { + taskListId: z.string().describe('The ID of the task list.'), + taskId: z.string().describe('The ID of the task to delete.'), + } + }, + tasksService.deleteTask + ); + // 4. Connect the transport layer and start listening const transport = new StdioServerTransport(); await server.connect(transport); diff --git a/workspace-server/src/services/TasksService.ts b/workspace-server/src/services/TasksService.ts new file mode 100644 index 0000000..2d2129c --- /dev/null +++ b/workspace-server/src/services/TasksService.ts @@ -0,0 +1,161 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { tasks_v1 } from 'googleapis'; +import { AuthManager } from '../auth/AuthManager'; + +export class TasksService { + private authManager: AuthManager; + + constructor(authManager: AuthManager) { + this.authManager = authManager; + } + + /** + * Lists the authenticated user's task lists. + */ + listTaskLists = async (params: { maxResults?: number; pageToken?: string } = {}) => { + const tasks = await this.authManager.getTasksClient(); + const response = await tasks.tasklists.list({ + maxResults: params.maxResults, + pageToken: params.pageToken, + }); + + return { + content: [{ + type: "text" as const, + text: JSON.stringify(response.data.items || [], null, 2) + }] + }; + }; + + /** + * Lists tasks in a specific task list. + */ + listTasks = async (params: { + taskListId: string; + showCompleted?: boolean; + showDeleted?: boolean; + showHidden?: boolean; + showAssigned?: boolean; + maxResults?: number; + pageToken?: string; + dueMin?: string; + dueMax?: string; + }) => { + const tasks = await this.authManager.getTasksClient(); + const response = await tasks.tasks.list({ + tasklist: params.taskListId, + showCompleted: params.showCompleted, + showDeleted: params.showDeleted, + showHidden: params.showHidden, + showAssigned: params.showAssigned, + maxResults: params.maxResults, + pageToken: params.pageToken, + dueMin: params.dueMin, + dueMax: params.dueMax, + }); + + return { + content: [{ + type: "text" as const, + text: JSON.stringify(response.data.items || [], null, 2) + }] + }; + }; + + /** + * Creates a new task in the specified task list. + */ + createTask = async (params: { + taskListId: string; + title: string; + notes?: string; + due?: string; // RFC 3339 timestamp + }) => { + const tasks = await this.authManager.getTasksClient(); + const requestBody: tasks_v1.Schema$Task = { + title: params.title, + ...(params.notes !== undefined && { notes: params.notes }), + ...(params.due !== undefined && { due: params.due }), + }; + + const response = await tasks.tasks.insert({ + tasklist: params.taskListId, + requestBody, + }); + + return { + content: [{ + type: "text" as const, + text: JSON.stringify(response.data, null, 2) + }] + }; + }; + + /** + * Updates an existing task. + */ + updateTask = async (params: { + taskListId: string; + taskId: string; + title?: string; + notes?: string; + status?: 'needsAction' | 'completed'; + due?: string; + }) => { + const tasks = await this.authManager.getTasksClient(); + + const requestBody: tasks_v1.Schema$Task = { + ...(params.title !== undefined && { title: params.title }), + ...(params.notes !== undefined && { notes: params.notes }), + ...(params.status !== undefined && { status: params.status }), + ...(params.due !== undefined && { due: params.due }), + }; + + const response = await tasks.tasks.patch({ + tasklist: params.taskListId, + task: params.taskId, + requestBody, + }); + + return { + content: [{ + type: "text" as const, + text: JSON.stringify(response.data, null, 2) + }] + }; + }; + + /** + * Completes a task (convenience wrapper around update). + */ + completeTask = async (params: { taskListId: string; taskId: string }) => { + return this.updateTask({ + taskListId: params.taskListId, + taskId: params.taskId, + status: 'completed', + }); + }; + + /** + * Deletes a task. + */ + deleteTask = async (params: { taskListId: string; taskId: string }) => { + const tasks = await this.authManager.getTasksClient(); + await tasks.tasks.delete({ + tasklist: params.taskListId, + task: params.taskId, + }); + + return { + content: [{ + type: "text" as const, + text: `Task ${params.taskId} deleted successfully from list ${params.taskListId}.` + }] + }; + }; +}