From 919d40b7bc1bba6a90a9da5f1215732fc67a6cd9 Mon Sep 17 00:00:00 2001 From: iliya Date: Sat, 7 Mar 2026 12:01:39 +0200 Subject: [PATCH] chore: remove MCP code (to new build new one from scratch) --- mcp-server/.gitignore | 2 + mcp-server/README.md | 301 ------------------ mcp-server/src/index.ts | 20 -- mcp-server/src/output-parser.ts | 52 --- mcp-server/src/schemas.ts | 115 ------- mcp-server/src/teamctl-runner.ts | 155 --------- mcp-server/src/tools/index.ts | 42 --- mcp-server/src/tools/kanban-move.ts | 45 --- mcp-server/src/tools/kanban-reviewers.ts | 46 --- mcp-server/src/tools/message-send.ts | 39 --- mcp-server/src/tools/review-action.ts | 50 --- mcp-server/src/tools/task-attach.ts | 42 --- mcp-server/src/tools/task-briefing.ts | 32 -- mcp-server/src/tools/task-comment.ts | 34 -- mcp-server/src/tools/task-create.ts | 52 --- mcp-server/src/tools/task-get.ts | 30 -- mcp-server/src/tools/task-link.ts | 51 --- mcp-server/src/tools/task-list.ts | 31 -- mcp-server/src/tools/task-set-owner.ts | 38 --- mcp-server/src/tools/task-set-status.ts | 35 -- mcp-server/test/output-parser.test.ts | 76 ----- mcp-server/test/schemas.test.ts | 220 ------------- mcp-server/test/teamctl-runner.test.ts | 78 ----- mcp-server/test/tools/kanban-move.test.ts | 42 --- .../test/tools/kanban-reviewers.test.ts | 61 ---- mcp-server/test/tools/message-send.test.ts | 48 --- mcp-server/test/tools/register-all.test.ts | 28 -- mcp-server/test/tools/review-action.test.ts | 66 ---- mcp-server/test/tools/task-attach.test.ts | 49 --- mcp-server/test/tools/task-briefing.test.ts | 33 -- mcp-server/test/tools/task-comment.test.ts | 33 -- mcp-server/test/tools/task-create.test.ts | 65 ---- mcp-server/test/tools/task-get.test.ts | 29 -- mcp-server/test/tools/task-link.test.ts | 44 --- mcp-server/test/tools/task-list.test.ts | 29 -- mcp-server/test/tools/task-set-owner.test.ts | 42 --- mcp-server/test/tools/task-set-status.test.ts | 31 -- mcp-server/test/tools/test-helpers.ts | 58 ---- 38 files changed, 2 insertions(+), 2242 deletions(-) create mode 100644 mcp-server/.gitignore delete mode 100644 mcp-server/README.md delete mode 100644 mcp-server/src/index.ts delete mode 100644 mcp-server/src/output-parser.ts delete mode 100644 mcp-server/src/schemas.ts delete mode 100644 mcp-server/src/teamctl-runner.ts delete mode 100644 mcp-server/src/tools/index.ts delete mode 100644 mcp-server/src/tools/kanban-move.ts delete mode 100644 mcp-server/src/tools/kanban-reviewers.ts delete mode 100644 mcp-server/src/tools/message-send.ts delete mode 100644 mcp-server/src/tools/review-action.ts delete mode 100644 mcp-server/src/tools/task-attach.ts delete mode 100644 mcp-server/src/tools/task-briefing.ts delete mode 100644 mcp-server/src/tools/task-comment.ts delete mode 100644 mcp-server/src/tools/task-create.ts delete mode 100644 mcp-server/src/tools/task-get.ts delete mode 100644 mcp-server/src/tools/task-link.ts delete mode 100644 mcp-server/src/tools/task-list.ts delete mode 100644 mcp-server/src/tools/task-set-owner.ts delete mode 100644 mcp-server/src/tools/task-set-status.ts delete mode 100644 mcp-server/test/output-parser.test.ts delete mode 100644 mcp-server/test/schemas.test.ts delete mode 100644 mcp-server/test/teamctl-runner.test.ts delete mode 100644 mcp-server/test/tools/kanban-move.test.ts delete mode 100644 mcp-server/test/tools/kanban-reviewers.test.ts delete mode 100644 mcp-server/test/tools/message-send.test.ts delete mode 100644 mcp-server/test/tools/register-all.test.ts delete mode 100644 mcp-server/test/tools/review-action.test.ts delete mode 100644 mcp-server/test/tools/task-attach.test.ts delete mode 100644 mcp-server/test/tools/task-briefing.test.ts delete mode 100644 mcp-server/test/tools/task-comment.test.ts delete mode 100644 mcp-server/test/tools/task-create.test.ts delete mode 100644 mcp-server/test/tools/task-get.test.ts delete mode 100644 mcp-server/test/tools/task-link.test.ts delete mode 100644 mcp-server/test/tools/task-list.test.ts delete mode 100644 mcp-server/test/tools/task-set-owner.test.ts delete mode 100644 mcp-server/test/tools/task-set-status.test.ts delete mode 100644 mcp-server/test/tools/test-helpers.ts diff --git a/mcp-server/.gitignore b/mcp-server/.gitignore new file mode 100644 index 00000000..763301fc --- /dev/null +++ b/mcp-server/.gitignore @@ -0,0 +1,2 @@ +dist/ +node_modules/ \ No newline at end of file diff --git a/mcp-server/README.md b/mcp-server/README.md deleted file mode 100644 index f6b0d7de..00000000 --- a/mcp-server/README.md +++ /dev/null @@ -1,301 +0,0 @@ -# @claude-team/mcp-server - -**Model Context Protocol (MCP) server for managing Claude Agent Teams kanban board and tasks.** - -Exposes 13 tools so AI agents (Claude, Cursor, or any MCP-compatible client) can create tasks, manage the kanban board, conduct code reviews, and send messages — backed by the same `teamctl.js` CLI that powers the Claude Agent Teams UI desktop app. - ---- - -## Table of Contents - -- [Overview](#overview) -- [Relationship to Claude Agent Teams UI](#relationship-to-claude-agent-teams-ui) -- [Quick start](#quick-start) -- [Prerequisites](#prerequisites) -- [Installation](#installation) -- [Configuration](#configuration) -- [Tools Reference](#tools-reference) -- [Data Storage](#data-storage) -- [Development](#development) -- [Testing](#testing) -- [Troubleshooting](#troubleshooting) -- [License](#license) - ---- - -## Overview - -Implements the [Model Context Protocol](https://modelcontextprotocol.io/) over **stdio**. All operations are delegated to `teamctl.js`, which: - -- Stores task data as JSON under `~/.claude/tasks/{teamName}/` -- Stores team config under `~/.claude/teams/{teamName}/` -- Is installed automatically when you run Claude Agent Teams UI at least once - -### Use cases - -| Scenario | Description | -|----------|-------------| -| **Cursor / Claude Desktop** | Add the server to MCP config so AI assistants can create tasks, assign owners, move cards, and send messages without leaving the chat | -| **Automation scripts** | Programmatic interface to the same task board that Claude Code agents use | -| **Multi-agent workflows** | Multiple AI agents sharing one task board, coordinating via comments and messages | - -### Architecture - -``` -┌─────────────────────┐ stdio ┌──────────────────────┐ spawn ┌─────────────────┐ -│ MCP Client │ ◄────────────► │ @claude-team/ │ ◄────────────► │ teamctl.js │ -│ (Cursor, Claude, │ │ mcp-server │ │ ~/.claude/ │ -│ custom scripts) │ │ (FastMCP + 13 tools) │ │ tools/ │ -└─────────────────────┘ └──────────────────────┘ └─────────────────┘ -``` - ---- - -## Relationship to Claude Agent Teams UI - -| Component | Role | -|-----------|------| -| **Claude Agent Teams UI** | Desktop app (Electron). Visualizes sessions, kanban board, code review, team messaging. Installs `teamctl.js` on first run. | -| **teamctl.js** | CLI at `~/.claude/tools/teamctl.js`. Reads/writes task JSON, manages kanban, inboxes, reviews. Used by both the app and agents. | -| **@claude-team/mcp-server** | MCP server wrapping `teamctl.js` so Cursor, Claude Desktop, and other MCP clients can call the same operations as tools. | - -Agents in Claude Code use `teamctl.js` via Bash. Agents in Cursor or Claude Desktop use this MCP server. Both operate on the same data. - ---- - -## Quick start - -1. Run **Claude Agent Teams UI** at least once (installs `teamctl.js`) -2. Build the MCP server: `cd mcp-server && pnpm install && pnpm build` -3. Add to Cursor MCP config (`~/.cursor/mcp.json`): - -```json -{ - "mcpServers": { - "claude-team-tools": { - "command": "node", - "args": ["/absolute/path/to/claude_agent_teams_ui/mcp-server/dist/index.js"] - } - } -} -``` - -4. Restart Cursor. The 13 tools will appear for the AI assistant. - ---- - -## Prerequisites - -- **Node.js 20+** -- **Claude Agent Teams UI** run at least once (installs `teamctl.js` to `~/.claude/tools/teamctl.js`) - -If `teamctl.js` is missing, the server throws at startup: - -``` -teamctl.js not found at ~/.claude/tools/teamctl.js. -Make sure Claude Agent Teams UI has been run at least once, -or set the TEAMCTL_PATH environment variable. -``` - ---- - -## Installation - -### From the monorepo (development) - -```bash -cd mcp-server -pnpm install -pnpm build -``` - -### As a dependency - -```bash -pnpm add @claude-team/mcp-server -# or -npm install @claude-team/mcp-server -``` - ---- - -## Configuration - -### Cursor - -Add to `~/.cursor/mcp.json` or project `.cursor/mcp.json`: - -```json -{ - "mcpServers": { - "claude-team-tools": { - "command": "node", - "args": ["/path/to/claude_agent_teams_ui/mcp-server/dist/index.js"] - } - } -} -``` - -With global install (`pnpm link` or `npm link`): - -```json -{ - "mcpServers": { - "claude-team-tools": { - "command": "team-mcp-server" - } - } -} -``` - -### Claude Desktop - -Add to `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or equivalent: - -```json -{ - "mcpServers": { - "claude-team-tools": { - "command": "node", - "args": ["/absolute/path/to/mcp-server/dist/index.js"] - } - } -} -``` - -### Environment variables - -| Variable | Description | -|----------|-------------| -| `TEAMCTL_PATH` | Path to `teamctl.js` if not at `~/.claude/tools/teamctl.js` | - ---- - -## Tools Reference - -All tools require a `team` parameter (team name, folder under `~/.claude/teams/`). - -### Task CRUD - -| Tool | Description | -|------|-------------| -| `task_create` | Create a task. Optional: `owner`, `description`, `blocked_by`, `related`, `status`, `notify`, `from` | -| `task_get` | Get a task by ID. Returns full JSON (status, owner, comments, dependencies, work intervals, history) | -| `task_list` | List all tasks. Returns JSON array (filter internal tasks client-side if needed) | -| `task_set_status` | Set status: `pending` → `in_progress` → `completed` → `deleted`. Records work intervals | -| `task_set_owner` | Assign or unassign owner. Use `owner="clear"` to unassign. Optional: `notify`, `from` | - -### Task collaboration - -| Tool | Description | -|------|-------------| -| `task_comment` | Add a comment. Sends inbox notification to owner (unless commenter is owner). Optional: `from` | -| `task_link` | Link/unlink dependencies. Types: `blocked-by`, `blocks`, `related`. Bidirectional; circular deps rejected | -| `task_briefing` | Human-readable briefing for a member: their assigned tasks vs full board | -| `task_attach` | Attach a file. Modes: `copy`, `link`. MIME auto-detected (PNG, JPEG, GIF, WebP, PDF, ZIP). Max 20 MB | - -### Kanban board - -| Tool | Description | -|------|-------------| -| `kanban_move` | Move to `review` or `approved`, or `clear` from board. Moving to column sets status to `completed` | -| `kanban_reviewers` | Manage reviewers: `list` (JSON array), `add`, `remove` | - -### Code review - -| Tool | Description | -|------|-------------| -| `review_action` | `approve` — mark approved (optional comment, `notify_owner`). `request-changes` — remove from kanban, reset to `in_progress`, notify owner (comment required) | - -### Messaging - -| Tool | Description | -|------|-------------| -| `message_send` | Send inbox message to a member. Optional: `summary`, `from`. Triggers notifications | - ---- - -## Data Storage - -| Location | Contents | -|----------|----------| -| `~/.claude/tasks/{teamName}/` | Task JSON files (`1.json`, `2.json`, …) | -| `~/.claude/teams/{teamName}/` | Team config, kanban reviewers, inboxes | - -Task IDs are numeric (highwatermark). Team and member names must be safe path segments (no `.`, `..`, `/`, `\`, null bytes). - ---- - -## Development - -### Scripts - -| Command | Description | -|---------|-------------| -| `pnpm build` | Build with tsup → `dist/index.js` | -| `pnpm dev` | Run with tsx (no build) | -| `pnpm test` | Run Vitest tests | -| `pnpm test:watch` | Watch mode | -| `pnpm typecheck` | TypeScript check | - -### Project structure - -``` -mcp-server/ -├── src/ -│ ├── index.ts # FastMCP server, registers all tools -│ ├── teamctl-runner.ts # Spawns teamctl.js subprocess -│ ├── output-parser.ts # Parses JSON / "OK ..." from teamctl stdout -│ ├── schemas.ts # Zod schemas (team, taskId, member, etc.) -│ └── tools/ -│ ├── index.ts # registerAllTools() -│ ├── task-create.ts -│ ├── task-get.ts -│ ├── ... -│ └── message-send.ts -├── test/ -│ ├── tools/ # Per-tool tests -│ ├── teamctl-runner.test.ts -│ ├── output-parser.test.ts -│ └── schemas.test.ts -├── package.json -├── tsup.config.ts -└── vitest.config.ts -``` - -### Adding a new tool - -1. Create `src/tools/your-tool.ts` with `register(server, runner)` -2. Add to `ALL_TOOLS` in `src/tools/index.ts` -3. Add Zod parameters and map to `teamctl` CLI args -4. Use `parseJsonOutput`, `parseOkOutput`, or `parseTextOutput` from `output-parser.ts` -5. Add tests in `test/tools/your-tool.test.ts` - ---- - -## Testing - -Tests use a mock `ITeamctlRunner` (no real `teamctl.js` required): - -```bash -pnpm test -``` - -For integration tests with real `teamctl.js`, use the main app: `test/main/services/team/teamctl.test.ts`. - ---- - -## Troubleshooting - -| Issue | Solution | -|-------|----------| -| **teamctl.js not found** | Run Claude Agent Teams UI at least once, or set `TEAMCTL_PATH` | -| **Invalid team/member name** | Names: 1–128 chars, no `.`, `..`, `/`, `\`, null bytes | -| **MCP client not discovering tools** | Check server starts without errors; use absolute path in config; some clients require it | -| **Timeout errors** | Default 10s. Increase via `TeamctlRunnerOptions.timeoutMs` (code change) | - ---- - -## License - -Same as the parent project: [AGPL-3.0](../LICENSE). diff --git a/mcp-server/src/index.ts b/mcp-server/src/index.ts deleted file mode 100644 index a30a5b06..00000000 --- a/mcp-server/src/index.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { FastMCP } from 'fastmcp'; -import { TeamctlRunner } from './teamctl-runner.js'; -import { registerAllTools } from './tools/index.js'; - -const server = new FastMCP({ - name: 'claude-team-tools', - version: '1.0.0', - instructions: `MCP server for managing Claude Agent Teams kanban board and tasks. - -Provides 13 tools for task CRUD, kanban board management, code reviews, and team messaging. -All operations are backed by teamctl.js — the battle-tested CLI tool from Claude Agent Teams UI. - -Data is stored as JSON files in ~/.claude/tasks/{teamName}/ and ~/.claude/teams/{teamName}/.`, -}); - -const runner = new TeamctlRunner(); - -registerAllTools(server, runner); - -server.start({ transportType: 'stdio' }); diff --git a/mcp-server/src/output-parser.ts b/mcp-server/src/output-parser.ts deleted file mode 100644 index e2b7c1a5..00000000 --- a/mcp-server/src/output-parser.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * Parses teamctl stdout into structured results. - * - * teamctl outputs in three formats: - * 1. JSON — task create/get/list, attach, message send, kanban reviewers list - * 2. "OK ..." text — status changes, comments, links, kanban moves, reviews - * 3. Plain text — task briefing (multi-line human-readable report) - */ - -/** Parse JSON from teamctl stdout (task create/get/list, attach, message send) */ -export function parseJsonOutput(stdout: string): T { - const trimmed = stdout.trim(); - if (!trimmed) { - throw new Error('Empty output from teamctl (expected JSON)'); - } - try { - return JSON.parse(trimmed) as T; - } catch { - throw new Error( - `Failed to parse teamctl JSON output: ${trimmed.slice(0, 200)}`, - ); - } -} - -/** Parse "OK ..." acknowledgment lines from teamctl */ -export function parseOkOutput(stdout: string): string { - const trimmed = stdout.trim(); - if (trimmed.startsWith('OK ')) { - return trimmed.slice(3); // Strip "OK " prefix, keep the rest - } - // Some commands output just "OK\n" - if (trimmed === 'OK') { - return 'OK'; - } - // Return as-is if format is unexpected — don't throw - return trimmed; -} - -/** Return plain text as-is (briefing, help output) */ -export function parseTextOutput(stdout: string): string { - return stdout.trim(); -} - -/** - * Format teamctl stderr into a user-friendly error message. - * teamctl writes errors to stderr via `die(message)` and exits with code 1. - */ -export function formatError(stderr: string, stdout: string): string { - const msg = stderr.trim() || stdout.trim(); - if (!msg) return 'Unknown teamctl error'; - return msg; -} diff --git a/mcp-server/src/schemas.ts b/mcp-server/src/schemas.ts deleted file mode 100644 index cc808e0b..00000000 --- a/mcp-server/src/schemas.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { z } from 'zod'; -import { sep, isAbsolute } from 'node:path'; - -// --------------------------------------------------------------------------- -// Identifiers -// --------------------------------------------------------------------------- - -/** - * Matches teamctl's `isSafePathSegment()`: - * rejects empty, '.', '..', and strings containing '/', '\\', '\0', or '..' - */ -const safePathSegment = (label: string) => - z - .string() - .min(1) - .max(128) - .refine( - (v) => - v.trim().length > 0 && - v !== '.' && - v !== '..' && - !v.includes('/') && - !v.includes('\\') && - !v.includes('\0') && - !v.includes('..'), - { message: `Invalid ${label}: must be a safe path segment` }, - ); - -/** Team name — folder inside `~/.claude/teams/` */ -export const teamNameSchema = safePathSegment('team name').describe( - 'Team name (folder in ~/.claude/teams/)', -); - -/** Numeric task ID produced by teamctl's highwatermark counter */ -export const taskIdSchema = z - .string() - .regex(/^\d{1,10}$/, 'Task ID must be a positive integer (e.g. "1", "42")') - .describe('Numeric task ID'); - -/** Team member name — folder inside inboxes, safe path segment */ -export const memberNameSchema = safePathSegment('member name').describe( - 'Team member name', -); - -// --------------------------------------------------------------------------- -// Enums — match teamctl's normalizeStatus / normalizeColumn / etc. -// --------------------------------------------------------------------------- - -export const taskStatusSchema = z.enum([ - 'pending', - 'in_progress', - 'completed', - 'deleted', -]); - -export const kanbanColumnSchema = z - .enum(['review', 'approved']) - .describe('Kanban column to move task to'); - -export const clarificationSchema = z - .enum(['lead', 'user', 'clear']) - .describe('Who needs to clarify: lead, user, or clear the flag'); - -export const linkTypeSchema = z - .enum(['blocked-by', 'blocks', 'related']) - .describe('Relationship type between tasks'); - -export const linkOperationSchema = z.enum(['link', 'unlink']); - -export const reviewDecisionSchema = z.enum(['approve', 'request-changes']); - -export const reviewerOperationSchema = z.enum(['list', 'add', 'remove']); - -// --------------------------------------------------------------------------- -// Composite schemas -// --------------------------------------------------------------------------- - -/** Comma-separated task IDs sent as a single CLI argument */ -export const taskIdsArraySchema = z - .array(taskIdSchema) - .describe('Array of task IDs (e.g. ["1", "3"])'); - -// --------------------------------------------------------------------------- -// File / attachment schemas — defence-in-depth for CLI arguments -// --------------------------------------------------------------------------- - -/** Absolute file path without traversal sequences */ -export const filePathSchema = z - .string() - .min(1) - .refine((p) => isAbsolute(p), { message: 'Path must be absolute' }) - .refine((p) => !p.split(sep).includes('..'), { - message: 'Path must not contain traversal sequences (..)', - }) - .refine((p) => !p.includes('\0'), { - message: 'Path must not contain null bytes', - }); - -/** Safe filename — no path separators, no null bytes, reasonable length */ -export const safeFilenameSchema = z - .string() - .min(1) - .max(255) - .refine( - (f) => !f.includes('/') && !f.includes('\\') && !f.includes('\0'), - { message: 'Filename must not contain path separators or null bytes' }, - ); - -/** MIME type — standard type/subtype format */ -export const mimeTypeSchema = z - .string() - .regex( - /^[a-zA-Z0-9][a-zA-Z0-9!#$&\-^_]*\/[a-zA-Z0-9][a-zA-Z0-9!#$&\-^_.+]*$/, - 'Invalid MIME type format (expected type/subtype)', - ); diff --git a/mcp-server/src/teamctl-runner.ts b/mcp-server/src/teamctl-runner.ts deleted file mode 100644 index d9892106..00000000 --- a/mcp-server/src/teamctl-runner.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { spawn } from 'node:child_process'; -import { existsSync } from 'node:fs'; -import { homedir } from 'node:os'; -import { join } from 'node:path'; - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -export interface TeamctlResult { - stdout: string; - stderr: string; - exitCode: number; -} - -export interface ITeamctlRunner { - execute(args: string[]): Promise; -} - -export interface TeamctlRunnerOptions { - /** Explicit path to teamctl.js. Falls back to TEAMCTL_PATH env, then default. */ - teamctlPath?: string; - /** Max concurrent subprocess calls (default: 5) */ - maxConcurrent?: number; - /** Subprocess timeout in ms (default: 10 000) */ - timeoutMs?: number; -} - -// --------------------------------------------------------------------------- -// Semaphore — limits concurrent subprocess spawns -// --------------------------------------------------------------------------- - -class Semaphore { - private current = 0; - private queue: Array<() => void> = []; - - constructor(private readonly max: number) {} - - async acquire(): Promise { - if (this.current < this.max) { - this.current++; - return; - } - return new Promise((resolve) => { - this.queue.push(() => { - this.current++; - resolve(); - }); - }); - } - - release(): void { - this.current--; - const next = this.queue.shift(); - if (next) next(); - } -} - -// --------------------------------------------------------------------------- -// TeamctlRunner -// --------------------------------------------------------------------------- - -export class TeamctlRunner implements ITeamctlRunner { - readonly teamctlPath: string; - private readonly timeoutMs: number; - private readonly semaphore: Semaphore; - - constructor(options?: TeamctlRunnerOptions) { - this.teamctlPath = resolveTeamctlPath(options?.teamctlPath); - this.timeoutMs = options?.timeoutMs ?? 10_000; - this.semaphore = new Semaphore(options?.maxConcurrent ?? 5); - - // Fail fast if teamctl.js doesn't exist - if (!existsSync(this.teamctlPath)) { - throw new Error( - `teamctl.js not found at ${this.teamctlPath}. ` + - 'Make sure Claude Agent Teams UI has been run at least once, ' + - 'or set the TEAMCTL_PATH environment variable.', - ); - } - } - - async execute(args: string[]): Promise { - await this.semaphore.acquire(); - try { - return await this.spawn(args); - } finally { - this.semaphore.release(); - } - } - - private spawn(args: string[]): Promise { - return new Promise((resolve, reject) => { - const child = spawn('node', [this.teamctlPath, ...args], { - stdio: ['ignore', 'pipe', 'pipe'], - timeout: this.timeoutMs, - env: { ...process.env }, - }); - - const stdoutChunks: Buffer[] = []; - const stderrChunks: Buffer[] = []; - - child.stdout.on('data', (chunk: Buffer) => stdoutChunks.push(chunk)); - child.stderr.on('data', (chunk: Buffer) => stderrChunks.push(chunk)); - - let settled = false; - - child.on('error', (err) => { - if (!settled) { - settled = true; - reject(new Error(`Failed to spawn teamctl: ${err.message}`)); - } - }); - - child.on('close', (code, signal) => { - if (settled) return; - settled = true; - - const stdout = Buffer.concat(stdoutChunks).toString('utf-8'); - const stderr = Buffer.concat(stderrChunks).toString('utf-8'); - - if (signal === 'SIGTERM') { - const partial = stdout.slice(0, 500) || stderr.slice(0, 500); - reject( - new Error( - `teamctl timed out after ${this.timeoutMs}ms` + - (partial ? `. Partial output: ${partial}` : ''), - ), - ); - return; - } - - resolve({ - stdout, - stderr, - exitCode: code ?? 1, - }); - }); - }); - } -} - -// --------------------------------------------------------------------------- -// Path resolution -// --------------------------------------------------------------------------- - -function resolveTeamctlPath(explicit?: string): string { - if (explicit) return explicit; - - const fromEnv = process.env['TEAMCTL_PATH']; - if (fromEnv) return fromEnv; - - // Default: ~/.claude/tools/teamctl.js - return join(homedir(), '.claude', 'tools', 'teamctl.js'); -} diff --git a/mcp-server/src/tools/index.ts b/mcp-server/src/tools/index.ts deleted file mode 100644 index 6a4da6f9..00000000 --- a/mcp-server/src/tools/index.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { FastMCP } from 'fastmcp'; -import type { ITeamctlRunner } from '../teamctl-runner.js'; - -import { register as taskCreate } from './task-create.js'; -import { register as taskSetStatus } from './task-set-status.js'; -import { register as taskSetOwner } from './task-set-owner.js'; -import { register as taskGet } from './task-get.js'; -import { register as taskList } from './task-list.js'; -import { register as taskComment } from './task-comment.js'; -import { register as taskLink } from './task-link.js'; -import { register as taskBriefing } from './task-briefing.js'; -import { register as taskAttach } from './task-attach.js'; -import { register as kanbanMove } from './kanban-move.js'; -import { register as kanbanReviewers } from './kanban-reviewers.js'; -import { register as reviewAction } from './review-action.js'; -import { register as messageSend } from './message-send.js'; - -const ALL_TOOLS = [ - taskCreate, - taskSetStatus, - taskSetOwner, - taskGet, - taskList, - taskComment, - taskLink, - taskBriefing, - taskAttach, - kanbanMove, - kanbanReviewers, - reviewAction, - messageSend, -] as const; - -/** - * Register all 13 MCP tools with the server. - * Each tool wraps a teamctl CLI command via the runner. - */ -export function registerAllTools(server: FastMCP, runner: ITeamctlRunner): void { - for (const register of ALL_TOOLS) { - register(server, runner); - } -} diff --git a/mcp-server/src/tools/kanban-move.ts b/mcp-server/src/tools/kanban-move.ts deleted file mode 100644 index 565a836f..00000000 --- a/mcp-server/src/tools/kanban-move.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { z } from 'zod'; -import { UserError } from 'fastmcp'; -import type { FastMCP } from 'fastmcp'; -import type { ITeamctlRunner } from '../teamctl-runner.js'; -import { parseOkOutput } from '../output-parser.js'; -import { teamNameSchema, taskIdSchema, kanbanColumnSchema } from '../schemas.js'; - -export function register(server: FastMCP, runner: ITeamctlRunner): void { - server.addTool({ - name: 'kanban_move', - description: `Move a task to a kanban column or clear it from the kanban board. - -Columns: "review" (awaiting code review) or "approved" (review passed). -Use operation "clear" to remove a task from the kanban board (returns to status-based display). -Moving to a kanban column also sets the task status to "completed".`, - annotations: { - readOnlyHint: false, - destructiveHint: false, - }, - parameters: z.object({ - team: teamNameSchema, - task_id: taskIdSchema, - operation: z.enum(['set-column', 'clear']).describe('"set-column" to move, "clear" to remove from kanban'), - column: kanbanColumnSchema.optional().describe('Target column (required for set-column)'), - }), - execute: async (args) => { - let cliArgs: string[]; - - if (args.operation === 'set-column') { - if (!args.column) { - throw new UserError('column is required when operation is "set-column"'); - } - cliArgs = ['--team', args.team, 'kanban', 'set-column', args.task_id, args.column]; - } else { - cliArgs = ['--team', args.team, 'kanban', 'clear', args.task_id]; - } - - const result = await runner.execute(cliArgs); - if (result.exitCode !== 0) { - throw new UserError(`Failed to update kanban: ${result.stderr.trim() || result.stdout.trim()}`); - } - return parseOkOutput(result.stdout); - }, - }); -} diff --git a/mcp-server/src/tools/kanban-reviewers.ts b/mcp-server/src/tools/kanban-reviewers.ts deleted file mode 100644 index 03569247..00000000 --- a/mcp-server/src/tools/kanban-reviewers.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { z } from 'zod'; -import { UserError } from 'fastmcp'; -import type { FastMCP } from 'fastmcp'; -import type { ITeamctlRunner } from '../teamctl-runner.js'; -import { parseJsonOutput, parseOkOutput } from '../output-parser.js'; -import { teamNameSchema, memberNameSchema, reviewerOperationSchema } from '../schemas.js'; - -export function register(server: FastMCP, runner: ITeamctlRunner): void { - server.addTool({ - name: 'kanban_reviewers', - description: `Manage the kanban board's reviewer list. - -Operations: -- "list": returns JSON array of reviewer names -- "add": adds a reviewer (name required) -- "remove": removes a reviewer (name required)`, - annotations: { - readOnlyHint: false, - destructiveHint: false, - }, - parameters: z.object({ - team: teamNameSchema, - operation: reviewerOperationSchema.describe('"list", "add", or "remove"'), - name: memberNameSchema.optional().describe('Reviewer name (required for add/remove)'), - }), - execute: async (args) => { - if (args.operation !== 'list' && !args.name) { - throw new UserError(`name is required for "${args.operation}" operation`); - } - - const cliArgs = ['--team', args.team, 'kanban', 'reviewers', args.operation]; - if (args.name) cliArgs.push(args.name); - - const result = await runner.execute(cliArgs); - if (result.exitCode !== 0) { - throw new UserError(`Failed to manage reviewers: ${result.stderr.trim() || result.stdout.trim()}`); - } - - // "list" returns JSON array, "add"/"remove" return "OK ..." text - if (args.operation === 'list') { - return parseJsonOutput(result.stdout); - } - return parseOkOutput(result.stdout); - }, - }); -} diff --git a/mcp-server/src/tools/message-send.ts b/mcp-server/src/tools/message-send.ts deleted file mode 100644 index 0693bd33..00000000 --- a/mcp-server/src/tools/message-send.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { z } from 'zod'; -import { UserError } from 'fastmcp'; -import type { FastMCP } from 'fastmcp'; -import type { ITeamctlRunner } from '../teamctl-runner.js'; -import { parseJsonOutput } from '../output-parser.js'; -import { teamNameSchema, memberNameSchema } from '../schemas.js'; - -export function register(server: FastMCP, runner: ITeamctlRunner): void { - server.addTool({ - name: 'message_send', - description: `Send an inbox message to a team member. Returns delivery confirmation JSON. - -Messages appear in the member's inbox and can trigger notifications. -The "source" field is automatically stripped for security — external callers cannot impersonate system notifications.`, - annotations: { - readOnlyHint: false, - destructiveHint: false, - }, - parameters: z.object({ - team: teamNameSchema, - to: memberNameSchema.describe('Recipient member name'), - text: z.string().min(1).max(10000).describe('Message text'), - summary: z.string().max(200).optional().describe('Short summary for notification preview'), - from: memberNameSchema.optional().describe('Sender name'), - }), - execute: async (args) => { - const cliArgs = ['--team', args.team, 'message', 'send', '--to', args.to, '--text', args.text]; - - if (args.summary) cliArgs.push('--summary', args.summary); - if (args.from) cliArgs.push('--from', args.from); - - const result = await runner.execute(cliArgs); - if (result.exitCode !== 0) { - throw new UserError(`Failed to send message: ${result.stderr.trim() || result.stdout.trim()}`); - } - return parseJsonOutput(result.stdout); - }, - }); -} diff --git a/mcp-server/src/tools/review-action.ts b/mcp-server/src/tools/review-action.ts deleted file mode 100644 index 64bad45d..00000000 --- a/mcp-server/src/tools/review-action.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { z } from 'zod'; -import { UserError } from 'fastmcp'; -import type { FastMCP } from 'fastmcp'; -import type { ITeamctlRunner } from '../teamctl-runner.js'; -import { parseOkOutput } from '../output-parser.js'; -import { teamNameSchema, taskIdSchema, memberNameSchema, reviewDecisionSchema } from '../schemas.js'; - -export function register(server: FastMCP, runner: ITeamctlRunner): void { - server.addTool({ - name: 'review_action', - description: `Approve a task or request changes. - -- "approve": marks the task as approved in kanban, optionally posts a review comment -- "request-changes": removes from kanban, resets status to "in_progress", notifies the task owner with the change request comment`, - annotations: { - readOnlyHint: false, - destructiveHint: false, - }, - parameters: z.object({ - team: teamNameSchema, - task_id: taskIdSchema, - decision: reviewDecisionSchema.describe('"approve" or "request-changes"'), - comment: z.string().max(5000).optional().describe('Review comment (required for request-changes)'), - from: memberNameSchema.optional().describe('Reviewer name'), - notify_owner: z.boolean().optional().describe('Notify the task owner (for approve)'), - }), - execute: async (args) => { - if (args.decision === 'request-changes' && !args.comment) { - throw new UserError('comment is required when requesting changes'); - } - - const cliArgs = ['--team', args.team, 'review', args.decision, args.task_id]; - - // approve uses --note for optional comment; request-changes uses --comment - if (args.decision === 'request-changes' && args.comment) { - cliArgs.push('--comment', args.comment); - } else if (args.decision === 'approve' && args.comment) { - cliArgs.push('--note', args.comment); - } - if (args.from) cliArgs.push('--from', args.from); - if (args.notify_owner) cliArgs.push('--notify-owner'); - - const result = await runner.execute(cliArgs); - if (result.exitCode !== 0) { - throw new UserError(`Failed to ${args.decision}: ${result.stderr.trim() || result.stdout.trim()}`); - } - return parseOkOutput(result.stdout); - }, - }); -} diff --git a/mcp-server/src/tools/task-attach.ts b/mcp-server/src/tools/task-attach.ts deleted file mode 100644 index a0e9830d..00000000 --- a/mcp-server/src/tools/task-attach.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { z } from 'zod'; -import { UserError } from 'fastmcp'; -import type { FastMCP } from 'fastmcp'; -import type { ITeamctlRunner } from '../teamctl-runner.js'; -import { parseJsonOutput } from '../output-parser.js'; -import { teamNameSchema, taskIdSchema, memberNameSchema, filePathSchema, safeFilenameSchema, mimeTypeSchema } from '../schemas.js'; - -export function register(server: FastMCP, runner: ITeamctlRunner): void { - server.addTool({ - name: 'task_attach', - description: `Attach a file to a task. Returns attachment metadata JSON. - -Supports copy (default) or hardlink mode. MIME type is auto-detected from file content (PNG, JPEG, GIF, WebP, PDF, ZIP) with fallback to application/octet-stream. Max file size: 20 MB.`, - annotations: { - readOnlyHint: false, - destructiveHint: false, - }, - parameters: z.object({ - team: teamNameSchema, - task_id: taskIdSchema, - file: filePathSchema.describe('Absolute path to the file to attach'), - filename: safeFilenameSchema.optional().describe('Override stored filename'), - mime_type: mimeTypeSchema.optional().describe('Override MIME type (auto-detected by default)'), - mode: z.enum(['copy', 'link']).optional().describe('Storage mode: copy (default) or hardlink'), - from: memberNameSchema.optional().describe('Uploader name'), - }), - execute: async (args) => { - const cliArgs = ['--team', args.team, 'task', 'attach', args.task_id, '--file', args.file]; - - if (args.filename) cliArgs.push('--filename', args.filename); - if (args.mime_type) cliArgs.push('--mime-type', args.mime_type); - if (args.mode) cliArgs.push('--mode', args.mode); - if (args.from) cliArgs.push('--from', args.from); - - const result = await runner.execute(cliArgs); - if (result.exitCode !== 0) { - throw new UserError(`Failed to attach file: ${result.stderr.trim() || result.stdout.trim()}`); - } - return parseJsonOutput(result.stdout); - }, - }); -} diff --git a/mcp-server/src/tools/task-briefing.ts b/mcp-server/src/tools/task-briefing.ts deleted file mode 100644 index 9273a6d7..00000000 --- a/mcp-server/src/tools/task-briefing.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { z } from 'zod'; -import { UserError } from 'fastmcp'; -import type { FastMCP } from 'fastmcp'; -import type { ITeamctlRunner } from '../teamctl-runner.js'; -import { parseTextOutput } from '../output-parser.js'; -import { teamNameSchema, memberNameSchema } from '../schemas.js'; - -export function register(server: FastMCP, runner: ITeamctlRunner): void { - server.addTool({ - name: 'task_briefing', - description: `Generate a text briefing for a team member showing their assigned tasks vs the team board. - -Returns a human-readable multi-line report. Automatically filters out internal bookkeeping tasks.`, - annotations: { - readOnlyHint: true, - destructiveHint: false, - }, - parameters: z.object({ - team: teamNameSchema, - member: memberNameSchema.describe('Member name to generate briefing for'), - }), - execute: async (args) => { - const cliArgs = ['--team', args.team, 'task', 'briefing', '--for', args.member]; - - const result = await runner.execute(cliArgs); - if (result.exitCode !== 0) { - throw new UserError(`Failed to get briefing: ${result.stderr.trim() || result.stdout.trim()}`); - } - return parseTextOutput(result.stdout); - }, - }); -} diff --git a/mcp-server/src/tools/task-comment.ts b/mcp-server/src/tools/task-comment.ts deleted file mode 100644 index 951ef0a9..00000000 --- a/mcp-server/src/tools/task-comment.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { z } from 'zod'; -import { UserError } from 'fastmcp'; -import type { FastMCP } from 'fastmcp'; -import type { ITeamctlRunner } from '../teamctl-runner.js'; -import { parseOkOutput } from '../output-parser.js'; -import { teamNameSchema, taskIdSchema, memberNameSchema } from '../schemas.js'; - -export function register(server: FastMCP, runner: ITeamctlRunner): void { - server.addTool({ - name: 'task_comment', - description: `Add a comment to a task. Sends an inbox notification to the task owner (unless the commenter is the owner).`, - annotations: { - readOnlyHint: false, - destructiveHint: false, - }, - parameters: z.object({ - team: teamNameSchema, - task_id: taskIdSchema, - text: z.string().min(1).max(10000).describe('Comment text'), - from: memberNameSchema.optional().describe('Author name (skips self-notification)'), - }), - execute: async (args) => { - const cliArgs = ['--team', args.team, 'task', 'comment', args.task_id, '--text', args.text]; - - if (args.from) cliArgs.push('--from', args.from); - - const result = await runner.execute(cliArgs); - if (result.exitCode !== 0) { - throw new UserError(`Failed to add comment: ${result.stderr.trim() || result.stdout.trim()}`); - } - return parseOkOutput(result.stdout); - }, - }); -} diff --git a/mcp-server/src/tools/task-create.ts b/mcp-server/src/tools/task-create.ts deleted file mode 100644 index 5c08e4e7..00000000 --- a/mcp-server/src/tools/task-create.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { z } from 'zod'; -import { UserError } from 'fastmcp'; -import type { FastMCP } from 'fastmcp'; -import type { ITeamctlRunner } from '../teamctl-runner.js'; -import { parseJsonOutput } from '../output-parser.js'; -import { teamNameSchema, memberNameSchema, taskIdsArraySchema } from '../schemas.js'; - -export function register(server: FastMCP, runner: ITeamctlRunner): void { - server.addTool({ - name: 'task_create', - description: `Create a new task in a team's task board. Returns the created task JSON. - -Behavior: -- If owner is set and no blockers → status defaults to "in_progress" -- If blocked_by specified → status defaults to "pending" (even with owner) -- If notify is true, sends an inbox notification to the assigned owner`, - annotations: { - readOnlyHint: false, - destructiveHint: false, - }, - parameters: z.object({ - team: teamNameSchema, - subject: z.string().min(1).max(500).describe('Task title'), - description: z.string().max(5000).optional().describe('Detailed task description'), - owner: memberNameSchema.optional().describe('Assign to a team member'), - blocked_by: taskIdsArraySchema.optional().describe('Task IDs that block this task'), - related: taskIdsArraySchema.optional().describe('Related (non-blocking) task IDs'), - status: z.enum(['pending', 'in_progress']).optional().describe('Initial status override'), - active_form: z.string().max(200).optional().describe('Active form hint for CLI display'), - notify: z.boolean().optional().describe('Send inbox notification to owner'), - from: memberNameSchema.optional().describe('Author name for notifications'), - }), - execute: async (args) => { - const cliArgs = ['--team', args.team, 'task', 'create', '--subject', args.subject]; - - if (args.description) cliArgs.push('--description', args.description); - if (args.owner) cliArgs.push('--owner', args.owner); - if (args.blocked_by?.length) cliArgs.push('--blocked-by', args.blocked_by.join(',')); - if (args.related?.length) cliArgs.push('--related', args.related.join(',')); - if (args.status) cliArgs.push('--status', args.status); - if (args.active_form) cliArgs.push('--activeForm', args.active_form); - if (args.notify) cliArgs.push('--notify'); - if (args.from) cliArgs.push('--from', args.from); - - const result = await runner.execute(cliArgs); - if (result.exitCode !== 0) { - throw new UserError(`Failed to create task: ${result.stderr.trim() || result.stdout.trim()}`); - } - return parseJsonOutput(result.stdout); - }, - }); -} diff --git a/mcp-server/src/tools/task-get.ts b/mcp-server/src/tools/task-get.ts deleted file mode 100644 index cf56ec6d..00000000 --- a/mcp-server/src/tools/task-get.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { z } from 'zod'; -import { UserError } from 'fastmcp'; -import type { FastMCP } from 'fastmcp'; -import type { ITeamctlRunner } from '../teamctl-runner.js'; -import { parseJsonOutput } from '../output-parser.js'; -import { teamNameSchema, taskIdSchema } from '../schemas.js'; - -export function register(server: FastMCP, runner: ITeamctlRunner): void { - server.addTool({ - name: 'task_get', - description: `Get a single task by its ID. Returns the full task JSON including status, owner, comments, dependencies, work intervals, and status history.`, - annotations: { - readOnlyHint: true, - destructiveHint: false, - }, - parameters: z.object({ - team: teamNameSchema, - task_id: taskIdSchema, - }), - execute: async (args) => { - const cliArgs = ['--team', args.team, 'task', 'get', args.task_id]; - - const result = await runner.execute(cliArgs); - if (result.exitCode !== 0) { - throw new UserError(`Failed to get task: ${result.stderr.trim() || result.stdout.trim()}`); - } - return parseJsonOutput(result.stdout); - }, - }); -} diff --git a/mcp-server/src/tools/task-link.ts b/mcp-server/src/tools/task-link.ts deleted file mode 100644 index 0d43021c..00000000 --- a/mcp-server/src/tools/task-link.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { z } from 'zod'; -import { UserError } from 'fastmcp'; -import type { FastMCP } from 'fastmcp'; -import type { ITeamctlRunner } from '../teamctl-runner.js'; -import { parseOkOutput } from '../output-parser.js'; -import { - teamNameSchema, - taskIdSchema, - linkTypeSchema, - linkOperationSchema, -} from '../schemas.js'; - -export function register(server: FastMCP, runner: ITeamctlRunner): void { - server.addTool({ - name: 'task_link', - description: `Link or unlink task dependencies. - -Relationship types: -- "blocked-by": this task is blocked by the target -- "blocks": this task blocks the target -- "related": non-blocking relationship - -Links are bidirectional: linking A blocked-by B also sets B blocks A. -Circular dependencies are detected and rejected.`, - annotations: { - readOnlyHint: false, - destructiveHint: false, - }, - parameters: z.object({ - team: teamNameSchema, - task_id: taskIdSchema, - operation: linkOperationSchema.describe('"link" to add, "unlink" to remove'), - relationship: linkTypeSchema, - target_id: taskIdSchema.describe('The other task ID to link/unlink'), - }), - execute: async (args) => { - const cliArgs = [ - '--team', args.team, - 'task', args.operation, - args.task_id, - `--${args.relationship}`, args.target_id, - ]; - - const result = await runner.execute(cliArgs); - if (result.exitCode !== 0) { - throw new UserError(`Failed to ${args.operation} tasks: ${result.stderr.trim() || result.stdout.trim()}`); - } - return parseOkOutput(result.stdout); - }, - }); -} diff --git a/mcp-server/src/tools/task-list.ts b/mcp-server/src/tools/task-list.ts deleted file mode 100644 index 37c05bfd..00000000 --- a/mcp-server/src/tools/task-list.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { z } from 'zod'; -import { UserError } from 'fastmcp'; -import type { FastMCP } from 'fastmcp'; -import type { ITeamctlRunner } from '../teamctl-runner.js'; -import { parseJsonOutput } from '../output-parser.js'; -import { teamNameSchema } from '../schemas.js'; - -export function register(server: FastMCP, runner: ITeamctlRunner): void { - server.addTool({ - name: 'task_list', - description: `List all tasks for a team. Returns a JSON array of task objects. - -Note: includes internal bookkeeping tasks (metadata._internal). Filter client-side if needed.`, - annotations: { - readOnlyHint: true, - destructiveHint: false, - }, - parameters: z.object({ - team: teamNameSchema, - }), - execute: async (args) => { - const cliArgs = ['--team', args.team, 'task', 'list']; - - const result = await runner.execute(cliArgs); - if (result.exitCode !== 0) { - throw new UserError(`Failed to list tasks: ${result.stderr.trim() || result.stdout.trim()}`); - } - return parseJsonOutput(result.stdout); - }, - }); -} diff --git a/mcp-server/src/tools/task-set-owner.ts b/mcp-server/src/tools/task-set-owner.ts deleted file mode 100644 index 4cfd04bf..00000000 --- a/mcp-server/src/tools/task-set-owner.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { z } from 'zod'; -import { UserError } from 'fastmcp'; -import type { FastMCP } from 'fastmcp'; -import type { ITeamctlRunner } from '../teamctl-runner.js'; -import { parseOkOutput } from '../output-parser.js'; -import { teamNameSchema, taskIdSchema, memberNameSchema } from '../schemas.js'; - -export function register(server: FastMCP, runner: ITeamctlRunner): void { - server.addTool({ - name: 'task_set_owner', - description: `Assign a task to a team member or clear the assignment. - -Pass owner="clear" to unassign. Optionally sends an inbox notification to the new owner.`, - annotations: { - readOnlyHint: false, - destructiveHint: false, - }, - parameters: z.object({ - team: teamNameSchema, - task_id: taskIdSchema, - owner: z.union([memberNameSchema, z.literal('clear')]).describe('Member name to assign, or "clear" to unassign'), - notify: z.boolean().optional().describe('Send inbox notification to new owner'), - from: memberNameSchema.optional().describe('Author name for notification'), - }), - execute: async (args) => { - const cliArgs = ['--team', args.team, 'task', 'set-owner', args.task_id, args.owner]; - - if (args.notify) cliArgs.push('--notify'); - if (args.from) cliArgs.push('--from', args.from); - - const result = await runner.execute(cliArgs); - if (result.exitCode !== 0) { - throw new UserError(`Failed to set owner: ${result.stderr.trim() || result.stdout.trim()}`); - } - return parseOkOutput(result.stdout); - }, - }); -} diff --git a/mcp-server/src/tools/task-set-status.ts b/mcp-server/src/tools/task-set-status.ts deleted file mode 100644 index a5b96294..00000000 --- a/mcp-server/src/tools/task-set-status.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { z } from 'zod'; -import { UserError } from 'fastmcp'; -import type { FastMCP } from 'fastmcp'; -import type { ITeamctlRunner } from '../teamctl-runner.js'; -import { parseOkOutput } from '../output-parser.js'; -import { teamNameSchema, taskIdSchema, taskStatusSchema } from '../schemas.js'; - -export function register(server: FastMCP, runner: ITeamctlRunner): void { - server.addTool({ - name: 'task_set_status', - description: `Change the status of a task. - -Valid transitions: pending → in_progress → completed → deleted (and back). -Shortcuts: status "in_progress" is equivalent to "task start", "completed" to "task complete". -Records status history and tracks work intervals (time spent in_progress).`, - annotations: { - readOnlyHint: false, - destructiveHint: false, - }, - parameters: z.object({ - team: teamNameSchema, - task_id: taskIdSchema, - status: taskStatusSchema.describe('New status for the task'), - }), - execute: async (args) => { - const cliArgs = ['--team', args.team, 'task', 'set-status', args.task_id, args.status]; - - const result = await runner.execute(cliArgs); - if (result.exitCode !== 0) { - throw new UserError(`Failed to set status: ${result.stderr.trim() || result.stdout.trim()}`); - } - return parseOkOutput(result.stdout); - }, - }); -} diff --git a/mcp-server/test/output-parser.test.ts b/mcp-server/test/output-parser.test.ts deleted file mode 100644 index 52f8179f..00000000 --- a/mcp-server/test/output-parser.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - parseJsonOutput, - parseOkOutput, - parseTextOutput, - formatError, -} from '../src/output-parser.js'; - -describe('parseJsonOutput', () => { - it('parses valid JSON object', () => { - const input = '{"id":"42","subject":"Fix bug"}\n'; - expect(parseJsonOutput(input)).toEqual({ id: '42', subject: 'Fix bug' }); - }); - - it('parses valid JSON array', () => { - const input = '[{"id":"1"},{"id":"2"}]\n'; - expect(parseJsonOutput(input)).toEqual([{ id: '1' }, { id: '2' }]); - }); - - it('trims whitespace', () => { - const input = ' \n {"ok":true} \n '; - expect(parseJsonOutput(input)).toEqual({ ok: true }); - }); - - it('throws on empty output', () => { - expect(() => parseJsonOutput('')).toThrow('Empty output'); - expect(() => parseJsonOutput(' \n ')).toThrow('Empty output'); - }); - - it('throws on invalid JSON', () => { - expect(() => parseJsonOutput('not json')).toThrow('Failed to parse'); - }); -}); - -describe('parseOkOutput', () => { - it('strips "OK " prefix', () => { - expect(parseOkOutput('OK task #1 status=completed\n')).toBe('task #1 status=completed'); - }); - - it('handles bare "OK"', () => { - expect(parseOkOutput('OK\n')).toBe('OK'); - }); - - it('returns as-is for unexpected format', () => { - expect(parseOkOutput('Something else')).toBe('Something else'); - }); - - it('trims whitespace', () => { - expect(parseOkOutput(' OK kanban #1 cleared \n')).toBe('kanban #1 cleared'); - }); -}); - -describe('parseTextOutput', () => { - it('trims and returns text', () => { - const briefing = '=== Task Briefing for alice ===\nTask #1: Fix bug\n'; - expect(parseTextOutput(briefing)).toBe('=== Task Briefing for alice ===\nTask #1: Fix bug'); - }); - - it('handles empty string', () => { - expect(parseTextOutput('')).toBe(''); - }); -}); - -describe('formatError', () => { - it('uses stderr when available', () => { - expect(formatError('Task not found: #42\n', '')).toBe('Task not found: #42'); - }); - - it('falls back to stdout', () => { - expect(formatError('', 'Unexpected error\n')).toBe('Unexpected error'); - }); - - it('returns default for empty', () => { - expect(formatError('', '')).toBe('Unknown teamctl error'); - }); -}); diff --git a/mcp-server/test/schemas.test.ts b/mcp-server/test/schemas.test.ts deleted file mode 100644 index cfd8b5f6..00000000 --- a/mcp-server/test/schemas.test.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - teamNameSchema, - taskIdSchema, - memberNameSchema, - taskStatusSchema, - kanbanColumnSchema, - clarificationSchema, - linkTypeSchema, - linkOperationSchema, - reviewDecisionSchema, - reviewerOperationSchema, - taskIdsArraySchema, - filePathSchema, - safeFilenameSchema, - mimeTypeSchema, -} from '../src/schemas.js'; - -describe('teamNameSchema', () => { - it('accepts valid team names', () => { - expect(teamNameSchema.parse('acme')).toBe('acme'); - expect(teamNameSchema.parse('my-team')).toBe('my-team'); - expect(teamNameSchema.parse('My_Team')).toBe('My_Team'); - expect(teamNameSchema.parse('team123')).toBe('team123'); - }); - - it('rejects empty', () => { - expect(() => teamNameSchema.parse('')).toThrow(); - }); - - it('rejects path traversal', () => { - expect(() => teamNameSchema.parse('..')).toThrow(); - expect(() => teamNameSchema.parse('.')).toThrow(); - expect(() => teamNameSchema.parse('a/b')).toThrow(); - expect(() => teamNameSchema.parse('a\\b')).toThrow(); - expect(() => teamNameSchema.parse('a..b')).toThrow(); - }); - - it('rejects null bytes', () => { - expect(() => teamNameSchema.parse('a\0b')).toThrow(); - }); - - it('rejects too long', () => { - expect(() => teamNameSchema.parse('a'.repeat(129))).toThrow(); - }); -}); - -describe('taskIdSchema', () => { - it('accepts numeric IDs', () => { - expect(taskIdSchema.parse('1')).toBe('1'); - expect(taskIdSchema.parse('42')).toBe('42'); - expect(taskIdSchema.parse('1234567890')).toBe('1234567890'); - }); - - it('accepts zero', () => { - expect(taskIdSchema.parse('0')).toBe('0'); - }); - - it('accepts leading zeros', () => { - expect(taskIdSchema.parse('007')).toBe('007'); - }); - - it('rejects non-numeric', () => { - expect(() => taskIdSchema.parse('abc')).toThrow(); - expect(() => taskIdSchema.parse('')).toThrow(); - expect(() => taskIdSchema.parse('1.5')).toThrow(); - expect(() => taskIdSchema.parse('-1')).toThrow(); - }); - - it('rejects too long', () => { - expect(() => taskIdSchema.parse('12345678901')).toThrow(); - }); -}); - -describe('memberNameSchema', () => { - it('accepts valid member names', () => { - expect(memberNameSchema.parse('alice')).toBe('alice'); - expect(memberNameSchema.parse('bob-smith')).toBe('bob-smith'); - expect(memberNameSchema.parse('user_1')).toBe('user_1'); - }); - - it('rejects path traversal', () => { - expect(() => memberNameSchema.parse('..')).toThrow(); - expect(() => memberNameSchema.parse('a/b')).toThrow(); - expect(() => memberNameSchema.parse('a\\b')).toThrow(); - expect(() => memberNameSchema.parse('a..b')).toThrow(); - }); - - it('rejects null bytes', () => { - expect(() => memberNameSchema.parse('a\0b')).toThrow(); - }); - - it('rejects too long', () => { - expect(() => memberNameSchema.parse('a'.repeat(129))).toThrow(); - }); - - it('rejects empty', () => { - expect(() => memberNameSchema.parse('')).toThrow(); - }); -}); - -describe('enum schemas', () => { - it('taskStatusSchema accepts valid values', () => { - expect(taskStatusSchema.parse('pending')).toBe('pending'); - expect(taskStatusSchema.parse('in_progress')).toBe('in_progress'); - expect(taskStatusSchema.parse('completed')).toBe('completed'); - expect(taskStatusSchema.parse('deleted')).toBe('deleted'); - expect(() => taskStatusSchema.parse('invalid')).toThrow(); - }); - - it('kanbanColumnSchema accepts valid values', () => { - expect(kanbanColumnSchema.parse('review')).toBe('review'); - expect(kanbanColumnSchema.parse('approved')).toBe('approved'); - expect(() => kanbanColumnSchema.parse('todo')).toThrow(); - expect(() => kanbanColumnSchema.parse('')).toThrow(); - }); - - it('clarificationSchema accepts valid values', () => { - expect(clarificationSchema.parse('lead')).toBe('lead'); - expect(clarificationSchema.parse('user')).toBe('user'); - expect(clarificationSchema.parse('clear')).toBe('clear'); - expect(() => clarificationSchema.parse('nobody')).toThrow(); - }); - - it('linkTypeSchema accepts valid values', () => { - expect(linkTypeSchema.parse('blocked-by')).toBe('blocked-by'); - expect(linkTypeSchema.parse('blocks')).toBe('blocks'); - expect(linkTypeSchema.parse('related')).toBe('related'); - }); - - it('linkOperationSchema accepts valid values', () => { - expect(linkOperationSchema.parse('link')).toBe('link'); - expect(linkOperationSchema.parse('unlink')).toBe('unlink'); - }); - - it('reviewDecisionSchema accepts valid values', () => { - expect(reviewDecisionSchema.parse('approve')).toBe('approve'); - expect(reviewDecisionSchema.parse('request-changes')).toBe('request-changes'); - }); - - it('reviewerOperationSchema accepts valid values', () => { - expect(reviewerOperationSchema.parse('list')).toBe('list'); - expect(reviewerOperationSchema.parse('add')).toBe('add'); - expect(reviewerOperationSchema.parse('remove')).toBe('remove'); - }); -}); - -describe('taskIdsArraySchema', () => { - it('accepts valid arrays', () => { - expect(taskIdsArraySchema.parse(['1', '2', '3'])).toEqual(['1', '2', '3']); - expect(taskIdsArraySchema.parse([])).toEqual([]); - }); - - it('rejects arrays with invalid IDs', () => { - expect(() => taskIdsArraySchema.parse(['abc'])).toThrow(); - expect(() => taskIdsArraySchema.parse(['1', 'bad'])).toThrow(); - }); -}); - -describe('filePathSchema', () => { - it('accepts absolute paths', () => { - expect(filePathSchema.parse('/home/user/file.txt')).toBe('/home/user/file.txt'); - expect(filePathSchema.parse('/tmp/attachment.pdf')).toBe('/tmp/attachment.pdf'); - }); - - it('rejects relative paths', () => { - expect(() => filePathSchema.parse('relative/path.txt')).toThrow(); - expect(() => filePathSchema.parse('./file.txt')).toThrow(); - }); - - it('rejects path traversal', () => { - expect(() => filePathSchema.parse('/home/user/../etc/passwd')).toThrow(); - expect(() => filePathSchema.parse('/home/../../../etc/shadow')).toThrow(); - }); - - it('rejects null bytes', () => { - expect(() => filePathSchema.parse('/home/user/\0evil')).toThrow(); - }); - - it('rejects empty', () => { - expect(() => filePathSchema.parse('')).toThrow(); - }); -}); - -describe('safeFilenameSchema', () => { - it('accepts valid filenames', () => { - expect(safeFilenameSchema.parse('report.pdf')).toBe('report.pdf'); - expect(safeFilenameSchema.parse('my-file_v2.tar.gz')).toBe('my-file_v2.tar.gz'); - }); - - it('rejects path separators', () => { - expect(() => safeFilenameSchema.parse('../../evil')).toThrow(); - expect(() => safeFilenameSchema.parse('dir/file')).toThrow(); - expect(() => safeFilenameSchema.parse('dir\\file')).toThrow(); - }); - - it('rejects null bytes', () => { - expect(() => safeFilenameSchema.parse('file\0name')).toThrow(); - }); - - it('rejects too long', () => { - expect(() => safeFilenameSchema.parse('a'.repeat(256))).toThrow(); - }); -}); - -describe('mimeTypeSchema', () => { - it('accepts valid MIME types', () => { - expect(mimeTypeSchema.parse('application/pdf')).toBe('application/pdf'); - expect(mimeTypeSchema.parse('image/png')).toBe('image/png'); - expect(mimeTypeSchema.parse('text/plain')).toBe('text/plain'); - expect(mimeTypeSchema.parse('application/octet-stream')).toBe('application/octet-stream'); - }); - - it('rejects invalid formats', () => { - expect(() => mimeTypeSchema.parse('invalid')).toThrow(); - expect(() => mimeTypeSchema.parse('/pdf')).toThrow(); - expect(() => mimeTypeSchema.parse('application/')).toThrow(); - expect(() => mimeTypeSchema.parse('')).toThrow(); - }); -}); diff --git a/mcp-server/test/teamctl-runner.test.ts b/mcp-server/test/teamctl-runner.test.ts deleted file mode 100644 index 54b703f7..00000000 --- a/mcp-server/test/teamctl-runner.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import { TeamctlRunner } from '../src/teamctl-runner.js'; -import type { ITeamctlRunner, TeamctlResult } from '../src/teamctl-runner.js'; - -// We can't easily test the real subprocess without teamctl.js installed, -// so we test the interface contract and error handling. - -describe('TeamctlRunner', () => { - it('throws if teamctl.js does not exist', () => { - expect( - () => new TeamctlRunner({ teamctlPath: '/nonexistent/teamctl.js' }), - ).toThrow('teamctl.js not found'); - }); - - it('resolves path from TEAMCTL_PATH env', () => { - const original = process.env['TEAMCTL_PATH']; - try { - process.env['TEAMCTL_PATH'] = '/tmp/test-teamctl.js'; - // Will throw because file doesn't exist, but we can check the error message - expect( - () => new TeamctlRunner(), - ).toThrow('/tmp/test-teamctl.js'); - } finally { - if (original !== undefined) { - process.env['TEAMCTL_PATH'] = original; - } else { - delete process.env['TEAMCTL_PATH']; - } - } - }); -}); - -// Mock runner for tool tests -export function createMockRunner( - responses: Map | TeamctlResult, -): ITeamctlRunner { - return { - execute: vi.fn(async (args: string[]): Promise => { - if (responses instanceof Map) { - const key = args.join(' '); - const result = responses.get(key); - if (result) return result; - // Fallback: check if any key is a prefix - for (const [k, v] of responses) { - if (key.startsWith(k)) return v; - } - return { stdout: '', stderr: 'No mock for: ' + key, exitCode: 1 }; - } - return responses; - }), - }; -} - -describe('ITeamctlRunner interface', () => { - it('mock runner returns success', async () => { - const runner = createMockRunner({ - stdout: '{"id":"1","subject":"Test"}\n', - stderr: '', - exitCode: 0, - }); - - const result = await runner.execute(['--team', 'test', 'task', 'create']); - expect(result.exitCode).toBe(0); - expect(JSON.parse(result.stdout)).toHaveProperty('id', '1'); - }); - - it('mock runner returns error', async () => { - const runner = createMockRunner({ - stdout: '', - stderr: 'Task not found: #99\n', - exitCode: 1, - }); - - const result = await runner.execute(['--team', 'test', 'task', 'get', '99']); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain('Task not found'); - }); -}); diff --git a/mcp-server/test/tools/kanban-move.test.ts b/mcp-server/test/tools/kanban-move.test.ts deleted file mode 100644 index e0468131..00000000 --- a/mcp-server/test/tools/kanban-move.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { register } from '../../src/tools/kanban-move.js'; -import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js'; - -describe('kanban_move', () => { - function setup(response = ok('OK kanban #1 column=review\n')) { - const runner = createMockRunner(response); - const { server, tools } = createMockServer(); - register(server, runner); - return { runner, tool: tools.get('kanban_move')! }; - } - - it('builds set-column CLI args', async () => { - const { runner, tool } = setup(); - await tool.execute({ team: 'acme', task_id: '1', operation: 'set-column', column: 'review' }); - expect(runner.execute).toHaveBeenCalledWith([ - '--team', 'acme', 'kanban', 'set-column', '1', 'review', - ]); - }); - - it('builds clear CLI args', async () => { - const { runner, tool } = setup(ok('OK kanban #1 cleared\n')); - await tool.execute({ team: 'acme', task_id: '1', operation: 'clear' }); - expect(runner.execute).toHaveBeenCalledWith([ - '--team', 'acme', 'kanban', 'clear', '1', - ]); - }); - - it('throws UserError when set-column called without column', async () => { - const { tool } = setup(); - await expect( - tool.execute({ team: 'acme', task_id: '1', operation: 'set-column' }), - ).rejects.toThrow('column is required'); - }); - - it('throws on CLI failure', async () => { - const { tool } = setup(fail('task not found')); - await expect( - tool.execute({ team: 'acme', task_id: '99', operation: 'clear' }), - ).rejects.toThrow('Failed to update kanban'); - }); -}); diff --git a/mcp-server/test/tools/kanban-reviewers.test.ts b/mcp-server/test/tools/kanban-reviewers.test.ts deleted file mode 100644 index ee854535..00000000 --- a/mcp-server/test/tools/kanban-reviewers.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { register } from '../../src/tools/kanban-reviewers.js'; -import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js'; - -describe('kanban_reviewers', () => { - function setup(response = ok('["alice","bob"]')) { - const runner = createMockRunner(response); - const { server, tools } = createMockServer(); - register(server, runner); - return { runner, tool: tools.get('kanban_reviewers')! }; - } - - it('builds list CLI args', async () => { - const { runner, tool } = setup(); - await tool.execute({ team: 'acme', operation: 'list' }); - expect(runner.execute).toHaveBeenCalledWith([ - '--team', 'acme', 'kanban', 'reviewers', 'list', - ]); - }); - - it('returns JSON array for list', async () => { - const { tool } = setup(); - const result = await tool.execute({ team: 'acme', operation: 'list' }); - expect(result).toEqual(['alice', 'bob']); - }); - - it('builds add CLI args with name', async () => { - const { runner, tool } = setup(ok('OK reviewer added\n')); - await tool.execute({ team: 'acme', operation: 'add', name: 'charlie' }); - expect(runner.execute).toHaveBeenCalledWith([ - '--team', 'acme', 'kanban', 'reviewers', 'add', 'charlie', - ]); - }); - - it('returns OK text for add/remove', async () => { - const { tool } = setup(ok('OK reviewer added\n')); - const result = await tool.execute({ team: 'acme', operation: 'add', name: 'charlie' }); - expect(result).toBe('reviewer added'); - }); - - it('throws UserError when add called without name', async () => { - const { tool } = setup(); - await expect( - tool.execute({ team: 'acme', operation: 'add' }), - ).rejects.toThrow('name is required'); - }); - - it('throws UserError when remove called without name', async () => { - const { tool } = setup(); - await expect( - tool.execute({ team: 'acme', operation: 'remove' }), - ).rejects.toThrow('name is required'); - }); - - it('throws on CLI failure', async () => { - const { tool } = setup(fail('reviewer not found')); - await expect( - tool.execute({ team: 'acme', operation: 'remove', name: 'nobody' }), - ).rejects.toThrow('Failed to manage reviewers'); - }); -}); diff --git a/mcp-server/test/tools/message-send.test.ts b/mcp-server/test/tools/message-send.test.ts deleted file mode 100644 index 8bb206e8..00000000 --- a/mcp-server/test/tools/message-send.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { register } from '../../src/tools/message-send.js'; -import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js'; - -describe('message_send', () => { - const deliveryJson = '{"deliveredToInbox":true,"messageId":"msg_abc"}'; - - function setup(response = ok(deliveryJson)) { - const runner = createMockRunner(response); - const { server, tools } = createMockServer(); - register(server, runner); - return { runner, tool: tools.get('message_send')! }; - } - - it('builds minimal CLI args', async () => { - const { runner, tool } = setup(); - await tool.execute({ team: 'acme', to: 'alice', text: 'Hello!' }); - expect(runner.execute).toHaveBeenCalledWith([ - '--team', 'acme', 'message', 'send', '--to', 'alice', '--text', 'Hello!', - ]); - }); - - it('includes summary and from flags', async () => { - const { runner, tool } = setup(); - await tool.execute({ - team: 'acme', to: 'alice', text: 'Task done', - summary: 'Completed task #1', from: 'bob', - }); - const args = runner.execute.mock.calls[0]![0] as string[]; - expect(args).toContain('--summary'); - expect(args[args.indexOf('--summary') + 1]).toBe('Completed task #1'); - expect(args).toContain('--from'); - expect(args[args.indexOf('--from') + 1]).toBe('bob'); - }); - - it('returns parsed JSON', async () => { - const { tool } = setup(); - const result = await tool.execute({ team: 'acme', to: 'alice', text: 'Hello!' }); - expect(result).toEqual({ deliveredToInbox: true, messageId: 'msg_abc' }); - }); - - it('throws on CLI failure', async () => { - const { tool } = setup(fail('recipient inbox not found')); - await expect( - tool.execute({ team: 'acme', to: 'nobody', text: 'Hi' }), - ).rejects.toThrow('Failed to send message'); - }); -}); diff --git a/mcp-server/test/tools/register-all.test.ts b/mcp-server/test/tools/register-all.test.ts deleted file mode 100644 index d562525e..00000000 --- a/mcp-server/test/tools/register-all.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { registerAllTools } from '../../src/tools/index.js'; -import { createMockRunner, createMockServer } from './test-helpers.js'; - -describe('registerAllTools', () => { - it('registers exactly 13 tools', () => { - const runner = createMockRunner({ stdout: '', stderr: '', exitCode: 0 }); - const { server, tools } = createMockServer(); - registerAllTools(server, runner); - expect(tools.size).toBe(13); - }); - - it('registers all expected tool names', () => { - const runner = createMockRunner({ stdout: '', stderr: '', exitCode: 0 }); - const { server, tools } = createMockServer(); - registerAllTools(server, runner); - - const expected = [ - 'task_create', 'task_set_status', 'task_set_owner', - 'task_get', 'task_list', 'task_comment', 'task_link', - 'task_briefing', 'task_attach', 'kanban_move', - 'kanban_reviewers', 'review_action', 'message_send', - ]; - for (const name of expected) { - expect(tools.has(name), `missing tool: ${name}`).toBe(true); - } - }); -}); diff --git a/mcp-server/test/tools/review-action.test.ts b/mcp-server/test/tools/review-action.test.ts deleted file mode 100644 index 952f1ae9..00000000 --- a/mcp-server/test/tools/review-action.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { register } from '../../src/tools/review-action.js'; -import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js'; - -describe('review_action', () => { - function setup(response = ok('OK review #1 approved\n')) { - const runner = createMockRunner(response); - const { server, tools } = createMockServer(); - register(server, runner); - return { runner, tool: tools.get('review_action')! }; - } - - it('builds approve CLI args (no comment)', async () => { - const { runner, tool } = setup(); - await tool.execute({ team: 'acme', task_id: '1', decision: 'approve' }); - expect(runner.execute).toHaveBeenCalledWith([ - '--team', 'acme', 'review', 'approve', '1', - ]); - }); - - it('builds approve CLI args with --note (not --comment)', async () => { - const { runner, tool } = setup(); - await tool.execute({ team: 'acme', task_id: '1', decision: 'approve', comment: 'LGTM' }); - const args = runner.execute.mock.calls[0]![0] as string[]; - // approve uses --note, NOT --comment - expect(args).toContain('--note'); - expect(args[args.indexOf('--note') + 1]).toBe('LGTM'); - expect(args).not.toContain('--comment'); - }); - - it('builds request-changes CLI args with --comment (not --note)', async () => { - const { runner, tool } = setup(ok('OK review #1 requested changes\n')); - await tool.execute({ - team: 'acme', task_id: '1', decision: 'request-changes', comment: 'Fix tests', - }); - const args = runner.execute.mock.calls[0]![0] as string[]; - expect(args).toContain('--comment'); - expect(args[args.indexOf('--comment') + 1]).toBe('Fix tests'); - expect(args).not.toContain('--note'); - }); - - it('throws when request-changes has no comment', async () => { - const { tool } = setup(); - await expect( - tool.execute({ team: 'acme', task_id: '1', decision: 'request-changes' }), - ).rejects.toThrow('comment is required when requesting changes'); - }); - - it('includes from and notify_owner flags', async () => { - const { runner, tool } = setup(); - await tool.execute({ - team: 'acme', task_id: '1', decision: 'approve', - from: 'alice', notify_owner: true, - }); - const args = runner.execute.mock.calls[0]![0] as string[]; - expect(args).toContain('--from'); - expect(args).toContain('--notify-owner'); - }); - - it('throws on CLI failure', async () => { - const { tool } = setup(fail('task not in review')); - await expect( - tool.execute({ team: 'acme', task_id: '1', decision: 'approve' }), - ).rejects.toThrow('Failed to approve'); - }); -}); diff --git a/mcp-server/test/tools/task-attach.test.ts b/mcp-server/test/tools/task-attach.test.ts deleted file mode 100644 index e8b63fe5..00000000 --- a/mcp-server/test/tools/task-attach.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { register } from '../../src/tools/task-attach.js'; -import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js'; - -describe('task_attach', () => { - const attachJson = '{"id":"att_123","filename":"report.pdf","mimeType":"application/pdf"}'; - - function setup(response = ok(attachJson)) { - const runner = createMockRunner(response); - const { server, tools } = createMockServer(); - register(server, runner); - return { runner, tool: tools.get('task_attach')! }; - } - - it('builds minimal CLI args', async () => { - const { runner, tool } = setup(); - await tool.execute({ team: 'acme', task_id: '1', file: '/tmp/report.pdf' }); - expect(runner.execute).toHaveBeenCalledWith([ - '--team', 'acme', 'task', 'attach', '1', '--file', '/tmp/report.pdf', - ]); - }); - - it('includes all optional flags', async () => { - const { runner, tool } = setup(); - await tool.execute({ - team: 'acme', task_id: '1', file: '/tmp/file.pdf', - filename: 'renamed.pdf', mime_type: 'application/pdf', - mode: 'link', from: 'alice', - }); - const args = runner.execute.mock.calls[0]![0] as string[]; - expect(args).toContain('--filename'); - expect(args).toContain('--mime-type'); - expect(args).toContain('--mode'); - expect(args).toContain('--from'); - }); - - it('returns parsed JSON', async () => { - const { tool } = setup(); - const result = await tool.execute({ team: 'acme', task_id: '1', file: '/tmp/report.pdf' }); - expect(result).toEqual({ id: 'att_123', filename: 'report.pdf', mimeType: 'application/pdf' }); - }); - - it('throws on CLI failure', async () => { - const { tool } = setup(fail('file too large')); - await expect( - tool.execute({ team: 'acme', task_id: '1', file: '/tmp/huge.bin' }), - ).rejects.toThrow('Failed to attach file'); - }); -}); diff --git a/mcp-server/test/tools/task-briefing.test.ts b/mcp-server/test/tools/task-briefing.test.ts deleted file mode 100644 index b915e994..00000000 --- a/mcp-server/test/tools/task-briefing.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { register } from '../../src/tools/task-briefing.js'; -import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js'; - -describe('task_briefing', () => { - const briefingText = '=== Task Briefing for alice ===\nTask #1: Fix bug [in_progress]\n'; - - function setup(response = ok(briefingText)) { - const runner = createMockRunner(response); - const { server, tools } = createMockServer(); - register(server, runner); - return { runner, tool: tools.get('task_briefing')! }; - } - - it('builds correct CLI args', async () => { - const { runner, tool } = setup(); - await tool.execute({ team: 'acme', member: 'alice' }); - expect(runner.execute).toHaveBeenCalledWith([ - '--team', 'acme', 'task', 'briefing', '--for', 'alice', - ]); - }); - - it('returns trimmed plain text', async () => { - const { tool } = setup(); - const result = await tool.execute({ team: 'acme', member: 'alice' }); - expect(result).toBe('=== Task Briefing for alice ===\nTask #1: Fix bug [in_progress]'); - }); - - it('throws on CLI failure', async () => { - const { tool } = setup(fail('member not found')); - await expect(tool.execute({ team: 'acme', member: 'nobody' })).rejects.toThrow('Failed to get briefing'); - }); -}); diff --git a/mcp-server/test/tools/task-comment.test.ts b/mcp-server/test/tools/task-comment.test.ts deleted file mode 100644 index 2c340e48..00000000 --- a/mcp-server/test/tools/task-comment.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { register } from '../../src/tools/task-comment.js'; -import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js'; - -describe('task_comment', () => { - function setup(response = ok('OK comment added to task #1\n')) { - const runner = createMockRunner(response); - const { server, tools } = createMockServer(); - register(server, runner); - return { runner, tool: tools.get('task_comment')! }; - } - - it('builds correct CLI args', async () => { - const { runner, tool } = setup(); - await tool.execute({ team: 'acme', task_id: '1', text: 'Looking good!' }); - expect(runner.execute).toHaveBeenCalledWith([ - '--team', 'acme', 'task', 'comment', '1', '--text', 'Looking good!', - ]); - }); - - it('includes from flag', async () => { - const { runner, tool } = setup(); - await tool.execute({ team: 'acme', task_id: '1', text: 'Done', from: 'alice' }); - const args = runner.execute.mock.calls[0]![0] as string[]; - expect(args).toContain('--from'); - expect(args[args.indexOf('--from') + 1]).toBe('alice'); - }); - - it('throws on CLI failure', async () => { - const { tool } = setup(fail('task not found')); - await expect(tool.execute({ team: 'acme', task_id: '99', text: 'Hi' })).rejects.toThrow('Failed to add comment'); - }); -}); diff --git a/mcp-server/test/tools/task-create.test.ts b/mcp-server/test/tools/task-create.test.ts deleted file mode 100644 index 5866e9f9..00000000 --- a/mcp-server/test/tools/task-create.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { register } from '../../src/tools/task-create.js'; -import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js'; - -describe('task_create', () => { - function setup(response = ok('{"id":"1","subject":"Fix bug","status":"in_progress"}')) { - const runner = createMockRunner(response); - const { server, tools } = createMockServer(); - register(server, runner); - return { runner, tool: tools.get('task_create')! }; - } - - it('builds minimal CLI args', async () => { - const { runner, tool } = setup(); - await tool.execute({ team: 'acme', subject: 'Fix bug' }); - expect(runner.execute).toHaveBeenCalledWith([ - '--team', 'acme', 'task', 'create', '--subject', 'Fix bug', - ]); - }); - - it('includes all optional flags', async () => { - const { runner, tool } = setup(); - await tool.execute({ - team: 'acme', - subject: 'Big task', - description: 'Details here', - owner: 'alice', - blocked_by: ['1', '2'], - related: ['3'], - status: 'pending', - active_form: 'Fixing bug', - notify: true, - from: 'bob', - }); - const args = runner.execute.mock.calls[0]![0] as string[]; - expect(args).toContain('--description'); - expect(args).toContain('--owner'); - expect(args).toContain('--blocked-by'); - expect(args[args.indexOf('--blocked-by') + 1]).toBe('1,2'); - expect(args).toContain('--related'); - expect(args[args.indexOf('--related') + 1]).toBe('3'); - expect(args).toContain('--status'); - expect(args).toContain('--activeForm'); - expect(args).toContain('--notify'); - expect(args).toContain('--from'); - }); - - it('skips empty blocked_by array', async () => { - const { runner, tool } = setup(); - await tool.execute({ team: 'acme', subject: 'Task', blocked_by: [] }); - const args = runner.execute.mock.calls[0]![0] as string[]; - expect(args).not.toContain('--blocked-by'); - }); - - it('returns parsed JSON', async () => { - const { tool } = setup(); - const result = await tool.execute({ team: 'acme', subject: 'Fix bug' }); - expect(result).toEqual({ id: '1', subject: 'Fix bug', status: 'in_progress' }); - }); - - it('throws on CLI failure', async () => { - const { tool } = setup(fail('team not found')); - await expect(tool.execute({ team: 'bad', subject: 'X' })).rejects.toThrow('Failed to create task'); - }); -}); diff --git a/mcp-server/test/tools/task-get.test.ts b/mcp-server/test/tools/task-get.test.ts deleted file mode 100644 index 79a3d97f..00000000 --- a/mcp-server/test/tools/task-get.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { register } from '../../src/tools/task-get.js'; -import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js'; - -describe('task_get', () => { - function setup(response = ok('{"id":"42","subject":"Test","status":"pending"}')) { - const runner = createMockRunner(response); - const { server, tools } = createMockServer(); - register(server, runner); - return { runner, tool: tools.get('task_get')! }; - } - - it('builds correct CLI args', async () => { - const { runner, tool } = setup(); - await tool.execute({ team: 'acme', task_id: '42' }); - expect(runner.execute).toHaveBeenCalledWith(['--team', 'acme', 'task', 'get', '42']); - }); - - it('returns parsed JSON', async () => { - const { tool } = setup(); - const result = await tool.execute({ team: 'acme', task_id: '42' }); - expect(result).toEqual({ id: '42', subject: 'Test', status: 'pending' }); - }); - - it('throws on CLI failure', async () => { - const { tool } = setup(fail('Task not found: #99')); - await expect(tool.execute({ team: 'acme', task_id: '99' })).rejects.toThrow('Failed to get task'); - }); -}); diff --git a/mcp-server/test/tools/task-link.test.ts b/mcp-server/test/tools/task-link.test.ts deleted file mode 100644 index 7e16b802..00000000 --- a/mcp-server/test/tools/task-link.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { register } from '../../src/tools/task-link.js'; -import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js'; - -describe('task_link', () => { - function setup(response = ok('OK task #1 blocked-by #2\n')) { - const runner = createMockRunner(response); - const { server, tools } = createMockServer(); - register(server, runner); - return { runner, tool: tools.get('task_link')! }; - } - - it('builds link CLI args', async () => { - const { runner, tool } = setup(); - await tool.execute({ - team: 'acme', task_id: '1', operation: 'link', - relationship: 'blocked-by', target_id: '2', - }); - expect(runner.execute).toHaveBeenCalledWith([ - '--team', 'acme', 'task', 'link', '1', '--blocked-by', '2', - ]); - }); - - it('builds unlink CLI args', async () => { - const { runner, tool } = setup(ok('OK task #1 unlinked from #2\n')); - await tool.execute({ - team: 'acme', task_id: '1', operation: 'unlink', - relationship: 'related', target_id: '3', - }); - expect(runner.execute).toHaveBeenCalledWith([ - '--team', 'acme', 'task', 'unlink', '1', '--related', '3', - ]); - }); - - it('throws on CLI failure', async () => { - const { tool } = setup(fail('circular dependency')); - await expect( - tool.execute({ - team: 'acme', task_id: '1', operation: 'link', - relationship: 'blocked-by', target_id: '1', - }), - ).rejects.toThrow('Failed to link tasks'); - }); -}); diff --git a/mcp-server/test/tools/task-list.test.ts b/mcp-server/test/tools/task-list.test.ts deleted file mode 100644 index be1a25af..00000000 --- a/mcp-server/test/tools/task-list.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { register } from '../../src/tools/task-list.js'; -import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js'; - -describe('task_list', () => { - function setup(response = ok('[{"id":"1"},{"id":"2"}]')) { - const runner = createMockRunner(response); - const { server, tools } = createMockServer(); - register(server, runner); - return { runner, tool: tools.get('task_list')! }; - } - - it('builds correct CLI args', async () => { - const { runner, tool } = setup(); - await tool.execute({ team: 'acme' }); - expect(runner.execute).toHaveBeenCalledWith(['--team', 'acme', 'task', 'list']); - }); - - it('returns parsed JSON array', async () => { - const { tool } = setup(); - const result = await tool.execute({ team: 'acme' }); - expect(result).toEqual([{ id: '1' }, { id: '2' }]); - }); - - it('throws on CLI failure', async () => { - const { tool } = setup(fail('team dir not found')); - await expect(tool.execute({ team: 'bad' })).rejects.toThrow('Failed to list tasks'); - }); -}); diff --git a/mcp-server/test/tools/task-set-owner.test.ts b/mcp-server/test/tools/task-set-owner.test.ts deleted file mode 100644 index d9859066..00000000 --- a/mcp-server/test/tools/task-set-owner.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { register } from '../../src/tools/task-set-owner.js'; -import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js'; - -describe('task_set_owner', () => { - function setup(response = ok('OK task #1 owner=alice\n')) { - const runner = createMockRunner(response); - const { server, tools } = createMockServer(); - register(server, runner); - return { runner, tool: tools.get('task_set_owner')! }; - } - - it('builds args for assignment', async () => { - const { runner, tool } = setup(); - await tool.execute({ team: 'acme', task_id: '1', owner: 'alice' }); - expect(runner.execute).toHaveBeenCalledWith([ - '--team', 'acme', 'task', 'set-owner', '1', 'alice', - ]); - }); - - it('builds args for clear', async () => { - const { runner, tool } = setup(ok('OK task #1 owner=cleared\n')); - await tool.execute({ team: 'acme', task_id: '1', owner: 'clear' }); - expect(runner.execute).toHaveBeenCalledWith([ - '--team', 'acme', 'task', 'set-owner', '1', 'clear', - ]); - }); - - it('includes notify and from flags', async () => { - const { runner, tool } = setup(); - await tool.execute({ team: 'acme', task_id: '1', owner: 'alice', notify: true, from: 'bob' }); - const args = runner.execute.mock.calls[0]![0] as string[]; - expect(args).toContain('--notify'); - expect(args).toContain('--from'); - expect(args[args.indexOf('--from') + 1]).toBe('bob'); - }); - - it('throws on CLI failure', async () => { - const { tool } = setup(fail('member not found')); - await expect(tool.execute({ team: 'acme', task_id: '1', owner: 'nobody' })).rejects.toThrow('Failed to set owner'); - }); -}); diff --git a/mcp-server/test/tools/task-set-status.test.ts b/mcp-server/test/tools/task-set-status.test.ts deleted file mode 100644 index 0ca68d0d..00000000 --- a/mcp-server/test/tools/task-set-status.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { register } from '../../src/tools/task-set-status.js'; -import { createMockRunner, createMockServer, ok, fail } from './test-helpers.js'; - -describe('task_set_status', () => { - function setup(response = ok('OK task #1 status=completed\n')) { - const runner = createMockRunner(response); - const { server, tools } = createMockServer(); - register(server, runner); - return { runner, tool: tools.get('task_set_status')! }; - } - - it('builds correct CLI args', async () => { - const { runner, tool } = setup(); - await tool.execute({ team: 'acme', task_id: '1', status: 'completed' }); - expect(runner.execute).toHaveBeenCalledWith([ - '--team', 'acme', 'task', 'set-status', '1', 'completed', - ]); - }); - - it('returns parsed OK text', async () => { - const { tool } = setup(); - const result = await tool.execute({ team: 'acme', task_id: '1', status: 'completed' }); - expect(result).toBe('task #1 status=completed'); - }); - - it('throws on CLI failure', async () => { - const { tool } = setup(fail('invalid transition')); - await expect(tool.execute({ team: 'acme', task_id: '1', status: 'deleted' })).rejects.toThrow('Failed to set status'); - }); -}); diff --git a/mcp-server/test/tools/test-helpers.ts b/mcp-server/test/tools/test-helpers.ts deleted file mode 100644 index fb0a8e01..00000000 --- a/mcp-server/test/tools/test-helpers.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { vi } from 'vitest'; -import type { FastMCP } from 'fastmcp'; -import type { ITeamctlRunner, TeamctlResult } from '../../src/teamctl-runner.js'; - -/** - * Creates a mock ITeamctlRunner that records calls and returns predetermined results. - */ -export function createMockRunner( - response: TeamctlResult | ((args: string[]) => TeamctlResult), -): ITeamctlRunner & { execute: ReturnType } { - return { - execute: vi.fn(async (args: string[]): Promise => { - return typeof response === 'function' ? response(args) : response; - }), - }; -} - -/** Success response helpers */ -export const ok = (stdout: string): TeamctlResult => ({ - stdout, - stderr: '', - exitCode: 0, -}); - -export const fail = (stderr: string): TeamctlResult => ({ - stdout: '', - stderr, - exitCode: 1, -}); - -/** - * Captures registered tools via a mock FastMCP-like server. - * Returns a map of tool name → { execute function, parameters schema }. - */ -export interface CapturedTool { - name: string; - execute: (args: Record) => Promise; - parameters: unknown; -} - -export function createMockServer(): { - server: FastMCP; - tools: Map; -} { - const tools = new Map(); - - const server = { - addTool: (def: { name: string; execute: CapturedTool['execute']; parameters: unknown }) => { - tools.set(def.name, { - name: def.name, - execute: def.execute, - parameters: def.parameters, - }); - }, - } as unknown as FastMCP; - - return { server, tools }; -}