Skip to main content

wowlab_sentinel/mcp/schema/
mod.rs

1//! Typed MCP projection of the canonical game-data table registry.
2
3use 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/// Column data type.
26#[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/// MCP column definition.
40#[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
100impl crate::mcp::McpFailure for UnknownTableError {
101    fn error_type(&self) -> crate::telemetry::McpErrorType {
102        crate::telemetry::McpErrorType::InvalidParams
103    }
104}
105
106#[derive(Debug)]
107pub(crate) struct SchemaCatalog {
108    tables: Vec<&'static Table>,
109    tables_by_name: FastMap<&'static str, &'static Table>,
110    json_columns: FastMap<(McpTableId, &'static str), Value>,
111}
112
113#[derive(Debug, JsonSchema, Serialize)]
114pub(crate) struct TableSummary {
115    pub name: &'static str,
116    pub category: &'static str,
117    pub description: &'static str,
118    pub column_count: usize,
119}
120
121#[derive(Debug, JsonSchema, Serialize)]
122pub(crate) struct ColumnSchema {
123    pub name: &'static str,
124    #[serde(rename = "type")]
125    pub typ: ColType,
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub json_schema: Option<Value>,
128}
129
130#[derive(Debug, JsonSchema, Serialize)]
131pub(crate) struct TableSchema {
132    pub name: &'static str,
133    pub category: &'static str,
134    pub description: &'static str,
135    pub columns: Vec<ColumnSchema>,
136}
137
138#[derive(Debug, Default, Deserialize, JsonSchema)]
139#[schemars(description = "Get column details for a specific table, or all tables if omitted.")]
140pub(crate) struct GetSchemaInput {
141    #[serde(default)]
142    #[schemars(description = "Table name (e.g. `game.spells`). Omit to get all tables.")]
143    pub table: Option<String>,
144}
145
146impl SchemaCatalog {
147    pub(crate) fn try_new() -> Result<Self, SchemaCatalogError> {
148        Self::from_descriptors(
149            inventory::iter::<Table>,
150            inventory::iter::<JsonColumnSchema>,
151        )
152    }
153
154    fn from_descriptors(
155        tables: impl IntoIterator<Item = &'static Table>,
156        json_schemas: impl IntoIterator<Item = &'static JsonColumnSchema>,
157    ) -> Result<Self, SchemaCatalogError> {
158        let descriptors = collect_descriptors(tables)?;
159        let ordered = order_descriptors(descriptors)?;
160        let tables_by_name = ordered
161            .iter()
162            .map(|table| (table.name(), *table))
163            .collect::<FastMap<_, _>>();
164        let json_columns = collect_json_schemas(&ordered, json_schemas)?;
165
166        Ok(Self {
167            tables: ordered,
168            tables_by_name,
169            json_columns,
170        })
171    }
172
173    #[cfg(test)]
174    pub(crate) fn tables(&self) -> &[&'static Table] {
175        &self.tables
176    }
177
178    pub(crate) fn table(&self, name: &str) -> Result<&'static Table, UnknownTableError> {
179        self.tables_by_name
180            .get(name)
181            .copied()
182            .ok_or_else(|| UnknownTableError {
183                name: name.to_owned(),
184            })
185    }
186
187    pub(crate) fn list_tables_compact(&self) -> Vec<TableSummary> {
188        self.tables
189            .iter()
190            .map(|table| TableSummary {
191                name: table.name(),
192                category: table.category,
193                description: table.description,
194                column_count: table.columns.len(),
195            })
196            .collect()
197    }
198
199    pub(crate) fn get_schema(
200        &self,
201        table: Option<&str>,
202    ) -> Result<Vec<TableSchema>, UnknownTableError> {
203        match table {
204            Some(name) => Ok(vec![self.table_schema(self.table(name)?)]),
205            None => Ok(self
206                .tables
207                .iter()
208                .map(|table| self.table_schema(table))
209                .collect()),
210        }
211    }
212
213    fn table_schema(&self, table: &Table) -> TableSchema {
214        TableSchema {
215            name: table.name(),
216            category: table.category,
217            description: table.description,
218            columns: table
219                .columns
220                .iter()
221                .map(|column| ColumnSchema {
222                    name: column.name,
223                    typ: column.typ,
224                    json_schema: (column.typ == ColType::Json)
225                        .then(|| self.json_schema_for(table.identity, column.name))
226                        .flatten(),
227                })
228                .collect(),
229        }
230    }
231
232    fn json_schema_for(&self, table: McpTableId, column: &str) -> Option<Value> {
233        self.json_columns.get(&(table, column)).cloned()
234    }
235}
236
237fn collect_descriptors(
238    tables: impl IntoIterator<Item = &'static Table>,
239) -> Result<FastMap<McpTableId, &'static Table>, SchemaCatalogError> {
240    let mut descriptors = FastMap::default();
241
242    for table in tables {
243        if let McpTableId::GameData(game_table) = table.identity
244            && !game_table.is_mcp_exposed()
245        {
246            return Err(SchemaCatalogError::HiddenDescriptor {
247                table: table.name(),
248            });
249        }
250
251        if descriptors.insert(table.identity, table).is_some() {
252            return Err(SchemaCatalogError::DuplicateDescriptor {
253                table: table.name(),
254            });
255        }
256    }
257
258    Ok(descriptors)
259}
260
261fn order_descriptors(
262    mut descriptors: FastMap<McpTableId, &'static Table>,
263) -> Result<Vec<&'static Table>, SchemaCatalogError> {
264    let mut ordered = Vec::with_capacity(
265        GameDataTable::iter()
266            .filter(|table| table.is_mcp_exposed())
267            .count()
268            + 1,
269    );
270
271    for table in GameDataTable::iter().filter(|table| table.is_mcp_exposed()) {
272        let descriptor = descriptors.remove(&McpTableId::GameData(table)).ok_or(
273            SchemaCatalogError::MissingDescriptor {
274                table: table.database_name(),
275            },
276        )?;
277
278        ordered.push(descriptor);
279    }
280
281    ordered.push(
282        descriptors
283            .remove(&McpTableId::Metadata)
284            .ok_or(SchemaCatalogError::MissingMetadata)?,
285    );
286    ordered.sort_by_key(|table| table.name());
287
288    Ok(ordered)
289}
290
291fn collect_json_schemas(
292    tables: &[&'static Table],
293    schemas: impl IntoIterator<Item = &'static JsonColumnSchema>,
294) -> Result<FastMap<(McpTableId, &'static str), Value>, SchemaCatalogError> {
295    let mut json_columns = FastMap::default();
296
297    for schema in schemas {
298        let table = table_for_json_schema(tables, schema)?;
299
300        if json_columns
301            .insert((schema.table, schema.column), (schema.schema_fn)())
302            .is_some()
303        {
304            return Err(SchemaCatalogError::DuplicateJsonSchema {
305                table: table.name(),
306                column: schema.column,
307            });
308        }
309    }
310
311    Ok(json_columns)
312}
313
314fn table_for_json_schema(
315    tables: &[&'static Table],
316    schema: &JsonColumnSchema,
317) -> Result<&'static Table, SchemaCatalogError> {
318    let table = tables
319        .iter()
320        .copied()
321        .find(|table| table.identity == schema.table)
322        .ok_or(SchemaCatalogError::UnknownJsonColumn {
323            table: schema.table.database_name(),
324            column: schema.column,
325        })?;
326
327    if table
328        .columns
329        .iter()
330        .all(|column| column.name != schema.column || column.typ != ColType::Json)
331    {
332        return Err(SchemaCatalogError::UnknownJsonColumn {
333            table: table.name(),
334            column: schema.column,
335        });
336    }
337
338    Ok(table)
339}
340
341pub(crate) fn get_column<'a>(table: &'a Table, column: &str) -> Option<&'a Column> {
342    table
343        .columns
344        .iter()
345        .find(|candidate| candidate.name == column)
346}
347
348/// Metadata registered for one tool exposed by the MCP server.
349#[derive(Clone, Copy, Debug, serde::Serialize)]
350#[non_exhaustive]
351pub struct McpToolMeta {
352    /// Protocol-level tool name.
353    pub name: &'static str,
354    /// Concise description presented to MCP clients.
355    pub summary: &'static str,
356    /// Recommended workflow position.
357    pub order: u8,
358    /// Usage guidance presented to MCP clients.
359    pub tips: &'static [&'static str],
360}
361
362inventory::collect!(McpToolMeta);
363
364pub(crate) fn all_tool_metas() -> Vec<&'static McpToolMeta> {
365    let mut tools = inventory::iter::<McpToolMeta>
366        .into_iter()
367        .collect::<Vec<_>>();
368
369    tools.sort_by_key(|tool| tool.order);
370
371    tools
372}
373
374pub(crate) fn build_workflow_doc(
375    mut doc: wowlab_common::markdown::Doc,
376) -> wowlab_common::markdown::Doc {
377    for (index, tool) in all_tool_metas().iter().enumerate() {
378        doc = workflow_step(doc, index + 1, tool);
379    }
380
381    doc
382}
383
384fn workflow_step(
385    doc: wowlab_common::markdown::Doc,
386    index: usize,
387    tool: &McpToolMeta,
388) -> wowlab_common::markdown::Doc {
389    let description = format!("`{}` — {}", tool.name, tool.summary);
390
391    doc.numbered(index, &description)
392}
393
394pub(crate) fn build_tips_doc(
395    mut doc: wowlab_common::markdown::Doc,
396) -> wowlab_common::markdown::Doc {
397    for tool in all_tool_metas() {
398        for tip in tool.tips {
399            doc = doc.bullet(tip);
400        }
401    }
402
403    doc
404}
405
406#[cfg(test)]
407mod tests;