matomo-mcp 0.1.4

Fast, curated MCP server for Matomo Analytics — ask your analytics anything from Claude, Cursor, or any MCP client
Documentation
//! MCP server handler wiring the tool registry to the Matomo client.

use std::sync::Arc;

use rmcp::handler::server::ServerHandler;
use rmcp::model::*;
use rmcp::service::{RequestContext, RoleServer};
use rmcp::ErrorData;
use tracing::debug;

use crate::client::MatomoClient;
use crate::tools::Registry;

/// Guidance returned when the server runs without a configured Matomo URL.
const UNCONFIGURED_HINT: &str =
    "Matomo is not configured. Set the MATOMO_URL (and usually MATOMO_TOKEN) environment \
     variables — or the --url/--token flags — and restart the MCP server. Example: \
     MATOMO_URL=https://matomo.example.com MATOMO_TOKEN=... matomo-mcp";

#[derive(Clone)]
pub struct MatomoServer {
    client: Option<Arc<MatomoClient>>,
    registry: Arc<Registry>,
    matomo_url: String,
    max_response_chars: usize,
}

impl MatomoServer {
    pub fn new(
        client: Option<MatomoClient>,
        registry: Registry,
        matomo_url: String,
        max_response_chars: usize,
    ) -> Self {
        Self {
            client: client.map(Arc::new),
            registry: Arc::new(registry),
            matomo_url,
            max_response_chars,
        }
    }
}

/// Compact-serialize a response, truncating at a character budget so a single
/// tool call can never flood the model's context window.
pub fn shape_response(value: &serde_json::Value, max_chars: usize) -> String {
    let text = value.to_string();
    if text.chars().count() <= max_chars {
        return text;
    }
    let cut: String = text.chars().take(max_chars).collect();
    format!(
        "{cut}\n... [response truncated at {max_chars} characters — narrow the query with \
         'limit', a shorter date range, or a segment]"
    )
}

impl ServerHandler for MatomoServer {
    fn get_info(&self) -> ServerInfo {
        ServerInfo {
            // rmcp negotiates down to the client's version automatically.
            protocol_version: ProtocolVersion::LATEST,
            capabilities: ServerCapabilities::builder().enable_tools().build(),
            server_info: Implementation {
                name: "matomo-mcp".to_string(),
                version: env!("CARGO_PKG_VERSION").to_string(),
                icons: Some(vec![Icon {
                    src:
                        "https://raw.githubusercontent.com/Liohtml/matomo-mcp/main/assets/logo.svg"
                            .to_string(),
                    mime_type: Some("image/svg+xml".to_string()),
                    sizes: Some(vec!["any".to_string()]),
                }]),
                title: Some("Matomo Analytics".to_string()),
                website_url: Some("https://github.com/Liohtml/matomo-mcp".to_string()),
            },
            instructions: Some(format!(
                "Matomo Analytics for {url}. {count} read-only tools.\n\n\
                 Workflow:\n\
                 1. If the site_id is unknown, call matomo_list_sites first.\n\
                 2. Use the dedicated matomo_* tools for common questions (traffic, pages, \
                 referrers, goals, e-commerce, devices, locations, real-time).\n\
                 3. For anything else, call matomo_api with any 'Module.action' method; \
                 discover methods via method='API.getReportMetadata'.\n\n\
                 Dates accept 'today', 'yesterday', 'YYYY-MM-DD', 'last7'/'last30', or a \
                 'start,end' range with period=range. Most tools default to yesterday/day.",
                url = self.matomo_url,
                count = self.registry.tool_count(),
            )),
        }
    }

    async fn list_tools(
        &self,
        _request: Option<PaginatedRequestParam>,
        _context: RequestContext<RoleServer>,
    ) -> Result<ListToolsResult, ErrorData> {
        Ok(ListToolsResult {
            tools: self.registry.mcp_tools(),
            next_cursor: None,
            meta: None,
        })
    }

    async fn call_tool(
        &self,
        request: CallToolRequestParam,
        _context: RequestContext<RoleServer>,
    ) -> Result<CallToolResult, ErrorData> {
        let tool_name = request.name.as_ref();
        let args = request.arguments.unwrap_or_default();
        debug!(tool = tool_name, "tool call");

        let invocation = match self.registry.resolve(tool_name, &args) {
            Ok(inv) => inv,
            Err(message) => return Ok(error_result(message)),
        };

        let Some(client) = &self.client else {
            return Ok(error_result(UNCONFIGURED_HINT.to_string()));
        };

        match client.call(&invocation.method, &invocation.params).await {
            Ok(value) => Ok(CallToolResult {
                content: vec![Content::text(shape_response(
                    &value,
                    self.max_response_chars,
                ))],
                is_error: Some(false),
                meta: None,
                structured_content: None,
            }),
            Err(err) => Ok(error_result(err.to_string())),
        }
    }
}

fn error_result(message: String) -> CallToolResult {
    CallToolResult {
        content: vec![Content::text(message)],
        is_error: Some(true),
        meta: None,
        structured_content: None,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    #[test]
    fn shape_passes_small_responses_through() {
        let value = json!({"nb_visits": 42});
        assert_eq!(shape_response(&value, 1_000), value.to_string());
    }

    #[test]
    fn shape_truncates_large_responses_with_guidance() {
        let value = json!(vec!["row"; 10_000]);
        let shaped = shape_response(&value, 500);
        assert!(shaped.contains("truncated at 500"));
        assert!(shaped.contains("limit"));
        assert!(shaped.chars().count() < 700);
    }

    #[test]
    fn shape_is_utf8_safe() {
        let value = json!("ü".repeat(2_000));
        let shaped = shape_response(&value, 100);
        assert!(shaped.contains("truncated"));
    }
}