ai-helper-agent 1.6.2 → 1.6.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-helper-agent",
3
- "version": "1.6.2",
3
+ "version": "1.6.3",
4
4
  "description": "AI Helper 代理服务 - 为 Chrome 扩展提供本地文件读写和系统命令执行能力",
5
5
  "type": "module",
6
6
  "main": "./src/server.js",
@@ -15,10 +15,13 @@
15
15
  "scripts": {
16
16
  "start": "node bin/agent.js start",
17
17
  "stop": "node bin/agent.js stop",
18
- "status": "node bin/agent.js status"
18
+ "status": "node bin/agent.js status",
19
+ "publish": "bash publish.sh"
19
20
  },
20
21
  "dependencies": {
21
- "ws": "^8.18.0"
22
+ "@modelcontextprotocol/sdk": "^1.29.0",
23
+ "ws": "^8.18.0",
24
+ "zod": "^4.4.3"
22
25
  },
23
26
  "engines": {
24
27
  "node": ">=18.0.0"
package/src/mcp/client.js CHANGED
@@ -1,80 +1,44 @@
1
- // mcp/client.js - MCP Client 核心
2
- // 管理与单个 MCP Server 的 JSON-RPC 2.0 通信
3
- import { createStdioTransport } from './transport.js';
1
+ import { Client } from '@modelcontextprotocol/sdk/client';
2
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
4
3
 
5
- let requestIdCounter = 0;
6
-
7
- /**
8
- * MCP Client 类
9
- * 管理与一个 MCP Server 的完整生命周期
10
- */
11
4
  export class McpClient {
12
5
  constructor(serverConfig) {
13
6
  this.serverId = serverConfig.id;
14
7
  this.serverName = serverConfig.name || serverConfig.id;
15
8
  this.serverConfig = serverConfig;
9
+ this.client = null;
16
10
  this.transport = null;
17
11
  this.connected = false;
18
12
  this.tools = [];
19
13
  this.serverInfo = null;
20
-
21
- // JSON-RPC 请求管理
22
- this.pendingRequests = new Map(); // id → { resolve, reject, timeoutId }
23
14
  }
24
15
 
25
- /**
26
- * 连接到 MCP Server
27
- * 流程: 启动子进程 → initialize → tools/list
28
- */
29
16
  async connect() {
30
17
  if (this.connected) return { success: true };
31
18
 
32
19
  try {
33
- // 1. 创建 stdio 传输
34
- this.transport = await createStdioTransport(this.serverConfig);
35
-
36
- // 2. 设置响应数据处理器
37
- this.transport.onData((line) => this._handleLine(line));
38
-
39
- // 2.5 监听进程崩溃(已连接后)
40
- this.transport.onExit((code, signal) => {
41
- console.error(`[MCP:${this.serverId}] Server 进程意外退出 (code=${code}, signal=${signal}),标记为断开`);
42
- this.connected = false;
43
- this.tools = [];
44
- // 拒绝所有进行中的请求
45
- for (const [id, pending] of this.pendingRequests) {
46
- clearTimeout(pending.timeoutId);
47
- pending.reject(new Error('MCP Server 进程已退出'));
48
- }
49
- this.pendingRequests.clear();
20
+ this.transport = new StdioClientTransport({
21
+ command: this.serverConfig.command,
22
+ args: this.serverConfig.args || [],
23
+ env: { ...(this.serverConfig.env || {}) },
24
+ cwd: this.serverConfig.cwd,
25
+ stderr: 'pipe'
50
26
  });
51
27
 
52
- console.log(`[MCP:${this.serverId}] 子进程已启动`);
53
-
54
- // 3. 发送 initialize 请求
55
- const initResult = await this._sendRequest('initialize', {
56
- protocolVersion: '2024-11-05',
57
- capabilities: {
58
- tools: {}
59
- },
60
- clientInfo: {
61
- name: 'ai-helper-agent',
62
- version: '1.3.4'
63
- }
28
+ this.client = new Client({
29
+ name: 'ai-helper-agent',
30
+ version: '1.3.4'
64
31
  });
65
32
 
66
- if (!initResult || initResult.error) {
67
- throw new Error(`Initialize 失败: ${initResult?.error?.message || '未知错误'}`);
68
- }
69
-
70
- this.serverInfo = initResult;
71
- console.log(`[MCP:${this.serverId}] Initialize 成功, server:`, initResult.serverInfo?.name);
33
+ await this.client.connect(this.transport);
72
34
 
73
- // 4. 发送 initialized 通知(MCP 协议要求)
74
- this._sendNotification('notifications/initialized', {});
35
+ this.serverInfo = {
36
+ serverInfo: this.client.getServerVersion(),
37
+ capabilities: this.client.getServerCapabilities()
38
+ };
39
+ console.log(`[MCP:${this.serverId}] Initialize 成功, server:`, this.serverInfo.serverInfo?.name);
75
40
 
76
- // 5. 发现工具
77
- const toolsResult = await this._sendRequest('tools/list', {});
41
+ const toolsResult = await this.client.listTools({});
78
42
  if (toolsResult && toolsResult.tools) {
79
43
  this.tools = toolsResult.tools;
80
44
  console.log(`[MCP:${this.serverId}] 发现 ${this.tools.length} 个工具:`,
@@ -92,18 +56,13 @@ export class McpClient {
92
56
  }
93
57
  }
94
58
 
95
- /**
96
- * 调用 MCP 工具
97
- * @param {string} toolName
98
- * @param {Object} args
99
- */
100
59
  async callTool(toolName, args) {
101
60
  if (!this.connected) {
102
61
  return { success: false, error: `MCP Server "${this.serverName}" 未连接` };
103
62
  }
104
63
 
105
64
  try {
106
- const result = await this._sendRequest('tools/call', {
65
+ const result = await this.client.callTool({
107
66
  name: toolName,
108
67
  arguments: args || {}
109
68
  });
@@ -112,7 +71,6 @@ export class McpClient {
112
71
  return { success: false, error: result.error.message || '工具调用失败' };
113
72
  }
114
73
 
115
- // MCP tools/call 返回 { content: [{ type: 'text', text: '...' }], isError: false }
116
74
  const content = result?.content || [];
117
75
  const textContent = content
118
76
  .filter(c => c.type === 'text')
@@ -129,31 +87,18 @@ export class McpClient {
129
87
  }
130
88
  }
131
89
 
132
- /**
133
- * 断开连接
134
- */
135
90
  disconnect() {
136
91
  this.connected = false;
137
92
  this.tools = [];
138
93
 
139
- // 清理所有未完成的请求
140
- for (const [id, pending] of this.pendingRequests) {
141
- clearTimeout(pending.timeoutId);
142
- pending.reject(new Error('连接已断开'));
143
- }
144
- this.pendingRequests.clear();
145
-
146
- if (this.transport) {
147
- try { this.transport.close(); } catch {}
148
- this.transport = null;
94
+ if (this.client) {
95
+ try { this.client.close(); } catch {}
96
+ this.client = null;
149
97
  }
150
98
 
151
99
  console.log(`[MCP:${this.serverId}] 已断开连接`);
152
100
  }
153
101
 
154
- /**
155
- * 获取状态信息
156
- */
157
102
  getStatus() {
158
103
  return {
159
104
  id: this.serverId,
@@ -163,82 +108,4 @@ export class McpClient {
163
108
  serverInfo: this.serverInfo?.serverInfo || null
164
109
  };
165
110
  }
166
-
167
- // ========== 私有方法 ==========
168
-
169
- /**
170
- * 处理从 transport 收到的每一行 JSON-RPC 消息
171
- */
172
- _handleLine(line) {
173
- try {
174
- const message = JSON.parse(line);
175
- const id = message.id;
176
-
177
- if (id !== undefined && id !== null) {
178
- // 有 id = 响应
179
- const pending = this.pendingRequests.get(id);
180
- if (pending) {
181
- clearTimeout(pending.timeoutId);
182
- this.pendingRequests.delete(id);
183
-
184
- if (message.error) {
185
- pending.reject(new Error(message.error.message || 'JSON-RPC Error'));
186
- } else {
187
- pending.resolve(message.result);
188
- }
189
- }
190
- } else {
191
- // 无 id = 通知,仅记录
192
- if (message.method) {
193
- console.log(`[MCP:${this.serverId}] 通知:`, message.method);
194
- }
195
- }
196
- } catch (err) {
197
- // 非 JSON 行,忽略
198
- }
199
- }
200
-
201
- /**
202
- * 发送 JSON-RPC 请求并等待响应
203
- */
204
- _sendRequest(method, params) {
205
- return new Promise((resolve, reject) => {
206
- const id = ++requestIdCounter;
207
-
208
- const timeoutId = setTimeout(() => {
209
- this.pendingRequests.delete(id);
210
- reject(new Error(`MCP 请求超时 (60s): ${method}`));
211
- }, 60000);
212
-
213
- this.pendingRequests.set(id, { resolve, reject, timeoutId });
214
-
215
- try {
216
- this.transport.send({
217
- jsonrpc: '2.0',
218
- id,
219
- method,
220
- params
221
- });
222
- } catch (err) {
223
- clearTimeout(timeoutId);
224
- this.pendingRequests.delete(id);
225
- reject(err);
226
- }
227
- });
228
- }
229
-
230
- /**
231
- * 发送 JSON-RPC 通知(无需响应)
232
- */
233
- _sendNotification(method, params) {
234
- try {
235
- this.transport.send({
236
- jsonrpc: '2.0',
237
- method,
238
- params
239
- });
240
- } catch {
241
- // 通知发送失败不抛出
242
- }
243
- }
244
- }
111
+ }
package/src/security.js CHANGED
@@ -1,9 +1,23 @@
1
1
  // agent/src/security.js - 安全管控:文件沙箱 + 命令分级
2
- import { resolve, normalize, sep, isAbsolute, join } from 'path';
2
+ import { resolve, normalize, sep, isAbsolute, join, dirname } from 'path';
3
3
  import { realpathSync } from 'fs';
4
- import { homedir } from 'os';
4
+ import { homedir, platform } from 'os';
5
5
  import { loadConfig } from './config.js';
6
6
 
7
+ const isWin32 = platform() === 'win32';
8
+
9
+ function normalizePathFormat(pathStr) {
10
+ if (!pathStr || typeof pathStr !== 'string') return pathStr;
11
+
12
+ if (isWin32) {
13
+ pathStr = pathStr.replace(/\/([a-zA-Z])\//g, '$1:/');
14
+ pathStr = pathStr.replace(/\/([a-zA-Z]):/g, '$1:/');
15
+ pathStr = pathStr.replace(/\//g, '\\');
16
+ }
17
+
18
+ return pathStr;
19
+ }
20
+
7
21
  // ==================== 硬阻止目录(任何情况下都不可访问) ====================
8
22
 
9
23
  const AGENT_DIR = join(homedir(), '.ai-helper-agent');
@@ -49,8 +63,10 @@ function checkPath(pathStr) {
49
63
  try {
50
64
  const config = loadConfig();
51
65
 
52
- // 相对路径基于 config.workdir 解析,而非 process.cwd()
53
- const resolved = isAbsolute(pathStr) ? resolve(pathStr) : resolve(config.workdir, pathStr);
66
+ const normalizedPathStr = normalizePathFormat(pathStr);
67
+ const normalizedWorkdir = normalizePathFormat(config.workdir);
68
+
69
+ const resolved = isAbsolute(normalizedPathStr) ? resolve(normalizedPathStr) : resolve(normalizedWorkdir, normalizedPathStr);
54
70
  const normalized = normalize(resolved);
55
71
 
56
72
  // 0. 硬阻止检查(优先级最高,不可绕过)
@@ -58,13 +74,12 @@ function checkPath(pathStr) {
58
74
  return { allowed: false, reason: '禁止访问代理系统文件' };
59
75
  }
60
76
 
61
- const allowedPaths = config.allowedPaths.length > 0 ? config.allowedPaths : [config.workdir];
77
+ const allowedPaths = config.allowedPaths.length > 0 ? config.allowedPaths.map(p => normalizePathFormat(p)) : [normalizedWorkdir];
62
78
 
63
79
  // 先做前缀检查(快速路径,兼容 Windows/Unix)
64
80
  let prefixMatch = false;
65
81
  for (const allowed of allowedPaths) {
66
82
  const allowedResolved = resolve(allowed);
67
- // 统一使用 OS 分隔符构造前缀
68
83
  const prefix = allowedResolved.endsWith(sep) ? allowedResolved : allowedResolved + sep;
69
84
  if (normalized.startsWith(prefix) || normalized === allowedResolved) {
70
85
  prefixMatch = true;
@@ -81,10 +96,12 @@ function checkPath(pathStr) {
81
96
  realPath = realpathSync(resolved);
82
97
  } catch (err) {
83
98
  if (err.code === 'ENOENT') {
84
- // 文件/目录不存在时,向上查找直到找到存在的父目录
85
99
  let existing = resolved;
86
- while (existing !== sep && existing !== '' && existing !== '/') {
87
- const parent = resolve(existing, '..');
100
+ while (true) {
101
+ const parent = dirname(existing);
102
+ if (parent === existing) {
103
+ break;
104
+ }
88
105
  try {
89
106
  realPath = realpathSync(parent) + sep + normalized.slice(resolve(parent).length).replace(/^[/\\]/, '');
90
107
  break;
package/src/server.js CHANGED
@@ -158,6 +158,85 @@ function parseBody(req) {
158
158
  });
159
159
  }
160
160
 
161
+ /**
162
+ * 解析 multipart/form-data,提取文件数据
163
+ * @param {http.IncomingMessage} req
164
+ * @returns {Promise<{fileBuffer: Buffer, fileName: string, mimeType: string}>}
165
+ */
166
+ function parseMultipartBody(req) {
167
+ return new Promise((resolve, reject) => {
168
+ const contentType = req.headers['content-type'] || '';
169
+ const boundaryMatch = contentType.match(/boundary=(.+)/);
170
+ if (!boundaryMatch) {
171
+ reject(new Error('不是有效的 multipart/form-data 请求'));
172
+ return;
173
+ }
174
+ const boundary = boundaryMatch[1].trim();
175
+ const boundaryBuffer = Buffer.from('--' + boundary);
176
+
177
+ const contentLength = parseInt(req.headers['content-length'] || '0', 10);
178
+ if (contentLength > MAX_BODY_SIZE) {
179
+ reject(new Error('文件过大,超出限制'));
180
+ return;
181
+ }
182
+
183
+ const chunks = [];
184
+ let totalSize = 0;
185
+
186
+ req.on('data', (chunk) => {
187
+ totalSize += chunk.length;
188
+ if (totalSize > MAX_BODY_SIZE) {
189
+ reject(new Error('文件过大,超出限制'));
190
+ req.destroy();
191
+ return;
192
+ }
193
+ chunks.push(chunk);
194
+ });
195
+
196
+ req.on('end', () => {
197
+ const buffer = Buffer.concat(chunks);
198
+
199
+ // 找到文件部分的起始位置
200
+ const startIdx = buffer.indexOf(boundaryBuffer) + boundaryBuffer.length + 2; // +2 for \r\n
201
+ if (startIdx < boundaryBuffer.length + 2) {
202
+ reject(new Error('multipart 数据格式不正确'));
203
+ return;
204
+ }
205
+
206
+ // 找 headers 结束位置(\r\n\r\n)
207
+ const headersEnd = buffer.indexOf('\r\n\r\n', startIdx);
208
+ if (headersEnd === -1) {
209
+ reject(new Error('multipart headers 格式不正确'));
210
+ return;
211
+ }
212
+
213
+ // 解析 headers
214
+ const headersStr = buffer.slice(startIdx, headersEnd).toString();
215
+ const dispMatch = headersStr.match(/filename="([^"]*)"/);
216
+ const fileName = dispMatch ? dispMatch[1] : 'file';
217
+
218
+ // 找数据结束位置(下一个 boundary)
219
+ const dataStart = headersEnd + 4; // after \r\n\r\n
220
+ const dataEnd = buffer.indexOf(boundaryBuffer, dataStart) - 2; // -2 for \r\n before boundary
221
+
222
+ if (dataEnd <= dataStart) {
223
+ reject(new Error('找不到文件数据'));
224
+ return;
225
+ }
226
+
227
+ const fileBuffer = buffer.slice(dataStart, dataEnd);
228
+
229
+ resolve({
230
+ fileBuffer,
231
+ fileName,
232
+ mimeType: headersStr.match(/Content-Type:\s*(.+?)\r?\n/i)?.[1]?.trim() || 'application/octet-stream'
233
+ });
234
+ });
235
+
236
+ req.on('error', () => reject(new Error('读取文件失败')));
237
+ });
238
+ }
239
+
161
240
  /**
162
241
  * 创建并启动服务器
163
242
  */
@@ -271,6 +350,47 @@ export function startServer() {
271
350
  });
272
351
  }
273
352
 
353
+ // === 文件上传(multipart/form-data,必须在通用 JSON body 解析之前) ===
354
+ if (req.method === 'POST' && pathname === '/api/files/upload') {
355
+ try {
356
+ const { fileBuffer, fileName, mimeType } = await parseMultipartBody(req);
357
+
358
+ // 安全检查:过滤文件名中的危险字符
359
+ const safeName = fileName.replace(/[/\\:*?"<>|]/g, '_');
360
+ const destPath = join(config.workdir, safeName);
361
+
362
+ // 去重:如果文件已存在,添加后缀
363
+ let finalPath = destPath;
364
+ if (existsSync(finalPath)) {
365
+ const ext = safeName.lastIndexOf('.') > 0 ? safeName.substring(safeName.lastIndexOf('.')) : '';
366
+ const base = safeName.substring(0, safeName.length - ext.length) || safeName;
367
+ let counter = 1;
368
+ while (existsSync(finalPath)) {
369
+ finalPath = join(config.workdir, `${base}_${counter}${ext}`);
370
+ counter++;
371
+ }
372
+ }
373
+
374
+ // 确保工作目录存在
375
+ mkdirSync(config.workdir, { recursive: true });
376
+
377
+ // 写入文件
378
+ writeFileSync(finalPath, fileBuffer);
379
+
380
+ logFs('upload', { path: finalPath, size: fileBuffer.length, mimeType });
381
+
382
+ return jsonResponse(res, 200, {
383
+ success: true,
384
+ path: finalPath,
385
+ name: safeName,
386
+ size: fileBuffer.length
387
+ });
388
+ } catch (err) {
389
+ logError('fs', 'upload_error', { error: err.message });
390
+ return jsonResponse(res, 400, { success: false, error: `文件上传失败: ${err.message}` });
391
+ }
392
+ }
393
+
274
394
  if (['POST', 'PUT', 'DELETE'].includes(req.method)) {
275
395
  let body;
276
396
  try { body = await parseBody(req); }
@@ -106,10 +106,11 @@ enabled: true
106
106
  - **Windows (PowerShell)**:\`New-Item -ItemType Directory -Force -Path "$env:USERPROFILE\\.ai-helper-agent\\skills\\<skill-name>"\`
107
107
  - 如果 \`mkdir\` 报错,尝试不带参数或使用 \`agent_write_file\` 工具在目标路径写入一个临时文件,让系统自动创建父目录。
108
108
 
109
- 5. **写入 SKILL.md**:使用 \`agent_write_file\` 工具将内容写入。
110
- - **重要**:\`agent_write_file\` 不会展开 \`~\`,必须使用绝对路径!
111
- - **macOS/Linux**:从当前工作目录推断用户主目录(工作目录通常是 \`/Users/<你的用户名>/...\`),完整路径为 \`/Users/<你的用户名>/.ai-helper-agent/skills/<skill-name>/SKILL.md\`
112
- - **Windows**:\`C:\\Users\\<你的用户名>\\.ai-helper-agent\\skills\\<skill-name>\\SKILL.md\`
109
+ 5. **写入 SKILL.md**:使用 \`agent_write_file\` 工具将内容写入目标文件。
110
+
111
+ > ⚠️ **关键限制**:\`agent_write_file\` 不接受 \`~\` 或相对路径,**必须传入绝对路径**。
112
+
113
+ **推荐做法**:先用 \`agent_exec_command\` 获取用户主目录的绝对路径,再拼接完整路径。
113
114
 
114
115
  6. **告知用户**:创建完成后,告知用户技能已就绪,说明触发关键词。建议用户在工具箱页面点击「重新加载」使技能生效。
115
116
 
@@ -1,100 +0,0 @@
1
- // mcp/transport.js - MCP stdio 传输层
2
- // MCP 协议使用 JSON-RPC 2.0 over stdin/stdout,每行一个 JSON 消息
3
- import { spawn } from 'child_process';
4
-
5
- /**
6
- * 创建 MCP 子进程传输
7
- * MCP 协议中,客户端先发 initialize 请求,服务端才会响应。因此 spawn 后立即 resolve,
8
- * 由上层 McpClient 负责发送 initialize 和等待响应。
9
- *
10
- * @param {Object} serverConfig - { command, args, env }
11
- * @returns {Promise<{send: Function, close: Function, onData: Function}>}
12
- */
13
- export function createStdioTransport(serverConfig) {
14
- const { command, args = [], env = {} } = serverConfig;
15
-
16
- return new Promise((resolve, reject) => {
17
- let resolved = false;
18
- let responseBuffer = '';
19
- let dataHandler = null;
20
- let exitHandler = null;
21
-
22
- const child = spawn(command, args, {
23
- stdio: ['pipe', 'pipe', 'pipe'],
24
- env: { ...process.env, ...env },
25
- detached: false
26
- });
27
-
28
- const cleanup = () => {
29
- if (child.stdout) child.stdout.removeAllListeners();
30
- if (child.stderr) child.stderr.removeAllListeners();
31
- child.removeAllListeners();
32
- };
33
-
34
- child.stderr.on('data', (data) => {
35
- const text = data.toString();
36
- if (text.trim()) {
37
- console.log(`[MCP:stderr] [${serverConfig.id || command}] ${text.trim()}`);
38
- }
39
- });
40
-
41
- child.on('error', (err) => {
42
- if (!resolved) {
43
- resolved = true;
44
- cleanup();
45
- reject(new Error(`无法启动 MCP Server "${command}": ${err.message}`));
46
- }
47
- });
48
-
49
- child.on('exit', (code, signal) => {
50
- console.log(`[MCP] Server 进程退出: ${command} (code=${code}, signal=${signal})`);
51
- if (!resolved) {
52
- resolved = true;
53
- cleanup();
54
- reject(new Error(`MCP Server 异常退出 (code=${code}, signal=${signal}): ${command}`));
55
- } else if (exitHandler) {
56
- exitHandler(code, signal);
57
- }
58
- });
59
-
60
- child.stdout.on('data', (data) => {
61
- responseBuffer += data.toString();
62
-
63
- let newlineIdx;
64
- while ((newlineIdx = responseBuffer.indexOf('\n')) !== -1) {
65
- const line = responseBuffer.slice(0, newlineIdx).trim();
66
- responseBuffer = responseBuffer.slice(newlineIdx + 1);
67
-
68
- if (!line) continue;
69
-
70
- if (dataHandler) {
71
- try { dataHandler(line); } catch {}
72
- }
73
- }
74
- });
75
-
76
- if (!resolved) {
77
- resolved = true;
78
- resolve({
79
- send: (jsonRpcMessage) => {
80
- const msg = JSON.stringify(jsonRpcMessage) + '\n';
81
- child.stdin.write(msg);
82
- },
83
- close: () => {
84
- cleanup();
85
- dataHandler = null;
86
- try { child.stdin.end(); } catch {}
87
- setTimeout(() => {
88
- try { child.kill('SIGTERM'); } catch {}
89
- }, 1000);
90
- },
91
- onData: (handler) => {
92
- dataHandler = handler;
93
- },
94
- onExit: (handler) => {
95
- exitHandler = handler;
96
- }
97
- });
98
- }
99
- });
100
- }