@zy_zhou/vps-mcp-server 1.0.0 → 1.0.2

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/dist/server.js CHANGED
@@ -5,7 +5,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
5
5
  import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
6
6
  import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
7
7
  import express from 'express';
8
- import { handleRunCommand, handleRunScript, handleFileRead, handleFileWrite, handleListFiles, handleFileDelete, handleFileSearch, handleFilePatch, handleSessionStart, handleSessionExec, handleSessionRead, handleSessionStop, handleGetSystemStatus, handleKillProcess, handleHttpRequest, } from './tools/index.js';
8
+ import { handleRunCommand, handleRunScript, handleFileRead, handleFileWrite, handleListFiles, handleFileDelete, handleFileSearch, handleFilePatch, handleSessionStart, handleSessionExec, handleSessionRead, handleSessionStop, handleGetSystemStatus, handleKillProcess, handleHttpRequest, handleAgyAsk, } from './tools/index.js';
9
9
  const TOKEN = process.env.MCP_TOKEN || 'change-me';
10
10
  const server = new Server({
11
11
  name: 'vps-sandbox',
@@ -203,6 +203,26 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
203
203
  required: ['sessionId'],
204
204
  },
205
205
  },
206
+ {
207
+ name: 'agy_ask',
208
+ description: '向 agy 提问,自动维护上下文会话 ID。支持直接在普通沙盒中执行,或指定持久化的 tmux 会话执行并自动读取完毕后一次性返回。',
209
+ inputSchema: {
210
+ type: 'object',
211
+ properties: {
212
+ prompt: { type: 'string', description: '要提问的具体内容' },
213
+ sessionId: { type: 'string', description: '(可选)绑定的持久 tmux 会话 ID。如果提供,将在该会话中执行;否则在常规沙盒环境中执行。' },
214
+ clear: { type: 'boolean', description: '是否清空可见屏幕及历史滚动缓冲区,默认为 true' },
215
+ dangerouslySkipPermissions: { type: 'boolean', description: '是否自动跳过权限询问,默认为 true' },
216
+ model: { type: 'string', description: '透传给 agy 的模型参数' },
217
+ agent: { type: 'string', description: '透传给 agy 的代理参数' },
218
+ effort: { type: 'string', description: '透传给 agy 的思考深度参数' },
219
+ mode: { type: 'string', description: '透传给 agy 的模式参数' },
220
+ project: { type: 'string', description: '透传给 agy 的项目参数' },
221
+ addDir: { type: 'string', description: '透传给 agy 的添加目录参数' }
222
+ },
223
+ required: ['prompt']
224
+ }
225
+ },
206
226
  ],
207
227
  }));
208
228
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
@@ -255,6 +275,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
255
275
  case 'session_stop':
256
276
  result = await handleSessionStop(args);
257
277
  break;
278
+ case 'agy_ask':
279
+ result = await handleAgyAsk(args);
280
+ break;
258
281
  default: throw new Error(`Unknown tool: ${name}`);
259
282
  }
260
283
  return result;
