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.
Files changed (156) hide show
  1. {dndkit-0.4.0/src/dndkit.egg-info → dndkit-0.5.0}/PKG-INFO +88 -18
  2. {dndkit-0.4.0 → dndkit-0.5.0}/README.md +87 -17
  3. {dndkit-0.4.0 → dndkit-0.5.0}/pyproject.toml +1 -1
  4. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/app.py +25 -7
  5. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/config.py +71 -3
  6. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/skills/dnd-game-master/SKILL.md +6 -3
  7. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/tools/apps.py +2 -0
  8. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/tools/deps.py +16 -1
  9. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/tools/players.py +30 -3
  10. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/tools/screen.py +9 -2
  11. dndkit-0.5.0/src/dndkit/transport.py +53 -0
  12. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/ui/components.js +3 -2
  13. dndkit-0.5.0/src/dndkit/ui/join.html +18 -0
  14. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/ui/words.js +2 -0
  15. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/web/access.py +33 -3
  16. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/web/routes.py +15 -4
  17. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/web/server.py +10 -6
  18. {dndkit-0.4.0 → dndkit-0.5.0/src/dndkit.egg-info}/PKG-INFO +88 -18
  19. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit.egg-info/SOURCES.txt +3 -0
  20. {dndkit-0.4.0 → dndkit-0.5.0}/tests/test_apps.py +26 -0
  21. {dndkit-0.4.0 → dndkit-0.5.0}/tests/test_tools.py +1 -1
  22. dndkit-0.5.0/tests/test_transport.py +89 -0
  23. {dndkit-0.4.0 → dndkit-0.5.0}/tests/test_web.py +13 -1
  24. {dndkit-0.4.0 → dndkit-0.5.0}/LICENSE +0 -0
  25. {dndkit-0.4.0 → dndkit-0.5.0}/MANIFEST.in +0 -0
  26. {dndkit-0.4.0 → dndkit-0.5.0}/setup.cfg +0 -0
  27. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/__init__.py +0 -0
  28. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/__main__.py +0 -0
  29. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/activity.py +0 -0
  30. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/errors.py +0 -0
  31. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/game/__init__.py +0 -0
  32. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/game/areas.py +0 -0
  33. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/game/battlefield.py +0 -0
  34. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/game/bestiary.py +0 -0
  35. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/game/characters.py +0 -0
  36. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/game/combat.py +0 -0
  37. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/game/combatants.py +0 -0
  38. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/game/concentration.py +0 -0
  39. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/game/dice.py +0 -0
  40. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/game/effects.py +0 -0
  41. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/game/grid.py +0 -0
  42. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/game/journal.py +0 -0
  43. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/game/movement.py +0 -0
  44. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/game/pathing.py +0 -0
  45. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/game/proficiencies.py +0 -0
  46. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/game/progression.py +0 -0
  47. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/game/readiness.py +0 -0
  48. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/game/reference.py +0 -0
  49. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/game/registry.py +0 -0
  50. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/game/resources.py +0 -0
  51. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/game/rests.py +0 -0
  52. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/game/rules.py +0 -0
  53. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/game/seating.py +0 -0
  54. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/game/sight.py +0 -0
  55. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/game/spellbook.py +0 -0
  56. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/game/tables.py +0 -0
  57. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/game/world.py +0 -0
  58. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/models/__init__.py +0 -0
  59. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/models/base.py +0 -0
  60. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/models/battle.py +0 -0
  61. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/models/bestiary.py +0 -0
  62. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/models/campaign.py +0 -0
  63. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/models/character.py +0 -0
  64. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/models/world.py +0 -0
  65. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/skills/__init__.py +0 -0
  66. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/skills/catalog.py +0 -0
  67. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/skills/dnd-battle-map/SKILL.md +0 -0
  68. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/skills/dnd-monsters/SKILL.md +0 -0
  69. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/skills/extension.py +0 -0
  70. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/storage/__init__.py +0 -0
  71. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/storage/catalog.py +0 -0
  72. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/storage/confirmations.py +0 -0
  73. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/storage/files.py +0 -0
  74. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/storage/sharing.py +0 -0
  75. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/storage/store.py +0 -0
  76. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/text.py +0 -0
  77. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/tools/__init__.py +0 -0
  78. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/tools/battle.py +0 -0
  79. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/tools/bestiary.py +0 -0
  80. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/tools/campaign.py +0 -0
  81. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/tools/characters.py +0 -0
  82. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/tools/combat.py +0 -0
  83. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/tools/journal.py +0 -0
  84. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/tools/locations.py +0 -0
  85. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/tools/npcs.py +0 -0
  86. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/tools/play.py +0 -0
  87. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/tools/progression.py +0 -0
  88. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/tools/quests.py +0 -0
  89. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/tools/recorder.py +0 -0
  90. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/tools/render.py +0 -0
  91. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/tools/render_combat.py +0 -0
  92. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/tools/render_map.py +0 -0
  93. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/tools/resources.py +0 -0
  94. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/tools/schema.py +0 -0
  95. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/tools/spells.py +0 -0
  96. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/tools/utility.py +0 -0
  97. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/ui/__init__.py +0 -0
  98. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/ui/backstage.js +0 -0
  99. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/ui/battlemap.js +0 -0
  100. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/ui/character.html +0 -0
  101. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/ui/combat.html +0 -0
  102. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/ui/components.css +0 -0
  103. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/ui/desk.js +0 -0
  104. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/ui/dice.html +0 -0
  105. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/ui/dicetray.js +0 -0
  106. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/ui/gm.html +0 -0
  107. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/ui/party.html +0 -0
  108. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/ui/player.html +0 -0
  109. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/ui/seating.html +0 -0
  110. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/ui/shared.css +0 -0
  111. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/ui/shared.js +0 -0
  112. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/ui/sheet.js +0 -0
  113. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/ui/table.html +0 -0
  114. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/ui/world.js +0 -0
  115. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/views/__init__.py +0 -0
  116. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/views/backstage.py +0 -0
  117. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/views/battle.py +0 -0
  118. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/views/desk.py +0 -0
  119. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/views/everyone.py +0 -0
  120. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/views/public.py +0 -0
  121. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/views/widgets.py +0 -0
  122. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/views/world.py +0 -0
  123. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/web/__init__.py +0 -0
  124. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/web/audiences.py +0 -0
  125. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/web/changes.py +0 -0
  126. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit/web/feed.py +0 -0
  127. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit.egg-info/dependency_links.txt +0 -0
  128. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit.egg-info/entry_points.txt +0 -0
  129. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit.egg-info/requires.txt +0 -0
  130. {dndkit-0.4.0 → dndkit-0.5.0}/src/dndkit.egg-info/top_level.txt +0 -0
  131. {dndkit-0.4.0 → dndkit-0.5.0}/tests/__init__.py +0 -0
  132. {dndkit-0.4.0 → dndkit-0.5.0}/tests/conftest.py +0 -0
  133. {dndkit-0.4.0 → dndkit-0.5.0}/tests/fixtures/legacy_campaign.json +0 -0
  134. {dndkit-0.4.0 → dndkit-0.5.0}/tests/helpers.py +0 -0
  135. {dndkit-0.4.0 → dndkit-0.5.0}/tests/test_battle.py +0 -0
  136. {dndkit-0.4.0 → dndkit-0.5.0}/tests/test_battle_effects.py +0 -0
  137. {dndkit-0.4.0 → dndkit-0.5.0}/tests/test_bosses.py +0 -0
  138. {dndkit-0.4.0 → dndkit-0.5.0}/tests/test_concentration.py +0 -0
  139. {dndkit-0.4.0 → dndkit-0.5.0}/tests/test_deletion.py +0 -0
  140. {dndkit-0.4.0 → dndkit-0.5.0}/tests/test_desk.py +0 -0
  141. {dndkit-0.4.0 → dndkit-0.5.0}/tests/test_dice_and_rules.py +0 -0
  142. {dndkit-0.4.0 → dndkit-0.5.0}/tests/test_game.py +0 -0
  143. {dndkit-0.4.0 → dndkit-0.5.0}/tests/test_levels_and_life.py +0 -0
  144. {dndkit-0.4.0 → dndkit-0.5.0}/tests/test_live_fixes.py +0 -0
  145. {dndkit-0.4.0 → dndkit-0.5.0}/tests/test_more_tools.py +0 -0
  146. {dndkit-0.4.0 → dndkit-0.5.0}/tests/test_names_and_fights.py +0 -0
  147. {dndkit-0.4.0 → dndkit-0.5.0}/tests/test_proficiencies.py +0 -0
  148. {dndkit-0.4.0 → dndkit-0.5.0}/tests/test_readiness.py +0 -0
  149. {dndkit-0.4.0 → dndkit-0.5.0}/tests/test_release.py +0 -0
  150. {dndkit-0.4.0 → dndkit-0.5.0}/tests/test_rules_and_table.py +0 -0
  151. {dndkit-0.4.0 → dndkit-0.5.0}/tests/test_seating.py +0 -0
  152. {dndkit-0.4.0 → dndkit-0.5.0}/tests/test_skills.py +0 -0
  153. {dndkit-0.4.0 → dndkit-0.5.0}/tests/test_srd.py +0 -0
  154. {dndkit-0.4.0 → dndkit-0.5.0}/tests/test_storage.py +0 -0
  155. {dndkit-0.4.0 → dndkit-0.5.0}/tests/test_web_views.py +0 -0
  156. {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.4.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 (from the first release, 0.4.0)
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 62 tool schemas weigh about 8,900 tokens by roomkit's own estimate, over
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,300 tokens), or raise
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,300 tokens.
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
- Six tools carry an MCP App (`_meta.ui.resourceUri`): the client opens their
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 server: a
498
- loopback peer, asking for this server by name (`localhost`, its address or
499
- `DNDKIT_WEB_URL`'s host), so a web page whose domain points at this
500
- computer (DNS rebinding) is refused. Requests carrying proxy headers are
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) and makes the GitHub release, its notes the version's
556
- changelog section, the built files attached. A failed step stops what follows:
557
- nothing reaches GitHub's releases before PyPI has the version.
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 (from the first release, 0.4.0)
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 62 tool schemas weigh about 8,900 tokens by roomkit's own estimate, over
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,300 tokens), or raise
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,300 tokens.
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
- Six tools carry an MCP App (`_meta.ui.resourceUri`): the client opens their
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 server: a
466
- loopback peer, asking for this server by name (`localhost`, its address or
467
- `DNDKIT_WEB_URL`'s host), so a web page whose domain points at this
468
- computer (DNS rebinding) is refused. Requests carrying proxy headers are
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) and makes the GitHub release, its notes the version's
524
- changelog section, the built files attached. A failed step stops what follows:
525
- nothing reaches GitHub's releases before PyPI has the version.
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.4.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(data_dir: Path | None = None, web: WebSettings | None = None) -> FastMCP:
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
- build_server(web=resolve_web_settings()).run(show_banner=False)
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
- if not (raw_port.isascii() and raw_port.isdigit()) or int(raw_port) > 65535:
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=int(raw_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 the fight show on the players' screen.
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