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
- Streamable-HTTP 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 |
| MCPStreamableHttpAdapter | Communicates with remote, optionally authenticated MCP servers over Streamable-HTTP (replaces the retired, never-actually-SSE MCPSseAdapter) |
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.
Streamable-HTTP Server Configuration
Remote MCP servers are reached over HTTP(S) using the Streamable-HTTP transport (D-02/D-03).
This is the real, currently-implemented remote transport, replacing the retired
MCPSseAdapter (which was never actually SSE — just a mislabeled, unauthenticated
plain-HTTP-POST adapter):
arsenal:
mcp_servers:
- name: my_api_server
type: streamable_http
endpoint: "http://localhost:8080/mcp"
# NAMES the env var holding the bearer token -- never a literal secret
# in this file. Omit entirely for an unauthenticated server.
auth_token_env: "MY_API_SERVER_TOKEN"
MCPStreamableHttpAdapter builds the connection and delegates to
MCPClient::connect_streamable_http, which performs the full
initialize -> notifications/initialized handshake:
use paladin::infrastructure::adapters::arsenal::mcp_streamable_http_adapter::MCPStreamableHttpAdapter;
let adapter = MCPStreamableHttpAdapter::new("http://localhost:8080/mcp")
.with_bearer_token(std::env::var("MY_API_SERVER_TOKEN")?); // never hardcode the token
let client = adapter.connect().await?;
let tools = client.discover_tools().await?;
config.yml Reference
arsenal:
mcp_servers:
- name: <identifier> # Unique name used in logs and errors
type: stdio | streamable_http # Transport type ("sse" is retired --
# fails loud with a migration message)
# STDIO fields:
command: <executable> # e.g. python3, npx, uvx
args: [<arg>, ...] # Command-line arguments
# Streamable-HTTP fields:
endpoint: <url> # Full URL of the remote MCP endpoint
auth_token_env: <ENV_VAR_NAME> # NAMES the env var holding the bearer
# token -- never a literal secret here
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. Armament carries a
parameters JSON Schema (not input_schema) plus a required_params list; the live
ArmamentResult has five fields — call_id, success, output, error and
execution_time_ms — and a call's arguments are read from the arguments map on
ArmamentCall, not an args field:
use async_trait::async_trait;
use paladin_core::platform::container::arsenal::{
Armament, ArmamentCall, ArmamentResult, ArsenalError,
};
use paladin_ports::output::arsenal_port::ArsenalPort;
/// Implement `ArsenalPort` to expose any Rust function as a tool.
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(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"expression": { "type": "string" }
},
"required": ["expression"]
}),
required_params: vec!["expression".to_string()],
}]
}
async fn invoke(&self, call: ArmamentCall) -> Result<ArmamentResult, ArsenalError> {
// Arguments live on the `arguments` map, not an `args` field.
let expr = call
.arguments
.get("expression")
.and_then(|v| v.as_str())
.unwrap_or_default();
// ... evaluate `expr` ...
Ok(ArmamentResult {
call_id: call.call_id,
success: true,
output: Some(serde_json::json!(42)),
error: None,
execution_time_ms: 1,
})
}
fn validate_call(&self, call: &ArmamentCall) -> Result<(), ArsenalError> {
match call.arguments.get("expression") {
Some(v) if v.is_string() => Ok(()),
Some(_) => Err(ArsenalError::InvalidArguments(
"expression must be a string".into(),
)),
None => 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 via with_handoffs, which takes
the whole specialist list at once — there is no per-call chainable registration method:
/// Register specialist agents on the builder so the built-in handoff Armament can
/// delegate to them at runtime — `with_handoffs` takes the whole specialist list at
/// once, there is no per-call chainable registration method.
pub async fn build_coordinator_with_handoffs() -> Result<(), Box<dyn std::error::Error>> {
let llm_port: Arc<dyn LlmPort> = Arc::new(MockLlmAdapter::new());
let code_paladin = PaladinBuilder::new(llm_port.clone())
.system_prompt("You review code changes.")
.name("CodeReviewer")
.build()
.await?;
let test_paladin = PaladinBuilder::new(llm_port.clone())
.system_prompt("You write and run tests.")
.name("TestEngineer")
.build()
.await?;
let coordinator = PaladinBuilder::new(llm_port)
.system_prompt("You are a coordinator. Delegate to specialists when needed.")
.with_handoffs(vec![Arc::new(code_paladin), Arc::new(test_paladin)])
.build()
.await?;
let _ = coordinator;
Ok(())
}
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.