import type { FastMCP } from 'fastmcp'; import { z } from 'zod'; import { agentBlocks, getController } from '../controller'; import { assertConfiguredTeam } from '../utils/teamConfig'; import { jsonTextContent, taskWriteResult, slimTask } from '../utils/format'; /** stripAgentBlocks from canonical agentBlocks module — single source of truth for the tag format. */ const stripAgentBlocksFn = (text: string): string => agentBlocks.stripAgentBlocks(text); const toolContextSchema = { teamName: z.string().min(1), claudeDir: z.string().min(1).optional(), }; const ALWAYS_LOAD_META = { 'anthropic/alwaysLoad': true, } as const; const relationshipTypeSchema = z.enum(['blocked-by', 'blocks', 'related']); const inventoryTaskStatusSchema = z.enum(['pending', 'in_progress', 'completed']); const reviewStateSchema = z.enum(['none', 'review', 'needsFix', 'approved']); const inventoryKanbanColumnSchema = z.enum(['review', 'approved']); const DEFAULT_TASK_LIST_LIMIT = 50; const MAX_TASK_LIST_LIMIT = 200; function normalizeTaskListLimit(limit: number | undefined): number { if (limit == null) { return DEFAULT_TASK_LIST_LIMIT; } return Math.min(Math.max(1, Math.floor(limit)), MAX_TASK_LIST_LIMIT); } /** Allowed message source types for task_create_from_message provenance. Fail closed — only explicit user-originated sources. */ const USER_ORIGINATED_SOURCES = new Set(['user_sent']); /** * Shared payload builder for task_create and task_create_from_message. * * Both tools MUST stay semantically aligned — any new field added to task_create * that also applies to message-derived tasks must be added here, not duplicated. * Do not turn this into a repo-wide abstraction; keep it local to MCP tools. */ function buildCreateTaskPayload(params: { subject: string; description?: string; owner?: string; createdBy?: string; from?: string; blockedBy?: string[]; related?: string[]; prompt?: string; startImmediately?: boolean; sourceMessageId?: string; sourceMessage?: Record; }): Record { return { subject: params.subject, ...(params.description ? { description: params.description } : {}), ...(params.owner ? { owner: params.owner } : {}), ...(params.createdBy ? { createdBy: params.createdBy } : {}), ...(!params.createdBy && params.from ? { from: params.from } : {}), ...(params.blockedBy?.length ? { 'blocked-by': params.blockedBy.join(',') } : {}), ...(params.related?.length ? { related: params.related.join(',') } : {}), ...(params.prompt ? { prompt: params.prompt } : {}), ...(params.startImmediately !== undefined ? { startImmediately: params.startImmediately } : {}), ...(params.sourceMessageId ? { sourceMessageId: params.sourceMessageId } : {}), ...(params.sourceMessage ? { sourceMessage: params.sourceMessage } : {}), }; } export function registerTaskTools(server: Pick) { server.addTool({ name: 'task_create', description: 'Create a team task', parameters: z.object({ ...toolContextSchema, subject: z.string().min(1), description: z.string().optional(), owner: z.string().optional(), createdBy: z.string().optional(), from: z.string().optional(), blockedBy: z.array(z.string().min(1)).optional(), related: z.array(z.string().min(1)).optional(), prompt: z.string().optional(), startImmediately: z.boolean().optional(), }), execute: async ({ teamName, claudeDir, subject, description, owner, createdBy, from, blockedBy, related, prompt, startImmediately, }) => { assertConfiguredTeam(teamName, claudeDir); const controller = getController(teamName, claudeDir); return await Promise.resolve( jsonTextContent( controller.tasks.createTask( buildCreateTaskPayload({ subject, description, owner, createdBy, from, blockedBy, related, prompt, startImmediately, }) ) ) ); }, }); /* * task_create_from_message — creates a task from an exact persisted user message. * * This is NOT a heuristic "current context" resolver. It requires an exact messageId * that points to a persisted row in sentMessages.json or an inbox file. * Must reject relay copies, non-user sources, and ambiguous matches. * Must not auto-generate subject or infer importState from attachments. */ server.addTool({ name: 'task_create_from_message', description: 'Create a task from a persisted user message. Resolves the message by exact messageId, builds sanitized provenance, and creates the task through the canonical path.', parameters: z.object({ ...toolContextSchema, messageId: z.string().min(1), subject: z.string().min(1), description: z.string().optional(), owner: z.string().optional(), createdBy: z.string().optional(), blockedBy: z.array(z.string().min(1)).optional(), related: z.array(z.string().min(1)).optional(), prompt: z.string().optional(), startImmediately: z.boolean().optional(), }), execute: async ({ teamName, claudeDir, messageId, subject, description, owner, createdBy, blockedBy, related, prompt, startImmediately, }) => { assertConfiguredTeam(teamName, claudeDir); const controller = getController(teamName, claudeDir); // 1. Lookup message by exact messageId let message: Record; try { ({ message } = controller.messages.lookupMessage(messageId)); } catch (error) { if (error instanceof Error && error.message.startsWith('Message not found:')) { throw new Error( `${error.message}. task_create_from_message only works with the explicit User MessageId shown in the relay prompt for a user_sent message. Do not use teammate inbox ids or guessed ids.` ); } throw error; } // 2. Reject if message source is not user-originated const source = typeof message.source === 'string' ? message.source : ''; if (!USER_ORIGINATED_SOURCES.has(source)) { throw new Error( `Message source "${source}" is not user-originated. task_create_from_message only accepts explicit user_sent messages from the relay prompt. For teammate, system, or cross-team messages, use task_create instead.` ); } // 3. Reject relay copies explicitly if (typeof message.relayOfMessageId === 'string' && message.relayOfMessageId.trim()) { throw new Error( 'Cannot create task from a relay copy. Use the original user_sent message and its explicit User MessageId from the relay prompt instead.' ); } // 4. Build sanitized source snapshot const rawText = typeof message.text === 'string' ? message.text : ''; const sanitizedText = stripAgentBlocksFn(rawText); const sourceMessage: Record = { text: sanitizedText, from: typeof message.from === 'string' ? message.from : 'unknown', timestamp: typeof message.timestamp === 'string' ? message.timestamp : '', ...(source ? { source } : {}), }; // Preserve attachment metadata — filePath included when available if (Array.isArray(message.attachments) && message.attachments.length > 0) { sourceMessage.attachments = (message.attachments as Record[]) .filter( (a) => a && typeof a === 'object' && typeof a.id === 'string' && typeof a.filename === 'string' ) .map((a) => ({ id: String(a.id), filename: String(a.filename), mimeType: typeof a.mimeType === 'string' ? a.mimeType : '', size: typeof a.size === 'number' ? a.size : 0, ...(typeof a.filePath === 'string' ? { filePath: a.filePath } : {}), })); } // 5. Forward into canonical create-task path return await Promise.resolve( jsonTextContent( controller.tasks.createTask( buildCreateTaskPayload({ subject, description, owner, createdBy, blockedBy, related, prompt, startImmediately, sourceMessageId: messageId, sourceMessage, }) ) ) ); }, }); server.addTool({ name: 'task_get', description: 'Get a task by id. Response includes:\n' + '- sourceMessage.attachments: from original user message (filePath = absolute path to file on disk, use Read tool to view)\n' + '- attachments: files attached to the task (filePath = absolute path, use Read tool to view)\n' + '- comments[].attachments: files on comments (filePath = absolute path, use Read tool to view)', parameters: z.object({ ...toolContextSchema, taskId: z.string().min(1), }), execute: async ({ teamName, claudeDir, taskId }) => { assertConfiguredTeam(teamName, claudeDir); return await Promise.resolve( jsonTextContent(getController(teamName, claudeDir).tasks.getTask(taskId)) ); }, }); server.addTool({ name: 'task_get_comment', description: 'Get a single task comment by id. Returns the comment object and minimal task context (id, displayId, subject, status, owner).', parameters: z.object({ ...toolContextSchema, taskId: z.string().min(1), commentId: z.string().min(1), }), execute: async ({ teamName, claudeDir, taskId, commentId }) => { assertConfiguredTeam(teamName, claudeDir); return await Promise.resolve( jsonTextContent(getController(teamName, claudeDir).tasks.getTaskComment(taskId, commentId)) ); }, }); server.addTool({ name: 'task_list', description: 'List compact active task inventory/search rows for a team. Deleted tasks are excluded. Use it to browse, filter, and drill into inventory, not as a primary working queue. Defaults to 50 rows and caps at 200 rows; use filters or a smaller limit to narrow results. Supports stable conjunctive filters for owner, active status, reviewState, review overlay column, and task relationships.', parameters: z.object({ ...toolContextSchema, owner: z.string().min(1).optional(), status: inventoryTaskStatusSchema.optional(), reviewState: reviewStateSchema.optional(), kanbanColumn: inventoryKanbanColumnSchema.optional(), relatedTo: z.string().min(1).optional(), blockedBy: z.string().min(1).optional(), limit: z.number().int().positive().optional(), }), execute: async ({ teamName, claudeDir, owner, status, reviewState, kanbanColumn, relatedTo, blockedBy, limit, }) => { assertConfiguredTeam(teamName, claudeDir); return await Promise.resolve( jsonTextContent( getController(teamName, claudeDir).tasks.listTaskInventory({ ...(owner ? { owner } : {}), ...(status ? { status } : {}), ...(reviewState ? { reviewState } : {}), ...(kanbanColumn ? { kanbanColumn } : {}), ...(relatedTo ? { relatedTo } : {}), ...(blockedBy ? { blockedBy } : {}), limit: normalizeTaskListLimit(limit), }) ) ); }, }); server.addTool({ name: 'task_set_status', description: 'Set task work status', parameters: z.object({ ...toolContextSchema, taskId: z.string().min(1), status: z.enum(['pending', 'in_progress', 'completed', 'deleted']), actor: z.string().optional(), }), execute: async ({ teamName, claudeDir, taskId, status, actor }) => { assertConfiguredTeam(teamName, claudeDir); return await Promise.resolve( jsonTextContent( slimTask( getController(teamName, claudeDir).tasks.setTaskStatus(taskId, status, actor) as Record< string, unknown > ) ) ); }, }); server.addTool({ name: 'task_restore', description: 'Restore a deleted task back to pending work state', parameters: z.object({ ...toolContextSchema, taskId: z.string().min(1), actor: z.string().optional(), }), execute: async ({ teamName, claudeDir, taskId, actor }) => { assertConfiguredTeam(teamName, claudeDir); return await Promise.resolve( jsonTextContent( slimTask( getController(teamName, claudeDir).tasks.restoreTask(taskId, actor) as Record< string, unknown > ) ) ); }, }); server.addTool({ name: 'task_start', description: 'Mark task as in progress', parameters: z.object({ ...toolContextSchema, taskId: z.string().min(1), actor: z.string().optional(), }), execute: async ({ teamName, claudeDir, taskId, actor }) => { assertConfiguredTeam(teamName, claudeDir); return await Promise.resolve( jsonTextContent( slimTask( getController(teamName, claudeDir).tasks.startTask(taskId, actor) as Record< string, unknown > ) ) ); }, }); server.addTool({ name: 'task_complete', description: 'Mark task as completed', parameters: z.object({ ...toolContextSchema, taskId: z.string().min(1), actor: z.string().optional(), }), execute: async ({ teamName, claudeDir, taskId, actor }) => { assertConfiguredTeam(teamName, claudeDir); return await Promise.resolve( jsonTextContent( slimTask( getController(teamName, claudeDir).tasks.completeTask(taskId, actor) as Record< string, unknown > ) ) ); }, }); server.addTool({ name: 'task_set_owner', description: 'Assign, reassign, or clear task owner', parameters: z.object({ ...toolContextSchema, taskId: z.string().min(1), owner: z.string().nullable(), actor: z.string().optional(), }), execute: async ({ teamName, claudeDir, taskId, owner, actor }) => { assertConfiguredTeam(teamName, claudeDir); return await Promise.resolve( jsonTextContent( slimTask( getController(teamName, claudeDir).tasks.setTaskOwner(taskId, owner, actor) as Record< string, unknown > ) ) ); }, }); server.addTool({ name: 'task_add_comment', description: 'Add task comment. from is required and must be your configured teammate name; user/system are reserved for app-owned writes.', parameters: z.object({ ...toolContextSchema, taskId: z.string().min(1), text: z.string().min(1), from: z.string().min(1), }), execute: async ({ teamName, claudeDir, taskId, text, from }) => { assertConfiguredTeam(teamName, claudeDir); return await Promise.resolve( jsonTextContent( taskWriteResult( getController(teamName, claudeDir).tasks.addTaskComment(taskId, { text, ...(from ? { from } : {}), }) as Record ) ) ); }, }); server.addTool({ name: 'task_attach_file', description: 'Attach a file to a task. Returns attachment metadata with filePath for future access via Read tool.', parameters: z.object({ ...toolContextSchema, taskId: z.string().min(1), filePath: z.string().min(1), mode: z.enum(['copy', 'link']).optional(), filename: z.string().optional(), mimeType: z.string().optional(), noFallback: z.boolean().optional(), }), execute: async ({ teamName, claudeDir, taskId, filePath, mode, filename, mimeType, noFallback, }) => { assertConfiguredTeam(teamName, claudeDir); return await Promise.resolve( jsonTextContent( taskWriteResult( getController(teamName, claudeDir).tasks.attachTaskFile(taskId, { file: filePath, ...(mode ? { mode } : {}), ...(filename ? { filename } : {}), ...(mimeType ? { 'mime-type': mimeType } : {}), ...(noFallback ? { 'no-fallback': true } : {}), }) as Record ) ) ); }, }); server.addTool({ name: 'task_attach_comment_file', description: 'Attach a file to a task comment. Returns attachment metadata with filePath for future access via Read tool.', parameters: z.object({ ...toolContextSchema, taskId: z.string().min(1), commentId: z.string().min(1), filePath: z.string().min(1), mode: z.enum(['copy', 'link']).optional(), filename: z.string().optional(), mimeType: z.string().optional(), noFallback: z.boolean().optional(), }), execute: async ({ teamName, claudeDir, taskId, commentId, filePath, mode, filename, mimeType, noFallback, }) => { assertConfiguredTeam(teamName, claudeDir); return await Promise.resolve( jsonTextContent( taskWriteResult( getController(teamName, claudeDir).tasks.attachCommentFile(taskId, commentId, { file: filePath, ...(mode ? { mode } : {}), ...(filename ? { filename } : {}), ...(mimeType ? { 'mime-type': mimeType } : {}), ...(noFallback ? { 'no-fallback': true } : {}), }) as Record ) ) ); }, }); server.addTool({ name: 'task_set_clarification', description: 'Set or clear task clarification state', parameters: z.object({ ...toolContextSchema, taskId: z.string().min(1), value: z.enum(['lead', 'user', 'clear']), }), execute: async ({ teamName, claudeDir, taskId, value }) => { assertConfiguredTeam(teamName, claudeDir); return await Promise.resolve( jsonTextContent( slimTask( getController(teamName, claudeDir).tasks.setNeedsClarification( taskId, value === 'clear' ? null : value ) as Record ) ) ); }, }); server.addTool({ name: 'task_link', description: 'Link tasks by blockedBy, blocks, or related relationship', parameters: z.object({ ...toolContextSchema, taskId: z.string().min(1), targetId: z.string().min(1), relationship: relationshipTypeSchema, }), execute: async ({ teamName, claudeDir, taskId, targetId, relationship }) => { assertConfiguredTeam(teamName, claudeDir); return await Promise.resolve( jsonTextContent( slimTask( getController(teamName, claudeDir).tasks.linkTask( taskId, targetId, relationship ) as Record ) ) ); }, }); server.addTool({ name: 'task_unlink', description: 'Remove task relationship link', parameters: z.object({ ...toolContextSchema, taskId: z.string().min(1), targetId: z.string().min(1), relationship: relationshipTypeSchema, }), execute: async ({ teamName, claudeDir, taskId, targetId, relationship }) => { assertConfiguredTeam(teamName, claudeDir); return await Promise.resolve( jsonTextContent( slimTask( getController(teamName, claudeDir).tasks.unlinkTask( taskId, targetId, relationship ) as Record ) ) ); }, }); server.addTool({ name: 'member_briefing', description: 'Get bootstrap briefing for a team member', _meta: ALWAYS_LOAD_META, parameters: z.object({ ...toolContextSchema, memberName: z.string().min(1), runtimeProvider: z.enum(['native', 'opencode', 'codex']).optional(), includeActiveProcesses: z.boolean().optional(), }), execute: async ({ teamName, claudeDir, memberName, runtimeProvider, includeActiveProcesses, }) => { assertConfiguredTeam(teamName, claudeDir); return { content: [ { type: 'text' as const, text: await getController(teamName, claudeDir).tasks.memberBriefing(memberName, { ...(runtimeProvider ? { runtimeProvider } : {}), ...(includeActiveProcesses !== undefined ? { includeActiveProcesses } : {}), }), }, ], }; }, }); server.addTool({ name: 'task_briefing', description: 'Get formatted task briefing for a member', parameters: z.object({ ...toolContextSchema, memberName: z.string().min(1), }), execute: async ({ teamName, claudeDir, memberName }) => { assertConfiguredTeam(teamName, claudeDir); return { content: [ { type: 'text' as const, text: await getController(teamName, claudeDir).tasks.taskBriefing(memberName), }, ], }; }, }); }