This commit is contained in:
2026-03-17 16:22:33 +03:00
parent df0b60c97a
commit 0fda025de3
10 changed files with 2342 additions and 0 deletions

View File

@@ -0,0 +1,4 @@
node_modules/
dist/
*.tgz
.env

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,27 @@
{
"name": "rpi-deploy-mcp-server",
"version": "1.0.0",
"description": "MCP server for Raspberry Pi deployment management via SSH",
"type": "module",
"main": "dist/index.js",
"scripts": {
"start": "node dist/index.js",
"dev": "tsx watch src/index.ts",
"build": "tsc",
"clean": "rm -rf dist"
},
"engines": {
"node": ">=18"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.6.1",
"ssh2": "^1.16.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/node": "^22.10.0",
"@types/ssh2": "^1.15.0",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
}
}

View File

@@ -0,0 +1,43 @@
// SSH connection and command execution constants
export const SSH_CONFIG = {
host: process.env.RPI_HOST || '95.70.252.214',
port: parseInt(process.env.RPI_PORT || '2222'),
username: process.env.RPI_USER || 'haruncan',
password: process.env.RPI_PASSWORD,
privateKeyPath: process.env.RPI_KEY_PATH,
};
// Safety limits
export const MAX_LOG_LINES = 500;
export const DEFAULT_LOG_LINES = 50;
export const COMMAND_TIMEOUT_MS = 30_000;
export const CHARACTER_LIMIT = 25_000;
// Commands that are considered safe (read-only)
export const SAFE_COMMAND_PREFIXES = [
'docker ps',
'docker logs',
'docker inspect',
'docker network',
'docker images',
'docker stats',
'docker compose ps',
'docker compose logs',
'df',
'free',
'uptime',
'top -bn1',
'cat',
'ls',
'tail',
'head',
'grep',
'wc',
'hostname',
'uname',
'date',
'whoami',
'systemctl status',
'journalctl',
];

View File

@@ -0,0 +1,29 @@
#!/usr/bin/env node
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { registerContainerTools } from './tools/containers.js';
import { registerSystemTools } from './tools/system.js';
import { registerDeployTools } from './tools/deploy.js';
const server = new McpServer({
name: 'rpi-deploy',
version: '1.0.0',
});
// Register all tools
registerContainerTools(server);
registerSystemTools(server);
registerDeployTools(server);
// Start server with stdio transport
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error('[rpi-deploy] MCP server started on stdio');
}
main().catch((error) => {
console.error('[rpi-deploy] Fatal error:', error);
process.exit(1);
});

View File

@@ -0,0 +1,86 @@
import { Client, type ConnectConfig } from 'ssh2';
import { readFileSync } from 'fs';
import { SSH_CONFIG, COMMAND_TIMEOUT_MS } from './constants.js';
export interface CommandResult {
stdout: string;
stderr: string;
code: number;
}
function buildConnectConfig(): ConnectConfig {
const config: ConnectConfig = {
host: SSH_CONFIG.host,
port: SSH_CONFIG.port,
username: SSH_CONFIG.username,
readyTimeout: 10_000,
};
if (SSH_CONFIG.privateKeyPath) {
try {
config.privateKey = readFileSync(SSH_CONFIG.privateKeyPath);
} catch {
// fallback to password
}
}
if (!config.privateKey && SSH_CONFIG.password) {
config.password = SSH_CONFIG.password;
}
return config;
}
export async function executeCommand(command: string): Promise<CommandResult> {
return new Promise((resolve, reject) => {
const conn = new Client();
const timeout = setTimeout(() => {
conn.end();
reject(new Error(`Command timed out after ${COMMAND_TIMEOUT_MS}ms: ${command}`));
}, COMMAND_TIMEOUT_MS);
conn.on('ready', () => {
conn.exec(command, (err, stream) => {
if (err) {
clearTimeout(timeout);
conn.end();
reject(err);
return;
}
let stdout = '';
let stderr = '';
stream.on('data', (data: Buffer) => {
stdout += data.toString();
});
stream.stderr.on('data', (data: Buffer) => {
stderr += data.toString();
});
stream.on('close', (code: number) => {
clearTimeout(timeout);
conn.end();
resolve({ stdout: stdout.trim(), stderr: stderr.trim(), code: code ?? 0 });
});
});
});
conn.on('error', (err) => {
clearTimeout(timeout);
reject(new Error(`SSH connection failed: ${err.message}`));
});
conn.connect(buildConnectConfig());
});
}
export async function testConnection(): Promise<boolean> {
try {
const result = await executeCommand('echo "connected"');
return result.stdout === 'connected';
} catch {
return false;
}
}

