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}