@doist/todoist-ai 4.9.4 → 4.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -0
- package/dist/index.d.ts +37 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -1
- package/dist/mcp-helpers.d.ts +8 -1
- package/dist/mcp-helpers.d.ts.map +1 -1
- package/dist/mcp-helpers.js +1 -1
- package/dist/mcp-server.d.ts.map +1 -1
- package/dist/mcp-server.js +5 -0
- package/dist/tool-helpers.d.ts +8 -1
- package/dist/tool-helpers.d.ts.map +1 -1
- package/dist/tool-helpers.js +13 -10
- package/dist/tools/__tests__/fetch.test.d.ts +2 -0
- package/dist/tools/__tests__/fetch.test.d.ts.map +1 -0
- package/dist/tools/__tests__/fetch.test.js +275 -0
- package/dist/tools/__tests__/find-completed-tasks.test.js +71 -12
- package/dist/tools/__tests__/search.test.d.ts +2 -0
- package/dist/tools/__tests__/search.test.d.ts.map +1 -0
- package/dist/tools/__tests__/search.test.js +208 -0
- package/dist/tools/add-tasks.d.ts.map +1 -1
- package/dist/tools/add-tasks.js +8 -2
- package/dist/tools/fetch.d.ts +26 -0
- package/dist/tools/fetch.d.ts.map +1 -0
- package/dist/tools/fetch.js +99 -0
- package/dist/tools/find-completed-tasks.d.ts +12 -12
- package/dist/tools/find-completed-tasks.d.ts.map +1 -1
- package/dist/tools/find-completed-tasks.js +15 -1
- package/dist/tools/find-projects.d.ts +2 -2
- package/dist/tools/search.d.ts +26 -0
- package/dist/tools/search.d.ts.map +1 -0
- package/dist/tools/search.js +64 -0
- package/dist/tools/update-tasks.d.ts.map +1 -1
- package/dist/tools/update-tasks.js +8 -2
- package/dist/utils/constants.d.ts +1 -1
- package/dist/utils/constants.js +1 -1
- package/dist/utils/tool-names.d.ts +2 -0
- package/dist/utils/tool-names.d.ts.map +1 -1
- package/dist/utils/tool-names.js +3 -0
- package/package.json +1 -1
|
@@ -6,11 +6,25 @@ import { findCompletedTasks } from '../find-completed-tasks.js';
|
|
|
6
6
|
const mockTodoistApi = {
|
|
7
7
|
getCompletedTasksByCompletionDate: jest.fn(),
|
|
8
8
|
getCompletedTasksByDueDate: jest.fn(),
|
|
9
|
+
getUser: jest.fn(),
|
|
9
10
|
};
|
|
10
11
|
const { FIND_COMPLETED_TASKS } = ToolNames;
|
|
11
12
|
describe(`${FIND_COMPLETED_TASKS} tool`, () => {
|
|
12
13
|
beforeEach(() => {
|
|
13
14
|
jest.clearAllMocks();
|
|
15
|
+
// Mock default user with UTC timezone
|
|
16
|
+
mockTodoistApi.getUser.mockResolvedValue({
|
|
17
|
+
id: 'test-user-id',
|
|
18
|
+
fullName: 'Test User',
|
|
19
|
+
email: 'test@example.com',
|
|
20
|
+
tzInfo: {
|
|
21
|
+
timezone: 'UTC',
|
|
22
|
+
gmtString: '+00:00',
|
|
23
|
+
hours: 0,
|
|
24
|
+
minutes: 0,
|
|
25
|
+
isDst: 0,
|
|
26
|
+
},
|
|
27
|
+
});
|
|
14
28
|
});
|
|
15
29
|
describe('getting completed tasks by completion date (default)', () => {
|
|
16
30
|
it('should get completed tasks by completion date', async () => {
|
|
@@ -46,8 +60,8 @@ describe(`${FIND_COMPLETED_TASKS} tool`, () => {
|
|
|
46
60
|
labelsOperator: 'or',
|
|
47
61
|
}, mockTodoistApi);
|
|
48
62
|
expect(mockTodoistApi.getCompletedTasksByCompletionDate).toHaveBeenCalledWith({
|
|
49
|
-
since: '2025-08-
|
|
50
|
-
until: '2025-08-
|
|
63
|
+
since: '2025-08-10T00:00:00.000Z',
|
|
64
|
+
until: '2025-08-15T23:59:59.000Z',
|
|
51
65
|
limit: 50,
|
|
52
66
|
});
|
|
53
67
|
expect(extractTextContent(result)).toMatchSnapshot();
|
|
@@ -68,8 +82,8 @@ describe(`${FIND_COMPLETED_TASKS} tool`, () => {
|
|
|
68
82
|
labelsOperator: 'or',
|
|
69
83
|
}, mockTodoistApi);
|
|
70
84
|
expect(mockTodoistApi.getCompletedTasksByCompletionDate).toHaveBeenCalledWith({
|
|
71
|
-
since: '2025-08-
|
|
72
|
-
until: '2025-08-
|
|
85
|
+
since: '2025-08-01T00:00:00.000Z',
|
|
86
|
+
until: '2025-08-31T23:59:59.000Z',
|
|
73
87
|
projectId: 'specific-project-id',
|
|
74
88
|
limit: 100,
|
|
75
89
|
cursor: 'current-cursor',
|
|
@@ -111,8 +125,8 @@ describe(`${FIND_COMPLETED_TASKS} tool`, () => {
|
|
|
111
125
|
labelsOperator: 'or',
|
|
112
126
|
}, mockTodoistApi);
|
|
113
127
|
expect(mockTodoistApi.getCompletedTasksByDueDate).toHaveBeenCalledWith({
|
|
114
|
-
since: '2025-08-
|
|
115
|
-
until: '2025-08-
|
|
128
|
+
since: '2025-08-10T00:00:00.000Z',
|
|
129
|
+
until: '2025-08-20T23:59:59.000Z',
|
|
116
130
|
limit: 50,
|
|
117
131
|
});
|
|
118
132
|
expect(mockTodoistApi.getCompletedTasksByCompletionDate).not.toHaveBeenCalled();
|
|
@@ -172,8 +186,8 @@ describe(`${FIND_COMPLETED_TASKS} tool`, () => {
|
|
|
172
186
|
mockMethod.mockResolvedValue(mockResponse);
|
|
173
187
|
const result = await findCompletedTasks.execute(params, mockTodoistApi);
|
|
174
188
|
expect(mockMethod).toHaveBeenCalledWith({
|
|
175
|
-
since: params.since
|
|
176
|
-
until: params.until
|
|
189
|
+
since: `${params.since}T00:00:00.000Z`,
|
|
190
|
+
until: `${params.until}T23:59:59.000Z`,
|
|
177
191
|
limit: params.limit,
|
|
178
192
|
filterQuery: expectedFilter,
|
|
179
193
|
filterLang: 'en',
|
|
@@ -194,8 +208,8 @@ describe(`${FIND_COMPLETED_TASKS} tool`, () => {
|
|
|
194
208
|
mockTodoistApi.getCompletedTasksByCompletionDate.mockResolvedValue(mockResponse);
|
|
195
209
|
await findCompletedTasks.execute(params, mockTodoistApi);
|
|
196
210
|
expect(mockTodoistApi.getCompletedTasksByCompletionDate).toHaveBeenCalledWith({
|
|
197
|
-
since: params.since
|
|
198
|
-
until: params.until
|
|
211
|
+
since: `${params.since}T00:00:00.000Z`,
|
|
212
|
+
until: `${params.until}T23:59:59.000Z`,
|
|
199
213
|
limit: params.limit,
|
|
200
214
|
});
|
|
201
215
|
});
|
|
@@ -221,8 +235,8 @@ describe(`${FIND_COMPLETED_TASKS} tool`, () => {
|
|
|
221
235
|
mockTodoistApi.getCompletedTasksByDueDate.mockResolvedValue(mockResponse);
|
|
222
236
|
const result = await findCompletedTasks.execute(params, mockTodoistApi);
|
|
223
237
|
expect(mockTodoistApi.getCompletedTasksByDueDate).toHaveBeenCalledWith({
|
|
224
|
-
since: params.since
|
|
225
|
-
until: params.until
|
|
238
|
+
since: `${params.since}T00:00:00.000Z`,
|
|
239
|
+
until: `${params.until}T23:59:59.000Z`,
|
|
226
240
|
limit: params.limit,
|
|
227
241
|
projectId: params.projectId,
|
|
228
242
|
sectionId: params.sectionId,
|
|
@@ -233,6 +247,51 @@ describe(`${FIND_COMPLETED_TASKS} tool`, () => {
|
|
|
233
247
|
expect(textContent).toMatchSnapshot();
|
|
234
248
|
});
|
|
235
249
|
});
|
|
250
|
+
describe('timezone handling', () => {
|
|
251
|
+
it('should convert user timezone to UTC correctly (Europe/Madrid)', async () => {
|
|
252
|
+
// Mock user with Madrid timezone
|
|
253
|
+
mockTodoistApi.getUser.mockResolvedValue({
|
|
254
|
+
id: 'test-user-id',
|
|
255
|
+
fullName: 'Test User',
|
|
256
|
+
email: 'test@example.com',
|
|
257
|
+
tzInfo: {
|
|
258
|
+
timezone: 'Europe/Madrid',
|
|
259
|
+
gmtString: '+02:00',
|
|
260
|
+
hours: 2,
|
|
261
|
+
minutes: 0,
|
|
262
|
+
isDst: 0,
|
|
263
|
+
},
|
|
264
|
+
});
|
|
265
|
+
const mockCompletedTasks = [
|
|
266
|
+
createMockTask({
|
|
267
|
+
id: '8485093750',
|
|
268
|
+
content: 'Task completed in Madrid timezone',
|
|
269
|
+
completedAt: '2025-10-11T15:30:00Z',
|
|
270
|
+
}),
|
|
271
|
+
];
|
|
272
|
+
mockTodoistApi.getCompletedTasksByCompletionDate.mockResolvedValue({
|
|
273
|
+
items: mockCompletedTasks,
|
|
274
|
+
nextCursor: null,
|
|
275
|
+
});
|
|
276
|
+
const result = await findCompletedTasks.execute({
|
|
277
|
+
getBy: 'completion',
|
|
278
|
+
limit: 50,
|
|
279
|
+
since: '2025-10-11',
|
|
280
|
+
until: '2025-10-11',
|
|
281
|
+
labels: [],
|
|
282
|
+
labelsOperator: 'or',
|
|
283
|
+
}, mockTodoistApi);
|
|
284
|
+
// Should convert Madrid local time to UTC
|
|
285
|
+
// 2025-10-11 00:00:00 +02:00 = 2025-10-10 22:00:00 UTC
|
|
286
|
+
// 2025-10-11 23:59:59 +02:00 = 2025-10-11 21:59:59 UTC
|
|
287
|
+
expect(mockTodoistApi.getCompletedTasksByCompletionDate).toHaveBeenCalledWith({
|
|
288
|
+
since: '2025-10-10T22:00:00.000Z',
|
|
289
|
+
until: '2025-10-11T21:59:59.000Z',
|
|
290
|
+
limit: 50,
|
|
291
|
+
});
|
|
292
|
+
expect(extractTextContent(result)).toMatchSnapshot();
|
|
293
|
+
});
|
|
294
|
+
});
|
|
236
295
|
describe('error handling', () => {
|
|
237
296
|
it('should propagate completion date API errors', async () => {
|
|
238
297
|
const apiError = new Error('API Error: Invalid date range');
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"search.test.d.ts","sourceRoot":"","sources":["../../../src/tools/__tests__/search.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { jest } from '@jest/globals';
|
|
2
|
+
import { getTasksByFilter } from '../../tool-helpers.js';
|
|
3
|
+
import { createMappedTask, createMockApiResponse, createMockProject, TEST_IDS, } from '../../utils/test-helpers.js';
|
|
4
|
+
import { ToolNames } from '../../utils/tool-names.js';
|
|
5
|
+
import { search } from '../search.js';
|
|
6
|
+
jest.mock('../../tool-helpers', () => {
|
|
7
|
+
const actual = jest.requireActual('../../tool-helpers');
|
|
8
|
+
return {
|
|
9
|
+
getTasksByFilter: jest.fn(),
|
|
10
|
+
buildTodoistUrl: actual.buildTodoistUrl,
|
|
11
|
+
};
|
|
12
|
+
});
|
|
13
|
+
const { SEARCH } = ToolNames;
|
|
14
|
+
const mockGetTasksByFilter = getTasksByFilter;
|
|
15
|
+
// Mock the Todoist API
|
|
16
|
+
const mockTodoistApi = {
|
|
17
|
+
getProjects: jest.fn(),
|
|
18
|
+
};
|
|
19
|
+
describe(`${SEARCH} tool`, () => {
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
jest.clearAllMocks();
|
|
22
|
+
});
|
|
23
|
+
describe('searching tasks and projects', () => {
|
|
24
|
+
it('should search both tasks and projects and return combined results', async () => {
|
|
25
|
+
const mockTasks = [
|
|
26
|
+
createMappedTask({
|
|
27
|
+
id: TEST_IDS.TASK_1,
|
|
28
|
+
content: 'Important meeting task',
|
|
29
|
+
}),
|
|
30
|
+
createMappedTask({
|
|
31
|
+
id: TEST_IDS.TASK_2,
|
|
32
|
+
content: 'Another important item',
|
|
33
|
+
}),
|
|
34
|
+
];
|
|
35
|
+
const mockProjects = [
|
|
36
|
+
createMockProject({
|
|
37
|
+
id: TEST_IDS.PROJECT_WORK,
|
|
38
|
+
name: 'Important Work Project',
|
|
39
|
+
}),
|
|
40
|
+
createMockProject({
|
|
41
|
+
id: TEST_IDS.PROJECT_TEST,
|
|
42
|
+
name: 'Test Project',
|
|
43
|
+
}),
|
|
44
|
+
];
|
|
45
|
+
mockGetTasksByFilter.mockResolvedValue({ tasks: mockTasks, nextCursor: null });
|
|
46
|
+
mockTodoistApi.getProjects.mockResolvedValue(createMockApiResponse(mockProjects));
|
|
47
|
+
const result = await search.execute({ query: 'important' }, mockTodoistApi);
|
|
48
|
+
// Verify both API calls were made
|
|
49
|
+
expect(mockGetTasksByFilter).toHaveBeenCalledWith({
|
|
50
|
+
client: mockTodoistApi,
|
|
51
|
+
query: 'search: important',
|
|
52
|
+
limit: 100, // TASKS_MAX
|
|
53
|
+
cursor: undefined,
|
|
54
|
+
});
|
|
55
|
+
expect(mockTodoistApi.getProjects).toHaveBeenCalledWith({
|
|
56
|
+
limit: 100, // PROJECTS_MAX
|
|
57
|
+
});
|
|
58
|
+
// Verify result structure
|
|
59
|
+
expect(result.content).toHaveLength(1);
|
|
60
|
+
expect(result.content[0]?.type).toBe('text');
|
|
61
|
+
// Parse the JSON response
|
|
62
|
+
const jsonResponse = JSON.parse(result.content[0]?.text ?? '{}');
|
|
63
|
+
expect(jsonResponse).toHaveProperty('results');
|
|
64
|
+
expect(jsonResponse.results).toHaveLength(3); // 2 tasks + 1 project matching "important"
|
|
65
|
+
// Verify task results
|
|
66
|
+
expect(jsonResponse.results[0]).toEqual({
|
|
67
|
+
id: `task:${TEST_IDS.TASK_1}`,
|
|
68
|
+
title: 'Important meeting task',
|
|
69
|
+
url: `https://app.todoist.com/app/task/${TEST_IDS.TASK_1}`,
|
|
70
|
+
});
|
|
71
|
+
expect(jsonResponse.results[1]).toEqual({
|
|
72
|
+
id: `task:${TEST_IDS.TASK_2}`,
|
|
73
|
+
title: 'Another important item',
|
|
74
|
+
url: `https://app.todoist.com/app/task/${TEST_IDS.TASK_2}`,
|
|
75
|
+
});
|
|
76
|
+
// Verify project result (only "Important Work Project" matches)
|
|
77
|
+
expect(jsonResponse.results[2]).toEqual({
|
|
78
|
+
id: `project:${TEST_IDS.PROJECT_WORK}`,
|
|
79
|
+
title: 'Important Work Project',
|
|
80
|
+
url: `https://app.todoist.com/app/project/${TEST_IDS.PROJECT_WORK}`,
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
it('should return only matching tasks when no projects match', async () => {
|
|
84
|
+
const mockTasks = [
|
|
85
|
+
createMappedTask({
|
|
86
|
+
id: TEST_IDS.TASK_1,
|
|
87
|
+
content: 'Unique task content',
|
|
88
|
+
}),
|
|
89
|
+
];
|
|
90
|
+
const mockProjects = [
|
|
91
|
+
createMockProject({
|
|
92
|
+
id: TEST_IDS.PROJECT_WORK,
|
|
93
|
+
name: 'Work Project',
|
|
94
|
+
}),
|
|
95
|
+
];
|
|
96
|
+
mockGetTasksByFilter.mockResolvedValue({ tasks: mockTasks, nextCursor: null });
|
|
97
|
+
mockTodoistApi.getProjects.mockResolvedValue(createMockApiResponse(mockProjects));
|
|
98
|
+
const result = await search.execute({ query: 'unique' }, mockTodoistApi);
|
|
99
|
+
const jsonResponse = JSON.parse(result.content[0]?.text ?? '{}');
|
|
100
|
+
expect(jsonResponse.results).toHaveLength(1);
|
|
101
|
+
expect(jsonResponse.results[0].id).toBe(`task:${TEST_IDS.TASK_1}`);
|
|
102
|
+
});
|
|
103
|
+
it('should return only matching projects when no tasks match', async () => {
|
|
104
|
+
const mockProjects = [
|
|
105
|
+
createMockProject({
|
|
106
|
+
id: TEST_IDS.PROJECT_WORK,
|
|
107
|
+
name: 'Special Project Name',
|
|
108
|
+
}),
|
|
109
|
+
createMockProject({
|
|
110
|
+
id: TEST_IDS.PROJECT_TEST,
|
|
111
|
+
name: 'Another Project',
|
|
112
|
+
}),
|
|
113
|
+
];
|
|
114
|
+
mockGetTasksByFilter.mockResolvedValue({ tasks: [], nextCursor: null });
|
|
115
|
+
mockTodoistApi.getProjects.mockResolvedValue(createMockApiResponse(mockProjects));
|
|
116
|
+
const result = await search.execute({ query: 'special' }, mockTodoistApi);
|
|
117
|
+
const jsonResponse = JSON.parse(result.content[0]?.text ?? '{}');
|
|
118
|
+
expect(jsonResponse.results).toHaveLength(1);
|
|
119
|
+
expect(jsonResponse.results[0]).toEqual({
|
|
120
|
+
id: `project:${TEST_IDS.PROJECT_WORK}`,
|
|
121
|
+
title: 'Special Project Name',
|
|
122
|
+
url: `https://app.todoist.com/app/project/${TEST_IDS.PROJECT_WORK}`,
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
it('should return empty results when nothing matches', async () => {
|
|
126
|
+
mockGetTasksByFilter.mockResolvedValue({ tasks: [], nextCursor: null });
|
|
127
|
+
mockTodoistApi.getProjects.mockResolvedValue(createMockApiResponse([]));
|
|
128
|
+
const result = await search.execute({ query: 'nonexistent' }, mockTodoistApi);
|
|
129
|
+
const jsonResponse = JSON.parse(result.content[0]?.text ?? '{}');
|
|
130
|
+
expect(jsonResponse.results).toHaveLength(0);
|
|
131
|
+
});
|
|
132
|
+
it('should perform case-insensitive project filtering', async () => {
|
|
133
|
+
const mockProjects = [
|
|
134
|
+
createMockProject({
|
|
135
|
+
id: TEST_IDS.PROJECT_WORK,
|
|
136
|
+
name: 'Important Work',
|
|
137
|
+
}),
|
|
138
|
+
];
|
|
139
|
+
mockGetTasksByFilter.mockResolvedValue({ tasks: [], nextCursor: null });
|
|
140
|
+
mockTodoistApi.getProjects.mockResolvedValue(createMockApiResponse(mockProjects));
|
|
141
|
+
const result = await search.execute({ query: 'IMPORTANT' }, mockTodoistApi);
|
|
142
|
+
const jsonResponse = JSON.parse(result.content[0]?.text ?? '{}');
|
|
143
|
+
expect(jsonResponse.results).toHaveLength(1);
|
|
144
|
+
expect(jsonResponse.results[0].title).toBe('Important Work');
|
|
145
|
+
});
|
|
146
|
+
it('should handle partial matches in project names', async () => {
|
|
147
|
+
const mockProjects = [
|
|
148
|
+
createMockProject({ id: 'project-1', name: 'Development Tasks' }),
|
|
149
|
+
createMockProject({ id: 'project-2', name: 'Developer Resources' }),
|
|
150
|
+
createMockProject({ id: 'project-3', name: 'Marketing' }),
|
|
151
|
+
];
|
|
152
|
+
mockGetTasksByFilter.mockResolvedValue({ tasks: [], nextCursor: null });
|
|
153
|
+
mockTodoistApi.getProjects.mockResolvedValue(createMockApiResponse(mockProjects));
|
|
154
|
+
const result = await search.execute({ query: 'develop' }, mockTodoistApi);
|
|
155
|
+
const jsonResponse = JSON.parse(result.content[0]?.text ?? '{}');
|
|
156
|
+
expect(jsonResponse.results).toHaveLength(2);
|
|
157
|
+
expect(jsonResponse.results[0].title).toBe('Development Tasks');
|
|
158
|
+
expect(jsonResponse.results[1].title).toBe('Developer Resources');
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
describe('error handling', () => {
|
|
162
|
+
it('should return error response for task search failure', async () => {
|
|
163
|
+
mockGetTasksByFilter.mockRejectedValue(new Error('Task search failed'));
|
|
164
|
+
mockTodoistApi.getProjects.mockResolvedValue(createMockApiResponse([]));
|
|
165
|
+
const result = await search.execute({ query: 'test' }, mockTodoistApi);
|
|
166
|
+
expect(result.isError).toBe(true);
|
|
167
|
+
expect(result.content[0]?.text).toBe('Task search failed');
|
|
168
|
+
});
|
|
169
|
+
it('should return error response for project search failure', async () => {
|
|
170
|
+
mockGetTasksByFilter.mockResolvedValue({ tasks: [], nextCursor: null });
|
|
171
|
+
mockTodoistApi.getProjects.mockRejectedValue(new Error('Project search failed'));
|
|
172
|
+
const result = await search.execute({ query: 'test' }, mockTodoistApi);
|
|
173
|
+
expect(result.isError).toBe(true);
|
|
174
|
+
expect(result.content[0]?.text).toBe('Project search failed');
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
describe('OpenAI MCP spec compliance', () => {
|
|
178
|
+
it('should return exactly one content item with type "text"', async () => {
|
|
179
|
+
mockGetTasksByFilter.mockResolvedValue({ tasks: [], nextCursor: null });
|
|
180
|
+
mockTodoistApi.getProjects.mockResolvedValue(createMockApiResponse([]));
|
|
181
|
+
const result = await search.execute({ query: 'test' }, mockTodoistApi);
|
|
182
|
+
expect(result.content).toHaveLength(1);
|
|
183
|
+
expect(result.content[0]?.type).toBe('text');
|
|
184
|
+
});
|
|
185
|
+
it('should return valid JSON string in text field', async () => {
|
|
186
|
+
mockGetTasksByFilter.mockResolvedValue({ tasks: [], nextCursor: null });
|
|
187
|
+
mockTodoistApi.getProjects.mockResolvedValue(createMockApiResponse([]));
|
|
188
|
+
const result = await search.execute({ query: 'test' }, mockTodoistApi);
|
|
189
|
+
expect(() => JSON.parse(result.content[0]?.text ?? '{}')).not.toThrow();
|
|
190
|
+
});
|
|
191
|
+
it('should include required fields (id, title, url) in each result', async () => {
|
|
192
|
+
const mockTasks = [createMappedTask({ id: TEST_IDS.TASK_1, content: 'Test' })];
|
|
193
|
+
const mockProjects = [createMockProject({ id: TEST_IDS.PROJECT_WORK, name: 'Test' })];
|
|
194
|
+
mockGetTasksByFilter.mockResolvedValue({ tasks: mockTasks, nextCursor: null });
|
|
195
|
+
mockTodoistApi.getProjects.mockResolvedValue(createMockApiResponse(mockProjects));
|
|
196
|
+
const result = await search.execute({ query: 'test' }, mockTodoistApi);
|
|
197
|
+
const jsonResponse = JSON.parse(result.content[0]?.text ?? '{}');
|
|
198
|
+
for (const item of jsonResponse.results) {
|
|
199
|
+
expect(item).toHaveProperty('id');
|
|
200
|
+
expect(item).toHaveProperty('title');
|
|
201
|
+
expect(item).toHaveProperty('url');
|
|
202
|
+
expect(typeof item.id).toBe('string');
|
|
203
|
+
expect(typeof item.title).toBe('string');
|
|
204
|
+
expect(typeof item.url).toBe('string');
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"add-tasks.d.ts","sourceRoot":"","sources":["../../src/tools/add-tasks.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAqB,UAAU,EAAE,MAAM,+BAA+B,CAAA;AAClF,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;
|
|
1
|
+
{"version":3,"file":"add-tasks.d.ts","sourceRoot":"","sources":["../../src/tools/add-tasks.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAqB,UAAU,EAAE,MAAM,+BAA+B,CAAA;AAClF,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAqDvB,QAAA,MAAM,QAAQ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAuB4B,CAAA;AAgI1C,OAAO,EAAE,QAAQ,EAAE,CAAA"}
|
package/dist/tools/add-tasks.js
CHANGED
|
@@ -7,8 +7,14 @@ import { convertPriorityToNumber, PrioritySchema } from '../utils/priorities.js'
|
|
|
7
7
|
import { generateTaskNextSteps, getDateString, summarizeTaskOperation, } from '../utils/response-builders.js';
|
|
8
8
|
import { ToolNames } from '../utils/tool-names.js';
|
|
9
9
|
const TaskSchema = z.object({
|
|
10
|
-
content: z
|
|
11
|
-
|
|
10
|
+
content: z
|
|
11
|
+
.string()
|
|
12
|
+
.min(1)
|
|
13
|
+
.describe('The task name/title. Should be concise and actionable (e.g., "Review PR #123", "Call dentist"). For longer content, use the description field instead. Supports Markdown.'),
|
|
14
|
+
description: z
|
|
15
|
+
.string()
|
|
16
|
+
.optional()
|
|
17
|
+
.describe('Additional details, notes, or context for the task. Use this for longer content rather than putting it in the task name. Supports Markdown.'),
|
|
12
18
|
priority: PrioritySchema.optional().describe('The priority of the task: p1 (highest), p2 (high), p3 (medium), p4 (lowest/default).'),
|
|
13
19
|
dueString: z.string().optional().describe('The due date for the task, in natural language.'),
|
|
14
20
|
duration: z
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
type FetchToolOutput = {
|
|
3
|
+
content: {
|
|
4
|
+
type: 'text';
|
|
5
|
+
text: string;
|
|
6
|
+
}[];
|
|
7
|
+
isError?: boolean;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* OpenAI MCP fetch tool - retrieves the full contents of a task or project by ID.
|
|
11
|
+
*
|
|
12
|
+
* This tool follows the OpenAI MCP fetch tool specification:
|
|
13
|
+
* @see https://platform.openai.com/docs/mcp#fetch-tool
|
|
14
|
+
*/
|
|
15
|
+
declare const fetch: {
|
|
16
|
+
name: "fetch";
|
|
17
|
+
description: string;
|
|
18
|
+
parameters: {
|
|
19
|
+
id: z.ZodString;
|
|
20
|
+
};
|
|
21
|
+
execute(args: {
|
|
22
|
+
id: string;
|
|
23
|
+
}, client: import("@doist/todoist-api-typescript").TodoistApi): Promise<FetchToolOutput>;
|
|
24
|
+
};
|
|
25
|
+
export { fetch };
|
|
26
|
+
//# sourceMappingURL=fetch.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fetch.d.ts","sourceRoot":"","sources":["../../src/tools/fetch.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAuBvB,KAAK,eAAe,GAAG;IACnB,OAAO,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,EAAE,CAAA;IACzC,OAAO,CAAC,EAAE,OAAO,CAAA;CACpB,CAAA;AAED;;;;;GAKG;AACH,QAAA,MAAM,KAAK;;;;;;;;oEAKsB,OAAO,CAAC,eAAe,CAAC;CAsFf,CAAA;AAE1C,OAAO,EAAE,KAAK,EAAE,CAAA"}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { getErrorOutput } from '../mcp-helpers.js';
|
|
3
|
+
import { buildTodoistUrl, mapProject, mapTask } from '../tool-helpers.js';
|
|
4
|
+
import { ToolNames } from '../utils/tool-names.js';
|
|
5
|
+
const ArgsSchema = {
|
|
6
|
+
id: z
|
|
7
|
+
.string()
|
|
8
|
+
.min(1)
|
|
9
|
+
.describe('A unique identifier for the document in the format "task:{id}" or "project:{id}".'),
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* OpenAI MCP fetch tool - retrieves the full contents of a task or project by ID.
|
|
13
|
+
*
|
|
14
|
+
* This tool follows the OpenAI MCP fetch tool specification:
|
|
15
|
+
* @see https://platform.openai.com/docs/mcp#fetch-tool
|
|
16
|
+
*/
|
|
17
|
+
const fetch = {
|
|
18
|
+
name: ToolNames.FETCH,
|
|
19
|
+
description: 'Fetch the full contents of a task or project by its ID. The ID should be in the format "task:{id}" or "project:{id}".',
|
|
20
|
+
parameters: ArgsSchema,
|
|
21
|
+
async execute(args, client) {
|
|
22
|
+
try {
|
|
23
|
+
const { id } = args;
|
|
24
|
+
// Parse the composite ID
|
|
25
|
+
const [type, objectId] = id.split(':', 2);
|
|
26
|
+
if (!objectId || (type !== 'task' && type !== 'project')) {
|
|
27
|
+
throw new Error('Invalid ID format. Expected "task:{id}" or "project:{id}". Example: "task:8485093748" or "project:6cfCcrrCFg2xP94Q"');
|
|
28
|
+
}
|
|
29
|
+
let result;
|
|
30
|
+
if (type === 'task') {
|
|
31
|
+
// Fetch task
|
|
32
|
+
const task = await client.getTask(objectId);
|
|
33
|
+
const mappedTask = mapTask(task);
|
|
34
|
+
// Build text content
|
|
35
|
+
const textParts = [mappedTask.content];
|
|
36
|
+
if (mappedTask.description) {
|
|
37
|
+
textParts.push(`\n\nDescription: ${mappedTask.description}`);
|
|
38
|
+
}
|
|
39
|
+
if (mappedTask.dueDate) {
|
|
40
|
+
textParts.push(`\nDue: ${mappedTask.dueDate}`);
|
|
41
|
+
}
|
|
42
|
+
if (mappedTask.labels.length > 0) {
|
|
43
|
+
textParts.push(`\nLabels: ${mappedTask.labels.join(', ')}`);
|
|
44
|
+
}
|
|
45
|
+
result = {
|
|
46
|
+
id: `task:${mappedTask.id}`,
|
|
47
|
+
title: mappedTask.content,
|
|
48
|
+
text: textParts.join(''),
|
|
49
|
+
url: buildTodoistUrl('task', mappedTask.id),
|
|
50
|
+
metadata: {
|
|
51
|
+
priority: mappedTask.priority,
|
|
52
|
+
projectId: mappedTask.projectId,
|
|
53
|
+
sectionId: mappedTask.sectionId,
|
|
54
|
+
parentId: mappedTask.parentId,
|
|
55
|
+
recurring: mappedTask.recurring,
|
|
56
|
+
duration: mappedTask.duration,
|
|
57
|
+
responsibleUid: mappedTask.responsibleUid,
|
|
58
|
+
assignedByUid: mappedTask.assignedByUid,
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
// Fetch project
|
|
64
|
+
const project = await client.getProject(objectId);
|
|
65
|
+
const mappedProject = mapProject(project);
|
|
66
|
+
// Build text content
|
|
67
|
+
const textParts = [mappedProject.name];
|
|
68
|
+
if (mappedProject.isShared) {
|
|
69
|
+
textParts.push('\n\nShared project');
|
|
70
|
+
}
|
|
71
|
+
if (mappedProject.isFavorite) {
|
|
72
|
+
textParts.push('\nFavorite: Yes');
|
|
73
|
+
}
|
|
74
|
+
result = {
|
|
75
|
+
id: `project:${mappedProject.id}`,
|
|
76
|
+
title: mappedProject.name,
|
|
77
|
+
text: textParts.join(''),
|
|
78
|
+
url: buildTodoistUrl('project', mappedProject.id),
|
|
79
|
+
metadata: {
|
|
80
|
+
color: mappedProject.color,
|
|
81
|
+
isFavorite: mappedProject.isFavorite,
|
|
82
|
+
isShared: mappedProject.isShared,
|
|
83
|
+
parentId: mappedProject.parentId,
|
|
84
|
+
inboxProject: mappedProject.inboxProject,
|
|
85
|
+
viewStyle: mappedProject.viewStyle,
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
// Return as JSON-encoded string in a text content item (OpenAI MCP spec)
|
|
90
|
+
const jsonText = JSON.stringify(result);
|
|
91
|
+
return { content: [{ type: 'text', text: jsonText }] };
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
const message = error instanceof Error ? error.message : 'An unknown error occurred';
|
|
95
|
+
return getErrorOutput(message);
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
export { fetch };
|
|
@@ -16,17 +16,17 @@ declare const findCompletedTasks: {
|
|
|
16
16
|
cursor: z.ZodOptional<z.ZodString>;
|
|
17
17
|
};
|
|
18
18
|
execute(args: {
|
|
19
|
-
|
|
20
|
-
getBy: "due" | "completion";
|
|
19
|
+
getBy: "completion" | "due";
|
|
21
20
|
since: string;
|
|
22
21
|
until: string;
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
limit: number;
|
|
23
|
+
labels?: string[] | undefined;
|
|
24
|
+
labelsOperator?: "and" | "or" | undefined;
|
|
25
25
|
workspaceId?: string | undefined;
|
|
26
|
+
projectId?: string | undefined;
|
|
26
27
|
sectionId?: string | undefined;
|
|
27
|
-
|
|
28
|
+
parentId?: string | undefined;
|
|
28
29
|
cursor?: string | undefined;
|
|
29
|
-
labelsOperator?: "and" | "or" | undefined;
|
|
30
30
|
}, client: import("@doist/todoist-api-typescript").TodoistApi): Promise<{
|
|
31
31
|
content: {
|
|
32
32
|
type: "text";
|
|
@@ -52,17 +52,17 @@ declare const findCompletedTasks: {
|
|
|
52
52
|
totalCount: number;
|
|
53
53
|
hasMore: boolean;
|
|
54
54
|
appliedFilters: {
|
|
55
|
-
|
|
56
|
-
getBy: "due" | "completion";
|
|
55
|
+
getBy: "completion" | "due";
|
|
57
56
|
since: string;
|
|
58
57
|
until: string;
|
|
59
|
-
|
|
60
|
-
|
|
58
|
+
limit: number;
|
|
59
|
+
labels?: string[] | undefined;
|
|
60
|
+
labelsOperator?: "and" | "or" | undefined;
|
|
61
61
|
workspaceId?: string | undefined;
|
|
62
|
+
projectId?: string | undefined;
|
|
62
63
|
sectionId?: string | undefined;
|
|
63
|
-
|
|
64
|
+
parentId?: string | undefined;
|
|
64
65
|
cursor?: string | undefined;
|
|
65
|
-
labelsOperator?: "and" | "or" | undefined;
|
|
66
66
|
};
|
|
67
67
|
};
|
|
68
68
|
} | {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"find-completed-tasks.d.ts","sourceRoot":"","sources":["../../src/tools/find-completed-tasks.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAiDvB,QAAA,MAAM,kBAAkB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
1
|
+
{"version":3,"file":"find-completed-tasks.d.ts","sourceRoot":"","sources":["../../src/tools/find-completed-tasks.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAiDvB,QAAA,MAAM,kBAAkB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAsDkB,CAAA;AAmE1C,OAAO,EAAE,kBAAkB,EAAE,CAAA"}
|
|
@@ -43,15 +43,29 @@ const findCompletedTasks = {
|
|
|
43
43
|
description: 'Get completed tasks.',
|
|
44
44
|
parameters: ArgsSchema,
|
|
45
45
|
async execute(args, client) {
|
|
46
|
-
const { getBy, labels, labelsOperator, ...rest } = args;
|
|
46
|
+
const { getBy, labels, labelsOperator, since, until, ...rest } = args;
|
|
47
47
|
const labelsFilter = generateLabelsFilter(labels, labelsOperator);
|
|
48
|
+
// Get user timezone to convert local dates to UTC
|
|
49
|
+
const user = await client.getUser();
|
|
50
|
+
const userGmtOffset = user.tzInfo?.gmtString || '+00:00';
|
|
51
|
+
// Convert user's local date to UTC timestamps
|
|
52
|
+
// This ensures we capture the entire day from the user's perspective
|
|
53
|
+
const sinceWithOffset = `${since}T00:00:00${userGmtOffset}`;
|
|
54
|
+
const untilWithOffset = `${until}T23:59:59${userGmtOffset}`;
|
|
55
|
+
// Parse and convert to UTC
|
|
56
|
+
const sinceDateTime = new Date(sinceWithOffset).toISOString();
|
|
57
|
+
const untilDateTime = new Date(untilWithOffset).toISOString();
|
|
48
58
|
const { items, nextCursor } = getBy === 'completion'
|
|
49
59
|
? await client.getCompletedTasksByCompletionDate({
|
|
50
60
|
...rest,
|
|
61
|
+
since: sinceDateTime,
|
|
62
|
+
until: untilDateTime,
|
|
51
63
|
...(labelsFilter ? { filterQuery: labelsFilter, filterLang: 'en' } : {}),
|
|
52
64
|
})
|
|
53
65
|
: await client.getCompletedTasksByDueDate({
|
|
54
66
|
...rest,
|
|
67
|
+
since: sinceDateTime,
|
|
68
|
+
until: untilDateTime,
|
|
55
69
|
...(labelsFilter ? { filterQuery: labelsFilter, filterLang: 'en' } : {}),
|
|
56
70
|
});
|
|
57
71
|
const mappedTasks = items.map(mapTask);
|
|
@@ -9,8 +9,8 @@ declare const findProjects: {
|
|
|
9
9
|
};
|
|
10
10
|
execute(args: {
|
|
11
11
|
limit: number;
|
|
12
|
-
cursor?: string | undefined;
|
|
13
12
|
search?: string | undefined;
|
|
13
|
+
cursor?: string | undefined;
|
|
14
14
|
}, client: import("@doist/todoist-api-typescript").TodoistApi): Promise<{
|
|
15
15
|
content: {
|
|
16
16
|
type: "text";
|
|
@@ -32,8 +32,8 @@ declare const findProjects: {
|
|
|
32
32
|
hasMore: boolean;
|
|
33
33
|
appliedFilters: {
|
|
34
34
|
limit: number;
|
|
35
|
-
cursor?: string | undefined;
|
|
36
35
|
search?: string | undefined;
|
|
36
|
+
cursor?: string | undefined;
|
|
37
37
|
};
|
|
38
38
|
};
|
|
39
39
|
} | {
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
type SearchToolOutput = {
|
|
3
|
+
content: {
|
|
4
|
+
type: 'text';
|
|
5
|
+
text: string;
|
|
6
|
+
}[];
|
|
7
|
+
isError?: boolean;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* OpenAI MCP search tool - returns a list of relevant search results from Todoist.
|
|
11
|
+
*
|
|
12
|
+
* This tool follows the OpenAI MCP search tool specification:
|
|
13
|
+
* @see https://platform.openai.com/docs/mcp#search-tool
|
|
14
|
+
*/
|
|
15
|
+
declare const search: {
|
|
16
|
+
name: "search";
|
|
17
|
+
description: string;
|
|
18
|
+
parameters: {
|
|
19
|
+
query: z.ZodString;
|
|
20
|
+
};
|
|
21
|
+
execute(args: {
|
|
22
|
+
query: string;
|
|
23
|
+
}, client: import("@doist/todoist-api-typescript").TodoistApi): Promise<SearchToolOutput>;
|
|
24
|
+
};
|
|
25
|
+
export { search };
|
|
26
|
+
//# sourceMappingURL=search.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../../src/tools/search.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAiBvB,KAAK,gBAAgB,GAAG;IACpB,OAAO,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,EAAE,CAAA;IACzC,OAAO,CAAC,EAAE,OAAO,CAAA;CACpB,CAAA;AAED;;;;;GAKG;AACH,QAAA,MAAM,MAAM;;;;;;;;oEAKqB,OAAO,CAAC,gBAAgB,CAAC;CAmDhB,CAAA;AAE1C,OAAO,EAAE,MAAM,EAAE,CAAA"}
|