@zy_zhou/vps-mcp-server 1.0.0
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 +131 -0
- package/dist/sandbox/executor.js +59 -0
- package/dist/sandbox/tmux-manager.js +48 -0
- package/dist/server.js +304 -0
- package/dist/tools/command.js +25 -0
- package/dist/tools/files.js +70 -0
- package/dist/tools/index.js +6 -0
- package/dist/tools/network.js +42 -0
- package/dist/tools/script.js +43 -0
- package/dist/tools/session.js +52 -0
- package/dist/tools/system.js +34 -0
- package/dist/utils.js +21 -0
- package/package.json +31 -0
package/README.md
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# VPS MCP Server 🚀
|
|
2
|
+
|
|
3
|
+
[English](#english) | [中文说明](#chinese)
|
|
4
|
+
|
|
5
|
+
<a name="english"></a>
|
|
6
|
+
## English Description
|
|
7
|
+
|
|
8
|
+
An advanced Model Context Protocol (MCP) server designed to turn your VPS into a powerful AI-driven remote workstation. It provides a comprehensive set of tools for command execution, file management, system monitoring, and persistent session handling.
|
|
9
|
+
|
|
10
|
+
### 🌟 Key Features
|
|
11
|
+
|
|
12
|
+
- **Command Execution**: Run shell commands and scripts (Python/Node/Bash) in a sandboxed environment.
|
|
13
|
+
- **Advanced File Management**: Read, write, patch, search, and visualize directory trees.
|
|
14
|
+
- **Persistent Sessions**: Based on `tmux`, allowing AI to handle long-running or interactive tasks.
|
|
15
|
+
- **System Monitoring**: Real-time process listing and system resource tracking.
|
|
16
|
+
- **Network Capabilities**: Make HTTP requests or download large files directly to the VPS.
|
|
17
|
+
- **Flexible Transport**: Supports both `stdio` and `SSE` (HTTP) modes.
|
|
18
|
+
|
|
19
|
+
### ⚡️ Quick Run with npx
|
|
20
|
+
|
|
21
|
+
You can run the server directly without cloning the repository:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
# stdio mode (for local use)
|
|
25
|
+
npx github:zhy1369800/vps-mcp-server
|
|
26
|
+
|
|
27
|
+
# SSE mode (for remote VPS deployment)
|
|
28
|
+
MODE=sse PORT=8080 MCP_TOKEN=your-token npx github:zhy1369800/vps-mcp-server
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### 🛠 Tools Reference
|
|
32
|
+
|
|
33
|
+
| Tool | Description |
|
|
34
|
+
|------|-------------|
|
|
35
|
+
| `run_command` | Execute a one-off shell command. |
|
|
36
|
+
| `run_script` | Run Python, Bash, or Node.js code blocks. |
|
|
37
|
+
| `list_files` | List directory contents or generate a recursive tree. |
|
|
38
|
+
| `file_read/write` | Read from or write to files. |
|
|
39
|
+
| `file_patch` | Edit specific parts of a file (Token-efficient). |
|
|
40
|
+
| `file_search` | Search for files by name or content (grep). |
|
|
41
|
+
| `get_system_status` | Get OS info, memory, disk, or process list. |
|
|
42
|
+
| `http_request` | Fetch web content or download files to disk. |
|
|
43
|
+
| `session_*` | Start, exec, read, or stop persistent `tmux` sessions. |
|
|
44
|
+
|
|
45
|
+
### 🚀 Manual Installation
|
|
46
|
+
|
|
47
|
+
1. **Clone & Install**:
|
|
48
|
+
```bash
|
|
49
|
+
git clone https://github.com/zhy1369800/vps-mcp-server.git
|
|
50
|
+
cd vps-mcp-server
|
|
51
|
+
npm install
|
|
52
|
+
```
|
|
53
|
+
2. **Build**:
|
|
54
|
+
```bash
|
|
55
|
+
npm run build
|
|
56
|
+
```
|
|
57
|
+
3. **Configure**:
|
|
58
|
+
Copy `.env.example` to `.env` and set your `MCP_TOKEN`.
|
|
59
|
+
4. **Run** (SSE Mode):
|
|
60
|
+
```bash
|
|
61
|
+
MODE=sse PORT=8080 npm start
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
<a name="chinese"></a>
|
|
67
|
+
## 中文说明
|
|
68
|
+
|
|
69
|
+
这是一个先进的 **Model Context Protocol (MCP)** 服务端,旨在将您的 VPS 变成一个强大的 AI 驱动远程工作站。它为 AI Agent 提供了完整的指令执行、文件管理、系统监控和持久化会话处理工具集。
|
|
70
|
+
|
|
71
|
+
### 🌟 核心特性
|
|
72
|
+
|
|
73
|
+
- **指令执行**:在沙箱环境中运行 Shell 命令和脚本(Python/Node/Bash)。
|
|
74
|
+
- **高级文件管理**:支持读取、写入、增量补丁(Patch)、全文搜索及目录树可视化。
|
|
75
|
+
- **持久化会话**:基于 `tmux` 实现,允许 AI 处理耗时较长或需要交互的任务。
|
|
76
|
+
- **系统监控**:实时查看进程列表、CPU、内存及磁盘占用情况。
|
|
77
|
+
- **网络增强**:直接从 VPS 发起 HTTP 请求或下载大文件到磁盘。
|
|
78
|
+
- **双模式支持**:支持 `stdio` 本地调用和 `SSE` (HTTP) 远程调用模式。
|
|
79
|
+
|
|
80
|
+
### ⚡️ 使用 npx 快速运行
|
|
81
|
+
|
|
82
|
+
无需克隆代码即可直接启动服务:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
# stdio 模式 (本地使用)
|
|
86
|
+
npx github:zhy1369800/vps-mcp-server
|
|
87
|
+
|
|
88
|
+
# SSE 模式 (远程 VPS 部署)
|
|
89
|
+
MODE=sse PORT=8080 MCP_TOKEN=your-token npx github:zhy1369800/vps-mcp-server
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### 🛠 工具列表
|
|
93
|
+
|
|
94
|
+
| 工具名称 | 功能描述 |
|
|
95
|
+
|----------|----------|
|
|
96
|
+
| `run_command` | 执行一次性 Shell 命令。 |
|
|
97
|
+
| `run_script` | 运行 Python、Bash 或 Node.js 代码块。 |
|
|
98
|
+
| `list_files` | 列出目录内容或生成递归目录树(支持排除目录)。 |
|
|
99
|
+
| `file_read/write` | 读取或写入文件。 |
|
|
100
|
+
| `file_patch` | 增量编辑文件特定部分(极度节省 Token)。 |
|
|
101
|
+
| `file_search` | 按名称或内容搜索文件(基于 grep)。 |
|
|
102
|
+
| `get_system_status` | 获取系统概览、内存、磁盘或进程列表。 |
|
|
103
|
+
| `http_request` | 发起 HTTP 请求或下载文件到 VPS 磁盘。 |
|
|
104
|
+
| `session_*` | 启动、执行、读取或停止持久化的 `tmux` 会话。 |
|
|
105
|
+
|
|
106
|
+
### 🚀 手动安装步骤
|
|
107
|
+
|
|
108
|
+
1. **克隆与安装**:
|
|
109
|
+
```bash
|
|
110
|
+
git clone https://github.com/zhy1369800/vps-mcp-server.git
|
|
111
|
+
cd vps-mcp-server
|
|
112
|
+
npm install
|
|
113
|
+
```
|
|
114
|
+
2. **编译**:
|
|
115
|
+
```bash
|
|
116
|
+
npm run build
|
|
117
|
+
```
|
|
118
|
+
3. **配置环境**:
|
|
119
|
+
将 `.env.example` 复制为 `.env` 并设置您的 `MCP_TOKEN`。
|
|
120
|
+
4. **启动服务** (SSE 模式):
|
|
121
|
+
```bash
|
|
122
|
+
MODE=sse PORT=8080 npm start
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### 🛡 安全建议
|
|
126
|
+
|
|
127
|
+
建议在 Docker 容器中运行此服务,并配合 Nginx 反向代理(配置 SSL 证书)以确保通信安全。
|
|
128
|
+
|
|
129
|
+
### 📄 开源协议
|
|
130
|
+
|
|
131
|
+
MIT
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
import { DEFAULT_TIMEOUT, MAX_BUFFER } from '../utils.js';
|
|
3
|
+
export function executeCommand(command, options) {
|
|
4
|
+
return new Promise((resolve) => {
|
|
5
|
+
const timeoutMs = options?.timeout ?? DEFAULT_TIMEOUT;
|
|
6
|
+
const cwd = options?.cwd ?? process.cwd();
|
|
7
|
+
const env = { ...process.env, ...options?.env };
|
|
8
|
+
const child = spawn('sh', ['-c', command], {
|
|
9
|
+
cwd,
|
|
10
|
+
env,
|
|
11
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
12
|
+
shell: false,
|
|
13
|
+
detached: false,
|
|
14
|
+
});
|
|
15
|
+
let stdout = '';
|
|
16
|
+
let stderr = '';
|
|
17
|
+
let timedOut = false;
|
|
18
|
+
const timer = setTimeout(() => {
|
|
19
|
+
timedOut = true;
|
|
20
|
+
child.kill('SIGKILL');
|
|
21
|
+
}, timeoutMs);
|
|
22
|
+
child.stdout.on('data', (data) => {
|
|
23
|
+
const chunk = data.toString();
|
|
24
|
+
stdout += chunk;
|
|
25
|
+
if (stdout.length > MAX_BUFFER)
|
|
26
|
+
stdout = stdout.slice(-MAX_BUFFER);
|
|
27
|
+
options?.onStdout?.(chunk);
|
|
28
|
+
});
|
|
29
|
+
child.stderr.on('data', (data) => {
|
|
30
|
+
const chunk = data.toString();
|
|
31
|
+
stderr += chunk;
|
|
32
|
+
if (stderr.length > MAX_BUFFER)
|
|
33
|
+
stderr = stderr.slice(-MAX_BUFFER);
|
|
34
|
+
options?.onStderr?.(chunk);
|
|
35
|
+
});
|
|
36
|
+
child.on('close', (code) => {
|
|
37
|
+
clearTimeout(timer);
|
|
38
|
+
resolve({
|
|
39
|
+
success: code === 0 && !timedOut,
|
|
40
|
+
timedOut,
|
|
41
|
+
exitCode: code,
|
|
42
|
+
stdout,
|
|
43
|
+
stderr,
|
|
44
|
+
error: timedOut ? 'Command timed out' : code !== 0 ? `Exit code ${code}` : undefined,
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
child.on('error', (err) => {
|
|
48
|
+
clearTimeout(timer);
|
|
49
|
+
resolve({
|
|
50
|
+
success: false,
|
|
51
|
+
timedOut: false,
|
|
52
|
+
exitCode: null,
|
|
53
|
+
stdout,
|
|
54
|
+
stderr,
|
|
55
|
+
error: err.message,
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { execAsync } from '../utils.js';
|
|
2
|
+
export class TmuxManager {
|
|
3
|
+
static SESSION_PREFIX = 'mcp-';
|
|
4
|
+
static async startSession(sessionId, command, cwd) {
|
|
5
|
+
const fullId = this.SESSION_PREFIX + sessionId;
|
|
6
|
+
const cdCmd = cwd ? `cd ${cwd} &&` : '';
|
|
7
|
+
await execAsync(`tmux new-session -d -s ${fullId} "${cdCmd} ${command}"`);
|
|
8
|
+
}
|
|
9
|
+
static async execInSession(sessionId, command) {
|
|
10
|
+
const fullId = this.SESSION_PREFIX + sessionId;
|
|
11
|
+
const { stdout } = await execAsync(`tmux send-keys -t ${fullId} "${command}" Enter`);
|
|
12
|
+
return stdout;
|
|
13
|
+
}
|
|
14
|
+
static async readSession(sessionId) {
|
|
15
|
+
const fullId = this.SESSION_PREFIX + sessionId;
|
|
16
|
+
try {
|
|
17
|
+
const { stdout } = await execAsync(`tmux capture-pane -p -t ${fullId}`);
|
|
18
|
+
return stdout;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return '';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
static async stopSession(sessionId) {
|
|
25
|
+
const fullId = this.SESSION_PREFIX + sessionId;
|
|
26
|
+
try {
|
|
27
|
+
await execAsync(`tmux kill-session -t ${fullId}`);
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
static async sessionExists(sessionId) {
|
|
35
|
+
const fullId = this.SESSION_PREFIX + sessionId;
|
|
36
|
+
try {
|
|
37
|
+
await execAsync(`tmux has-session -t ${fullId}`);
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
static async listSessions() {
|
|
45
|
+
const { stdout } = await execAsync(`tmux list-sessions -F "#{session_name}"`);
|
|
46
|
+
return stdout.split('\n').filter(s => s.startsWith(this.SESSION_PREFIX)).map(s => s.slice(this.SESSION_PREFIX.length));
|
|
47
|
+
}
|
|
48
|
+
}
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import 'dotenv/config';
|
|
3
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
4
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
5
|
+
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
|
6
|
+
import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
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';
|
|
9
|
+
const TOKEN = process.env.MCP_TOKEN || 'change-me';
|
|
10
|
+
const server = new Server({
|
|
11
|
+
name: 'vps-sandbox',
|
|
12
|
+
version: '1.0.0',
|
|
13
|
+
}, {
|
|
14
|
+
capabilities: {
|
|
15
|
+
tools: {},
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
19
|
+
tools: [
|
|
20
|
+
{
|
|
21
|
+
name: 'run_command',
|
|
22
|
+
description: '在 VPS 沙盒中执行 Shell 命令(一次性)',
|
|
23
|
+
inputSchema: {
|
|
24
|
+
type: 'object',
|
|
25
|
+
properties: {
|
|
26
|
+
command: { type: 'string', description: '要执行的命令' },
|
|
27
|
+
cwd: { type: 'string', description: '工作目录,默认 /tmp' },
|
|
28
|
+
timeout: { type: 'number', description: '超时(ms),默认 30000' },
|
|
29
|
+
},
|
|
30
|
+
required: ['command'],
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
name: 'run_script',
|
|
35
|
+
description: '执行脚本代码 (python/bash/node)',
|
|
36
|
+
inputSchema: {
|
|
37
|
+
type: 'object',
|
|
38
|
+
properties: {
|
|
39
|
+
language: { type: 'string', enum: ['python', 'bash', 'node', 'sh'] },
|
|
40
|
+
code: { type: 'string', description: '脚本源码' },
|
|
41
|
+
timeout: { type: 'number', description: '超时(ms)' },
|
|
42
|
+
},
|
|
43
|
+
required: ['language', 'code'],
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
name: 'file_read',
|
|
48
|
+
description: '读取文件内容',
|
|
49
|
+
inputSchema: {
|
|
50
|
+
type: 'object',
|
|
51
|
+
properties: {
|
|
52
|
+
filepath: { type: 'string' },
|
|
53
|
+
},
|
|
54
|
+
required: ['filepath'],
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
name: 'file_write',
|
|
59
|
+
description: '写入文件',
|
|
60
|
+
inputSchema: {
|
|
61
|
+
type: 'object',
|
|
62
|
+
properties: {
|
|
63
|
+
filepath: { type: 'string' },
|
|
64
|
+
content: { type: 'string' },
|
|
65
|
+
},
|
|
66
|
+
required: ['filepath', 'content'],
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
name: 'file_patch',
|
|
71
|
+
description: '增量编辑文件(搜索并替换)',
|
|
72
|
+
inputSchema: {
|
|
73
|
+
type: 'object',
|
|
74
|
+
properties: {
|
|
75
|
+
filepath: { type: 'string' },
|
|
76
|
+
search: { type: 'string', description: '要搜索的原始文本' },
|
|
77
|
+
replace: { type: 'string', description: '要替换成的新文本' },
|
|
78
|
+
},
|
|
79
|
+
required: ['filepath', 'search', 'replace'],
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
name: 'list_files',
|
|
84
|
+
description: '列出目录内容或递归获取目录树',
|
|
85
|
+
inputSchema: {
|
|
86
|
+
type: 'object',
|
|
87
|
+
properties: {
|
|
88
|
+
dirpath: { type: 'string', description: '目录路径' },
|
|
89
|
+
recursive: { type: 'boolean', description: '是否递归列出 (树状结构)' },
|
|
90
|
+
depth: { type: 'number', description: '递归深度' },
|
|
91
|
+
exclude: { type: 'array', items: { type: 'string' }, description: '排除的目录' },
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
name: 'file_delete',
|
|
97
|
+
description: '删除文件或目录',
|
|
98
|
+
inputSchema: {
|
|
99
|
+
type: 'object',
|
|
100
|
+
properties: {
|
|
101
|
+
filepath: { type: 'string' },
|
|
102
|
+
},
|
|
103
|
+
required: ['filepath'],
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
name: 'file_search',
|
|
108
|
+
description: '搜索文件名或文件内容',
|
|
109
|
+
inputSchema: {
|
|
110
|
+
type: 'object',
|
|
111
|
+
properties: {
|
|
112
|
+
dirpath: { type: 'string', description: '起始目录' },
|
|
113
|
+
query: { type: 'string', description: '搜索关键词' },
|
|
114
|
+
type: { type: 'string', enum: ['name', 'content'], description: '搜索类型' },
|
|
115
|
+
recursive: { type: 'boolean', description: '是否递归搜索' },
|
|
116
|
+
},
|
|
117
|
+
required: ['query'],
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
name: 'get_system_status',
|
|
122
|
+
description: '获取 VPS 系统状态或进程列表',
|
|
123
|
+
inputSchema: {
|
|
124
|
+
type: 'object',
|
|
125
|
+
properties: {
|
|
126
|
+
type: { type: 'string', enum: ['summary', 'processes'], description: 'summary: 系统概览, processes: 进程列表' },
|
|
127
|
+
sort: { type: 'string', enum: ['cpu', 'mem'], description: '进程排序方式' },
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
name: 'kill_process',
|
|
133
|
+
description: '终止指定 PID 的进程',
|
|
134
|
+
inputSchema: {
|
|
135
|
+
type: 'object',
|
|
136
|
+
properties: {
|
|
137
|
+
pid: { type: 'number' },
|
|
138
|
+
force: { type: 'boolean' },
|
|
139
|
+
},
|
|
140
|
+
required: ['pid'],
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
name: 'http_request',
|
|
145
|
+
description: '发起 HTTP 请求或下载文件',
|
|
146
|
+
inputSchema: {
|
|
147
|
+
type: 'object',
|
|
148
|
+
properties: {
|
|
149
|
+
url: { type: 'string' },
|
|
150
|
+
method: { type: 'string', enum: ['GET', 'POST', 'PUT', 'DELETE'] },
|
|
151
|
+
headers: { type: 'object' },
|
|
152
|
+
body: { type: 'any' },
|
|
153
|
+
savePath: { type: 'string', description: '如果提供,则将响应保存到此文件路径' },
|
|
154
|
+
},
|
|
155
|
+
required: ['url'],
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
name: 'session_start',
|
|
160
|
+
description: '启动一个持久 tmux 会话',
|
|
161
|
+
inputSchema: {
|
|
162
|
+
type: 'object',
|
|
163
|
+
properties: {
|
|
164
|
+
sessionId: { type: 'string' },
|
|
165
|
+
command: { type: 'string' },
|
|
166
|
+
cwd: { type: 'string' },
|
|
167
|
+
},
|
|
168
|
+
required: ['sessionId'],
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
name: 'session_exec',
|
|
173
|
+
description: '向已有会话发送命令',
|
|
174
|
+
inputSchema: {
|
|
175
|
+
type: 'object',
|
|
176
|
+
properties: {
|
|
177
|
+
sessionId: { type: 'string' },
|
|
178
|
+
command: { type: 'string' },
|
|
179
|
+
},
|
|
180
|
+
required: ['sessionId', 'command'],
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
name: 'session_read',
|
|
185
|
+
description: '读取会话的最近输出',
|
|
186
|
+
inputSchema: {
|
|
187
|
+
type: 'object',
|
|
188
|
+
properties: {
|
|
189
|
+
sessionId: { type: 'string' },
|
|
190
|
+
maxChars: { type: 'number' },
|
|
191
|
+
},
|
|
192
|
+
required: ['sessionId'],
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
name: 'session_stop',
|
|
197
|
+
description: '停止并销毁会话',
|
|
198
|
+
inputSchema: {
|
|
199
|
+
type: 'object',
|
|
200
|
+
properties: {
|
|
201
|
+
sessionId: { type: 'string' },
|
|
202
|
+
},
|
|
203
|
+
required: ['sessionId'],
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
],
|
|
207
|
+
}));
|
|
208
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
209
|
+
const { name, arguments: args } = request.params;
|
|
210
|
+
try {
|
|
211
|
+
let result;
|
|
212
|
+
switch (name) {
|
|
213
|
+
case 'run_command':
|
|
214
|
+
result = await handleRunCommand(args);
|
|
215
|
+
break;
|
|
216
|
+
case 'run_script':
|
|
217
|
+
result = await handleRunScript(args);
|
|
218
|
+
break;
|
|
219
|
+
case 'file_read':
|
|
220
|
+
result = await handleFileRead(args);
|
|
221
|
+
break;
|
|
222
|
+
case 'file_write':
|
|
223
|
+
result = await handleFileWrite(args);
|
|
224
|
+
break;
|
|
225
|
+
case 'file_patch':
|
|
226
|
+
result = await handleFilePatch(args);
|
|
227
|
+
break;
|
|
228
|
+
case 'list_files':
|
|
229
|
+
result = await handleListFiles(args);
|
|
230
|
+
break;
|
|
231
|
+
case 'file_delete':
|
|
232
|
+
result = await handleFileDelete(args);
|
|
233
|
+
break;
|
|
234
|
+
case 'file_search':
|
|
235
|
+
result = await handleFileSearch(args);
|
|
236
|
+
break;
|
|
237
|
+
case 'get_system_status':
|
|
238
|
+
result = await handleGetSystemStatus(args);
|
|
239
|
+
break;
|
|
240
|
+
case 'kill_process':
|
|
241
|
+
result = await handleKillProcess(args);
|
|
242
|
+
break;
|
|
243
|
+
case 'http_request':
|
|
244
|
+
result = await handleHttpRequest(args);
|
|
245
|
+
break;
|
|
246
|
+
case 'session_start':
|
|
247
|
+
result = await handleSessionStart(args);
|
|
248
|
+
break;
|
|
249
|
+
case 'session_exec':
|
|
250
|
+
result = await handleSessionExec(args);
|
|
251
|
+
break;
|
|
252
|
+
case 'session_read':
|
|
253
|
+
result = await handleSessionRead(args);
|
|
254
|
+
break;
|
|
255
|
+
case 'session_stop':
|
|
256
|
+
result = await handleSessionStop(args);
|
|
257
|
+
break;
|
|
258
|
+
default: throw new Error(`Unknown tool: ${name}`);
|
|
259
|
+
}
|
|
260
|
+
return result;
|
|
261
|
+
}
|
|
262
|
+
catch (error) {
|
|
263
|
+
return {
|
|
264
|
+
content: [{ type: 'text', text: `Error: ${error.message}` }],
|
|
265
|
+
isError: true,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
const mode = process.env.MODE || 'stdio';
|
|
270
|
+
if (mode === 'sse') {
|
|
271
|
+
const app = express();
|
|
272
|
+
app.use(express.json());
|
|
273
|
+
let transport = null;
|
|
274
|
+
app.get('/sse', async (req, res) => {
|
|
275
|
+
const auth = (req.headers.authorization?.replace('Bearer ', '') || req.query.token);
|
|
276
|
+
if (auth !== TOKEN) {
|
|
277
|
+
res.status(401).json({ error: 'Unauthorized' });
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
transport = new SSEServerTransport('/messages', res);
|
|
281
|
+
await server.connect(transport);
|
|
282
|
+
});
|
|
283
|
+
app.post('/messages', async (req, res) => {
|
|
284
|
+
const auth = req.headers.authorization?.replace('Bearer ', '');
|
|
285
|
+
if (auth !== TOKEN) {
|
|
286
|
+
res.status(401).json({ error: 'Unauthorized' });
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
if (transport) {
|
|
290
|
+
await transport.handlePostMessage(req, res, req.body);
|
|
291
|
+
}
|
|
292
|
+
else {
|
|
293
|
+
res.status(400).json({ error: 'No active SSE transport' });
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
app.listen(process.env.PORT || 8080, () => {
|
|
297
|
+
console.error(`MCP SSE server running on port ${process.env.PORT || 8080}`);
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
else {
|
|
301
|
+
const transport = new StdioServerTransport();
|
|
302
|
+
server.connect(transport);
|
|
303
|
+
console.error('MCP stdio server running');
|
|
304
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { executeCommand } from '../sandbox/executor.js';
|
|
2
|
+
export async function handleRunCommand(args) {
|
|
3
|
+
const { command, cwd, timeout = 30000 } = args;
|
|
4
|
+
if (!command)
|
|
5
|
+
throw new Error('command is required');
|
|
6
|
+
let output = '';
|
|
7
|
+
const result = await executeCommand(command, {
|
|
8
|
+
cwd: cwd || '/tmp',
|
|
9
|
+
timeout,
|
|
10
|
+
onStdout: (chunk) => { output += chunk; },
|
|
11
|
+
onStderr: (chunk) => { output += chunk; },
|
|
12
|
+
});
|
|
13
|
+
return {
|
|
14
|
+
content: [{
|
|
15
|
+
type: 'text',
|
|
16
|
+
text: JSON.stringify({
|
|
17
|
+
success: result.success,
|
|
18
|
+
exitCode: result.exitCode,
|
|
19
|
+
output: output.slice(-20000),
|
|
20
|
+
timedOut: result.timedOut,
|
|
21
|
+
error: result.error,
|
|
22
|
+
}, null, 2),
|
|
23
|
+
}],
|
|
24
|
+
};
|
|
25
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { executeCommand } from '../sandbox/executor.js';
|
|
4
|
+
export async function handleFileRead(args) {
|
|
5
|
+
const { filepath } = args;
|
|
6
|
+
if (!filepath)
|
|
7
|
+
throw new Error('filepath required');
|
|
8
|
+
const content = await fs.readFile(filepath, 'utf-8');
|
|
9
|
+
return { content: [{ type: 'text', text: content }] };
|
|
10
|
+
}
|
|
11
|
+
export async function handleFileWrite(args) {
|
|
12
|
+
const { filepath, content } = args;
|
|
13
|
+
if (!filepath)
|
|
14
|
+
throw new Error('filepath required');
|
|
15
|
+
await fs.mkdir(path.dirname(filepath), { recursive: true });
|
|
16
|
+
await fs.writeFile(filepath, content);
|
|
17
|
+
return { content: [{ type: 'text', text: `Written to ${filepath}` }] };
|
|
18
|
+
}
|
|
19
|
+
export async function handleFileDelete(args) {
|
|
20
|
+
const { filepath } = args;
|
|
21
|
+
if (!filepath)
|
|
22
|
+
throw new Error('filepath required');
|
|
23
|
+
await fs.rm(filepath, { recursive: true, force: true });
|
|
24
|
+
return { content: [{ type: 'text', text: `Deleted ${filepath}` }] };
|
|
25
|
+
}
|
|
26
|
+
export async function handleFileSearch(args) {
|
|
27
|
+
const { dirpath = '.', query, type = 'name', recursive = true } = args;
|
|
28
|
+
if (!query)
|
|
29
|
+
throw new Error('query required');
|
|
30
|
+
let command = '';
|
|
31
|
+
if (type === 'name') {
|
|
32
|
+
command = `find ${dirpath} ${recursive ? '' : '-maxdepth 1'} -name "*${query}*"`;
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
command = `grep -rnE "${query}" ${dirpath} ${recursive ? '' : '--maxdepth=0'}`;
|
|
36
|
+
}
|
|
37
|
+
const result = await executeCommand(command, { timeout: 15000 });
|
|
38
|
+
const output = result.stdout || result.stderr || (result.success ? '(no matches)' : 'Search failed');
|
|
39
|
+
return {
|
|
40
|
+
content: [{ type: 'text', text: output.slice(0, 20000) }]
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
export async function handleFilePatch(args) {
|
|
44
|
+
const { filepath, search, replace } = args;
|
|
45
|
+
if (!filepath || search === undefined || replace === undefined)
|
|
46
|
+
throw new Error('filepath, search, and replace required');
|
|
47
|
+
const content = await fs.readFile(filepath, 'utf-8');
|
|
48
|
+
if (!content.includes(search)) {
|
|
49
|
+
throw new Error(`Search string not found in ${filepath}`);
|
|
50
|
+
}
|
|
51
|
+
const newContent = content.replace(search, replace);
|
|
52
|
+
await fs.writeFile(filepath, newContent);
|
|
53
|
+
return {
|
|
54
|
+
content: [{ type: 'text', text: `Successfully patched ${filepath}` }]
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
export async function handleListFiles(args) {
|
|
58
|
+
const { dirpath = '.', recursive = false, depth = 3, exclude = [] } = args;
|
|
59
|
+
if (recursive) {
|
|
60
|
+
let excludePart = exclude.map((item) => `-not -path "*/${item}/*"`).join(' ');
|
|
61
|
+
const command = `find ${dirpath} -maxdepth ${depth} -not -path "*/.*" ${excludePart} | sed -e "s/[^-][^\/]*\// |/g" -e "s/| / |-/g"`;
|
|
62
|
+
const result = await executeCommand(command, { timeout: 10000 });
|
|
63
|
+
return { content: [{ type: 'text', text: result.stdout || '(no results)' }] };
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
const entries = await fs.readdir(dirpath, { withFileTypes: true });
|
|
67
|
+
const list = entries.map(e => `${e.isDirectory() ? '📁' : '📄'} ${e.name}`).join('\n');
|
|
68
|
+
return { content: [{ type: 'text', text: list || '(empty)' }] };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { executeCommand } from '../sandbox/executor.js';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import fs from 'fs/promises';
|
|
4
|
+
export async function handleHttpRequest(args) {
|
|
5
|
+
const { url, method = 'GET', body, headers = {}, savePath } = args;
|
|
6
|
+
if (!url)
|
|
7
|
+
throw new Error('url required');
|
|
8
|
+
let command = `curl -sL -X ${method} "${url}"`;
|
|
9
|
+
if (savePath) {
|
|
10
|
+
const dir = path.dirname(savePath);
|
|
11
|
+
await fs.mkdir(dir, { recursive: true });
|
|
12
|
+
command = `curl -L -o "${savePath}" "${url}"`;
|
|
13
|
+
}
|
|
14
|
+
else {
|
|
15
|
+
Object.entries(headers).forEach(([key, value]) => {
|
|
16
|
+
command += ` -H "${key}: ${value}"`;
|
|
17
|
+
});
|
|
18
|
+
if (body) {
|
|
19
|
+
const bodyStr = typeof body === 'string' ? body : JSON.stringify(body);
|
|
20
|
+
command += ` -d '${bodyStr.replace(/'/g, "'\\''")}'`;
|
|
21
|
+
if (!headers['Content-Type']) {
|
|
22
|
+
command += ` -H "Content-Type: application/json"`;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const result = await executeCommand(command, { timeout: savePath ? 60000 : 20000 });
|
|
27
|
+
if (savePath) {
|
|
28
|
+
if (result.success) {
|
|
29
|
+
return { content: [{ type: 'text', text: `Successfully downloaded to ${savePath}` }] };
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
throw new Error(`Download failed: ${result.stderr || result.error}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const output = result.stdout || result.stderr || (result.success ? '(empty response)' : 'Request failed');
|
|
36
|
+
return {
|
|
37
|
+
content: [{
|
|
38
|
+
type: 'text',
|
|
39
|
+
text: output.slice(0, 30000)
|
|
40
|
+
}]
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { executeCommand } from '../sandbox/executor.js';
|
|
4
|
+
const RUNNERS = {
|
|
5
|
+
python: 'python3',
|
|
6
|
+
bash: 'bash',
|
|
7
|
+
node: 'node',
|
|
8
|
+
sh: 'sh',
|
|
9
|
+
};
|
|
10
|
+
export async function handleRunScript(args) {
|
|
11
|
+
const { language, code, timeout = 30000 } = args;
|
|
12
|
+
if (!language || !code)
|
|
13
|
+
throw new Error('language and code required');
|
|
14
|
+
const runner = RUNNERS[language];
|
|
15
|
+
if (!runner)
|
|
16
|
+
throw new Error(`Unsupported language: ${language}`);
|
|
17
|
+
const tmpDir = '/tmp/mcp-scripts';
|
|
18
|
+
await fs.mkdir(tmpDir, { recursive: true });
|
|
19
|
+
const extMap = { python: 'py', bash: 'sh', node: 'js', sh: 'sh' };
|
|
20
|
+
const fileName = `script_${Date.now()}.${extMap[language] || 'txt'}`;
|
|
21
|
+
const filePath = path.join(tmpDir, fileName);
|
|
22
|
+
await fs.writeFile(filePath, code, { mode: 0o644 });
|
|
23
|
+
let output = '';
|
|
24
|
+
const result = await executeCommand(`${runner} ${filePath}`, {
|
|
25
|
+
cwd: tmpDir,
|
|
26
|
+
timeout,
|
|
27
|
+
onStdout: (chunk) => { output += chunk; },
|
|
28
|
+
onStderr: (chunk) => { output += chunk; },
|
|
29
|
+
});
|
|
30
|
+
await fs.unlink(filePath).catch(() => { });
|
|
31
|
+
return {
|
|
32
|
+
content: [{
|
|
33
|
+
type: 'text',
|
|
34
|
+
text: JSON.stringify({
|
|
35
|
+
success: result.success,
|
|
36
|
+
exitCode: result.exitCode,
|
|
37
|
+
output: output.slice(-20000),
|
|
38
|
+
timedOut: result.timedOut,
|
|
39
|
+
error: result.error,
|
|
40
|
+
}, null, 2),
|
|
41
|
+
}],
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { TmuxManager } from '../sandbox/tmux-manager.js';
|
|
2
|
+
export async function handleSessionStart(args) {
|
|
3
|
+
const { sessionId, command, cwd } = args;
|
|
4
|
+
if (!sessionId)
|
|
5
|
+
throw new Error('sessionId required');
|
|
6
|
+
const exists = await TmuxManager.sessionExists(sessionId);
|
|
7
|
+
if (exists) {
|
|
8
|
+
return { content: [{
|
|
9
|
+
type: 'text',
|
|
10
|
+
text: `Session ${sessionId} already exists`
|
|
11
|
+
}] };
|
|
12
|
+
}
|
|
13
|
+
await TmuxManager.startSession(sessionId, command || 'bash', cwd);
|
|
14
|
+
return { content: [{
|
|
15
|
+
type: 'text',
|
|
16
|
+
text: `Session ${sessionId} started`
|
|
17
|
+
}] };
|
|
18
|
+
}
|
|
19
|
+
export async function handleSessionExec(args) {
|
|
20
|
+
const { sessionId, command } = args;
|
|
21
|
+
if (!sessionId || !command)
|
|
22
|
+
throw new Error('sessionId and command required');
|
|
23
|
+
const exists = await TmuxManager.sessionExists(sessionId);
|
|
24
|
+
if (!exists)
|
|
25
|
+
throw new Error(`Session ${sessionId} not found`);
|
|
26
|
+
await TmuxManager.execInSession(sessionId, command);
|
|
27
|
+
return { content: [{
|
|
28
|
+
type: 'text',
|
|
29
|
+
text: `Command sent to session ${sessionId}`
|
|
30
|
+
}] };
|
|
31
|
+
}
|
|
32
|
+
export async function handleSessionRead(args) {
|
|
33
|
+
const { sessionId, maxChars = 20000 } = args;
|
|
34
|
+
if (!sessionId)
|
|
35
|
+
throw new Error('sessionId required');
|
|
36
|
+
const transcript = await TmuxManager.readSession(sessionId);
|
|
37
|
+
const truncated = transcript.length > maxChars ? transcript.slice(-maxChars) : transcript;
|
|
38
|
+
return { content: [{
|
|
39
|
+
type: 'text',
|
|
40
|
+
text: truncated
|
|
41
|
+
}] };
|
|
42
|
+
}
|
|
43
|
+
export async function handleSessionStop(args) {
|
|
44
|
+
const { sessionId } = args;
|
|
45
|
+
if (!sessionId)
|
|
46
|
+
throw new Error('sessionId required');
|
|
47
|
+
await TmuxManager.stopSession(sessionId);
|
|
48
|
+
return { content: [{
|
|
49
|
+
type: 'text',
|
|
50
|
+
text: `Session ${sessionId} stopped`
|
|
51
|
+
}] };
|
|
52
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { executeCommand } from '../sandbox/executor.js';
|
|
2
|
+
export async function handleGetSystemStatus(args) {
|
|
3
|
+
const { type = 'summary', sort = 'cpu' } = args;
|
|
4
|
+
if (type === 'summary') {
|
|
5
|
+
const command = 'echo "OS Info:"; uname -a; echo "\nUptime:"; uptime; echo "\nMemory Usage:"; free -h; echo "\nDisk Usage:"; df -h /';
|
|
6
|
+
const result = await executeCommand(command);
|
|
7
|
+
return {
|
|
8
|
+
content: [{ type: 'text', text: result.stdout || result.stderr }]
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
else {
|
|
12
|
+
let command = 'ps aux --sort=-%cpu | head -n 31';
|
|
13
|
+
if (sort === 'mem') {
|
|
14
|
+
command = 'ps aux --sort=-%mem | head -n 31';
|
|
15
|
+
}
|
|
16
|
+
const result = await executeCommand(command);
|
|
17
|
+
return {
|
|
18
|
+
content: [{ type: 'text', text: result.stdout || result.stderr }]
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export async function handleKillProcess(args) {
|
|
23
|
+
const { pid, force = false } = args;
|
|
24
|
+
if (!pid)
|
|
25
|
+
throw new Error('pid required');
|
|
26
|
+
const command = `kill ${force ? '-9' : ''} ${pid}`;
|
|
27
|
+
const result = await executeCommand(command);
|
|
28
|
+
if (result.success) {
|
|
29
|
+
return { content: [{ type: 'text', text: `Successfully sent kill signal to process ${pid}` }] };
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
throw new Error(`Failed to kill process: ${result.stderr || result.error}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
package/dist/utils.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { exec } from 'child_process';
|
|
2
|
+
import { promisify } from 'util';
|
|
3
|
+
export const execAsync = promisify(exec);
|
|
4
|
+
export const DEFAULT_TIMEOUT = 30000;
|
|
5
|
+
export const MAX_BUFFER = 10 * 1024 * 1024;
|
|
6
|
+
export function quoteShell(arg) {
|
|
7
|
+
return `'${arg.replace(/'/g, "'\\''")}'`;
|
|
8
|
+
}
|
|
9
|
+
export function truncateOutput(output, maxLen = 20000) {
|
|
10
|
+
if (output.length <= maxLen)
|
|
11
|
+
return output;
|
|
12
|
+
return output.slice(0, maxLen) + '\n... [truncated]';
|
|
13
|
+
}
|
|
14
|
+
export function safeJsonStringify(obj) {
|
|
15
|
+
try {
|
|
16
|
+
return JSON.stringify(obj, null, 2);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return String(obj);
|
|
20
|
+
}
|
|
21
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zy_zhou/vps-mcp-server",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "MCP Server for VPS sandbox execution — run commands, scripts, and manage sessions remotely via MCP protocol. Supports stdio and SSE transports.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/server.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"vps-mcp-server": "dist/server.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist/**/*.js",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsc",
|
|
16
|
+
"prepare": "npm run build",
|
|
17
|
+
"start": "node dist/server.js",
|
|
18
|
+
"dev": "tsx src/server.ts"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
22
|
+
"dotenv": "^16.4.5",
|
|
23
|
+
"express": "^4.19.2"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@types/node": "^20.14.0",
|
|
27
|
+
"@types/express": "^4.17.21",
|
|
28
|
+
"tsx": "^4.19.0",
|
|
29
|
+
"typescript": "^5.5.3"
|
|
30
|
+
}
|
|
31
|
+
}
|