Skip to main content

wowlab_sentinel/mcp/
mod.rs

1pub(crate) mod schema;
2mod tools;
3
4crate::db_client!(McpDb, "mcp", max = 8, statement_timeout_secs = 30);
5
6tokio::task_local! {
7    static MCP_USER_ID: Option<uuid::Uuid>;
8}
9
10pub(crate) fn current_user_id() -> Option<uuid::Uuid> {
11    MCP_USER_ID.try_with(|id| *id).ok().flatten()
12}
13
14fn request_user_id(context: &RequestContext<RoleServer>) -> Option<uuid::Uuid> {
15    context
16        .extensions
17        .get::<axum::http::request::Parts>()
18        .and_then(user_id_from_http_parts)
19}
20
21fn user_id_from_http_parts(parts: &axum::http::request::Parts) -> Option<uuid::Uuid> {
22    parts
23        .extensions
24        .get::<crate::http::auth::AuthenticatedUser>()
25        .map(|user| user.user_id)
26}
27
28/// Define an MCP tool: route builder fn plus inventory metadata.
29macro_rules! mcp_tool {
30    ($fn_name:ident, $order:expr, $desc:literal, $param:ty => $output:ty,
31     tips = [$($tip:literal),* $(,)?],
32     |$srv:ident, $p:ident| async $body:block
33    ) => {
34        paste::paste! {
35            pub(super) fn [< build_ $fn_name _route >]() -> rmcp::handler::server::router::tool::ToolRoute<$crate::mcp::McpHandler> {
36                use rmcp::{
37                    handler::server::{tool::schema_for_type, wrapper::Parameters},
38                    model::ToolAnnotations,
39                };
40
41                let tool = rmcp::model::Tool::new(
42                    stringify!($fn_name),
43                    $desc,
44                    $crate::mcp::portable_schema(schema_for_type::<Parameters<$param>>()),
45                )
46                .with_raw_output_schema($crate::mcp::output_schema_for::<$output>())
47                .with_annotations(
48                    ToolAnnotations::new()
49                        .read_only(true)
50                        .destructive(false)
51                        .idempotent(true)
52                        .open_world(false),
53                );
54
55                rmcp::handler::server::router::tool::ToolRoute::new_dyn(tool, |ctx| {
56                    Box::pin(async move {
57                        let $srv: &$crate::mcp::McpHandler = ctx.service;
58                        let user_id = $crate::mcp::request_user_id(&ctx.request_context);
59                        let arguments = ctx.arguments.unwrap_or_default();
60
61                        $crate::mcp::with_metrics(
62                            $crate::telemetry::McpTool::[< $fn_name:camel >],
63                            || $crate::mcp::MCP_USER_ID.scope(user_id, async move {
64                                let $p: $param = match rmcp::handler::server::tool::parse_json_object(
65                                    arguments,
66                                ) {
67                                    Ok(params) => params,
68                                    Err(error) => {
69                                        return Ok($crate::mcp::invalid_params_result(
70                                            $crate::telemetry::McpTool::[< $fn_name:camel >],
71                                            error.message,
72                                        ));
73                                    }
74                                };
75
76                                (async move $body).await
77                            }),
78                        ).await.map(Into::into)
79                    })
80                })
81            }
82
83            inventory::submit! {
84                $crate::mcp::schema::McpToolMeta {
85                    name: stringify!($fn_name),
86                    summary: $desc,
87                    order: $order,
88                    tips: &[$($tip),*],
89                }
90            }
91        }
92    };
93}
94
95pub(crate) use mcp_tool;
96
97/// Register an MCP-queryable table via `inventory`.
98macro_rules! mcp_table {
99    (meta, $cat:literal, $desc:literal, [ $($tokens:tt)* ]) => {
100        $crate::mcp::mcp_table_parse!(@cols
101            $crate::mcp::schema::McpTableId::Metadata,
102            $cat, $desc, [], [], $($tokens)*);
103    };
104    ($table:path, $cat:literal, $desc:literal, [ $($tokens:tt)* ]) => {
105        $crate::mcp::mcp_table_parse!(@cols
106            $crate::mcp::schema::McpTableId::GameData($table),
107            $cat, $desc, [], [], $($tokens)*);
108    };
109}
110
111pub(crate) use mcp_table;
112
113/// Internal tt-munching helper for `mcp_table!`.
114macro_rules! mcp_table_parse {
115    (@cols $identity:expr, $cat:literal, $desc:literal,
116     [$($cols:tt)*], [$($schemas:tt)*],
117     $col:literal : Json($($ty:tt)+), $($rest:tt)*) => {
118        $crate::mcp::mcp_table_parse!(@cols $identity, $cat, $desc,
119            [$($cols)* { $col, Json },],
120            [$($schemas)* { $identity, $col, $($ty)+ },],
121            $($rest)*);
122    };
123    (@cols $identity:expr, $cat:literal, $desc:literal,
124     [$($cols:tt)*], [$($schemas:tt)*],
125     $col:literal : $typ:ident, $($rest:tt)*) => {
126        $crate::mcp::mcp_table_parse!(@cols $identity, $cat, $desc,
127            [$($cols)* { $col, $typ },],
128            [$($schemas)*],
129            $($rest)*);
130    };
131    (@cols $identity:expr, $cat:literal, $desc:literal,
132     [$($cols:tt)*], [$($schemas:tt)*],
133     $col:literal : Json($($ty:tt)+)) => {
134        $crate::mcp::mcp_table_parse!(@emit $identity, $cat, $desc,
135            [$($cols)* { $col, Json },],
136            [$($schemas)* { $identity, $col, $($ty)+ },]);
137    };
138    (@cols $identity:expr, $cat:literal, $desc:literal,
139     [$($cols:tt)*], [$($schemas:tt)*],
140     $col:literal : $typ:ident) => {
141        $crate::mcp::mcp_table_parse!(@emit $identity, $cat, $desc,
142            [$($cols)* { $col, $typ },],
143            [$($schemas)*]);
144    };
145    (@cols $identity:expr, $cat:literal, $desc:literal,
146     [$($cols:tt)*], [$($schemas:tt)*], ) => {
147        $crate::mcp::mcp_table_parse!(@emit $identity, $cat, $desc,
148            [$($cols)*], [$($schemas)*]);
149    };
150
151    (@emit $identity:expr, $cat:literal, $desc:literal,
152     [$({ $col:literal, $typ:ident },)*],
153     [$({ $table:expr, $jcol:literal, $($jty:tt)+ },)*]) => {
154        inventory::submit! {
155            $crate::mcp::schema::Table {
156                identity: $identity,
157                category: $cat,
158                description: $desc,
159                columns: &[
160                    $( $crate::mcp::schema::Column {
161                        name: $col,
162                        typ: $crate::mcp::schema::ColType::$typ,
163                    } ),*
164                ],
165            }
166        }
167        $(
168            inventory::submit! {
169                $crate::mcp::schema::JsonColumnSchema {
170                    table: $table,
171                    column: $jcol,
172                    schema_fn: || {
173                        serde_json::to_value(
174                            schemars::schema_for!($($jty)+)
175                        )
176                            .unwrap_or_default()
177                    },
178                }
179            }
180        )*
181    };
182}
183
184use std::{borrow::Cow, sync::Arc};
185
186pub(crate) use mcp_table_parse;
187use rmcp::{
188    ErrorData, ServerHandler,
189    handler::server::router::tool::ToolRouter,
190    model::{
191        CacheScope, CallToolResult, ContentBlock, DiscoverResult, Implementation, ListToolsResult,
192        MetaObject, PaginatedRequestParams, ServerCapabilities, ServerInfo,
193    },
194    service::{RequestContext, RoleServer},
195    tool_handler,
196    transport::streamable_http_server::{
197        StreamableHttpServerConfig, session::local::LocalSessionManager,
198        tower::StreamableHttpService,
199    },
200};
201use sqlx::PgPool;
202use wowlab_common::{markdown::Doc, time::Instant};
203
204use crate::{
205    telemetry::{self, McpErrorType, McpTool},
206    utils::meta,
207};
208
209const SUPPORTED_PROTOCOL_VERSIONS: &[rmcp::model::ProtocolVersion] =
210    rmcp::model::ProtocolVersion::KNOWN_VERSIONS;
211const DISCOVERABLE_PROTOCOL_VERSIONS: &[rmcp::model::ProtocolVersion] =
212    &[rmcp::model::ProtocolVersion::V_2026_07_28];
213const DISCOVERY_CACHE_TTL_MS: u64 = 300_000;
214const SERVER_INFO_META_KEY: &str = "io.modelcontextprotocol/serverInfo";
215
216fn portable_schema(schema: Arc<rmcp::model::JsonObject>) -> Arc<rmcp::model::JsonObject> {
217    use schemars::transform::{RestrictFormats, Transform};
218
219    let mut schema = schemars::Schema::from(Arc::unwrap_or_clone(schema));
220
221    RestrictFormats::default().transform(&mut schema);
222    let serde_json::Value::Object(schema) = serde_json::Value::from(schema) else {
223        unreachable!("tool schemas have object roots")
224    };
225
226    Arc::new(schema)
227}
228
229fn output_schema_for<T>() -> Arc<rmcp::model::JsonObject>
230where
231    T: schemars::JsonSchema,
232{
233    let generator = schemars::generate::SchemaSettings::draft2020_12()
234        .for_serialize()
235        .into_generator();
236    let schema = generator.into_root_schema_for::<T>();
237    let serde_json::Value::Object(mut schema) = serde_json::Value::from(schema) else {
238        unreachable!("JSON schemas have object roots")
239    };
240
241    schema.remove("title");
242    schema.remove("description");
243
244    portable_schema(Arc::new(schema))
245}
246
247const fn current_protocol_version() -> rmcp::model::ProtocolVersion {
248    rmcp::model::ProtocolVersion::V_2026_07_28
249}
250
251pub(crate) async fn with_metrics<F, Fut>(tool: McpTool, f: F) -> Result<CallToolResult, ErrorData>
252where
253    F: FnOnce() -> Fut,
254    Fut: Future<Output = Result<CallToolResult, ErrorData>>,
255{
256    let start = Instant::now();
257
258    telemetry::record_mcp_request(tool);
259    let result = f().await;
260
261    if result.is_ok() {
262        telemetry::record_mcp_duration(tool, start.elapsed().as_secs_f64());
263    }
264
265    result
266}
267
268pub(crate) trait McpFailure: std::fmt::Display {
269    fn error_type(&self) -> McpErrorType;
270}
271
272fn mcp_error(tool: McpTool, error: &impl McpFailure) -> CallToolResult {
273    let message = error.to_string();
274
275    telemetry::record_mcp_error(tool, error.error_type());
276
277    with_result_meta(CallToolResult::error(vec![ContentBlock::text(message)]))
278}
279
280fn invalid_params_result(tool: McpTool, message: impl Into<String>) -> CallToolResult {
281    struct InvalidParams(String);
282
283    impl std::fmt::Display for InvalidParams {
284        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
285            f.write_str(&self.0)
286        }
287    }
288
289    impl McpFailure for InvalidParams {
290        fn error_type(&self) -> McpErrorType {
291            McpErrorType::InvalidParams
292        }
293    }
294
295    mcp_error(tool, &InvalidParams(message.into()))
296}
297
298fn json_result(v: impl serde::Serialize) -> CallToolResult {
299    match serde_json::to_value(v) {
300        Ok(value) => with_result_meta(CallToolResult::structured(value)),
301        Err(error) => with_result_meta(CallToolResult::error(vec![ContentBlock::text(format!(
302            "Failed to serialize tool result: {error}"
303        ))])),
304    }
305}
306
307fn text_result(text: impl Into<String>) -> CallToolResult {
308    let text = text.into();
309    let mut result = CallToolResult::structured(serde_json::Value::String(text.clone()));
310
311    result.content = vec![ContentBlock::text(text)];
312
313    with_result_meta(result)
314}
315
316fn server_implementation() -> Implementation {
317    Implementation::new("wowlab", env!("CARGO_PKG_VERSION"))
318        .with_title("WoW Lab MCP Server")
319        .with_website_url(meta::WEBSITE)
320}
321
322fn result_meta() -> MetaObject {
323    let mut meta = MetaObject::new();
324    let server_info = serde_json::to_value(server_implementation())
325        .expect("server implementation is serializable");
326
327    meta.insert(SERVER_INFO_META_KEY.to_owned(), server_info);
328
329    meta
330}
331
332fn with_result_meta(result: CallToolResult) -> CallToolResult {
333    result.with_meta(Some(result_meta()))
334}
335
336fn tool_result<T, E>(tool: McpTool, result: Result<T, E>) -> CallToolResult
337where
338    T: serde::Serialize,
339    E: McpFailure,
340{
341    match result {
342        Ok(value) => json_result(value),
343        Err(error) => mcp_error(tool, &error),
344    }
345}
346
347#[derive(Debug, Default, schemars::JsonSchema, serde::Deserialize)]
348#[schemars(description = "Fetch project documentation.")]
349pub(crate) struct GetDocsInput {
350    #[serde(default)]
351    #[schemars(description = "Return full content instead of index. Default: false.")]
352    pub full: Option<bool>,
353}
354
355#[derive(Debug, Default, schemars::JsonSchema, serde::Deserialize)]
356#[schemars(description = "No parameters.")]
357#[expect(
358    clippy::empty_structs_with_brackets,
359    reason = "MCP input must remain a JSON object rather than a unit value"
360)]
361pub(crate) struct EmptyInput {}
362
363#[derive(Clone)]
364pub(crate) struct McpHandler {
365    pub db: PgPool,
366    pub mcp: crate::state::McpStateHandle,
367    tool_router: ToolRouter<Self>,
368}
369
370impl McpHandler {
371    pub(crate) fn new(db: PgPool, mcp: crate::state::McpStateHandle) -> Self {
372        Self {
373            db,
374            mcp,
375            tool_router: build_tool_router(),
376        }
377    }
378}
379
380fn build_tool_router() -> ToolRouter<McpHandler> {
381    ToolRouter::new()
382        .with_route(tools::build_get_docs_route())
383        .with_route(tools::build_list_tables_route())
384        .with_route(tools::build_get_schema_route())
385        .with_route(tools::build_query_route())
386        .with_route(tools::build_query_batch_route())
387        .with_route(tools::build_query_count_route())
388        .with_route(tools::build_resolve_effects_route())
389        .with_route(tools::build_decode_loadout_route())
390        .with_route(tools::build_get_rotation_route())
391        .with_route(tools::build_list_rotations_route())
392        .with_route(tools::build_list_spell_labels_route())
393        .with_route(tools::build_parse_simc_route())
394}
395
396impl std::fmt::Debug for McpHandler {
397    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
398        f.debug_struct("McpHandler")
399            .field("db", &"<PgPool>")
400            .field("mcp", &self.mcp)
401            .finish_non_exhaustive()
402    }
403}
404
405#[tool_handler(router = self.tool_router)]
406impl ServerHandler for McpHandler {
407    fn supported_protocol_versions(&self) -> Cow<'static, [rmcp::model::ProtocolVersion]> {
408        Cow::Borrowed(SUPPORTED_PROTOCOL_VERSIONS)
409    }
410
411    fn get_info(&self) -> ServerInfo {
412        ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
413            .with_protocol_version(current_protocol_version())
414            .with_server_info(server_implementation())
415            .with_instructions(mcp_instructions())
416    }
417
418    async fn list_tools(
419        &self,
420        _request: Option<PaginatedRequestParams>,
421        _context: RequestContext<RoleServer>,
422    ) -> Result<ListToolsResult, ErrorData> {
423        Ok(list_tools_result(&self.tool_router))
424    }
425
426    async fn discover(
427        &self,
428        _context: RequestContext<RoleServer>,
429    ) -> Result<DiscoverResult, ErrorData> {
430        Ok(DiscoverResult::from_server_info(
431            DISCOVERABLE_PROTOCOL_VERSIONS.to_vec(),
432            self.get_info(),
433        )
434        .with_ttl_ms(DISCOVERY_CACHE_TTL_MS)
435        .with_cache_scope(CacheScope::Public))
436    }
437}
438
439fn list_tools_result(router: &ToolRouter<McpHandler>) -> ListToolsResult {
440    let mut result = ListToolsResult::with_all_items(router.list_all())
441        .with_ttl_ms(DISCOVERY_CACHE_TTL_MS)
442        .with_cache_scope(CacheScope::Public);
443
444    result.meta = Some(result_meta());
445
446    result
447}
448
449pub(crate) fn create_service(
450    db: PgPool,
451    mcp: crate::state::McpStateHandle,
452) -> StreamableHttpService<McpHandler, LocalSessionManager> {
453    StreamableHttpService::new(
454        move || Ok(McpHandler::new(db.clone(), Arc::clone(&mcp))),
455        Arc::new(LocalSessionManager::default()),
456        server_config(),
457    )
458}
459
460fn server_config() -> StreamableHttpServerConfig {
461    StreamableHttpServerConfig::default()
462        .with_legacy_session_mode(true)
463        .with_json_response(true)
464        .with_sse_keep_alive(None)
465        .with_stateless_protocol_metadata_required(true)
466        .disable_allowed_hosts()
467}
468
469fn mcp_instructions() -> String {
470    let doc = schema::build_workflow_doc(
471        Doc::new()
472            .line("WoW game data and rotation API.")
473            .blank()
474            .h2("Workflow"),
475    )
476    .blank()
477    .h2("Tips");
478
479    schema::build_tips_doc(doc).build()
480}
481
482#[cfg(test)]
483// #t(block: rust_inline_test_module_size) private transport and schema contracts exercise module-only route helpers
484mod tests;