1use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6use wowlab_types::{sim::FastMap, table_registry::GameDataTable};
7
8mod tables;
9
10#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
11pub(crate) enum McpTableId {
12 GameData(GameDataTable),
13 Metadata,
14}
15
16impl McpTableId {
17 pub(crate) const fn database_name(self) -> &'static str {
18 match self {
19 Self::GameData(table) => table.database_name(),
20 Self::Metadata => "game.meta",
21 }
22 }
23}
24
25#[derive(Clone, Copy, Debug, Eq, JsonSchema, PartialEq, Serialize)]
27#[serde(rename_all = "snake_case")]
28#[non_exhaustive]
29pub(crate) enum ColType {
30 Int,
31 Text,
32 Bool,
33 Float,
34 Json,
35 IntArray,
36 Timestamp,
37}
38
39#[derive(Clone, Debug, JsonSchema, Serialize)]
41pub(crate) struct Column {
42 pub name: &'static str,
43 #[serde(rename = "type")]
44 pub typ: ColType,
45}
46
47#[derive(Debug)]
48pub(crate) struct Table {
49 pub identity: McpTableId,
50 pub category: &'static str,
51 pub description: &'static str,
52 pub columns: &'static [Column],
53}
54
55impl Table {
56 pub(crate) const fn name(&self) -> &'static str {
57 self.identity.database_name()
58 }
59}
60
61inventory::collect!(Table);
62
63#[derive(Debug)]
64pub(crate) struct JsonColumnSchema {
65 pub table: McpTableId,
66 pub column: &'static str,
67 pub schema_fn: fn() -> Value,
68}
69
70inventory::collect!(JsonColumnSchema);
71
72#[derive(Debug, Eq, PartialEq, thiserror::Error)]
73pub(crate) enum SchemaCatalogError {
74 #[error("duplicate MCP descriptor for {table}")]
75 DuplicateDescriptor { table: &'static str },
76 #[error("MCP descriptor registered for hidden table {table}")]
77 HiddenDescriptor { table: &'static str },
78 #[error("missing MCP descriptor for exposed table {table}")]
79 MissingDescriptor { table: &'static str },
80 #[error("missing MCP descriptor for game.meta")]
81 MissingMetadata,
82 #[error("duplicate JSON schema for {table}.{column}")]
83 DuplicateJsonSchema {
84 table: &'static str,
85 column: &'static str,
86 },
87 #[error("JSON schema registered for unknown column {table}.{column}")]
88 UnknownJsonColumn {
89 table: &'static str,
90 column: &'static str,
91 },
92}
93
94#[derive(Debug, Eq, PartialEq, thiserror::Error)]
95#[error("Unknown table: {name}")]
96pub(crate) struct UnknownTableError {
97 name: String,
98}
99
100#[derive(Debug)]
101pub(crate) struct SchemaCatalog {
102 tables: Vec<&'static Table>,
103 tables_by_name: FastMap<&'static str, &'static Table>,
104 json_columns: FastMap<(McpTableId, &'static str), Value>,
105}
106
107#[rustfmt::skip]
108const CATEGORY_ORDER: &[(&str, &str)] = &[
109 ("core" , "Core") ,
111 ("spell_metadata", "Spell Metadata"),
112 ("item_scaling" , "Item Scaling") ,
113 ("armor_damage" , "Armor & Damage"),
114 ("combat_scaling", "Combat Scaling"),
115 ("loot" , "Loot & Drops") ,
116 ("seasons" , "Seasons & PvP") ,
117 ("currencies" , "Currencies") ,
118 ("supporting" , "Supporting") ,
119 ("ui_reference" , "UI Reference") ,
120];
121
122#[derive(Debug, Serialize)]
123pub(crate) struct TableSummary {
124 pub name: &'static str,
125 pub category: &'static str,
126 pub description: &'static str,
127 pub column_count: usize,
128}
129
130#[derive(Debug, Serialize)]
131pub(crate) struct ColumnSchema {
132 pub name: &'static str,
133 #[serde(rename = "type")]
134 pub typ: ColType,
135 #[serde(skip_serializing_if = "Option::is_none")]
136 pub json_schema: Option<Value>,
137}
138
139#[derive(Debug, Serialize)]
140pub(crate) struct TableSchema {
141 pub name: &'static str,
142 pub category: &'static str,
143 pub description: &'static str,
144 pub columns: Vec<ColumnSchema>,
145}
146
147#[derive(Debug, Default, Deserialize, JsonSchema)]
148#[schemars(description = "Get column details for a specific table, or all tables if omitted.")]
149pub(crate) struct GetSchemaInput {
150 #[serde(default)]
151 #[schemars(description = "Table name (e.g. `game.spells`). Omit to get all tables.")]
152 pub table: Option<String>,
153}
154
155impl SchemaCatalog {
156 pub(crate) fn try_new() -> Result<Self, SchemaCatalogError> {
157 Self::from_descriptors(
158 inventory::iter::<Table>,
159 inventory::iter::<JsonColumnSchema>,
160 )
161 }
162
163 fn from_descriptors(
164 tables: impl IntoIterator<Item = &'static Table>,
165 json_schemas: impl IntoIterator<Item = &'static JsonColumnSchema>,
166 ) -> Result<Self, SchemaCatalogError> {
167 let descriptors = collect_descriptors(tables)?;
168 let ordered = order_descriptors(descriptors)?;
169 let tables_by_name = ordered
170 .iter()
171 .map(|table| (table.name(), *table))
172 .collect::<FastMap<_, _>>();
173 let json_columns = collect_json_schemas(&ordered, json_schemas)?;
174
175 Ok(Self {
176 tables: ordered,
177 tables_by_name,
178 json_columns,
179 })
180 }
181
182 #[cfg(test)]
183 pub(crate) fn tables(&self) -> &[&'static Table] {
184 &self.tables
185 }
186
187 pub(crate) fn table(&self, name: &str) -> Result<&'static Table, UnknownTableError> {
188 self.tables_by_name
189 .get(name)
190 .copied()
191 .ok_or_else(|| UnknownTableError {
192 name: name.to_owned(),
193 })
194 }
195
196 pub(crate) fn list_tables_compact(&self) -> Vec<TableSummary> {
197 self.tables
198 .iter()
199 .map(|table| TableSummary {
200 name: table.name(),
201 category: table.category,
202 description: table.description,
203 column_count: table.columns.len(),
204 })
205 .collect()
206 }
207
208 pub(crate) fn get_schema(
209 &self,
210 table: Option<&str>,
211 ) -> Result<Vec<TableSchema>, UnknownTableError> {
212 match table {
213 Some(name) => Ok(vec![self.table_schema(self.table(name)?)]),
214 None => Ok(self
215 .tables
216 .iter()
217 .map(|table| self.table_schema(table))
218 .collect()),
219 }
220 }
221
222 pub(crate) fn build_tables_doc(
223 &self,
224 mut doc: wowlab_common::markdown::Doc,
225 ) -> wowlab_common::markdown::Doc {
226 for &(category, label) in CATEGORY_ORDER {
227 let category_tables = self
228 .tables
229 .iter()
230 .filter(|table| table.category == category)
231 .collect::<Vec<_>>();
232
233 if category_tables.is_empty() {
234 continue;
235 }
236
237 doc = doc.h3(label);
238
239 for table in category_tables {
240 doc = table_column_detail(doc, table);
241 }
242 }
243
244 doc
245 }
246
247 fn table_schema(&self, table: &Table) -> TableSchema {
248 TableSchema {
249 name: table.name(),
250 category: table.category,
251 description: table.description,
252 columns: table
253 .columns
254 .iter()
255 .map(|column| ColumnSchema {
256 name: column.name,
257 typ: column.typ,
258 json_schema: (column.typ == ColType::Json)
259 .then(|| self.json_schema_for(table.identity, column.name))
260 .flatten(),
261 })
262 .collect(),
263 }
264 }
265
266 fn json_schema_for(&self, table: McpTableId, column: &str) -> Option<Value> {
267 self.json_columns.get(&(table, column)).cloned()
268 }
269}
270
271fn collect_descriptors(
272 tables: impl IntoIterator<Item = &'static Table>,
273) -> Result<FastMap<McpTableId, &'static Table>, SchemaCatalogError> {
274 let mut descriptors = FastMap::default();
275
276 for table in tables {
277 if let McpTableId::GameData(game_table) = table.identity
278 && !game_table.is_mcp_exposed()
279 {
280 return Err(SchemaCatalogError::HiddenDescriptor {
281 table: table.name(),
282 });
283 }
284
285 if descriptors.insert(table.identity, table).is_some() {
286 return Err(SchemaCatalogError::DuplicateDescriptor {
287 table: table.name(),
288 });
289 }
290 }
291
292 Ok(descriptors)
293}
294
295fn order_descriptors(
296 mut descriptors: FastMap<McpTableId, &'static Table>,
297) -> Result<Vec<&'static Table>, SchemaCatalogError> {
298 let mut ordered = Vec::with_capacity(
299 GameDataTable::iter()
300 .filter(|table| table.is_mcp_exposed())
301 .count()
302 + 1,
303 );
304
305 for table in GameDataTable::iter().filter(|table| table.is_mcp_exposed()) {
306 let descriptor = descriptors.remove(&McpTableId::GameData(table)).ok_or(
307 SchemaCatalogError::MissingDescriptor {
308 table: table.database_name(),
309 },
310 )?;
311
312 ordered.push(descriptor);
313 }
314
315 ordered.push(
316 descriptors
317 .remove(&McpTableId::Metadata)
318 .ok_or(SchemaCatalogError::MissingMetadata)?,
319 );
320 ordered.sort_by_key(|table| table.name());
321
322 Ok(ordered)
323}
324
325fn collect_json_schemas(
326 tables: &[&'static Table],
327 schemas: impl IntoIterator<Item = &'static JsonColumnSchema>,
328) -> Result<FastMap<(McpTableId, &'static str), Value>, SchemaCatalogError> {
329 let mut json_columns = FastMap::default();
330
331 for schema in schemas {
332 let table = table_for_json_schema(tables, schema)?;
333
334 if json_columns
335 .insert((schema.table, schema.column), (schema.schema_fn)())
336 .is_some()
337 {
338 return Err(SchemaCatalogError::DuplicateJsonSchema {
339 table: table.name(),
340 column: schema.column,
341 });
342 }
343 }
344
345 Ok(json_columns)
346}
347
348fn table_for_json_schema(
349 tables: &[&'static Table],
350 schema: &JsonColumnSchema,
351) -> Result<&'static Table, SchemaCatalogError> {
352 let table = tables
353 .iter()
354 .copied()
355 .find(|table| table.identity == schema.table)
356 .ok_or(SchemaCatalogError::UnknownJsonColumn {
357 table: schema.table.database_name(),
358 column: schema.column,
359 })?;
360
361 if table
362 .columns
363 .iter()
364 .all(|column| column.name != schema.column || column.typ != ColType::Json)
365 {
366 return Err(SchemaCatalogError::UnknownJsonColumn {
367 table: table.name(),
368 column: schema.column,
369 });
370 }
371
372 Ok(table)
373}
374
375fn table_column_detail(
376 doc: wowlab_common::markdown::Doc,
377 table: &Table,
378) -> wowlab_common::markdown::Doc {
379 let detail = format!("{} cols", table.columns.len());
380
381 doc.def_detail(table.name(), &detail, table.description)
382}
383
384pub(crate) fn get_column<'a>(table: &'a Table, column: &str) -> Option<&'a Column> {
385 table
386 .columns
387 .iter()
388 .find(|candidate| candidate.name == column)
389}
390
391#[derive(Clone, Copy, Debug, serde::Serialize)]
393#[non_exhaustive]
394pub struct McpToolMeta {
395 pub name: &'static str,
397 pub summary: &'static str,
399 pub order: u8,
401 pub tips: &'static [&'static str],
403}
404
405inventory::collect!(McpToolMeta);
406
407pub(crate) fn all_tool_metas() -> Vec<&'static McpToolMeta> {
408 let mut tools = inventory::iter::<McpToolMeta>
409 .into_iter()
410 .collect::<Vec<_>>();
411
412 tools.sort_by_key(|tool| tool.order);
413
414 tools
415}
416
417pub(crate) fn build_workflow_doc(
418 mut doc: wowlab_common::markdown::Doc,
419) -> wowlab_common::markdown::Doc {
420 for (index, tool) in all_tool_metas().iter().enumerate() {
421 doc = workflow_step(doc, index + 1, tool);
422 }
423
424 doc
425}
426
427fn workflow_step(
428 doc: wowlab_common::markdown::Doc,
429 index: usize,
430 tool: &McpToolMeta,
431) -> wowlab_common::markdown::Doc {
432 let description = format!("`{}` — {}", tool.name, tool.summary);
433
434 doc.numbered(index, &description)
435}
436
437pub(crate) fn build_tips_doc(
438 mut doc: wowlab_common::markdown::Doc,
439) -> wowlab_common::markdown::Doc {
440 for tool in all_tool_metas() {
441 for tip in tool.tips {
442 doc = doc.bullet(tip);
443 }
444 }
445
446 doc
447}
448
449#[cfg(test)]
450mod tests;