motion_search
Search Motion tasks and projects using keywords to find specific items in your calendar and task management platform.
Instructions
Search Motion tasks and projects by query
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| operation | Yes | Operation to perform | |
| query | No | Search query (required) | |
| searchScope | No | What to search (default: both) | |
| workspaceId | No | Workspace ID to limit search | |
| workspaceName | No | Workspace name (alternative to workspaceId) | |
| limit | No | Maximum number of results |
Implementation Reference
- src/handlers/SearchHandler.ts:15-106 (handler)The SearchHandler class implements the main logic for the motion_search tool. It handles the 'content' operation, resolves workspace, searches tasks and projects based on query, manages limits, and formats results using formatSearchResults helper.
export class SearchHandler extends BaseHandler { async handle(args: MotionSearchArgs): Promise<McpToolResponse> { try { const { operation } = args; switch(operation) { case 'content': return await this.handleContentSearch(args as ContentSearchArgs); default: return this.handleUnknownOperation(operation); } } catch (error: unknown) { return this.handleError(error); } } private async handleContentSearch(args: ContentSearchArgs): Promise<McpToolResponse> { if (!args.query) { return this.handleError(new Error("Query is required for content search")); } const entityTypes = this.resolveEntityTypes(args); // Use configurable limit to prevent resource exhaustion const limit = args.limit || LIMITS.MAX_SEARCH_RESULTS; const workspace = await this.workspaceResolver.resolveWorkspace({ workspaceId: args.workspaceId, workspaceName: args.workspaceName }); const results: Array<{ id: string; name: string; entityType: 'task' | 'project' }> = []; let mergedTruncation: TruncationInfo | undefined; if (entityTypes.includes('tasks')) { const { items: tasks, truncation } = await this.motionService.searchTasks(args.query, workspace.id, limit); results.push(...tasks.map(task => ({ id: task.id, name: task.name, entityType: 'task' as const }))); if (truncation?.wasTruncated && !mergedTruncation) { mergedTruncation = { ...truncation }; } } if (entityTypes.includes('projects')) { const { items: projects, truncation } = await this.motionService.searchProjects(args.query, workspace.id, limit); results.push(...projects.map(project => ({ id: project.id, name: project.name, entityType: 'project' as const }))); if (truncation?.wasTruncated && !mergedTruncation) { mergedTruncation = { ...truncation }; } } const slicedResults = results.slice(0, limit); if (results.length > limit) { // Combined results exceeded limit — report truncation for the combined result mergedTruncation = { wasTruncated: true, returnedCount: slicedResults.length, reason: 'max_items', limit }; } else if (mergedTruncation) { // A source was truncated but combined results fit within the limit — // update returnedCount to reflect the actual number of results returned mergedTruncation.returnedCount = slicedResults.length; } return formatSearchResults(slicedResults, args.query, { limit, searchScope: entityTypes.join(',') || 'both', truncation: mergedTruncation }); } private resolveEntityTypes(args: ContentSearchArgs): Array<'tasks' | 'projects'> { if (args.entityTypes && args.entityTypes.length > 0) { return Array.from(new Set(args.entityTypes)); } if (args.searchScope === 'tasks') { return ['tasks']; } if (args.searchScope === 'projects') { return ['projects']; } return ['tasks', 'projects']; } } - src/tools/ToolDefinitions.ts:196-231 (schema)The searchToolDefinition defines the MCP tool schema for motion_search, including input parameters (operation, query, searchScope, workspaceId, workspaceName, limit) and the tool description.
export const searchToolDefinition: McpToolDefinition = { name: TOOL_NAMES.SEARCH, description: "Search Motion tasks and projects by query", inputSchema: { type: "object", properties: { operation: { type: "string", enum: ["content"], description: "Operation to perform" }, query: { type: "string", description: "Search query (required)" }, searchScope: { type: "string", enum: ["tasks", "projects", "both"], description: "What to search (default: both)" }, workspaceId: { type: "string", description: "Workspace ID to limit search" }, workspaceName: { type: "string", description: "Workspace name (alternative to workspaceId)" }, limit: { type: "number", description: "Maximum number of results" } }, required: ["operation"] } }; - src/handlers/HandlerFactory.ts:27-38 (registration)The HandlerFactory registers the SearchHandler class with the TOOL_NAMES.SEARCH constant ('motion_search'), mapping the tool name to its handler implementation.
private registerHandlers(): void { this.handlers.set(TOOL_NAMES.TASKS, TaskHandler); this.handlers.set(TOOL_NAMES.PROJECTS, ProjectHandler); this.handlers.set(TOOL_NAMES.WORKSPACES, WorkspaceHandler); this.handlers.set(TOOL_NAMES.USERS, UserHandler); this.handlers.set(TOOL_NAMES.SEARCH, SearchHandler); this.handlers.set(TOOL_NAMES.COMMENTS, CommentHandler); this.handlers.set(TOOL_NAMES.CUSTOM_FIELDS, CustomFieldHandler); this.handlers.set(TOOL_NAMES.RECURRING_TASKS, RecurringTaskHandler); this.handlers.set(TOOL_NAMES.SCHEDULES, ScheduleHandler); this.handlers.set(TOOL_NAMES.STATUSES, StatusHandler); } - src/types/mcp-tool-args.ts:53-60 (schema)The MotionSearchArgs interface defines the TypeScript type for the motion_search tool arguments, matching the MCP schema definition.
export interface MotionSearchArgs { operation: 'content'; query?: string; searchScope?: 'tasks' | 'projects' | 'both'; workspaceId?: string; workspaceName?: string; limit?: number; } - src/services/motionApi.ts:1447-1695 (helper)The MotionApiService.searchTasks and searchProjects methods implement the actual search logic, fetching data from Motion API with pagination, filtering by query (case-insensitive), supporting cross-workspace search, and managing truncation metadata.
async searchTasks(query: string, workspaceId: string, limit?: number): Promise<ListResult<MotionTask>> { try { mcpLog(LOG_LEVELS.DEBUG, 'Searching tasks', { method: 'searchTasks', query, workspaceId, limit }); // Apply search limit to prevent resource exhaustion const effectiveLimit = limit || LIMITS.MAX_SEARCH_RESULTS; const lowerQuery = query.toLowerCase(); const allMatchingTasks: MotionTask[] = []; let aggregateTruncation: TruncationInfo | undefined; // First, search in the specified workspace const { items: primaryTasks, truncation: primaryTruncation } = await this.getTasks({ workspaceId, limit: calculateAdaptiveFetchLimit(allMatchingTasks.length, effectiveLimit), maxPages: LIMITS.MAX_PAGES }); aggregateTruncation = this.mergeTruncationMetadata(aggregateTruncation, primaryTruncation); const primaryMatches = primaryTasks.filter(task => task.name?.toLowerCase().includes(lowerQuery) || task.description?.toLowerCase().includes(lowerQuery) ); allMatchingTasks.push(...primaryMatches.slice(0, effectiveLimit)); mcpLog(LOG_LEVELS.DEBUG, 'Primary workspace search completed', { method: 'searchTasks', query, primaryWorkspaceId: workspaceId, primaryMatches: primaryMatches.length, keptMatches: allMatchingTasks.length }); // If we haven't reached the limit, search other workspaces if (allMatchingTasks.length < effectiveLimit) { try { const allWorkspaces = await this.getWorkspaces(); const otherWorkspaces = allWorkspaces.filter(w => w.id !== workspaceId); for (const workspace of otherWorkspaces) { if (allMatchingTasks.length >= effectiveLimit) break; try { // Calculate fetch limit before API call (defense-in-depth) const fetchLimit = calculateAdaptiveFetchLimit(allMatchingTasks.length, effectiveLimit); if (fetchLimit <= 0) break; mcpLog(LOG_LEVELS.DEBUG, 'Searching additional workspace for tasks', { method: 'searchTasks', query, searchingWorkspaceId: workspace.id, searchingWorkspaceName: workspace.name, remainingNeeded: effectiveLimit - allMatchingTasks.length }); const { items: workspaceTasks, truncation: wsTruncation } = await this.getTasks({ workspaceId: workspace.id, limit: fetchLimit, maxPages: LIMITS.MAX_PAGES }); aggregateTruncation = this.mergeTruncationMetadata(aggregateTruncation, wsTruncation); const workspaceMatches = workspaceTasks.filter(task => task.name?.toLowerCase().includes(lowerQuery) || task.description?.toLowerCase().includes(lowerQuery) ); // Only add as many as we still need const remaining = effectiveLimit - allMatchingTasks.length; allMatchingTasks.push(...workspaceMatches.slice(0, remaining)); if (workspaceMatches.length > 0) { mcpLog(LOG_LEVELS.DEBUG, 'Found additional matches in workspace', { method: 'searchTasks', query, workspaceId: workspace.id, workspaceName: workspace.name, matches: workspaceMatches.length, keptMatches: Math.min(workspaceMatches.length, remaining) }); } } catch (workspaceError: unknown) { // Log error but continue searching other workspaces mcpLog(LOG_LEVELS.WARN, 'Failed to search workspace for tasks', { method: 'searchTasks', query, workspaceId: workspace.id, workspaceName: workspace.name, error: getErrorMessage(workspaceError) }); } } } catch (workspaceListError: unknown) { mcpLog(LOG_LEVELS.WARN, 'Failed to get workspace list for cross-workspace search', { method: 'searchTasks', query, error: getErrorMessage(workspaceListError) }); } } // Results are already limited during collection, no need to slice again mcpLog(LOG_LEVELS.INFO, 'Task search completed across all workspaces', { method: 'searchTasks', query, returnedResults: allMatchingTasks.length, limit: effectiveLimit }); if (aggregateTruncation) { aggregateTruncation.returnedCount = allMatchingTasks.length; } return { items: allMatchingTasks, truncation: aggregateTruncation }; } catch (error: unknown) { mcpLog(LOG_LEVELS.ERROR, 'Failed to search tasks', { method: 'searchTasks', query, error: getErrorMessage(error) }); throw error; } } async searchProjects(query: string, workspaceId: string, limit?: number): Promise<ListResult<MotionProject>> { try { mcpLog(LOG_LEVELS.DEBUG, 'Searching projects', { method: 'searchProjects', query, workspaceId, limit }); // Apply search limit to prevent resource exhaustion const effectiveLimit = limit || LIMITS.MAX_SEARCH_RESULTS; const lowerQuery = query.toLowerCase(); const allMatchingProjects: MotionProject[] = []; let aggregateTruncation: TruncationInfo | undefined; // First, search in the specified workspace const { items: primaryProjects, truncation: primaryTruncation } = await this.getProjects(workspaceId, { maxPages: LIMITS.MAX_PAGES, limit: calculateAdaptiveFetchLimit(allMatchingProjects.length, effectiveLimit) }); aggregateTruncation = this.mergeTruncationMetadata(aggregateTruncation, primaryTruncation); const primaryMatches = primaryProjects.filter(project => project.name?.toLowerCase().includes(lowerQuery) || project.description?.toLowerCase().includes(lowerQuery) ); allMatchingProjects.push(...primaryMatches.slice(0, effectiveLimit)); mcpLog(LOG_LEVELS.DEBUG, 'Primary workspace search completed', { method: 'searchProjects', query, primaryWorkspaceId: workspaceId, primaryMatches: primaryMatches.length, keptMatches: allMatchingProjects.length }); // If we haven't reached the limit, search other workspaces if (allMatchingProjects.length < effectiveLimit) { try { const allWorkspaces = await this.getWorkspaces(); const otherWorkspaces = allWorkspaces.filter(w => w.id !== workspaceId); for (const workspace of otherWorkspaces) { if (allMatchingProjects.length >= effectiveLimit) break; try { // Calculate fetch limit before API call (defense-in-depth) const fetchLimit = calculateAdaptiveFetchLimit(allMatchingProjects.length, effectiveLimit); if (fetchLimit <= 0) break; mcpLog(LOG_LEVELS.DEBUG, 'Searching additional workspace for projects', { method: 'searchProjects', query, searchingWorkspaceId: workspace.id, searchingWorkspaceName: workspace.name, remainingNeeded: effectiveLimit - allMatchingProjects.length }); const { items: workspaceProjects, truncation: wsTruncation } = await this.getProjects(workspace.id, { maxPages: LIMITS.MAX_PAGES, limit: fetchLimit }); aggregateTruncation = this.mergeTruncationMetadata(aggregateTruncation, wsTruncation); const workspaceMatches = workspaceProjects.filter(project => project.name?.toLowerCase().includes(lowerQuery) || project.description?.toLowerCase().includes(lowerQuery) ); // Only add as many as we still need const remaining = effectiveLimit - allMatchingProjects.length; allMatchingProjects.push(...workspaceMatches.slice(0, remaining)); if (workspaceMatches.length > 0) { mcpLog(LOG_LEVELS.DEBUG, 'Found additional matches in workspace', { method: 'searchProjects', query, workspaceId: workspace.id, workspaceName: workspace.name, matches: workspaceMatches.length, keptMatches: Math.min(workspaceMatches.length, remaining) }); } } catch (workspaceError: unknown) { // Log error but continue searching other workspaces mcpLog(LOG_LEVELS.WARN, 'Failed to search workspace for projects', { method: 'searchProjects', query, workspaceId: workspace.id, workspaceName: workspace.name, error: getErrorMessage(workspaceError) }); } } } catch (workspaceListError: unknown) { mcpLog(LOG_LEVELS.WARN, 'Failed to get workspace list for cross-workspace search', { method: 'searchProjects', query, error: getErrorMessage(workspaceListError) }); } } // Results are already limited during collection, no need to slice again mcpLog(LOG_LEVELS.INFO, 'Project search completed across all workspaces', { method: 'searchProjects', query, returnedResults: allMatchingProjects.length, limit: effectiveLimit }); if (aggregateTruncation) { aggregateTruncation.returnedCount = allMatchingProjects.length; } return { items: allMatchingProjects, truncation: aggregateTruncation }; } catch (error: unknown) { mcpLog(LOG_LEVELS.ERROR, 'Failed to search projects', { method: 'searchProjects', query, error: getErrorMessage(error) }); throw error; } }