outrig-cli 0.1.0

Command-line tool for running LLM agents with podman-isolated MCP servers.
Documentation
use std::sync::Arc;

use rmcp::ErrorData as McpError;
use rmcp::ServerHandler;
use rmcp::model::{
    CallToolRequestParams, CallToolResult, Content, Implementation, JsonObject, ListToolsResult,
    PaginatedRequestParams, ServerCapabilities, ServerInfo, Tool, ToolAnnotations,
};
use rmcp::service::{RequestContext, RoleServer};
use schemars::JsonSchema;
use serde::Serialize;
use serde::de::DeserializeOwned;
use tokio_util::sync::CancellationToken;

use crate::error::{OutrigError, Result};
use crate::mcp_self::{docs, schema, suggestions, validate};

const LIST_DOCS: &str = "list_docs";
const GET_DOC: &str = "get_doc";
const GET_CONFIG_SCHEMA: &str = "get_config_schema";
const LIST_BASE_IMAGES: &str = "list_base_images";
const LIST_MCP_SERVER_SUGGESTIONS: &str = "list_mcp_server_suggestions";
const VALIDATE_DOCKERFILE: &str = "validate_dockerfile";
const VALIDATE_CONFIG: &str = "validate_config";
const VALIDATE_IMAGE_TOML: &str = "validate_image_toml";

#[derive(Debug, Clone, Default)]
pub struct SelfServer;

#[derive(Debug, serde::Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct GetDocArgs {
    page: String,
}

#[derive(Debug, serde::Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct ValidateDockerfileArgs {
    dockerfile: String,
}

#[derive(Debug, serde::Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct ValidateConfigArgs {
    toml: String,
}

pub async fn serve_stdio() -> Result<i32> {
    let ct = CancellationToken::new();
    let service =
        rmcp::service::serve_server_with_ct(SelfServer, rmcp::transport::stdio(), ct).await?;
    eprintln!("[outrig] mcp self server ready");
    match service.waiting().await {
        Ok(reason) => {
            tracing::debug!(target: "outrig::mcp_self", "rmcp service exited: {reason:?}");
            Ok(0)
        }
        Err(err) => {
            Err(OutrigError::Configuration(format!("mcp self server task failed: {err}")).into())
        }
    }
}

impl SelfServer {
    fn tools() -> Vec<Tool> {
        vec![
            tool::<EmptyArgs>(
                LIST_DOCS,
                "List Docs",
                "List embedded OutRig documentation pages with one-line summaries.",
            ),
            tool::<GetDocArgs>(
                GET_DOC,
                "Get Doc",
                "Return the markdown for one embedded documentation page.",
            ),
            tool::<EmptyArgs>(
                GET_CONFIG_SCHEMA,
                "Get Config Schema",
                "Return JSON Schema plus path and image-label hints.",
            ),
            tool::<EmptyArgs>(
                LIST_BASE_IMAGES,
                "List Base Image Suggestions",
                "List base-image suggestions used by outrig image add.",
            ),
            tool::<EmptyArgs>(
                LIST_MCP_SERVER_SUGGESTIONS,
                "List MCP Server Suggestions",
                "List MCP server suggestions and shell guidance for OutRig images.",
            ),
            tool::<ValidateDockerfileArgs>(
                VALIDATE_DOCKERFILE,
                "Validate Dockerfile",
                "Return advisory warnings for a proposed OutRig image Dockerfile.",
            ),
            tool::<ValidateConfigArgs>(
                VALIDATE_CONFIG,
                "Validate Config",
                "Parse and validate a TOML fragment containing [images.<name>] entries.",
            ),
            tool::<ValidateConfigArgs>(
                VALIDATE_IMAGE_TOML,
                "Validate Image TOML",
                "Parse and validate complete standalone image.toml content.",
            ),
        ]
    }

    fn dispatch(request: CallToolRequestParams) -> std::result::Result<CallToolResult, McpError> {
        match request.name.as_ref() {
            LIST_DOCS => json_result(docs::list_docs()),
            GET_DOC => {
                let args: GetDocArgs = match parse_args(request.arguments) {
                    Ok(args) => args,
                    Err(result) => return Ok(result),
                };
                match docs::get_doc(&args.page) {
                    Some(doc) => json_result(doc),
                    None => Ok(CallToolResult::error(vec![Content::text(format!(
                        "unknown doc page: {}",
                        args.page
                    ))])),
                }
            }
            GET_CONFIG_SCHEMA => json_result(schema::get_config_schema()),
            LIST_BASE_IMAGES => json_result(suggestions::list_base_images()),
            LIST_MCP_SERVER_SUGGESTIONS => json_result(suggestions::list_mcp_server_suggestions()),
            VALIDATE_DOCKERFILE => {
                let args: ValidateDockerfileArgs = match parse_args(request.arguments) {
                    Ok(args) => args,
                    Err(result) => return Ok(result),
                };
                json_result(validate::validate_dockerfile(&args.dockerfile))
            }
            VALIDATE_CONFIG => {
                let args: ValidateConfigArgs = match parse_args(request.arguments) {
                    Ok(args) => args,
                    Err(result) => return Ok(result),
                };
                json_result(validate::validate_config(&args.toml))
            }
            VALIDATE_IMAGE_TOML => {
                let args: ValidateConfigArgs = match parse_args(request.arguments) {
                    Ok(args) => args,
                    Err(result) => return Ok(result),
                };
                json_result(validate::validate_image_toml(&args.toml))
            }
            other => Ok(CallToolResult::error(vec![Content::text(format!(
                "unknown tool: {other}"
            ))])),
        }
    }
}

