@iamjameslennon/ddb-mcp 2.3.0

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 (66) hide show
  1. package/README.md +520 -0
  2. package/dist/auth.d.ts +3 -0
  3. package/dist/auth.d.ts.map +1 -0
  4. package/dist/auth.js +69 -0
  5. package/dist/auth.js.map +1 -0
  6. package/dist/browser.d.ts +9 -0
  7. package/dist/browser.d.ts.map +1 -0
  8. package/dist/browser.js +68 -0
  9. package/dist/browser.js.map +1 -0
  10. package/dist/cache.d.ts +18 -0
  11. package/dist/cache.d.ts.map +1 -0
  12. package/dist/cache.js +45 -0
  13. package/dist/cache.js.map +1 -0
  14. package/dist/index.d.ts +2 -0
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/index.js +654 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/open5e.d.ts +74 -0
  19. package/dist/open5e.d.ts.map +1 -0
  20. package/dist/open5e.js +455 -0
  21. package/dist/open5e.js.map +1 -0
  22. package/dist/session-fetch.d.ts +35 -0
  23. package/dist/session-fetch.d.ts.map +1 -0
  24. package/dist/session-fetch.js +155 -0
  25. package/dist/session-fetch.js.map +1 -0
  26. package/dist/tools/campaign.d.ts +4 -0
  27. package/dist/tools/campaign.d.ts.map +1 -0
  28. package/dist/tools/campaign.js +72 -0
  29. package/dist/tools/campaign.js.map +1 -0
  30. package/dist/tools/character.d.ts +21 -0
  31. package/dist/tools/character.d.ts.map +1 -0
  32. package/dist/tools/character.js +1128 -0
  33. package/dist/tools/character.js.map +1 -0
  34. package/dist/tools/encounter.d.ts +22 -0
  35. package/dist/tools/encounter.d.ts.map +1 -0
  36. package/dist/tools/encounter.js +453 -0
  37. package/dist/tools/encounter.js.map +1 -0
  38. package/dist/tools/library.d.ts +4 -0
  39. package/dist/tools/library.d.ts.map +1 -0
  40. package/dist/tools/library.js +112 -0
  41. package/dist/tools/library.js.map +1 -0
  42. package/dist/tools/monster.d.ts +27 -0
  43. package/dist/tools/monster.d.ts.map +1 -0
  44. package/dist/tools/monster.js +378 -0
  45. package/dist/tools/monster.js.map +1 -0
  46. package/dist/tools/navigate.d.ts +5 -0
  47. package/dist/tools/navigate.d.ts.map +1 -0
  48. package/dist/tools/navigate.js +67 -0
  49. package/dist/tools/navigate.js.map +1 -0
  50. package/dist/tools/reference.d.ts +58 -0
  51. package/dist/tools/reference.d.ts.map +1 -0
  52. package/dist/tools/reference.js +850 -0
  53. package/dist/tools/reference.js.map +1 -0
  54. package/dist/tools/search.d.ts +4 -0
  55. package/dist/tools/search.d.ts.map +1 -0
  56. package/dist/tools/search.js +64 -0
  57. package/dist/tools/search.js.map +1 -0
  58. package/dist/tools/treasure.d.ts +12 -0
  59. package/dist/tools/treasure.d.ts.map +1 -0
  60. package/dist/tools/treasure.js +522 -0
  61. package/dist/tools/treasure.js.map +1 -0
  62. package/dist/utils.d.ts +5 -0
  63. package/dist/utils.d.ts.map +1 -0
  64. package/dist/utils.js +21 -0
  65. package/dist/utils.js.map +1 -0
  66. package/package.json +36 -0
