"Simple is the opposite of complex. Easy is the opposite of hard." — Rich Hickey
A Babashka Streamable HTTP MCP server wrapping the ART19 Content API. Replaces the old art19-cli — an AI agent is a better operator of this API than a human typing commands.
Transport: MCP Streamable HTTP (spec 2025-03-26)
Endpoint: Single /mcp POST + /health
Compatible with: mcp-injector {:art19 {:url "http://127.0.0.1:PORT/mcp"}}
art19-mcp/
├── art19_mcp.bb # Single-file MCP server (the whole thing)
├── bb.edn # Tasks: run, start, test, lint, health
├── flake.nix # Nix package + NixOS service module
├── README.md # This file
├── AGENTS.md # Agent-specific context
├── .art19/
│ └── state.edn # Project state tracking
├── tests/
│ └── test_art19_mcp.clj # Integration tests (fake API)
├── CHANGELOG.md # Release history
└── DEVLOG.md # Development notes
- Test-driven — tests guide development, write tests that verify real client usage
- Integration tests only — fake API server, no mocks, test like a client would
- Clean lint — no warnings tolerated (
clj-kondo) - Formatting — uniform across all types:
- Clojure/Babashka:
nix run nixpkgs#cljfmt -- fix <file> - Markdown:
nix run nixpkgs#mdformat -- <file> - Nix:
nix fmt . - EDN:
clojure.pprint
- Clojure/Babashka:
- Feature branches — commit often as snapshots, rewrite history later
- Docs up to date — update before commit
- Keep bb.edn current — tasks mirror actual commands
# Dev — OS-assigned port, logs JSON startup line
bb run
# Dev — fixed port 3007 (or ART19_MCP_PORT)
bb start
# Health check
bb healthAuth via env vars or ~/.config/art19/config.edn:
export ART19_API_TOKEN="your-token"
export ART19_API_CREDENTIAL="your-credential";; ~/.config/art19/config.edn
{:api-token "your-token" :api-credential "your-credential"}This is managed as a NixOS service exposed by the flake module.
# In hosts/<hostname>/default.nix
services.art19-mcp = {
enable = true;
port = 3007;
tokenFile = "/run/secrets/art19"; # EnvironmentFile format
};Secrets file at /run/secrets/art19:
ART19_API_TOKEN=your-token
ART19_API_CREDENTIAL=your-credential
Add to mcp-servers.edn:
{:servers
{:art19
{:url "http://127.0.0.1:3007/mcp"
:tools ["list_episodes" "get_episode" "create_episode" "update_episode"
"delete_episode" "publish_episode"
"list_series" "get_series"
"list_seasons" "get_season"
"list_credits" "add_credit" "update_credit" "remove_credit"
"search_people" "get_person" "create_person"
"list_episode_versions" "create_episode_version"
"get_episode_version" "update_episode_version" "delete_episode_version"
"get_episode_next_sibling" "get_episode_previous_sibling"
"upload_image"
"list_media_assets"
"list_marker_points" "create_marker_point" "delete_marker_point"
"list_feed_items" "get_feed_item" "create_feed_item"
"update_feed_item" "delete_feed_item"]}}}| Tool | Description |
|---|---|
list_episodes |
List episodes for a series; filter by status, date, season, search |
get_episode |
Full episode details; optionally include season/series/credits |
create_episode |
Create draft episode; accepts series slug aliases (lu, twib, cr, sh, tl) |
update_episode |
Update metadata, publish status, release time |
delete_episode |
Permanently delete |
publish_episode |
Publish; optionally schedule released_at |
| Tool | Description |
|---|---|
list_series |
All series in the account |
get_series |
Series details + seasons; accepts slug aliases |
list_seasons |
Seasons for a series |
get_season |
Season details |
| Tool | Description |
|---|---|
list_credits |
Credits for an episode |
add_credit |
Add credit; roles: HostCredit, CoHostCredit, GuestCredit, ProducerCredit, EditorCredit |
update_credit |
Change role on existing credit |
remove_credit |
Remove a credit |
| Tool | Description |
|---|---|
search_people |
Search by name |
get_person |
Person details |
create_person |
Create new person record |
| Tool | Description |
|---|---|
list_episode_versions |
Audio versions for an episode |
create_episode_version |
Attach audio via source URL; ART19 fetches and processes it |
get_episode_version |
Version details and processing status |
update_episode_version |
Submit version for processing, set status_on_completion |
delete_episode_version |
Delete a version |
| Tool | Description |
|---|---|
get_episode_next_sibling |
Get the episode released after this one |
get_episode_previous_sibling |
Get the episode released before this one |
| Tool | Description |
|---|---|
upload_image |
Upload image artwork for series/season/episode |
| Tool | Description |
|---|---|
list_media_assets |
List media assets for an episode version. NOTE: Returns empty after episode is published - use feed_items instead. |
| Tool | Description |
|---|---|
list_marker_points |
Chapter/ad insertion markers on a version |
create_marker_point |
Add marker: 0=preroll, 1=midroll, 2=postroll |
delete_marker_point |
Remove a marker |
| Tool | Description |
|---|---|
list_feed_items |
List feed items; filter by episode_id, feed_id, series_id |
get_feed_item |
Get a single feed item by ID |
create_feed_item |
Create a new feed item |
update_feed_item |
Update feed item metadata |
delete_feed_item |
Permanently delete a feed item |
These series slugs are hardcoded for convenience:
| Alias | Resolves to |
|---|---|
lu |
linux-unplugged |
twib |
this-week-in-bitcoin |
tl |
the-launch |
cr |
coder-radio |
sh |
self-hosted |
Single /mcp POST endpoint. Session lifecycle:
- Client sends
initialize→ server creates session, returnsMcp-Session-Idheader - Client sends
notifications/initialized(no response needed, 204) - All subsequent requests include
Mcp-Session-Idheader - Server validates session on every non-initialize request
Everything lives in art19_mcp.bb — it's one file, intentionally:
Configuration / auth loading
│
ART19 HTTP client (api-get, api-post, api-patch, api-delete, fetch-all-pages)
│
Tool implementations (tool-*)
│
Tool registry (tools vector — the schemas the LLM sees)
│
Tool dispatch (case on name → tool-*)
│
JSON-RPC handlers (handle-initialize, handle-tools-list, handle-tools-call)
│
HTTP server (http-kit, handler, handle-mcp)
│
Entry point (-main)
Tool errors return {:error true :message "..."} which dispatch-tool wraps in an MCP isError: true content block. The LLM sees the error message and can reason about it (e.g., retry with different args, report back to user).
API errors (4xx/5xx) are surfaced the same way — they never throw past the tool boundary.
- Write
tool-<name> [args config]function that returns data or{:error ...} - Add entry to
toolsvector with:name,:description,:inputSchema - Add case branch in
dispatch-tool - Add test in
tests/test_art19_mcp.clj
# Run integration tests (fake API, no real credentials needed)
bb testTests use a fake API server that mimics ART19's responses. Tests call the real server process via JSON-RPC, exercising the full request/response cycle. No mocks — test like a client would.
Port 0 allocation: The server uses port 0 by default (OS assigns). The actual port is in the startup JSON line on stdout. For NixOS services, use a fixed port via ART19_MCP_PORT.
Session validation: mcp-injector re-initializes sessions on startup (via warm-up!). If the server restarts, stale session IDs in mcp-injector will hit a 400. mcp-injector handles this by re-calling initialize on 400/401/404 — don't fight it.
ART19 API note: The Content API has full CRUD. Earlier research suggested it was read-only — that was wrong. All write operations (create/update/publish/delete episode, manage credits, upload versions) are confirmed working via the OpenAPI spec.
Pagination: fetch-all-pages caps at 20 pages (2000 items). For list_episodes on large feeds, encourage the agent to use released_after/released_before or year/month filters rather than paginating everything.
Post-publish media_assets: After an episode is published, list_media_assets returns an empty array. This is ART19 API behavior. To get the public MP3 URL after publish, use list_feed_items (returns enclosure_url) or get_episode (includes enclosure_url in response).
Follow grumpy pragmatism:
- Actions, Calculations, Data — tool functions are actions, keep them thin; pure extraction/formatting logic lives in
-rowhelpers or inline maps - One file is fine — don't split into namespaces until you genuinely need to
- No abstractions until they hurt — the dispatch
caseis fine, resist the urge to make it data-driven - Test against real services — mock drift kills confidence
- YAGNI — resources/prompts MCP extensions not implemented because they're not needed yet