View File

@@ -0,0 +1,149 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { executeCommand } from '../ssh-client.js';
import { DEFAULT_LOG_LINES, MAX_LOG_LINES, CHARACTER_LIMIT } from '../constants.js';
export function registerContainerTools(server: McpServer): void {
// ── rpi_list_containers ──────────────────────────────────────────────
server.registerTool(
'rpi_list_containers',
{
title: 'List Docker Containers',
description: `List all Docker containers on the Raspberry Pi with their status, ports, and resource usage.
Returns a formatted table of all containers (running and stopped) including:
- Container ID, Name, Image, Status, Ports, Created time
Args:
- all (boolean): Include stopped containers (default: true)
- format ('table' | 'json'): Output format (default: 'table')`,
inputSchema: {
all: z.boolean().default(true).describe('Include stopped containers'),
format: z.enum(['table', 'json']).default('table').describe('Output format'),
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
},
async ({ all, format }) => {
try {
const flags = all ? '-a' : '';
const formatFlag = format === 'json'
? '--format "{{json .}}"'
: '--format "table {{.ID}}\t{{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}"';
const result = await executeCommand(`docker ps ${flags} ${formatFlag}`);
if (result.code !== 0) {
return { content: [{ type: 'text', text: `Error: ${result.stderr || 'Failed to list containers'}` }] };
}
const output = result.stdout || 'No containers found.';
return { content: [{ type: 'text', text: output }] };
} catch (error) {
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }] };
}
}
);
// ── rpi_container_logs ───────────────────────────────────────────────
server.registerTool(
'rpi_container_logs',
{
title: 'Get Container Logs',
description: `Fetch logs from a specific Docker container on the Raspberry Pi.
Args:
- container (string): Container name or ID
- lines (number): Number of tail lines to fetch (default: 50, max: 500)
- since (string): Show logs since timestamp (e.g., "1h", "30m", "2024-01-01")
- grep (string): Optional grep filter to apply on logs`,
inputSchema: {
container: z.string().min(1).describe('Container name or ID'),
lines: z.number().int().min(1).max(MAX_LOG_LINES).default(DEFAULT_LOG_LINES).describe('Number of tail lines'),
since: z.string().optional().describe('Show logs since (e.g., "1h", "30m")'),
grep: z.string().optional().describe('Filter logs with grep pattern'),
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
},
async ({ container, lines, since, grep }) => {
try {
let cmd = `docker logs --tail ${lines}`;
if (since) cmd += ` --since ${since}`;
cmd += ` ${container} 2>&1`;
if (grep) cmd += ` | grep -i "${grep}"`;
const result = await executeCommand(cmd);
if (result.code !== 0 && !result.stdout) {
return { content: [{ type: 'text', text: `Error: ${result.stderr || `Container '${container}' not found`}` }] };
}
let output = result.stdout || 'No logs found.';
if (output.length > CHARACTER_LIMIT) {
output = output.slice(-CHARACTER_LIMIT);
output = `[...truncated, showing last ${CHARACTER_LIMIT} characters]\n\n${output}`;
}
return { content: [{ type: 'text', text: `## Logs: ${container} (last ${lines} lines)\n\n\`\`\`\n${output}\n\`\`\`` }] };
} catch (error) {
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }] };
}
}
);
// ── rpi_restart_container ────────────────────────────────────────────
server.registerTool(
'rpi_restart_container',
{
title: 'Restart Docker Container',
description: `Restart a specific Docker container on the Raspberry Pi.
⚠️ This is a destructive operation — the container will be temporarily unavailable.
Args:
- container (string): Container name or ID to restart
- timeout (number): Seconds to wait before killing (default: 10)`,
inputSchema: {
container: z.string().min(1).describe('Container name or ID'),
timeout: z.number().int().min(0).max(120).default(10).describe('Seconds to wait before killing'),
},
annotations: {
readOnlyHint: false,
destructiveHint: true,
idempotentHint: true,
openWorldHint: false,
},
},
async ({ container, timeout }) => {
try {
const result = await executeCommand(`docker restart --time ${timeout} ${container}`);
if (result.code !== 0) {
return { content: [{ type: 'text', text: `Error restarting '${container}': ${result.stderr}` }] };
}
// Verify the container is running
const verify = await executeCommand(`docker ps --filter "name=${container}" --format "{{.Names}}\t{{.Status}}"`);
return {
content: [{
type: 'text',
text: `✅ Container '${container}' restarted successfully.\n\nCurrent status:\n${verify.stdout}`
}]
};
} catch (error) {
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }] };
}
}
);
}