package/dist/index.js ADDED
@@ -0,0 +1,654 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { z } from "zod";
4
+ import { getBrowser, getContext, closeBrowser } from "./browser.js";
5
+ import { login } from "./auth.js";
6
+ import { getCharacter, downloadCharacter, listCharacters, parseCharacter, findCharacterByName, getDefinition } from "./tools/character.js";
7
+ import { getCampaign, listMyCampaigns } from "./tools/campaign.js";
8
+ import { navigate, interact, getCurrentPageContent } from "./tools/navigate.js";
9
+ import { search } from "./tools/search.js";
10
+ import { listLibrary, readBook } from "./tools/library.js";
11
+ import { searchMonsters, getMonster } from "./tools/monster.js";
12
+ import { rateEncounter, targetEncounterCr } from "./tools/encounter.js";
13
+ import { generateTreasure } from "./tools/treasure.js";
14
+ import { getCondition, searchSpells, getSpell, searchItems, getItem, searchRaces, searchClasses, searchBackgrounds, searchFeats, searchClassFeatures, searchRacialTraits, searchRules, getRule } from "./tools/reference.js";
15
+ const server = new McpServer({
16
+ name: "dndbeyond",
17
+ version: "1.0.0",
18
+ });
19
+ // Lazy-initialized shared browser context (headless by default)
20
+ async function getSharedContext() {
21
+ const browser = await getBrowser();
22
+ const context = await getContext(browser);
23
+ return context;
24
+ }
25
+ // Login-specific context — opens a visible window for the OAuth flow
26
+ async function getLoginContext() {
27
+ const browser = await getBrowser(false);
28
+ const context = await getContext(browser);
29
+ return context;
30
+ }
31
+ // ─── ddb_login ────────────────────────────────────────────────────────────────
32
+ server.tool("ddb_login", "Launch a browser and log into D&D Beyond. A Chrome window will open for you to complete login — it closes automatically once your session is saved. After that, character tools work without any browser.", {}, async () => {
33
+ try {
34
+ const context = await getLoginContext();
35
+ const result = await login(context);
36
+ // Close the browser immediately after saving the session — no need to
37
+ // keep it open since all API-based tools use the saved cookies directly.
38
+ await closeBrowser();
39
+ return { content: [{ type: "text", text: `${result}\nBrowser closed. Session saved to disk — no browser needed for future requests.` }] };
40
+ }
41
+ catch (err) {
42
+ // Still try to close the browser even if login failed
43
+ await closeBrowser().catch(() => { });
44
+ const msg = err instanceof Error ? err.message : String(err);
45
+ process.stderr.write(`[ddb-mcp] ddb_login error: ${msg}\n`);
46
+ return { content: [{ type: "text", text: `Login failed: ${msg}` }], isError: true };
47
+ }
48
+ });
49
+ // ─── ddb_close_browser ───────────────────────────────────────────────────────
50
+ server.tool("ddb_close_browser", "Close the background browser window if one is open. Useful after running ddb_navigate, ddb_interact, or ddb_get_page.", {}, async () => {
51
+ try {
52
+ await closeBrowser();
53
+ return { content: [{ type: "text", text: "Browser closed." }] };
54
+ }
55
+ catch (err) {
56
+ const msg = err instanceof Error ? err.message : String(err);
57
+ process.stderr.write(`[ddb-mcp] ddb_close_browser error: ${msg}\n`);
58
+ return { content: [{ type: "text", text: `Failed to close browser: ${msg}` }], isError: true };
59
+ }
60
+ });
61
+ // ─── ddb_list_characters ──────────────────────────────────────────────────────
62
+ server.tool("ddb_list_characters", "List all characters in your D&D Beyond account, including their ID, level, race, and class.", {}, async () => {
63
+ try {
64
+ const result = await listCharacters();
65
+ return { content: [{ type: "text", text: result }] };
66
+ }
67
+ catch (err) {
68
+ const msg = err instanceof Error ? err.message : String(err);
69
+ process.stderr.write(`[ddb-mcp] ddb_list_characters error: ${msg}\n`);
70
+ return { content: [{ type: "text", text: `Failed to list characters: ${msg}` }], isError: true };
71
+ }
72
+ });
73
+ // ─── ddb_get_character_raw ────────────────────────────────────────────────────
74
+ server.tool("ddb_get_character_raw", "Returns raw 300–500 KB character JSON. Requires confirm_large_response: true. Use ddb_get_character instead for all normal use.", {
75
+ character_id: z.string().min(1).optional().describe("The D&D Beyond character ID (e.g. '12345678')"),
76
+ character_name: z.string().min(1).optional().describe("Character name to look up (fuzzy matched against your account)"),
77
+ confirm_large_response: z.literal(true).describe("Must be true to proceed — acknowledges this call returns 300–500 KB."),
78
+ }, async ({ character_id, character_name }) => {
79
+ try {
80
+ let resolvedId = character_id;
81
+ if (!resolvedId) {
82
+ if (!character_name) {
83
+ return { content: [{ type: "text", text: "Either character_id or character_name must be provided." }], isError: true };
84
+ }
85
+ const found = await findCharacterByName(character_name);
86
+ if (!found) {
87
+ return { content: [{ type: "text", text: `No character found matching "${character_name}". Try ddb_list_characters to see your characters.` }], isError: true };
88
+ }
89
+ resolvedId = found.id;
90
+ }
91
+ const data = await getCharacter(resolvedId);
92
+ return { content: [{ type: "text", text: data }] };
93
+ }
94
+ catch (err) {
95
+ const msg = err instanceof Error ? err.message : String(err);
96
+ process.stderr.write(`[ddb-mcp] ddb_get_character_raw error: ${msg}\n`);
97
+ return { content: [{ type: "text", text: `Failed to get character: ${msg}` }], isError: true };
98
+ }
99
+ });
100
+ // ─── ddb_download_character ───────────────────────────────────────────────────
101
+ server.tool("ddb_download_character", "Download a character's full JSON data to a local file.", {
102
+ character_id: z.string().min(1).describe("The D&D Beyond character ID"),
103
+ output_path: z
104
+ .string()
105
+ .optional()
106
+ .describe("Full file path to save to (defaults to ~/Downloads/{name}-{id}.json). Must be a path under ~/Downloads or ~/Documents."),
107
+ }, async ({ character_id, output_path }) => {
108
+ try {
109
+ // No browser needed — uses saved session cookies directly
110
+ const result = await downloadCharacter(character_id, output_path);
111
+ return { content: [{ type: "text", text: result }] };
112
+ }
113
+ catch (err) {
114
+ const msg = err instanceof Error ? err.message : String(err);
115
+ process.stderr.write(`[ddb-mcp] ddb_download_character error: ${msg}\n`);
116
+ return { content: [{ type: "text", text: `Download failed: ${msg}` }], isError: true };
117
+ }
118
+ });
119
+ // ─── ddb_get_character ───────────────────────────────────────────────────────
120
+ server.tool("ddb_get_character", "Parse and display a character sheet. Use sections to reduce output: summary (vitals+stats), combat (adds actions/weapons), spells (spellcasting only), inventory, features, or full (default).", {
121
+ character_id: z.string().min(1).optional().describe("The D&D Beyond character ID (e.g. '12345678')"),
122
+ character_name: z.string().min(1).optional().describe("Character name to look up (fuzzy matched — e.g. 'Throin' finds 'Thorin Ironforge')"),
123
+ sections: z.enum(["summary", "combat", "spells", "inventory", "features", "full"])
124
+ .default("full")
125
+ .describe("Which sections to return. Default: full. Use summary for a quick overview."),
126
+ }, async ({ character_id, character_name, sections }) => {
127
+ try {
128
+ let resolvedId = character_id;
129
+ if (!resolvedId) {
130
+ if (!character_name) {
131
+ return { content: [{ type: "text", text: "Either character_id or character_name must be provided." }], isError: true };
132
+ }
133
+ const found = await findCharacterByName(character_name);
134
+ if (!found) {
135
+ return { content: [{ type: "text", text: `No character found matching "${character_name}". Try ddb_list_characters to see your characters.` }], isError: true };
136
+ }
137
+ resolvedId = found.id;
138
+ }
139
+ const summary = await parseCharacter(resolvedId, sections);
140
+ return { content: [{ type: "text", text: summary }] };
141
+ }
142
+ catch (err) {
143
+ const msg = err instanceof Error ? err.message : String(err);
144
+ process.stderr.write(`[ddb-mcp] ddb_get_character error: ${msg}\n`);
145
+ return { content: [{ type: "text", text: `Failed to parse character: ${msg}` }], isError: true };
146
+ }
147
+ });
148
+ // ─── ddb_character_lookup ────────────────────────────────────────────────────
149
+ server.tool("ddb_character_lookup", "Look up the full description of a spell, feat, class feature, subclass feature, racial trait, background feature, or equipped item by name. Supports partial and fuzzy name matching (e.g. 'cutting' finds Cutting Words, 'sheild' finds Shield). Accepts either a numeric character_id or a character_name.", {
150
+ character_id: z.string().min(1).optional().describe("The D&D Beyond character ID"),
151
+ character_name: z.string().min(1).optional().describe("Character name (fuzzy matched against your account)"),
152
+ name: z.string().min(1).describe("Name to search for — partial match, e.g. 'hunter' finds Hunter's Mark"),
153
+ }, async ({ character_id, character_name, name }) => {
154
+ try {
155
+ let resolvedId = character_id;
156
+ if (!resolvedId) {
157
+ if (!character_name) {
158
+ return { content: [{ type: "text", text: "Either character_id or character_name must be provided." }], isError: true };
159
+ }
160
+ const found = await findCharacterByName(character_name);
161
+ if (!found) {
162
+ return { content: [{ type: "text", text: `No character found matching "${character_name}". Try ddb_list_characters to see your characters.` }], isError: true };
163
+ }
164
+ resolvedId = found.id;
165
+ }
166
+ const result = await getDefinition(resolvedId, name);
167
+ return { content: [{ type: "text", text: result }] };
168
+ }
169
+ catch (err) {
170
+ const msg = err instanceof Error ? err.message : String(err);
171
+ process.stderr.write(`[ddb-mcp] ddb_character_lookup error: ${msg}\n`);
172
+ return { content: [{ type: "text", text: `Definition lookup failed: ${msg}` }], isError: true };
173
+ }
174
+ });
175
+ // ─── ddb_search_monsters ──────────────────────────────────────────────────────
176
+ server.tool("ddb_search_monsters", "Search the D&D Beyond monster compendium by name, CR, type, or size. Returns a summary list. Use ddb_get_monster for the full stat block. Requires login.", {
177
+ name: z.string().optional().describe("Partial name to search for (e.g. 'goblin', 'dragon')"),
178
+ cr: z.number().optional().describe("Challenge Rating filter (e.g. 0.25, 1, 5, 20)"),
179
+ type: z.string().optional().describe("Monster type filter (e.g. 'undead', 'fiend', 'beast')"),
180
+ size: z.string().optional().describe("Size filter (e.g. 'large', 'tiny')"),
181
+ }, async ({ name, cr, type, size }) => {
182
+ try {
183
+ const result = await searchMonsters({ name, cr, type, size });
184
+ return { content: [{ type: "text", text: result }] };
185
+ }
186
+ catch (err) {
187
+ const msg = err instanceof Error ? err.message : String(err);
188
+ process.stderr.write(`[ddb-mcp] ddb_search_monsters error: ${msg}\n`);
189
+ return { content: [{ type: "text", text: `Monster search failed: ${msg}` }], isError: true };
190
+ }
191
+ });
192
+ // ─── ddb_get_monster ──────────────────────────────────────────────────────────
193
+ server.tool("ddb_get_monster", "Get the full stat block for a specific monster from the D&D Beyond compendium. Searches by name (partial match). Requires login.", {
194
+ name: z.string().min(1).describe("Monster name (e.g. 'Beholder', 'Adult Red Dragon')"),
195
+ }, async ({ name }) => {
196
+ try {
197
+ const result = await getMonster(name);
198
+ return { content: [{ type: "text", text: result }] };
199
+ }
200
+ catch (err) {
201
+ const msg = err instanceof Error ? err.message : String(err);
202
+ process.stderr.write(`[ddb-mcp] ddb_get_monster error: ${msg}\n`);
203
+ return { content: [{ type: "text", text: `Monster lookup failed: ${msg}` }], isError: true };
204
+ }
205
+ });
206
+ // ─── ddb_get_campaign ─────────────────────────────────────────────────────────
207
+ server.tool("ddb_get_campaign", "Fetch campaign information including player characters, notes, and description from a D&D Beyond campaign page.", {
208
+ campaign_id: z.string().min(1).describe("The D&D Beyond campaign ID (found in the campaign URL)"),
209
+ }, async ({ campaign_id }) => {
210
+ try {
211
+ const context = await getSharedContext();
212
+ const data = await getCampaign(context, campaign_id);
213
+ await closeBrowser();
214
+ return { content: [{ type: "text", text: data }] };
215
+ }
216
+ catch (err) {
217
+ await closeBrowser().catch(() => { });
218
+ const msg = err instanceof Error ? err.message : String(err);
219
+ process.stderr.write(`[ddb-mcp] ddb_get_campaign error: ${msg}\n`);
220
+ return { content: [{ type: "text", text: `Failed to get campaign: ${msg}` }], isError: true };
221
+ }
222
+ });
223
+ // ─── ddb_list_campaigns ───────────────────────────────────────────────────────
224
+ server.tool("ddb_list_campaigns", "List all D&D Beyond campaigns you are part of (as DM or player).", {}, async () => {
225
+ try {
226
+ const context = await getSharedContext();
227
+ const data = await listMyCampaigns(context);
228
+ await closeBrowser();
229
+ return { content: [{ type: "text", text: data }] };
230
+ }
231
+ catch (err) {
232
+ await closeBrowser().catch(() => { });
233
+ const msg = err instanceof Error ? err.message : String(err);
234
+ process.stderr.write(`[ddb-mcp] ddb_list_campaigns error: ${msg}\n`);
235
+ return { content: [{ type: "text", text: `Failed to list campaigns: ${msg}` }], isError: true };
236
+ }
237
+ });
238
+ // ─── ddb_navigate ─────────────────────────────────────────────────────────────
239
+ server.tool("ddb_navigate", "Navigate to any D&D Beyond URL and return the page's text content. Only dndbeyond.com URLs are allowed. The browser stays open after this call for follow-up ddb_interact or ddb_get_page calls. Call ddb_close_browser when finished.", {
240
+ url: z
241
+ .string()
242
+ .min(1)
243
+ .describe("Full D&D Beyond URL to navigate to (must start with https://www.dndbeyond.com/)"),
244
+ }, async ({ url }) => {
245
+ try {
246
+ const context = await getSharedContext();
247
+ const content = await navigate(context, url);
248
+ return { content: [{ type: "text", text: content }] };
249
+ }
250
+ catch (err) {
251
+ const msg = err instanceof Error ? err.message : String(err);
252
+ process.stderr.write(`[ddb-mcp] ddb_navigate error: ${msg}\n`);
253
+ return { content: [{ type: "text", text: `Navigation failed: ${msg}` }], isError: true };
254
+ }
255
+ });
256
+ // ─── ddb_interact ─────────────────────────────────────────────────────────────
257
+ server.tool("ddb_interact", "Interact with the currently loaded D&D Beyond page by clicking, filling a form field, or taking a screenshot. The 'fill' action requires confirm_fill: true — this is a safety gate to prevent prompt injection on rendered DDB pages from triggering unintended form submissions.", {
258
+ action: z
259
+ .enum(["click", "fill", "screenshot"])
260
+ .describe("The action to perform: click an element, fill a text field, or take a screenshot"),
261
+ selector: z.string().min(1).describe("CSS or text selector for the target element. Prefer specific selectors to avoid matching hidden elements — e.g. 'button:visible:has-text(\"Spells\")' or '[role=\"tab\"]:has-text(\"Spells\")' rather than 'text=Spells', which may match hidden nav dropdowns on DnD Beyond pages. Hardcoded DnD Beyond class names are unreliable (CSS module hashes change)."),
262
+ value: z
263
+ .string()
264
+ .optional()
265
+ .describe("Value to type into the field (required for 'fill' action)"),
266
+ confirm_fill: z
267
+ .literal(true)
268
+ .optional()
269
+ .describe("Must be true when action is 'fill'. Verify the selector and value are correct before setting this."),
270
+ }, async ({ action, selector, value, confirm_fill }) => {
271
+ if (action === "fill" && confirm_fill !== true) {
272
+ return {
273
+ content: [{ type: "text", text: "confirm_fill: true is required for fill actions — verify the selector and value are correct before proceeding." }],
274
+ isError: true,
275
+ };
276
+ }
277
+ try {
278
+ const context = await getSharedContext();
279
+ const result = await interact(context, action, selector, value);
280
+ return { content: [{ type: "text", text: result }] };
281
+ }
282
+ catch (err) {
283
+ const msg = err instanceof Error ? err.message : String(err);
284
+ process.stderr.write(`[ddb-mcp] ddb_interact error: ${msg}\n`);
285
+ return { content: [{ type: "text", text: `Interaction failed: ${msg}` }], isError: true };
286
+ }
287
+ });
288
+ // ─── ddb_get_page ─────────────────────────────────────────────────────────────
289
+ server.tool("ddb_get_page", "Return the text content of the currently loaded page in the browser. The browser stays open — call ddb_close_browser when finished.", {}, async () => {
290
+ try {
291
+ const context = await getSharedContext();
292
+ const content = await getCurrentPageContent(context);
293
+ return { content: [{ type: "text", text: content }] };
294
+ }
295
+ catch (err) {
296
+ const msg = err instanceof Error ? err.message : String(err);
297
+ process.stderr.write(`[ddb-mcp] ddb_get_page error: ${msg}\n`);
298
+ return { content: [{ type: "text", text: `Failed to get page content: ${msg}` }], isError: true };
299
+ }
300
+ });
301
+ // ─── ddb_search_site ──────────────────────────────────────────────────────────
302
+ server.tool("ddb_search_site", "Search D&D Beyond for spells, monsters, magic items, races, classes, or feats.", {
303
+ query: z.string().min(1).describe("The search query (e.g. 'Fireball', 'Beholder', 'Vorpal Sword')"),
304
+ category: z
305
+ .enum(["spells", "monsters", "items", "races", "classes", "feats", "all"])
306
+ .optional()
307
+ .describe("Category to search within (defaults to 'all')"),
308
+ }, async ({ query, category }) => {
309
+ try {
310
+ const context = await getSharedContext();
311
+ const results = await search(context, query, category ?? "all");
312
+ await closeBrowser();
313
+ return { content: [{ type: "text", text: results }] };
314
+ }
315
+ catch (err) {
316
+ await closeBrowser().catch(() => { });
317
+ const msg = err instanceof Error ? err.message : String(err);
318
+ process.stderr.write(`[ddb-mcp] ddb_search_site error: ${msg}\n`);
319
+ return { content: [{ type: "text", text: `Search failed: ${msg}` }], isError: true };
320
+ }
321
+ });
322
+ // ─── ddb_list_library ─────────────────────────────────────────────────────────
323
+ server.tool("ddb_list_library", "List all books and sourcebooks you own in your D&D Beyond library.", {}, async () => {
324
+ try {
325
+ const context = await getSharedContext();
326
+ const books = await listLibrary(context);
327
+ await closeBrowser();
328
+ return { content: [{ type: "text", text: books }] };
329
+ }
330
+ catch (err) {
331
+ await closeBrowser().catch(() => { });
332
+ const msg = err instanceof Error ? err.message : String(err);
333
+ process.stderr.write(`[ddb-mcp] ddb_list_library error: ${msg}\n`);
334
+ return { content: [{ type: "text", text: `Failed to list library: ${msg}` }], isError: true };
335
+ }
336
+ });
337
+ // ─── ddb_read_book ────────────────────────────────────────────────────────────
338
+ server.tool("ddb_read_book", "Read a D&D Beyond book. Specify chapter_slug for a chapter, query to jump to a heading, and max_chars to control response size.", {
339
+ book_slug: z
340
+ .string()
341
+ .min(1)
342
+ .regex(/^[a-z0-9][a-z0-9\-\/]*$/, "book_slug may only contain lowercase letters, digits, hyphens, and forward slashes")
343
+ .describe("The book slug from the D&D Beyond URL (e.g. 'players-handbook', 'dungeon-masters-guide')"),
344
+ chapter_slug: z
345
+ .string()
346
+ .regex(/^[a-z0-9][a-z0-9\-\/]*$/, "chapter_slug may only contain lowercase letters, digits, hyphens, and forward slashes")
347
+ .optional()
348
+ .describe("Optional chapter or section slug (e.g. 'classes/ranger'). If omitted, returns the book's table of contents."),
349
+ max_chars: z.number().int().min(500).max(8000).default(3000)
350
+ .describe("Max characters to return (default 3000). Increase for deeper reading."),
351
+ query: z.string().optional()
352
+ .describe("Jump to the first heading containing this text (e.g. 'spell slots', 'wild shape')."),
353
+ }, async ({ book_slug, chapter_slug, max_chars, query }) => {
354
+ try {
355
+ const context = await getSharedContext();
356
+ const content = await readBook(context, book_slug, chapter_slug, max_chars, query);
357
+ await closeBrowser();
358
+ return { content: [{ type: "text", text: content }] };
359
+ }
360
+ catch (err) {
361
+ await closeBrowser().catch(() => { });
362
+ const msg = err instanceof Error ? err.message : String(err);
363
+ process.stderr.write(`[ddb-mcp] ddb_read_book error: ${msg}\n`);
364
+ return { content: [{ type: "text", text: `Failed to read book: ${msg}` }], isError: true };
365
+ }
366
+ });
367
+ // ─── Reference Tools ──────────────────────────────────────────────────────────
368
+ server.tool("ddb_get_condition", "Look up the rules text for a D&D condition (Blinded, Charmed, Frightened, Grappled, etc.). No login required.", {
369
+ name: z.string().min(1).describe("Condition name (e.g. 'frightened', 'grappled')"),
370
+ }, async ({ name }) => {
371
+ const result = getCondition(name);
372
+ return { content: [{ type: "text", text: result }] };
373
+ });
374
+ server.tool("ddb_search_spells", "Search the full D&D Beyond spell compendium by name, level, school, concentration, or ritual. First call builds the compendium (slow); subsequent calls are instant. Requires login.", {
375
+ name: z.string().optional().describe("Partial spell name (e.g. 'fire' finds Fireball, Fire Storm, etc.)"),
376
+ level: z.number().int().min(0).max(9).optional().describe("Spell level (0 = cantrip)"),
377
+ school: z.string().optional().describe("School of magic (e.g. 'evocation', 'illusion')"),
378
+ concentration: z.boolean().optional().describe("Filter by concentration requirement"),
379
+ ritual: z.boolean().optional().describe("Filter by ritual tag"),
380
+ limit: z.number().int().min(1).max(100).default(20).describe("Max results to return (default 20)"),
381
+ offset: z.number().int().min(0).default(0).describe("Skip N results for pagination"),
382
+ }, async ({ name, level, school, concentration, ritual, limit, offset }) => {
383
+ try {
384
+ const result = await searchSpells({ name, level, school, concentration, ritual, limit, offset });
385
+ return { content: [{ type: "text", text: result }] };
386
+ }
387
+ catch (err) {
388
+ const msg = err instanceof Error ? err.message : String(err);
389
+ process.stderr.write(`[ddb-mcp] ddb_search_spells error: ${msg}\n`);
390
+ return { content: [{ type: "text", text: `Spell search failed: ${msg}` }], isError: true };
391
+ }
392
+ });
393
+ server.tool("ddb_get_spell", "Get the full description of any spell in the D&D Beyond compendium by name. Not limited to a character's known spells. Requires login.", {
394
+ name: z.string().min(1).describe("Spell name (e.g. 'Fireball', 'Hunter\\'s Mark')"),
395
+ }, async ({ name }) => {
396
+ try {
397
+ const result = await getSpell(name);
398
+ return { content: [{ type: "text", text: result }] };
399
+ }
400
+ catch (err) {
401
+ const msg = err instanceof Error ? err.message : String(err);
402
+ process.stderr.write(`[ddb-mcp] ddb_get_spell error: ${msg}\n`);
403
+ return { content: [{ type: "text", text: `Spell lookup failed: ${msg}` }], isError: true };
404
+ }
405
+ });
406
+ // ─── ddb_search_equipment ────────────────────────────────────────────────────
407
+ server.tool("ddb_search_equipment", "Search the D&D Beyond item/equipment compendium by name, rarity, or type. Covers mundane weapons (Longsword, Shortbow), armour (Plate, Chain Mail), adventuring gear, and magic items. Filter by rarity='common' to see only mundane equipment. Requires login.", {
408
+ name: z.string().optional().describe("Partial item name (e.g. 'sword', 'cloak')"),
409
+ rarity: z.string().optional().describe("Rarity filter (e.g. 'rare', 'legendary', 'uncommon')"),
410
+ type: z.string().optional().describe("Item type filter (e.g. 'weapon', 'armor', 'wondrous')"),
411
+ }, async ({ name, rarity, type }) => {
412
+ try {
413
+ const result = await searchItems({ name, rarity, type });
414
+ return { content: [{ type: "text", text: result }] };
415
+ }
416
+ catch (err) {
417
+ const msg = err instanceof Error ? err.message : String(err);
418
+ process.stderr.write(`[ddb-mcp] ddb_search_equipment error: ${msg}\n`);
419
+ return { content: [{ type: "text", text: `Item search failed: ${msg}` }], isError: true };
420
+ }
421
+ });
422
+ // ─── ddb_get_equipment ───────────────────────────────────────────────────────
423
+ server.tool("ddb_get_equipment", "Get the full stats and description of any item or equipment in the D&D Beyond compendium by name. Works for mundane weapons (e.g. 'Longbow'), armour (e.g. 'Plate'), adventuring gear, and magic items. Requires login.", {
424
+ name: z.string().min(1).describe("Item name (e.g. 'Bag of Holding', 'Flame Tongue')"),
425
+ }, async ({ name }) => {
426
+ try {
427
+ const result = await getItem(name);
428
+ return { content: [{ type: "text", text: result }] };
429
+ }
430
+ catch (err) {
431
+ const msg = err instanceof Error ? err.message : String(err);
432
+ process.stderr.write(`[ddb-mcp] ddb_get_equipment error: ${msg}\n`);
433
+ return { content: [{ type: "text", text: `Item lookup failed: ${msg}` }], isError: true };
434
+ }
435
+ });
436
+ server.tool("ddb_search_races", "Search all D&D Beyond races and subraces (including homebrew). Not character-specific.", {
437
+ name: z.string().optional().describe("Partial race name (e.g. 'elf', 'tiefling')"),
438
+ limit: z.number().int().min(1).max(100).default(30).describe("Max results (default 30)"),
439
+ offset: z.number().int().min(0).default(0).describe("Skip N results for pagination"),
440
+ }, async ({ name, limit, offset }) => {
441
+ try {
442
+ const result = await searchRaces(name, limit, offset);
443
+ return { content: [{ type: "text", text: result }] };
444
+ }
445
+ catch (err) {
446
+ const msg = err instanceof Error ? err.message : String(err);
447
+ process.stderr.write(`[ddb-mcp] ddb_search_races error: ${msg}\n`);
448
+ return { content: [{ type: "text", text: `Race search failed: ${msg}` }], isError: true };
449
+ }
450
+ });
451
+ server.tool("ddb_search_classes", "Search all D&D Beyond classes with hit die, spellcasting, and subclasses.", {
452
+ name: z.string().optional().describe("Partial class name (e.g. 'fighter', 'wizard')"),
453
+ limit: z.number().int().min(1).max(100).default(30).describe("Max results (default 30)"),
454
+ offset: z.number().int().min(0).default(0).describe("Skip N results for pagination"),
455
+ }, async ({ name, limit, offset }) => {
456
+ try {
457
+ const result = await searchClasses(name, limit, offset);
458
+ return { content: [{ type: "text", text: result }] };
459
+ }
460
+ catch (err) {
461
+ const msg = err instanceof Error ? err.message : String(err);
462
+ process.stderr.write(`[ddb-mcp] ddb_search_classes error: ${msg}\n`);
463
+ return { content: [{ type: "text", text: `Class search failed: ${msg}` }], isError: true };
464
+ }
465
+ });
466
+ server.tool("ddb_search_backgrounds", "Search all D&D Beyond backgrounds (including homebrew).", {
467
+ name: z.string().optional().describe("Partial background name (e.g. 'sage', 'criminal')"),
468
+ limit: z.number().int().min(1).max(100).default(30).describe("Max results (default 30)"),
469
+ offset: z.number().int().min(0).default(0).describe("Skip N results for pagination"),
470
+ }, async ({ name, limit, offset }) => {
471
+ try {
472
+ const result = await searchBackgrounds(name, limit, offset);
473
+ return { content: [{ type: "text", text: result }] };
474
+ }
475
+ catch (err) {
476
+ const msg = err instanceof Error ? err.message : String(err);
477
+ process.stderr.write(`[ddb-mcp] ddb_search_backgrounds error: ${msg}\n`);
478
+ return { content: [{ type: "text", text: `Background search failed: ${msg}` }], isError: true };
479
+ }
480
+ });
481
+ server.tool("ddb_search_feats", "Search feats by name or prerequisite text.", {
482
+ name: z.string().optional().describe("Partial feat name (e.g. 'sharpshooter', 'magic')"),
483
+ prerequisite: z.string().optional().describe("Filter by prerequisite text (e.g. 'spellcaster', 'level 4')"),
484
+ limit: z.number().int().min(1).max(100).default(30).describe("Max results (default 30)"),
485
+ offset: z.number().int().min(0).default(0).describe("Skip N results for pagination"),
486
+ }, async ({ name, prerequisite, limit, offset }) => {
487
+ try {
488
+ const result = await searchFeats({ name, prerequisite, limit, offset });
489
+ return { content: [{ type: "text", text: result }] };
490
+ }
491
+ catch (err) {
492
+ const msg = err instanceof Error ? err.message : String(err);
493
+ process.stderr.write(`[ddb-mcp] ddb_search_feats error: ${msg}\n`);
494
+ return { content: [{ type: "text", text: `Feat search failed: ${msg}` }], isError: true };
495
+ }
496
+ });
497
+ server.tool("ddb_search_class_features", "Search class features by name, class, or level gained.", {
498
+ name: z.string().optional().describe("Partial feature name (e.g. 'action surge', 'sneak attack')"),
499
+ class_name: z.string().optional().describe("Class name filter (e.g. 'fighter', 'rogue')"),
500
+ level: z.number().int().min(1).max(20).optional().describe("Level at which the feature is gained"),
501
+ limit: z.number().int().min(1).max(100).default(30).describe("Max results (default 30)"),
502
+ offset: z.number().int().min(0).default(0).describe("Skip N results for pagination"),
503
+ }, async ({ name, class_name, level, limit, offset }) => {
504
+ try {
505
+ const result = await searchClassFeatures({ name, className: class_name, level, limit, offset });
506
+ return { content: [{ type: "text", text: result }] };
507
+ }
508
+ catch (err) {
509
+ const msg = err instanceof Error ? err.message : String(err);
510
+ process.stderr.write(`[ddb-mcp] ddb_search_class_features error: ${msg}\n`);
511
+ return { content: [{ type: "text", text: `Class feature search failed: ${msg}` }], isError: true };
512
+ }
513
+ });
514
+ server.tool("ddb_search_racial_traits", "Search racial traits by name or race.", {
515
+ name: z.string().optional().describe("Partial trait name (e.g. 'darkvision', 'breath weapon')"),
516
+ race_name: z.string().optional().describe("Race name filter (e.g. 'elf', 'dragonborn')"),
517
+ limit: z.number().int().min(1).max(100).default(30).describe("Max results (default 30)"),
518
+ offset: z.number().int().min(0).default(0).describe("Skip N results for pagination"),
519
+ }, async ({ name, race_name, limit, offset }) => {
520
+ try {
521
+ const result = await searchRacialTraits({ name, raceName: race_name, limit, offset });
522
+ return { content: [{ type: "text", text: result }] };
523
+ }
524
+ catch (err) {
525
+ const msg = err instanceof Error ? err.message : String(err);
526
+ process.stderr.write(`[ddb-mcp] ddb_search_racial_traits error: ${msg}\n`);
527
+ return { content: [{ type: "text", text: `Racial trait search failed: ${msg}` }], isError: true };
528
+ }
529
+ });
530
+ // ─── ddb_search_rules ─────────────────────────────────────────────────────────
531
+ server.tool("ddb_search_rules", "Search the SRD rules sections by keyword. Searches across section names and full content — e.g. 'grapple' finds the Attacking section (which covers grapple rules) and the Conditions section (which has the Grappled condition). Returns a list of matching sections with their slugs. Use ddb_get_rules to read the full text of a section. No login required.", {
532
+ query: z.string().optional().describe("Keyword to search for (e.g. 'grapple', 'concentration', 'death saving throw'). Omit to list all 45 available sections."),
533
+ }, async ({ query }) => {
534
+ try {
535
+ const result = await searchRules(query);
536
+ return { content: [{ type: "text", text: result }] };
537
+ }
538
+ catch (err) {
539
+ const msg = err instanceof Error ? err.message : String(err);
540
+ process.stderr.write(`[ddb-mcp] ddb_search_rules error: ${msg}\n`);
541
+ return { content: [{ type: "text", text: `Rules search failed: ${msg}` }], isError: true };
542
+ }
543
+ });
544
+ // ─── ddb_get_rules ────────────────────────────────────────────────────────────
545
+ server.tool("ddb_get_rules", "Get the full SRD rules text for a topic by section name or slug. Covers all core 5e rules: Spellcasting, Abilities, Attacking, Combat Sequence, Actions in Combat, Damage and Healing, Conditions, Movement, Multiclassing, Rest, Saving Throws, Environment, Traps, Diseases, Madness, Poisons, Weapons, Armor, and more. Use query to jump to a specific topic within a long section. For non-SRD rules or more detail, use ddb_read_book. No login required.", {
546
+ name: z.string().min(1).describe("Section name or slug (e.g. 'Spellcasting', 'attacking', 'Damage and Healing', 'multiclassing')"),
547
+ max_chars: z.number().int().min(500).max(30000).default(4000).describe("Max characters to return (default 4000). Some sections are 20,000+ chars — increase if you need the full text."),
548
+ query: z.string().optional().describe("Jump to the first occurrence of this keyword within the section (e.g. 'concentration', 'death saving throw')."),
549
+ }, async ({ name, max_chars, query }) => {
550
+ try {
551
+ const result = await getRule(name, max_chars, query);
552
+ return { content: [{ type: "text", text: result }] };
553
+ }
554
+ catch (err) {
555
+ const msg = err instanceof Error ? err.message : String(err);
556
+ process.stderr.write(`[ddb-mcp] ddb_get_rules error: ${msg}\n`);
557
+ return { content: [{ type: "text", text: `Rules lookup failed: ${msg}` }], isError: true };
558
+ }
559
+ });
560
+ // ─── ddb_rate_encounter ───────────────────────────────────────────────────────
561
+ server.tool("ddb_rate_encounter", "Rate the difficulty of a D&D encounter. Defaults to 2024 XDMG rules (Low/Moderate/High, no multiplier). Set rules_edition to '2014' for the classic DMG XP threshold method (Easy/Medium/Hard/Deadly with encounter multiplier). Monsters are looked up in the compendium automatically — supply 'cr' directly for homebrew.", {
562
+ party: z.array(z.object({
563
+ count: z.number().int().min(1).max(20)
564
+ .describe("Number of characters at this level"),
565
+ level: z.number().int().min(1).max(20)
566
+ .describe("Character level"),
567
+ })).min(1).describe("Party composition. Use multiple entries for mixed-level parties — e.g. [{count:3,level:5},{count:1,level:3}]"),
568
+ monsters: z.array(z.object({
569
+ name: z.string().optional()
570
+ .describe("Monster name — looked up in the compendium (DDB with Open5e fallback)"),
571
+ cr: z.number().optional()
572
+ .describe("CR as a decimal — 0.125, 0.25, 0.5, or 1–30. Use for homebrew or instead of name"),
573
+ count: z.number().int().min(1).default(1)
574
+ .describe("Number of this monster (default 1)"),
575
+ })).min(1).describe("Monsters in the encounter. Each entry needs at least a name or cr."),
576
+ rules_edition: z.enum(["2024", "2014"]).default("2024")
577
+ .describe("Rules edition. '2024' (default) uses XDMG XP budgets with Low/Moderate/High difficulty — no encounter multiplier. '2014' uses DMG XP thresholds with Easy/Medium/Hard/Deadly and a monster count multiplier."),
578
+ }, async ({ party, monsters, rules_edition }) => {
579
+ try {
580
+ const result = await rateEncounter(party, monsters, rules_edition);
581
+ return { content: [{ type: "text", text: result }] };
582
+ }
583
+ catch (err) {
584
+ const msg = err instanceof Error ? err.message : String(err);
585
+ process.stderr.write(`[ddb-mcp] ddb_rate_encounter error: ${msg}\n`);
586
+ return { content: [{ type: "text", text: `Encounter rating failed: ${msg}` }], isError: true };
587
+ }
588
+ });
589
+ // ─── ddb_encounter_cr ─────────────────────────────────────────────────────────
590
+ server.tool("ddb_encounter_cr", "Given a party and a target difficulty, returns the CR to aim for — broken down by encounter shape (solo boss, duo, squad, horde). Defaults to 2024 XDMG rules; set rules_edition to '2014' for classic DMG. No monster lookup required. Use with ddb_rate_encounter to verify a specific monster list.", {
591
+ party: z.array(z.object({
592
+ count: z.number().int().min(1).max(20).describe("Number of characters at this level"),
593
+ level: z.number().int().min(1).max(20).describe("Character level"),
594
+ })).min(1).describe("Party composition — same format as ddb_rate_encounter"),
595
+ difficulty: z.enum(["low", "moderate", "high", "easy", "medium", "hard", "deadly"])
596
+ .describe("Target difficulty. Use 'low', 'moderate', 'high' for 2024 rules; 'easy', 'medium', 'hard', 'deadly' for 2014 rules."),
597
+ monster_count: z.number().int().min(1).optional()
598
+ .describe("If provided, shows only this count instead of all four archetypes"),
599
+ rules_edition: z.enum(["2024", "2014"]).default("2024")
600
+ .describe("Rules edition. '2024' (default) uses XDMG XP budgets, no multiplier. '2014' uses DMG XP thresholds with encounter multiplier."),
601
+ }, async ({ party, difficulty, monster_count, rules_edition }) => {
602
+ try {
603
+ const result = await targetEncounterCr(party, difficulty, monster_count, rules_edition);
604
+ return { content: [{ type: "text", text: result }] };
605
+ }
606
+ catch (err) {
607
+ const msg = err instanceof Error ? err.message : String(err);
608
+ process.stderr.write(`[ddb-mcp] ddb_encounter_cr error: ${msg}\n`);
609
+ return { content: [{ type: "text", text: `Encounter CR lookup failed: ${msg}` }], isError: true };
610
+ }
611
+ });
612
+ // ─── ddb_roll_treasure ───────────────────────────────────────────────────────
613
+ server.tool("ddb_roll_treasure", "Generate a treasure reward using the 2024 XDMG treasure tables (default). Provide either a CR directly or a list of monster names — CR is resolved automatically via fuzzy name matching. treasure_type 'hoard' makes one roll using the highest CR and includes magic items (requires character_level); 'individual' rolls once per monster and sums the results.", {
614
+ cr: z.number().min(0).max(30).optional()
615
+ .describe("Challenge rating (0–30). Use instead of monsters for a direct CR lookup."),
616
+ monsters: z.array(z.object({
617
+ name: z.string().describe("Monster name — fuzzy matched, DDB with Open5e fallback"),
618
+ count: z.number().int().min(1).default(1).describe("Number of this monster (default 1)"),
619
+ })).optional()
620
+ .describe("Monsters from the encounter. Highest CR determines the treasure tier for hoards."),
621
+ treasure_type: z.enum(["individual", "hoard"]).default("hoard")
622
+ .describe("individual = one roll per monster. hoard = one roll using the highest CR."),
623
+ character_level: z.number().int().min(1).max(20).optional()
624
+ .describe("Character level (1–20). Required for hoard treasure to determine which magic item table to use. If omitted, magic items are skipped."),
625
+ }, async ({ cr, monsters, treasure_type, character_level }) => {
626
+ try {
627
+ const result = await generateTreasure({ cr, monsters, treasureType: treasure_type, characterLevel: character_level });
628
+ return { content: [{ type: "text", text: result }] };
629
+ }
630
+ catch (err) {
631
+ const msg = err instanceof Error ? err.message : String(err);
632
+ process.stderr.write(`[ddb-mcp] ddb_roll_treasure error: ${msg}\n`);
633
+ return { content: [{ type: "text", text: `Treasure generation failed: ${msg}` }], isError: true };
634
+ }
635
+ });
636
+ // ─── Graceful shutdown ────────────────────────────────────────────────────────
637
+ async function shutdown(signal) {
638
+ process.stderr.write(`[ddb-mcp] Received ${signal}, shutting down...\n`);
639
+ await closeBrowser().catch(() => { });
640
+ process.exit(0);
641
+ }
642
+ process.on("SIGTERM", () => { void shutdown("SIGTERM"); });
643
+ process.on("SIGINT", () => { void shutdown("SIGINT"); });
644
+ // ─── Start server ─────────────────────────────────────────────────────────────
645
+ async function main() {
646
+ const transport = new StdioServerTransport();
647
+ await server.connect(transport);
648
+ process.stderr.write("D&D Beyond MCP server running on stdio\n");
649
+ }
650
+ main().catch((err) => {
651
+ process.stderr.write(`Fatal error: ${err}\n`);
652
+ process.exit(1);
653
+ });
654
+ //# sourceMappingURL=index.js.map