Cozyvtt MCP
Run campaigns as game master in CozyVTT, a self-hosted virtual tabletop: dice, chat narration, tokens and maps, initiative, character sheets and rulebooks.
What can you do with Cozyvtt MCP?
- Session/campaign:
campaign_get(role and owner reported separately; new capability keys may beunknown),session_manage,session_list,session_notes_update,campaign_transfer_dm(owner reclaim uses the same tool),map_list,map_switch - Narration:
chat_send(DM / PLAYER),chat_read - Dice:
dice_roll(server-authoritative results;is_secret=true(wire field:secret) delivers to the roller and DMs on v1.4.0),events_poll(recent buffered dice events, not durable history; DICE_ROLL events are absent from chat history) - Tokens/maps:
token_add(integer sizes 1..10),token_move,token_hp_update,token_place_creature,token_delete,map_create,map_delete,creature_search(SRD + custom library). - Combat:
initiative_manage(add / remove / roll / set / reorder / start / next / end),initiative_read(note: CoC7e initiative is DEX-ordered, no roll — this is upstream rules behavior, androllis gated accordingly) - Characters:
character_list,character_get,character_create,character_delete,character_validate,character_update(rules math is done by the agent; the bridge just writes values) - Documents:
document_upload,document_create,document_list,campaign_document_list,document_read,document_update,document_share,document_unshare,document_delete - Saved Rolls:
saved_roll_list,saved_roll_create,saved_roll_update,saved_roll_delete(private to the current user and campaign; 50 macros per user/campaign, server-validated expressions).saved_roll_listreturns complete macros — there is no ...
Install
The installation steps are in the documentation below. Documentation
Documentation
From the project's README on GitHub, refreshed weekly. Links open on GitHub.
Contents
cozyvtt-mcp
English | 中文
MCP (Model Context Protocol) bridge for CozyVTT — the self-hosted, open-source virtual tabletop. It lets an AI agent join a campaign as DM/KP: narrate over chat, roll server-authoritative dice on the server, move tokens, switch maps, manage initiative, and settle character sheets.
Built for and tested with Hermes Agent, but works with any MCP client (stdio transport).
Upgrading
This README describes how to install and use the current release; the change history lives in CHANGELOG.md. Two migration points are worth knowing before you wire up a client:
- Three tools were renamed in 0.3.0:
campaign_status→campaign_get,initiative_state→initiative_read,token_hp→token_hp_update. No aliases are registered — update client tool selections. Old-to-new mapping. chat_readpaginates withlimit/cursor(theoffsetargument was removed in 0.2.0).
The default tool set is every tool; optional presets are described under Tool sets.
Tool sets
The default is every tool. Optional registration-time filtering drops excluded tools from
tools/list entirely; it changes nothing about how the remaining tools behave.
Choose comma-separated presets (union); duplicates are accepted and all wins.
The flag overrides COZYVTT_MCP_TOOLSETS. Empty values or unknown names fail with
valid preset names and counts. --list-toolsets prints all memberships and the default,
then exits successfully without connecting to CozyVTT.
| Preset | Tools | Selection |
|---|---|---|
play | 20 | campaign_get, chat_read, chat_send, creature_search, dice_roll, events_poll, initiative_manage, initiative_read, map_create, map_delete, map_list, map_switch, session_list, session_manage, session_notes_update, token_add, token_delete, token_hp_update, token_move, token_place_creature |
docs | 9 | All document_* tools plus campaign_document_list |
roster | 7 | All character_* tools |
macros | 4 | All saved_roll_* tools |
admin | 1 | campaign_transfer_dm |
all (default) | 41 | Every tool |
.venv/bin/python server.py --toolsets play,macros
COZYVTT_MCP_TOOLSETS=docs,roster .venv/bin/python server.py
.venv/bin/python server.py --list-toolsets
For an MCP client, append "--toolsets", "play" to its server args, or set
COZYVTT_MCP_TOOLSETS in its server env.
Measured with .venv/bin/python scripts/measure_tool_budget.py (offline stdio).
Bytes sum UTF-8 JSON tool definitions, excluding JSON-RPC envelope/list separators;
tokens are estimates (bytes // 4), not tokenizer measurements.
| Preset | JSON bytes | Estimated tokens | Savings vs all |
|---|---|---|---|
play | 30,925 | 7,731 | 48% |
docs | 13,418 | 3,354 | 77% |
roster | 9,152 | 2,288 | 85% |
macros | 4,664 | 1,166 | 92% |
admin | 1,199 | 299 | 98% |
all | 59,358 | 14,839 | 0% |
The surface also includes map_create, map_delete, token_delete, and character_delete.
Create maps from existing image assets; use map_switch before deleting the current
map. token_delete removes a map token; token_hp_update changes sheet HP using a
character ID. character_delete permanently deletes an owned sheet. Add roster or
docs when a session needs those tools.
Compatibility
| cozyvtt-mcp | CozyVTT | Notes |
|---|---|---|
| 0.6.1 | v1.5.3 (1dd6bcc) | Tooling-only patch: configurable smoke pins and offline deployment-evidence validation. 503 offline tests on Python 3.12.13, including the v0.6.0 contract coverage; 41 runtime tools and parameter schemas unchanged. No new live campaign run performed for this patch. |
| 0.6.0 | v1.5.3 (1dd6bcc) | Offline review of v1.5.0→v1.5.3 (REST/WS docs + implementation): scope-aware document edits, Keeper-note filtering, newer input limits, conservative dice throttling, campaign.access.revoked. 456 offline tests on Python 3.12.13; 41 tools and parameter schemas unchanged. No live v1.5.3 campaign run; v1.5.0 remains the last live-smoked baseline. |
| 0.5.0 | v1.5.0 (b03e287) | Exact pinned baseline. 379 offline tests on Python 3.12.13 (89 adaptation + 62 helper + 228 historical); recorded 2026-10-04 15:44 CST DM read-only REST/passive WS smoke: 16/16 checks, post-cleanup teardown and observed exit 0. Deployment identity is operator-attested. |
| 0.4.0 | v1.2.2 / v1.4.0 | Dual baseline. Tool-set presets plus map_create/map_delete/token_delete/character_delete. Offline contract suite 228/228; the Dockerfile was replayed inside the real python:3.12-slim rootfs (hash-checked layers) and boots with no environment set, listing 41 tools. No live campaign run, and no OCI image was built (no Docker daemon on the build host). |
| 0.3.0 | v1.2.2 / v1.4.0 | Dual baseline. Parameter descriptions, MCP annotations, container image. Offline suite 176/176; image built and booted (37 tools, no environment). No live campaign run. |
| 0.2.0 | v1.2.2 / v1.4.0 | Dual baseline: retain original tools; new REST routes degrade explicitly on old instances. Offline suite 176/176; live v1.4.0 smoke (read/write + Documents/Saved Rolls round-trips) passed 2026-09-17. |
| 0.1.1 | v1.2.2 | Previous 20-tool release |
The recorded DM run used CALL_OF_CTHULHU_7E in PREPARATION: 22 requests (1 login + 21 GETs), one passive WS connection, and only authenticate / initiative.request_state emits. No live campaign writes, conflict-write roundtrip, multi-user visibility or secret-recipient isolation were tested. Optional character/document checks were skipped (zero character sheets/readable documents); session, Saved Roll and custom creature lists were empty. This is an explicit version pin, not a v1.5.x/future-compatibility promise; older compatibility rows remain historical evidence only. See CHANGELOG for the final verification and limits.
The compatibility table describes supported contracts, not an inferred server version. New feature availability is unknown until established; an empty list or a business 404 is not evidence that the route is missing. See SPEC v2 for the complete 41-tool contract.
The current default baseline is CozyVTT v1.5.3. Its selected offline contract coverage does not certify full compatibility or live behavior; see the versioned evidence in CHANGELOG.md.
Features
41 tools returning {ok, data?, error?} for tool-body results. MCP argument validation remains handled by FastMCP:
- Session/campaign:
campaign_get(role and owner reported separately; new capability keys may beunknown),session_manage,session_list,session_notes_update,campaign_transfer_dm(owner reclaim uses the same tool),map_list,map_switch - Narration:
chat_send(DM / PLAYER),chat_read - Dice:
dice_roll(server-authoritative results;is_secret=true(wire field:secret) delivers to the roller and DMs on v1.4.0),events_poll(recent buffered dice events, not durable history; DICE_ROLL events are absent from chat history) - Tokens/maps:
token_add(integer sizes 1..10),token_move,token_hp_update,token_place_creature,token_delete,map_create,map_delete,creature_search(SRD + custom library). - Combat:
initiative_manage(add / remove / roll / set / reorder / start / next / end),initiative_read(note: CoC7e initiative is DEX-ordered, no roll — this is upstream rules behavior, androllis gated accordingly) - Characters:
character_list,character_get,character_create,character_delete,character_validate,character_update(rules math is done by the agent; the bridge just writes values) - Documents:
document_upload,document_create,document_list,campaign_document_list,document_read,document_update,document_share,document_unshare,document_delete - Saved Rolls:
saved_roll_list,saved_roll_create,saved_roll_update,saved_roll_delete(private to the current user and campaign; 50 macros per user/campaign, server-validated expressions).saved_roll_listreturns complete macros — there is nosaved_roll_get. - Hit Dice:
character_hitdice_spend(DND_5E only; dispatches one spend without rolling dice or healing)
System gating
The bridge stays game-system agnostic, but a few capabilities only make sense under a specific rule system. Those are gated against the campaign's gameSystem (fetched once, cached; enum: DND_5E / PATHFINDER_2E / SHADOWRUN_6E / CALL_OF_CTHULHU_7E):
| Capability | Allowed systems | Why |
|---|---|---|
creature_search source=srd | DND_5E | The SRD library is seeded from Open5e — a D&D 5e data source |
initiative_manage action=roll | DND_5E, PATHFINDER_2E, SHADOWRUN_6E | The server derives the initiative dice per system; CoC7e doesn't roll at all (DEX order) |
character_hitdice_spend | DND_5E | Only the system gate is enforced locally. No reliable WS capability probe exists; dispatch always remains pending. |
Gated calls return a clear {ok: false, error} explaining which systems are allowed, instead of emitting an event the server would ignore or misinterpret. Campaigns with no gameSystem set (flexible) fail closed. With no source specified, non-5e campaigns search only custom; 5e campaigns may search both sources. campaign_get().features reports the current campaign's available gated capabilities.
Architecture
MCP client (stdio)
└─ server.py (FastMCP, lazy init, synchronous first-call self-check)
├─ auth.py — rememberMe login, 10-min keepalive, 3-min re-login spacing, 429 backoff
├─ client.py — REST wrapper: one 401→re-login→retry, 429 exponential backoff (1/2/4s, ≤3)
├─ ws_listener.py — socket.io listener, 500-event ring buffer, one reconnect worker
└─ tools/ — 41 MCP tools (read/write, documents, campaign additions)
Design notes:
- Dice discipline: rolls are generated and persisted by the server. Public results go to the table; reviewed v1.4.0 sends secret results to the roller and DMs. The bridge provides recent buffered events, not a durable roll-history query.
- Rules live outside the bridge: skill checks, SAN loss, damage — computed by the agent/GM, the bridge only performs authoritative rolls and writes results. The bridge is game-system agnostic.
token_moveuses REST PUT; the server checks DM/controlledBy permissions, and the bridge additionally rejects spectators. REST persistence does not imply amap.changedbroadcast.map_switchsaves through REST then explicitly dispatchesmap.changethrough WS; a WS failure preserves the successful REST result and never replays it. On both REST and WStoken.move, PLAYER must use the current campaign map while DM may use any map of this campaign.
Result and update contracts
- Resource IDs identify one URL segment. Empty IDs and
./..are rejected; separators in other IDs are encoded so a malformed ID cannot select a different resource through URL normalization. - Transient login failures wait at least 180 seconds, including any longer server
Retry-After. A login401stops password retries until you correct the configured credentials and restart the bridge; repeatedly invoking tools cannot bypass this block. - Keepalive does not extend an absolute sign-in lifetime. Session-expiry
401gets one bounded recovery attempt; policy403retains its upstream code. The bridge has no MFA login/enrolment workflow. - For newer server validation, send whole-square token coordinates, MAP image assets and maps at most 500 squares wide/high. Use grid sizes 10..500; the server may substitute 50 outside that range. Dice labels should fit 200 characters for
character_nameand 300 forpurpose. The bridge preserves server refusals and returned coordinates without silently coercing inputs. - Malformed business REST responses return
INVALID_RESPONSEwith the actual HTTP status. A write may already have happened: inspect state before retrying. In particular, an invalid map-switch response prevents the later WS broadcast. Raw document content, conditional reads and/healthreachability retain their separate handling. dice_roll,chat_send,token_hp_update,initiative_manage, andcharacter_hitdice_spendreturnsent: true,confirmed: false,status: "pending". Read business broadcasts andsystem.errorwithevents_poll; do not blindly replay writes. Neither baseline provides correlated business ACKs. Dice may carrypurposeandcharacter_name(wire:characterName) for Custom Roll display. Hit Dice spend, rolling, and healing are separate operations, not a transaction; an old server may silently ignore the spend event.events_pollreturns the oldest unread events first. Savenext_seqfor the nextsince;latest_seqis its compatibility alias.high_water_seqis the buffer high-water mark, not a pagination cursor. Checkgap,cursor_reset,has_more,connected, andauthenticated.character_update(character_id, data={"data": {"hp": {"current": 5}}})recursively merges sheet fields before PUT. Unspecified fields survive; arrays/scalars replace values andnullis explicit. Top-level fields arename,data, andtokenImageUrl. Every accepted update reads under the process lock and sends the exactupdatedAtvalue as a guard when present. A 409 is returned without retry; if the timestamp is absent, the legacy unguarded path remains. This protects only edits after the tool GET, not stale intent from an earlier read. The upstream parsed validator may discard undeclared fixed-system fields even when preserved in the outbound merge.character_createcreates the card, checks the roster, then assigns it only if not confirmed as assigned. If assignment fails, the error includes the created character ID: assign that card in the UI instead of creating another. CoC conditions/Mythos/spells/appearance/notes and DND legacy/new hit-dice fields survive character merges; The server may withhold Keeper notes; do not fill in omitted private fields.session_manageuses REST. Pause/end resolvecampaign.activeSession.id; start creates a session. End acceptsnotes(≤2000 characters, shared with the campaign) andsave_state=true. Empty end notes do not clear old notes; usesession_notes_update(session_id,notes="")to clear.session_listincludes active sessions among the most recent 50.- Documents use scope
USER(personal),CAMPAIGN, orGLOBAL.document_listfilters the asset library;campaign_document_listdiscovers shared private documents too. Typed txt/md create/update is limited to 900 KiB UTF-8; file uploads use the instance limit (default 50 MiB). PDF content cannot be edited. ConsultcanEdit/canChangewhen supplied; edit rights depend on asset scope and current membership. Unsharing removes only one link and cannot revoke native/global access;shared:falseindicates a native campaign document. Deleting removes the asset and all its links. - WS reconnects use fresh, URL-scoped Cookies and request current initiative state after campaign authentication. Use HTTPS for remote deployments.
chat_readpaginates withlimit+cursor: read the newest page first, then pass the returnedpagination.nextCursorback ascursor, and stop when it isnull. Instances without cursor metadata serve only the latest page and say so (This instance does not support reliable cursor pagination for history; only the latest page can be read.) instead of repeating it.- Raw document reads preserve MIME type and ETag. Text returns
{mime_type,etag,content}; PDFs are written to the project'sdownloads/<document_id>.pdfand return{mime_type,etag,file_path,file_size}. PassetagasIf-None-Match; a 304 returns{not_modified:true}so the caller can reuse existing content. Downloads are Git-ignored and local copies survive upstream deletion. - REST
401invalidates existing WS authentication and campaign caches; losing campaign membership cancels WS authentication and clears cached authority; the reconnect worker retains its existing bounded backoff.campaign_transfer_dmclears role and system caches, and the bridge bufferscharacter.updated,campaign.dm.transferred,roster.updated,dice.historyCleared,campaign.role.changed,map:settings:updated,token:appeared,token:disappeared, andcampaign.access.revokedforevents_poll. Revocation also producessystem.error; only sign-out requests a credential refresh, subject to cooldown and the login-rejection block. character_validatealways includesvalidation_reliable:false: CozyVTT v1.5.0 fixes the v1.4.0 validation defect; the bridge retains the false flag because a running server may be unverified.initiative_read(refresh=true)requests fresh state and reports unknown on timeout; use the returned server token coordinates because the server may clamp positions to the map.- Route-missing 404 — upstream message exactly
The requested resource does not exist— returnsThis CozyVTT instance does not provide this feature; upgrade to a supported version and retry.Other 404s returnResource not found or inaccessible to the current account (HTTP 404): <upstream message>. Errordatapreserves the status and upstream detail. Uploading a DOCUMENT to an old upload route may return 400; that error is preserved without retrying with a different type or scope.
Requirements
- Python ≥ 3.11
- A CozyVTT v1.5.3 instance (the current default baseline; see compatibility evidence and limits) and a campaign where your account has the permissions required by the tools
- uv (recommended) or pip
Install
git clone https://github.com/yanjingzhaisun/cozyvtt-mcp.git
cd cozyvtt-mcp
uv sync # or: python -m venv .venv && .venv/bin/pip install fastmcp requests "python-socketio[client]" websocket-client
Docker (stdio)
docker build -t cozyvtt-mcp:0.6.1 .
docker run --rm -i --env-file /path/to/cozyvtt.env cozyvtt-mcp:0.6.1
Use -i to keep stdin open; MCP uses stdin/stdout, without a network port or TTY.
The image installs only compatible, hash-checked runtime wheels from uv.lock,
including fastmcp, requests, python-socketio[client], and websocket-client. No editable
package install or dependency re-resolution is performed. Credentials are supplied
at runtime. Without any COZYVTT variables, initialize and tools/list still work;
only business tool calls initialize authentication. Mount upload files inside the
container and pass those container paths to document_upload. Mount /app/downloads
if binary downloads must survive container removal.
Wheel URLs come from the lock as direct links with sha256 hashes, so only the host
serving those blobs is configurable. The default is the canonical CDN; builders in
mainland China can use a mirror, which serves byte-identical files (the hashes still
verify):
docker build --build-arg WHEEL_BASE=https://mirrors.aliyun.com/pypi/packages -t cozyvtt-mcp:0.6.1 .
scripts/docker_requirements.py --wheel-base "" keeps the lock file's own URLs.
Configuration
Environment variables (no secrets in the repo):
| Var | Example | Notes |
|---|---|---|
COZYVTT_URL | http://localhost:8899 | Your instance URL |
COZYVTT_EMAIL | dm@example.local | DM account |
COZYVTT_PASSWORD | — | DM password |
COZYVTT_CAMPAIGN_ID | uuid | Target campaign |
Hermes Agent (config.yaml)
mcp_servers:
cozyvtt:
command: /path/to/cozyvtt-mcp/.venv/bin/python
args: [/path/to/cozyvtt-mcp/server.py]
env:
COZYVTT_URL: "http://localhost:8899"
COZYVTT_EMAIL: "dm@example.local"
COZYVTT_PASSWORD: "<secret>"
COZYVTT_CAMPAIGN_ID: "<campaign-uuid>"
Restart Hermes after registering (MCP servers are not hot-reloaded).
Generic MCP client
Any stdio-capable client: command = the venv python, args = server.py, env as above.
Testing
.venv/bin/python -m pytest -q
The guarded smoke helper retains its original scripts/smoke_v150.py filename and accepts any explicit upstream pin. --deployed-version defaults to v1.5.3; --deployed-revision defaults to 1dd6bccd536afe1defc48dfc42378653e86d6343 and requires a full 40-character hexadecimal revision. The nonempty version and revision must exactly match cozyvtt_version and source_revision in the JSON file supplied with --deployment-evidence. Both values are emitted in the report. To smoke an older or different revision, supply both flags and its matching evidence.
Create an evidence file outside the repository using this structure, replacing evidence with the actual operator verification record (never credentials):
{
"cozyvtt_version": "v1.5.3",
"source_revision": "1dd6bccd536afe1defc48dfc42378653e86d6343",
"evidence": "Replace with the operator's deployment verification record"
}
Check the pin offline before a live run:
COZYVTT_SMOKE=1 .venv/bin/python scripts/smoke_v150.py \
--dry-run --allow-login \
--deployed-version v1.5.3 \
--deployed-revision 1dd6bccd536afe1defc48dfc42378653e86d6343 \
--deployment-evidence /path/to/deployment-evidence.json \
--report /path/to/smoke-dry-run.json
--dry-run checks evidence without reading credentials, initializing the bridge, or contacting the network. Its report is identified as a dry run and does not prove live compatibility. It still requires COZYVTT_SMOKE=1, an explicit --cookie-file or --allow-login choice, evidence, and a report path.
For an authorized live read-only smoke, set COZYVTT_URL, COZYVTT_CAMPAIGN_ID, COZYVTT_EMAIL and COZYVTT_PASSWORD in the environment as described under Configuration, then run:
COZYVTT_SMOKE=1 .venv/bin/python scripts/smoke_v150.py \
--allow-login \
--deployed-version v1.5.3 \
--deployed-revision 1dd6bccd536afe1defc48dfc42378653e86d6343 \
--deployment-evidence /path/to/deployment-evidence.json \
--report /path/to/smoke-report.json
Alternatively, replace --allow-login with --cookie-file /path/to/session-cookie.txt: the file must be outside the repository with mode 0600; no default credential path exists. Add --ws only when passive WS observation is authorized. The helper retains its HTTP/WS allowlists, single-login/connection limits, read-only state comparisons and teardown checks. Deployment identity is operator-attested; /health does not discover the server version.
(scripts/smoke_write.py writes chat, a public roll, and a secret roll — run it manually and only on a throwaway campaign. It checks sender and unique purpose; proving that players do not receive secret rolls additionally requires an independent player connection.)
Offline tests block TCP connections and include real FastMCP in-memory and stdio checks; they do not require campaign credentials. Live integration against an attested v1.5.0 instance was verified 2026-10-04 with authenticated read-only REST and passive WS state reads; no campaign writes or multi-user isolation were tested. Historical v1.4.0 read/write evidence remains historical. The local venv used for verification runs Python 3.12.13; Python 3.13 remains unverified.
Troubleshooting
- Logs:
logs/cozyvtt-mcp.log(auth events, WS state, tool calls; never contains passwords) - Repeated 401: reviewed v1.4.0 limits failed credential attempts to 5 per 15 minutes/IP. Successful credential requests do not count there; older accounting is unverified. The bridge enforces a re-login interval of at least 3 minutes, including failed attempts; Retry-After may extend it.
events_pollempty: may mean no new events. Inspectconnected,authenticated,last_error, andconnection_error; the single WS worker retries disconnected or rejected connections. Initialization failures can be retried after a 180-second cooldown without restarting.- CoC7e initiative doesn't roll dice: upstream behavior — CoC7e initiative is DEX-ordered, no roll is produced
License
MIT (see LICENSE). CozyVTT itself is AGPLv3 — this project is an independent API client and contains no CozyVTT code.
Links
- CozyVTT upstream: https://github.com/CheekyChinchilla/CozyVTT
- AI-integration discussion: https://github.com/CheekyChinchilla/CozyVTT/issues/32
- Release history and breaking-change mappings: CHANGELOG.md
- Releases: https://github.com/yanjingzhaisun/cozyvtt-mcp/releases
- Tool-definition accuracy audit (wording corrections and limits): ForAI/TDQS_Quality_Report.md
Ecosystem
- dnd5e-rules — deterministic D&D 5e rules calculations (pure functions, SRD 5.1 data under CC-BY-4.0). The rules layer we pair with this bridge: the server rolls the dice, the bridge carries them, this library does the math, the agent narrates.
Frequently asked questions
What is Cozyvtt MCP?
Run campaigns as game master in CozyVTT, a self-hosted virtual tabletop: dice, chat narration, tokens and maps, initiative, character sheets and rulebooks.
How do I install Cozyvtt MCP?
Follow the installation steps in its documentation, shown on this page and in its repository.
Is Cozyvtt MCP open source?
Yes. Its code is on GitHub under the MIT license.
Who maintains Cozyvtt MCP?
It is published by yanjingzhaisun. AI.tn lists it but does not maintain or endorse it.
From the community awesome-mcp-servers lists and the project's GitHub repository. Listing is not an endorsement: review a server's code and permissions before giving it access to your data.