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
- Concepts
- Quick Start — STDIO Server
- SSE Server Configuration
- config.yml Reference
- ArsenalPort Trait
- ArsenalRegistry Trait
- Attaching Arsenal to a Paladin
- Custom Armaments (Direct Rust Tools)
- Handoff Tool
- Error Handling
- Best Practices
Concepts
| Term | Definition |
|---|---|
| Armament | A single callable tool (name, description, JSON schema) |
| ArmamentCall | A runtime invocation (tool name + argument map) |
| ArmamentResult | Return value (success: bool, output: Option<Value>, error: Option<String>) |
| ArsenalPort | Trait for discovering and invoking armaments |
| ArsenalRegistry | Trait for managing the registry lifecycle (register, remove) |
| MCPStdioAdapter | Communicates with command-line MCP servers via stdin/stdout |
| MCPSseAdapter | Communicates 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):
| Variant | Cause | Recovery |
|---|---|---|
ToolNotFound(String) | Armament name not in registry | Check list_armaments() |
InvalidArguments(String) | Schema validation failed | Fix argument map |
Timeout | Tool took too long | Increase timeout_seconds in config |
ProtocolError(String) | Malformed MCP message | Check MCP server logs |
TransportError(String) | Process/network failure | Verify server is running |
Best Practices
- Validate before invoking — call
validate_call()to catch argument errors early. - Set timeouts — all MCP servers should have
timeout_secondsto avoid blocking the reasoning loop indefinitely. - Describe tools well — the Armament
descriptionis what the LLM reads to decide whether to call the tool; make it precise. - Namespace tool names — use
server_name.tool_nameconvention to avoid collisions when registering multiple servers. - Test with mock — implement a
MockArsenalPortin tests to avoid spawning real subprocesses.