Arsenal Tools

The Arsenal system (crates/paladin-ports/src/output/arsenal_port.rs) gives Paladins access to external tools and services through the Model Context Protocol (MCP). Tools are called Armaments; the registry that holds them is the Arsenal.


Table of Contents

  1. Concepts
  2. Quick Start — STDIO Server
  3. SSE Server Configuration
  4. config.yml Reference
  5. ArsenalPort Trait
  6. ArsenalRegistry Trait
  7. Attaching Arsenal to a Paladin
  8. Custom Armaments (Direct Rust Tools)
  9. Handoff Tool
  10. Error Handling
  11. Best Practices

Concepts

TermDefinition
ArmamentA single callable tool (name, description, JSON schema)
ArmamentCallA runtime invocation (tool name + argument map)
ArmamentResultReturn value (success: bool, output: Option<Value>, error: Option<String>)
ArsenalPortTrait for discovering and invoking armaments
ArsenalRegistryTrait for managing the registry lifecycle (register, remove)
MCPStdioAdapterCommunicates with command-line MCP servers via stdin/stdout
MCPSseAdapterCommunicates with HTTP-based MCP servers via SSE

Quick Start — STDIO Server

STDIO servers are the most common MCP transport. The process is spawned and communicated with via newline-delimited JSON on stdin/stdout.

1. Configure in config.yml

arsenal:
  mcp_servers:
    - name: web_search
      type: stdio
      command: uvx
      args: ["mcp-server-brave-search"]
      env:
        BRAVE_API_KEY: "${BRAVE_API_KEY}"
    - name: filesystem
      type: stdio
      command: npx
      args: ["-y", "@modelcontextprotocol/server-filesystem", "/workspace"]

2. Build a Paladin with the Arsenal

use paladin::application::services::paladin::paladin_builder::PaladinBuilder;
use paladin_ports::output::llm_port::LlmPort;
use paladin_ports::output::arsenal_port::ArsenalRegistry;
use std::sync::Arc;

// Arsenal registry is built from config.yml automatically when using
// PaladinBuilder::from_config() or can be constructed manually.
let paladin = PaladinBuilder::new(llm_port)
    .system_prompt("You are a research assistant with web search access.")
    .with_arsenal_registry(arsenal_registry)
    .build()
    .await?;

let result = paladin.execute("Find the latest Rust release notes").await?;
println!("{}", result.output);

The Paladin will automatically detect tool-call JSON in LLM responses, invoke the tool via the Arsenal, and feed results back into the reasoning loop.


SSE Server Configuration

HTTP/SSE servers expose a REST endpoint:

arsenal:
  mcp_servers:
    - name: my_api_server
      type: sse
      endpoint: "http://localhost:8080/mcp"
      timeout_seconds: 30
      max_retries: 3

The MCPSseAdapter sends requests and reads responses over the SSE stream:

use paladin::infrastructure::adapters::arsenal::mcp_sse_adapter::MCPSseAdapter;

let mut adapter = MCPSseAdapter::new("http://localhost:8080/mcp");
adapter.connect().await?;

config.yml Reference

arsenal:
  mcp_servers:
    - name: <identifier>          # Unique name used in logs and errors
      type: stdio | sse           # Transport type
      # STDIO fields:
      command: <executable>       # e.g. python3, npx, uvx
      args: [<arg>, ...]          # Command-line arguments
      env:                        # Optional environment variables
        KEY: value
      # SSE fields:
      endpoint: <url>             # Full URL of the SSE endpoint
      timeout_seconds: 30         # Request timeout
      max_retries: 3              # Retry attempts on failure

ArsenalPort Trait

Defined in crates/paladin-ports/src/output/arsenal_port.rs:

#[async_trait]
pub trait ArsenalPort: Send + Sync {
    /// List all available armaments from this MCP server
    async fn list_armaments(&self) -> Vec<Armament>;

    /// Invoke an armament with the given arguments
    async fn invoke(&self, call: ArmamentCall) -> Result<ArmamentResult, ArsenalError>;

    /// Validate call arguments against the armament's JSON schema
    fn validate_call(&self, call: &ArmamentCall) -> Result<(), ArsenalError>;
}

Direct usage:

