dndkit 0.4.0__tar.gz → 0.5.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {dndkit-0.4.0/src/dndkit.egg-info → dndkit-0.5.0}/PKG-INFO +88 -18
- {dndkit-0.4.0 → dndkit-0.5.0}/README.md +87 -17
- {dndkit-0.4.0 → dndkit-0.5.0}/pyproject.toml +1 -1
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/app.py +25 -7
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/config.py +71 -3
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/skills/dnd-game-master/SKILL.md +6 -3
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/tools/apps.py +2 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/tools/deps.py +16 -1
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/tools/players.py +30 -3
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/tools/screen.py +9 -2
- dndkit-0.5.0/src/dndkit/transport.py +53 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/ui/components.js +3 -2
- dndkit-0.5.0/src/dndkit/ui/join.html +18 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/ui/words.js +2 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/web/access.py +33 -3
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/web/routes.py +15 -4
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/web/server.py +10 -6
- {dndkit-0.4.0 → dndkit-0.5.0/src/dndkit.egg-info}/PKG-INFO +88 -18
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit.egg-info/SOURCES.txt +3 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/tests/test_apps.py +26 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/tests/test_tools.py +1 -1
- dndkit-0.5.0/tests/test_transport.py +89 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/tests/test_web.py +13 -1
- {dndkit-0.4.0 → dndkit-0.5.0}/LICENSE +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/MANIFEST.in +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/setup.cfg +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/__init__.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/__main__.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/activity.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/errors.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/game/__init__.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/game/areas.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/game/battlefield.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/game/bestiary.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/game/characters.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/game/combat.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/game/combatants.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/game/concentration.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/game/dice.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/game/effects.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/game/grid.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/game/journal.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/game/movement.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/game/pathing.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/game/proficiencies.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/game/progression.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/game/readiness.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/game/reference.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/game/registry.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/game/resources.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/game/rests.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/game/rules.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/game/seating.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/game/sight.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/game/spellbook.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/game/tables.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/game/world.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/models/__init__.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/models/base.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/models/battle.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/models/bestiary.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/models/campaign.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/models/character.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/models/world.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/skills/__init__.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/skills/catalog.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/skills/dnd-battle-map/SKILL.md +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/skills/dnd-monsters/SKILL.md +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/skills/extension.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/storage/__init__.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/storage/catalog.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/storage/confirmations.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/storage/files.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/storage/sharing.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/storage/store.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/text.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/tools/__init__.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/tools/battle.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/tools/bestiary.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/tools/campaign.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/tools/characters.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/tools/combat.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/tools/journal.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/tools/locations.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/tools/npcs.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/tools/play.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/tools/progression.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/tools/quests.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/tools/recorder.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/tools/render.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/tools/render_combat.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/tools/render_map.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/tools/resources.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/tools/schema.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/tools/spells.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/tools/utility.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/ui/__init__.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/ui/backstage.js +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/ui/battlemap.js +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/ui/character.html +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/ui/combat.html +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/ui/components.css +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/ui/desk.js +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/ui/dice.html +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/ui/dicetray.js +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/ui/gm.html +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/ui/party.html +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/ui/player.html +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/ui/seating.html +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/ui/shared.css +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/ui/shared.js +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/ui/sheet.js +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/ui/table.html +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/ui/world.js +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/views/__init__.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/views/backstage.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/views/battle.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/views/desk.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/views/everyone.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/views/public.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/views/widgets.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/views/world.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/web/__init__.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/web/audiences.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/web/changes.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/web/feed.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit.egg-info/dependency_links.txt +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit.egg-info/entry_points.txt +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit.egg-info/requires.txt +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit.egg-info/top_level.txt +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/tests/__init__.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/tests/conftest.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/tests/fixtures/legacy_campaign.json +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/tests/helpers.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/tests/test_battle.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/tests/test_battle_effects.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/tests/test_bosses.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/tests/test_concentration.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/tests/test_deletion.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/tests/test_desk.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/tests/test_dice_and_rules.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/tests/test_game.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/tests/test_levels_and_life.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/tests/test_live_fixes.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/tests/test_more_tools.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/tests/test_names_and_fights.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/tests/test_proficiencies.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/tests/test_readiness.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/tests/test_release.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/tests/test_rules_and_table.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/tests/test_seating.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/tests/test_skills.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/tests/test_srd.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/tests/test_storage.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/tests/test_web_views.py +0 -0
- {dndkit-0.4.0 → dndkit-0.5.0}/tests/web_helpers.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dndkit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: A D&D 5e campaign management MCP server, built for voice game masters
|
|
5
5
|
Author: Sylvain Boily, Joel Casimir
|
|
6
6
|
Maintainer: Sylvain Boily
|
|
@@ -95,7 +95,7 @@ says why and lists the known names, so the model can correct itself.
|
|
|
95
95
|
Requires Python 3.12+ and [uv](https://docs.astral.sh/uv/).
|
|
96
96
|
|
|
97
97
|
```bash
|
|
98
|
-
# From PyPI
|
|
98
|
+
# From PyPI
|
|
99
99
|
uvx dndkit
|
|
100
100
|
|
|
101
101
|
# Straight from GitHub
|
|
@@ -109,7 +109,9 @@ uv run dndkit
|
|
|
109
109
|
```
|
|
110
110
|
|
|
111
111
|
The server speaks MCP over stdio: an MCP client starts it, you don't keep it
|
|
112
|
-
running yourself. `uvx` fetches it on first use and keeps it cached.
|
|
112
|
+
running yourself. `uvx` fetches it on first use and keeps it cached. To run it
|
|
113
|
+
as a server of its own, for several computers or on a home server, see
|
|
114
|
+
[Docker](#docker).
|
|
113
115
|
|
|
114
116
|
### Configuration
|
|
115
117
|
|
|
@@ -117,9 +119,13 @@ running yourself. `uvx` fetches it on first use and keeps it cached.
|
|
|
117
119
|
| --- | --- | --- |
|
|
118
120
|
| `DNDKIT_STORAGE_DIR` | the per-user data directory (below) | Where campaigns are stored |
|
|
119
121
|
| `DNDKIT_LOG_LEVEL` | `INFO` | Log level; logs go to stderr |
|
|
120
|
-
| `DNDKIT_WEB_PORT` | unset: no web pages | Serve the [live web pages](#live-web-pages) on this port |
|
|
121
|
-
| `DNDKIT_WEB_HOST` | `127.0.0.1` | `0.0.0.0` lets phones on the same network open them |
|
|
122
|
+
| `DNDKIT_WEB_PORT` | unset: no web pages | Serve the [live web pages](#live-web-pages) on this port; the players' join codes (`show_join_codes`) need them |
|
|
123
|
+
| `DNDKIT_WEB_HOST` | `127.0.0.1` | `0.0.0.0` lets phones on the same network open them, and scan their join codes |
|
|
122
124
|
| `DNDKIT_WEB_URL` | guessed | The address phones should use, when the server cannot tell (a DNS name, Tailscale) |
|
|
125
|
+
| `DNDKIT_TRANSPORT` | `stdio` | `http`: a server of its own, MCP at `/mcp` (Streamable HTTP) and `/health` |
|
|
126
|
+
| `DNDKIT_HTTP_HOST` | `127.0.0.1` | Where HTTP listens; beyond this machine it takes a token |
|
|
127
|
+
| `DNDKIT_HTTP_PORT` | `8000` | The HTTP port |
|
|
128
|
+
| `DNDKIT_HTTP_TOKEN` | unset | The token every MCP request must carry (`Authorization: Bearer ...`); 16 characters at least, `openssl rand -hex 32` makes one |
|
|
123
129
|
|
|
124
130
|
Default data directory:
|
|
125
131
|
|
|
@@ -146,7 +152,7 @@ In **Settings → Integrations → MCP**, add a server:
|
|
|
146
152
|
| Transport | `stdio` |
|
|
147
153
|
| Command | `uvx` |
|
|
148
154
|
| Arguments | `dndkit` (from PyPI), or `--from git+https://github.com/sboily/dndkit dndkit` |
|
|
149
|
-
| Environment variables | `DNDKIT_STORAGE_DIR=/Users/you/Games/dnd` (optional)<br>`DNDKIT_WEB_PORT=8765` and `DNDKIT_WEB_HOST=0.0.0.0` for the web pages (optional) |
|
|
155
|
+
| Environment variables | `DNDKIT_STORAGE_DIR=/Users/you/Games/dnd` (optional)<br>`DNDKIT_WEB_PORT=8765` and `DNDKIT_WEB_HOST=0.0.0.0` for the web pages and the players' join codes on their phones (optional) |
|
|
150
156
|
|
|
151
157
|
From a local checkout instead: Command `uv`, Arguments
|
|
152
158
|
`run --directory /path/to/dndkit dndkit`.
|
|
@@ -156,9 +162,9 @@ roomkit-ui reads what the server publishes besides its tools: its skills
|
|
|
156
162
|
tools are meant to be used (roomkit 0.99). Put the system prompt below in the
|
|
157
163
|
assistant's personality.
|
|
158
164
|
|
|
159
|
-
The
|
|
165
|
+
The 63 tool schemas weigh about 8,950 tokens by roomkit's own estimate, over
|
|
160
166
|
the 8,000 at which roomkit may hide tools behind `find_tools`: keep the skills
|
|
161
|
-
on (the tools they bring then wait for them: about 7,
|
|
167
|
+
on (the tools they bring then wait for them: about 7,350 tokens), or raise
|
|
162
168
|
roomkit's threshold (`tool_search_threshold_tokens`). roomkit-ui's automatic
|
|
163
169
|
Tool Search leaves a local voice model every tool. Optional parameters are
|
|
164
170
|
advertised without their `null` alternative to save room.
|
|
@@ -193,10 +199,68 @@ at each skill's URI, for hosts that only read resources. Load
|
|
|
193
199
|
|
|
194
200
|
A skill's `allowed_tools` frontmatter is roomkit's: roomkit keeps those tools
|
|
195
201
|
out of the catalogue, and out of its token count, until the model activates
|
|
196
|
-
the skill. With the skills on, the catalogue weighs about 7,
|
|
202
|
+
the skill. With the skills on, the catalogue weighs about 7,350 tokens.
|
|
197
203
|
roomkit-ui lists them under the server's name; a client that does not speak
|
|
198
204
|
the extension can load them from `src/dndkit/skills/` as a skill folder.
|
|
199
205
|
|
|
206
|
+
### Docker
|
|
207
|
+
|
|
208
|
+
The image runs dndkit as a server of its own: MCP over HTTP on port 8000 and
|
|
209
|
+
the web pages on 8765, campaigns in a volume. It suits a table that plays from
|
|
210
|
+
several computers, or a server that stays on (a NAS, a Raspberry Pi: the image
|
|
211
|
+
is built for amd64 and arm64).
|
|
212
|
+
|
|
213
|
+
```bash
|
|
214
|
+
curl -O https://raw.githubusercontent.com/sboily/dndkit/main/compose.yaml
|
|
215
|
+
echo "DNDKIT_HTTP_TOKEN=$(openssl rand -hex 32)" > .env
|
|
216
|
+
echo "DNDKIT_WEB_URL=http://192.168.1.20:8765" >> .env # this computer on your network
|
|
217
|
+
docker compose up -d
|
|
218
|
+
docker compose logs dndkit | grep Backstage # the game master's link
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
The image sets `DNDKIT_TRANSPORT=http`, `DNDKIT_HTTP_HOST=0.0.0.0`,
|
|
222
|
+
`DNDKIT_HTTP_PORT=8000`, `DNDKIT_WEB_HOST=0.0.0.0`, `DNDKIT_WEB_PORT=8765` and
|
|
223
|
+
`DNDKIT_STORAGE_DIR=/data`; `compose.yaml` adds `DNDKIT_HTTP_TOKEN` and
|
|
224
|
+
`DNDKIT_WEB_URL` from `.env`. Any other setting from the table above goes in
|
|
225
|
+
the service's `environment`.
|
|
226
|
+
|
|
227
|
+
In roomkit-ui, add the server with Transport `streamable_http`, URL
|
|
228
|
+
`http://<the server's address>:8000/mcp`, and the header
|
|
229
|
+
`Authorization: Bearer <the token from .env>`. The server refuses any request
|
|
230
|
+
without that token, and does not start listening beyond its machine without
|
|
231
|
+
one.
|
|
232
|
+
|
|
233
|
+
`DNDKIT_WEB_URL` is the address the players' QR codes carry. Inside a
|
|
234
|
+
container the server only knows its own addresses, which phones cannot reach
|
|
235
|
+
(the log's "Phones on this network" names one): without the setting, the codes
|
|
236
|
+
are right only when the table screen is opened at the server's network
|
|
237
|
+
address, never at `localhost`.
|
|
238
|
+
|
|
239
|
+
The backstage cannot tell the computer running Docker from any other (every
|
|
240
|
+
visitor comes through Docker's gateway), so it opens with its link instead,
|
|
241
|
+
`/gm/<code>/`, which the server writes in its log at each start: keep it to
|
|
242
|
+
yourself, it shows everything. The code comes from the secret in the data
|
|
243
|
+
volume, like the players' links.
|
|
244
|
+
|
|
245
|
+
The image is `ghcr.io/sboily/dndkit`, published with each release from 0.5.0
|
|
246
|
+
on, for amd64 and arm64: `0.5.0` is one release, `0.5` the latest of its
|
|
247
|
+
fixes, `latest` the latest release. `compose.yaml` takes `latest`; name a
|
|
248
|
+
version there to choose when to update (`docker compose pull` then
|
|
249
|
+
`docker compose up -d`). Without compose:
|
|
250
|
+
|
|
251
|
+
```bash
|
|
252
|
+
docker run -d --name dndkit --restart unless-stopped \
|
|
253
|
+
-p 8000:8000 -p 8765:8765 -v dndkit-data:/data \
|
|
254
|
+
-e DNDKIT_HTTP_TOKEN="$(openssl rand -hex 32)" \
|
|
255
|
+
-e DNDKIT_WEB_URL=http://192.168.1.20:8765 \
|
|
256
|
+
ghcr.io/sboily/dndkit:0.5
|
|
257
|
+
docker exec dndkit env | grep DNDKIT_HTTP_TOKEN # the token, for roomkit-ui
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
The container runs as an unprivileged user (uid 1000), and `/health` tells
|
|
261
|
+
Docker whether it is up. From a checkout, `docker compose up -d --build` builds
|
|
262
|
+
the image instead of pulling it.
|
|
263
|
+
|
|
200
264
|
### With Claude Desktop
|
|
201
265
|
|
|
202
266
|
```json
|
|
@@ -290,7 +354,7 @@ Then call check_campaign and fix what it lists before the first scene.
|
|
|
290
354
|
| NPCs | `create_npc`, `get_npc`, `update_npc`, `list_npcs` |
|
|
291
355
|
| Locations | `create_location`, `get_location`, `update_location`, `list_locations` |
|
|
292
356
|
| Quests | `create_quest`, `get_quest`, `update_quest`, `list_quests` |
|
|
293
|
-
| Table | `seat_players` 🖥, `get_game_state` 🖥, `update_game_state`, `change_hit_points`, `set_condition`, `set_concentration` |
|
|
357
|
+
| Table | `seat_players` 🖥, `show_join_codes` 🖥, `get_game_state` 🖥, `update_game_state`, `change_hit_points`, `set_condition`, `set_concentration` |
|
|
294
358
|
| Combat | `start_combat` 🖥, `add_combatant`, `next_turn`, `remove_combatant`, `use_legendary`, `take_reaction`, `end_combat` |
|
|
295
359
|
| Battle map | `set_battlefield` 🖥, `place_tokens`, `move_token`, `area_of_effect`, `check_target` |
|
|
296
360
|
| Bestiary | `save_monster`, `get_monsters`, `plan_encounter`, `list_encounters` |
|
|
@@ -448,7 +512,7 @@ from where it can be put back by hand. A wrong code cancels the request.
|
|
|
448
512
|
|
|
449
513
|
## On-screen widgets (MCP Apps)
|
|
450
514
|
|
|
451
|
-
|
|
515
|
+
Seven tools carry an MCP App (`_meta.ui.resourceUri`): the client opens their
|
|
452
516
|
page next to the conversation.
|
|
453
517
|
|
|
454
518
|
| Tool | Widget |
|
|
@@ -457,6 +521,7 @@ page next to the conversation.
|
|
|
457
521
|
| `start_combat`, `set_battlefield` | Combat tracker, live: the battle map when there is one, initiative order, current turn, hit point bars that flash on damage or healing, AC, conditions, the defeated and their XP. |
|
|
458
522
|
| `get_game_state` | Party board, live: location, date, funds, every character's hit points, spell slots, conditions and death saves, quest progress, combat banner. |
|
|
459
523
|
| `seat_players` | The table, live: the players seated around it with their characters, newcomers without one yet, absent players and characters nobody plays. |
|
|
524
|
+
| `show_join_codes` | Join codes: each character's QR code for the players' phones, the same as on the table screen, so roomkit-ui alone is enough to hand them out. Needs the web pages (`DNDKIT_WEB_PORT`, and `DNDKIT_WEB_HOST=0.0.0.0` for phones). |
|
|
460
525
|
| `get_character` | Character sheet, live: hit points, AC, proficiency, XP to next level, abilities, conditions, spell slots, spells, inventory. |
|
|
461
526
|
|
|
462
527
|
Each page is self-contained (no network access), French or English after the
|
|
@@ -494,10 +559,11 @@ the game goes on without the pages.
|
|
|
494
559
|
skips the widgets' own `get_screen_data` polling. A call the client refuses
|
|
495
560
|
before sending it (roomkit checks required arguments) never reaches the
|
|
496
561
|
server, so it is not there: look at the client's log for those.
|
|
497
|
-
- **The backstage** opens only from the computer running the
|
|
498
|
-
loopback peer, asking for this server by name (`localhost`, its
|
|
499
|
-
`DNDKIT_WEB_URL`'s host), so a web page whose domain points at
|
|
500
|
-
computer (DNS rebinding) is refused.
|
|
562
|
+
- **The backstage** opens at `/gm/` only from the computer running the
|
|
563
|
+
server: a loopback peer, asking for this server by name (`localhost`, its
|
|
564
|
+
address or `DNDKIT_WEB_URL`'s host), so a web page whose domain points at
|
|
565
|
+
this computer (DNS rebinding) is refused. From anywhere else it opens with
|
|
566
|
+
its link, `/gm/<code>/`, written in the server's log at each start. Requests carrying proxy headers are
|
|
501
567
|
refused too, but a reverse proxy or tunnel on that computer that adds none
|
|
502
568
|
would make every visitor look local: do not put one in front of the pages.
|
|
503
569
|
- **Read-only**: the pages never change the game; it changes through the
|
|
@@ -552,14 +618,18 @@ Unreleased section or an existing tag. The tag runs
|
|
|
552
618
|
`.github/workflows/release.yml`: it checks that the tag matches the version,
|
|
553
619
|
sits on `main` and has its changelog section, runs the tests and builds, then
|
|
554
620
|
publishes to PyPI by trusted publishing (no token: PyPI trusts the workflow in
|
|
555
|
-
its `pypi` environment)
|
|
556
|
-
|
|
557
|
-
|
|
621
|
+
its `pypi` environment), pushes the Docker image to `ghcr.io/sboily/dndkit`
|
|
622
|
+
(the version, `<major>.<minor>` and `latest`, for amd64 and arm64), and makes
|
|
623
|
+
the GitHub release, its notes the version's changelog section, the built files
|
|
624
|
+
attached. A failed step stops what follows: nothing reaches GitHub's releases
|
|
625
|
+
before PyPI and the registry have the version.
|
|
558
626
|
|
|
559
627
|
Once, on pypi.org: add a pending trusted publisher for the project `dndkit`
|
|
560
628
|
(owner `sboily`, repository `dndkit`, workflow `release.yml`, environment
|
|
561
629
|
`pypi`). GitHub creates the `pypi` environment at the first release; required
|
|
562
630
|
reviewers set on it (Settings → Environments) hold each release for approval.
|
|
631
|
+
GitHub creates the image's package private at its first push: make it public
|
|
632
|
+
once (the package's settings → Change visibility), or nobody can pull it.
|
|
563
633
|
|
|
564
634
|
## License
|
|
565
635
|
|
|
@@ -63,7 +63,7 @@ says why and lists the known names, so the model can correct itself.
|
|
|
63
63
|
Requires Python 3.12+ and [uv](https://docs.astral.sh/uv/).
|
|
64
64
|
|
|
65
65
|
```bash
|
|
66
|
-
# From PyPI
|
|
66
|
+
# From PyPI
|
|
67
67
|
uvx dndkit
|
|
68
68
|
|
|
69
69
|
# Straight from GitHub
|
|
@@ -77,7 +77,9 @@ uv run dndkit
|
|
|
77
77
|
```
|
|
78
78
|
|
|
79
79
|
The server speaks MCP over stdio: an MCP client starts it, you don't keep it
|
|
80
|
-
running yourself. `uvx` fetches it on first use and keeps it cached.
|
|
80
|
+
running yourself. `uvx` fetches it on first use and keeps it cached. To run it
|
|
81
|
+
as a server of its own, for several computers or on a home server, see
|
|
82
|
+
[Docker](#docker).
|
|
81
83
|
|
|
82
84
|
### Configuration
|
|
83
85
|
|
|
@@ -85,9 +87,13 @@ running yourself. `uvx` fetches it on first use and keeps it cached.
|
|
|
85
87
|
| --- | --- | --- |
|
|
86
88
|
| `DNDKIT_STORAGE_DIR` | the per-user data directory (below) | Where campaigns are stored |
|
|
87
89
|
| `DNDKIT_LOG_LEVEL` | `INFO` | Log level; logs go to stderr |
|
|
88
|
-
| `DNDKIT_WEB_PORT` | unset: no web pages | Serve the [live web pages](#live-web-pages) on this port |
|
|
89
|
-
| `DNDKIT_WEB_HOST` | `127.0.0.1` | `0.0.0.0` lets phones on the same network open them |
|
|
90
|
+
| `DNDKIT_WEB_PORT` | unset: no web pages | Serve the [live web pages](#live-web-pages) on this port; the players' join codes (`show_join_codes`) need them |
|
|
91
|
+
| `DNDKIT_WEB_HOST` | `127.0.0.1` | `0.0.0.0` lets phones on the same network open them, and scan their join codes |
|
|
90
92
|
| `DNDKIT_WEB_URL` | guessed | The address phones should use, when the server cannot tell (a DNS name, Tailscale) |
|
|
93
|
+
| `DNDKIT_TRANSPORT` | `stdio` | `http`: a server of its own, MCP at `/mcp` (Streamable HTTP) and `/health` |
|
|
94
|
+
| `DNDKIT_HTTP_HOST` | `127.0.0.1` | Where HTTP listens; beyond this machine it takes a token |
|
|
95
|
+
| `DNDKIT_HTTP_PORT` | `8000` | The HTTP port |
|
|
96
|
+
| `DNDKIT_HTTP_TOKEN` | unset | The token every MCP request must carry (`Authorization: Bearer ...`); 16 characters at least, `openssl rand -hex 32` makes one |
|
|
91
97
|
|
|
92
98
|
Default data directory:
|
|
93
99
|
|
|
@@ -114,7 +120,7 @@ In **Settings → Integrations → MCP**, add a server:
|
|
|
114
120
|
| Transport | `stdio` |
|
|
115
121
|
| Command | `uvx` |
|
|
116
122
|
| Arguments | `dndkit` (from PyPI), or `--from git+https://github.com/sboily/dndkit dndkit` |
|
|
117
|
-
| Environment variables | `DNDKIT_STORAGE_DIR=/Users/you/Games/dnd` (optional)<br>`DNDKIT_WEB_PORT=8765` and `DNDKIT_WEB_HOST=0.0.0.0` for the web pages (optional) |
|
|
123
|
+
| Environment variables | `DNDKIT_STORAGE_DIR=/Users/you/Games/dnd` (optional)<br>`DNDKIT_WEB_PORT=8765` and `DNDKIT_WEB_HOST=0.0.0.0` for the web pages and the players' join codes on their phones (optional) |
|
|
118
124
|
|
|
119
125
|
From a local checkout instead: Command `uv`, Arguments
|
|
120
126
|
`run --directory /path/to/dndkit dndkit`.
|
|
@@ -124,9 +130,9 @@ roomkit-ui reads what the server publishes besides its tools: its skills
|
|
|
124
130
|
tools are meant to be used (roomkit 0.99). Put the system prompt below in the
|
|
125
131
|
assistant's personality.
|
|
126
132
|
|
|
127
|
-
The
|
|
133
|
+
The 63 tool schemas weigh about 8,950 tokens by roomkit's own estimate, over
|
|
128
134
|
the 8,000 at which roomkit may hide tools behind `find_tools`: keep the skills
|
|
129
|
-
on (the tools they bring then wait for them: about 7,
|
|
135
|
+
on (the tools they bring then wait for them: about 7,350 tokens), or raise
|
|
130
136
|
roomkit's threshold (`tool_search_threshold_tokens`). roomkit-ui's automatic
|
|
131
137
|
Tool Search leaves a local voice model every tool. Optional parameters are
|
|
132
138
|
advertised without their `null` alternative to save room.
|
|
@@ -161,10 +167,68 @@ at each skill's URI, for hosts that only read resources. Load
|
|
|
161
167
|
|
|
162
168
|
A skill's `allowed_tools` frontmatter is roomkit's: roomkit keeps those tools
|
|
163
169
|
out of the catalogue, and out of its token count, until the model activates
|
|
164
|
-
the skill. With the skills on, the catalogue weighs about 7,
|
|
170
|
+
the skill. With the skills on, the catalogue weighs about 7,350 tokens.
|
|
165
171
|
roomkit-ui lists them under the server's name; a client that does not speak
|
|
166
172
|
the extension can load them from `src/dndkit/skills/` as a skill folder.
|
|
167
173
|
|
|
174
|
+
### Docker
|
|
175
|
+
|
|
176
|
+
The image runs dndkit as a server of its own: MCP over HTTP on port 8000 and
|
|
177
|
+
the web pages on 8765, campaigns in a volume. It suits a table that plays from
|
|
178
|
+
several computers, or a server that stays on (a NAS, a Raspberry Pi: the image
|
|
179
|
+
is built for amd64 and arm64).
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
curl -O https://raw.githubusercontent.com/sboily/dndkit/main/compose.yaml
|
|
183
|
+
echo "DNDKIT_HTTP_TOKEN=$(openssl rand -hex 32)" > .env
|
|
184
|
+
echo "DNDKIT_WEB_URL=http://192.168.1.20:8765" >> .env # this computer on your network
|
|
185
|
+
docker compose up -d
|
|
186
|
+
docker compose logs dndkit | grep Backstage # the game master's link
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
The image sets `DNDKIT_TRANSPORT=http`, `DNDKIT_HTTP_HOST=0.0.0.0`,
|
|
190
|
+
`DNDKIT_HTTP_PORT=8000`, `DNDKIT_WEB_HOST=0.0.0.0`, `DNDKIT_WEB_PORT=8765` and
|
|
191
|
+
`DNDKIT_STORAGE_DIR=/data`; `compose.yaml` adds `DNDKIT_HTTP_TOKEN` and
|
|
192
|
+
`DNDKIT_WEB_URL` from `.env`. Any other setting from the table above goes in
|
|
193
|
+
the service's `environment`.
|
|
194
|
+
|
|
195
|
+
In roomkit-ui, add the server with Transport `streamable_http`, URL
|
|
196
|
+
`http://<the server's address>:8000/mcp`, and the header
|
|
197
|
+
`Authorization: Bearer <the token from .env>`. The server refuses any request
|
|
198
|
+
without that token, and does not start listening beyond its machine without
|
|
199
|
+
one.
|
|
200
|
+
|
|
201
|
+
`DNDKIT_WEB_URL` is the address the players' QR codes carry. Inside a
|
|
202
|
+
container the server only knows its own addresses, which phones cannot reach
|
|
203
|
+
(the log's "Phones on this network" names one): without the setting, the codes
|
|
204
|
+
are right only when the table screen is opened at the server's network
|
|
205
|
+
address, never at `localhost`.
|
|
206
|
+
|
|
207
|
+
The backstage cannot tell the computer running Docker from any other (every
|
|
208
|
+
visitor comes through Docker's gateway), so it opens with its link instead,
|
|
209
|
+
`/gm/<code>/`, which the server writes in its log at each start: keep it to
|
|
210
|
+
yourself, it shows everything. The code comes from the secret in the data
|
|
211
|
+
volume, like the players' links.
|
|
212
|
+
|
|
213
|
+
The image is `ghcr.io/sboily/dndkit`, published with each release from 0.5.0
|
|
214
|
+
on, for amd64 and arm64: `0.5.0` is one release, `0.5` the latest of its
|
|
215
|
+
fixes, `latest` the latest release. `compose.yaml` takes `latest`; name a
|
|
216
|
+
version there to choose when to update (`docker compose pull` then
|
|
217
|
+
`docker compose up -d`). Without compose:
|
|
218
|
+
|
|
219
|
+
```bash
|
|
220
|
+
docker run -d --name dndkit --restart unless-stopped \
|
|
221
|
+
-p 8000:8000 -p 8765:8765 -v dndkit-data:/data \
|
|
222
|
+
-e DNDKIT_HTTP_TOKEN="$(openssl rand -hex 32)" \
|
|
223
|
+
-e DNDKIT_WEB_URL=http://192.168.1.20:8765 \
|
|
224
|
+
ghcr.io/sboily/dndkit:0.5
|
|
225
|
+
docker exec dndkit env | grep DNDKIT_HTTP_TOKEN # the token, for roomkit-ui
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
The container runs as an unprivileged user (uid 1000), and `/health` tells
|
|
229
|
+
Docker whether it is up. From a checkout, `docker compose up -d --build` builds
|
|
230
|
+
the image instead of pulling it.
|
|
231
|
+
|
|
168
232
|
### With Claude Desktop
|
|
169
233
|
|
|
170
234
|
```json
|
|
@@ -258,7 +322,7 @@ Then call check_campaign and fix what it lists before the first scene.
|
|
|
258
322
|
| NPCs | `create_npc`, `get_npc`, `update_npc`, `list_npcs` |
|
|
259
323
|
| Locations | `create_location`, `get_location`, `update_location`, `list_locations` |
|
|
260
324
|
| Quests | `create_quest`, `get_quest`, `update_quest`, `list_quests` |
|
|
261
|
-
| Table | `seat_players` 🖥, `get_game_state` 🖥, `update_game_state`, `change_hit_points`, `set_condition`, `set_concentration` |
|
|
325
|
+
| Table | `seat_players` 🖥, `show_join_codes` 🖥, `get_game_state` 🖥, `update_game_state`, `change_hit_points`, `set_condition`, `set_concentration` |
|
|
262
326
|
| Combat | `start_combat` 🖥, `add_combatant`, `next_turn`, `remove_combatant`, `use_legendary`, `take_reaction`, `end_combat` |
|
|
263
327
|
| Battle map | `set_battlefield` 🖥, `place_tokens`, `move_token`, `area_of_effect`, `check_target` |
|
|
264
328
|
| Bestiary | `save_monster`, `get_monsters`, `plan_encounter`, `list_encounters` |
|
|
@@ -416,7 +480,7 @@ from where it can be put back by hand. A wrong code cancels the request.
|
|
|
416
480
|
|
|
417
481
|
## On-screen widgets (MCP Apps)
|
|
418
482
|
|
|
419
|
-
|
|
483
|
+
Seven tools carry an MCP App (`_meta.ui.resourceUri`): the client opens their
|
|
420
484
|
page next to the conversation.
|
|
421
485
|
|
|
422
486
|
| Tool | Widget |
|
|
@@ -425,6 +489,7 @@ page next to the conversation.
|
|
|
425
489
|
| `start_combat`, `set_battlefield` | Combat tracker, live: the battle map when there is one, initiative order, current turn, hit point bars that flash on damage or healing, AC, conditions, the defeated and their XP. |
|
|
426
490
|
| `get_game_state` | Party board, live: location, date, funds, every character's hit points, spell slots, conditions and death saves, quest progress, combat banner. |
|
|
427
491
|
| `seat_players` | The table, live: the players seated around it with their characters, newcomers without one yet, absent players and characters nobody plays. |
|
|
492
|
+
| `show_join_codes` | Join codes: each character's QR code for the players' phones, the same as on the table screen, so roomkit-ui alone is enough to hand them out. Needs the web pages (`DNDKIT_WEB_PORT`, and `DNDKIT_WEB_HOST=0.0.0.0` for phones). |
|
|
428
493
|
| `get_character` | Character sheet, live: hit points, AC, proficiency, XP to next level, abilities, conditions, spell slots, spells, inventory. |
|
|
429
494
|
|
|
430
495
|
Each page is self-contained (no network access), French or English after the
|
|
@@ -462,10 +527,11 @@ the game goes on without the pages.
|
|
|
462
527
|
skips the widgets' own `get_screen_data` polling. A call the client refuses
|
|
463
528
|
before sending it (roomkit checks required arguments) never reaches the
|
|
464
529
|
server, so it is not there: look at the client's log for those.
|
|
465
|
-
- **The backstage** opens only from the computer running the
|
|
466
|
-
loopback peer, asking for this server by name (`localhost`, its
|
|
467
|
-
`DNDKIT_WEB_URL`'s host), so a web page whose domain points at
|
|
468
|
-
computer (DNS rebinding) is refused.
|
|
530
|
+
- **The backstage** opens at `/gm/` only from the computer running the
|
|
531
|
+
server: a loopback peer, asking for this server by name (`localhost`, its
|
|
532
|
+
address or `DNDKIT_WEB_URL`'s host), so a web page whose domain points at
|
|
533
|
+
this computer (DNS rebinding) is refused. From anywhere else it opens with
|
|
534
|
+
its link, `/gm/<code>/`, written in the server's log at each start. Requests carrying proxy headers are
|
|
469
535
|
refused too, but a reverse proxy or tunnel on that computer that adds none
|
|
470
536
|
would make every visitor look local: do not put one in front of the pages.
|
|
471
537
|
- **Read-only**: the pages never change the game; it changes through the
|
|
@@ -520,14 +586,18 @@ Unreleased section or an existing tag. The tag runs
|
|
|
520
586
|
`.github/workflows/release.yml`: it checks that the tag matches the version,
|
|
521
587
|
sits on `main` and has its changelog section, runs the tests and builds, then
|
|
522
588
|
publishes to PyPI by trusted publishing (no token: PyPI trusts the workflow in
|
|
523
|
-
its `pypi` environment)
|
|
524
|
-
|
|
525
|
-
|
|
589
|
+
its `pypi` environment), pushes the Docker image to `ghcr.io/sboily/dndkit`
|
|
590
|
+
(the version, `<major>.<minor>` and `latest`, for amd64 and arm64), and makes
|
|
591
|
+
the GitHub release, its notes the version's changelog section, the built files
|
|
592
|
+
attached. A failed step stops what follows: nothing reaches GitHub's releases
|
|
593
|
+
before PyPI and the registry have the version.
|
|
526
594
|
|
|
527
595
|
Once, on pypi.org: add a pending trusted publisher for the project `dndkit`
|
|
528
596
|
(owner `sboily`, repository `dndkit`, workflow `release.yml`, environment
|
|
529
597
|
`pypi`). GitHub creates the `pypi` environment at the first release; required
|
|
530
598
|
reviewers set on it (Settings → Environments) hold each release for approval.
|
|
599
|
+
GitHub creates the image's package private at its first push: make it public
|
|
600
|
+
once (the package's settings → Change visibility), or nobody can pull it.
|
|
531
601
|
|
|
532
602
|
## License
|
|
533
603
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "dndkit"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.5.0"
|
|
8
8
|
description = "A D&D 5e campaign management MCP server, built for voice game masters"
|
|
9
9
|
# Joel Casimir wrote gamemaster-mcp, the project dndkit was forked from.
|
|
10
10
|
authors = [{ name = "Sylvain Boily" }, { name = "Joel Casimir" }]
|
|
@@ -17,9 +17,11 @@ from fastmcp import FastMCP
|
|
|
17
17
|
from dndkit import __version__
|
|
18
18
|
from dndkit.activity import ActivityLog
|
|
19
19
|
from dndkit.config import (
|
|
20
|
+
SettingsError,
|
|
20
21
|
WebSettings,
|
|
21
22
|
configure_logging,
|
|
22
23
|
resolve_data_dir,
|
|
24
|
+
resolve_http_settings,
|
|
23
25
|
resolve_web_settings,
|
|
24
26
|
)
|
|
25
27
|
from dndkit.game.dice import DiceLog
|
|
@@ -27,9 +29,10 @@ from dndkit.skills import register_skills
|
|
|
27
29
|
from dndkit.storage import CampaignStore
|
|
28
30
|
from dndkit.tools import ALL_TOOLS
|
|
29
31
|
from dndkit.tools.apps import register_apps
|
|
30
|
-
from dndkit.tools.deps import DICE_LOG_KEY, STORE_KEY
|
|
32
|
+
from dndkit.tools.deps import DICE_LOG_KEY, JOIN_CODES_KEY, STORE_KEY
|
|
31
33
|
from dndkit.tools.recorder import Recorder
|
|
32
34
|
from dndkit.tools.schema import compact
|
|
35
|
+
from dndkit.transport import BearerToken, add_health, serve
|
|
33
36
|
from dndkit.web import Changes, web_pages
|
|
34
37
|
|
|
35
38
|
logger = logging.getLogger(__name__)
|
|
@@ -59,10 +62,13 @@ skill://dnd-monsters/SKILL.md (save_monster, plan_encounter: monsters, bosses,
|
|
|
59
62
|
sizing a fight) and skill://dnd-battle-map/SKILL.md (the map tools)."""
|
|
60
63
|
|
|
61
64
|
|
|
62
|
-
def build_server(
|
|
65
|
+
def build_server(
|
|
66
|
+
data_dir: Path | None = None, web: WebSettings | None = None, token: str | None = None
|
|
67
|
+
) -> FastMCP:
|
|
63
68
|
"""The MCP server; campaigns live in *data_dir*, else the configured directory.
|
|
64
69
|
|
|
65
|
-
With *web*, the server also serves the live web pages (``web/``).
|
|
70
|
+
With *web*, the server also serves the live web pages (``web/``). With
|
|
71
|
+
*token*, served over HTTP, every MCP request must carry it.
|
|
66
72
|
"""
|
|
67
73
|
|
|
68
74
|
activity = ActivityLog() # in memory, filled by the Recorder middleware
|
|
@@ -76,8 +82,8 @@ def build_server(data_dir: Path | None = None, web: WebSettings | None = None) -
|
|
|
76
82
|
dice_log = DiceLog(on_record=changes.notify)
|
|
77
83
|
activity.on_record = changes.notify_activity
|
|
78
84
|
try:
|
|
79
|
-
async with web_pages(web, store, dice_log, activity, changes):
|
|
80
|
-
yield {STORE_KEY: store, DICE_LOG_KEY: dice_log}
|
|
85
|
+
async with web_pages(web, store, dice_log, activity, changes) as codes:
|
|
86
|
+
yield {STORE_KEY: store, DICE_LOG_KEY: dice_log, JOIN_CODES_KEY: codes}
|
|
81
87
|
finally:
|
|
82
88
|
store.close()
|
|
83
89
|
|
|
@@ -90,16 +96,28 @@ def build_server(data_dir: Path | None = None, web: WebSettings | None = None) -
|
|
|
90
96
|
# errors (``GameError``) keep their message, written for the model.
|
|
91
97
|
mask_error_details=True,
|
|
92
98
|
middleware=[Recorder(activity)],
|
|
99
|
+
auth=BearerToken(token) if token else None,
|
|
93
100
|
)
|
|
94
101
|
for fn in ALL_TOOLS:
|
|
95
102
|
tool = server.add_tool(fn)
|
|
96
103
|
tool.parameters = compact(tool.parameters)
|
|
97
104
|
register_apps(server)
|
|
98
105
|
register_skills(server, activity)
|
|
106
|
+
add_health(server)
|
|
99
107
|
return server
|
|
100
108
|
|
|
101
109
|
|
|
102
110
|
def main() -> None:
|
|
103
|
-
"""Run the server over stdio, the transport MCP clients launch
|
|
111
|
+
"""Run the server: over stdio, the transport MCP clients launch, or over HTTP
|
|
112
|
+
(``DNDKIT_TRANSPORT=http``) as a server of its own."""
|
|
104
113
|
configure_logging()
|
|
105
|
-
|
|
114
|
+
try:
|
|
115
|
+
http = resolve_http_settings()
|
|
116
|
+
except SettingsError as error:
|
|
117
|
+
logger.error("%s", error)
|
|
118
|
+
raise SystemExit(2) from None
|
|
119
|
+
server = build_server(web=resolve_web_settings(), token=http.token if http else None)
|
|
120
|
+
if http is None:
|
|
121
|
+
server.run(show_banner=False)
|
|
122
|
+
else:
|
|
123
|
+
serve(server, http)
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
"""Runtime configuration: where the data lives, how much is logged, the web pages
|
|
1
|
+
"""Runtime configuration: where the data lives, how much is logged, the web pages,
|
|
2
|
+
and how clients reach the server (stdio, or HTTP for a server of its own).
|
|
2
3
|
|
|
3
4
|
MCP clients such as roomkit-ui or Claude Desktop start the server with no
|
|
4
5
|
working directory of their own (often ``/`` on macOS), so the data directory
|
|
@@ -9,6 +10,7 @@ The project was called gamemaster-mcp before its rename: its ``GAMEMASTER_*``
|
|
|
9
10
|
variables and its data directory still work.
|
|
10
11
|
"""
|
|
11
12
|
|
|
13
|
+
import ipaddress
|
|
12
14
|
import logging
|
|
13
15
|
import os
|
|
14
16
|
import sys
|
|
@@ -26,7 +28,15 @@ ENV_LOG_LEVEL = "DNDKIT_LOG_LEVEL"
|
|
|
26
28
|
ENV_WEB_PORT = "DNDKIT_WEB_PORT"
|
|
27
29
|
ENV_WEB_HOST = "DNDKIT_WEB_HOST"
|
|
28
30
|
ENV_WEB_URL = "DNDKIT_WEB_URL"
|
|
31
|
+
ENV_TRANSPORT = "DNDKIT_TRANSPORT"
|
|
32
|
+
ENV_HTTP_HOST = "DNDKIT_HTTP_HOST"
|
|
33
|
+
ENV_HTTP_PORT = "DNDKIT_HTTP_PORT"
|
|
34
|
+
# Names and advice, not secrets: bandit takes any "token" string for a password.
|
|
35
|
+
ENV_HTTP_TOKEN = "DNDKIT_HTTP_TOKEN" # nosec B105
|
|
29
36
|
DEFAULT_WEB_HOST = "127.0.0.1"
|
|
37
|
+
DEFAULT_HTTP_PORT = 8000
|
|
38
|
+
MIN_TOKEN_LENGTH = 16
|
|
39
|
+
MAKE_A_TOKEN = "openssl rand -hex 32" # nosec B105
|
|
30
40
|
|
|
31
41
|
logger = logging.getLogger(__name__)
|
|
32
42
|
|
|
@@ -40,6 +50,19 @@ class WebSettings:
|
|
|
40
50
|
public_url: str | None = None # how phones reach the pages when the server cannot tell
|
|
41
51
|
|
|
42
52
|
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class HttpSettings:
|
|
55
|
+
"""Where the MCP server listens over HTTP, and the token each request must carry."""
|
|
56
|
+
|
|
57
|
+
host: str = DEFAULT_WEB_HOST
|
|
58
|
+
port: int = DEFAULT_HTTP_PORT
|
|
59
|
+
token: str | None = None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class SettingsError(ValueError):
|
|
63
|
+
"""A setting the server cannot start with."""
|
|
64
|
+
|
|
65
|
+
|
|
43
66
|
def setting(env: Mapping[str, str], name: str) -> str:
|
|
44
67
|
"""``$name``, else the same variable under its name from before the rename."""
|
|
45
68
|
value = env.get(name, "").strip()
|
|
@@ -89,7 +112,8 @@ def resolve_web_settings(env: Mapping[str, str] = os.environ) -> WebSettings | N
|
|
|
89
112
|
raw_port = setting(env, ENV_WEB_PORT)
|
|
90
113
|
if not raw_port:
|
|
91
114
|
return None
|
|
92
|
-
|
|
115
|
+
port = _port(raw_port)
|
|
116
|
+
if port is None:
|
|
93
117
|
# The game must still run: only the web pages are lost to a typo.
|
|
94
118
|
logger.warning("Web pages are off: %s=%r is not a port number.", ENV_WEB_PORT, raw_port)
|
|
95
119
|
return None
|
|
@@ -98,7 +122,51 @@ def resolve_web_settings(env: Mapping[str, str] = os.environ) -> WebSettings | N
|
|
|
98
122
|
logger.warning("Ignoring %s=%r: not an http(s) address.", ENV_WEB_URL, public_url)
|
|
99
123
|
public_url = None
|
|
100
124
|
return WebSettings(
|
|
101
|
-
port=
|
|
125
|
+
port=port,
|
|
102
126
|
host=setting(env, ENV_WEB_HOST) or DEFAULT_WEB_HOST,
|
|
103
127
|
public_url=public_url,
|
|
104
128
|
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def resolve_http_settings(env: Mapping[str, str] = os.environ) -> HttpSettings | None:
|
|
132
|
+
"""None for stdio, the default; how to listen for ``DNDKIT_TRANSPORT=http``.
|
|
133
|
+
|
|
134
|
+
Unlike the web pages', a bad value stops the start: without its endpoint
|
|
135
|
+
there is no game. Listening beyond this machine takes a token.
|
|
136
|
+
"""
|
|
137
|
+
transport = (setting(env, ENV_TRANSPORT) or "stdio").lower()
|
|
138
|
+
if transport == "stdio":
|
|
139
|
+
return None
|
|
140
|
+
if transport != "http":
|
|
141
|
+
raise SettingsError(f"{ENV_TRANSPORT} is stdio or http, not {transport!r}.")
|
|
142
|
+
raw_port = setting(env, ENV_HTTP_PORT) or str(DEFAULT_HTTP_PORT)
|
|
143
|
+
port = _port(raw_port)
|
|
144
|
+
if port is None:
|
|
145
|
+
raise SettingsError(f"{ENV_HTTP_PORT}={raw_port!r} is not a port number.")
|
|
146
|
+
host = setting(env, ENV_HTTP_HOST) or DEFAULT_WEB_HOST
|
|
147
|
+
token = setting(env, ENV_HTTP_TOKEN) or None
|
|
148
|
+
if token is not None and len(token) < MIN_TOKEN_LENGTH:
|
|
149
|
+
raise SettingsError(
|
|
150
|
+
f"{ENV_HTTP_TOKEN} needs {MIN_TOKEN_LENGTH} characters at least ({MAKE_A_TOKEN})."
|
|
151
|
+
)
|
|
152
|
+
if token is None and not _is_loopback(host):
|
|
153
|
+
raise SettingsError(
|
|
154
|
+
f"Listening on {host} lets other computers in: set {ENV_HTTP_TOKEN} "
|
|
155
|
+
f"({MAKE_A_TOKEN}), or {ENV_HTTP_HOST}=127.0.0.1."
|
|
156
|
+
)
|
|
157
|
+
return HttpSettings(host, port, token)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _port(raw: str) -> int | None:
|
|
161
|
+
if not (raw.isascii() and raw.isdigit()) or int(raw) > 65535:
|
|
162
|
+
return None
|
|
163
|
+
return int(raw)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _is_loopback(host: str) -> bool:
|
|
167
|
+
if host == "localhost":
|
|
168
|
+
return True
|
|
169
|
+
try:
|
|
170
|
+
return ipaddress.ip_address(host).is_loopback
|
|
171
|
+
except ValueError:
|
|
172
|
+
return False
|
|
@@ -32,8 +32,9 @@ result with the tools, and hand the story back with "what do you do?".
|
|
|
32
32
|
- **Characters**: `create_character`, `update_character` (scores, AC, skills,
|
|
33
33
|
languages, features), `get_character`, items, spells and slots, `rest`,
|
|
34
34
|
`award_experience`, `level_up`.
|
|
35
|
-
- **The table**: `seat_players` (who plays whom tonight), `get_game_state
|
|
36
|
-
The dice, the party, a sheet and
|
|
35
|
+
- **The table**: `seat_players` (who plays whom tonight), `get_game_state`,
|
|
36
|
+
`show_join_codes` (the players' phones). The dice, the party, a sheet and
|
|
37
|
+
the fight show on the players' screen.
|
|
37
38
|
- **The world**: NPCs, locations and quests (`create_`, `get_`, `update_`,
|
|
38
39
|
`list_`), `update_game_state` (where the party is, the date).
|
|
39
40
|
- **Fights**: `start_combat`, `next_turn` after every turn, `take_reaction`,
|
|
@@ -50,7 +51,9 @@ result with the tools, and hand the story back with "what do you do?".
|
|
|
50
51
|
3. A campaign under way: `get_sessions` and `get_game_state`, then recall in
|
|
51
52
|
two sentences where the story stopped.
|
|
52
53
|
4. The roll call: as each player says who they are, `seat_players` with them
|
|
53
|
-
and their character (`start_over` the first time tonight).
|
|
54
|
+
and their character (`start_over` the first time tonight). Players who
|
|
55
|
+
want their sheet on their phone: `show_join_codes` puts each character's
|
|
56
|
+
QR code on screen.
|
|
54
57
|
5. Set the opening scene in three or four sentences and ask the first player
|
|
55
58
|
what they do.
|
|
56
59
|
|
|
@@ -18,6 +18,7 @@ COMBAT_APP = "ui://dndkit/combat.html"
|
|
|
18
18
|
PARTY_APP = "ui://dndkit/party.html"
|
|
19
19
|
CHARACTER_APP = "ui://dndkit/character.html"
|
|
20
20
|
SEATING_APP = "ui://dndkit/seating.html"
|
|
21
|
+
JOIN_APP = "ui://dndkit/join.html"
|
|
21
22
|
|
|
22
23
|
_PAGES = {
|
|
23
24
|
DICE_APP: ("dice", "Dice tray: the latest roll, rolling."),
|
|
@@ -25,6 +26,7 @@ _PAGES = {
|
|
|
25
26
|
PARTY_APP: ("party", "Party board: hit points, conditions, location and quests, live."),
|
|
26
27
|
CHARACTER_APP: ("character", "Character sheet, live."),
|
|
27
28
|
SEATING_APP: ("seating", "The table: who plays which character tonight, live."),
|
|
29
|
+
JOIN_APP: ("join", "Join codes: each character's QR code, for the players' phones."),
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
|