motionmcp 1.0.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.
@@ -0,0 +1,1177 @@
1
+ const axios = require('axios');
2
+
3
+ // MCP-compliant logger: outputs structured JSON to stderr
4
+ const mcpLog = (level, message, extra = {}) => {
5
+ const logEntry = {
6
+ level,
7
+ msg: message,
8
+ time: new Date().toISOString(),
9
+ ...extra
10
+ };
11
+
12
+ // MCP servers should log to stderr in JSON format
13
+ console.error(JSON.stringify(logEntry));
14
+ };
15
+
16
+ class MotionApiService {
17
+ constructor() {
18
+ this.apiKey = process.env.MOTION_API_KEY;
19
+ this.baseUrl = 'https://api.usemotion.com/v1';
20
+
21
+ if (!this.apiKey) {
22
+ mcpLog('error', 'Motion API key not found in environment variables', {
23
+ component: 'MotionApiService',
24
+ method: 'constructor'
25
+ });
26
+ throw new Error('MOTION_API_KEY environment variable is required');
27
+ }
28
+
29
+ mcpLog('info', 'Initializing Motion API service', {
30
+ component: 'MotionApiService',
31
+ baseUrl: this.baseUrl
32
+ });
33
+
34
+ this.client = axios.create({
35
+ baseURL: this.baseUrl,
36
+ headers: {
37
+ 'X-API-Key': `${this.apiKey}`,
38
+ 'Content-Type': 'application/json'
39
+ }
40
+ });
41
+
42
+ this.client.interceptors.response.use(
43
+ response => {
44
+ mcpLog('info', 'Motion API response successful', {
45
+ url: response.config?.url,
46
+ method: response.config?.method?.toUpperCase(),
47
+ status: response.status,
48
+ component: 'MotionApiService'
49
+ });
50
+ return response;
51
+ },
52
+ error => {
53
+ const errorDetails = {
54
+ url: error.config?.url,
55
+ method: error.config?.method?.toUpperCase(),
56
+ status: error.response?.status,
57
+ statusText: error.response?.statusText,
58
+ apiMessage: error.response?.data?.message,
59
+ errorMessage: error.message,
60
+ component: 'MotionApiService'
61
+ };
62
+
63
+ mcpLog('error', 'Motion API request failed', errorDetails);
64
+ throw error;
65
+ }
66
+ );
67
+ }
68
+
69
+ async getProjects(workspaceId = null) {
70
+ try {
71
+ mcpLog('debug', 'Fetching projects from Motion API', {
72
+ method: 'getProjects',
73
+ workspaceId
74
+ });
75
+
76
+ // If no workspace ID provided, try to get the first available workspace
77
+ if (!workspaceId) {
78
+ try {
79
+ const workspaces = await this.getWorkspaces();
80
+ if (workspaces && workspaces.length > 0) {
81
+ workspaceId = workspaces[0].id;
82
+ mcpLog('info', 'Using first available workspace for projects', {
83
+ method: 'getProjects',
84
+ workspaceId,
85
+ workspaceName: workspaces[0].name
86
+ });
87
+ }
88
+ } catch (workspaceError) {
89
+ mcpLog('warn', 'Could not fetch workspace for projects', {
90
+ method: 'getProjects',
91
+ error: workspaceError.message
92
+ });
93
+ }
94
+ }
95
+
96
+ // Build the query string with workspace ID if available
97
+ const params = new URLSearchParams();
98
+ if (workspaceId) {
99
+ params.append('workspaceId', workspaceId);
100
+ }
101
+
102
+ const url = `/projects${params.toString() ? '?' + params.toString() : ''}`;
103
+ const response = await this.client.get(url);
104
+
105
+ // Handle Motion API response structure - projects are wrapped in a projects array
106
+ const projects = response.data?.projects || response.data || [];
107
+
108
+ mcpLog('info', 'Successfully fetched projects', {
109
+ method: 'getProjects',
110
+ count: projects.length,
111
+ workspaceId,
112
+ responseStructure: response.data?.projects ? 'wrapped' : 'direct'
113
+ });
114
+ return projects;
115
+ } catch (error) {
116
+ mcpLog('error', 'Failed to fetch projects', {
117
+ method: 'getProjects',
118
+ error: error.message,
119
+ apiStatus: error.response?.status,
120
+ apiMessage: error.response?.data?.message,
121
+ workspaceId
122
+ });
123
+ throw new Error(`Failed to fetch projects: ${error.response?.data?.message || error.message}`);
124
+ }
125
+ }
126
+
127
+ async createProject(projectData) {
128
+ try {
129
+ mcpLog('debug', 'Creating new project in Motion API', {
130
+ method: 'createProject',
131
+ projectName: projectData.name
132
+ });
133
+
134
+ // If no workspace ID provided, try to get the default workspace
135
+ if (!projectData.workspaceId) {
136
+ try {
137
+ const defaultWorkspace = await this.getDefaultWorkspace();
138
+ projectData = { ...projectData, workspaceId: defaultWorkspace.id };
139
+ mcpLog('info', 'Using default workspace for new project', {
140
+ method: 'createProject',
141
+ workspaceId: defaultWorkspace.id,
142
+ workspaceName: defaultWorkspace.name
143
+ });
144
+ } catch (workspaceError) {
145
+ mcpLog('warn', 'Could not get default workspace for project creation', {
146
+ method: 'createProject',
147
+ error: workspaceError.message
148
+ });
149
+ }
150
+ }
151
+
152
+ const response = await this.client.post('/projects', projectData);
153
+ mcpLog('info', 'Successfully created project', {
154
+ method: 'createProject',
155
+ projectId: response.data?.id,
156
+ projectName: response.data?.name,
157
+ workspaceId: projectData.workspaceId
158
+ });
159
+ return response.data;
160
+ } catch (error) {
161
+ mcpLog('error', 'Failed to create project', {
162
+ method: 'createProject',
163
+ projectName: projectData.name,
164
+ error: error.message,
165
+ apiStatus: error.response?.status,
166
+ apiMessage: error.response?.data?.message
167
+ });
168
+ throw new Error(`Failed to create project: ${error.response?.data?.message || error.message}`);
169
+ }
170
+ }
171
+
172
+ async getProject(projectId) {
173
+ try {
174
+ mcpLog('debug', 'Fetching project details from Motion API', {
175
+ method: 'getProject',
176
+ projectId
177
+ });
178
+ const response = await this.client.get(`/projects/${projectId}`);
179
+ mcpLog('info', 'Successfully fetched project details', {
180
+ method: 'getProject',
181
+ projectId,
182
+ projectName: response.data?.name
183
+ });
184
+ return response.data;
185
+ } catch (error) {
186
+ mcpLog('error', 'Failed to fetch project', {
187
+ method: 'getProject',
188
+ projectId,
189
+ error: error.message,
190
+ apiStatus: error.response?.status,
191
+ apiMessage: error.response?.data?.message
192
+ });
193
+ throw new Error(`Failed to fetch project: ${error.response?.data?.message || error.message}`);
194
+ }
195
+ }
196
+
197
+ async updateProject(projectId, projectData) {
198
+ try {
199
+ mcpLog('debug', 'Updating project in Motion API', {
200
+ method: 'updateProject',
201
+ projectId,
202
+ updateFields: Object.keys(projectData)
203
+ });
204
+ const response = await this.client.patch(`/projects/${projectId}`, projectData);
205
+ mcpLog('info', 'Successfully updated project', {
206
+ method: 'updateProject',
207
+ projectId,
208
+ projectName: response.data?.name
209
+ });
210
+ return response.data;
211
+ } catch (error) {
212
+ mcpLog('error', 'Failed to update project', {
213
+ method: 'updateProject',
214
+ projectId,
215
+ error: error.message,
216
+ apiStatus: error.response?.status,
217
+ apiMessage: error.response?.data?.message
218
+ });
219
+ throw new Error(`Failed to update project: ${error.response?.data?.message || error.message}`);
220
+ }
221
+ }
222
+
223
+ async deleteProject(projectId) {
224
+ try {
225
+ mcpLog('debug', 'Deleting project from Motion API', {
226
+ method: 'deleteProject',
227
+ projectId
228
+ });
229
+ await this.client.delete(`/projects/${projectId}`);
230
+ mcpLog('info', 'Successfully deleted project', {
231
+ method: 'deleteProject',
232
+ projectId
233
+ });
234
+ return { success: true };
235
+ } catch (error) {
236
+ mcpLog('error', 'Failed to delete project', {
237
+ method: 'deleteProject',
238
+ projectId,
239
+ error: error.message,
240
+ apiStatus: error.response?.status,
241
+ apiMessage: error.response?.data?.message
242
+ });
243
+ throw new Error(`Failed to delete project: ${error.response?.data?.message || error.message}`);
244
+ }
245
+ }
246
+
247
+ async getTasks(options = {}) {
248
+ try {
249
+ mcpLog('debug', 'Fetching tasks from Motion API', {
250
+ method: 'getTasks',
251
+ filters: options
252
+ });
253
+ const params = new URLSearchParams();
254
+ if (options.projectId) params.append('projectId', options.projectId);
255
+ if (options.status) params.append('status', options.status);
256
+ if (options.assigneeId) params.append('assigneeId', options.assigneeId);
257
+
258
+ const response = await this.client.get(`/tasks?${params}`);
259
+
260
+ // Handle Motion API response structure - tasks are wrapped in a tasks array
261
+ const tasks = response.data?.tasks || response.data || [];
262
+
263
+ mcpLog('info', 'Successfully fetched tasks', {
264
+ method: 'getTasks',
265
+ count: tasks.length,
266
+ filters: options,
267
+ responseStructure: response.data?.tasks ? 'wrapped' : 'direct'
268
+ });
269
+ return tasks;
270
+ } catch (error) {
271
+ mcpLog('error', 'Failed to fetch tasks', {
272
+ method: 'getTasks',
273
+ filters: options,
274
+ error: error.message,
275
+ apiStatus: error.response?.status,
276
+ apiMessage: error.response?.data?.message
277
+ });
278
+ throw new Error(`Failed to fetch tasks: ${error.response?.data?.message || error.message}`);
279
+ }
280
+ }
281
+
282
+ async createTask(taskData) {
283
+ try {
284
+ mcpLog('debug', 'Creating new task in Motion API', {
285
+ method: 'createTask',
286
+ taskName: taskData.name,
287
+ projectId: taskData.projectId,
288
+ workspaceId: taskData.workspaceId
289
+ });
290
+
291
+ // Ensure workspaceId is present (required by Motion API)
292
+ if (!taskData.workspaceId) {
293
+ try {
294
+ const defaultWorkspace = await this.getDefaultWorkspace();
295
+ taskData = { ...taskData, workspaceId: defaultWorkspace.id };
296
+ mcpLog('info', 'Using default workspace for new task', {
297
+ method: 'createTask',
298
+ workspaceId: defaultWorkspace.id,
299
+ workspaceName: defaultWorkspace.name
300
+ });
301
+ } catch (workspaceError) {
302
+ throw new Error('workspaceId is required to create a task and no default workspace could be found');
303
+ }
304
+ }
305
+
306
+ // Validate required fields according to Motion API
307
+ if (!taskData.name) {
308
+ throw new Error('Task name is required');
309
+ }
310
+
311
+ // Log the final task data being sent to API
312
+ mcpLog('debug', 'Sending task data to Motion API', {
313
+ method: 'createTask',
314
+ taskData: {
315
+ name: taskData.name,
316
+ workspaceId: taskData.workspaceId,
317
+ projectId: taskData.projectId || 'none',
318
+ status: taskData.status || 'default',
319
+ priority: taskData.priority || 'not_set'
320
+ }
321
+ });
322
+
323
+ const response = await this.client.post('/tasks', taskData);
324
+
325
+ mcpLog('info', 'Successfully created task', {
326
+ method: 'createTask',
327
+ taskId: response.data?.id,
328
+ taskName: response.data?.name,
329
+ projectId: taskData.projectId,
330
+ workspaceId: taskData.workspaceId
331
+ });
332
+
333
+ return response.data;
334
+ } catch (error) {
335
+ mcpLog('error', 'Failed to create task', {
336
+ method: 'createTask',
337
+ taskName: taskData.name,
338
+ projectId: taskData.projectId,
339
+ workspaceId: taskData.workspaceId,
340
+ error: error.message,
341
+ apiStatus: error.response?.status,
342
+ apiMessage: error.response?.data?.message,
343
+ apiErrors: error.response?.data?.errors
344
+ });
345
+
346
+ // Provide more specific error messages
347
+ if (error.response?.status === 400) {
348
+ const apiMessage = error.response?.data?.message || '';
349
+ if (apiMessage.includes('workspaceId')) {
350
+ throw new Error('Invalid or missing workspaceId. Please provide a valid workspace ID.');
351
+ } else if (apiMessage.includes('projectId')) {
352
+ throw new Error('Invalid projectId. Please check that the project exists in the specified workspace.');
353
+ }
354
+ }
355
+
356
+ throw new Error(`Failed to create task: ${error.response?.data?.message || error.message}`);
357
+ }
358
+ }
359
+
360
+ async getTask(taskId) {
361
+ try {
362
+ mcpLog('debug', 'Fetching task details from Motion API', {
363
+ method: 'getTask',
364
+ taskId
365
+ });
366
+ const response = await this.client.get(`/tasks/${taskId}`);
367
+ mcpLog('info', 'Successfully fetched task details', {
368
+ method: 'getTask',
369
+ taskId,
370
+ taskName: response.data?.name
371
+ });
372
+ return response.data;
373
+ } catch (error) {
374
+ mcpLog('error', 'Failed to fetch task', {
375
+ method: 'getTask',
376
+ taskId,
377
+ error: error.message,
378
+ apiStatus: error.response?.status,
379
+ apiMessage: error.response?.data?.message
380
+ });
381
+ throw new Error(`Failed to fetch task: ${error.response?.data?.message || error.message}`);
382
+ }
383
+ }
384
+
385
+ async updateTask(taskId, taskData) {
386
+ try {
387
+ mcpLog('debug', 'Updating task in Motion API', {
388
+ method: 'updateTask',
389
+ taskId,
390
+ updateFields: Object.keys(taskData)
391
+ });
392
+ const response = await this.client.patch(`/tasks/${taskId}`, taskData);
393
+ mcpLog('info', 'Successfully updated task', {
394
+ method: 'updateTask',
395
+ taskId,
396
+ taskName: response.data?.name
397
+ });
398
+ return response.data;
399
+ } catch (error) {
400
+ mcpLog('error', 'Failed to update task', {
401
+ method: 'updateTask',
402
+ taskId,
403
+ error: error.message,
404
+ apiStatus: error.response?.status,
405
+ apiMessage: error.response?.data?.message
406
+ });
407
+ throw new Error(`Failed to update task: ${error.response?.data?.message || error.message}`);
408
+ }
409
+ }
410
+
411
+ async deleteTask(taskId) {
412
+ try {
413
+ mcpLog('debug', 'Deleting task from Motion API', {
414
+ method: 'deleteTask',
415
+ taskId
416
+ });
417
+ await this.client.delete(`/tasks/${taskId}`);
418
+ mcpLog('info', 'Successfully deleted task', {
419
+ method: 'deleteTask',
420
+ taskId
421
+ });
422
+ return { success: true };
423
+ } catch (error) {
424
+ mcpLog('error', 'Failed to delete task', {
425
+ method: 'deleteTask',
426
+ taskId,
427
+ error: error.message,
428
+ apiStatus: error.response?.status,
429
+ apiMessage: error.response?.data?.message
430
+ });
431
+ throw new Error(`Failed to delete task: ${error.response?.data?.message || error.message}`);
432
+ }
433
+ }
434
+
435
+ async getWorkspaces() {
436
+ try {
437
+ mcpLog('debug', 'Fetching workspaces from Motion API', { method: 'getWorkspaces' });
438
+ const response = await this.client.get('/workspaces');
439
+
440
+ // Motion API returns workspaces wrapped in a "workspaces" property
441
+ let workspaces = response.data;
442
+
443
+ if (workspaces && workspaces.workspaces && Array.isArray(workspaces.workspaces)) {
444
+ // Expected structure: { workspaces: [...] }
445
+ workspaces = workspaces.workspaces;
446
+ mcpLog('info', 'Successfully fetched workspaces', {
447
+ method: 'getWorkspaces',
448
+ count: workspaces.length,
449
+ responseStructure: 'wrapped_array',
450
+ workspaceNames: workspaces.map(w => w.name)
451
+ });
452
+ } else if (Array.isArray(workspaces)) {
453
+ // Fallback: if it's already an array
454
+ mcpLog('info', 'Successfully fetched workspaces', {
455
+ method: 'getWorkspaces',
456
+ count: workspaces.length,
457
+ responseStructure: 'direct_array',
458
+ workspaceNames: workspaces.map(w => w.name)
459
+ });
460
+ } else {
461
+ // Unexpected structure
462
+ mcpLog('warn', 'Unexpected workspace response structure', {
463
+ method: 'getWorkspaces',
464
+ responseData: workspaces,
465
+ responseType: typeof workspaces
466
+ });
467
+ workspaces = [];
468
+ }
469
+
470
+ return workspaces;
471
+ } catch (error) {
472
+ mcpLog('error', 'Failed to fetch workspaces', {
473
+ method: 'getWorkspaces',
474
+ error: error.message,
475
+ apiStatus: error.response?.status,
476
+ apiMessage: error.response?.data?.message
477
+ });
478
+ throw new Error(`Failed to fetch workspaces: ${error.response?.data?.message || error.message}`);
479
+ }
480
+ }
481
+
482
+ async getUsers() {
483
+ try {
484
+ mcpLog('debug', 'Fetching users from Motion API', { method: 'getUsers' });
485
+ const response = await this.client.get('/users');
486
+
487
+ // Handle different response structures from Motion API
488
+ let users = response.data;
489
+
490
+ // If response.data is not an array, check if it's wrapped in a property
491
+ if (!Array.isArray(users)) {
492
+ if (users && users.users && Array.isArray(users.users)) {
493
+ users = users.users;
494
+ } else if (users && typeof users === 'object') {
495
+ // If it's a single user object, wrap it in an array
496
+ users = [users];
497
+ } else {
498
+ // If we can't determine the structure, log it and return empty array
499
+ mcpLog('warn', 'Unexpected users response structure', {
500
+ method: 'getUsers',
501
+ responseData: users,
502
+ responseType: typeof users
503
+ });
504
+ users = [];
505
+ }
506
+ }
507
+
508
+ mcpLog('info', 'Successfully fetched users', {
509
+ method: 'getUsers',
510
+ count: users.length,
511
+ responseStructure: Array.isArray(response.data) ? 'array' : 'object'
512
+ });
513
+ return users;
514
+ } catch (error) {
515
+ mcpLog('error', 'Failed to fetch users', {
516
+ method: 'getUsers',
517
+ error: error.message,
518
+ apiStatus: error.response?.status,
519
+ apiMessage: error.response?.data?.message
520
+ });
521
+ throw new Error(`Failed to fetch users: ${error.response?.data?.message || error.message}`);
522
+ }
523
+ }
524
+
525
+ async getDefaultWorkspace() {
526
+ try {
527
+ const workspaces = await this.getWorkspaces();
528
+
529
+ if (!workspaces || workspaces.length === 0) {
530
+ throw new Error('No workspaces available');
531
+ }
532
+
533
+ // Log all available workspaces for debugging
534
+ mcpLog('debug', 'Available workspaces', {
535
+ method: 'getDefaultWorkspace',
536
+ workspaces: workspaces.map(w => ({ id: w.id, name: w.name, type: w.type }))
537
+ });
538
+
539
+ // Prefer the first workspace, but could add logic here to prefer certain types
540
+ // For example, prefer "INDIVIDUAL" type workspaces over team workspaces
541
+ let defaultWorkspace = workspaces[0];
542
+
543
+ // Look for a personal or individual workspace first
544
+ const personalWorkspace = workspaces.find(w =>
545
+ w.type === 'INDIVIDUAL' &&
546
+ (w.name.toLowerCase().includes('personal') || w.name.toLowerCase().includes('my'))
547
+ );
548
+
549
+ if (personalWorkspace) {
550
+ defaultWorkspace = personalWorkspace;
551
+ mcpLog('info', 'Selected personal workspace as default', {
552
+ method: 'getDefaultWorkspace',
553
+ workspaceId: defaultWorkspace.id,
554
+ workspaceName: defaultWorkspace.name,
555
+ type: defaultWorkspace.type
556
+ });
557
+ } else {
558
+ mcpLog('info', 'Selected first available workspace as default', {
559
+ method: 'getDefaultWorkspace',
560
+ workspaceId: defaultWorkspace.id,
561
+ workspaceName: defaultWorkspace.name,
562
+ type: defaultWorkspace.type
563
+ });
564
+ }
565
+
566
+ return defaultWorkspace;
567
+ } catch (error) {
568
+ mcpLog('error', 'Failed to get default workspace', {
569
+ method: 'getDefaultWorkspace',
570
+ error: error.message
571
+ });
572
+ throw error;
573
+ }
574
+ }
575
+
576
+ async getWorkspaceByName(workspaceName) {
577
+ try {
578
+ const workspaces = await this.getWorkspaces();
579
+ const workspace = workspaces.find(w => w.name.toLowerCase() === workspaceName.toLowerCase());
580
+
581
+ if (!workspace) {
582
+ throw new Error(`Workspace with name "${workspaceName}" not found`);
583
+ }
584
+
585
+ mcpLog('info', 'Found workspace by name', {
586
+ method: 'getWorkspaceByName',
587
+ workspaceName,
588
+ workspaceId: workspace.id
589
+ });
590
+
591
+ return workspace;
592
+ } catch (error) {
593
+ mcpLog('error', 'Failed to find workspace by name', {
594
+ method: 'getWorkspaceByName',
595
+ workspaceName,
596
+ error: error.message
597
+ });
598
+ throw error;
599
+ }
600
+ }
601
+
602
+ async getTaskStatuses(workspaceId = null) {
603
+ try {
604
+ if (!workspaceId) {
605
+ const defaultWorkspace = await this.getDefaultWorkspace();
606
+ workspaceId = defaultWorkspace.id;
607
+ }
608
+
609
+ const workspaces = await this.getWorkspaces();
610
+ const workspace = workspaces.find(w => w.id === workspaceId);
611
+
612
+ if (!workspace) {
613
+ throw new Error(`Workspace with ID "${workspaceId}" not found`);
614
+ }
615
+
616
+ mcpLog('info', 'Retrieved task statuses for workspace', {
617
+ method: 'getTaskStatuses',
618
+ workspaceId,
619
+ workspaceName: workspace.name,
620
+ statusCount: workspace.taskStatuses?.length || 0
621
+ });
622
+
623
+ return workspace.taskStatuses || [];
624
+ } catch (error) {
625
+ mcpLog('error', 'Failed to get task statuses', {
626
+ method: 'getTaskStatuses',
627
+ workspaceId,
628
+ error: error.message
629
+ });
630
+ throw error;
631
+ }
632
+ }
633
+
634
+ async getWorkspaceLabels(workspaceId = null) {
635
+ try {
636
+ if (!workspaceId) {
637
+ const defaultWorkspace = await this.getDefaultWorkspace();
638
+ workspaceId = defaultWorkspace.id;
639
+ }
640
+
641
+ const workspaces = await this.getWorkspaces();
642
+ const workspace = workspaces.find(w => w.id === workspaceId);
643
+
644
+ if (!workspace) {
645
+ throw new Error(`Workspace with ID "${workspaceId}" not found`);
646
+ }
647
+
648
+ mcpLog('info', 'Retrieved labels for workspace', {
649
+ method: 'getWorkspaceLabels',
650
+ workspaceId,
651
+ workspaceName: workspace.name,
652
+ labelCount: workspace.labels?.length || 0
653
+ });
654
+
655
+ return workspace.labels || [];
656
+ } catch (error) {
657
+ mcpLog('error', 'Failed to get workspace labels', {
658
+ method: 'getWorkspaceLabels',
659
+ workspaceId,
660
+ error: error.message
661
+ });
662
+ throw error;
663
+ }
664
+ }
665
+
666
+ async getProjectByName(projectName, workspaceId = null) {
667
+ try {
668
+ if (!workspaceId) {
669
+ const defaultWorkspace = await this.getDefaultWorkspace();
670
+ workspaceId = defaultWorkspace.id;
671
+ }
672
+
673
+ const projects = await this.getProjects(workspaceId);
674
+ const project = projects.find(p =>
675
+ p.name.toLowerCase() === projectName.toLowerCase() ||
676
+ p.name.toLowerCase().includes(projectName.toLowerCase())
677
+ );
678
+
679
+ if (!project) {
680
+ throw new Error(`Project "${projectName}" not found in workspace`);
681
+ }
682
+
683
+ mcpLog('info', 'Found project by name', {
684
+ method: 'getProjectByName',
685
+ projectName,
686
+ projectId: project.id,
687
+ workspaceId
688
+ });
689
+
690
+ return project;
691
+ } catch (error) {
692
+ mcpLog('error', 'Failed to find project by name', {
693
+ method: 'getProjectByName',
694
+ projectName,
695
+ workspaceId,
696
+ error: error.message
697
+ });
698
+ throw error;
699
+ }
700
+ }
701
+
702
+ // Enhanced Intelligence Methods
703
+
704
+ async getMotionContext(options = {}) {
705
+ try {
706
+ const {
707
+ includeRecentActivity = true,
708
+ includeWorkloadSummary = true,
709
+ includeSuggestions = true
710
+ } = options;
711
+
712
+ mcpLog('info', 'Fetching Motion context', {
713
+ method: 'getMotionContext',
714
+ includeRecentActivity,
715
+ includeWorkloadSummary,
716
+ includeSuggestions
717
+ });
718
+
719
+ const context = {
720
+ timestamp: new Date().toISOString(),
721
+ user: null,
722
+ defaultWorkspace: null,
723
+ workspaces: [],
724
+ recentActivity: [],
725
+ workloadSummary: {},
726
+ suggestions: []
727
+ };
728
+
729
+ // Get user info and workspaces
730
+ try {
731
+ const [users, workspaces] = await Promise.all([
732
+ this.getUsers(),
733
+ this.getWorkspaces()
734
+ ]);
735
+
736
+ context.user = users[0] || null; // Assume first user is current user
737
+ context.workspaces = workspaces;
738
+ context.defaultWorkspace = await this.getDefaultWorkspace();
739
+ } catch (error) {
740
+ mcpLog('warn', 'Could not fetch user or workspace info for context', {
741
+ method: 'getMotionContext',
742
+ error: error.message
743
+ });
744
+ }
745
+
746
+ // Get recent activity if requested
747
+ if (includeRecentActivity && context.defaultWorkspace) {
748
+ try {
749
+ const recentTasks = await this.getTasks({
750
+ workspaceId: context.defaultWorkspace.id,
751
+ limit: 10
752
+ });
753
+
754
+ context.recentActivity = recentTasks
755
+ .sort((a, b) => new Date(b.updatedAt || b.createdAt) - new Date(a.updatedAt || a.createdAt))
756
+ .slice(0, 5)
757
+ .map(task => ({
758
+ type: 'task',
759
+ id: task.id,
760
+ name: task.name,
761
+ status: task.status,
762
+ priority: task.priority,
763
+ updatedAt: task.updatedAt || task.createdAt
764
+ }));
765
+ } catch (error) {
766
+ mcpLog('warn', 'Could not fetch recent activity for context', {
767
+ method: 'getMotionContext',
768
+ error: error.message
769
+ });
770
+ }
771
+ }
772
+
773
+ // Generate workload summary if requested
774
+ if (includeWorkloadSummary && context.defaultWorkspace) {
775
+ try {
776
+ context.workloadSummary = await this.generateWorkloadSummary(context.defaultWorkspace.id);
777
+ } catch (error) {
778
+ mcpLog('warn', 'Could not generate workload summary for context', {
779
+ method: 'getMotionContext',
780
+ error: error.message
781
+ });
782
+ }
783
+ }
784
+
785
+ // Generate suggestions if requested
786
+ if (includeSuggestions && context.defaultWorkspace) {
787
+ try {
788
+ context.suggestions = await this.generateContextSuggestions(context.defaultWorkspace.id);
789
+ } catch (error) {
790
+ mcpLog('warn', 'Could not generate suggestions for context', {
791
+ method: 'getMotionContext',
792
+ error: error.message
793
+ });
794
+ }
795
+ }
796
+
797
+ mcpLog('info', 'Successfully generated Motion context', {
798
+ method: 'getMotionContext',
799
+ workspaceCount: context.workspaces.length,
800
+ recentActivityCount: context.recentActivity.length,
801
+ suggestionsCount: context.suggestions.length
802
+ });
803
+
804
+ return context;
805
+ } catch (error) {
806
+ mcpLog('error', 'Failed to get Motion context', {
807
+ method: 'getMotionContext',
808
+ error: error.message
809
+ });
810
+ throw error;
811
+ }
812
+ }
813
+
814
+ async searchContent(options) {
815
+ try {
816
+ const { query, searchScope = "both", workspaceId, limit = 20 } = options;
817
+
818
+ mcpLog('info', 'Searching Motion content', {
819
+ method: 'searchContent',
820
+ query,
821
+ searchScope,
822
+ workspaceId,
823
+ limit
824
+ });
825
+
826
+ let results = [];
827
+
828
+ // Search tasks if scope includes tasks
829
+ if (searchScope === "tasks" || searchScope === "both") {
830
+ try {
831
+ const tasks = await this.getTasks({ workspaceId });
832
+ const taskResults = tasks
833
+ .filter(task =>
834
+ task.name.toLowerCase().includes(query.toLowerCase()) ||
835
+ (task.description && task.description.toLowerCase().includes(query.toLowerCase()))
836
+ )
837
+ .map(task => ({
838
+ ...task,
839
+ type: 'task',
840
+ relevance: this.calculateRelevance(task, query)
841
+ }));
842
+
843
+ results.push(...taskResults);
844
+ } catch (error) {
845
+ mcpLog('warn', 'Error searching tasks', {
846
+ method: 'searchContent',
847
+ error: error.message
848
+ });
849
+ }
850
+ }
851
+
852
+ // Search projects if scope includes projects
853
+ if (searchScope === "projects" || searchScope === "both") {
854
+ try {
855
+ const projects = await this.getProjects(workspaceId);
856
+ const projectResults = projects
857
+ .filter(project =>
858
+ project.name.toLowerCase().includes(query.toLowerCase()) ||
859
+ (project.description && project.description.toLowerCase().includes(query.toLowerCase()))
860
+ )
861
+ .map(project => ({
862
+ ...project,
863
+ type: 'project',
864
+ relevance: this.calculateRelevance(project, query)
865
+ }));
866
+
867
+ results.push(...projectResults);
868
+ } catch (error) {
869
+ mcpLog('warn', 'Error searching projects', {
870
+ method: 'searchContent',
871
+ error: error.message
872
+ });
873
+ }
874
+ }
875
+
876
+ // Sort by relevance and limit results
877
+ results = results
878
+ .sort((a, b) => b.relevance - a.relevance)
879
+ .slice(0, limit);
880
+
881
+ mcpLog('info', 'Search completed', {
882
+ method: 'searchContent',
883
+ query,
884
+ resultsCount: results.length
885
+ });
886
+
887
+ return results;
888
+ } catch (error) {
889
+ mcpLog('error', 'Failed to search content', {
890
+ method: 'searchContent',
891
+ error: error.message
892
+ });
893
+ throw error;
894
+ }
895
+ }
896
+
897
+ calculateRelevance(item, query) {
898
+ const queryLower = query.toLowerCase();
899
+ let score = 0;
900
+
901
+ // Exact name match gets highest score
902
+ if (item.name.toLowerCase() === queryLower) {
903
+ score += 100;
904
+ }
905
+ // Name contains query
906
+ else if (item.name.toLowerCase().includes(queryLower)) {
907
+ score += 50;
908
+ }
909
+
910
+ // Description contains query
911
+ if (item.description && item.description.toLowerCase().includes(queryLower)) {
912
+ score += 25;
913
+ }
914
+
915
+ // Boost score for high priority items
916
+ if (item.priority === 'ASAP') score += 20;
917
+ else if (item.priority === 'HIGH') score += 10;
918
+
919
+ return score;
920
+ }
921
+
922
+ async analyzeWorkload(options) {
923
+ try {
924
+ const { workspaceId, timeframe = "this_week", includeProjects = true } = options;
925
+
926
+ mcpLog('info', 'Analyzing workload', {
927
+ method: 'analyzeWorkload',
928
+ workspaceId,
929
+ timeframe,
930
+ includeProjects
931
+ });
932
+
933
+ const wsId = workspaceId || (await this.getDefaultWorkspace()).id;
934
+ const tasks = await this.getTasks({ workspaceId: wsId });
935
+
936
+ const now = new Date();
937
+ const analysis = {
938
+ totalTasks: tasks.length,
939
+ overdueTasks: 0,
940
+ upcomingDeadlines: 0,
941
+ taskDistribution: {
942
+ byStatus: {},
943
+ byPriority: {},
944
+ byProject: {}
945
+ },
946
+ projectInsights: {}
947
+ };
948
+
949
+ // Analyze tasks
950
+ tasks.forEach(task => {
951
+ // Count overdue tasks
952
+ if (task.dueDate && new Date(task.dueDate) < now) {
953
+ analysis.overdueTasks++;
954
+ }
955
+
956
+ // Count upcoming deadlines (next 7 days)
957
+ if (task.dueDate) {
958
+ const dueDate = new Date(task.dueDate);
959
+ const daysUntilDue = (dueDate - now) / (1000 * 60 * 60 * 24);
960
+ if (daysUntilDue >= 0 && daysUntilDue <= 7) {
961
+ analysis.upcomingDeadlines++;
962
+ }
963
+ }
964
+
965
+ // Task distribution by status
966
+ analysis.taskDistribution.byStatus[task.status] =
967
+ (analysis.taskDistribution.byStatus[task.status] || 0) + 1;
968
+
969
+ // Task distribution by priority
970
+ analysis.taskDistribution.byPriority[task.priority || 'NONE'] =
971
+ (analysis.taskDistribution.byPriority[task.priority || 'NONE'] || 0) + 1;
972
+
973
+ // Task distribution by project
974
+ const projectKey = task.projectId || 'No Project';
975
+ analysis.taskDistribution.byProject[projectKey] =
976
+ (analysis.taskDistribution.byProject[projectKey] || 0) + 1;
977
+ });
978
+
979
+ // Project insights if requested
980
+ if (includeProjects) {
981
+ try {
982
+ const projects = await this.getProjects(wsId);
983
+ analysis.projectInsights = {
984
+ totalProjects: projects.length,
985
+ activeProjects: projects.filter(p => p.status === 'active').length,
986
+ completedProjects: projects.filter(p => p.status === 'completed').length
987
+ };
988
+ } catch (error) {
989
+ mcpLog('warn', 'Could not fetch project insights', {
990
+ method: 'analyzeWorkload',
991
+ error: error.message
992
+ });
993
+ }
994
+ }
995
+
996
+ mcpLog('info', 'Workload analysis completed', {
997
+ method: 'analyzeWorkload',
998
+ totalTasks: analysis.totalTasks,
999
+ overdueTasks: analysis.overdueTasks,
1000
+ upcomingDeadlines: analysis.upcomingDeadlines
1001
+ });
1002
+
1003
+ return analysis;
1004
+ } catch (error) {
1005
+ mcpLog('error', 'Failed to analyze workload', {
1006
+ method: 'analyzeWorkload',
1007
+ error: error.message
1008
+ });
1009
+ throw error;
1010
+ }
1011
+ }
1012
+
1013
+ async generateWorkloadSummary(workspaceId) {
1014
+ const tasks = await this.getTasks({ workspaceId });
1015
+ const now = new Date();
1016
+
1017
+ return {
1018
+ total: tasks.length,
1019
+ completed: tasks.filter(t => t.status === 'completed' || t.status === 'done').length,
1020
+ inProgress: tasks.filter(t => t.status === 'in-progress' || t.status === 'in_progress').length,
1021
+ overdue: tasks.filter(t => t.dueDate && new Date(t.dueDate) < now).length,
1022
+ highPriority: tasks.filter(t => t.priority === 'ASAP' || t.priority === 'HIGH').length
1023
+ };
1024
+ }
1025
+
1026
+ async generateContextSuggestions(workspaceId) {
1027
+ const tasks = await this.getTasks({ workspaceId });
1028
+ const suggestions = [];
1029
+
1030
+ // Find overdue tasks
1031
+ const overdueTasks = tasks.filter(t =>
1032
+ t.dueDate && new Date(t.dueDate) < new Date()
1033
+ );
1034
+
1035
+ if (overdueTasks.length > 0) {
1036
+ suggestions.push(`You have ${overdueTasks.length} overdue task(s) that need attention`);
1037
+ }
1038
+
1039
+ // Find high priority tasks
1040
+ const highPriorityTasks = tasks.filter(t =>
1041
+ t.priority === 'ASAP' || t.priority === 'HIGH'
1042
+ );
1043
+
1044
+ if (highPriorityTasks.length > 0) {
1045
+ suggestions.push(`Consider focusing on ${highPriorityTasks.length} high-priority task(s)`);
1046
+ }
1047
+
1048
+ // Find tasks due soon
1049
+ const soon = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours from now
1050
+ const tasksDueSoon = tasks.filter(t =>
1051
+ t.dueDate && new Date(t.dueDate) <= soon && new Date(t.dueDate) >= new Date()
1052
+ );
1053
+
1054
+ if (tasksDueSoon.length > 0) {
1055
+ suggestions.push(`${tasksDueSoon.length} task(s) are due within 24 hours`);
1056
+ }
1057
+
1058
+ return suggestions;
1059
+ }
1060
+
1061
+ async bulkUpdateTasks(taskIds, updates) {
1062
+ try {
1063
+ mcpLog('info', 'Starting bulk task update', {
1064
+ method: 'bulkUpdateTasks',
1065
+ taskCount: taskIds.length,
1066
+ updates
1067
+ });
1068
+
1069
+ const updatePromises = taskIds.map(taskId =>
1070
+ this.updateTask(taskId, updates)
1071
+ );
1072
+
1073
+ const results = await Promise.allSettled(updatePromises);
1074
+ const successful = results.filter(r => r.status === 'fulfilled').length;
1075
+ const failed = results.filter(r => r.status === 'rejected').length;
1076
+
1077
+ mcpLog('info', 'Bulk task update completed', {
1078
+ method: 'bulkUpdateTasks',
1079
+ successful,
1080
+ failed,
1081
+ total: taskIds.length
1082
+ });
1083
+
1084
+ if (failed > 0) {
1085
+ const failures = results
1086
+ .filter(r => r.status === 'rejected')
1087
+ .map(r => r.reason.message);
1088
+
1089
+ throw new Error(`${failed} task updates failed: ${failures.join(', ')}`);
1090
+ }
1091
+
1092
+ return { successful, failed };
1093
+ } catch (error) {
1094
+ mcpLog('error', 'Failed to bulk update tasks', {
1095
+ method: 'bulkUpdateTasks',
1096
+ error: error.message
1097
+ });
1098
+ throw error;
1099
+ }
1100
+ }
1101
+
1102
+ async smartScheduleTasks(taskIds, workspaceId, preferences = {}) {
1103
+ try {
1104
+ mcpLog('info', 'Starting smart task scheduling', {
1105
+ method: 'smartScheduleTasks',
1106
+ taskCount: taskIds?.length || 0,
1107
+ workspaceId,
1108
+ preferences
1109
+ });
1110
+
1111
+ // Get tasks to schedule
1112
+ let tasksToSchedule = [];
1113
+ if (taskIds && taskIds.length > 0) {
1114
+ // Get specific tasks
1115
+ const taskPromises = taskIds.map(id => this.getTask(id));
1116
+ const taskResults = await Promise.allSettled(taskPromises);
1117
+ tasksToSchedule = taskResults
1118
+ .filter(r => r.status === 'fulfilled')
1119
+ .map(r => r.value);
1120
+ } else {
1121
+ // Get all unscheduled tasks in workspace
1122
+ const allTasks = await this.getTasks({ workspaceId });
1123
+ tasksToSchedule = allTasks.filter(task =>
1124
+ !task.scheduledStart || task.status === 'todo'
1125
+ );
1126
+ }
1127
+
1128
+ // Sort tasks by priority and deadline
1129
+ const sortedTasks = tasksToSchedule.sort((a, b) => {
1130
+ // Priority order: ASAP > HIGH > MEDIUM > LOW
1131
+ const priorityOrder = { 'ASAP': 4, 'HIGH': 3, 'MEDIUM': 2, 'LOW': 1 };
1132
+ const aPriority = priorityOrder[a.priority] || 1;
1133
+ const bPriority = priorityOrder[b.priority] || 1;
1134
+
1135
+ if (aPriority !== bPriority) {
1136
+ return bPriority - aPriority; // Higher priority first
1137
+ }
1138
+
1139
+ // If same priority, sort by due date
1140
+ if (a.dueDate && b.dueDate) {
1141
+ return new Date(a.dueDate) - new Date(b.dueDate);
1142
+ }
1143
+
1144
+ return 0;
1145
+ });
1146
+
1147
+ // Generate schedule (simplified - in reality, this would use Motion's auto-scheduling)
1148
+ const schedule = sortedTasks.map((task, index) => {
1149
+ const startTime = new Date();
1150
+ startTime.setHours(9 + index, 0, 0, 0); // Start at 9 AM, one hour apart
1151
+
1152
+ return {
1153
+ taskId: task.id,
1154
+ taskName: task.name,
1155
+ scheduledTime: startTime.toISOString(),
1156
+ priority: task.priority,
1157
+ estimatedDuration: task.duration || 60
1158
+ };
1159
+ });
1160
+
1161
+ mcpLog('info', 'Smart scheduling completed', {
1162
+ method: 'smartScheduleTasks',
1163
+ scheduledCount: schedule.length
1164
+ });
1165
+
1166
+ return schedule;
1167
+ } catch (error) {
1168
+ mcpLog('error', 'Failed to smart schedule tasks', {
1169
+ method: 'smartScheduleTasks',
1170
+ error: error.message
1171
+ });
1172
+ throw error;
1173
+ }
1174
+ }
1175
+ }
1176
+
1177
+ module.exports = MotionApiService;