@maplezzk/mcps 1.0.29 → 1.0.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -13,6 +13,7 @@
13
13
  - 📊 **表格输出**:清晰的服务器状态和工具列表展示
14
14
  - 🔍 **工具筛选**:按关键词筛选工具,支持简洁模式
15
15
  - 🚨 **详细日志**:可选的详细日志模式,方便调试
16
+ - ✅ **自动化测试**:完整的测试套件,确保代码质量
16
17
 
17
18
  ## 安装
18
19
 
@@ -3,6 +3,30 @@ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
3
3
  import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
4
4
  import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
5
5
  import { EventSource } from 'eventsource';
6
+ import { exec } from 'child_process';
7
+ import { promisify } from 'util';
8
+ import fs from 'fs';
9
+ import path from 'path';
10
+ const execAsync = promisify(exec);
11
+ // 日志文件路径
12
+ const logFile = path.join(process.env.HOME || '~', '.mcps', 'daemon.log');
13
+ // 日志函数(异步,避免阻塞)
14
+ const log = (message) => {
15
+ const timestamp = new Date().toISOString();
16
+ const logMessage = `[${timestamp}] ${message}\n`;
17
+ try {
18
+ fs.appendFileSync(logFile, logMessage);
19
+ }
20
+ catch (e) {
21
+ // 忽略日志写入错误
22
+ }
23
+ try {
24
+ console.log(message);
25
+ }
26
+ catch (e) {
27
+ // 忽略 console 错误
28
+ }
29
+ };
6
30
  // Required for SSEClientTransport in Node.js environment
7
31
  // @ts-ignore
8
32
  global.EventSource = EventSource;
