Tool Integration Guide
This guide covers how to integrate external tools and capabilities into your Paladins using the Arsenal system and Model Context Protocol (MCP).
Table of Contents
- Overview
- Arsenal Architecture
- MCP Protocol
- STDIO Tool Servers
- Streamable-HTTP Tool Servers
- Custom Tool Development
- Tool Result Handling
- Best Practices
- Troubleshooting
Overview
The Arsenal system enables Paladins to:
- Execute external tools and capabilities
- Search the web, access databases, run calculations
- Interact with APIs and services
- Extend functionality without modifying core code
Key Concepts:
- Arsenal: The registry of available tools
- Armament: A single tool or capability
- MCP (Model Context Protocol): Standard protocol for tool servers
- Tool Call: Request from Paladin to execute a tool
- Tool Result: Response from tool execution
Reachability note: Arsenal and MCP tool execution ship and work today — everything below this note is real and invocable. No shipped
LlmPortadapter (OpenAI, Anthropic, DeepSeek, or the bundled mock) ever returns a populated function call fromgenerate()— that narrower, wire-level path still requires a consumer-suppliedLlmPortimplementation that parses tool calls itself. See ADR-0042 for the tracked status of native, wire-level tool calling.There is a shipped, opt-in alternative that does not require a custom
LlmPort: the prompt-level tool-call protocol middleware pair,ToolCallProtocolMiddlewareandFinishOnPlainAnswerMiddleware, lets a Paladin's own reasoning loop parse tool calls out of plain-text output and trigger an Armament without any consumer-written port implementation. Both are installed by thereasoning_agentpreset. See Agent Runtime: The Tool-Call Protocol for the full mechanism.
Arsenal Architecture
Core Components
// Armament - Tool definition
pub struct Armament {
pub name: String,
pub description: String,
pub parameters: Value, // JSON Schema describing accepted parameters
pub required_params: Vec<String>,
}
// Arsenal Port - Tool execution interface
#[async_trait]
pub trait ArsenalPort: Send + Sync {
async fn list_armaments(&self) -> Vec<Armament>;
async fn invoke(&self, call: ArmamentCall) -> Result<ArmamentResult, ArsenalError>;
fn validate_call(&self, call: &ArmamentCall) -> Result<(), ArsenalError>;
}
// Armament Call - Tool invocation request
pub struct ArmamentCall {
pub tool_name: String,
pub arguments: HashMap<String, Value>,
pub call_id: Uuid,
}
// Armament Result - Tool execution response
pub struct ArmamentResult {
pub call_id: Uuid,
pub success: bool,
pub output: Option<Value>,
pub error: Option<String>,
pub execution_time_ms: u64,
}
Tool Flow
Paladin → LLM decides to use tool → ArmamentCall
↓
ArsenalPort validates call → Routes to correct Armament
↓
Tool executes (MCP server, API, local function)
↓
ArmamentResult → Injected into Paladin context
↓
Paladin continues reasoning with tool result
The first arrow — "LLM decides to use tool" — is the step no shipped adapter can take today; see the reachability note above and ADR-0042.
MCP Protocol
The Model Context Protocol (MCP) is an open standard for connecting LLM applications to external tools and data sources.
MCP Server Types
- STDIO Servers: Command-line tools communicating via stdin/stdout
- Streamable-HTTP Servers: Remote, optionally authenticated web services
(the current, real transport; supersedes the legacy standalone SSE
transport per the MCP spec — Paladin's own retired
MCPSseAdapterwas never actually SSE, just a mislabeled plain-HTTP-POST adapter)
MCP Message Format
// Tool Discovery Request
{
"jsonrpc": "2.0",
"method": "tools/list",
"id": 1
}
// Tool Discovery Response
{
"jsonrpc": "2.0",
"result": {
"tools": [
{
"name": "web_search",
"description": "Search the web for information",
"inputSchema": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search query"
}
},
"required": ["query"]
}
}
]
},
"id": 1
}
// Tool Invocation Request
{
"jsonrpc": "2.0",
"method": "tools/call",
"params": {
"name": "web_search",
"arguments": {
"query": "Rust async programming"
}
},
"id": 2
}
// Tool Invocation Response
{
"jsonrpc": "2.0",
"result": {
"content": [
{
"type": "text",
"text": "Search results: ..."
}
]
},
"id": 2
}
STDIO Tool Servers
STDIO servers are command-line programs that communicate via standard input/output.
Connecting a STDIO Server
use paladin::infrastructure::adapters::arsenal::mcp_stdio_adapter::MCPStdioAdapter;
use paladin::application::services::paladin::paladin_builder::PaladinBuilder;
use paladin::prelude::*;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let llm_adapter = Arc::new(OpenAIAdapter::new().build()?);
// Connect to an MCP STDIO server: MCPStdioAdapter::new(command, args) is
// a thin builder; connect() spawns the subprocess and performs the full
// MCP initialize -> notifications/initialized handshake, returning an
// MCPClient with discover_tools()/invoke_tool().
let mcp_client = MCPStdioAdapter::new("uvx", vec!["mcp-server-fetch"])
.connect()
.await?;
let tools = mcp_client.discover_tools().await?;
// Build Paladin with tool access (register discovered tools, or attach
// an ArsenalRegistry backed by the connected client)
let paladin = PaladinBuilder::new(llm_adapter)
.name("ResearchAssistant")
.system_prompt("You are a research assistant with web search capabilities. \
Use the web_search tool to find current information. \
Always cite your sources.")
.build()
.await?;
// Paladin will automatically use tools when needed
let response = paladin.execute("What are the latest Rust features in 2024?").await?;
println!("{}", response.content);
Ok(())
}
Popular STDIO MCP Servers
# Web search
uvx mcp-server-fetch
# File system access
uvx mcp-server-filesystem --allowed-directory ~/Documents
# Git operations
uvx mcp-server-git --repository /path/to/repo
# Database queries
uvx mcp-server-sqlite --db-path database.db
# Calculator
uvx mcp-server-calculator
Configuration Example
The YAML key is server_type (the MCPServerConfig field name,
src/config/arsenal.rs:13), not type -- type deserializes as an unknown field and
server_type is required, so a type:-keyed entry fails to load. There is no enabled
field on MCPServerConfig; omit a server entirely to disable it.
arsenal:
mcp_servers:
- name: "web_search"
server_type: "stdio"
command: "uvx"
args: ["mcp-server-fetch"]
- name: "filesystem"
server_type: "stdio"
command: "uvx"
args:
- "mcp-server-filesystem"
- "--allowed-directory"
- "/home/user/workspace"
- name: "calculator"
server_type: "stdio"
command: "uvx"
args: ["mcp-server-calculator"]
Advanced STDIO Configuration
MCPStdioAdapter is currently a minimal builder — it only accepts a command
and its arguments; working-directory, per-server env-var injection, custom
timeouts, and retry policies are not yet exposed on the adapter itself
(pass any required env vars via the spawned command's own args/environment,
or via the process that launches Paladin):
let client = MCPStdioAdapter::new("uvx", vec!["mcp-server-fetch"])
.connect()
.await?;
Streamable-HTTP Tool Servers
Streamable-HTTP servers are remote, optionally authenticated MCP servers reached over
HTTP(S) — Paladin's real remote transport (Phase 12.1 D-02/D-03). This supersedes the
legacy standalone SSE transport in the MCP spec; Paladin's own retired MCPSseAdapter
was never actually SSE, just a mislabeled, unauthenticated plain-HTTP-POST adapter, and
has been removed entirely.
Connecting a Streamable-HTTP Server
use paladin::infrastructure::adapters::arsenal::mcp_streamable_http_adapter::MCPStreamableHttpAdapter;
use paladin::application::services::paladin::paladin_builder::PaladinBuilder;
use paladin::prelude::*;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let llm_adapter = Arc::new(OpenAIAdapter::new().build()?);
// Connect to a remote MCP server over Streamable-HTTP. The bearer token
// is read from an env var here in application code -- never hardcode a
// literal secret, and never accept one as a CLI argument.
let bearer_token = std::env::var("API_KEY").ok();
let mut adapter = MCPStreamableHttpAdapter::new("https://api.example.com/mcp");
if let Some(token) = bearer_token {
adapter = adapter.with_bearer_token(token);
}
let mcp_client = adapter.connect().await?;
let tools = mcp_client.discover_tools().await?;
let paladin = PaladinBuilder::new(llm_adapter)
.name("APIAssistant")
.system_prompt("You have access to company APIs. Use them to retrieve data.")
.build()
.await?;
let response = paladin.execute("Get user statistics for last month").await?;
println!("{}", response.content);
Ok(())
}
Streamable-HTTP Configuration
The config-driven flow (recommended for most use cases) declares the server in
config.yml and lets the CLI/loader connect it for you. (.mcp.json is Claude
Code's own project-scoped MCP configuration for this repository's editor tooling;
the Paladin application's config loader does not read it -- only config.yml's
arsenal.mcp_servers list, src/config/arsenal.rs:37.)
arsenal:
mcp_servers:
- name: "company_api"
server_type: "streamable_http"
endpoint: "https://api.example.com/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: "API_KEY"
export API_KEY="your-token"
paladin arsenal test --mcp-streamable-http "https://api.example.com/mcp" \
--mcp-auth-token-env API_KEY
The equivalent programmatic flow — MCPStreamableHttpAdapter is a thin builder over
MCPClient::connect_streamable_http, mirroring MCPStdioAdapter's shape for the
remote transport:
use paladin::infrastructure::adapters::arsenal::mcp_streamable_http_adapter::MCPStreamableHttpAdapter;
let adapter = MCPStreamableHttpAdapter::new("https://api.example.com/mcp")
.with_bearer_token(std::env::var("API_KEY")?); // never hardcode the token
// .with_custom_headers(headers) is also available for non-bearer auth schemes
let client = adapter.connect().await?; // full initialize -> notifications/initialized handshake
Or call the underlying MCPClient constructor directly:
use paladin::infrastructure::adapters::arsenal::mcp_protocol::MCPClient;
let client = MCPClient::connect_streamable_http(
"https://api.example.com/mcp",
Some(&std::env::var("API_KEY")?),
None, // optional custom headers
)
.await?;
Verifying Connectivity and Listing Tools
There is no separate health_check() — a successful connect()/connect_streamable_http()
already means the full MCP handshake succeeded, so discover_tools() doubles as the
liveness check:
// A successful discover_tools() call confirms the server is reachable and
// the handshake succeeded.
let tools = client.discover_tools().await?;
println!("Streamable-HTTP server is healthy — {} tool(s) available", tools.len());
for tool in tools {
println!("Tool: {} - {}", tool.name, tool.description);
}
Custom Tool Development
Create your own tools by implementing the ArsenalPort trait.
Wiring note:
PaladinBuilderhas noadd_armament()method (it does not appear anywhere in the tree). The builder's only Arsenal-related method iswith_arsenal_registry(registry: Arc<dyn ArsenalRegistry>)(src/application/services/paladin/paladin_builder.rs:686), which attaches anArsenalRegistry— a metadata catalog ofArmaments, populated viaregistry.register(armament).await(crates/paladin-ports/src/output/arsenal_port.rs:809) — not a liveArsenalPortinvoker. The examples below buildArc::new(<YourTool>)and call.add_armament(tool)on the builder for brevity; treat that call as shorthand for registering the tool'sArmamentmetadata into a registry via.with_arsenal_registry(registry)and wiring the tool's actualinvoke()logic through the execution path that consumesOption<Arc<dyn ArsenalPort>>(src/application/services/paladin/paladin_execution_service.rs:116), not a single builder call.
Simple Custom Tool
use paladin_ports::output::arsenal_port::{ArsenalPort, ArsenalRegistry};
use paladin_core::platform::container::arsenal::{Armament, ArmamentCall, ArmamentResult};
use async_trait::async_trait;
pub struct CalculatorTool;
#[async_trait]
impl ArsenalPort for CalculatorTool {
async fn list_armaments(&self) -> Vec<Armament> {
vec![
Armament {
name: "add".to_string(),
description: "Add two numbers".to_string(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"a": { "type": "number", "description": "First number" },
"b": { "type": "number", "description": "Second number" }
},
"required": ["a", "b"]
}),
required_params: vec!["a".to_string(), "b".to_string()],
},
Armament {
name: "multiply".to_string(),
description: "Multiply two numbers".to_string(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"a": { "type": "number", "description": "First number" },
"b": { "type": "number", "description": "Second number" }
},
"required": ["a", "b"]
}),
required_params: vec!["a".to_string(), "b".to_string()],
},
]
}
async fn invoke(&self, call: ArmamentCall) -> Result<ArmamentResult, ArsenalError> {
let a = call.arguments.get("a")
.and_then(|v| v.as_f64())
.ok_or_else(|| ArsenalError::InvalidArguments("a".to_string()))?;
let b = call.arguments.get("b")
.and_then(|v| v.as_f64())
.ok_or_else(|| ArsenalError::InvalidArguments("b".to_string()))?;
let result = match call.tool_name.as_str() {
"add" => a + b,
"multiply" => a * b,
_ => return Err(ArsenalError::ToolNotFound(call.tool_name.clone())),
};
Ok(ArmamentResult {
call_id: call.call_id,
success: true,
output: Some(serde_json::json!(result)),
error: None,
execution_time_ms: 1,
})
}
fn validate_call(&self, call: &ArmamentCall) -> Result<(), ArsenalError> {
// validate_call is synchronous, so it checks against a known, static
// required-parameter list rather than awaiting the async list_armaments().
let required: &[&str] = match call.tool_name.as_str() {
"add" | "multiply" => &["a", "b"],
_ => return Err(ArsenalError::ToolNotFound(call.tool_name.clone())),
};
for param in required {
if !call.arguments.contains_key(*param) {
return Err(ArsenalError::InvalidArguments(format!("missing required parameter: {param}")));
}
}
Ok(())
}
}
// Use the custom tool
let calculator = Arc::new(CalculatorTool);
let paladin = PaladinBuilder::new(llm_adapter)
.add_armament(calculator)
.build()?;
API Integration Tool
use reqwest::Client;
pub struct WeatherTool {
client: Client,
api_key: String,
}
impl WeatherTool {
pub fn new(api_key: String) -> Self {
Self {
client: Client::new(),
api_key,
}
}
}
#[async_trait]
impl ArsenalPort for WeatherTool {
async fn list_armaments(&self) -> Vec<Armament> {
vec![
Armament {
name: "get_weather".to_string(),
description: "Get current weather for a location".to_string(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"location": { "type": "string", "description": "City name or coordinates" },
"units": { "type": "string", "description": "Temperature units (celsius/fahrenheit)" }
},
"required": ["location"]
}),
required_params: vec!["location".to_string()],
},
]
}
async fn invoke(&self, call: ArmamentCall) -> Result<ArmamentResult, ArsenalError> {
let location = call.arguments.get("location")
.and_then(|v| v.as_str())
.ok_or_else(|| ArsenalError::InvalidArguments("location".to_string()))?;
let units = call.arguments.get("units")
.and_then(|v| v.as_str())
.unwrap_or("celsius");
// Call weather API
let url = format!(
"https://api.openweathermap.org/data/2.5/weather?q={}&appid={}&units={}",
location, self.api_key, units
);
let response = self.client.get(&url)
.send()
.await
.map_err(|e| ArsenalError::TransportError(e.to_string()))?;
let weather_data = response.json::<serde_json::Value>()
.await
.map_err(|e| ArsenalError::TransportError(e.to_string()))?;
let temp = weather_data["main"]["temp"].as_f64().unwrap_or(0.0);
let description = weather_data["weather"][0]["description"]
.as_str()
.unwrap_or("unknown");
let output = format!(
"Weather in {}: {} with temperature of {}°",
location, description, temp
);
Ok(ArmamentResult {
call_id: call.call_id,
success: true,
output: Some(serde_json::json!(output)),
error: None,
execution_time_ms: 200,
})
}
fn validate_call(&self, call: &ArmamentCall) -> Result<(), ArsenalError> {
if call.tool_name != "get_weather" {
return Err(ArsenalError::ToolNotFound(call.tool_name.clone()));
}
if !call.arguments.contains_key("location") {
return Err(ArsenalError::InvalidArguments("missing required parameter: location".to_string()));
}
Ok(())
}
}
// Usage
let weather = Arc::new(WeatherTool::new(api_key));
let paladin = PaladinBuilder::new(llm_adapter)
.system_prompt("You can check weather. Use get_weather tool.")
.add_armament(weather)
.build()?;
Database Query Tool
use sqlx::SqlitePool;
pub struct DatabaseTool {
pool: SqlitePool,
}
impl DatabaseTool {
pub async fn new(database_url: &str) -> Result<Self, sqlx::Error> {
let pool = SqlitePool::connect(database_url).await?;
Ok(Self { pool })
}
}
#[async_trait]
impl ArsenalPort for DatabaseTool {
async fn list_armaments(&self) -> Vec<Armament> {
vec![
Armament {
name: "query_database".to_string(),
description: "Execute a read-only SQL query".to_string(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"query": { "type": "string", "description": "SQL SELECT query" }
},
"required": ["query"]
}),
required_params: vec!["query".to_string()],
},
]
}
async fn invoke(&self, call: ArmamentCall) -> Result<ArmamentResult, ArsenalError> {
let query = call.arguments.get("query")
.and_then(|v| v.as_str())
.ok_or_else(|| ArsenalError::InvalidArguments("query".to_string()))?;
// Security: Only allow SELECT queries
if !query.trim().to_lowercase().starts_with("select") {
return Ok(ArmamentResult {
call_id: call.call_id,
success: false,
output: None,
error: Some("Only SELECT queries are allowed".to_string()),
execution_time_ms: 0,
});
}
let start = std::time::Instant::now();
let rows = sqlx::query(query)
.fetch_all(&self.pool)
.await
.map_err(|e| ArsenalError::TransportError(e.to_string()))?;
// Convert rows to JSON
let result_json = serde_json::to_value(&rows)
.unwrap_or_else(|_| serde_json::json!([]));
Ok(ArmamentResult {
call_id: call.call_id,
success: true,
output: Some(result_json),
error: None,
execution_time_ms: start.elapsed().as_millis() as u64,
})
}
fn validate_call(&self, call: &ArmamentCall) -> Result<(), ArsenalError> {
if !call.arguments.contains_key("query") {
return Err(ArsenalError::InvalidArguments("missing required parameter: query".to_string()));
}
Ok(())
}
}
Tool Result Handling
Automatic Context Injection
When a Paladin invokes a tool, the result is automatically added to the conversation context:
// Paladin execution loop
loop {
let response = llm.generate(context).await?;
if let Some(tool_call) = response.tool_calls.first() {
// Execute tool
let result = arsenal.invoke(tool_call).await?;
// Add result to context
context.add_tool_result(result);
// Continue reasoning with tool output
continue;
}
// No more tool calls, return final response
break Ok(response);
}
Custom Result Processing
pub struct LoggingArsenalPort<T: ArsenalPort> {
inner: T,
}
#[async_trait]
impl<T: ArsenalPort> ArsenalPort for LoggingArsenalPort<T> {
async fn invoke(&self, call: ArmamentCall) -> Result<ArmamentResult, ArsenalError> {
println!("Invoking tool: {}", call.tool_name);
println!("Parameters: {:?}", call.arguments);
let start = std::time::Instant::now();
let result = self.inner.invoke(call).await?;
let duration = start.elapsed();
println!("Tool completed in {:?}", duration);
println!("Success: {}", result.success);
if let Some(error) = &result.error {
eprintln!("Tool error: {}", error);
}
Ok(result)
}
// Forward other methods
async fn list_armaments(&self) -> Vec<Armament> {
self.inner.list_armaments().await
}
fn validate_call(&self, call: &ArmamentCall) -> Result<(), ArsenalError> {
self.inner.validate_call(call)
}
}
// Usage
let weather_tool = Arc::new(WeatherTool::new(api_key));
let logged_tool = Arc::new(LoggingArsenalPort { inner: weather_tool });
paladin.add_armament(logged_tool);
Error Handling
match arsenal.invoke(call).await {
Ok(result) if result.success => {
// Tool succeeded
process_result(&result.output);
}
Ok(result) => {
// Tool failed but returned error message
eprintln!("Tool failed: {}", result.error.unwrap_or_default());
// Decide: retry, use fallback, or fail
}
Err(ArsenalError::ToolNotFound(name)) => {
eprintln!("Tool not found: {}", name);
// Handle missing tool
}
Err(ArsenalError::Timeout(secs)) => {
eprintln!("Tool execution timed out after {} seconds", secs);
// Retry with longer timeout
}
Err(e) => {
eprintln!("Arsenal error: {}", e);
// Handle other errors
}
}
Best Practices
1. Clear Tool Descriptions
// ❌ Bad: Vague description
Armament {
name: "search",
description: "Search for stuff",
// ...
}
// ✅ Good: Clear, specific description
Armament {
name: "web_search",
description: "Search the web using Google. Returns top 10 results with titles, \
URLs, and snippets. Use this when you need current information \
not in your training data.",
// ...
}
2. Validate Inputs
fn validate_call(&self, call: &ArmamentCall) -> Result<(), ArsenalError> {
// Check required parameters
for param in &self.required_params {
if !call.arguments.contains_key(param) {
return Err(ArsenalError::InvalidArguments(format!("missing required parameter: {param}")));
}
}
// Validate parameter types and values
if let Some(url) = call.arguments.get("url") {
if !url.as_str().unwrap_or("").starts_with("http") {
return Err(ArsenalError::InvalidArguments("url must start with http".into()));
}
}
Ok(())
}
3. Set Timeouts
let tool = CustomTool::new()
.timeout(Duration::from_secs(30)) // Prevent hanging
.build()?;
4. Implement Retries for Flaky Operations
async fn invoke_with_retry(&self, call: ArmamentCall) -> Result<ArmamentResult, ArsenalError> {
let mut attempts = 0;
let max_attempts = 3;
loop {
attempts += 1;
// ArsenalError has no is_retryable() helper (unlike PaladinError) --
// match the variants worth retrying explicitly instead.
let is_retryable = |e: &ArsenalError| {
matches!(e, ArsenalError::Timeout(_) | ArsenalError::TransportError(_))
};
match self.invoke(call.clone()).await {
Ok(result) => return Ok(result),
Err(e) if attempts < max_attempts && is_retryable(&e) => {
tokio::time::sleep(Duration::from_secs(2_u64.pow(attempts))).await;
continue;
}
Err(e) => return Err(e),
}
}
}
5. Sanitize Inputs
fn sanitize_sql(query: &str) -> Result<String, ArsenalError> {
// Remove dangerous keywords
let dangerous = ["DROP", "DELETE", "UPDATE", "INSERT", "CREATE", "ALTER"];
let query_upper = query.to_uppercase();
for keyword in dangerous {
if query_upper.contains(keyword) {
return Err(ArsenalError::InvalidArguments(
format!("Query contains forbidden keyword: {}", keyword)
));
}
}
Ok(query.to_string())
}
6. Rate Limiting
use std::sync::Arc;
use tokio::sync::Semaphore;
pub struct RateLimitedTool<T: ArsenalPort> {
inner: T,
semaphore: Arc<Semaphore>,
}
impl<T: ArsenalPort> RateLimitedTool<T> {
pub fn new(inner: T, max_concurrent: usize) -> Self {
Self {
inner,
semaphore: Arc::new(Semaphore::new(max_concurrent)),
}
}
}
#[async_trait]
impl<T: ArsenalPort> ArsenalPort for RateLimitedTool<T> {
async fn invoke(&self, call: ArmamentCall) -> Result<ArmamentResult, ArsenalError> {
let _permit = self.semaphore.acquire().await
.map_err(|e| ArsenalError::TransportError(e.to_string()))?;
self.inner.invoke(call).await
}
// Forward other methods...
}
7. Structured Output
// Return structured data that's easy to parse
let output = serde_json::json!({
"status": "success",
"data": {
"temperature": 72.5,
"conditions": "partly cloudy",
"humidity": 65
},
"timestamp": chrono::Utc::now().to_rfc3339()
});
Ok(ArmamentResult {
call_id: call.call_id,
success: true,
output: Some(output),
error: None,
execution_time_ms: 150,
})
8. Model-Level Structured Output (response_format)
The structured ArmamentResult output above is about the shape a tool returns, not the
shape the model itself is asked to produce. To constrain a model's own completion to JSON
(with an optional JSON Schema), attach a ResponseFormat to the request through
LlmRequest::with_response_format — this is a request-level hint, not an Arsenal/tool
concept, and it is independent of everything else in this guide.
Native support for response_format varies by provider: some adapters put it on the wire as
a real constrained-decoding mode, and at least one ignores it harmlessly because it has no
native JSON mode. This guide does not duplicate that per-provider breakdown — see the
per-provider table in the agent-runtime user guide for the authoritative list of which
adapters honor response_format natively and which fall back to prompt-only instructions.
The Tool-Call Protocol and InProcessArsenal
The reachability note above is about LLM-initiated tool calls; giving an agent a tool it can
actually invoke through the reasoning loop is what the Agent Runtime guide's
prompt-level tool-call protocol and InProcessArsenal add. InProcessArsenal is a
closure-backed ArsenalPort — no MCP server or subprocess required — for registering a Rust
closure directly as an Armament, and ToolCallProtocolMiddleware is what makes a shipped
provider (which never populates LlmResponse.function_call, per ADR-0042) actually reach that
tool: it renders the arsenal's catalogue into the prompt and decodes the model's JSON reply back
into a synthesized tool call. See the Agent Runtime guide's
Tool-Call Protocol section for the full envelope and the
reasoning_agent preset, which wires both together in one call.
Troubleshooting
Tool Not Being Called
Problem: Paladin doesn't use the tool even though it should.
Solutions:
- Check tool description is clear and relevant
- Update system prompt to mention tool availability
- Verify tool appears in
list_armaments()output - Ensure LLM supports function calling (GPT-4, Claude 3+)
// Make tool usage explicit in system prompt
.system_prompt("You have access to a web_search tool. USE IT to find current information. \
Always search before answering questions about recent events.")
MCP Server Connection Failed
Problem: Cannot connect to MCP STDIO server.
Solutions:
- Verify command is in PATH:
which uvx - Test command manually:
uvx mcp-server-fetch - Check server logs for errors
- Verify environment variables are set
// MCPStdioAdapter::new(command, args) takes both up front; there is no
// separate .command()/.args() builder step and no .debug_mode() flag --
// enable verbose logging via the process's own RUST_LOG env var instead.
let tool = MCPStdioAdapter::new("uvx", vec!["mcp-server-fetch"])
.connect()
.await?;
Tool Execution Timeout
Problem: Tools timing out frequently.
Solutions:
- Increase timeout duration
- Optimize tool implementation
- Add caching for expensive operations
- Use async/parallel execution where possible
let tool = CustomTool::new()
.timeout(Duration::from_secs(120)) // Longer timeout
.build()?;
Invalid Parameters
Problem: Tool receives wrong parameter types.
Solutions:
- Strengthen parameter validation
- Add type coercion in invoke()
- Improve tool schema definitions
- Add examples to tool descriptions
// Robust parameter extraction
let count = call.arguments.get("count")
.and_then(|v| {
// Try as number, then as string
v.as_i64()
.or_else(|| v.as_str().and_then(|s| s.parse::<i64>().ok()))
})
.unwrap_or(10); // Default value
Streamable-HTTP Server Authentication
Problem: Streamable-HTTP server rejects the connection with ArsenalError::AuthFailed
(a 401/403-shaped rejection during the initialize handshake).
Solutions:
- Verify the bearer token is correct and hasn't expired
- Confirm the token is NOT prefixed with
"Bearer "yourself —MCPClient::connect_streamable_http/MCPStreamableHttpAdapteradd the prefix internally; a manually-prefixed token double-prefixes and breaks auth - If using
auth_token_env/--mcp-auth-token-env, confirm the NAMED env var is actually set in the process environment (a missing env var fails loud with an actionable error, not a silent 401) - Check the server's own auth requirements (some accept custom headers instead of a bearer token —
use
MCPStreamableHttpAdapter::with_custom_headers)
use paladin::infrastructure::adapters::arsenal::mcp_streamable_http_adapter::MCPStreamableHttpAdapter;
let adapter = MCPStreamableHttpAdapter::new("https://api.example.com/mcp")
.with_bearer_token(std::env::var("API_KEY")?); // never a literal token, never "Bearer "-prefixed
let client = adapter.connect().await?;
Testing Tools
Unit Testing Custom Tools
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_calculator_add() {
let calc = CalculatorTool;
let call = ArmamentCall {
tool_name: "add".to_string(),
arguments: HashMap::from([
("a".to_string(), json!(5.0)),
("b".to_string(), json!(3.0)),
]),
call_id: Uuid::new_v4(),
};
let result = calc.invoke(call).await.unwrap();
assert!(result.success);
assert_eq!(result.output, Some(json!(8.0)));
}
#[tokio::test]
async fn test_invalid_parameter() {
let calc = CalculatorTool;
let call = ArmamentCall {
tool_name: "add".to_string(),
arguments: HashMap::from([
("a".to_string(), json!(5.0)),
// Missing 'b' parameter
]),
call_id: Uuid::new_v4(),
};
assert!(calc.invoke(call).await.is_err());
}
}
Integration Testing with Paladin
#[tokio::test]
async fn test_paladin_uses_tool() {
let llm_adapter = Arc::new(MockLlmAdapter::new());
let calc = Arc::new(CalculatorTool);
let paladin = PaladinBuilder::new(llm_adapter)
.system_prompt("You have a calculator. Use it for math.")
.add_armament(calc)
.build()
.unwrap();
let response = paladin.execute("What is 15 + 27?").await.unwrap();
assert!(response.content.contains("42"));
}
Examples
See working examples:
examples/arsenal_stdio_tools.rs- MCP STDIO integrationexamples/arsenal_streamable_http_tools.rs- MCP Streamable-HTTP integration (authenticated remote transport)
(examples/custom_tools.rs and examples/tool_error_handling.rs, previously cited here, do not
exist in the tree -- ls examples/ carries no custom-tool-implementation or error-handling-pattern
example beyond what the two files above already cover.)
Next Steps
- Memory Management - Use Garrison with tools
- Battalion Patterns - Tools in multi-agent systems
- API Reference - Arsenal API documentation