terminus-mcp
# terminus-mcp
An MCP server for agents that create and publish content to a [Terminus](https://github.com/usetrmnl/terminus) instance.
The tools are [effect-actions](https://github.com/gjermundgaraba/effect-actions) actions on [Effect](https://effect.website) v4, served over stdio or Streamable HTTP. It needs Node.js 26.13 or newer.
## Status
The curated publishing API is implemented and verified against Terminus `0.79.0`. See [docs/design.md](docs/design.md) for the API boundary and security model.
The server exposes these tools:
- `get_display_context`, `list_screens`, `get_screen_image`, and `list_playlists` for Terminus discovery;
- `search_screen_docs` and `read_screen_doc` for agent-readable authoring and TRMNL Framework documentation;
- `create_screen` and `update_screen` for complete HTML/CSS screens;
- `save_playlist` for complete ordered playlist replacement;
- `assign_playlist` and `update_display_settings` for the only device changes allowed: a device's playlist, label, refresh rate, and sleep window;
- `list_extensions`, `get_extension`, `create_extension`, `update_extension`, `create_extension_exchange`, and `update_extension_exchange` for extensions, which Terminus rebuilds into screens on a schedule from fetched or supplied data;
- `delete_screen`, `delete_playlist`, `delete_extension`, and `delete_extension_exchange`, which cannot be undone;
- `create_model` and `update_model`, for hardware Terminus does not know, only when `TERMINUS_MODEL_TOOLS` is true.
It deliberately does not expose authentication, firmware uploads or updates, model deletion, changes to TRMNL's own models, device deletion, raw device credentials, exchange header values, or a generic API proxy.
Extension exchanges make the Terminus server fetch URLs an agent chooses, so run Terminus with its outbound network restricted; see [docs/design.md](docs/design.md#extension-exchanges).
## Installation
Register the server with your MCP host:
```json
{
"mcpServers": {
"terminus": {
"command": "npx",
"args": ["-y", "terminus-mcp"],
"env": {
"TERMINUS_URL": "http://terminus.example:2300",
"TERMINUS_LOGIN": "you@example.com",
"TERMINUS_PASSWORD": "..."
}
}
}
}
```
Or run the public container image:
```sh
docker run --rm -p 127.0.0.1:8002:8002 \
-e MCP_HOST=0.0.0.0 \
-e TERMINUS_URL -e TERMINUS_LOGIN -e TERMINUS_PASSWORD \
ghcr.io/gjermundgaraba/terminus-mcp:latest
```
See [Configuration](#configuration) for the environment variables, the note on credential handling, and the network the HTTP endpoint needs.
## Development
Development uses [Vite+](https://viteplus.dev) (`npm install -g vite-plus`):
```sh
git clone https://github.com/gjermundgaraba/terminus-mcp.git
cd terminus-mcp
vp install
vp check # format, lint, and type-check
vp check --fix # apply formatting and lint fixes
vp run ready # check, test, build
```
Run the development server over stdio:
```sh
vp run dev
```
Or run its Streamable HTTP endpoint:
```sh
vp run dev:http
```
Build and run the compiled server:
```sh
vp pack
vp run start
# or
MCP_HOST=0.0.0.0 vp run start:http
```
Standard output is reserved for MCP messages. Diagnostics use standard error.
## Configuration
The MCP host must provide:
- `TERMINUS_URL`: Base URL of one Terminus instance.
- `TERMINUS_LOGIN`: Login email for the Terminus account.
- `TERMINUS_PASSWORD`: Login password for the Terminus account.
It may also set `TERMINUS_MODEL_TOOLS` to `true` to serve `create_model` and `update_model`. They are off by default because a model sets how every screen renders on every device that uses it.
`TERMINUS_URL` must use HTTP or HTTPS and must not contain credentials.
The HTTP entrypoint additionally accepts:
- `MCP_HOST`: Listen address; defaults to `127.0.0.1`.
- `MCP_PORT`: Listen port from 1 to 65535; defaults to `8002`.
- `MCP_ALLOWED_ORIGINS`: Optional comma-separated exact origins, such as `https://ui.example.com`, a browser may call `/mcp` from. A request carrying any other `Origin` is refused with 403; one without `Origin`, as MCP clients outside a browser send, is served. An entry that is not an exact origin, such as one with a trailing slash or a path, stops the server at startup.
Credentials will never be accepted as MCP tool arguments, returned in tool results, or intentionally written to logs. Inject them with the MCP host's secret-management facility instead of committing them to its configuration.
For remote MCP clients, use `http://<host>:8002/mcp`. It speaks MCP `2026-07-28`, `2025-11-25` and `2025-06-18`, as the stdio server does. A client on a 2025 revision opens a session, which lasts until the client ends it or the server stops. The tools never change after startup: a `2026-07-28` client cannot subscribe to them, and a 2025 session, though told they may change, never hears that they have. Every caller gets the same tools, so a `2026-07-28` `tools/list` result carries `ttlMs: 300000` and `cacheScope: "private"`: a client may reuse it for five minutes, and may see a new release's tools that much later. `/healthz` provides a container health check. The HTTP endpoint has no application-level authentication, so whoever reaches it can call every tool and open sessions: it must remain on an access-controlled internal network.
## Verification
`vp run ready` runs formatting, linting, strict type checks, the tests, and a production build. Linting comes from `@clanker-stuff/oxlint-config`, which builds on [Ultracite](https://www.ultracite.ai)'s presets and `@effect/tsgo`'s Effect diagnostics, through `config()` in `oxlint.config.ts`, with repo overrides there. Formatting uses Ultracite's preset from `oxfmt.config.ts`. The tests call the tools through an in-memory MCP client against a fake Terminus, and build the server to call it over stdio as an MCP host would.
To verify against a configured Terminus instance:
```sh
vp run verify:live
```
The live check calls every action in process against the Terminus in `TERMINUS_URL`, `TERMINUS_LOGIN` and `TERMINUS_PASSWORD`. It creates a temporary screen, playlist, and unscheduled extension, with an exchange that fetches the Terminus health check, and assigns the device's existing playlist back to itself. It changes the device's refresh rate and sleep window and puts back their values when it ends, even when the run fails. It also creates and updates a temporary model, checks that `update_model` refuses the device's model when TRMNL provides it, and deletes the temporary model directly, since no tool deletes models. It deletes the temporary objects with the delete tools, or directly if the run fails first. Set `TERMINUS_DEVICE_ID` when Terminus has more than one device.
## License
[MIT](LICENSE)
TDQS
Scored across 10 tools
Each tool targets a distinct resource-action combination: playlists, screens, docs, and device context are clearly separated. The only potential overlap is list_playlists vs save_playlist vs assign_playlist, but their verbs (list, save, assign) make the differences explicit.
All tool names follow the snake_case verb_noun pattern consistently (list_*, get_*, search_*, read_*, create_*, update_*, save_*, assign_*). There are no camelCase or mixed-style names, so the naming pattern is uniform and predictable.
The server has 10 tools, which is within the ideal 3-15 range. Each tool serves a clear purpose in the Terminus workflow (screens, playlists, docs, device context), and no tool feels redundant.
The core lifecycle is covered for screens (create, update, list, get image) and playlists (save, list, assign), but there is no delete operation for either screens or playlists. Additionally, there is no way to unassign a playlist from a device or retrieve the raw HTML of a screen, which are notable gaps in the workflow.