chore: remove MCP code (to new build new one from scratch)

This commit is contained in:
iliya 2026-03-07 12:01:39 +02:00
parent f88fa9cb09
commit 919d40b7bc
38 changed files with 2 additions and 2242 deletions

2
mcp-server/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
dist/
node_modules/

View file

@ -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: 1128 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).

View file

@ -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' });

View file

@ -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<T = unknown>(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;
}

View file

@ -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)',
);

View file

@ -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<TeamctlResult>;
}
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<void> {
if (this.current < this.max) {
this.current++;
return;
}
return new Promise<void>((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<TeamctlResult> {
await this.semaphore.acquire();
try {
return await this.spawn(args);
} finally {
this.semaphore.release();
}
}
private spawn(args: string[]): Promise<TeamctlResult> {
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');
}

View file

@ -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);
}
}

View file

@ -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);
},
});
}

View file

@ -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);
},
});
}

View file

@ -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);
},
});
}

View file

@ -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);
},
});
}

View file

@ -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);
},
});
}

View file

@ -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);
},
});
}

View file

@ -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);
},
});
}

View file

@ -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);
},
});
}

View file

@ -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);
},
});
}

View file

@ -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);
},
});
}

View file

@ -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);
},
});
}

View file

@ -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);
},
});
}

View file

@ -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);
},
});
}

View file

@ -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');
});
});

View file

@ -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();
});
});

View file

@ -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<string, TeamctlResult> | TeamctlResult,
): ITeamctlRunner {
return {
execute: vi.fn(async (args: string[]): Promise<TeamctlResult> => {
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');
});
});

View file

@ -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');
});
});

View file

@ -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');
});
});

View file

@ -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');
});
});

View file

@ -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);
}
});
});

View file

@ -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');
});
});

View file

@ -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');
});
});

View file

@ -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');
});
});

View file

@ -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');
});
});

View file

@ -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');
});
});

View file

@ -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');
});
});

View file

@ -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');
});
});

View file

@ -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');
});
});

View file

@ -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');
});
});

View file

@ -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');
});
});

View file

@ -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<typeof vi.fn> } {
return {
execute: vi.fn(async (args: string[]): Promise<TeamctlResult> => {
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<string, unknown>) => Promise<unknown>;
parameters: unknown;
}
export function createMockServer(): {
server: FastMCP;
tools: Map<string, CapturedTool>;
} {
const tools = new Map<string, CapturedTool>();
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 };
}