@@ -0,0 +1,178 @@
1
+ import { executeCommand } from '../sandbox/executor.js';
2
+ import { TmuxManager } from '../sandbox/tmux-manager.js';
3
+ import { quoteShell } from '../utils.js';
4
+ import fs from 'fs/promises';
5
+ // 跨平台临时目录规范
6
+ const TMP_DIR = process.platform === 'win32' ? 'd:/vps-mcp-server/tmp' : '/tmp';
7
+ // 平台无关的工具存在性检查方法
8
+ async function checkCommand(cmd) {
9
+ try {
10
+ const check = await executeCommand(`command -v ${cmd} || which ${cmd} || where ${cmd}`);
11
+ return check.success;
12
+ }
13
+ catch {
14
+ return false;
15
+ }
16
+ }
17
+ export async function handleAgyAsk(args) {
18
+ const { prompt, sessionId, clear = true, dangerouslySkipPermissions = true, model, agent, effort, mode, project, addDir, } = args;
19
+ if (!prompt) {
20
+ throw new Error('prompt required');
21
+ }
22
+ // 确保临时目录存在
23
+ await fs.mkdir(TMP_DIR, { recursive: true }).catch(() => { });
24
+ // 1. 检查是否存在 agy 命令
25
+ const hasAgy = await checkCommand('agy');
26
+ if (!hasAgy) {
27
+ return {
28
+ content: [{
29
+ type: 'text',
30
+ text: '❌ 系统中未检测到 agy 命令行工具,请确保 Antigravity 运行环境已在远程沙盒中正确配置。'
31
+ }]
32
+ };
33
+ }
34
+ // 2. 检查会话是否存在(如果传了 sessionId)
35
+ if (sessionId) {
36
+ const hasTmux = await checkCommand('tmux');
37
+ if (!hasTmux) {
38
+ return {
39
+ content: [{
40
+ type: 'text',
41
+ text: '❌ 系统未安装 tmux,无法在会话中执行。'
42
+ }]
43
+ };
44
+ }
45
+ const sessionExists = await TmuxManager.sessionExists(sessionId);
46
+ if (!sessionExists) {
47
+ return {
48
+ content: [{
49
+ type: 'text',
50
+ text: `❌ 找不到指定的 tmux 会话 sessionId: ${sessionId}。请先使用 session_start 启动该会话。`
51
+ }]
52
+ };
53
+ }
54
+ }
55
+ // 3. 构造参数串
56
+ const extraArgs = [];
57
+ if (model)
58
+ extraArgs.push(`--model ${quoteShell(model)}`);
59
+ if (agent)
60
+ extraArgs.push(`--agent ${quoteShell(agent)}`);
61
+ if (effort)
62
+ extraArgs.push(`--effort ${quoteShell(effort)}`);
63
+ if (mode)
64
+ extraArgs.push(`--mode ${quoteShell(mode)}`);
65
+ if (project)
66
+ extraArgs.push(`--project ${quoteShell(project)}`);
67
+ if (addDir)
68
+ extraArgs.push(`--add-dir ${quoteShell(addDir)}`);
69
+ if (dangerouslySkipPermissions)
70
+ extraArgs.push('--dangerously-skip-permissions');
71
+ // 4. 分情况执行
72
+ if (sessionId) {
73
+ // 【场景 A:传了 sessionId,需要实现上下文持续】
74
+ // 获取 TTY 隔离键
75
+ let ttyId = 'default';
76
+ try {
77
+ const ttyCheck = await executeCommand(`tmux display-message -p -t mcp-${sessionId} '#{pane_tty}'`);
78
+ if (ttyCheck.success && ttyCheck.stdout) {
79
+ ttyId = ttyCheck.stdout.trim().replace(/^\/dev\//, '').replace(/\//g, '_');
80
+ }
81
+ }
82
+ catch {
83
+ ttyId = `tmux_${sessionId}`;
84
+ }
85
+ const logFile = `${TMP_DIR}/agy_conv_${ttyId}.log`;
86
+ const sidStore = `${TMP_DIR}/agy_sid_${ttyId}.env`;
87
+ // 读入已有的 agy_sid
88
+ let agySid = null;
89
+ try {
90
+ const storeContent = await fs.readFile(sidStore, 'utf-8');
91
+ const match = storeContent.match(/export agy_sid='([a-zA-Z0-9-]+)'/);
92
+ if (match) {
93
+ agySid = match[1];
94
+ }
95
+ }
96
+ catch (err) {
97
+ console.error(`Failed to read sidStore (${sidStore}):`, err.message);
98
+ }
99
+ if (agySid) {
100
+ extraArgs.push(`--conversation ${agySid}`);
101
+ }
102
+ else {
103
+ // 首次提问,强清除潜在的同名脏数据,避免污染
104
+ await fs.rm(sidStore, { force: true }).catch(() => { });
105
+ await fs.rm(logFile, { force: true }).catch(() => { });
106
+ extraArgs.push(`--log-file ${quoteShell(logFile)}`);
107
+ }
108
+ const argsStr = extraArgs.join(' ');
109
+ const markerId = Math.random().toString(36).slice(2, 8);
110
+ // 构造组合命令
111
+ let runCmd = '';
112
+ if (clear) {
113
+ runCmd += 'clear; ';
114
+ }
115
+ // 在 tmux 中执行
116
+ runCmd += `echo "===AGY_START_${markerId}==="; agy -p ${quoteShell(prompt)} ${argsStr}; echo "===AGY_DONE_${markerId}==="`;
117
+ await TmuxManager.execInSession(sessionId, runCmd);
118
+ // 轮询等待结束
119
+ let output = '';
120
+ const startTimeout = Date.now();
121
+ const timeoutLimit = 180000; // 3分钟超时
122
+ let isDone = false;
123
+ while (Date.now() - startTimeout < timeoutLimit) {
124
+ await new Promise(resolve => setTimeout(resolve, 500));
125
+ const screen = await TmuxManager.readSession(sessionId);
126
+ if (screen.includes(`===AGY_DONE_${markerId}===`)) {
127
+ // 截取 Marker 之间的部分
128
+ const startIdx = screen.indexOf(`===AGY_START_${markerId}===`);
129
+ const endIdx = screen.indexOf(`===AGY_DONE_${markerId}===`);
130
+ if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
131
+ output = screen.substring(startIdx + `===AGY_START_${markerId}===`.length, endIdx).trim();
132
+ isDone = true;
133
+ break;
134
+ }
135
+ }
136
+ }
137
+ if (!isDone) {
138
+ // 超时兜底读取当前会话内容
139
+ const screen = await TmuxManager.readSession(sessionId);
140
+ const startIdx = screen.indexOf(`===AGY_START_${markerId}===`);
141
+ if (startIdx !== -1) {
142
+ output = screen.substring(startIdx + `===AGY_START_${markerId}===`.length).trim();
143
+ }
144
+ else {
145
+ output = screen;
146
+ }
147
+ output += '\n\n⚠️ [警告] 会话执行可能超时(超过 180 秒)。';
148
+ }
149
+ // 首次提问,从日志中提取并覆盖写入新分配的会话 ID
150
+ if (!agySid) {
151
+ try {
152
+ const logContent = await fs.readFile(logFile, 'utf-8');
153
+ const sidMatch = logContent.match(/Created conversation ([a-zA-Z0-9-]+)/);
154
+ if (sidMatch) {
155
+ const newSid = sidMatch[1];
156
+ await fs.writeFile(sidStore, `export agy_sid='${newSid}'`, 'utf-8');
157
+ }
158
+ }
159
+ catch (err) {
160
+ console.error('Failed to parse newly created conversation ID:', err);
161
+ }
162
+ }
163
+ return {
164
+ content: [{ type: 'text', text: output }]
165
+ };
166
+ }
167
+ else {
168
+ // 【场景 B:未传 sessionId,一次性命令执行,不做任何状态记录】
169
+ const argsStr = extraArgs.join(' ');
170
+ const command = `bash -c "agy -p ${quoteShell(prompt)} ${argsStr}"`;
171
+ const result = await executeCommand(command, { timeout: 120000 });
172
+ const output = result.stdout || result.stderr || (result.success ? '(无输出)' : '执行失败');
173
+ return {
174
+ content: [{ type: 'text', text: output }],
175
+ isError: !result.success
176
+ };
177
+ }
178
+ }
@@ -4,3 +4,4 @@ export * from './files.js';
4
4
  export * from './session.js';
