125 lines
4.9 KiB
Desktop File
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
|