Architecture Overview
July 30, 2026 · View on GitHub
System diagram
Claude / MCP client (Claude Code, Codex, curl)
│
▼
HTTP Transport (axum, port 9158)
│
▼
Auth Middleware (bearer token check)
│
▼
RMCP Streamable HTTP service (stateless JSON-response mode)
│
▼
mcp/tools.rs (thin shim: parse JSON args → call service)
│
▼
GotifyService (app.rs) (business logic, destructive gate)
│
▼
GotifyClient (gotify.rs) (reqwest HTTP client)
│ X-Gotify-Key: <token>
▼
Gotify REST API (external server)
Mode dispatch (main.rs)
main.rs reads the first CLI argument and routes to one of three entry points:
| Args | Entry point | Description |
|---|---|---|
(none), serve, serve mcp | serve_mcp() | RMCP Streamable HTTP on port 9158 |
mcp | serve_stdio_mcp() | RMCP stdio transport for local child-process clients |
| anything else | run_cli() | Direct CLI execution |
All three entry points load the same Config and construct the same GotifyService. The transport layer is the only difference.
Layer responsibilities
gotify.rs — HTTP REST client
- Owns the
reqwest::Clientinstance and base URL. - Implements all Gotify REST API calls: GET, POST, PUT, DELETE.
- Attaches
X-Gotify-Keyheader with the appropriate token per operation type. send_messageusesapp_token; all other methods useclient_token.- HTTP 204 responses are normalized to
{"status":"ok"}. - Non-2xx responses bail with the HTTP status and response body.
- No business logic — only HTTP mechanics.
app.rs — GotifyService (business layer)
- Single
GotifyServicestruct shared by CLI and MCP. destructive_gate(confirm)— central safety check for all destructive operations. ReturnsErrunlessconfirm == trueorallow_destructive == true.- All methods delegate directly to
GotifyClient. No logic beyond the gate.
mcp/tools.rs — MCP shim
execute_tool(state, name, args)— top-level tool dispatch. Only tool name is"gotify".dispatch(state, args)— matches onactionstring, extracts typed args fromserde_json::Value, callsGotifyService.- Zero business logic. If parsing fails, returns a typed
anyhow::Error.
cli.rs — CLI shim
- Parses
Vec<String>args into a typedCliCommandenum. - Calls
GotifyServicemethods identically to the MCP shim. - Formats output as human-readable text or JSON (
--jsonflag).
mcp.rs — HTTP server
- Builds the
axum::RouterwithPOST /mcpandGET /healthroutes. AuthPolicyenum selects bearer-token enforcement or loopback-dev bypass.- Mounts the RMCP service as a tower-compatible layer.
Request flow (MCP)
POST /mcp { "method": "tools/call", "params": { "name": "gotify", "arguments": { "action": "send", ... } } }
↓
Auth middleware (checks Authorization: Bearer header)
↓
RMCP layer (validates JSON-RPC envelope, routes to tool handler)
↓
execute_tool("gotify", args)
↓
dispatch() → match "send" → GotifyService::send(...)
↓
GotifyClient::send_message(...) → POST /message (X-Gotify-Key: <app_token>)
↓
Gotify server returns created message JSON
↓
Value returned up the call stack → wrapped in MCP content → JSON-RPC response
Auth model
| Condition | Auth behavior |
|---|---|
GOTIFY_MCP_TOKEN set | Bearer token required on all /mcp requests |
GOTIFY_MCP_NO_AUTH=true | Auth disabled |
Bind host starts with 127. | Auth disabled (loopback dev mode) |
| stdio mode | No auth (process boundary is the trust boundary) |
/health is always unauthenticated.
Error handling
| Source | Error | Behavior |
|---|---|---|
| Auth middleware | Missing/invalid bearer token | HTTP 401 |
| Tool dispatch | Unknown tool name | RMCP tool error |
| Tool handler | Missing required arg | RMCP invalid params error |
| Destructive gate | No confirm, no env flag | anyhow::Error → MCP isError: true |
| Gotify client | Non-2xx HTTP response | anyhow::bail! with status + body |
| Config | Empty GOTIFY_URL | anyhow::bail! at startup |
Cross-references
- TECH.md — technology choices and rationale
- ../INVENTORY.md — complete action/command/env inventory
- ../../README.md — user-facing reference