feat: update pnpm lockfile and workspace configuration, enhance tool usage tracking and UI components
- Added new dependencies for mcp-server in pnpm-lock.yaml, including fastmcp and zod. - Updated pnpm-workspace.yaml to include mcp-server in the workspace packages. - Modified TeamDataService and TeamProvisioningService to exclude 'SendMessage' from tool usage counts, improving accuracy in tool tracking. - Enhanced ChatHistory component to display context injection percentages, improving user feedback on context usage. - Updated TeamDetailView to ensure session details are fetched periodically for active tabs, enhancing data freshness. - Improved ActivityTimeline styling for better visual separation of sessions and messages.
This commit is contained in:
parent
79ea547674
commit
161c675aaa
48 changed files with 3198 additions and 24 deletions
31
mcp-server/package.json
Normal file
31
mcp-server/package.json
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"name": "@claude-team/mcp-server",
|
||||
"version": "1.0.0",
|
||||
"description": "MCP server for managing Claude Agent Teams kanban board and tasks",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"bin": {
|
||||
"team-mcp-server": "dist/index.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"dev": "tsx src/index.ts",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"fastmcp": "^3.34.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsup": "^8.5.1",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.8.2",
|
||||
"vitest": "^3.1.4",
|
||||
"@types/node": "^22.15.18"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
20
mcp-server/src/index.ts
Normal file
20
mcp-server/src/index.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
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' });
|
||||
52
mcp-server/src/output-parser.ts
Normal file
52
mcp-server/src/output-parser.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
/**
|
||||
* 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;
|
||||
}
|
||||
115
mcp-server/src/schemas.ts
Normal file
115
mcp-server/src/schemas.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
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)',
|
||||
);
|
||||
155
mcp-server/src/teamctl-runner.ts
Normal file
155
mcp-server/src/teamctl-runner.ts
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
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');
|
||||
}
|
||||
42
mcp-server/src/tools/index.ts
Normal file
42
mcp-server/src/tools/index.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
45
mcp-server/src/tools/kanban-move.ts
Normal file
45
mcp-server/src/tools/kanban-move.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
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);
|
||||
},
|
||||
});
|
||||
}
|
||||
46
mcp-server/src/tools/kanban-reviewers.ts
Normal file
46
mcp-server/src/tools/kanban-reviewers.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
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);
|
||||
},
|
||||
});
|
||||
}
|
||||
39
mcp-server/src/tools/message-send.ts
Normal file
39
mcp-server/src/tools/message-send.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
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);
|
||||
},
|
||||
});
|
||||
}
|
||||
50
mcp-server/src/tools/review-action.ts
Normal file
50
mcp-server/src/tools/review-action.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
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);
|
||||
},
|
||||
});
|
||||
}
|
||||
42
mcp-server/src/tools/task-attach.ts
Normal file
42
mcp-server/src/tools/task-attach.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
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);
|
||||
},
|
||||
});
|
||||
}
|
||||
32
mcp-server/src/tools/task-briefing.ts
Normal file
32
mcp-server/src/tools/task-briefing.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
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);
|
||||
},
|
||||
});
|
||||
}
|
||||
34
mcp-server/src/tools/task-comment.ts
Normal file
34
mcp-server/src/tools/task-comment.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
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);
|
||||
},
|
||||
});
|
||||
}
|
||||
52
mcp-server/src/tools/task-create.ts
Normal file
52
mcp-server/src/tools/task-create.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
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);
|
||||
},
|
||||
});
|
||||
}
|
||||
30
mcp-server/src/tools/task-get.ts
Normal file
30
mcp-server/src/tools/task-get.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
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);
|
||||
},
|
||||
});
|
||||
}
|
||||
51
mcp-server/src/tools/task-link.ts
Normal file
51
mcp-server/src/tools/task-link.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
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);
|
||||
},
|
||||
});
|
||||
}
|
||||
31
mcp-server/src/tools/task-list.ts
Normal file
31
mcp-server/src/tools/task-list.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
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);
|
||||
},
|
||||
});
|
||||
}
|
||||
38
mcp-server/src/tools/task-set-owner.ts
Normal file
38
mcp-server/src/tools/task-set-owner.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
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);
|
||||
},
|
||||
});
|
||||
}
|
||||
35
mcp-server/src/tools/task-set-status.ts
Normal file
35
mcp-server/src/tools/task-set-status.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
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);
|
||||
},
|
||||
});
|
||||
}
|
||||
76
mcp-server/test/output-parser.test.ts
Normal file
76
mcp-server/test/output-parser.test.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
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');
|
||||
});
|
||||
});
|
||||
220
mcp-server/test/schemas.test.ts
Normal file
220
mcp-server/test/schemas.test.ts
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
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();
|
||||
});
|
||||
});
|
||||
78
mcp-server/test/teamctl-runner.test.ts
Normal file
78
mcp-server/test/teamctl-runner.test.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
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');
|
||||
});
|
||||
});
|
||||
42
mcp-server/test/tools/kanban-move.test.ts
Normal file
42
mcp-server/test/tools/kanban-move.test.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
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');
|
||||
});
|
||||
});
|
||||
61
mcp-server/test/tools/kanban-reviewers.test.ts
Normal file
61
mcp-server/test/tools/kanban-reviewers.test.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
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');
|
||||
});
|
||||
});
|
||||
48
mcp-server/test/tools/message-send.test.ts
Normal file
48
mcp-server/test/tools/message-send.test.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
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');
|
||||
});
|
||||
});
|
||||
28
mcp-server/test/tools/register-all.test.ts
Normal file
28
mcp-server/test/tools/register-all.test.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
66
mcp-server/test/tools/review-action.test.ts
Normal file
66
mcp-server/test/tools/review-action.test.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
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');
|
||||
});
|
||||
});
|
||||
49
mcp-server/test/tools/task-attach.test.ts
Normal file
49
mcp-server/test/tools/task-attach.test.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
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');
|
||||
});
|
||||
});
|
||||
33
mcp-server/test/tools/task-briefing.test.ts
Normal file
33
mcp-server/test/tools/task-briefing.test.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
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');
|
||||
});
|
||||
});
|
||||
33
mcp-server/test/tools/task-comment.test.ts
Normal file
33
mcp-server/test/tools/task-comment.test.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
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');
|
||||
});
|
||||
});
|
||||
65
mcp-server/test/tools/task-create.test.ts
Normal file
65
mcp-server/test/tools/task-create.test.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
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');
|
||||
});
|
||||
});
|
||||
29
mcp-server/test/tools/task-get.test.ts
Normal file
29
mcp-server/test/tools/task-get.test.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
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');
|
||||
});
|
||||
});
|
||||
44
mcp-server/test/tools/task-link.test.ts
Normal file
44
mcp-server/test/tools/task-link.test.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
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');
|
||||
});
|
||||
});
|
||||
29
mcp-server/test/tools/task-list.test.ts
Normal file
29
mcp-server/test/tools/task-list.test.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
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');
|
||||
});
|
||||
});
|
||||
42
mcp-server/test/tools/task-set-owner.test.ts
Normal file
42
mcp-server/test/tools/task-set-owner.test.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
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');
|
||||
});
|
||||
});
|
||||
31
mcp-server/test/tools/task-set-status.test.ts
Normal file
31
mcp-server/test/tools/task-set-status.test.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
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');
|
||||
});
|
||||
});
|
||||
58
mcp-server/test/tools/test-helpers.ts
Normal file
58
mcp-server/test/tools/test-helpers.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
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 };
|
||||
}
|
||||
21
mcp-server/tsconfig.json
Normal file
21
mcp-server/tsconfig.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"isolatedModules": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist", "test"]
|
||||
}
|
||||
14
mcp-server/tsup.config.ts
Normal file
14
mcp-server/tsup.config.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { defineConfig } from 'tsup';
|
||||
|
||||
export default defineConfig({
|
||||
entry: ['src/index.ts'],
|
||||
format: ['esm'],
|
||||
target: 'node20',
|
||||
outDir: 'dist',
|
||||
clean: true,
|
||||
sourcemap: true,
|
||||
dts: false,
|
||||
banner: {
|
||||
js: '#!/usr/bin/env node',
|
||||
},
|
||||
});
|
||||
10
mcp-server/vitest.config.ts
Normal file
10
mcp-server/vitest.config.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['test/**/*.test.ts'],
|
||||
testTimeout: 15_000,
|
||||
},
|
||||
});
|
||||
1108
pnpm-lock.yaml
1108
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
|
|
@ -1,2 +1,4 @@
|
|||
packages:
|
||||
- mcp-server
|
||||
ignoredBuiltDependencies:
|
||||
- esbuild
|
||||
|
|
|
|||
|
|
@ -1524,7 +1524,7 @@ export class TeamDataService {
|
|||
const tBlocks = tContent as Record<string, unknown>[];
|
||||
if (tBlocks.some((b) => b.type === 'text')) break; // next text = stop
|
||||
for (const b of tBlocks) {
|
||||
if (b.type === 'tool_use' && typeof b.name === 'string') {
|
||||
if (b.type === 'tool_use' && typeof b.name === 'string' && b.name !== 'SendMessage') {
|
||||
toolCounts.set(b.name, (toolCounts.get(b.name) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2963,7 +2963,11 @@ export class TeamProvisioningService {
|
|||
// These counts will be attached to the next text message as toolSummary.
|
||||
if (run.provisioningComplete) {
|
||||
for (const block of content ?? []) {
|
||||
if (block?.type === 'tool_use' && typeof block.name === 'string') {
|
||||
if (
|
||||
block?.type === 'tool_use' &&
|
||||
typeof block.name === 'string' &&
|
||||
block.name !== 'SendMessage'
|
||||
) {
|
||||
run.pendingToolCounts.set(
|
||||
block.name as string,
|
||||
(run.pendingToolCounts.get(block.name as string) ?? 0) + 1
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { ChatHistoryEmptyState } from './ChatHistoryEmptyState';
|
|||
import { ChatHistoryItem } from './ChatHistoryItem';
|
||||
import { ChatHistoryLoadingState } from './ChatHistoryLoadingState';
|
||||
|
||||
import { formatPercentOfTotal, sumContextInjectionTokens } from '@renderer/utils/contextMath';
|
||||
import type { ContextInjection } from '@renderer/types/contextInjection';
|
||||
|
||||
/**
|
||||
|
|
@ -190,6 +191,11 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
|
|||
return { allContextInjections: injections, lastAiGroupTotalTokens: totalTokens };
|
||||
}, [sessionContextStats, conversation, selectedContextPhase, sessionPhaseInfo]);
|
||||
|
||||
const visibleContextPercentLabel = useMemo(() => {
|
||||
const visibleTokens = sumContextInjectionTokens(allContextInjections);
|
||||
return formatPercentOfTotal(visibleTokens, lastAiGroupTotalTokens);
|
||||
}, [allContextInjections, lastAiGroupTotalTokens]);
|
||||
|
||||
// State for navigation highlight (blue, used for Turn navigation from CLAUDE.md panel)
|
||||
const [isNavigationHighlight, setIsNavigationHighlight] = useState(false);
|
||||
const navigationHighlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
|
@ -828,7 +834,7 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => {
|
|||
: 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
Context ({allContextInjections.length})
|
||||
{visibleContextPercentLabel ?? `Context (${allContextInjections.length})`}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -456,16 +456,19 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
|
||||
// Keep lead-session context fresh in the background while the team tab is active.
|
||||
// This keeps the button value current even when the panel is closed.
|
||||
// For offline teams: fetch once on mount so the percentage shows immediately.
|
||||
// For alive teams: fetch on mount + periodic refresh every 30s.
|
||||
useEffect(() => {
|
||||
if (!isThisTabActive) return;
|
||||
if (!tabId || !projectId || !leadSessionId) return;
|
||||
|
||||
void fetchSessionDetail(projectId, leadSessionId, tabId, { silent: true });
|
||||
|
||||
if (!data?.isAlive) return;
|
||||
|
||||
const tick = (): void => {
|
||||
const id = window.setInterval(() => {
|
||||
void fetchSessionDetail(projectId, leadSessionId, tabId, { silent: true });
|
||||
};
|
||||
tick();
|
||||
const id = window.setInterval(tick, 30_000);
|
||||
}, 30_000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [isThisTabActive, tabId, projectId, leadSessionId, data?.isAlive, fetchSessionDetail]);
|
||||
|
||||
|
|
|
|||
|
|
@ -326,13 +326,11 @@ export const ActivityTimeline = ({
|
|||
sessionSeparator = (
|
||||
<div
|
||||
className="flex items-center gap-3"
|
||||
style={{ paddingTop: 30, paddingBottom: 30 }}
|
||||
style={{ paddingTop: 90, paddingBottom: 90 }}
|
||||
>
|
||||
<div className="h-px flex-1 bg-[var(--color-border-emphasis)]" />
|
||||
<span className="whitespace-nowrap text-[11px] text-[var(--color-text-muted)]">
|
||||
New session
|
||||
</span>
|
||||
<div className="h-px flex-1 bg-[var(--color-border-emphasis)]" />
|
||||
<div className="h-px flex-1 bg-blue-400/30" />
|
||||
<span className="whitespace-nowrap text-[11px] text-blue-400">New session</span>
|
||||
<div className="h-px flex-1 bg-blue-400/30" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -413,7 +411,7 @@ export const ActivityTimeline = ({
|
|||
<span className="text-[11px] tabular-nums text-[var(--color-text-muted)]">
|
||||
+{hiddenCount} older
|
||||
</span>
|
||||
<span className="h-3 w-px bg-[var(--color-border-emphasis)]" />
|
||||
<span className="h-3 w-px bg-blue-400/30" />
|
||||
<button
|
||||
onClick={handleShowMore}
|
||||
className="rounded-full px-2.5 py-0.5 text-[11px] font-medium text-[var(--color-text-secondary)] transition-all hover:bg-[rgba(255,255,255,0.08)] hover:text-[var(--color-text)]"
|
||||
|
|
@ -422,7 +420,7 @@ export const ActivityTimeline = ({
|
|||
</button>
|
||||
{hiddenCount > MESSAGES_PAGE_SIZE && (
|
||||
<>
|
||||
<span className="h-3 w-px bg-[var(--color-border-emphasis)]" />
|
||||
<span className="h-3 w-px bg-blue-400/30" />
|
||||
<button
|
||||
onClick={handleShowAll}
|
||||
className="rounded-full px-2.5 py-0.5 text-[11px] text-[var(--color-text-muted)] transition-all hover:bg-[rgba(255,255,255,0.08)] hover:text-[var(--color-text-secondary)]"
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
CARD_TEXT_LIGHT,
|
||||
} from '@renderer/constants/cssVariables';
|
||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { formatToolSummary, parseToolSummary } from '@shared/utils/toolSummary';
|
||||
|
||||
|
|
@ -101,6 +102,27 @@ function isRecentTimestamp(timestamp: string): boolean {
|
|||
return Date.now() - t <= LIVE_WINDOW_MS;
|
||||
}
|
||||
|
||||
function ToolSummaryTooltipContent({ summary }: { summary: string }): JSX.Element {
|
||||
const parsed = parseToolSummary(summary);
|
||||
if (!parsed) return <span>{summary}</span>;
|
||||
|
||||
const sorted = Object.entries(parsed.byName).sort((a, b) => b[1] - a[1]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="mb-0.5 text-[10px] text-text-secondary">
|
||||
{parsed.total} {parsed.total === 1 ? 'tool call' : 'tool calls'}
|
||||
</div>
|
||||
{sorted.map(([name, count]) => (
|
||||
<div key={name} className="flex justify-between gap-3">
|
||||
<span>{name}</span>
|
||||
<span className="text-text-secondary">{count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const LeadThoughtsGroupRow = ({
|
||||
group,
|
||||
memberColor,
|
||||
|
|
@ -244,9 +266,16 @@ export const LeadThoughtsGroupRow = ({
|
|||
: `${formatTime(oldest.timestamp)}–${formatTime(newest.timestamp)}`}
|
||||
</span>
|
||||
{totalToolSummary && (
|
||||
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
|
||||
{totalToolSummary}
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="cursor-default text-[10px]" style={{ color: CARD_ICON_MUTED }}>
|
||||
{totalToolSummary}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="font-mono text-[11px]">
|
||||
<ToolSummaryTooltipContent summary={totalToolSummary} />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
@ -299,12 +328,19 @@ export const LeadThoughtsGroupRow = ({
|
|||
</div>
|
||||
</div>
|
||||
{thought.toolSummary && (
|
||||
<div
|
||||
className="px-1 pb-0.5 font-mono text-[9px]"
|
||||
style={{ color: CARD_ICON_MUTED }}
|
||||
>
|
||||
🔧 {thought.toolSummary}
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className="cursor-default pb-0.5 pl-3 pr-1 font-mono text-[9px]"
|
||||
style={{ color: CARD_ICON_MUTED }}
|
||||
>
|
||||
🔧 {thought.toolSummary}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="font-mono text-[11px]">
|
||||
<ToolSummaryTooltipContent summary={thought.toolSummary} />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
|
|
|||
Loading…
Reference in a new issue