Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

### ✨ 新特性 (Feat)

- **MCP 批量操作**: 模块工具新增 `ids` 参数,支持批量获取、更新或删除对象,并按 `batchFailFast` 配置聚合成功、失败和跳过结果。

### 🐛 修复 (Fix)

- **Skill 命令契约**: 对齐 `zentao-api` 当前模块 schema,修正列表作用域、创建字段、状态流转必填参数、过滤 OR 语义和分页说明。
Expand Down
95 changes: 92 additions & 3 deletions src/mcp/tools.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { z } from 'zod';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
import { getAllModules } from '../modules/helper.js';
import { getAction, getAllModules } from '../modules/helper.js';
import type { ModuleDefinition, ModuleAction, ModuleActionOptions } from '../types/index.js';
import { executeModuleCommand } from '../modules/executor.js';
import { ZentaoError } from '../errors.js';
Expand All @@ -18,6 +18,12 @@ function buildToolDescription(mod: ModuleDefinition): string {
parts.push(`${mod.display ?? mod.name} 管理`);
}
parts.push(`支持操作: ${actions.join(', ')}`);
const batchActions = mod.actions
.filter(action => action.type === 'get' || action.type === 'update' || action.type === 'delete')
.map(action => action.name);
if (batchActions.length > 0) {
parts.push(`批量操作: ${batchActions.join(', ')} 可通过 ids 传入多个对象 ID`);
}

