A FastAPI server that exposes arbitrary terminal commands — plus
CalDAV calendars, ICS feeds, Gitea repositories, and notification
providers — as reusable tools for a language model. CLI programs are
registered by dropping a YAML file into registry/; integrations are
enabled by setting environment variables. The model discovers
available tools via the OpenAPI schema and calls them through typed
HTTP endpoints.
- Language-agnostic – wrap any script, binary, or compiled program.
- Self-describing – each command carries a JSON schema of its args.
- Discoverable – OpenAPI schema at
/openapi.json; each command is exposed as a typed, native tool. - Each registry command gets its own dedicated endpoint (e.g.
POST /log) — no generic "execute" route. - Secure execution – arguments are validated against the schema before the command is ever run; a 30 s timeout prevents hangs.
- Conditional registration – endpoints only exist when their backing service is configured. The LLM never sees routes that would return 503.
- Optional API key – set
MCP_API_KEYto require authentication on all endpoints except/api/healthand/api/about.
cd ~/projects/mcp-server
python3 -m venv .venv
.venv/bin/pip install -e ".[dev]"
# Optional: set an API key to secure the server
export MCP_API_KEY="your-secret-key"
.venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8000The server now listens on http://127.0.0.1:8000.
If MCP_API_KEY is set, all endpoints except /api/health and /api/about
require the key to be sent either as an X-API-Key: <key> header or as
Authorization: Bearer <key>. If unset, the server runs
open (suitable for local development or trusted networks).
Startup safety: the server refuses to start in two situations:
- Nothing configured — no calendar providers, no Gitea, no notify providers, no weather, and no registry commands. At least one feature must be enabled.
- Secrets without auth — if any integration is configured with secrets (Discord webhooks, ntfy token, CalDAV credentials, ICS feed URL, Gitea token, vital-pulse/penny-track API keys) and
MCP_API_KEYis not set, the server refuses to start. It will not hold secrets while running open.
The server uses a factory pattern (create_app()) that inspects
environment variables at startup and conditionally registers routers
for each configured integration. This means the OpenAPI schema only
contains endpoints that will actually work — the LLM never discovers
routes that would return 503.
Calendar integrations (CalDAV and ICS) are implemented as providers
that implement a common protocol. A global provider_registry holds all
active providers. The unified router (unified_routes.py) exposes
/events, /calendars, and (when ICS is configured) /calendars/refresh
across all providers. Write operations (create/update/delete events) are
only registered when an editable provider exists (i.e. CalDAV with
CALDAV_EDITABLE_CALENDAR set).
A lightweight job scheduler (jobs.py) runs periodic background tasks
during the app's lifespan. Currently used for ICS cache refresh. Job
status is visible at GET /jobs.
The server speaks the Model Context Protocol (MCP) over the
Streamable HTTP transport (the current standard, which superseded
the legacy HTTP+SSE transport), in addition to its OpenAPI surface.
The same tool surface is exposed over both transports with stable,
identical tool names (list_events, create_issue, log, …) so a
model sees one consistent namespace however it connects.
The MCP endpoint is mounted on the existing FastAPI app at:
POST /mcp – client → server messages (JSON-RPC 2.0)
GET /mcp – server → client stream (SSE)
DELETE /mcp – end a session
It is enabled by default alongside OpenAPI. Set MCP_ENABLED=false
to disable it.
The MCP layer uses the official mcp Python SDK (v2, MCPServer)
rather than a hand-rolled JSON-RPC handler. Our project is adapted to
the SDK's idioms (first-class MCPServer.tool() registration, service
layer called via the same code paths the REST routes use) instead of
wrapping or monkey-patching the SDK.
app/mcp_app.py— builds theMCPServer, mounts the SDK'sstreamable_http_app()at/mcp, and wires the SDK session manager into the FastAPI lifespan.app/mcp_tools.py— sdk-native tool handlers: registry commands (derived from the YAML arg specs) plus calendar/CalDAV, Gitea, notify, and weather, all calling the existing service singletons.
The mcp CLI (from the SDK) can inspect and call the server:
# List tools
mcp connect http://localhost:8000/mcp
# Interactive session / inspect tools
mcp connect http://localhost:8000/mcp --transport streamable-httpimport asyncio
from mcp import ClientSession
from mcp.client.streamable_http import streamable_http_client
async def main():
async with streamable_http_client("http://localhost:8000/mcp") as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
tools = await session.list_tools()
print([t.name for t in tools.tools])
res = await session.call_tool("log", {"level": "info", "message": "hi from MCP"})
print(res.content[0].text)
asyncio.run(main())MCP clients authenticate with the same MCP_API_KEY as the REST API,
sent as a standard Bearer token:
Authorization: Bearer <MCP_API_KEY>
When MCP_API_KEY is set, unauthenticated MCP requests are rejected.
The SDK's built-in Bearer verification is wired to our key check via
its token_verifier= hook.
- Host allow-list: the SDK arms DNS-rebinding protection for
localhost by default, which rejects requests sent to a real hostname
with
421 Misdirected Request. When deploying behind a hostname, setMCP_ALLOWED_HOSTSto a comma-separated list of accepted hosts (e.g.mcp.example.com,api.example.com). Leave it unset for localhost-only. - Public URL: when auth is enabled,
MCP_PUBLIC_URL(defaulthttp://localhost) is used to build the auth metadata URLs. Set it to the public base URL of the server (e.g.https://mcp.example.com) in deployment. - Config flag:
MCP_ENABLED=falsedisables the MCP endpoint while keeping the OpenAPI surface intact.
Endpoints are conditionally registered based on configuration. The table below shows all possible endpoints; only those for configured features will be present.
| Method | Path | Description |
|---|---|---|
| GET | /api/health |
Liveness probe (no auth required) |
| GET | /api/about |
App name & version (no auth required) |
| GET | /commands |
List all registered commands |
| GET | /commands/{name} |
Retrieve one command's schema |
| GET | /validate |
Validate all registry files (detailed report) |
| GET | /jobs |
List status of periodic background jobs |
| POST | /{command} |
Dedicated route per registry command (auto-gen) |
| Method | Path | Description |
|---|---|---|
| GET | /events |
List events across all calendar providers |
| GET | /events/{uid} |
Get a single event by UID |
| GET | /calendars |
List accessible calendars with metadata |
| POST | /calendars/refresh |
Refresh ICS cache (when ICS configured) |
| POST | /events |
Create an event (only if editable provider) |
| PUT | /events/{uid} |
Update an event (only if editable provider) |
| DELETE | /events/{uid} |
Delete an event (only if editable provider) |
| Method | Path | Description |
|---|---|---|
| GET | /tasks |
List calendar tasks (VTODO) |
| GET | /tasks/{uid} |
Get a single task by UID |
| POST | /tasks |
Create a task (only if editable provider) |
| PUT | /tasks/{uid} |
Update a task (only if editable provider) |
| DELETE | /tasks/{uid} |
Delete a task (only if editable provider) |
| Method | Path | Description |
|---|---|---|
| GET | /repos/search |
Search repos across the instance |
| GET | /repos/{owner}/{repo} |
Get repository info |
| GET | /repos/{owner}/{repo}/commits |
List recent commits |
| GET | /repos/{owner}/{repo}/compare |
Compare two refs |
| GET | /branches |
List branches (default repo or owner/repo) |
| POST | /branches |
Create a new branch |
| DELETE | /branches/{name} |
Delete a branch |
| GET | /prs |
List pull requests |
| POST | /prs |
Create a pull request |
| GET | /prs/{index} |
Get a single PR |
| PATCH | /prs/{index} |
Update a PR (e.g. close it) |
| POST | /prs/{index}/merge |
Merge a pull request |
| GET | /prs/{index}/reviews |
List reviews on a PR |
| POST | /prs/{index}/comments |
Comment on a PR |
| GET | /actions |
List CI workflow runs |
| GET | /commits/{sha}/statuses |
Get CI status checks |
| Method | Path | Description |
|---|---|---|
| GET | /issues |
List issues (default repo or owner/repo) |
| GET | /issues/{index} |
Get a single issue by number |
| POST | /issues |
Create a new issue |
| PATCH | /issues/{index} |
Update an issue (e.g. close it) |
| GET | /issues/{index}/comments |
List comments on an issue |
| POST | /issues/{index}/comments |
Comment on an issue |
| Method | Path | Description |
|---|---|---|
| GET | /releases |
List releases |
| POST | /releases |
Create a release |
| GET | /releases/{release_id} |
Get a single release |
| PATCH | /releases/{release_id} |
Update a release |
| DELETE | /releases/{release_id} |
Delete a release |
| Method | Path | Description |
|---|---|---|
| POST | /notify |
Send a notification to configured providers |
| Method | Path | Description |
|---|---|---|
| GET | /weather |
Current conditions and multi-day forecast |
| Method | Path | Description |
|---|---|---|
| POST | /vital_readings |
List health readings for a date range |
Registry command — requires from and to (both ISO 8601, inclusive).
Use the vital-pulse read-only API key so the integration can query
but never mutate health data.
| Method | Path | Description |
|---|---|---|
| POST | /penny_transactions |
List expense transactions for a date range |
Registry command — requires from and to (both ISO 8601, inclusive).
Use the penny-track read-only API key (app:api-key:create --read-only)
so the integration can query but never mutate transaction data.
# List available commands
curl http://127.0.0.1:8000/commands
# Execute the `log` command (dedicated route — the only way to run it)
curl -X POST http://127.0.0.1:8000/log \
-H 'Content-Type: application/json' \
-d '{"message": "Server started"}'Response:
{"stdout": "[2026-01-15T10:30:00-0500] [INFO] Server started\n", "stderr": "", "exit_code": 0, "success": true}If an API key is set, include it in the header:
curl -H "X-API-Key: your-secret-key" http://127.0.0.1:8000/commands
# or: curl -H "Authorization: Bearer your-secret-key" http://127.0.0.1:8000/commandsBefore restarting the server after editing registry files, you can
validate them — like caddy validate does for Caddy's config.
python -m app.validateOptionally pass a custom registry directory:
python -m app.validate /path/to/registryOutput:
MCP Server registry validation: /app/registry
✓ log.yaml → log
✓ log_read.yaml → log_read
✗ broken.yaml: mapping values are not allowed here
⚠ noprogram.yaml → noprogram: Executable not found: /usr/bin/nonexistent
4 file(s) checked · 1 error(s) · 1 warning(s)
Registry has errors — fix them before restarting.
Exit codes:
0— all files valid (warnings are OK)1— one or more files have errors2— registry directory does not exist
Running the server with docker compose? Your registry/ directory is
mounted into the container at /app/registry, so validate your YAML the
same way from the running container:
docker compose exec mcp-server python -m app.validate /app/registryOr with plain docker run (adjust the container name if you gave it
one):
docker exec -it mcp-server python -m app.validateThis works for the official digitaladapt/mcp-server image too — the
validator ships inside the image. Once the report is clean, restart the
container to pick up your new tool:
docker compose restart mcp-servercurl http://127.0.0.1:8000/validateReturns a JSON report with per-file results, including duplicate name detection and executable existence checks.
Add your own command by creating a YAML file in registry/ — every
file there becomes a dedicated, typed HTTP endpoint.
Running the server in Docker? Validate your registry file before restarting — no rebuild needed, the validator is already inside the image (see Validating the registry):
docker compose exec mcp-server python -m app.validate /app/registryHere's an example definition (e.g. my_tool.yaml):
name: my_tool
description: Does something useful.
executable: /usr/local/bin/my_tool
# (relative paths like scripts/my_tool.sh are resolved against
# the project root, so they work in any clone or Docker image)
args:
- name: input
type: string
required: true
help: Path to the input file.
- name: --verbose
type: flag
required: false
help: Enable verbose output.
- name: --mode
type: string
required: false
choices: [fast, slow]
help: Execution mode.| Field | Type | Notes |
|---|---|---|
name |
string | Positional placeholder or --flag name. |
type |
string | string, int, float, bool, or flag. |
required |
bool | Default false. |
choices |
list | Optional allowed-value whitelist. |
default |
any | Optional default value, auto-applied when the arg |
| is omitted by the caller. | ||
help |
string | Human-readable description. |
field_name |
string | Optional clean name for the native tool parameter. |
| When set, this becomes the OpenAPI property name | ||
(e.g. title instead of -t). The original |
||
name is still used as the CLI flag. |
||
hidden |
bool | When true, the arg is invisible in the tool |
surface but always applied with its default |
||
| value. Use for flags that must always be passed | ||
| but should never be controllable by the model. |
A flag type means presence-only (no value); the flag name is appended to
the command line when the argument is truthy.
Commands can declare a requires list of environment-variable
conditions. If the conditions are unmet, the command is loaded but its
route is not registered (it won't appear in GET /commands).
requires:
- "MCP_LOG_ENABLED != false"This is used by log and log_read to disappear when logging is
disabled via MCP_LOG_ENABLED=false.
Any argument may carry a default value. When the caller omits that
argument, the executor fills it in automatically — useful for forcing
flags that should always be on (e.g. discord.sh -q for quiet mode):
args:
- name: -q
type: flag
default: true
help: Quiet mode — forced on by default.Each command defined in registry/ is automatically exposed as its own
dedicated FastAPI route — POST /{command_name} — with a Pydantic
request model generated from the YAML arg specs. This means the
platform can read the OpenAPI schema and surface each command as a
native tool with properly typed parameters (strings, enums, flags,
defaults).
These dedicated routes are the only way to execute registry commands —
there is no generic POST /execute endpoint. Registry files still feed
GET /commands and GET /validate so you can discover and inspect
commands, but execution happens through the typed per-command routes
only.
Unknown fields are rejected (extra: forbid) with a 422 response, and
missing required arguments also return 422.
The field_name YAML key controls the parameter name shown to the model.
When omitted, the arg name is used (with leading dashes stripped).
If a registry command's name collides with an existing route (e.g.
events, issues), the dedicated route is skipped with a warning and
the command cannot be executed over HTTP (it still appears in
GET /commands). Rename the command in the registry to enable
execution.
A small synchronous httpx-based client lives in app/client.py. It
mirrors the HTTP API so a model or script can treat each registered
command as a native Python callable.
from app.client import MCPClient
mc = MCPClient("http://127.0.0.1:8000", api_key="your-secret-key")
# Discover available commands
for cmd in mc.list_commands():
print(cmd["name"], "-", cmd["description"])
# Execute a command
result = mc.execute("log", message="Server started")
print(result["stdout"])
# Bind a command to a reusable callable
log = mc.tool("log")
log(message="Deploy complete")Flag names that start with - aren't valid Python identifiers, so pass
them via dict unpacking: **{"-c": "green"}.
If the server has MCP_API_KEY set, pass api_key= to the client —
it will be sent as X-API-Key on every request.
The client also works as a context manager:
with MCPClient() as mc:
mc.execute("log_read", lines="10")The client also provides typed convenience methods for the calendar,
task, and Gitea APIs (list_events, create_task, list_issues, etc.).
mcp-server/
├─ app/
│ ├─ __init__.py # package marker, resolves version via importlib.metadata
│ ├─ main.py # FastAPI app factory + conditional router registration
│ ├─ auth.py # API key authentication dependency
│ ├─ models.py # Pydantic schemas (commands, args, validation)
│ ├─ executor.py # validation + subprocess wrapper with timeout
│ ├─ registry.py # YAML/JSON command loader + validate_registry()
│ ├─ validate.py # `python -m app.validate` CLI
│ ├─ client.py # httpx client library (commands + calendar + Gitea API)
│ ├─ registry_routes.py # Auto-generated native routes for registry commands
│ ├─ caldav_models.py # Pydantic models for CalDAV events/tasks
│ ├─ caldav_service.py # CalDAV service (1 editable + N read-only calendars)
│ ├─ caldav_routes.py # FastAPI router for /tasks (CalDAV-specific)
│ ├─ ics_models.py # Pydantic models for ICS feed config
│ ├─ ics_service.py # ICS feed fetcher, parser, cache
│ ├─ ics_routes.py # ICS service singleton management
│ ├─ unified_routes.py # Unified /events, /calendars router across providers
│ ├─ provider_adapters.py # CalDAVProvider, ICSProvider adapters
│ ├─ providers.py # Global provider registry
│ ├─ gitea_models.py # Pydantic models for Gitea resources
│ ├─ gitea_service.py # Gitea API service (issues, PRs, branches, releases)
│ ├─ gitea_routes.py # FastAPI router for /issues, /prs, /branches, etc.
│ ├─ notify_models.py # Pydantic models for notifications
│ ├─ notify_service.py # Discord + Ntfy notify providers
│ ├─ notify_routes.py # FastAPI router for /notify
│ ├─ weather_models.py # Pydantic models for weather config
│ ├─ weather_service.py # Open-Meteo API client
│ ├─ weather_routes.py # FastAPI router for /weather
│ └─ jobs.py # Lightweight background job scheduler
├─ registry/ # command definitions (one file per command)
│ ├─ log.yaml # logging command
│ ├─ log_read.yaml # read log tail
│ ├─ vital_readings.yaml # vital-pulse readings
│ └─ penny_transactions.yaml # penny-track transactions
├─ scripts/ # helper scripts referenced by registry YAMLs
│ ├─ log.sh # append to log file
│ ├─ log_read.sh # read log tail
│ ├─ vital_readings.sh # query vital-pulse API
│ ├─ penny_transactions.sh # query penny-track API
│ └─ config.sh.example # template (unused in Docker; for reference)
├─ tests/ # pytest test suite
│ ├─ conftest.py
│ ├─ test_models.py
│ ├─ test_executor.py
│ ├─ test_registry.py
│ ├─ test_api.py
│ ├─ test_client.py
│ ├─ test_auth.py
│ ├─ test_caldav.py
│ ├─ test_ics.py
│ ├─ test_ics_recurrence.py
│ ├─ test_gitea.py
│ ├─ test_notify.py
│ ├─ test_weather.py
│ ├─ test_logging.py
│ ├─ test_jobs.py
│ └─ test_conditional_endpoints.py
├─ Dockerfile # multi-arch base image definition
├─ LICENSE # MIT license
├─ variants/ # variant Dockerfiles (PHP, Node, etc.)
│ ├─ Dockerfile.php
│ └─ Dockerfile.node
├─ docker-compose.yml # easy local run with volumes
├─ .env.example # environment variable template
├─ .dockerignore # excludes venv, secrets, tests, etc.
├─ pyproject.toml # package metadata + pytest/ruff config
└─ requirements.txt # pip dependencies (used by Dockerfile)
All configuration is via environment variables. See .env.example
for a complete reference with comments. The server reads these at
startup and conditionally registers endpoints.
| Variable | Feature | Description |
|---|---|---|
MCP_API_KEY |
Auth | API key for REST + MCP endpoints (unset = open) |
MCP_ENABLED |
MCP | Set to false to disable the MCP endpoint (default enabled) |
MCP_ALLOWED_HOSTS |
MCP | Comma-separated host allow-list for deployed hostnames (unset = localhost only) |
MCP_PUBLIC_URL |
MCP | Public base URL used in MCP auth metadata (default http://localhost) |
MCP_REGISTRY_DIR |
Registry | Custom registry directory |
MCP_LOG_FILE |
Logging | Log file path |
MCP_LOG_DIR |
Logging | Log directory (file is mcp.log inside) |
MCP_LOG_LEVEL |
Logging | Log level (default: INFO) |
MCP_LOG_ENABLED |
Logging | Set to false to disable log commands |
CALDAV_URL |
CalDAV | CalDAV server URL |
CALDAV_USERNAME |
CalDAV | CalDAV username |
CALDAV_PASSWORD |
CalDAV | CalDAV password |
CALDAV_EDITABLE_CALENDAR |
CalDAV | Editable calendar name (unset = all read-only) |
CALDAV_READONLY_CALENDARS |
CalDAV | Comma-separated read-only calendar names |
ICS_CALENDAR_URL |
ICS | Read-only ICS feed URL |
ICS_CALENDAR_NAME |
ICS | Display name for ICS feed |
ICS_REFRESH_INTERVAL |
ICS | Cache refresh interval in seconds (default 300) |
GITEA_URL |
Gitea | Gitea server URL |
GITEA_TOKEN |
Gitea | API token |
VITAL_PULSE_URL |
Vital Pulse | vital-pulse base URL (enables vital_readings) |
VITAL_PULSE_API_KEY |
Vital Pulse | vital-pulse API key (read-only recommended) |
PENNY_TRACK_URL |
Penny Track | penny-track base URL (enables penny_transactions) |
PENNY_TRACK_API_KEY |
Penny Track | penny-track API key (read-only recommended) |
GITEA_DEFAULT_OWNER |
Gitea | Default repo owner |
GITEA_DEFAULT_REPO |
Gitea | Default repo name |
DISCORD_*_HOOK |
Notify | Discord webhook URLs (per severity level) |
DISCORD_SERVER_NAME |
Notify | Bot display name override |
DISCORD_TITLE_SUFFIX |
Notify | Title suffix for Discord messages |
NTFY_URL |
Notify | Ntfy server URL |
NTFY_*_TOPIC |
Notify | Ntfy topics (per severity level) |
NTFY_TOKEN |
Notify | Ntfy access token |
NTFY_USERNAME / NTFY_PASSWORD |
Notify | Ntfy basic auth |
NTFY_TITLE_SUFFIX |
Notify | Title suffix for ntfy messages |
WEATHER_LOCATION |
Weather | "lat,long" for weather data |
TZ |
Server | Timezone (defaults to UTC) |
The server can connect to a CalDAV server (e.g. Radicale, Baikal, Nextcloud) to manage calendar events and tasks. The design uses one editable calendar (where events and tasks can be created, updated, and deleted) and multiple read-only calendars (visible but not writable).
When CALDAV_EDITABLE_CALENDAR is not set, all calendars are read-only
and no create/update/delete endpoints are registered.
All events and tasks carry an editable flag and calendar_name, so the
model can see the full unified calendar view but is isolated from
accidentally modifying calendars it shouldn't touch.
CALDAV_URL=https://caldav.example.com/dav
CALDAV_USERNAME=user
CALDAV_PASSWORD=secret
# Optional: set to make a calendar writable. When unset, all calendars
# are read-only and write endpoints are not registered.
#CALDAV_EDITABLE_CALENDAR=MyCalendar
# Optional: comma-separated list of read-only calendar names to include.
# If empty, all calendars except the editable one are included as read-only.
#CALDAV_READONLY_CALENDARS=Personal,Work
When CALDAV_URL is not set, calendar endpoints are not registered.
- Events (VEVENT): list (with date-range filtering), get by UID, create, update, delete — all-day and timed events supported.
- Tasks (VTODO): list, get by UID, create, update, delete — with priority, due date, and status management.
- Connection recovery: if the CalDAV server becomes unreachable
mid-operation, the service automatically resets its connection and
retries once. Catches
DAVError,ConnectionError,TimeoutError, andOSError. - Calendar caching: the calendar list is fetched once per connection and cached, avoiding redundant server round-trips.
- Explicit UUIDs: created events and tasks always get a
uuid4UID, guaranteeing they can be updated or deleted immediately after creation. - Recurrence expansion: recurring events are expanded into individual
occurrences when a date range is given. Each occurrence gets a composite
UID of the form
{original_uid}__{start_iso}so every instance is addressable (e.g. viaGET /events/{uid}) and nothing is lost in the merged result.
The server can merge a read-only ICS calendar feed (e.g. Outlook
published calendar, Google Calendar iCal) into the unified /events
endpoint alongside CalDAV events.
ICS_CALENDAR_URL=https://outlook.office365.com/owa/calendar/.../calendar.ics
ICS_CALENDAR_NAME=Work
ICS_REFRESH_INTERVAL=300 # seconds (default 300, minimum 30)
The ICS feed is fetched and cached on startup, then refreshed
periodically by a background job. Use POST /calendars/refresh to
manually trigger a cache refresh.
The server can connect to a Gitea instance to manage repositories,
issues, pull requests, branches, releases, and CI actions. When
GITEA_URL is not set, Gitea endpoints are not registered.
GITEA_URL=https://git.example.com
GITEA_TOKEN=your-api-token
GITEA_DEFAULT_OWNER=your-username
GITEA_DEFAULT_REPO=your-repo
Issue, branch, PR, and release endpoints accept optional owner and
repo query parameters that default to the configured values. Repository
info, commits, and compare endpoints use path parameters
(/repos/{owner}/{repo}/...).
The server can send notifications via Discord webhooks and/or Ntfy.
Multiple providers can be active simultaneously — a /notify call fans
out to all configured providers.
Discord webhooks are configured per severity level (info, notice,
critical, emergency). If a level isn't configured, the system falls
back to the nearest lower configured level.
Ntfy works similarly with topics per severity level. Authentication supports either token-based or basic auth.
The log and log_read commands provide a simple logging utility —
append timestamped messages to a file and read them back.
# Log a message
curl -X POST http://127.0.0.1:8000/log \
-H 'Content-Type: application/json' \
-d '{"message": "Deploy complete"}'
# Log with a level
curl -X POST http://127.0.0.1:8000/log \
-H 'Content-Type: application/json' \
-d '{"message": "Disk full", "level": "error"}'
# Read the last 20 lines
curl -X POST http://127.0.0.1:8000/log_read \
-H 'Content-Type: application/json' \
-d '{"lines": "20"}'The log file path is determined by (in priority order):
MCP_LOG_FILEenvironment variable — full path to the log file.MCP_LOG_DIRenvironment variable — directory; file ismcp.loginside.- Default:
/tmp/mcp/mcp.log.
Parent directories are created automatically if they don't exist.
Set MCP_LOG_ENABLED=false to disable logging entirely — the log and
log_read commands won't be registered and their routes won't exist.
Pre-built images are published to Docker Hub as
digitaladapt/mcp-server
— multi-arch (amd64/arm64), tagged on every versioned release
(latest, vX.Y.Z, develop, plus -php/-node variant tags). There is
no need to build from source for normal use.
-
Copy the env template and record your secrets:
cp .env.example .envThe server needs at least one feature configured to start (see Configuration). If you configure any integration secrets (Discord webhooks, ntfy token, CalDAV credentials, ICS feed URL, Gitea token), you must also setMCP_API_KEY— otherwise the server refuses to start rather than run open while holding secrets. -
Use the compose file in this repo. The quickest path:
docker compose up -d
The
docker-compose.ymlmounts./registryso you can add or edit command definitions without rebuilding the image. -
Verify:
curl http://localhost:8000/api/health # → {"status":"healthy"}
If you aren't using compose, the container needs the same bits:
docker run -d --name mcp-server -p 8000:8000 \
--env-file .env \
-v ./registry:/app/registry \
digitaladapt/mcp-server:latest| Mount | Purpose |
|---|---|
/app/registry |
Command definitions — override or extend at runtime. |
/tmp/mcp |
Default log file location (or set MCP_LOG_FILE). |
The scripts/ directory (including log.sh) is baked into the image.
Secrets are never baked in — provide them via environment variables
(--env-file .env or .env + compose).
- Base:
python:3.12-slim(multi-arch) - System deps:
curl,jq(for scripts),tini - Runs as: non-root user
mcp(uid 1000) - Entrypoint:
tini(proper PID-1 signal handling)
The base image layers additional runtimes on top for wrapping scripts in other languages. Pre-built variants are published alongside the base image:
| Variant | Tag | Runtime |
|---|---|---|
| Base | latest / vX.Y.Z |
Python 3.12 (default) |
| PHP | latest-php / vX.Y.Z-php |
PHP CLI + curl, mbstring, xml |
| Node.js | latest-node / vX.Y.Z-node |
Node.js 22 LTS + npm |
docker pull digitaladapt/mcp-server:latest-php
# or
# docker pull digitaladapt/mcp-server:latest-nodePoint the compose file (or docker run) at the variant tag and add a
registry YAML that wraps the new runtime's binary — e.g. for PHP, create
registry/php_eval.yaml with executable: /usr/bin/php.
Building your own variants: the variants/ Dockerfiles are meant as
a foundation if you want a runtime that isn't pre-built. For example,
adding Ruby:
# variants/Dockerfile.ruby
FROM digitaladapt/mcp-server:latest
USER root
RUN apt-get update && apt-get install -y --no-install-recommends \
ruby && rm -rf /var/lib/apt/lists/*
USER mcpThen add a registry/ruby_eval.yaml pointing at /usr/bin/ruby.
The project includes a comprehensive pytest suite covering models, executor, registry, API endpoints, client library, authentication, CalDAV operations, ICS parsing, Gitea integration, notify, weather, logging, background jobs, and conditional endpoint registration.
# Install dev dependencies
pip install -e ".[dev]"
# Run the full suite
pytest
# Run with verbose output
pytest -v
# Run a single test module
pytest tests/test_executor.pyThe flag-default regression (e.g. a flag with default: true) is
covered by test_executor.py::TestValidateAndBuild::test_flag_default_true_*.
The executor's timeout and process-group kill logic is tested in
test_executor.py.
- Only commands present in
registry/can be executed — there is no arbitrary-command endpoint. - Arguments are validated (type, required, choices) before the subprocess is spawned, and unknown arguments are rejected.
- Every command has a hard 30 s timeout with process-group kill.
- API key authentication — set
MCP_API_KEYto require anX-API-Keyheader or anAuthorization: Bearertoken on all endpoints except/api/healthand/api/about. When unset, the server is open. - Error messages are sanitized — internal details are logged server-side but not exposed in HTTP responses (important since errors flow back into the LLM's context window).
- Run the server under a limited user account; do not grant it sudo.
- Commands that allow introspection of the server filesystem or arbitrary code execution have been removed by design — only specific, allowed commands should be registered.
Built by Lyra — your silver-haired assistant in the corner. ✨