5
5
  export * from './system.js';
6
6
  export * from './network.js';
7
+ export * from './agy.js';
@@ -40,13 +40,38 @@ export async function handleSessionRead(args) {
40
40
  text: truncated
41
41
  }] };
42
42
  }
43
+ import fs from 'fs/promises';
44
+ import { executeCommand } from '../sandbox/executor.js';
45
+ const TMP_DIR = process.platform === 'win32' ? 'd:/vps-mcp-server/tmp' : '/tmp';
43
46
  export async function handleSessionStop(args) {
44
47
  const { sessionId } = args;
45
48
  if (!sessionId)
46
49
  throw new Error('sessionId required');
50
+ // 1. 获取 TTY 隔离键以删除关联的 agy 缓存临时文件
51
+ let ttyId = 'default';
52
+ try {
53
+ const ttyCheck = await executeCommand(`tmux display-message -p -t mcp-${sessionId} '#{pane_tty}'`);
54
+ if (ttyCheck.success && ttyCheck.stdout) {
55
+ ttyId = ttyCheck.stdout.trim().replace(/^\/dev\//, '').replace(/\//g, '_');
56
+ }
57
+ }
58
+ catch {
59
+ ttyId = `tmux_${sessionId}`;
60
+ }
61
+ // 删除 TTY 关联和兜底命名的缓存与日志
62
+ const filesToRemove = [
63
+ `${TMP_DIR}/agy_sid_${ttyId}.env`,
64
+ `${TMP_DIR}/agy_conv_${ttyId}.log`,
65
+ `${TMP_DIR}/agy_sid_tmux_${sessionId}.env`,
66
+ `${TMP_DIR}/agy_conv_tmux_${sessionId}.log`
67
+ ];
68
+ for (const file of filesToRemove) {
69
+ await fs.rm(file, { force: true }).catch(() => { });
70
+ }
71
+ // 2. 停止 tmux 会话
47
72
  await TmuxManager.stopSession(sessionId);
48
73
  return { content: [{
49
74
  type: 'text',
50
- text: `Session ${sessionId} stopped`
75
+ text: `Session ${sessionId} stopped and associated temporary context files cleaned up.`
51
76
  }] };
52
77
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zy_zhou/vps-mcp-server",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "MCP Server for VPS sandbox execution — run commands, scripts, and manage sessions remotely via MCP protocol. Supports stdio and SSE transports.",
5
5
  "type": "module",
6
6
  "main": "dist/server.js",
@@ -28,4 +28,4 @@
28
28
  "tsx": "^4.19.0",
29
29
  "typescript": "^5.5.3"
30
30
  }
31
- }
31
+ }