@@ -26,9 +50,22 @@ const resolveEnvPlaceholders = (input) => {
26
50
  export class McpClientService {
27
51
  client = null;
28
52
  transport = null;
29
- async connect(config) {
53
+ serverName = '';
54
+ serverType = '';
55
+ serverCommand = '';
56
+ serverArgs = [];
57
+ daemonPid = process.pid;
58
+ childPids = new Set();
59
+ static globalPidsBeforeConnection = new Set();
60
+ async connect(config, serverName = '') {
61
+ this.serverName = serverName;
62
+ this.serverType = config.type;
63
+ this.daemonPid = process.pid;
30
64
  try {
31
65
  if (config.type === 'stdio') {
66
+ // 保存命令和参数用于后续清理进程
67
+ this.serverCommand = config.command;
68
+ this.serverArgs = config.args || [];
32
69
  const resolvedConfigEnv = {};
33
70
  if (config.env) {
34
71
  for (const key in config.env) {
@@ -68,12 +105,81 @@ export class McpClientService {
68
105
  capabilities: {},
69
106
  });
70
107
  await this.client.connect(this.transport);
108
+ // 连接成功后,立即查找并保存子进程 PID
109
+ if (config.type === 'stdio') {
110
+ await this.recordChildPids();
111
+ }
71
112
  }
72
113
  catch (error) {
73
114
  // Error will be handled by the caller
74
115
  throw error;
75
116
  }
76
117
  }
118
+ // 记录子进程 PID(使用快照差异法)
119
+ async recordChildPids() {
120
+ try {
121
+ // 获取连接前的所有子进程快照
122
+ const getSnapshot = async () => {
123
+ const pids = new Set();
124
+ const getAllDescendants = async (parentPid) => {
125
+ try {
126
+ const { stdout } = await execAsync(`pgrep -P ${parentPid}`);
127
+ const childPids = stdout.trim().split('\n').filter(pid => pid);
128
+ for (const pid of childPids) {
129
+ pids.add(parseInt(pid));
130
+ await getAllDescendants(parseInt(pid));
131
+ }
132
+ }
133
+ catch {
134
+ // 没有子进程
135
+ }
136
+ };
137
+ await getAllDescendants(this.daemonPid);
138
+ return pids;
139
+ };
140
+ // 记录连接前的快照(第一次连接时为空)
141
+ const beforeSnapshot = new Set(McpClientService.globalPidsBeforeConnection);
142
+ // 等待一小段时间,确保子进程完全启动
143
+ await new Promise(resolve => setTimeout(resolve, 500));
144
+ // 获取连接后的快照
145
+ const afterSnapshot = await getSnapshot();
146
+ // 找出差异(新启动的进程)
147
+ const newPids = new Set();
148
+ for (const pid of afterSnapshot) {
149
+ if (!beforeSnapshot.has(pid)) {
150
+ newPids.add(pid);
151
+ }
152
+ }
153
+ // 更新全局快照
154
+ McpClientService.globalPidsBeforeConnection = afterSnapshot;
155
+ // 过滤出匹配我们命令的进程
156
+ const commandBaseName = this.serverCommand.split('/').pop() || this.serverCommand;
157
+ for (const pid of newPids) {
158
+ try {
159
+ const { stdout: cmdOutput } = await execAsync(`ps -p ${pid} -o command=`);
160
+ const cmdLine = cmdOutput.trim();
161
+ // 检查命令行是否匹配
162
+ const isMatch = cmdLine.includes(commandBaseName) ||
163
+ cmdLine.includes('npm exec') || // npx 会转换成 npm exec
164
+ cmdLine.includes('npx') ||
165
+ (this.serverArgs.length > 0 && this.serverArgs.some(arg => {
166
+ return cmdLine.includes(arg) && arg.length > 10;
167
+ }));
168
+ if (isMatch) {
169
+ this.childPids.add(pid);
170
+ // 调试日志已移除
171
+ }
172
+ }
173
+ catch {
174
+ // 进程可能已经不存在了
175
+ }
176
+ }
177
+ }
178
+ catch (e) {
179
+ // 记录失败,不影响主流程
180
+ console.error(`[Daemon] Failed to record child PIDs: ${e}`);
181
+ }
182
+ }
77
183
  async listTools() {
78
184
  if (!this.client)
79
185
  throw new Error('Client not connected');
@@ -87,9 +193,24 @@ export class McpClientService {
87
193
  arguments: args,
88
194
  });
89
195
  }
90
- async close() {
91
- if (this.transport) {
92
- await this.transport.close();
196
+ close() {
197
+ // 对于 stdio 类型的服务器,先杀掉子进程(在关闭 transport 之前)
198
+ if (this.serverType === 'stdio' && this.childPids.size > 0) {
199
+ log(`[Daemon] Closing ${this.serverName}, killing ${this.childPids.size} child process(es)...`);
200
+ // 直接使用 SIGKILL,确保进程被终止
201
+ for (const pid of this.childPids) {
202
+ try {
203
+ process.kill(pid, 'SIGKILL');
204
+ log(`[Daemon] SIGKILLED child process ${pid} (${this.serverName})`);
205
+ }
206
+ catch (e) {
207
+ log(`[Daemon] Failed to kill ${pid}: ${e}`);
208
+ }
209
+ }
210
+ this.childPids.clear();
211
+ log(`[Daemon] All child processes cleared for ${this.serverName}`);
93
212
  }
213
+ // 暂时不关闭 transport,避免卡住
214
+ log(`[Daemon] ${this.serverName} close() completed`);
94
215
  }
95
216
  }
@@ -2,24 +2,29 @@ import fs from 'fs';
2
2
  import path from 'path';
3
3
  import os from 'os';
4
4
  import { ServerConfigSchema } from '../types/config.js';
5
- const CONFIG_DIR = process.env.MCPS_CONFIG_DIR || path.join(os.homedir(), '.mcps');
6
- const CONFIG_FILE = path.join(CONFIG_DIR, 'mcp.json');
5
+ const getDefaultConfigDir = () => process.env.MCPS_CONFIG_DIR || path.join(os.homedir(), '.mcps');
7
6
  export class ConfigManager {
7
+ configDir;
8
+ configFile;
9
+ constructor(configDir) {
10
+ this.configDir = configDir || getDefaultConfigDir();
11
+ this.configFile = path.join(this.configDir, 'mcp.json');
12
+ }
8
13
  ensureConfigDir() {
9
- if (!fs.existsSync(CONFIG_DIR)) {
10
- fs.mkdirSync(CONFIG_DIR, { recursive: true });
14
+ if (!fs.existsSync(this.configDir)) {
15
+ fs.mkdirSync(this.configDir, { recursive: true });
11
16
  }
12
17
  }
13
18
  loadConfig() {
14
19
  this.ensureConfigDir();
15
- if (!fs.existsSync(CONFIG_FILE)) {
20
+ if (!fs.existsSync(this.configFile)) {
16
21
  return { servers: [] };
17
22
  }
18
23
  try {
19
- const content = fs.readFileSync(CONFIG_FILE, 'utf-8');
24
+ const content = fs.readFileSync(this.configFile, 'utf-8');
20
25
  const json = JSON.parse(content);
21
26
  // Log for debugging (can be removed later or controlled by verbose flag)
22
- // console.log('Loading config from:', CONFIG_FILE);
27
+ // console.log('Loading config from:', this.configFile);
23
28
  if (!json || typeof json !== 'object') {
24
29
  console.warn('Invalid config file structure. Expected JSON object.');
25
30
  return { servers: [] };
@@ -59,7 +64,7 @@ export class ConfigManager {
59
64
  }
60
65
  saveConfig(config) {
61
66
  this.ensureConfigDir();
62
- fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8');
67
+ fs.writeFileSync(this.configFile, JSON.stringify(config, null, 2), 'utf-8');
63
68
  }
64
69
  listServers() {
65
70
  return this.loadConfig().servers;
package/dist/core/pool.js CHANGED
@@ -55,7 +55,7 @@ export class ConnectionPool {
55
55
  for (const [name, client] of this.clients) {
56
56
  console.log(`[Daemon] Closing connection to ${name}...`);
57
57
  try {
58
- await client.close();
58
+ client.close(); // 同步调用,不使用 await
59
59
  }
60
60
  catch (e) {
61
61
  console.error(`[Daemon] Error closing ${name}:`, e);
package/dist/index.js CHANGED
File without changes
@@ -0,0 +1,52 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import { vi } from 'vitest';
5
+ export const TEST_CONFIG_DIR = path.join(os.tmpdir(), `mcps-test-${Date.now()}`);
6
+ export function setupTestConfig() {
7
+ // 确保测试配置目录存在
8
+ if (!fs.existsSync(TEST_CONFIG_DIR)) {
9
+ fs.mkdirSync(TEST_CONFIG_DIR, { recursive: true });
10
+ }
11
+ // 设置测试环境变量
12
+ process.env.MCPS_CONFIG_DIR = TEST_CONFIG_DIR;
13
+ return TEST_CONFIG_DIR;
14
+ }
15
+ export function cleanupTestConfig() {
16
+ if (fs.existsSync(TEST_CONFIG_DIR)) {
17
+ fs.rmSync(TEST_CONFIG_DIR, { recursive: true, force: true });
18
+ }
19
+ }
20
+ export function getTestConfigPath() {
21
+ return path.join(TEST_CONFIG_DIR, 'mcp.json');
22
+ }
23
+ export function createTestServer(overrides = {}) {
24
+ return {
25
+ name: 'test-server',
26
+ type: 'stdio',
27
+ command: 'node',
28
+ args: ['--version'],
29
+ ...overrides
30
+ };
31
+ }
32
+ export function createMockClient(toolsCount = 5) {
33
+ const tools = [];
34
+ for (let i = 0; i < toolsCount; i++) {
35
+ tools.push({
36
+ name: `test_tool_${i}`,
37
+ description: `Test tool ${i}`,
38
+ inputSchema: {
39
+ type: 'object',
40
+ properties: {
41
+ param: { type: 'string', description: `Parameter ${i}` }
42
+ }
43
+ }
44
+ });
45
+ }
46
+ return {
47
+ listTools: vi.fn().mockResolvedValue({ tools }),
48
+ callTool: vi.fn().mockResolvedValue({ result: 'success' }),
49
+ connect: vi.fn().mockResolvedValue(undefined),
50
+ close: vi.fn().mockResolvedValue(undefined)
51
+ };
52
+ }
@@ -0,0 +1,218 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import os from 'os';
5
+ import { ConfigManager } from '../../core/config.js';
6
+ describe('ConfigManager', () => {
7
+ let testConfigDir;
8
+ let manager;
9
+ beforeEach(() => {
10
+ // 创建临时配置目录
11
+ testConfigDir = path.join(os.tmpdir(), `mcps-config-test-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`);
12
+ fs.mkdirSync(testConfigDir, { recursive: true });
13
+ // 创建新的 ConfigManager 实例,传入测试目录
14
+ manager = new ConfigManager(testConfigDir);
15
+ });
16
+ afterEach(() => {
17
+ // 清理临时目录
18
+ if (fs.existsSync(testConfigDir)) {
19
+ try {
20
+ fs.rmSync(testConfigDir, { recursive: true, force: true });
21
+ }
22
+ catch (e) {
23
+ // 忽略清理错误
24
+ }
25
+ }
26
+ });
27
+ describe('addServer', () => {
28
+ it('should add a stdio server', () => {
29
+ const server = {
30
+ name: 'test-stdio',
31
+ type: 'stdio',
32
+ command: 'node',
33
+ args: ['--version']
34
+ };
35
+ manager.addServer(server);
36
+ const servers = manager.listServers();
37
+ expect(servers).toHaveLength(1);
38
+ expect(servers[0]).toMatchObject(server);
39
+ });
40
+ it('should add an sse server', () => {
41
+ const server = {
42
+ name: 'test-sse',
43
+ type: 'sse',
44
+ url: 'http://localhost:3000/sse'
45
+ };
46
+ manager.addServer(server);
47
+ const retrieved = manager.getServer('test-sse');
48
+ expect(retrieved).toMatchObject(server);
49
+ });
50
+ it('should add an http server', () => {
51
+ const server = {
52
+ name: 'test-http',
53
+ type: 'http',
54
+ url: 'http://localhost:3000/mcp'
55
+ };
56
+ manager.addServer(server);
57
+ const retrieved = manager.getServer('test-http');
58
+ expect(retrieved).toMatchObject(server);
59
+ });
60
+ it('should add server with disabled flag', () => {
61
+ const server = {
62
+ name: 'disabled-server',
63
+ type: 'stdio',
64
+ command: 'node',
65
+ args: [],
66
+ disabled: true
67
+ };
68
+ manager.addServer(server);
69
+ const retrieved = manager.getServer('disabled-server');
70
+ expect(retrieved).toMatchObject({
71
+ name: 'disabled-server',
72
+ disabled: true
73
+ });
74
+ });
75
+ it('should throw error when adding duplicate server', () => {
76
+ const server = {
77
+ name: 'duplicate',
78
+ type: 'stdio',
79
+ command: 'node',
80
+ args: []
81
+ };
82
+ manager.addServer(server);
83
+ expect(() => manager.addServer(server)).toThrow('already exists');
84
+ });
85
+ });
86
+ describe('getServer', () => {
87
+ it('should retrieve existing server', () => {
88
+ const server = {
89
+ name: 'to-retrieve',
90
+ type: 'stdio',
91
+ command: 'node',
92
+ args: []
93
+ };
94
+ manager.addServer(server);
95
+ const retrieved = manager.getServer('to-retrieve');
96
+ expect(retrieved).toMatchObject(server);
97
+ });
98
+ it('should return undefined for non-existing server', () => {
99
+ const retrieved = manager.getServer('non-existing');
100
+ expect(retrieved).toBeUndefined();
101
+ });
102
+ });
103
+ describe('listServers', () => {
104
+ it('should return empty array when no servers', () => {
105
+ const servers = manager.listServers();
106
+ expect(servers).toEqual([]);
107
+ });
108
+ it('should return all servers including disabled ones', () => {
109
+ const server1 = {
110
+ name: 'server-1',
111
+ type: 'stdio',
112
+ command: 'node',
113
+ args: []
114
+ };
115
+ const server2 = {
116
+ name: 'server-2',
117
+ type: 'stdio',
118
+ command: 'npm',
119
+ args: ['start'],
120
+ disabled: true
121
+ };
122
+ manager.addServer(server1);
123
+ manager.addServer(server2);
124
+ const servers = manager.listServers();
125
+ expect(servers).toHaveLength(2);
126
+ });
127
+ });
128
+ describe('removeServer', () => {
129
+ it('should remove existing server', () => {
130
+ const server = {
131
+ name: 'to-remove',
132
+ type: 'stdio',
133
+ command: 'node',
134
+ args: []
135
+ };
136
+ manager.addServer(server);
137
+ expect(manager.listServers()).toHaveLength(1);
138
+ manager.removeServer('to-remove');
139
+ expect(manager.listServers()).toHaveLength(0);
140
+ });
141
+ it('should throw error when removing non-existing server', () => {
142
+ expect(() => manager.removeServer('non-existing')).toThrow('not found');
143
+ });
144
+ });
145
+ describe('updateServer', () => {
146
+ it('should update server configuration', () => {
147
+ const original = {
148
+ name: 'to-update',
149
+ type: 'stdio',
150
+ command: 'node',
151
+ args: ['--version']
152
+ };
153
+ manager.addServer(original);
154
+ const updates = {
155
+ command: 'npm',
156
+ args: ['start']
157
+ };
158
+ manager.updateServer('to-update', updates);
159
+ const updated = manager.getServer('to-update');
160
+ expect(updated).toMatchObject({
161
+ name: 'to-update',
162
+ command: 'npm',
163
+ args: ['start']
164
+ });
165
+ });
166
+ it('should preserve disabled status during update', () => {
167
+ const original = {
168
+ name: 'update-disabled',
169
+ type: 'stdio',
170
+ command: 'node',
171
+ args: [],
172
+ disabled: true
173
+ };
174
+ manager.addServer(original);
175
+ manager.updateServer('update-disabled', { command: 'npm' });
176
+ const updated = manager.getServer('update-disabled');
177
+ expect(updated?.disabled).toBe(true);
178
+ expect(updated?.command).toBe('npm');
179
+ });
180
+ it('should throw error when updating non-existing server', () => {
181
+ expect(() => manager.updateServer('non-existing', {})).toThrow('not found');
182
+ });
183
+ });
184
+ describe('persistence', () => {
185
+ it('should persist configuration to file', () => {
186
+ const server = {
187
+ name: 'persistent',
188
+ type: 'stdio',
189
+ command: 'node',
190
+ args: []
191
+ };
192
+ manager.addServer(server);
193
+ const configFile = path.join(testConfigDir, 'mcp.json');
194
+ expect(fs.existsSync(configFile)).toBe(true);
195
+ const content = JSON.parse(fs.readFileSync(configFile, 'utf-8'));
196
+ expect(content.servers).toHaveLength(1);
197
+ expect(content.servers[0].name).toBe('persistent');
198
+ });
199
+ it('should load existing configuration on instantiation', () => {
200
+ const configFile = path.join(testConfigDir, 'mcp.json');
201
+ const existingConfig = {
202
+ servers: [
203
+ {
204
+ name: 'existing',
205
+ type: 'stdio',
206
+ command: 'node',
207
+ args: []
208
+ }
209
+ ]
210
+ };
211
+ fs.writeFileSync(configFile, JSON.stringify(existingConfig, null, 2));
212
+ const newManager = new ConfigManager(testConfigDir);
213
+ const servers = newManager.listServers();
214
+ expect(servers).toHaveLength(1);
215
+ expect(servers[0].name).toBe('existing');
216
+ });
217
+ });
218
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maplezzk/mcps",
3
- "version": "1.0.29",
3
+ "version": "1.0.30",
4
4
  "description": "A CLI to manage and use MCP servers",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -14,6 +14,10 @@
14
14
  ],
15
15
  "author": "",
16
16
  "license": "ISC",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "https://github.com/a13835614623/mcps"
20
+ },
17
21
  "type": "module",
18
22
  "bin": {
19
23
  "mcps": "./dist/index.js"
@@ -23,10 +27,13 @@
23
27
  ],
24
28
  "scripts": {
25
29
  "build": "tsc",
26
- "prepublishOnly": "npm run build",
30
+ "prepublishOnly": "npm run build && npm run test",
27
31
  "start": "node dist/index.js",
28
32
  "dev": "ts-node src/index.ts",
29
- "test": "echo \"Error: no test specified\" && exit 1"
33
+ "test": "vitest run",
34
+ "test:watch": "vitest",
35
+ "test:ui": "vitest --ui",
36
+ "test:coverage": "vitest --coverage"
30
37
  },
31
38
  "dependencies": {
32
39
  "@modelcontextprotocol/sdk": "^1.25.3",
@@ -39,7 +46,10 @@
39
46
  "devDependencies": {
40
47
  "@types/eventsource": "^1.1.15",
41
48
  "@types/node": "^22.10.7",
49
+ "@vitest/coverage-v8": "^4.0.18",
50
+ "@vitest/ui": "^4.0.18",
42
51
  "ts-node": "^10.9.2",
43
- "typescript": "^5.7.3"
52
+ "typescript": "^5.7.3",
53
+ "vitest": "^4.0.18"
44
54
  }
45
55
  }
@@ -1,83 +0,0 @@
1
- import fs from 'fs';
2
- import path from 'path';
3
- import chalk from 'chalk';
4
- import { configManager } from '../core/config.js';
5
- export const registerConfigCommand = (program) => {
6
- const configCmd = program.command('config')
7
- .description('Manage configuration');
8
- configCmd.command('import <file>')
9
- .description('Import servers from a JSON configuration file (e.g., mcporter.json)')
10
- .option('-f, --force', 'Overwrite existing servers with the same name', false)
11
- .action((file, options) => {
12
- try {
13
- const absolutePath = path.resolve(file);
14
- if (!fs.existsSync(absolutePath)) {
15
- console.error(chalk.red(`File not found: ${absolutePath}`));
16
- process.exit(1);
17
- }
18
- const content = fs.readFileSync(absolutePath, 'utf-8');
19
- let json;
20
- try {
21
- json = JSON.parse(content);
22
- }
23
- catch (e) {
24
- console.error(chalk.red('Invalid JSON file.'));
25
- process.exit(1);
26
- }
27
- let importedCount = 0;
28
- let skippedCount = 0;
29
- // Support standard MCP config format (mcpServers object)
30
- const serversMap = json.mcpServers || {};
31
- Object.entries(serversMap).forEach(([name, config]) => {
32
- // Skip disabled servers
33
- if (config.disabled === true) {
34
- skippedCount++;
35
- return;
36
- }
37
- const serverName = name;
38
- let newServer;
39
- if (config.command) {
40
- newServer = {
41
- name: serverName,
42
- type: 'stdio',
43
- command: config.command,
44
- args: config.args || [],
45
- env: config.env,
46
- };
47
- }
48
- else if (config.url) {
49
- newServer = {
50
- name: serverName,
51
- type: 'sse',
52
- url: config.url
53
- };
54
- }
55
- else {
56
- console.warn(chalk.yellow(`Skipping invalid server config for "${serverName}": missing command or url`));
57
- return;
58
- }
59
- const existing = configManager.getServer(serverName);
60
- if (existing) {
61
- if (options.force) {
62
- configManager.updateServer(serverName, newServer);
63
- console.log(chalk.gray(`Updated existing server: ${serverName}`));
64
- importedCount++;
65
- }
66
- else {
67
- console.log(chalk.yellow(`Skipping existing server: ${serverName} (use --force to overwrite)`));
68
- skippedCount++;
69
- }
70
- }
71
- else {
72
- configManager.addServer(newServer);
73
- console.log(chalk.green(`Imported server: ${serverName}`));
74
- importedCount++;
75
- }
76
- });
77
- console.log(chalk.bold(`\nImport complete. Imported: ${importedCount}, Skipped: ${skippedCount}`));
78
- }
79
- catch (error) {
80
- console.error(chalk.red(`Import failed: ${error.message}`));
81
- }
82
- });
83
- };