Skip to main content
Glama
README.md
# MCP Server – Modular Command Provider

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.

## Why

- **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_KEY` to require authentication on all
  endpoints except `/api/health` and `/api/about`.

## Quick start

```bash
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 8000
```

The 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:
> 1. **Nothing configured** — no calendar providers, no Gitea, no notify
>    providers, no weather, and no registry commands.  At least one
>    feature must be enabled.
> 2. **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_KEY`
>    is **not** set, the server refuses to start.  It will not hold
>    secrets while running open.

## Architecture

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.

### Provider system

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).

### Background jobs

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`.

## Model Context Protocol (MCP) — Streamable HTTP

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.

### Implementation

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 the `MCPServer`, mounts the SDK's
  `streamable_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.

### Connecting with the `mcp` CLI

The `mcp` CLI (from the SDK) can inspect and call the server:

```bash
# List tools
mcp connect http://localhost:8000/mcp

# Interactive session / inspect tools
mcp connect http://localhost:8000/mcp --transport streamable-http
```

### Connecting with the Python SDK client

```python
import 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())
```

### Auth

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.

### Deployment notes

- **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,
  set `MCP_ALLOWED_HOSTS` to 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` (default
  `http://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=false` disables the MCP endpoint while
  keeping the OpenAPI surface intact.

## API

Endpoints are conditionally registered based on configuration.  The
table below shows all possible endpoints; only those for configured
features will be present.

### Core (always 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) |

### Calendar (when CalDAV or ICS is configured)

| 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)  |

### CalDAV Tasks (when CalDAV is configured)

| 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)    |

### Gitea (when `GITEA_URL` is set)

| 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                         |

### Notify (when Discord or Ntfy is configured)

| Method | Path       | Description                                  |
|--------|------------|----------------------------------------------|
| POST   | `/notify`  | Send a notification to configured providers  |

### Weather (when `WEATHER_LOCATION` is set)

| Method | Path       | Description                                  |
|--------|------------|----------------------------------------------|
| GET    | `/weather` | Current conditions and multi-day forecast    |

### Vital Pulse (when `VITAL_PULSE_URL` + `VITAL_PULSE_API_KEY` are set)

| 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.

### Penny Track (when `PENNY_TRACK_URL` + `PENNY_TRACK_API_KEY` are set)

| 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.

### Example

```bash
# 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:
```json
{"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:
```bash
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/commands
```

## Validating the registry

Before restarting the server after editing registry files, you can
validate them — like `caddy validate` does for Caddy's config.

### CLI

```bash
python -m app.validate
```

Optionally pass a custom registry directory:

```bash
python -m app.validate /path/to/registry
```

Output:
```
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 errors
- `2` — registry directory does not exist

### Inside Docker

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:

```bash
docker compose exec mcp-server python -m app.validate /app/registry
```

Or with plain `docker run` (adjust the container name if you gave it
one):

```bash
docker exec -it mcp-server python -m app.validate
```

This 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:

```bash
docker compose restart mcp-server
```

### HTTP

```bash
curl http://127.0.0.1:8000/validate
```

Returns a JSON report with per-file results, including duplicate name
detection and executable existence checks.

## Registering a command

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](#validating-the-registry)):

```bash
docker compose exec mcp-server python -m app.validate /app/registry
```

Here's an example definition (e.g. `my_tool.yaml`):

```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.
```

### Argument spec fields

| 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.

### Conditional commands (`requires`)

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`).

```yaml
requires:
  - "MCP_LOG_ENABLED != false"
```

This is used by `log` and `log_read` to disappear when logging is
disabled via `MCP_LOG_ENABLED=false`.

### Defaults

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):

```yaml
args:
  - name: -q
    type: flag
    default: true
    help: Quiet mode — forced on by default.
```

## Native routes for registry commands

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.

## Client library

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.

```python
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:

```python
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.).

## Project layout

```
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)
```

## Configuration

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)                     |

## CalDAV Calendar

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.

### Configuration

```
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.

### Features

- **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`,
  and `OSError`.
- **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 `uuid4` UID,
  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. via `GET /events/{uid}`) and nothing is lost in the
  merged result.

## ICS Calendar (read-only)

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.

## Gitea Integration

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.

### Configuration

```
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}/...`).

## Notify

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.

## Logging

The `log` and `log_read` commands provide a simple logging utility —
append timestamped messages to a file and read them back.

```bash
# 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):

1. `MCP_LOG_FILE` environment variable — full path to the log file.
2. `MCP_LOG_DIR` environment variable — directory; file is `mcp.log` inside.
3. 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.

## Docker

Pre-built images are published to Docker Hub as
[`digitaladapt/mcp-server`](https://hub.docker.com/r/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.

### Quick start (production)

1. **Copy the env template** and record your secrets:
   `cp .env.example .env`
   The server needs at least one feature configured to start (see
   [Configuration](#configuration)).  If you configure any integration
   secrets (Discord webhooks, ntfy token, CalDAV credentials, ICS feed
   URL, Gitea token), you **must** also set `MCP_API_KEY` — otherwise the
   server refuses to start rather than run open while holding secrets.

2. **Use the compose file** in this repo.  The quickest path:

   ```bash
   docker compose up -d
   ```

   The `docker-compose.yml` mounts `./registry` so you can add or edit
   command definitions without rebuilding the image.

3. **Verify:**

   ```bash
   curl http://localhost:8000/api/health
   # → {"status":"healthy"}
   ```

### Running with plain `docker run`

If you aren't using compose, the container needs the same bits:

```bash
docker run -d --name mcp-server -p 8000:8000 \
  --env-file .env \
  -v ./registry:/app/registry \
  digitaladapt/mcp-server:latest
```

### Volumes

| 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).

### Image details

- **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)

### Variant images (PHP, Node.js)

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            |

```bash
docker pull digitaladapt/mcp-server:latest-php
# or
# docker pull digitaladapt/mcp-server:latest-node
```

Point 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:

```dockerfile
# 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 mcp
```

Then add a `registry/ruby_eval.yaml` pointing at `/usr/bin/ruby`.

## Testing

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.

```bash
# 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.py
```

The 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`.

## Security notes

- 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_KEY` to require an `X-API-Key`
  header or an `Authorization: Bearer` token on all endpoints except
  `/api/health` and `/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. ✨*