morphit/ops/systemd/morphit-mcp.service

125 lines
4.9 KiB
Desktop File

[Unit]
Description=Morphit MCP server (Model Context Protocol — read-only orderbook surface for AI agents)
Documentation=https://git.agorise.net/agorise/morphit/src/branch/main/docs/OPERATIONS.md
After=network-online.target morphit-indexer.service
Wants=network-online.target
# Reboot recovery (cp597): disable the start-rate-limit so this never latches
# into a permanent 'failed' state if the indexer is still starting after a
# power-cut reboot — it keeps retrying every RestartSec until the indexer is up.
StartLimitIntervalSec=0
[Service]
Type=simple
User=morphit-mcp
Group=morphit-mcp
# Working directory holds the morphit-mcp source + node_modules.
# Service user must own it with 0750 perms.
WorkingDirectory=/opt/morphit-mcp
# Run the hardened Streamable-HTTP transport (NOT stdio): the server
# binds an HTTP listener and STAYS UP as a daemon. In stdio mode it
# would read EOF on a service's empty stdin and exit 0 in well under a
# second — correct for LOCAL agent spawning (Claude Desktop et al.),
# wrong for a persistent service, which is why a plain `npm start`
# daemon used to die immediately. The bind is fail-closed in code: it
# accepts loopback or a private/bridge address (127.0.0.1 by default,
# or e.g. 172.18.0.1 so a dockerized reverse proxy like BunkerWeb can
# reach the host via the Docker bridge — exactly like the indexer/relay
# listen host) but REFUSES 0.0.0.0/:: or a public address unless
# MORPHIT_MCP_ALLOW_PUBLIC_BIND=1. Public exposure goes through the
# reverse proxy at /mcp/* (see OPERATIONS.md §41), not a public bind.
# Defenses (DNS-rebinding Host/Origin allowlists, per-client rate limit,
# request-body cap, connection ceiling, slowloris timeouts) have safe
# defaults in the source.
Environment=MORPHIT_MCP_TRANSPORT=http
Environment=MORPHIT_MCP_HTTP_HOST=127.0.0.1
Environment=MORPHIT_MCP_HTTP_PORT=8124
# MCP config in its own optional env file. Read AFTER the Environment=
# defaults above, so it can OVERRIDE any of them — chiefly
# MORPHIT_MCP_INSTANCE_URL (which instance to surface; the installer
# points it at THIS node's public origin so a self-hosted MCP serves the
# operator's own orderbook, not the default morphit.io), and on a
# dockerized-proxy host MORPHIT_MCP_HTTP_HOST=172.18.0.1. The server is
# a read-only HTTP CLIENT of the instance's public /v1 API — NO DB, NO
# keys. The `-` prefix makes it optional (absent → source defaults).
# It deliberately does NOT read the relay's env: pulling in the relay DB
# URL / keys would breach the isolation.
EnvironmentFile=-/etc/morphit/mcp.env
ExecStart=/usr/bin/env npm start --silent
# No passphrase, no tty — MCP holds no keys and reads no stdin in HTTP
# mode (the listener keeps the process alive; SIGTERM shuts it down).
# Graceful shutdown. MCP holds no in-flight chain ops; 10s is plenty.
KillSignal=SIGTERM
TimeoutStopSec=10s
# Always bring the MCP back up; the start-limit below is the circuit
# breaker against a misconfig crash-loop. The server only exits 0 on
# SIGTERM, so this never fights a deliberate `systemctl stop`.
Restart=always
RestartSec=5s
StartLimitBurst=10
StartLimitIntervalSec=120
# ─── Hardening ───────────────────────────────────────────────────────
# Defence-in-depth: MCP is the most exposed surface (AI agents reach it
# from anywhere) so we lock it down even harder than the relay. It
# is strictly read-only — no chain broadcasts, no key handling.
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
ReadOnlyPaths=/etc/morphit /opt/morphit-mcp
PrivateDevices=yes
NoNewPrivileges=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectProc=invisible
ProcSubset=pid
RestrictNamespaces=yes
LockPersonality=yes
# Same V8 JIT carve-out as the relay.
MemoryDenyWriteExecute=no
RestrictRealtime=yes
RestrictSUIDSGID=yes
SystemCallArchitectures=native
# Seccomp: allow only the service syscall set, then subtract privileged
# and resource-control syscalls. @system-service is V8-compatible — the
# MemoryDenyWriteExecute carve-out above keeps the JIT working.
SystemCallFilter=@system-service
SystemCallFilter=~@privileged ~@resources
SystemCallErrorNumber=EPERM
# Further isolation: private file-creation mask, no host identity / clock
# writes, no leaked SysV IPC, and a private mount namespace.
UMask=0077
ProtectHostname=yes
ProtectClock=yes
RemoveIPC=yes
PrivateMounts=yes
# Network: TCP only. No raw sockets, no AF_PACKET.
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
CapabilityBoundingSet=
AmbientCapabilities=
# Resource limits — MCP is lighter than the relay because it doesn't
# parse chain blocks. 256 MiB is comfortable for a few concurrent
# tool invocations.
MemoryMax=256M
TasksMax=128
StandardOutput=journal
StandardError=journal
SyslogIdentifier=morphit-mcp
[Install]
WantedBy=multi-user.target