const listAction = mod.actions.find(a => a.type === 'list');
if (listAction?.pathParams && 'scope' in listAction.pathParams) {
Expand All @@ -42,11 +48,20 @@ function buildActionEnum(mod: ModuleDefinition): [string, ...string[]] {

function buildInputSchema(mod: ModuleDefinition) {
const actionEnum = buildActionEnum(mod);
const supportsBatch = mod.actions.some(action =>
action.type === 'get' || action.type === 'update' || action.type === 'delete'
);
return {
action: z.enum(actionEnum).describe('要执行的操作。' + mod.actions.map(a =>
`${a.name}: ${a.display ?? a.name}`
).join('; ')),
id: z.number().optional().describe('对象 ID(get/update/delete 及扩展操作必填)'),
...(supportsBatch ? {
ids: z.array(z.number().int().positive()).min(1).optional()
.describe('批量对象 ID(仅支持 get/update/delete;不能与 id 同时使用)'),
batchFailFast: z.boolean().optional()
.describe('批量操作遇到错误时立即停止;默认使用当前用户配置'),
} : {}),
product: z.number().optional().describe('产品 ID(范围参数)'),
project: z.number().optional().describe('项目 ID(范围参数)'),
execution: z.number().optional().describe('执行 ID(范围参数)'),
Expand All @@ -64,6 +79,8 @@ function buildInputSchema(mod: ModuleDefinition) {
interface ToolInput {
action: string;
id?: number;
ids?: number[];
batchFailFast?: boolean;
product?: number;
project?: number;
execution?: number;
Expand All @@ -77,6 +94,16 @@ interface ToolInput {
recPerPage?: number;
}

function serializeBatchError(error: unknown): Record<string, unknown> {
if (error instanceof ZentaoError) {
return {
code: `E${error.code}`,
message: error.message,
};
}
return { message: error instanceof Error ? error.message : String(error) };
}

async function handleProfileTool(auth: AuthProvider): Promise<CallToolResult> {
const client = await auth.getClient();
const profile = getCurrentProfile();
Expand Down Expand Up @@ -126,15 +153,41 @@ async function handleSwitchProfileTool(input: SwitchProfileInput, auth: AuthProv
};
}

async function handleModuleTool(
export async function handleModuleTool(
mod: ModuleDefinition,
input: ToolInput,
auth: AuthProvider,
): Promise<CallToolResult> {
const actionName = input.action;
const action = getAction(mod, actionName);
if (!action) {
throw new ZentaoError('E2005', { module: mod.name });
}

if (input.ids !== undefined && (input.id !== undefined || input.params?.id !== undefined)) {
throw new ZentaoError('E2009', {
option: 'ids',
reason: '不能与 id 或 params.id 同时使用',
});
}
if (input.ids !== undefined) {
if (input.ids.length === 0 || !input.ids.every(id => Number.isInteger(id) && id > 0)) {
throw new ZentaoError('E2009', {
option: 'ids',
reason: '必须是非空的正整数数组',
});
}
if (action.type !== 'get' && action.type !== 'update' && action.type !== 'delete') {
throw new ZentaoError('E2009', {
option: 'ids',
reason: `操作 ${actionName} 不支持批量执行,仅 get/update/delete 支持 ids`,
});
}
}

const client = await auth.getClient();
const profile = getCurrentProfile();
const config = profile ? getProfileConfig(profile) : DEFAULT_CONFIG;
const actionName = input.action;

const opts: ModuleActionOptions = {
id: input.id != null ? String(input.id) : undefined,
Expand All @@ -153,6 +206,42 @@ async function handleModuleTool(
yes: true,
};

if (input.ids !== undefined) {
const results: Array<{ id: number; data: unknown }> = [];
const errors: Array<{ id: number; error: Record<string, unknown> }> = [];
let skipped: number[] = [];
const failFast = input.batchFailFast ?? config.batchFailFast;

for (const [index, id] of input.ids.entries()) {
try {
const execution = await executeModuleCommand(
client,
mod,
actionName,
[],
{ ...opts, id: String(id) },
config,
);
results.push({ id, data: execution.data ?? execution.rawResponse });
} catch (error) {
errors.push({ id, error: serializeBatchError(error) });
if (failFast) {
skipped = input.ids.slice(index + 1);
break;
}
}
}

const status = errors.length === 0 ? 'success' : results.length === 0 ? 'fail' : 'partial';
const response: Record<string, unknown> = { status, results };
if (errors.length > 0) response.errors = errors;
if (skipped.length > 0) response.skipped = skipped;
return {
...(status === 'fail' ? { isError: true } : {}),
content: [{ type: 'text', text: JSON.stringify(response, null, 2) }],
};
}

const execution = await executeModuleCommand(client, mod, actionName, [], opts, config);

if (execution.action.type === 'list') {
Expand Down
164 changes: 164 additions & 0 deletions tests/mcp-e2e.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { describe, test, expect } from 'bun:test';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import { getAllModules } from '../src/modules';
Expand Down Expand Up @@ -38,10 +40,172 @@ describe('MCP server (stdio e2e smoke)', () => {
const bugTool = tools.find(t => t.name === 'zentao_bug');
expect(bugTool?.annotations?.readOnlyHint).toBe(false);
expect(bugTool?.annotations?.destructiveHint).toBe(true);
expect(bugTool?.description).toContain('批量操作: get, update, delete');
expect(bugTool?.inputSchema.properties?.ids).toMatchObject({
type: 'array',
minItems: 1,
items: { type: 'integer', exclusiveMinimum: 0 },
description: expect.stringContaining('get/update/delete'),
});
expect(bugTool?.inputSchema.properties?.batchFailFast).toMatchObject({
type: 'boolean',
});
} finally {
await client.close();
}
},
{ timeout: 20_000 },
);

test(
'callTool batches get/update/delete and reports aggregate status',
async () => {
const requestedIds: number[] = [];
const requestedMethods: string[] = [];
const updateBodies: Array<{ id: number; body: unknown }> = [];
const api = Bun.serve({
port: 0,
async fetch(req) {
const match = new URL(req.url).pathname.match(/^\/api\.php\/v2\/bugs\/(\d+)$/);
if (!match) return new Response('not found', { status: 404 });
const id = Number(match[1]);
requestedIds.push(id);
requestedMethods.push(req.method);
if (id === 2 || id === 8) return new Response('forbidden', { status: 403 });
if (req.method === 'PUT') {
updateBodies.push({ id, body: await req.json() });
return Response.json({ status: 'success' });
}
return Response.json({ status: 'success', bug: { id, title: `Bug ${id}` } });
},
});
const dir = mkdtempSync(join(tmpdir(), 'zentao-cli-mcp-batch-'));
const configFile = join(dir, 'zentao.json');
const serverUrl = api.url.toString().replace(/\/$/, '');
const account = 'test-user';
writeFileSync(configFile, JSON.stringify({
currentProfile: `${account}@${serverUrl}`,
profiles: [{
server: serverUrl,
account,
token: 'test-token',
loginTime: '2026-01-01T00:00:00.000Z',
lastUsedTime: '2026-01-01T00:00:00.000Z',
}],
}));

const env = Object.fromEntries(
Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined),
);
delete env.ZENTAO_URL;
delete env.ZENTAO_ACCOUNT;
delete env.ZENTAO_PASSWORD;
delete env.ZENTAO_TOKEN;

const transport = new StdioClientTransport({
command: 'bun',
args: ['run', join(repoRoot, 'src/index.ts'), '--config', configFile, 'mcp'],
cwd: repoRoot,
env,
});
const client = new Client({ name: 'zentao-cli-batch-e2e', version: '0.0.0' });

try {
await client.connect(transport);
const response = await client.callTool({
name: 'zentao_bug',
arguments: {
action: 'get',
ids: [1, 2, 3],
batchFailFast: true,
},
});
expect(response.isError).not.toBe(true);
const content = response.content as Array<{ type: string; text?: string }>;
const payload = JSON.parse(content[0]?.text ?? '{}');
expect(payload.status).toBe('partial');
expect(payload.results).toEqual([{ id: 1, data: { id: 1, title: 'Bug 1' } }]);
expect(payload.errors).toEqual([{
id: 2,
error: { code: 'E2006', message: '当前用户没有权限执行此操作' },
}]);
expect(payload.skipped).toEqual([3]);
expect(requestedIds).toEqual([1, 2]);

requestedIds.length = 0;
const continuedResponse = await client.callTool({
name: 'zentao_bug',
arguments: {
action: 'get',
ids: [1, 2, 3],
batchFailFast: false,
},
});
const continuedContent = continuedResponse.content as Array<{ type: string; text?: string }>;
const continued = JSON.parse(continuedContent[0]?.text ?? '{}');
expect(continued.status).toBe('partial');
expect(continued.results).toEqual([
{ id: 1, data: { id: 1, title: 'Bug 1' } },
{ id: 3, data: { id: 3, title: 'Bug 3' } },
]);
expect(continued.errors).toEqual([{
id: 2,
error: { code: 'E2006', message: '当前用户没有权限执行此操作' },
}]);
expect(continued.skipped).toBeUndefined();
expect(requestedIds).toEqual([1, 2, 3]);

requestedIds.length = 0;
requestedMethods.length = 0;
const deleteResponse = await client.callTool({
name: 'zentao_bug',
arguments: { action: 'delete', ids: [4, 5] },
});
const deleteContent = deleteResponse.content as Array<{ type: string; text?: string }>;
const deleted = JSON.parse(deleteContent[0]?.text ?? '{}');
expect(deleted.status).toBe('success');
expect(deleted.results.map((result: { id: number }) => result.id)).toEqual([4, 5]);
expect(deleted.errors).toBeUndefined();
expect(requestedIds).toEqual([4, 5]);
expect(requestedMethods).toEqual(['DELETE', 'DELETE']);

requestedIds.length = 0;
requestedMethods.length = 0;
const updateResponse = await client.callTool({
name: 'zentao_bug',
arguments: {
action: 'update',
ids: [6, 7],
params: { title: 'Batch updated', severity: 2 },
},
});
const updateContent = updateResponse.content as Array<{ type: string; text?: string }>;
const updated = JSON.parse(updateContent[0]?.text ?? '{}');
expect(updated.status).toBe('success');
expect(updated.results.map((result: { id: number }) => result.id)).toEqual([6, 7]);
expect(updateBodies).toEqual([
{ id: 6, body: { title: 'Batch updated', severity: 2 } },
{ id: 7, body: { title: 'Batch updated', severity: 2 } },
]);
expect(requestedIds).toEqual([6, 6, 7, 7]);
expect(requestedMethods).toEqual(['GET', 'PUT', 'GET', 'PUT']);

const failedResponse = await client.callTool({
name: 'zentao_bug',
arguments: { action: 'get', ids: [2, 8], batchFailFast: false },
});
expect(failedResponse.isError).toBe(true);
const failedContent = failedResponse.content as Array<{ type: string; text?: string }>;
const failed = JSON.parse(failedContent[0]?.text ?? '{}');
expect(failed.status).toBe('fail');
expect(failed.results).toEqual([]);
expect(failed.errors.map((entry: { id: number }) => entry.id)).toEqual([2, 8]);
} finally {
await client.close();
api.stop();
rmSync(dir, { recursive: true, force: true });
}
},
{ timeout: 20_000 },
);
});
Loading