Skip to main content

codoseo_web/agent/
mcp.rs

1//! The cloud MCP server's backend: [`CloudBackend`] over the shared [`AgentService`], so a keyed
2//! tool call returns the JSON the REST API returns and is charged to the same daily allowance,
3//! and [`AnonBackend`] over [`AnonService`] for the no-key tools.
4//! Errors become the message the agent reads as a tool error (`AgentError::message`, never raw
5//! internal text; quota exhaustion reads the same as over REST).
6
7use std::convert::Infallible;
8
9use codoseo_mcp::cloud::types::{
10    AuditIssueUrls, ChangesPage, CrawlQueued, IssueUrlsPage, MonitoringRequested, PageInfo,
11    QuickAuditState, SiteHealth, SiteInfo,
12};
13use codoseo_mcp::cloud::{AnonBackend, CloudBackend};
14
15use super::anon::{AnonCaller, AnonService};
16use super::auth::ApiCaller;
17use super::error::AgentError;
18use super::service::{AgentService, Reply};
19use crate::metrics::{self, Surface, Tier};
20use crate::state::AppState;
21
22#[derive(Clone)]
23pub struct AgentBackend {
24    state: AppState,
25}
26
27impl AgentBackend {
28    pub fn new(state: AppState) -> AgentBackend {
29        AgentBackend { state }
30    }
31
32    fn service(&self) -> AgentService<'_> {
33        AgentService::new(&self.state)
34    }
35}
36
37/// What the agent is told for a failed call. An internal failure is logged here (REST logs it
38/// when it renders the response) and the agent only sees the generic message.
39fn tool_error(e: AgentError) -> String {
40    if let AgentError::Internal(detail) = &e {
41        tracing::error!(%detail, "mcp tool call failed");
42    }
43    e.message()
44}
45
46fn outcome<T>(reply: Reply<T>) -> Result<T, String> {
47    let result = reply.into_result();
48    metrics::api_request(
49        Surface::Mcp,
50        Tier::Key,
51        result.as_ref().map_or_else(AgentError::code, |_| "ok"),
52    );
53    result.map_err(tool_error)
54}
55
56/// A no-key tool call's outcome, counted. Those errors are plain messages without a code.
57fn anon<T>(result: Result<T, String>) -> Result<T, String> {
58    let label = if result.is_ok() { "ok" } else { "error" };
59    metrics::api_request(Surface::Mcp, Tier::Anon, label);
60    result
61}
62
63impl CloudBackend for AgentBackend {
64    type Keyed = ApiCaller;
65    type Anon = AnonCaller;
66
67    async fn list_sites(&self, who: &ApiCaller) -> Result<Vec<SiteInfo>, String> {
68        outcome(self.service().list_sites(who).await)
69    }
70
71    async fn site_health(&self, who: &ApiCaller, site_id: &str) -> Result<SiteHealth, String> {
72        outcome(self.service().site_health(who, site_id).await)
73    }
74
75    async fn issue_urls(
76        &self,
77        who: &ApiCaller,
78        site_id: &str,
79        check: &str,
80        limit: Option<u32>,
81        offset: Option<u32>,
82    ) -> Result<IssueUrlsPage, String> {
83        outcome(
84            self.service()
85                .issue_urls(who, site_id, check, limit, offset)
86                .await,
87        )
88    }
89
90    async fn page(&self, who: &ApiCaller, site_id: &str, url: &str) -> Result<PageInfo, String> {
91        outcome(self.service().page(who, site_id, url).await)
92    }
93
94    async fn changes(
95        &self,
96        who: &ApiCaller,
97        site_id: &str,
98        severity: Option<&str>,
99        limit: Option<u32>,
100        offset: Option<u32>,
101    ) -> Result<ChangesPage, String> {
102        outcome(
103            self.service()
104                .changes(who, site_id, severity, limit, offset)
105                .await,
106        )
107    }
108
109    async fn run_crawl(&self, who: &ApiCaller, site_id: &str) -> Result<CrawlQueued, String> {
110        outcome(self.service().run_crawl(who, site_id).await)
111    }
112
113    async fn reject(&self, who: &ApiCaller, message: String) -> Result<Infallible, String> {
114        let refused = self
115            .service()
116            .refuse::<Infallible>(who, AgentError::BadRequest(message))
117            .await;
118        outcome(refused)
119    }
120}
121
122impl AnonBackend for AgentBackend {
123    async fn quick_audit(&self, who: &AnonCaller, url: &str) -> Result<QuickAuditState, String> {
124        anon(AnonService::new(&self.state).quick_audit(who, url).await)
125    }
126
127    async fn get_audit(&self, who: &AnonCaller, audit_id: &str) -> Result<QuickAuditState, String> {
128        anon(AnonService::new(&self.state).get_audit(who, audit_id).await)
129    }
130
131    /// `quick_audit` looks at its audit every second or so while it waits; those looks are part
132    /// of the one tool call the client's allowance already counted.
133    async fn poll_audit(&self, _: &AnonCaller, audit_id: &str) -> Result<QuickAuditState, String> {
134        AnonService::new(&self.state).poll_audit(audit_id).await
135    }
136
137    async fn audit_issue_urls(
138        &self,
139        who: &AnonCaller,
140        audit_id: &str,
141        check: &str,
142        limit: Option<u32>,
143        offset: Option<u32>,
144    ) -> Result<AuditIssueUrls, String> {
145        anon(
146            AnonService::new(&self.state)
147                .audit_issue_urls(who, audit_id, check, limit, offset)
148                .await,
149        )
150    }
151
152    async fn start_monitoring(
153        &self,
154        who: &AnonCaller,
155        url: &str,
156        email: &str,
157    ) -> Result<MonitoringRequested, String> {
158        anon(
159            AnonService::new(&self.state)
160                .start_monitoring(who, url, email)
161                .await,
162        )
163    }
164}