generated from fahricansecer/boilerplate-fe
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0fda025de3 |
4
mcp-servers/rpi-deploy-mcp-server/.gitignore
vendored
Normal file
4
mcp-servers/rpi-deploy-mcp-server/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.tgz
|
||||
.env
|
||||
1688
mcp-servers/rpi-deploy-mcp-server/package-lock.json
generated
Normal file
1688
mcp-servers/rpi-deploy-mcp-server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
mcp-servers/rpi-deploy-mcp-server/package.json
Normal file
27
mcp-servers/rpi-deploy-mcp-server/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
43
mcp-servers/rpi-deploy-mcp-server/src/constants.ts
Normal file
43
mcp-servers/rpi-deploy-mcp-server/src/constants.ts
Normal 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',
|
||||
];
|
||||
29
mcp-servers/rpi-deploy-mcp-server/src/index.ts
Normal file
29
mcp-servers/rpi-deploy-mcp-server/src/index.ts
Normal 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);
|
||||
});
|
||||
86
mcp-servers/rpi-deploy-mcp-server/src/ssh-client.ts
Normal file
86
mcp-servers/rpi-deploy-mcp-server/src/ssh-client.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
149
mcp-servers/rpi-deploy-mcp-server/src/tools/containers.ts
Normal file
149
mcp-servers/rpi-deploy-mcp-server/src/tools/containers.ts
Normal 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)}` }] };
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
163
mcp-servers/rpi-deploy-mcp-server/src/tools/deploy.ts
Normal file
163
mcp-servers/rpi-deploy-mcp-server/src/tools/deploy.ts
Normal 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)}` }] };
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
134
mcp-servers/rpi-deploy-mcp-server/src/tools/system.ts
Normal file
134
mcp-servers/rpi-deploy-mcp-server/src/tools/system.ts
Normal 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)}` }] };
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
19
mcp-servers/rpi-deploy-mcp-server/tsconfig.json
Normal file
19
mcp-servers/rpi-deploy-mcp-server/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user