Files
firefrost-operations-manual/docs/tasks/task-092-desktop-mcp/README.md
Claude d6a36d6d35 Add Task #92 and #93: Desktop MCP + Trinity Codex architecture
WHAT WAS DONE:
- Task #92: Desktop MCP + Dispatch Architecture
  - Complete Node.js MCP server code (config, ssh_helper, index.js)
  - Express webhook listener for mobile dispatch
  - Cloudflare Tunnel setup instructions
  - 6 tools: server_status, restart_service, docker_compose_restart,
    git_pull, tail_logs, pterodactyl_power
  - Frostwall security rules documented
  - Claude Desktop configuration

- Task #93: Trinity Codex (Shared Knowledge Base)
  - Dify/Qdrant RAG architecture
  - Three lineages: Wizard's, Emissary's, Catalyst's Chroniclers
  - Gitea -> n8n -> Dify ingestion pipeline
  - MCP connector for Michael (heavy use)
  - Dify Web App for Meg/Holly (light use)
  - Chunking strategy per content type
  - Security and access levels

ARCHITECTURE DECISIONS (via Gemini consultation):
- Claude Web cannot dispatch webhooks - use Discord bot + n8n instead
- Build Codex (Task #93) FIRST - read-only, lower risk
- Separate Discord Ops Bot from Arbiter for security
- Meg/Holly use Dify Web App, not local MCP

STATUS: Ready for implementation next session

Signed-off-by: Claude (Chronicler #60) <claude@firefrostgaming.com>
2026-04-05 00:02:26 +00:00

16 KiB

Task #92: Desktop MCP + Dispatch Architecture

Created: April 5, 2026
Created By: Chronicler #60 + Gemini AI
Status: READY FOR IMPLEMENTATION
Priority: High (Accessibility accommodation)
Assignee: Michael


Overview

Build a local MCP server on Michael's always-on Windows laptop that enables Claude Desktop to execute SSH commands on all Firefrost servers. Include a mobile dispatch system via Discord bot + n8n + Cloudflare Tunnel.

The Problem

Michael has hand/arm limitations from reconstructive surgery. Copy-pasting commands between Claude and terminals is physically taxing. Claude's web sandbox blocks SSH (port 22) regardless of port number — it detects the protocol, not just the port.

The Solution

  1. Claude Desktop on always-on laptop with local MCP server
  2. Local MCP Server (Node.js) with SSH access to all servers
  3. Cloudflare Tunnel exposing a webhook listener (no router ports needed)
  4. Discord Ops Bot for mobile dispatch (separate from Arbiter)
  5. n8n bridge connecting Discord → Laptop

Architecture

MOBILE DISPATCH:
Phone (Discord) → Firefrost Ops Bot → n8n (Command Center) → 
Cloudflare Tunnel → Express Listener (Laptop) → SSH (Destination Server)

DESKTOP DIRECT:
Claude Desktop (Laptop) → Local MCP Server → SSH (Destination Server)

Infrastructure Reference

Server IP SSH User
Command Center 63.143.34.217 root
Panel VPS 45.94.168.138 root
TX1 Dallas 38.68.14.26 root
NC1 Charlotte 216.239.104.130 root
Services VPS 38.68.14.188 root
Wiki VPS 64.50.188.14 architect

Implementation Steps

Step 1: SSH Key Setup (Windows)

Open PowerShell and generate a dedicated Ed25519 key:

ssh-keygen -t ed25519 -C "laptop-mcp-automation" -f "$HOME\.ssh\mcp_rsa"

Then append the public key to ~/.ssh/authorized_keys on each server:

Step 2: MCP Server Setup

Create project directory:

cd C:\Firefrost
mkdir mcp-server
cd mcp-server
npm init -y
npm install @modelcontextprotocol/sdk ssh2 dotenv express axios

Step 3: Create Config File

File: C:\Firefrost\mcp-server\config.js

// config.js
const os = require('os');
const path = require('path');

module.exports = {
    servers: {
        command_center: { host: '63.143.34.217', user: 'root' },
        panel: { host: '45.94.168.138', user: 'root' },
        tx1: { host: '38.68.14.26', user: 'root' },
        nc1: { host: '216.239.104.130', user: 'root' },
        services: { host: '38.68.14.188', user: 'root' },
        wiki: { host: '64.50.188.14', user: 'architect' }
    },
    sshKeyPath: path.join(os.homedir(), '.ssh', 'mcp_rsa')
};

Step 4: Create SSH Helper

File: C:\Firefrost\mcp-server\ssh_helper.js

// ssh_helper.js
const { Client } = require('ssh2');
const fs = require('fs');
const config = require('./config');

async function executeCommand(serverName, command) {
    return new Promise((resolve, reject) => {
        const target = config.servers[serverName];
        if (!target) return reject(`Server ${serverName} not found in config.`);

        const conn = new Client();
        conn.on('ready', () => {
            conn.exec(command, (err, stream) => {
                if (err) { conn.end(); return reject(err); }
                let output = '';
                stream.on('close', (code, signal) => {
                    conn.end();
                    resolve({ code, output });
                }).on('data', (data) => { output += data; })
                  .stderr.on('data', (data) => { output += `ERROR: ${data}`; });
            });
        }).on('error', (err) => {
            reject(err);
        }).connect({
            host: target.host,
            port: 22,
            username: target.user,
            privateKey: fs.readFileSync(config.sshKeyPath)
        });
    });
}

module.exports = { executeCommand };

Step 5: Create MCP Server

File: C:\Firefrost\mcp-server\index.js

// index.js
const { Server } = require("@modelcontextprotocol/sdk/server/index.js");
const { StdioServerTransport } = require("@modelcontextprotocol/sdk/server/stdio.js");
const { CallToolRequestSchema, ListToolsRequestSchema } = require("@modelcontextprotocol/sdk/types.js");
const { executeCommand } = require('./ssh_helper');
const axios = require('axios');

const server = new Server(
    { name: "firefrost-mcp", version: "1.0.0" },
    { capabilities: { tools: {} } }
);

// Tool Definitions
server.setRequestHandler(ListToolsRequestSchema, async () => ({
    tools: [
        {
            name: "server_status",
            description: "Returns uptime, memory, and disk usage for a server",
            inputSchema: {
                type: "object",
                properties: { 
                    server: { 
                        type: "string",
                        enum: ["command_center", "panel", "tx1", "nc1", "services", "wiki"]
                    } 
                },
                required: ["server"]
            }
        },
        {
            name: "restart_service",
            description: "Restarts a systemd service",
            inputSchema: {
                type: "object",
                properties: { 
                    server: { type: "string" }, 
                    service: { type: "string" } 
                },
                required: ["server", "service"]
            }
        },
        {
            name: "docker_compose_restart",
            description: "Restarts a docker compose stack",
            inputSchema: {
                type: "object",
                properties: { 
                    server: { type: "string" }, 
                    stack: { type: "string" } 
                },
                required: ["server", "stack"]
            }
        },
        {
            name: "git_pull",
            description: "Pulls latest changes on a repository",
            inputSchema: {
                type: "object",
                properties: { 
                    server: { type: "string" }, 
                    repo_path: { type: "string" } 
                },
                required: ["server", "repo_path"]
            }
        },
        {
            name: "tail_logs",
            description: "Returns last N lines of a service log",
            inputSchema: {
                type: "object",
                properties: { 
                    server: { type: "string" }, 
                    service: { type: "string" },
                    lines: { type: "number", default: 50 }
                },
                required: ["server", "service"]
            }
        },
        {
            name: "pterodactyl_power",
            description: "Start/stop/restart a game server via Pterodactyl API",
            inputSchema: {
                type: "object",
                properties: { 
                    server_id: { type: "string" }, 
                    action: { 
                        type: "string",
                        enum: ["start", "stop", "restart", "kill"]
                    }
                },
                required: ["server_id", "action"]
            }
        }
    ]
}));

File: C:\Firefrost\mcp-server\index.js (continued - execution logic)

// Tool Execution
server.setRequestHandler(CallToolRequestSchema, async (request) => {
    const { name, arguments: args } = request.params;
    
    try {
        let cmd = '';
        let result;

        if (name === "server_status") {
            cmd = "uptime && free -m && df -h /";
            result = await executeCommand(args.server, cmd);
            
        } else if (name === "restart_service") {
            if (!/^[a-zA-Z0-9_-]+$/.test(args.service)) {
                throw new Error("Invalid service name format");
            }
            cmd = `systemctl restart ${args.service}`;
            result = await executeCommand(args.server, cmd);
            
        } else if (name === "docker_compose_restart") {
            if (!/^[a-zA-Z0-9_-]+$/.test(args.stack)) {
                throw new Error("Invalid stack name format");
            }
            cmd = `cd /opt/${args.stack} && docker compose restart`;
            result = await executeCommand(args.server, cmd);
            
        } else if (name === "git_pull") {
            const safePath = args.repo_path.replace(/[^a-zA-Z0-9_\-\/]/g, '');
            cmd = `cd ${safePath} && git pull origin main`;
            result = await executeCommand(args.server, cmd);
            
        } else if (name === "tail_logs") {
            const lines = parseInt(args.lines) || 50;
            if (!/^[a-zA-Z0-9_-]+$/.test(args.service)) {
                throw new Error("Invalid service name format");
            }
            cmd = `journalctl -u ${args.service} -n ${lines} --no-pager`;
            result = await executeCommand(args.server, cmd);
            
        } else if (name === "pterodactyl_power") {
            const pteroUrl = `https://panel.firefrostgaming.com/api/client/servers/${args.server_id}/power`;
            await axios.post(pteroUrl, { signal: args.action }, {
                headers: {
                    'Authorization': `Bearer ${process.env.PTERO_API_KEY}`,
                    'Content-Type': 'application/json',
                    'Accept': 'application/json'
                }
            });
            return {
                content: [{ type: "text", text: `Power command '${args.action}' sent to server ${args.server_id}` }]
            };
            
        } else {
            throw new Error("Unknown tool");
        }

        return {
            content: [{ type: "text", text: `Exit Code: ${result.code}\nOutput:\n${result.output}` }]
        };
    } catch (error) {
        return { 
            content: [{ type: "text", text: `Error: ${error.toString()}` }], 
            isError: true 
        };
    }
});

const transport = new StdioServerTransport();
server.connect(transport).catch(console.error);

Step 6: Create Webhook Listener

File: C:\Firefrost\mcp-server\webhook_listener.js

// webhook_listener.js
const express = require('express');
const { executeCommand } = require('./ssh_helper');
const app = express();
app.use(express.json());

const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET || 'CHANGE_ME_IN_PRODUCTION';

// Frostwall: Bearer Token validation
app.use((req, res, next) => {
    const authHeader = req.headers.authorization;
    if (!authHeader || authHeader !== `Bearer ${WEBHOOK_SECRET}`) {
        console.log('Frostwall blocked unauthorized request');
        return res.status(403).json({ error: 'Frostwall blocked: Unauthorized' });
    }
    next();
});

// Action allowlist
const ALLOWED_ACTIONS = [
    'server_status',
    'restart_service',
    'docker_compose_restart',
    'git_pull',
    'tail_logs'
];

app.post('/dispatch', async (req, res) => {
    const { action, server, target, extra } = req.body;
    
    // Frostwall: Action allowlist
    if (!ALLOWED_ACTIONS.includes(action)) {
        return res.status(400).json({ error: 'Action not in allowlist' });
    }
    
    try {
        let cmd = '';
        
        if (action === 'server_status') {
            cmd = "uptime && free -m && df -h /";
            
        } else if (action === 'restart_service') {
            if (!/^[a-zA-Z0-9_-]+$/.test(target)) {
                throw new Error("Invalid service name format");
            }
            cmd = `systemctl restart ${target}`;
            
        } else if (action === 'docker_compose_restart') {
            if (!/^[a-zA-Z0-9_-]+$/.test(target)) {
                throw new Error("Invalid stack name format");
            }
            cmd = `cd /opt/${target} && docker compose restart`;
            
        } else if (action === 'git_pull') {
            const safePath = target.replace(/[^a-zA-Z0-9_\-\/]/g, '');
            cmd = `cd ${safePath} && git pull origin main`;
            
        } else if (action === 'tail_logs') {
            const lines = parseInt(extra?.lines) || 50;
            if (!/^[a-zA-Z0-9_-]+$/.test(target)) {
                throw new Error("Invalid service name format");
            }
            cmd = `journalctl -u ${target} -n ${lines} --no-pager`;
        }

        const result = await executeCommand(server, cmd);
        res.json({ success: true, code: result.code, output: result.output });
        
    } catch (error) {
        console.error('Dispatch error:', error);
        res.status(500).json({ success: false, error: error.message });
    }
});

// Health check endpoint
app.get('/health', (req, res) => {
    res.json({ status: 'ok', timestamp: new Date().toISOString() });
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
    console.log(`Firefrost Ops Listener running on port ${PORT}`);
});

Step 7: Environment Variables

File: C:\Firefrost\mcp-server\.env

WEBHOOK_SECRET=your-secure-32-char-token-from-vaultwarden
PTERO_API_KEY=your-pterodactyl-client-api-key
DIFY_API_KEY=your-dify-api-key

Step 8: Claude Desktop Configuration

File: %APPDATA%\Claude\claude_desktop_config.json

{
  "mcpServers": {
    "firefrost-admin": {
      "command": "node",
      "args": [
        "C:\\Firefrost\\mcp-server\\index.js"
      ]
    }
  }
}

Step 9: Cloudflare Tunnel Setup

  1. Download cloudflared-windows-amd64.msi from Cloudflare
  2. Install and authenticate via browser
  3. In Cloudflare Zero Trust dashboard, create tunnel for ops.firefrostgaming.com
  4. Run the provided command to install as Windows Service
  5. Tunnel auto-starts on boot

Step 10: Windows Power Settings

  1. Settings → System → Power & battery
  2. Set "Screen and sleep" to Never when plugged in
  3. Optional: Install Microsoft PowerToys → Enable Awake tool

Step 11: Discord Ops Bot (Separate from Arbiter)

Create a new Discord bot for server operations:

  • Restrict to hidden #ops-dispatch channel
  • Trinity-only permissions
  • Commands trigger n8n webhooks

Testing & Validation

Test MCP Server Locally

npx @modelcontextprotocol/inspector node index.js

This opens a web UI to test tools before connecting Claude.

SSH Dry Run Mode

In ssh_helper.js, temporarily change:

conn.exec(command, ...)

to:

conn.exec('echo DRY RUN: ' + command, ...)

Test Cloudflare Tunnel

curl -X POST https://ops.firefrostgaming.com/health \
  -H "Authorization: Bearer YOUR_TOKEN"

Frostwall Security Rules

  1. Bearer Token Authentication — Every request must include valid token
  2. Action Allowlist — Only predefined actions accepted, no raw bash
  3. Input Sanitization — Regex validation on all service/stack names
  4. Audit Logging — All requests logged with timestamp
  5. Separate Bot — Ops commands isolated from public Arbiter bot

Fallback Procedures

Condition Red (Tunnel/Laptop Offline):

  • Fall back to manual SSH via Termius on mobile
  • Document in FFG-STD-005 (Emergency Operations)

Vaultwarden Storage

Create folder: Firefrost Ops Infrastructure

Item Type Notes
Laptop MCP Ed25519 Key Secure Note Private key text
Ops Webhook Bearer Token Password Random 32-char string
Cloudflare Tunnel Secret Password From Zero Trust dashboard
Pterodactyl Client API Key Password From panel settings

Dependencies

  • Node.js (Windows)
  • npm packages: @modelcontextprotocol/sdk, ssh2, express, axios, dotenv
  • Cloudflare account with firefrostgaming.com
  • Claude Desktop app

Files Created

File Purpose
config.js Server definitions
ssh_helper.js SSH execution wrapper
index.js MCP server with tool definitions
webhook_listener.js Express app for mobile dispatch
.env Environment variables

  • Task #93: Trinity Codex (shared knowledge base)

Fire + Frost + Foundation = Where Love Builds Legacy 🔥❄️