View File

@@ -0,0 +1,163 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { executeCommand } from '../ssh-client.js';
export function registerDeployTools(server: McpServer): void {
// ── rpi_deploy_status ────────────────────────────────────────────────
server.registerTool(
'rpi_deploy_status',
{
title: 'Deploy Status',
description: `Check the current deployment status on the Raspberry Pi.
Gathers information about:
- Running containers and their health
- Docker Compose project status
- Last container restart/creation times
- Gitea runner status (if available)`,
inputSchema: {
compose_dir: z.string().optional().describe('Path to docker-compose directory (default: auto-detect)'),
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
},
async ({ compose_dir }) => {
try {
const sections: string[] = ['# 🚀 Deploy Status\n'];
// Container status with creation time
const containers = await executeCommand(
'docker ps -a --format "table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.CreatedAt}}"'
);
sections.push(`## Containers\n\`\`\`\n${containers.stdout || 'No containers found'}\n\`\`\`\n`);
// Docker compose status if compose_dir specified
if (compose_dir) {
const composeStatus = await executeCommand(`cd ${compose_dir} && docker compose ps 2>&1`);
sections.push(`## Docker Compose (${compose_dir})\n\`\`\`\n${composeStatus.stdout || composeStatus.stderr}\n\`\`\`\n`);
}
// Check for Gitea runner
const runner = await executeCommand('docker ps --filter "name=runner" --filter "name=gitea" --format "{{.Names}}\t{{.Status}}"');
if (runner.stdout) {
sections.push(`## Gitea Runner\n\`\`\`\n${runner.stdout}\n\`\`\`\n`);
}
// Recent container events (last 10)
const events = await executeCommand(
'docker events --since "1h" --until "0s" --format "{{.Time}} {{.Action}} {{.Actor.Attributes.name}}" 2>/dev/null | tail -10'
);
if (events.stdout) {
sections.push(`## Recent Events (last 1h)\n\`\`\`\n${events.stdout}\n\`\`\`\n`);
}
return { content: [{ type: 'text', text: sections.join('\n') }] };
} catch (error) {
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }] };
}
}
);
// ── rpi_docker_compose_up ────────────────────────────────────────────
server.registerTool(
'rpi_docker_compose_up',
{
title: 'Docker Compose Up',
description: `Run 'docker compose up -d' in a specified directory on the Raspberry Pi.
⚠️ This will start/restart services defined in docker-compose.yml.
Args:
- directory (string): Absolute path to the directory containing docker-compose.yml
- build (boolean): Force rebuild images before starting (default: false)
- services (string[]): Specific services to start (default: all)`,
inputSchema: {
directory: z.string().min(1).describe('Absolute path to docker-compose directory'),
build: z.boolean().default(false).describe('Rebuild images before starting'),
services: z.array(z.string()).optional().describe('Specific services to start'),
},
annotations: {
readOnlyHint: false,
destructiveHint: true,
idempotentHint: true,
openWorldHint: false,
},
},
async ({ directory, build, services }) => {
try {
let cmd = `cd ${directory} && docker compose up -d`;
if (build) cmd += ' --build';
if (services?.length) cmd += ` ${services.join(' ')}`;
const result = await executeCommand(cmd);
if (result.code !== 0) {
return { content: [{ type: 'text', text: `❌ docker compose up failed:\n\`\`\`\n${result.stderr || result.stdout}\n\`\`\`` }] };
}
// Show final status
const status = await executeCommand(`cd ${directory} && docker compose ps`);
return {
content: [{
type: 'text',
text: `✅ docker compose up completed in \`${directory}\`\n\n## Current Status\n\`\`\`\n${status.stdout}\n\`\`\``
}]
};
} catch (error) {
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }] };
}
}
);
// ── rpi_docker_compose_down ──────────────────────────────────────────
server.registerTool(
'rpi_docker_compose_down',
{
title: 'Docker Compose Down',
description: `Run 'docker compose down' in a specified directory on the Raspberry Pi.
⚠️ This will stop and remove containers, networks defined in docker-compose.yml.
Args:
- directory (string): Absolute path to the directory containing docker-compose.yml
- volumes (boolean): Remove named volumes too (default: false)`,
inputSchema: {
directory: z.string().min(1).describe('Absolute path to docker-compose directory'),
volumes: z.boolean().default(false).describe('Also remove named volumes'),
},
annotations: {
readOnlyHint: false,
destructiveHint: true,
idempotentHint: true,
openWorldHint: false,
},
},
async ({ directory, volumes }) => {
try {
let cmd = `cd ${directory} && docker compose down`;
if (volumes) cmd += ' -v';
const result = await executeCommand(cmd);
if (result.code !== 0) {
return { content: [{ type: 'text', text: `❌ docker compose down failed:\n\`\`\`\n${result.stderr || result.stdout}\n\`\`\`` }] };
}
return {
content: [{
type: 'text',
text: `✅ docker compose down completed in \`${directory}\`\n\n\`\`\`\n${result.stdout || result.stderr || 'Services stopped and removed.'}\n\`\`\``
}]
};
} catch (error) {
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }] };
}
}
);
}

