Skip to content

Commit d6eb661

Browse files
committed
feat(mcp): 添加 MCP 服务器状态查看功能
- 新增 /mcp 斜杠命令用于查看服务器状态和工具列表 - 改进 mcp list 命令显示服务器健康状态 - 优化 MCP 客户端连接处理和错误分类 - 添加服务器详情和工具列表展示功能 - 更新帮助信息和文档提示
1 parent 303a263 commit d6eb661

8 files changed

Lines changed: 609 additions & 61 deletions

File tree

src/blade.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export async function main() {
3636
.demandCommand(0, '')
3737
.recommendCommands()
3838
.strict(cliConfig.strict)
39+
.parserConfiguration({ 'populate--': true })
3940

4041
// 应用全局选项
4142
.options(globalOptions)

src/commands/mcp.ts

Lines changed: 116 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,31 @@
66
import type { CommandModule } from 'yargs';
77
import { ConfigManager } from '../config/ConfigManager.js';
88
import type { McpServerConfig } from '../config/types.js';
9+
import { McpRegistry } from '../mcp/McpRegistry.js';
10+
import { McpConnectionStatus } from '../mcp/types.js';
11+
12+
/**
13+
* 显示 MCP 命令的帮助信息
14+
*/
15+
function showMcpHelp(): void {
16+
console.log('\nblade mcp\n');
17+
console.log('管理 MCP 服务器\n');
18+
console.log('Commands:');
19+
console.log(' blade mcp add <name> <commandOrUrl> [args...] 添加 MCP 服务器');
20+
console.log(' blade mcp remove <name> 删除 MCP 服务器 [aliases: rm]');
21+
console.log(' blade mcp list 列出所有 MCP 服务器并检查健康状态 [aliases: ls]');
22+
console.log(' blade mcp get <name> 获取服务器详情');
23+
console.log(' blade mcp add-json <name> <json> 从 JSON 字符串添加服务器');
24+
console.log(' blade mcp reset-project-choices 重置项目级 .mcp.json 确认记录\n');
25+
console.log('Options:');
26+
console.log(' -h, --help 显示帮助信息 [boolean]\n');
27+
console.log('Examples:');
28+
console.log(' blade mcp list');
29+
console.log(' blade mcp add github npx -y @modelcontextprotocol/server-github');
30+
console.log(' blade mcp add api --transport http http://localhost:3000');
31+
console.log(' blade mcp remove github\n');
32+
console.log('使用 blade mcp <command> --help 查看各子命令的详细帮助\n');
33+
}
934

1035
// 工具函数:解析环境变量数组
1136
function parseEnvArray(envArray: string[]): Record<string, string> {
@@ -27,7 +52,7 @@ function parseHeaderArray(headerArray: string[]): Record<string, string> {
2752

2853
// MCP Add 子命令
2954
const mcpAddCommand: CommandModule = {
30-
command: 'add <name> <commandOrUrl> [args...]',
55+
command: 'add <name> [commandOrUrl] [args...]',
3156
describe: '添加 MCP 服务器',
3257
builder: (yargs) => {
3358
return yargs
@@ -39,7 +64,6 @@ const mcpAddCommand: CommandModule = {
3964
.positional('commandOrUrl', {
4065
type: 'string',
4166
describe: 'stdio: 命令 | http/sse: URL',
42-
demandOption: true,
4367
})
4468
.positional('args', {
4569
type: 'string',
@@ -69,7 +93,11 @@ const mcpAddCommand: CommandModule = {
6993
})
7094
.example([
7195
[
72-
'$0 mcp add github npx -y @modelcontextprotocol/server-github -e GITHUB_TOKEN=xxx',
96+
'$0 mcp add github -- npx -y @modelcontextprotocol/server-github',
97+
'Add stdio server (recommended)',
98+
],
99+
[
100+
'$0 mcp add github npx @modelcontextprotocol/server-github -e GITHUB_TOKEN=xxx',
73101
'Add stdio server with env',
74102
],
75103
[
@@ -81,7 +109,27 @@ const mcpAddCommand: CommandModule = {
81109
handler: async (argv: any) => {
82110
try {
83111
const configManager = ConfigManager.getInstance();
84-
const { name, commandOrUrl, args, transport, env, header, timeout } = argv;
112+
let { name, commandOrUrl, args, transport, env, header, timeout } = argv;
113+
114+
// 处理 -- 分隔符的情况
115+
// 当使用 `blade mcp add name -- command args` 时,yargs 会把 -- 后的内容放到 argv['--'] 中
116+
if (argv['--'] && argv['--'].length > 0) {
117+
// argv['--'] = ['command', ...args]
118+
commandOrUrl = argv['--'][0];
119+
args = argv['--'].slice(1);
120+
}
121+
122+
// 验证必需参数
123+
if (!name || !commandOrUrl) {
124+
console.error('❌ 缺少必需参数: name 和 commandOrUrl');
125+
console.log('\n💡 用法:');
126+
console.log(' blade mcp add <name> <command> [args...]');
127+
console.log(' blade mcp add <name> -- <command> [args...]');
128+
console.log('\n示例:');
129+
console.log(' blade mcp add github npx -y @modelcontextprotocol/server-github');
130+
console.log(' blade mcp add github -- npx -y @modelcontextprotocol/server-github');
131+
process.exit(1);
132+
}
85133

86134
const config: McpServerConfig = { type: transport };
87135

@@ -152,7 +200,7 @@ const mcpRemoveCommand: CommandModule = {
152200
// MCP List 子命令
153201
const mcpListCommand: CommandModule = {
154202
command: 'list',
155-
describe: '列出所有 MCP 服务器',
203+
describe: '列出所有 MCP 服务器并检查健康状态',
156204
aliases: ['ls'],
157205
handler: async () => {
158206
try {
@@ -166,29 +214,59 @@ const mcpListCommand: CommandModule = {
166214
return;
167215
}
168216

169-
console.log('MCP 服务器列表:\n');
170-
for (const [name, config] of Object.entries(servers)) {
171-
console.log(`📦 ${name}`);
172-
console.log(` 类型: ${config.type}`);
217+
console.log('检查 MCP 服务器健康状态...\n');
173218

174-
if (config.type === 'stdio') {
175-
console.log(` 命令: ${config.command} ${config.args?.join(' ') || ''}`);
176-
if (config.env && Object.keys(config.env).length > 0) {
177-
console.log(` 环境变量: ${Object.keys(config.env).join(', ')}`);
178-
}
179-
} else {
180-
console.log(` URL: ${config.url}`);
181-
if (config.headers) {
182-
console.log(` Headers: ${Object.keys(config.headers).length} 个`);
219+
const mcpRegistry = McpRegistry.getInstance();
220+
221+
// 尝试连接所有服务器
222+
const checkPromises = Object.entries(servers).map(async ([name, config]) => {
223+
try {
224+
// 检查服务器是否已注册
225+
let serverInfo = mcpRegistry.getServerStatus(name);
226+
227+
if (!serverInfo) {
228+
// 如果未注册,先注册
229+
await mcpRegistry.registerServer(name, config);
230+
serverInfo = mcpRegistry.getServerStatus(name);
231+
} else if (serverInfo.status === McpConnectionStatus.DISCONNECTED) {
232+
// 如果已注册但未连接,尝试连接
233+
await mcpRegistry.connectServer(name);
183234
}
235+
236+
return { name, config, serverInfo };
237+
} catch (error) {
238+
return { name, config, serverInfo: null, error };
184239
}
240+
});
241+
242+
const results = await Promise.all(checkPromises);
243+
244+
// 显示结果
245+
for (const { name, config, serverInfo, error } of results) {
246+
const status = serverInfo?.status || McpConnectionStatus.DISCONNECTED;
247+
const statusSymbol = status === McpConnectionStatus.CONNECTED ? '✓' : '✗';
248+
const statusText = status === McpConnectionStatus.CONNECTED ? 'Connected' : 'Failed';
185249

186-
if (config.timeout) {
187-
console.log(` 超时: ${config.timeout}ms`);
250+
console.log(`${name}: ${config.type === 'stdio' ? config.command : config.url} - ${statusSymbol} ${statusText}`);
251+
252+
if (error && status !== McpConnectionStatus.CONNECTED) {
253+
console.log(` 错误: ${error instanceof Error ? error.message : String(error)}`);
188254
}
255+
}
256+
257+
console.log('');
189258

190-
console.log('');
259+
// 断开所有服务器连接并清理
260+
for (const { name } of results) {
261+
try {
262+
await mcpRegistry.unregisterServer(name);
263+
} catch (error) {
264+
// 忽略断开连接时的错误
265+
}
191266
}
267+
268+
// 确保进程退出
269+
process.exit(0);
192270
} catch (error) {
193271
console.error(
194272
`❌ 列表获取失败: ${error instanceof Error ? error.message : '未知错误'}`
@@ -309,16 +387,23 @@ export const mcpCommands: CommandModule = {
309387
.command(mcpGetCommand)
310388
.command(mcpAddJsonCommand)
311389
.command(mcpResetProjectChoicesCommand)
312-
.demandCommand(1, '请指定子命令')
313-
.help()
314-
.example([
315-
['$0 mcp list', 'List all MCP servers'],
316-
['$0 mcp add github npx -y @modelcontextprotocol/server-github', 'Add stdio server'],
317-
['$0 mcp add api --transport http http://localhost:3000', 'Add HTTP server'],
318-
['$0 mcp remove github', 'Remove server'],
319-
]);
390+
.demandCommand(0) // 允许不传子命令
391+
.help(false) // 禁用自动帮助,我们自己处理
392+
.option('help', {
393+
alias: 'h',
394+
type: 'boolean',
395+
describe: '显示帮助信息',
396+
});
320397
},
321-
handler: () => {
322-
// 如果没有子命令,yargs 会自动显示帮助
398+
handler: (argv: any) => {
399+
// 检查是否有子命令
400+
const subcommands = ['add', 'remove', 'list', 'ls', 'get', 'add-json', 'reset-project-choices'];
401+
const hasSubcommand = argv._.some((arg: string) => subcommands.includes(arg));
402+
403+
// 如果没有子命令或者显式请求帮助,显示帮助信息
404+
if (!hasSubcommand || argv.help) {
405+
showMcpHelp();
406+
process.exit(0);
407+
}
323408
},
324409
};

src/mcp/McpClient.ts

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,20 @@
44
* 支持重试、自动重连、错误分类、OAuth 认证、健康监控
55
*/
66

7-
import { EventEmitter } from 'events';
87
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
9-
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
108
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
9+
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
1110
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
11+
import { EventEmitter } from 'events';
12+
import type { McpServerConfig } from '../config/types.js';
13+
import { getPackageName, getVersion } from '../utils/packageInfo.js';
14+
import { OAuthProvider } from './auth/index.js';
15+
import { type HealthCheckConfig, HealthMonitor } from './HealthMonitor.js';
1216
import {
1317
McpConnectionStatus,
1418
type McpToolCallResponse,
1519
type McpToolDefinition,
1620
} from './types.js';
17-
import type { McpServerConfig } from '../config/types.js';
18-
import { getPackageName, getVersion } from '../utils/packageInfo.js';
19-
import { OAuthProvider } from './auth/index.js';
20-
import { HealthMonitor, type HealthCheckConfig } from './HealthMonitor.js';
2121

2222
/**
2323
* 错误类型枚举
@@ -73,7 +73,11 @@ function classifyError(error: unknown): ClassifiedError {
7373
}
7474

7575
// 认证错误(不应自动重试,需要用户介入)
76-
if (msg.includes('unauthorized') || msg.includes('401') || msg.includes('authentication failed')) {
76+
if (
77+
msg.includes('unauthorized') ||
78+
msg.includes('401') ||
79+
msg.includes('authentication failed')
80+
) {
7781
return {
7882
type: ErrorType.AUTH_ERROR,
7983
isRetryable: false,
@@ -219,7 +223,9 @@ export class McpClient extends EventEmitter {
219223
// 如果还有重试机会,等待后重试
220224
if (attempt < maxRetries) {
221225
const delay = initialDelay * Math.pow(2, attempt - 1); // 指数退避
222-
console.warn(`[McpClient] 连接失败(${attempt}/${maxRetries}),${delay}ms 后重试...`);
226+
console.warn(
227+
`[McpClient] 连接失败(${attempt}/${maxRetries}),${delay}ms 后重试...`
228+
);
223229
await new Promise((resolve) => setTimeout(resolve, delay));
224230
}
225231
}
@@ -326,7 +332,9 @@ export class McpClient extends EventEmitter {
326332
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000);
327333
this.reconnectAttempts++;
328334

329-
console.log(`[McpClient] 将在 ${delay}ms 后进行第 ${this.reconnectAttempts} 次重连...`);
335+
console.log(
336+
`[McpClient] 将在 ${delay}ms 后进行第 ${this.reconnectAttempts} 次重连...`
337+
);
330338

331339
this.reconnectTimer = setTimeout(async () => {
332340
try {
@@ -433,7 +441,7 @@ export class McpClient extends EventEmitter {
433441
const { type, command, args, env, url, headers, oauth } = this.config;
434442

435443
// 准备请求头(可能包含 OAuth 令牌)
436-
let finalHeaders = { ...headers };
444+
const finalHeaders = { ...headers };
437445

438446
// 如果启用了 OAuth 且是 HTTP/SSE 传输
439447
if (oauth?.enabled && this.oauthProvider && (type === 'sse' || type === 'http')) {
@@ -444,15 +452,20 @@ export class McpClient extends EventEmitter {
444452
if (!token) {
445453
// 没有令牌,需要认证
446454
console.log(`[McpClient] 服务器 "${this.serverName}" 需要 OAuth 认证`);
447-
const newToken = await this.oauthProvider.authenticate(this.serverName, oauth);
455+
const newToken = await this.oauthProvider.authenticate(
456+
this.serverName,
457+
oauth
458+
);
448459
finalHeaders['Authorization'] = `Bearer ${newToken.accessToken}`;
449460
} else {
450461
// 有有效令牌
451462
finalHeaders['Authorization'] = `Bearer ${token}`;
452463
}
453464
} catch (error) {
454465
console.error('[McpClient] OAuth 认证失败:', error);
455-
throw new Error(`OAuth 认证失败: ${error instanceof Error ? error.message : String(error)}`);
466+
throw new Error(
467+
`OAuth 认证失败: ${error instanceof Error ? error.message : String(error)}`
468+
);
456469
}
457470
}
458471

@@ -471,6 +484,7 @@ export class McpClient extends EventEmitter {
471484
command,
472485
args: args || [],
473486
env: { ...processEnv, ...env },
487+
stderr: 'ignore', // 忽略子进程的 stderr 输出
474488
});
475489
} else if (type === 'sse') {
476490
if (!url) {

src/slash-commands/builtinCommands.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
*/
44

55
import compactCommand from './compact.js';
6+
import mcpCommand from './mcp.js';
67
import permissionsCommand from './permissions.js';
78
import resumeCommand from './resume.js';
89
import type { SlashCommand, SlashCommandContext, SlashCommandResult } from './types.js';
@@ -22,6 +23,7 @@ const helpCommand: SlashCommand = {
2223
const helpText = `🔧 **可用的 Slash Commands:**
2324
2425
**/init** - 分析当前项目并生成 BLADE.md 配置文件
26+
**/mcp** - 显示 MCP 服务器状态和可用工具
2527
**/help** - 显示此帮助信息
2628
**/clear** - 清除屏幕内容
2729
**/resume** - 恢复历史会话
@@ -315,4 +317,5 @@ export const builtinCommands = {
315317
permissions: permissionsCommand,
316318
resume: resumeCommand,
317319
compact: compactCommand,
320+
mcp: mcpCommand,
318321
};

0 commit comments

Comments
 (0)