Skip to content
Draft
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
7 changes: 7 additions & 0 deletions workspace-server/WORKSPACE-Context.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
3 changes: 2 additions & 1 deletion workspace-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
189 changes: 189 additions & 0 deletions workspace-server/src/__tests__/services/TasksService.test.ts
Original file line number Diff line number Diff line change
@@ -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<any>>();
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',
}
});
Comment on lines +162 to +170
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

The test for completeTask verifies that the patch method is called with the correct arguments, but it doesn't check the return value of completeTask. For consistency with other tests in this file (like updateTask), you should also assert that the returned result is correct.

        const result = await tasksService.completeTask({ taskListId: 'list1', taskId: 'task1' });

        expect(mockTasksClient.tasks.patch).toHaveBeenCalledWith({
            tasklist: 'list1',
            task: 'task1',
            requestBody: {
                status: 'completed',
            }
        });
        expect(result).toEqual({
            content: [{ type: 'text', text: JSON.stringify(mockResponse.data, null, 2) }],
        });

});
});

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.' }],
});
});
});
});
5 changes: 5 additions & 0 deletions workspace-server/src/auth/AuthManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
logToFile('Clearing authentication...');
this.client = null;
Expand Down
89 changes: 89 additions & 0 deletions workspace-server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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
Expand All @@ -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({
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading