unofficial-ravensburger-playhub-mcp 1.0.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.
- package/LICENSE +24 -0
- package/README.md +215 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +30 -0
- package/dist/lib/api.d.ts +23 -0
- package/dist/lib/api.js +171 -0
- package/dist/lib/formatters.d.ts +8 -0
- package/dist/lib/formatters.js +134 -0
- package/dist/lib/types.d.ts +132 -0
- package/dist/lib/types.js +4 -0
- package/dist/mcp-tools.integration.test.d.ts +8 -0
- package/dist/mcp-tools.integration.test.js +186 -0
- package/dist/test/api.test.d.ts +1 -0
- package/dist/test/api.test.js +209 -0
- package/dist/test/formatters.test.d.ts +1 -0
- package/dist/test/formatters.test.js +230 -0
- package/dist/test/mcp-tools.integration.test.d.ts +8 -0
- package/dist/test/mcp-tools.integration.test.js +264 -0
- package/dist/test/registrations.test.d.ts +1 -0
- package/dist/test/registrations.test.js +37 -0
- package/dist/test/standings.test.d.ts +1 -0
- package/dist/test/standings.test.js +43 -0
- package/dist/tools/events.d.ts +5 -0
- package/dist/tools/events.js +326 -0
- package/dist/tools/filters.d.ts +5 -0
- package/dist/tools/filters.js +87 -0
- package/dist/tools/stores.d.ts +5 -0
- package/dist/tools/stores.js +142 -0
- package/package.json +48 -0
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP tools for event search, details, registrations, and tournament standings.
|
|
3
|
+
*/
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { fetchEventDetails, fetchEventRegistrations, fetchEvents, fetchTournamentRoundStandings, resolveCategoryIds, resolveFormatIds, STATUSES, } from "../lib/api.js";
|
|
6
|
+
import { formatEvent, formatRegistrationEntry, formatStandingEntry, } from "../lib/formatters.js";
|
|
7
|
+
export function registerEventTools(server) {
|
|
8
|
+
// Tool: Search Events
|
|
9
|
+
server.registerTool("search_events", {
|
|
10
|
+
description: "Search for Disney Lorcana TCG events near a location by latitude/longitude. Use this when you have coordinates (e.g. from a map or device). For city names like 'Seattle' or 'Austin, TX', use search_events_by_city instead. Optional: call list_filters first to get format/category names for the formats and categories parameters.",
|
|
11
|
+
inputSchema: {
|
|
12
|
+
latitude: z.number().describe("Latitude of the search center (e.g. 42.33)"),
|
|
13
|
+
longitude: z.number().describe("Longitude of the search center (e.g. -83.05)"),
|
|
14
|
+
radius_miles: z.number().default(25).describe("Search radius in miles (default: 25)"),
|
|
15
|
+
start_date: z.string().optional().describe("Only show events starting after this date (YYYY-MM-DD)"),
|
|
16
|
+
formats: z.array(z.string()).optional().describe("Filter by format names; get exact names from list_filters (e.g. ['Constructed'])"),
|
|
17
|
+
categories: z.array(z.string()).optional().describe("Filter by category names; get exact names from list_filters"),
|
|
18
|
+
statuses: z.array(z.enum(STATUSES)).default(["upcoming", "inProgress"]).describe("Include: upcoming, inProgress (live), past"),
|
|
19
|
+
featured_only: z.boolean().default(false).describe("If true, only featured/headlining events"),
|
|
20
|
+
text_search: z.string().optional().describe("Search event names by keyword"),
|
|
21
|
+
store_id: z.number().optional().describe("Limit to events at this store (ID from search_stores)"),
|
|
22
|
+
page: z.number().default(1).describe("Page number (default: 1)"),
|
|
23
|
+
page_size: z.number().default(25).describe("Results per page, max 100 (default: 25)"),
|
|
24
|
+
},
|
|
25
|
+
}, async (args) => {
|
|
26
|
+
const params = {
|
|
27
|
+
game_slug: "disney-lorcana",
|
|
28
|
+
latitude: args.latitude.toString(),
|
|
29
|
+
longitude: args.longitude.toString(),
|
|
30
|
+
num_miles: args.radius_miles.toString(),
|
|
31
|
+
page: args.page.toString(),
|
|
32
|
+
page_size: Math.min(args.page_size, 100).toString(),
|
|
33
|
+
};
|
|
34
|
+
params.display_statuses = args.statuses;
|
|
35
|
+
if (args.start_date) {
|
|
36
|
+
params.start_date_after = new Date(args.start_date).toISOString();
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
params.start_date_after = new Date().toISOString();
|
|
40
|
+
}
|
|
41
|
+
if (args.formats && args.formats.length > 0) {
|
|
42
|
+
const formatIds = resolveFormatIds(args.formats);
|
|
43
|
+
if (formatIds.length > 0) {
|
|
44
|
+
params.gameplay_format_id = formatIds;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (args.categories && args.categories.length > 0) {
|
|
48
|
+
const categoryIds = resolveCategoryIds(args.categories);
|
|
49
|
+
if (categoryIds.length > 0) {
|
|
50
|
+
params.event_configuration_template_id = categoryIds;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (args.featured_only) {
|
|
54
|
+
params.is_headlining_event = "true";
|
|
55
|
+
}
|
|
56
|
+
if (args.text_search) {
|
|
57
|
+
params.name = args.text_search;
|
|
58
|
+
}
|
|
59
|
+
if (args.store_id) {
|
|
60
|
+
params.store = args.store_id.toString();
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
const response = await fetchEvents(params);
|
|
64
|
+
if (response.results.length === 0) {
|
|
65
|
+
return {
|
|
66
|
+
content: [
|
|
67
|
+
{
|
|
68
|
+
type: "text",
|
|
69
|
+
text: "No events found matching your criteria. Try expanding your search radius or adjusting filters.",
|
|
70
|
+
},
|
|
71
|
+
],
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
const formattedEvents = response.results.map(formatEvent).join("\n\n---\n\n");
|
|
75
|
+
const summary = `Found ${response.count} event(s). Showing ${response.results.length} (page ${args.page} of ${Math.ceil(response.count / args.page_size)}).`;
|
|
76
|
+
return {
|
|
77
|
+
content: [
|
|
78
|
+
{
|
|
79
|
+
type: "text",
|
|
80
|
+
text: `${summary}\n\n${formattedEvents}`,
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
return {
|
|
87
|
+
content: [
|
|
88
|
+
{
|
|
89
|
+
type: "text",
|
|
90
|
+
text: `Error searching events: ${error instanceof Error ? error.message : String(error)}`,
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
isError: true,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
// Tool: Get Event Details
|
|
98
|
+
server.registerTool("get_event_details", {
|
|
99
|
+
description: "Get full details for one Disney Lorcana event by ID. Use after search_events or search_events_by_city when the user asks for more info about a specific event, or when you have an event ID (e.g. from a previous search).",
|
|
100
|
+
inputSchema: {
|
|
101
|
+
event_id: z.number().describe("Event ID (e.g. from search results)"),
|
|
102
|
+
},
|
|
103
|
+
}, async (args) => {
|
|
104
|
+
try {
|
|
105
|
+
const event = await fetchEventDetails(args.event_id);
|
|
106
|
+
return {
|
|
107
|
+
content: [
|
|
108
|
+
{
|
|
109
|
+
type: "text",
|
|
110
|
+
text: formatEvent(event),
|
|
111
|
+
},
|
|
112
|
+
],
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
return {
|
|
117
|
+
content: [
|
|
118
|
+
{
|
|
119
|
+
type: "text",
|
|
120
|
+
text: `Error fetching event details: ${error instanceof Error ? error.message : String(error)}`,
|
|
121
|
+
},
|
|
122
|
+
],
|
|
123
|
+
isError: true,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
// Tool: Get Tournament Round Standings
|
|
128
|
+
server.registerTool("get_tournament_round_standings", {
|
|
129
|
+
description: "Get standings (leaderboard) for a tournament round. Use when the user asks who is winning, standings, or results for a round. You need the round ID (sometimes in event context or from get_event_details).",
|
|
130
|
+
inputSchema: {
|
|
131
|
+
round_id: z.number().describe("Tournament round ID (e.g. 414976)"),
|
|
132
|
+
page: z.number().default(1).describe("Page number (default: 1)"),
|
|
133
|
+
page_size: z.number().default(25).describe("Results per page (default: 25)"),
|
|
134
|
+
},
|
|
135
|
+
}, async (args) => {
|
|
136
|
+
try {
|
|
137
|
+
const response = await fetchTournamentRoundStandings(args.round_id, args.page, args.page_size);
|
|
138
|
+
if (response.results.length === 0) {
|
|
139
|
+
return {
|
|
140
|
+
content: [
|
|
141
|
+
{
|
|
142
|
+
type: "text",
|
|
143
|
+
text: `No standings found for round ${args.round_id}. The round may not exist or may not have standings yet.`,
|
|
144
|
+
},
|
|
145
|
+
],
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
const formatted = response.results.map((e, i) => formatStandingEntry(e, (args.page - 1) * args.page_size + i)).join("\n\n");
|
|
149
|
+
const summary = `Round ${args.round_id} standings: ${response.count} shown (page ${args.page} of ${Math.ceil(response.total / args.page_size)}). Total: ${response.total}.\n\n${formatted}`;
|
|
150
|
+
return {
|
|
151
|
+
content: [
|
|
152
|
+
{
|
|
153
|
+
type: "text",
|
|
154
|
+
text: summary,
|
|
155
|
+
},
|
|
156
|
+
],
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
return {
|
|
161
|
+
content: [
|
|
162
|
+
{
|
|
163
|
+
type: "text",
|
|
164
|
+
text: `Error fetching round standings: ${error instanceof Error ? error.message : String(error)}`,
|
|
165
|
+
},
|
|
166
|
+
],
|
|
167
|
+
isError: true,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
// Tool: Get Event Registrations
|
|
172
|
+
server.registerTool("get_event_registrations", {
|
|
173
|
+
description: "Get the list of players registered for an event. Use when the user asks who is signed up, the registration list, or how many spots are taken. You need the event ID (from search_events, search_events_by_city, or get_event_details).",
|
|
174
|
+
inputSchema: {
|
|
175
|
+
event_id: z.number().describe("Event ID (e.g. from search or get_event_details)"),
|
|
176
|
+
page: z.number().default(1).describe("Page number (default: 1)"),
|
|
177
|
+
page_size: z.number().default(25).describe("Results per page (default: 25)"),
|
|
178
|
+
},
|
|
179
|
+
}, async (args) => {
|
|
180
|
+
try {
|
|
181
|
+
const response = await fetchEventRegistrations(args.event_id, args.page, args.page_size);
|
|
182
|
+
if (response.results.length === 0) {
|
|
183
|
+
return {
|
|
184
|
+
content: [
|
|
185
|
+
{
|
|
186
|
+
type: "text",
|
|
187
|
+
text: `No registrations found for event ${args.event_id}. The event may not exist or may not have registrations yet.`,
|
|
188
|
+
},
|
|
189
|
+
],
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
const formatted = response.results
|
|
193
|
+
.map((e, i) => formatRegistrationEntry(e, (args.page - 1) * args.page_size + i))
|
|
194
|
+
.join("\n\n");
|
|
195
|
+
const summary = `Event ${args.event_id} registrations: ${response.count} shown (page ${args.page} of ${Math.ceil(response.total / args.page_size)}). Total: ${response.total}.\n\n${formatted}`;
|
|
196
|
+
return {
|
|
197
|
+
content: [
|
|
198
|
+
{
|
|
199
|
+
type: "text",
|
|
200
|
+
text: summary,
|
|
201
|
+
},
|
|
202
|
+
],
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
catch (error) {
|
|
206
|
+
return {
|
|
207
|
+
content: [
|
|
208
|
+
{
|
|
209
|
+
type: "text",
|
|
210
|
+
text: `Error fetching event registrations: ${error instanceof Error ? error.message : String(error)}`,
|
|
211
|
+
},
|
|
212
|
+
],
|
|
213
|
+
isError: true,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
// Tool: Search by City Name
|
|
218
|
+
server.registerTool("search_events_by_city", {
|
|
219
|
+
description: "Search for Disney Lorcana TCG events by city name (geocoded). Use this when the user says a city, e.g. 'events in Seattle' or 'Austin, TX'. For coordinates use search_events instead. Optional: call list_filters for format/category names.",
|
|
220
|
+
inputSchema: {
|
|
221
|
+
city: z.string().describe("City name, ideally with state/country (e.g. 'Detroit, MI' or 'New York, NY')"),
|
|
222
|
+
radius_miles: z.number().default(25).describe("Search radius in miles (default: 25)"),
|
|
223
|
+
start_date: z.string().optional().describe("Only events starting after this date (YYYY-MM-DD)"),
|
|
224
|
+
formats: z.array(z.string()).optional().describe("Filter by format names from list_filters (e.g. ['Constructed'])"),
|
|
225
|
+
categories: z.array(z.string()).optional().describe("Filter by category names from list_filters"),
|
|
226
|
+
statuses: z.array(z.enum(STATUSES)).default(["upcoming", "inProgress"]).describe("Include: upcoming, inProgress, past"),
|
|
227
|
+
featured_only: z.boolean().default(false).describe("If true, only featured events"),
|
|
228
|
+
text_search: z.string().optional().describe("Search event names by keyword"),
|
|
229
|
+
store_id: z.number().optional().describe("Limit to events at this store (ID from search_stores)"),
|
|
230
|
+
page: z.number().default(1).describe("Page number (default: 1)"),
|
|
231
|
+
page_size: z.number().default(25).describe("Results per page, max 100 (default: 25)"),
|
|
232
|
+
},
|
|
233
|
+
}, async (args) => {
|
|
234
|
+
const geocodeUrl = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(args.city)}&format=json&limit=1`;
|
|
235
|
+
try {
|
|
236
|
+
const geoResponse = await fetch(geocodeUrl, {
|
|
237
|
+
headers: {
|
|
238
|
+
"User-Agent": "lorcana-event-finder/1.0",
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
if (!geoResponse.ok) {
|
|
242
|
+
throw new Error("Geocoding failed");
|
|
243
|
+
}
|
|
244
|
+
const geoData = (await geoResponse.json());
|
|
245
|
+
if (!geoData || geoData.length === 0) {
|
|
246
|
+
return {
|
|
247
|
+
content: [
|
|
248
|
+
{
|
|
249
|
+
type: "text",
|
|
250
|
+
text: `Could not find location: ${args.city}. Try being more specific (e.g., "Detroit, MI, USA").`,
|
|
251
|
+
},
|
|
252
|
+
],
|
|
253
|
+
isError: true,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
const location = geoData[0];
|
|
257
|
+
const latitude = parseFloat(location.lat);
|
|
258
|
+
const longitude = parseFloat(location.lon);
|
|
259
|
+
const params = {
|
|
260
|
+
game_slug: "disney-lorcana",
|
|
261
|
+
latitude: latitude.toString(),
|
|
262
|
+
longitude: longitude.toString(),
|
|
263
|
+
num_miles: args.radius_miles.toString(),
|
|
264
|
+
display_statuses: args.statuses,
|
|
265
|
+
page: args.page.toString(),
|
|
266
|
+
page_size: Math.min(args.page_size, 100).toString(),
|
|
267
|
+
start_date_after: args.start_date
|
|
268
|
+
? new Date(args.start_date).toISOString()
|
|
269
|
+
: new Date().toISOString(),
|
|
270
|
+
};
|
|
271
|
+
if (args.formats && args.formats.length > 0) {
|
|
272
|
+
const formatIds = resolveFormatIds(args.formats);
|
|
273
|
+
if (formatIds.length > 0) {
|
|
274
|
+
params.gameplay_format_id = formatIds;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if (args.categories && args.categories.length > 0) {
|
|
278
|
+
const categoryIds = resolveCategoryIds(args.categories);
|
|
279
|
+
if (categoryIds.length > 0) {
|
|
280
|
+
params.event_configuration_template_id = categoryIds;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
if (args.featured_only) {
|
|
284
|
+
params.is_headlining_event = "true";
|
|
285
|
+
}
|
|
286
|
+
if (args.text_search) {
|
|
287
|
+
params.name = args.text_search;
|
|
288
|
+
}
|
|
289
|
+
if (args.store_id) {
|
|
290
|
+
params.store = args.store_id.toString();
|
|
291
|
+
}
|
|
292
|
+
const response = await fetchEvents(params);
|
|
293
|
+
if (response.results.length === 0) {
|
|
294
|
+
return {
|
|
295
|
+
content: [
|
|
296
|
+
{
|
|
297
|
+
type: "text",
|
|
298
|
+
text: `No events found near ${location.display_name} within ${args.radius_miles} miles. Try expanding your search radius or adjusting filters.`,
|
|
299
|
+
},
|
|
300
|
+
],
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
const formattedEvents = response.results.map(formatEvent).join("\n\n---\n\n");
|
|
304
|
+
const summary = `Found ${response.count} event(s) near ${location.display_name}. Showing ${response.results.length} (page ${args.page} of ${Math.ceil(response.count / args.page_size)}).`;
|
|
305
|
+
return {
|
|
306
|
+
content: [
|
|
307
|
+
{
|
|
308
|
+
type: "text",
|
|
309
|
+
text: `${summary}\n\n${formattedEvents}`,
|
|
310
|
+
},
|
|
311
|
+
],
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
catch (error) {
|
|
315
|
+
return {
|
|
316
|
+
content: [
|
|
317
|
+
{
|
|
318
|
+
type: "text",
|
|
319
|
+
text: `Error: ${error instanceof Error ? error.message : String(error)}`,
|
|
320
|
+
},
|
|
321
|
+
],
|
|
322
|
+
isError: true,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP tools for listing filters (formats, categories) and server capabilities (for LLM discovery).
|
|
3
|
+
*/
|
|
4
|
+
import { fetchCategories, fetchGameplayFormats, updateFilterMaps } from "../lib/api.js";
|
|
5
|
+
const CAPABILITIES_TEXT = `# Lorcana Event Finder – Tool Guide
|
|
6
|
+
|
|
7
|
+
Use this to choose the right tool. All tools return plain text.
|
|
8
|
+
|
|
9
|
+
| Tool | When to use |
|
|
10
|
+
|------|-------------|
|
|
11
|
+
| **list_filters** | User wants to filter events by format (e.g. Constructed) or category; call first to get exact names for \`formats\` / \`categories\` parameters. |
|
|
12
|
+
| **search_events** | You have latitude and longitude (e.g. from a map or device). |
|
|
13
|
+
| **search_events_by_city** | User says a city name, e.g. "events in Seattle" or "Austin, TX". |
|
|
14
|
+
| **get_event_details** | User asks for more info about a specific event; you have an event ID (from search). |
|
|
15
|
+
| **get_event_registrations** | User asks who is signed up or the registration list; you need event ID. |
|
|
16
|
+
| **get_tournament_round_standings** | User asks who is winning or standings for a round; you need round ID. |
|
|
17
|
+
| **search_stores** | User asks for stores, venues, or places to play; optional: name (\`search\`) and/or location (lat/long + \`radius_miles\`). |
|
|
18
|
+
| **search_stores_by_city** | User says a city for stores, e.g. "stores in Seattle". |
|
|
19
|
+
|
|
20
|
+
**Typical flow:** For "events near Seattle" → \`search_events_by_city\` with \`city: "Seattle, WA"\`. For "who's signed up for that event?" → \`get_event_registrations\` with the event ID from the previous search.
|
|
21
|
+
`;
|
|
22
|
+
export function registerFilterTools(server) {
|
|
23
|
+
// Tool: List Capabilities (for LLM discovery)
|
|
24
|
+
server.registerTool("list_capabilities", {
|
|
25
|
+
description: "List all tools and when to use each. Call this first if you are unsure which tool to use (e.g. search_events vs search_events_by_city, or how to get event IDs).",
|
|
26
|
+
inputSchema: {},
|
|
27
|
+
}, async () => ({
|
|
28
|
+
content: [{ type: "text", text: CAPABILITIES_TEXT }],
|
|
29
|
+
}));
|
|
30
|
+
// Tool: List Available Filters
|
|
31
|
+
server.registerTool("list_filters", {
|
|
32
|
+
description: "List format and category names you can use when searching events. Call before search_events or search_events_by_city if the user wants to filter by format (e.g. Constructed) or category. Use the exact names shown in the formats and categories array parameters.",
|
|
33
|
+
inputSchema: {},
|
|
34
|
+
}, async () => {
|
|
35
|
+
try {
|
|
36
|
+
const [formats, categories] = await Promise.all([
|
|
37
|
+
fetchGameplayFormats(),
|
|
38
|
+
fetchCategories(),
|
|
39
|
+
]);
|
|
40
|
+
updateFilterMaps(formats, categories);
|
|
41
|
+
const formatList = formats.map((f) => `- ${f.name}${f.description ? ` - ${f.description}` : ""}`).join("\n");
|
|
42
|
+
const categoryList = categories.map((c) => `- ${c.name}`).join("\n");
|
|
43
|
+
const filterInfo = `# Event Search Filters (use exact names in parameters)
|
|
44
|
+
|
|
45
|
+
## Formats (use in \`formats\` array)
|
|
46
|
+
${formatList}
|
|
47
|
+
|
|
48
|
+
## Categories (use in \`categories\` array)
|
|
49
|
+
${categoryList}
|
|
50
|
+
|
|
51
|
+
## Statuses (use in \`statuses\` array)
|
|
52
|
+
- upcoming - Not started yet
|
|
53
|
+
- inProgress - Live now
|
|
54
|
+
- past - Completed
|
|
55
|
+
|
|
56
|
+
## Example
|
|
57
|
+
To filter by format and category in search_events or search_events_by_city, pass arrays of the exact names above, e.g. \`formats: ["Constructed"]\`, \`categories: ["League"]\`.
|
|
58
|
+
|
|
59
|
+
## Other optional parameters
|
|
60
|
+
- featured_only (boolean): Only featured events
|
|
61
|
+
- text_search (string): Search event names
|
|
62
|
+
- store_id (number): Limit to one store (IDs from search_stores)
|
|
63
|
+
- radius_miles (number): Search radius, default 25
|
|
64
|
+
- start_date (string): YYYY-MM-DD, events starting after this date
|
|
65
|
+
`;
|
|
66
|
+
return {
|
|
67
|
+
content: [
|
|
68
|
+
{
|
|
69
|
+
type: "text",
|
|
70
|
+
text: filterInfo,
|
|
71
|
+
},
|
|
72
|
+
],
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
return {
|
|
77
|
+
content: [
|
|
78
|
+
{
|
|
79
|
+
type: "text",
|
|
80
|
+
text: `Error fetching filter options: ${error instanceof Error ? error.message : String(error)}`,
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
isError: true,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP tools for store search (by name, location, or city).
|
|
3
|
+
*/
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { fetchStores } from "../lib/api.js";
|
|
6
|
+
import { formatStore } from "../lib/formatters.js";
|
|
7
|
+
export function registerStoreTools(server) {
|
|
8
|
+
// Tool: Search Stores
|
|
9
|
+
server.registerTool("search_stores", {
|
|
10
|
+
description: "Search for game stores that host Disney Lorcana events. Use when the user asks for stores, venues, or places to play. Pass search (name) and/or latitude+longitude+radius_miles; you can pass both. No location = first page of all stores.",
|
|
11
|
+
inputSchema: {
|
|
12
|
+
search: z.string().optional().describe("Search by store name (e.g. 'game' or store name)"),
|
|
13
|
+
latitude: z.number().optional().describe("Latitude for location search (use with longitude and radius_miles)"),
|
|
14
|
+
longitude: z.number().optional().describe("Longitude for location search (use with latitude and radius_miles)"),
|
|
15
|
+
radius_miles: z.number().default(25).describe("Radius in miles when using lat/long (default: 25)"),
|
|
16
|
+
page: z.number().default(1).describe("Page number (default: 1)"),
|
|
17
|
+
page_size: z.number().default(25).describe("Results per page, max 100 (default: 25)"),
|
|
18
|
+
},
|
|
19
|
+
}, async (args) => {
|
|
20
|
+
const params = {
|
|
21
|
+
page: args.page.toString(),
|
|
22
|
+
page_size: Math.min(args.page_size, 100).toString(),
|
|
23
|
+
};
|
|
24
|
+
if (args.search) {
|
|
25
|
+
params.search = args.search;
|
|
26
|
+
}
|
|
27
|
+
if (args.latitude !== undefined && args.longitude !== undefined) {
|
|
28
|
+
params.latitude = args.latitude.toString();
|
|
29
|
+
params.longitude = args.longitude.toString();
|
|
30
|
+
params.num_miles = args.radius_miles.toString();
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
const response = await fetchStores(params);
|
|
34
|
+
if (response.results.length === 0) {
|
|
35
|
+
return {
|
|
36
|
+
content: [
|
|
37
|
+
{
|
|
38
|
+
type: "text",
|
|
39
|
+
text: "No stores found matching your criteria. Try a different search term or location.",
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
const formattedStores = response.results.map(formatStore).join("\n\n---\n\n");
|
|
45
|
+
const summary = `Found ${response.count} store(s). Showing ${response.results.length} (page ${args.page}).`;
|
|
46
|
+
return {
|
|
47
|
+
content: [
|
|
48
|
+
{
|
|
49
|
+
type: "text",
|
|
50
|
+
text: `${summary}\n\n${formattedStores}`,
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
return {
|
|
57
|
+
content: [
|
|
58
|
+
{
|
|
59
|
+
type: "text",
|
|
60
|
+
text: `Error searching stores: ${error instanceof Error ? error.message : String(error)}`,
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
isError: true,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
// Tool: Search Stores by City
|
|
68
|
+
server.registerTool("search_stores_by_city", {
|
|
69
|
+
description: "Search for game stores that host Disney Lorcana events by city name (geocoded). Use when the user says a city, e.g. 'stores in Seattle' or 'where to play in Austin'. For coordinates use search_stores with latitude/longitude.",
|
|
70
|
+
inputSchema: {
|
|
71
|
+
city: z.string().describe("City name, ideally with state/country (e.g. 'Detroit, MI' or 'New York, NY')"),
|
|
72
|
+
radius_miles: z.number().default(25).describe("Radius in miles (default: 25)"),
|
|
73
|
+
page: z.number().default(1).describe("Page number (default: 1)"),
|
|
74
|
+
page_size: z.number().default(25).describe("Results per page, max 100 (default: 25)"),
|
|
75
|
+
},
|
|
76
|
+
}, async (args) => {
|
|
77
|
+
const geocodeUrl = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(args.city)}&format=json&limit=1`;
|
|
78
|
+
try {
|
|
79
|
+
const geoResponse = await fetch(geocodeUrl, {
|
|
80
|
+
headers: { "User-Agent": "lorcana-event-finder/1.0" },
|
|
81
|
+
});
|
|
82
|
+
if (!geoResponse.ok) {
|
|
83
|
+
throw new Error("Geocoding failed");
|
|
84
|
+
}
|
|
85
|
+
const geoData = (await geoResponse.json());
|
|
86
|
+
if (!geoData || geoData.length === 0) {
|
|
87
|
+
return {
|
|
88
|
+
content: [
|
|
89
|
+
{
|
|
90
|
+
type: "text",
|
|
91
|
+
text: `Could not find location: ${args.city}. Try being more specific (e.g., "Detroit, MI, USA").`,
|
|
92
|
+
},
|
|
93
|
+
],
|
|
94
|
+
isError: true,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
const location = geoData[0];
|
|
98
|
+
const latitude = parseFloat(location.lat);
|
|
99
|
+
const longitude = parseFloat(location.lon);
|
|
100
|
+
const params = {
|
|
101
|
+
latitude: latitude.toString(),
|
|
102
|
+
longitude: longitude.toString(),
|
|
103
|
+
num_miles: args.radius_miles.toString(),
|
|
104
|
+
page: args.page.toString(),
|
|
105
|
+
page_size: Math.min(args.page_size, 100).toString(),
|
|
106
|
+
};
|
|
107
|
+
const response = await fetchStores(params);
|
|
108
|
+
if (response.results.length === 0) {
|
|
109
|
+
return {
|
|
110
|
+
content: [
|
|
111
|
+
{
|
|
112
|
+
type: "text",
|
|
113
|
+
text: `No stores found near ${location.display_name} within ${args.radius_miles} miles.`,
|
|
114
|
+
},
|
|
115
|
+
],
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
const formattedStores = response.results.map(formatStore).join("\n\n---\n\n");
|
|
119
|
+
const totalPages = Math.ceil(response.count / args.page_size);
|
|
120
|
+
const summary = `Found ${response.count} store(s) near ${location.display_name}. Showing ${response.results.length} (page ${args.page} of ${totalPages}).`;
|
|
121
|
+
return {
|
|
122
|
+
content: [
|
|
123
|
+
{
|
|
124
|
+
type: "text",
|
|
125
|
+
text: `${summary}\n\n${formattedStores}`,
|
|
126
|
+
},
|
|
127
|
+
],
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
return {
|
|
132
|
+
content: [
|
|
133
|
+
{
|
|
134
|
+
type: "text",
|
|
135
|
+
text: `Error: ${error instanceof Error ? error.message : String(error)}`,
|
|
136
|
+
},
|
|
137
|
+
],
|
|
138
|
+
isError: true,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "unofficial-ravensburger-playhub-mcp",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "MCP server for finding Disney Lorcana TCG events",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"unofficial-ravensburger-playhub-mcp": "./dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "rm -rf dist && tsc",
|
|
12
|
+
"start": "node dist/index.js",
|
|
13
|
+
"dev": "tsx src/index.ts",
|
|
14
|
+
"test": "node --test dist",
|
|
15
|
+
"test:coverage": "c8 --exclude='**/test/**' --reporter=text --reporter=lcov node --test dist",
|
|
16
|
+
"prepublishOnly": "npm run build"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"mcp",
|
|
20
|
+
"lorcana",
|
|
21
|
+
"tcg",
|
|
22
|
+
"events"
|
|
23
|
+
],
|
|
24
|
+
"author": "",
|
|
25
|
+
"license": "Unlicense",
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=18.0.0"
|
|
28
|
+
},
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "https://github.com/zammitt/unofficial-ravensburger-playhub-mcp.git"
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"dist",
|
|
35
|
+
"README.md",
|
|
36
|
+
"LICENSE"
|
|
37
|
+
],
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@modelcontextprotocol/sdk": "^1.25.3",
|
|
40
|
+
"zod": "^4.3.6"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@types/node": "^25.2.0",
|
|
44
|
+
"c8": "^10.1.2",
|
|
45
|
+
"tsx": "^4.21.0",
|
|
46
|
+
"typescript": "^5.9.3"
|
|
47
|
+
}
|
|
48
|
+
}
|