subako-function-sdk 0.2.3

Write Subako function packages in Rust: typed functions become tools, and the program prints its own manifest.
Documentation
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use subako_function_sdk::{Answer, MANIFEST_FLAG, ToolError, function, package};

#[derive(Deserialize, JsonSchema)]
struct GreetInput {
    /// Who to greet.
    name: String,
    #[serde(default)]
    shout: bool,
}

#[derive(Serialize, JsonSchema)]
struct Greeting {
    text: String,
}

/// Greet someone by name.
#[function]
fn greet(input: GreetInput) -> Result<Greeting, ToolError> {
    let text = format!("hello {}", input.name);
    Ok(Greeting {
        text: match input.shout {
            true => text.to_uppercase(),
            false => text,
        },
    })
}

mod tools {
    use super::*;

    #[derive(Deserialize, JsonSchema)]
    pub struct Nothing {}

    /// Always fails.
    #[function]
    pub fn fail(_: Nothing) -> Result<Greeting, String> {
        Err("no".to_string())
    }

    /// Shows a picture.
    #[function]
    pub fn show(_: Nothing) -> Result<Answer<Greeting>, ToolError> {
        Ok(Answer::new(Greeting {
            text: "look".to_string(),
        })
        .with_image_file("/volume/tmp/picture.png"))
    }
}

package! {
    name: "hello",
    description: concat!("Says ", "hello."),
    functions: [greet, tools::fail, tools::show],
    main: false,
}

fn answer(value: Value) -> Value {
    json!({"content": [{"type": "text", "text": value.to_string()}], "structuredContent": value})
}

fn failure(message: &str) -> Value {
    json!({"content": [{"type": "text", "text": message}], "isError": true})
}

/// The result the package answers `method` with, less what every result
/// carries.
fn answered(method: &str, mut params: Value) -> Value {
    params["_meta"] = json!({
        "io.modelcontextprotocol/protocolVersion": "2026-07-28",
        "io.modelcontextprotocol/clientCapabilities": {},
    });
    let request = json!({"jsonrpc": "2.0", "id": 1, "method": method, "params": params});
    let (out, code) = package().execute(&["hello".to_string()], format!("{request}\n").as_bytes());
    assert_eq!(code, 0);
    let response: Value = serde_json::from_slice(&out).unwrap();
    assert_eq!(response["id"], 1);
    let mut result = response["result"].clone();
    let result = result.as_object_mut().unwrap();
    assert_eq!(result.remove("resultType"), Some(json!("complete")));
    assert_eq!(
        result.remove("_meta"),
        Some(json!({"io.modelcontextprotocol/serverInfo": {
            "name": "hello",
            "version": env!("CARGO_PKG_VERSION"),
        }}))
    );
    Value::Object(result.clone())
}

/// The result of a `tools/call` of `tool`.
fn call(tool: &str, arguments: Value) -> Value {
    answered("tools/call", json!({"name": tool, "arguments": arguments}))
}

#[test]
fn tools_list_comes_from_the_functions() {
    let text = json!({
        "type": "object",
        "properties": {"text": {"type": "string"}},
        "required": ["text"],
    });
    assert_eq!(
        answered("tools/list", json!({}))["tools"],
        json!([
            {
                "name": "greet",
                "description": "Greet someone by name.",
                "inputSchema": {
                    "type": "object",
                    "properties": {
                        "name": {"type": "string", "description": "Who to greet."},
                        "shout": {"type": "boolean", "default": false},
                    },
                    "required": ["name"],
                },
                "outputSchema": text,
            },
            {
                "name": "fail",
                "description": "Always fails.",
                "inputSchema": {"type": "object"},
                "outputSchema": text,
            },
            {
                "name": "show",
                "description": "Shows a picture.",
                "inputSchema": {"type": "object"},
                "outputSchema": text,
            },
        ])
    );
}

#[test]
fn the_manifest_names_the_abi_and_the_package() {
    let args = ["hello".to_string(), MANIFEST_FLAG.to_string()];
    let (manifest, code) = package().execute(&args, b"");
    assert_eq!(code, 0);
    let manifest: Value = serde_json::from_slice(&manifest).unwrap();
    assert_eq!(
        manifest,
        json!({"abi": 1, "name": "hello", "description": "Says hello."})
    );
}

#[test]
fn a_call_runs_the_named_function() {
    assert_eq!(
        call("greet", json!({"name": "ada", "shout": true})),
        answer(json!({"text": "HELLO ADA"}))
    );
}

#[test]
fn an_answer_shows_its_json_then_its_blocks() {
    assert_eq!(
        call("show", json!({})),
        json!({
            "content": [
                {"type": "text", "text": r#"{"text":"look"}"#},
                {"type": "resource_link", "uri": "volume:///tmp/picture.png", "name": "picture.png"},
            ],
            "structuredContent": {"text": "look"},
        })
    );
}

#[test]
fn a_function_error_reaches_the_model() {
    assert_eq!(call("fail", json!({})), failure("no"));
}

#[test]
fn bad_input_and_unknown_tools_are_tool_errors() {
    let out = call("greet", json!({"shout": true}));
    assert_eq!(out["isError"], true);
    assert!(
        out["content"][0]["text"]
            .as_str()
            .unwrap()
            .starts_with("invalid input")
    );
    assert_eq!(call("nope", json!({})), failure("unknown tool: nope"));
}

#[test]
fn the_function_stays_callable() {
    let greeting = greet(GreetInput {
        name: "ada".to_string(),
        shout: false,
    })
    .unwrap();
    assert_eq!(greeting.text, "hello ada");
    assert_eq!(
        package()
            .functions()
            .iter()
            .map(|f| f.name())
            .collect::<Vec<_>>(),
        ["greet", "fail", "show"]
    );
}