impl ServerHandler for SelfServer {
    fn get_info(&self) -> ServerInfo {
        ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
            .with_server_info(Implementation::new(
                "outrig-self",
                env!("CARGO_PKG_VERSION"),
            ))
            .with_instructions(
                "Read OutRig docs and schema, then validate proposed image artifacts. \
                 This server never writes files or builds images. If your client permits normal \
                 repo edits, write the validated artifacts directly; otherwise return exact file \
                 contents and paths for the user to install. Do not stage files in /tmp and ask \
                 for an opaque copy into .agents/outrig.",
            )
    }

    async fn list_tools(
        &self,
        _request: Option<PaginatedRequestParams>,
        _ctx: RequestContext<RoleServer>,
    ) -> std::result::Result<ListToolsResult, McpError> {
        Ok(ListToolsResult {
            next_cursor: None,
            meta: None,
            tools: Self::tools(),
        })
    }

    async fn call_tool(
        &self,
        request: CallToolRequestParams,
        _ctx: RequestContext<RoleServer>,
    ) -> std::result::Result<CallToolResult, McpError> {
        Self::dispatch(request)
    }

    fn get_tool(&self, name: &str) -> Option<Tool> {
        Self::tools()
            .into_iter()
            .find(|tool| tool.name.as_ref() == name)
    }
}

#[derive(Debug, serde::Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct EmptyArgs {}

fn tool<T: JsonSchema>(name: &'static str, title: &'static str, description: &'static str) -> Tool {
    Tool::new(name, description, input_schema::<T>())
        .with_title(title)
        .with_annotations(read_only_annotations(title))
}

fn read_only_annotations(title: &'static str) -> ToolAnnotations {
    ToolAnnotations::with_title(title)
        .read_only(true)
        .open_world(false)
}

fn input_schema<T: JsonSchema>() -> Arc<JsonObject> {
    let value = serde_json::to_value(schemars::schema_for!(T)).expect("schema serializes");
    match value {
        serde_json::Value::Object(map) => Arc::new(map),
        _ => unreachable!("schemars root schema serializes as an object"),
    }
}

#[allow(clippy::result_large_err)]
fn parse_args<T: DeserializeOwned>(
    arguments: Option<JsonObject>,
) -> std::result::Result<T, CallToolResult> {
    serde_json::from_value(serde_json::Value::Object(arguments.unwrap_or_default())).map_err(
        |err| CallToolResult::error(vec![Content::text(format!("invalid arguments: {err}"))]),
    )
}

fn json_result<T: Serialize>(value: T) -> std::result::Result<CallToolResult, McpError> {
    Ok(CallToolResult::success(vec![Content::json(value)?]))
}

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

    fn call(name: &str, args: serde_json::Value) -> CallToolResult {
        let arguments = match args {
            serde_json::Value::Object(map) => Some(map),
            serde_json::Value::Null => None,
            other => panic!("test args must be object or null, got {other:?}"),
        };
        let mut request = CallToolRequestParams::new(name.to_string());
        if let Some(arguments) = arguments {
            request = request.with_arguments(arguments);
        }
        SelfServer::dispatch(request).expect("dispatch")
    }

    fn text(result: &CallToolResult) -> &str {
        match &result.content[0].raw {
            RawContent::Text(t) => &t.text,
            other => panic!("expected text content, got {other:?}"),
        }
    }

    #[test]
    fn lists_expected_tool_set() {
        let tools: Vec<String> = SelfServer::tools()
            .iter()
            .map(|tool| tool.name.as_ref().to_string())
            .collect();
        assert_eq!(
            tools,
            vec![
                LIST_DOCS,
                GET_DOC,
                GET_CONFIG_SCHEMA,
                LIST_BASE_IMAGES,
                LIST_MCP_SERVER_SUGGESTIONS,
                VALIDATE_DOCKERFILE,
                VALIDATE_CONFIG,
                VALIDATE_IMAGE_TOML,
            ],
        );
    }

    #[test]
    fn tool_list_is_read_only_and_closed_world() {
        for tool in SelfServer::tools() {
            let annotations = tool
                .annotations
                .as_ref()
                .unwrap_or_else(|| panic!("{} should have annotations", tool.name));
            assert_eq!(annotations.read_only_hint, Some(true));
            assert_eq!(annotations.open_world_hint, Some(false));
            assert!(annotations.title.is_some());
        }
    }

    #[test]
    fn get_doc_dispatch_returns_json_text() {
        let result = call(GET_DOC, json!({"page": "concepts/containers"}));
        assert_eq!(result.is_error, Some(false));
        assert!(text(&result).contains("# Containers"));
    }

    #[test]
    fn invalid_arguments_are_tool_errors() {
        let result = call(
            GET_DOC,
            json!({"page": "concepts/containers", "extra": true}),
        );
        assert_eq!(result.is_error, Some(true));
        assert!(text(&result).contains("invalid arguments"));
    }
}