View File

@@ -0,0 +1,134 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { executeCommand } from '../ssh-client.js';
import { SAFE_COMMAND_PREFIXES } from '../constants.js';
export function registerSystemTools(server: McpServer): void {
// ── rpi_system_status ────────────────────────────────────────────────
server.registerTool(
'rpi_system_status',
{
title: 'Raspberry Pi System Status',
description: `Get system resource usage on the Raspberry Pi: CPU load, memory, disk, uptime, and temperature.
Returns a formatted overview of the system health.`,
inputSchema: {},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
},
async () => {
try {
const commands = [
{ label: 'Hostname', cmd: 'hostname' },
{ label: 'Uptime', cmd: 'uptime' },
{ label: 'CPU Temperature', cmd: 'cat /sys/class/thermal/thermal_zone0/temp 2>/dev/null || echo "N/A"' },
{ label: 'Memory', cmd: 'free -h | head -3' },
{ label: 'Disk', cmd: 'df -h / | tail -1' },
{ label: 'CPU Load', cmd: 'top -bn1 | head -5' },
];
const sections: string[] = ['# 🖥️ Raspberry Pi System Status\n'];
for (const { label, cmd } of commands) {
const result = await executeCommand(cmd);
let value = result.stdout || 'N/A';
// Convert CPU temp from millidegrees
if (label === 'CPU Temperature' && value !== 'N/A') {
const temp = parseInt(value);
if (!isNaN(temp)) {
value = `${(temp / 1000).toFixed(1)}°C`;
}
}
sections.push(`## ${label}\n\`\`\`\n${value}\n\`\`\`\n`);
}
return { content: [{ type: 'text', text: sections.join('\n') }] };
} catch (error) {
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }] };
}
}
);
// ── rpi_docker_networks ──────────────────────────────────────────────
server.registerTool(
'rpi_docker_networks',
{
title: 'List Docker Networks',
description: `List all Docker networks on the Raspberry Pi with driver and scope info.`,
inputSchema: {},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
},
async () => {
try {
const result = await executeCommand('docker network ls --format "table {{.ID}}\t{{.Name}}\t{{.Driver}}\t{{.Scope}}"');
if (result.code !== 0) {
return { content: [{ type: 'text', text: `Error: ${result.stderr}` }] };
}
return { content: [{ type: 'text', text: `## Docker Networks\n\n\`\`\`\n${result.stdout}\n\`\`\`` }] };
} catch (error) {
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }] };
}
}
);
// ── rpi_run_command ──────────────────────────────────────────────────
server.registerTool(
'rpi_run_command',
{
title: 'Run Safe Command on RPi',
description: `Execute a read-only command on the Raspberry Pi via SSH.
Only whitelisted safe commands are allowed (docker ps, docker logs, df, free, uptime, cat, ls, tail, head, grep, etc.).
Args:
- command (string): The command to execute (must start with a safe prefix)`,
inputSchema: {
command: z.string().min(1).max(500).describe('Safe read-only command to execute'),
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: true,
},
},
async ({ command }) => {
try {
const trimmedCmd = command.trim();
const isSafe = SAFE_COMMAND_PREFIXES.some(prefix => trimmedCmd.startsWith(prefix));
if (!isSafe) {
return {
content: [{
type: 'text',
text: `❌ Command rejected: '${trimmedCmd}' is not in the safe command list.\n\nAllowed prefixes:\n${SAFE_COMMAND_PREFIXES.map(p => ` - ${p}`).join('\n')}`
}]
};
}
const result = await executeCommand(trimmedCmd);
const output = result.stdout || result.stderr || '(no output)';
const status = result.code === 0 ? '✅' : `⚠️ (exit code: ${result.code})`;
return { content: [{ type: 'text', text: `${status} \`${trimmedCmd}\`\n\n\`\`\`\n${output}\n\`\`\`` }] };
} catch (error) {
return { content: [{ type: 'text', text: `Error: ${error instanceof Error ? error.message : String(error)}` }] };
}
}
);
}

View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"sourceMap": true,
"allowSyntheticDefaultImports": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}