Skip to main content

outrig_cli/mcp_self/
server.rs

1use std::sync::Arc;
2
3use rmcp::ErrorData as McpError;
4use rmcp::ServerHandler;
5use rmcp::model::{
6    CallToolRequestParams, CallToolResult, Content, Implementation, JsonObject, ListToolsResult,
7    PaginatedRequestParams, ServerCapabilities, ServerInfo, Tool, ToolAnnotations,
8};
9use rmcp::service::{RequestContext, RoleServer};
10use schemars::JsonSchema;
11use serde::Serialize;
12use serde::de::DeserializeOwned;
13use tokio_util::sync::CancellationToken;
14
15use crate::error::{OutrigError, Result};
16use crate::mcp_self::{docs, schema, suggestions, validate};
17
18const LIST_DOCS: &str = "list_docs";
19const GET_DOC: &str = "get_doc";
20const GET_CONFIG_SCHEMA: &str = "get_config_schema";
21const LIST_BASE_IMAGES: &str = "list_base_images";
22const LIST_MCP_SERVER_SUGGESTIONS: &str = "list_mcp_server_suggestions";
23const VALIDATE_DOCKERFILE: &str = "validate_dockerfile";
24const VALIDATE_CONFIG: &str = "validate_config";
25const VALIDATE_IMAGE_TOML: &str = "validate_image_toml";
26
27#[derive(Debug, Clone, Default)]
28pub struct SelfServer;
29
30#[derive(Debug, serde::Deserialize, JsonSchema)]
31#[serde(deny_unknown_fields)]
32struct GetDocArgs {
33    page: String,
34}
35
36#[derive(Debug, serde::Deserialize, JsonSchema)]
37#[serde(deny_unknown_fields)]
38struct ValidateDockerfileArgs {
39    dockerfile: String,
40}
41
42#[derive(Debug, serde::Deserialize, JsonSchema)]
43#[serde(deny_unknown_fields)]
44struct ValidateConfigArgs {
45    toml: String,
46}
47
48pub async fn serve_stdio() -> Result<i32> {
49    let ct = CancellationToken::new();
50    let service =
51        rmcp::service::serve_server_with_ct(SelfServer, rmcp::transport::stdio(), ct).await?;
52    eprintln!("[outrig] mcp self server ready");
53    match service.waiting().await {
54        Ok(reason) => {
55            tracing::debug!(target: "outrig::mcp_self", "rmcp service exited: {reason:?}");
56            Ok(0)
57        }
58        Err(err) => {
59            Err(OutrigError::Configuration(format!("mcp self server task failed: {err}")).into())
60        }
61    }
62}
63
64impl SelfServer {
65    fn tools() -> Vec<Tool> {
66        vec![
67            tool::<EmptyArgs>(
68                LIST_DOCS,
69                "List Docs",
70                "List embedded OutRig documentation pages with one-line summaries.",
71            ),
72            tool::<GetDocArgs>(
73                GET_DOC,
74                "Get Doc",
75                "Return the markdown for one embedded documentation page.",
76            ),
77            tool::<EmptyArgs>(
78                GET_CONFIG_SCHEMA,
79                "Get Config Schema",
80                "Return JSON Schema plus path and image-label hints.",
81            ),
82            tool::<EmptyArgs>(
83                LIST_BASE_IMAGES,
84                "List Base Image Suggestions",
85                "List base-image suggestions used by outrig image add.",
86            ),
87            tool::<EmptyArgs>(
88                LIST_MCP_SERVER_SUGGESTIONS,
89                "List MCP Server Suggestions",
90                "List MCP server suggestions and shell guidance for OutRig images.",
91            ),
92            tool::<ValidateDockerfileArgs>(
93                VALIDATE_DOCKERFILE,
94                "Validate Dockerfile",
95                "Return advisory warnings for a proposed OutRig image Dockerfile.",
96            ),
97            tool::<ValidateConfigArgs>(
98                VALIDATE_CONFIG,
99                "Validate Config",
100                "Parse and validate a TOML fragment containing [images.<name>] entries.",
101            ),
102            tool::<ValidateConfigArgs>(
103                VALIDATE_IMAGE_TOML,
104                "Validate Image TOML",
105                "Parse and validate complete standalone image.toml content.",
106            ),
107        ]
108    }
109
110    fn dispatch(request: CallToolRequestParams) -> std::result::Result<CallToolResult, McpError> {
111        match request.name.as_ref() {
112            LIST_DOCS => json_result(docs::list_docs()),
113            GET_DOC => {
114                let args: GetDocArgs = match parse_args(request.arguments) {
115                    Ok(args) => args,
116                    Err(result) => return Ok(result),
117                };
118                match docs::get_doc(&args.page) {
119                    Some(doc) => json_result(doc),
120                    None => Ok(CallToolResult::error(vec![Content::text(format!(
121                        "unknown doc page: {}",
122                        args.page
123                    ))])),
124                }
125            }
126            GET_CONFIG_SCHEMA => json_result(schema::get_config_schema()),
127            LIST_BASE_IMAGES => json_result(suggestions::list_base_images()),
128            LIST_MCP_SERVER_SUGGESTIONS => json_result(suggestions::list_mcp_server_suggestions()),
129            VALIDATE_DOCKERFILE => {
130                let args: ValidateDockerfileArgs = match parse_args(request.arguments) {
131                    Ok(args) => args,
132                    Err(result) => return Ok(result),
133                };
134                json_result(validate::validate_dockerfile(&args.dockerfile))
135            }
136            VALIDATE_CONFIG => {
137                let args: ValidateConfigArgs = match parse_args(request.arguments) {
138                    Ok(args) => args,
139                    Err(result) => return Ok(result),
140                };
141                json_result(validate::validate_config(&args.toml))
142            }
143            VALIDATE_IMAGE_TOML => {
144                let args: ValidateConfigArgs = match parse_args(request.arguments) {
145                    Ok(args) => args,
146                    Err(result) => return Ok(result),
147                };
148                json_result(validate::validate_image_toml(&args.toml))
149            }
150            other => Ok(CallToolResult::error(vec![Content::text(format!(
151                "unknown tool: {other}"
152            ))])),
153        }
154    }
155}
156
157impl ServerHandler for SelfServer {
158    fn get_info(&self) -> ServerInfo {
159        ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
160            .with_server_info(Implementation::new(
161                "outrig-self",
162                env!("CARGO_PKG_VERSION"),
163            ))
164            .with_instructions(
165                "Read OutRig docs and schema, then validate proposed image artifacts. \
166                 This server never writes files or builds images. If your client permits normal \
167                 repo edits, write the validated artifacts directly; otherwise return exact file \
168                 contents and paths for the user to install. Do not stage files in /tmp and ask \
169                 for an opaque copy into .agents/outrig.",
170            )
171    }
172
173    async fn list_tools(
174        &self,
175        _request: Option<PaginatedRequestParams>,
176        _ctx: RequestContext<RoleServer>,
177    ) -> std::result::Result<ListToolsResult, McpError> {
178        Ok(ListToolsResult {
179            next_cursor: None,
180            meta: None,
181            tools: Self::tools(),
182        })
183    }
184
185    async fn call_tool(
186        &self,
187        request: CallToolRequestParams,
188        _ctx: RequestContext<RoleServer>,
189    ) -> std::result::Result<CallToolResult, McpError> {
190        Self::dispatch(request)
191    }
192
193    fn get_tool(&self, name: &str) -> Option<Tool> {
194        Self::tools()
195            .into_iter()
196            .find(|tool| tool.name.as_ref() == name)
197    }
198}
199
200#[derive(Debug, serde::Deserialize, JsonSchema)]
201#[serde(deny_unknown_fields)]
202struct EmptyArgs {}
203
204fn tool<T: JsonSchema>(name: &'static str, title: &'static str, description: &'static str) -> Tool {
205    Tool::new(name, description, input_schema::<T>())
206        .with_title(title)
207        .with_annotations(read_only_annotations(title))
208}
209
210fn read_only_annotations(title: &'static str) -> ToolAnnotations {
211    ToolAnnotations::with_title(title)
212        .read_only(true)
213        .open_world(false)
214}
215
216fn input_schema<T: JsonSchema>() -> Arc<JsonObject> {
217    let value = serde_json::to_value(schemars::schema_for!(T)).expect("schema serializes");
218    match value {
219        serde_json::Value::Object(map) => Arc::new(map),
220        _ => unreachable!("schemars root schema serializes as an object"),
221    }
222}
223
224#[allow(clippy::result_large_err)]
225fn parse_args<T: DeserializeOwned>(
226    arguments: Option<JsonObject>,
227) -> std::result::Result<T, CallToolResult> {
228    serde_json::from_value(serde_json::Value::Object(arguments.unwrap_or_default())).map_err(
229        |err| CallToolResult::error(vec![Content::text(format!("invalid arguments: {err}"))]),
230    )
231}
232
233fn json_result<T: Serialize>(value: T) -> std::result::Result<CallToolResult, McpError> {
234    Ok(CallToolResult::success(vec![Content::json(value)?]))
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240    use rmcp::model::RawContent;
241    use serde_json::json;
242
243    fn call(name: &str, args: serde_json::Value) -> CallToolResult {
244        let arguments = match args {
245            serde_json::Value::Object(map) => Some(map),
246            serde_json::Value::Null => None,
247            other => panic!("test args must be object or null, got {other:?}"),
248        };
249        let mut request = CallToolRequestParams::new(name.to_string());
250        if let Some(arguments) = arguments {
251            request = request.with_arguments(arguments);
252        }
253        SelfServer::dispatch(request).expect("dispatch")
254    }
255
256    fn text(result: &CallToolResult) -> &str {
257        match &result.content[0].raw {
258            RawContent::Text(t) => &t.text,
259            other => panic!("expected text content, got {other:?}"),
260        }
261    }
262
263    #[test]
264    fn lists_expected_tool_set() {
265        let tools: Vec<String> = SelfServer::tools()
266            .iter()
267            .map(|tool| tool.name.as_ref().to_string())
268            .collect();
269        assert_eq!(
270            tools,
271            vec![
272                LIST_DOCS,
273                GET_DOC,
274                GET_CONFIG_SCHEMA,
275                LIST_BASE_IMAGES,
276                LIST_MCP_SERVER_SUGGESTIONS,
277                VALIDATE_DOCKERFILE,
278                VALIDATE_CONFIG,
279                VALIDATE_IMAGE_TOML,
280            ],
281        );
282    }
283
284    #[test]
285    fn tool_list_is_read_only_and_closed_world() {
286        for tool in SelfServer::tools() {
287            let annotations = tool
288                .annotations
289                .as_ref()
290                .unwrap_or_else(|| panic!("{} should have annotations", tool.name));
291            assert_eq!(annotations.read_only_hint, Some(true));
292            assert_eq!(annotations.open_world_hint, Some(false));
293            assert!(annotations.title.is_some());
294        }
295    }
296
297    #[test]
298    fn get_doc_dispatch_returns_json_text() {
299        let result = call(GET_DOC, json!({"page": "concepts/containers"}));
300        assert_eq!(result.is_error, Some(false));
301        assert!(text(&result).contains("# Containers"));
302    }
303
304    #[test]
305    fn invalid_arguments_are_tool_errors() {
306        let result = call(
307            GET_DOC,
308            json!({"page": "concepts/containers", "extra": true}),
309        );
310        assert_eq!(result.is_error, Some(true));
311        assert!(text(&result).contains("invalid arguments"));
312    }
313}