use paladin_core::platform::container::arsenal::ArmamentCall;
use serde_json::json;
use std::collections::HashMap;

let mut args = HashMap::new();
args.insert("query".to_string(), json!("Rust 2024 edition features"));

let call = ArmamentCall::new("web_search", args);
arsenal_port.validate_call(&call)?;

let result = arsenal_port.invoke(call).await?;
if result.success {
    println!("{}", result.output.unwrap());
}

ArsenalRegistry Trait

Defined alongside ArsenalPort:

#[async_trait]
pub trait ArsenalRegistry: Send + Sync {
    /// Register a new armament in the registry
    async fn register(&self, armament: Armament);

    /// Remove an armament by name
    async fn remove(&self, name: &str);

    /// Get all registered armament descriptors
    async fn list(&self) -> Vec<Armament>;

    /// Look up a specific armament by name
    async fn get(&self, name: &str) -> Option<Armament>;
}

Attaching Arsenal to a Paladin

use paladin::application::services::paladin::paladin_builder::PaladinBuilder;
use paladin_ports::output::arsenal_port::ArsenalRegistry;
use std::sync::Arc;

let paladin = PaladinBuilder::new(llm_port)
    .system_prompt(
        "You are a coding assistant. Use the filesystem tool to read files when needed."
    )
    .with_arsenal_registry(Arc::new(my_registry))
    .build()
    .await?;

Custom Armaments (Direct Rust Tools)

Implement ArsenalPort to expose any Rust function as a tool:

use async_trait::async_trait;
use paladin_core::platform::container::arsenal::{
    Armament, ArmamentCall, ArmamentResult, ArsenalError,
};
use paladin_ports::output::arsenal_port::ArsenalPort;

pub struct CalculatorTool;

#[async_trait]
impl ArsenalPort for CalculatorTool {
    async fn list_armaments(&self) -> Vec<Armament> {
        vec![Armament {
            name: "calculate".to_string(),
            description: "Evaluate a mathematical expression".to_string(),
            input_schema: serde_json::json!({
                "type": "object",
                "properties": {
                    "expression": { "type": "string" }
                },
                "required": ["expression"]
            }),
        }]
    }

    async fn invoke(&self, call: ArmamentCall) -> Result<ArmamentResult, ArsenalError> {
        let expr = call.args["expression"].as_str().unwrap_or_default();
        // ... evaluate ...
        Ok(ArmamentResult { success: true, output: Some(serde_json::json!(42)), error: None })
    }

    fn validate_call(&self, call: &ArmamentCall) -> Result<(), ArsenalError> {
        if call.args.contains_key("expression") {
            Ok(())
        } else {
            Err(ArsenalError::InvalidArguments("expression is required".into()))
        }
    }
}

Handoff Tool

The handoff_tool in crates/paladin-core/src/platform/container/arsenal/handoff_tool.rs is a built-in Armament that allows a Paladin to delegate sub-tasks to specialist agents at runtime. Register specialist agents on the builder:

let coordinator = PaladinBuilder::new(llm_port)
    .system_prompt("You are a coordinator. Delegate to specialists when needed.")
    .with_specialist(Arc::new(code_paladin))
    .with_specialist(Arc::new(test_paladin))
    .build()
    .await?;

The LLM will emit a tool-call for handoff when it determines a specialist is more appropriate. Delegation records appear in PaladinResult.handoff_history.


Error Handling

ArsenalError variants (from paladin_core::platform::container::arsenal):

VariantCauseRecovery
ToolNotFound(String)Armament name not in registryCheck list_armaments()
InvalidArguments(String)Schema validation failedFix argument map
TimeoutTool took too longIncrease timeout_seconds in config
ProtocolError(String)Malformed MCP messageCheck MCP server logs
TransportError(String)Process/network failureVerify server is running

Best Practices

  • Validate before invoking — call validate_call() to catch argument errors early.
  • Set timeouts — all MCP servers should have timeout_seconds to avoid blocking the reasoning loop indefinitely.
  • Describe tools well — the Armament description is what the LLM reads to decide whether to call the tool; make it precise.
  • Namespace tool names — use server_name.tool_name convention to avoid collisions when registering multiple servers.
  • Test with mock — implement a MockArsenalPort in tests to avoid spawning real subprocesses.