Skip to main content

artifactize_core/
mcp.rs

1//! MCP is a bounded transport over the same core operations as the CLI.
2mod catalog;
3mod input;
4mod page;
5mod read;
6
7use crate::{project::VerifyOptions, runs};
8use input::*;
9use rmcp::{ErrorData, ServerHandler, ServiceExt, model::*, service::RequestContext};
10use serde::{Serialize, de::DeserializeOwned};
11use serde_json::{Value, json};
12use std::time::Duration;
13use tokio_util::sync::CancellationToken;
14
15/// Leave room under the host's 45-second deadline for transport serialization.
16const MAX_WAIT_SECONDS: u64 = 40;
17const DEFAULT_WAIT_SECONDS: u64 = 30;
18/// Limit each Run-list query to the agreed maximum record group; offset selects older groups.
19const RUN_LIST_LIMIT: u32 = 20;
20/// Includes a zero-bound initial read and failure recovery; no call reaches 45 s.
21const WAIT_CALL_LIMIT: Duration = Duration::from_secs(44);
22/// Both mirrors count; reserve 1 KiB for JSON-RPC framing under the 64 KiB public cap.
23const RESPONSE_BYTES: usize = 63 * 1024;
24/// Operational errors are readable diagnostics, not unbounded owner output.
25const ERROR_CHARS: usize = 2048;
26
27#[derive(Clone)]
28pub struct Server {
29    context: runs::Context,
30}
31impl Server {
32    pub fn new(context: runs::Context) -> Self {
33        Self { context }
34    }
35
36    async fn invoke(&self, name: &str, arguments: Value) -> Result<Value, String> {
37        let context = &self.context;
38        match name {
39            "verify_start" => {
40                let args: Start = parse(arguments)?;
41                let options = VerifyOptions {
42                    jobs: args.jobs.map(|limit| limit as usize),
43                    modes: runs::RunModes {
44                        speculate: args.speculate,
45                        defer_human: args.defer_human,
46                    },
47                    ..Default::default()
48                };
49                encode(runs::start(context, &args.scope.0, &options, args.request_key).await?)
50            }
51            "verify_status" => {
52                let args: Run = parse(arguments)?;
53                encode(runs::status(context, &args.run).await?)
54            }
55            "verify_wait" => {
56                let args: Wait = parse(arguments)?;
57                let seconds = args.seconds.unwrap_or(DEFAULT_WAIT_SECONDS);
58                if seconds > MAX_WAIT_SECONDS {
59                    return Err("seconds must be an integer from 0 through 40.".into());
60                }
61                encode(
62                    tokio::time::timeout(
63                        WAIT_CALL_LIMIT,
64                        runs::wait(
65                            context,
66                            &args.run,
67                            Some(Duration::from_secs(seconds)),
68                            CancellationToken::new(),
69                        ),
70                    )
71                    .await
72                    .map_err(|_| "State unavailable within the MCP observation deadline.")??,
73                )
74            }
75            "verify_result" => {
76                let args: ResultInput = parse(arguments)?;
77                encode(runs::result(context, &args.run, args.cursor).await?)
78            }
79            "verify_cancel" => {
80                let args: Run = parse(arguments)?;
81                encode(runs::cancel(context, &args.run).await?)
82            }
83            name => read::inspect(context, name, arguments).await,
84        }
85    }
86}
87fn parse<T: DeserializeOwned>(value: Value) -> Result<T, String> {
88    serde_json::from_value(value).map_err(|error| format!("Invalid arguments: {error}"))
89}
90fn encode(value: impl Serialize) -> Result<Value, String> {
91    serde_json::to_value(value).map_err(|error| error.to_string())
92}
93impl ServerHandler for Server {
94    fn get_info(&self) -> ServerConfig {
95        ServerConfig::new(ServerCapabilities::builder().enable_tools().build())
96            .with_server_info(Implementation::new("artifactize", env!("CARGO_PKG_VERSION")))
97            .with_instructions("Runs belong to artifactize, never the MCP connection. Retain the Run ID; use verify_wait rather than short polling. Human sign-offs belong to people. Read tools page the exact CLI JSON as UTF-8 text; append entries.text until next_cursor is null.")
98    }
99    async fn list_tools(
100        &self,
101        _: Option<PaginatedRequestParams>,
102        _: RequestContext<rmcp::RoleServer>,
103    ) -> Result<ListToolsResult, ErrorData> {
104        Ok(ListToolsResult {
105            tools: catalog::tools(),
106            ..Default::default()
107        })
108    }
109    fn get_tool(&self, name: &str) -> Option<Tool> {
110        catalog::tools().into_iter().find(|tool| tool.name == name)
111    }
112    async fn call_tool(
113        &self,
114        request: CallToolRequestParams,
115        _: RequestContext<rmcp::RoleServer>,
116    ) -> Result<CallToolResponse, ErrorData> {
117        let arguments = Value::Object(request.arguments.unwrap_or_default());
118        let result = match self.invoke(&request.name, arguments).await {
119            Ok(value) => CallToolResult::structured(value),
120            Err(error) => CallToolResult::structured_error(
121                json!({"code":"operation_failed", "message":error.chars().take(ERROR_CHARS).collect::<String>()}),
122            ),
123        };
124        if serde_json::to_vec(&result)
125            .map_err(|error| ErrorData::internal_error(error.to_string(), None))?
126            .len()
127            > RESPONSE_BYTES
128        {
129            return Ok(CallToolResult::structured_error(json!({
130                "code":"response_too_large", "message":"Response exceeds the detail bound; narrow the inspection."
131            })).into());
132        }
133        Ok(result.into())
134    }
135}
136
137/// Only rmcp writes stdout; detached Run workers own their own diagnostics.
138pub async fn serve(context: runs::Context) -> Result<(), String> {
139    match Server::new(context).serve(rmcp::transport::stdio()).await {
140        Ok(service) => {
141            service.waiting().await.map_err(|e| e.to_string())?;
142            Ok(())
143        }
144        // A modern discovery probe can finish without opening an inline session.
145        Err(rmcp::service::ServerInitializeError::ConnectionClosed(_)) => Ok(()),
146        Err(error) => Err(error.to_string()),
147    }
148}