Skip to content

Commit ca2b563

Browse files
olaservoclaude
andcommitted
Add structured output to the Rust weather server and client
Both tools now declare an outputSchema and return structuredContent alongside the human-readable text. get_alerts declares Vec<Alert> as its output schema, so its structured content is a top-level JSON array rather than an array nested in an object. Protocol revision 2026-07-28 is the first to allow this: through 2025-11-25 an outputSchema had to be object-rooted, forcing a wrapper key for what is naturally a list. No alerts is now simply [], not a special case. get_forecast returns an object, for contrast. Failure paths return a tool-level error result rather than a bare text result: a tool that declares an outputSchema MUST return conforming structured content, so a path with no data to return has to fail. The client compiles every declared outputSchema at connect time and validates results against it, per the spec's client-side SHOULD. rmcp does not do this for you; its docs point at the jsonschema crate, which is the one new dependency here. The client also prefers structuredContent when forwarding to the model. The client no longer treats a missing .env as fatal, and checks for ANTHROPIC_API_KEY after connecting rather than before. Connecting and listing tools needs no credentials, so this matches the Python and TypeScript clients, which report the missing key and exit cleanly - the behaviour the smoke tests rely on. This supersedes modelcontextprotocol#143. That PR bumped rmcp 0.3 -> 1.4 and failed CI because the 0.3 #[tool] macro API does not survive the jump; the rewrite here was required either way, so the bump is folded in and taken further, to 3.0.0-beta.2 - the first version with the 2026-07-28 model. Two rmcp gaps worth knowing about, both discovered by connecting a TypeScript SDK client to this server: list_tools is hand-written. #[tool_handler] generates one that hardcodes ttl_ms: None, cache_scope: None, but both are required on a paginated result at 2026-07-28 (SEP-2549), and a strict client rejects the response outright. The macro skips generation when the impl defines the method. Without this workaround the server is unusable from such a client. get_info does not pin ProtocolVersion::V_2026_07_28. rmcp's server never implements server/discover - the string appears only in its client - so it cannot serve a modern-era opening, and pinning the version only overstates support. Note also that rmcp sends an array-rooted schema as written on every connection; it does not project it down to the object form older revisions require, the way the TypeScript SDK does. Adopting an array root is therefore a breaking change for pre-2026-07-28 clients. The README says so. The model identifier moves to claude-sonnet-5, the current Sonnet. All four quickstart clients now name the same model; they had drifted onto three different ones, and the Rust client was on claude-sonnet-4-20250514, which is past end of life and warns on every run. Each channel goes to its stated reader. `content` is what the model reads, so that is what the client forwards, narrowed to the text blocks. Structured content is for the application around the model - already validated against the declared schema - so the client uses it as data, reporting how many items came back. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 7b7f81e commit ca2b563

8 files changed

Lines changed: 761 additions & 207 deletions

File tree

mcp-client-rust/Cargo.lock

Lines changed: 419 additions & 99 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

mcp-client-rust/Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ edition = "2024"
66
[dependencies]
77
anyhow = "1.0.100"
88
genai = "0.4.2"
9-
rmcp = { version = "0.8.0", features = ["server", "client", "transport-io", "transport-child-process"] }
9+
jsonschema = "0.35"
10+
rmcp = { version = "3.0.0-beta.2", features = ["server", "client", "transport-io", "transport-child-process"] }
1011
tokio = { version = "1.47.1", features = ['full']}
1112
tracing = "0.1.41"
1213
tracing-subscriber = {version = "0.3", features = ["env-filter"]}

mcp-client-rust/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
11
# An LLM-Powered Chatbot MCP Client written in Rust
22

33
See the [Build an MCP client](https://modelcontextprotocol.io/docs/develop/build-client) tutorial for more information.
4+
5+
## Structured output
6+
7+
rmcp does not validate tool output, so this client compiles each declared `outputSchema` at connect time and checks results against it — the spec's client-side SHOULD. It uses the [`jsonschema`](https://docs.rs/jsonschema) crate, which rmcp's own documentation recommends.
8+
9+
The two channels go to different readers: `content` is forwarded to the model, while `structured_content` is used as data — the client counts the items it returns. See [Structured Content](https://modelcontextprotocol.io/specification/draft/server/tools#structured-content).

mcp-client-rust/src/main.rs

Lines changed: 74 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,24 @@ use genai::Client;
33
use genai::chat::{
44
ChatMessage, ChatRequest, ChatResponse, ContentPart, Tool as GenaiTool, ToolResponse,
55
};
6-
use rmcp::model::{CallToolRequestParam, Tool as McpTool};
6+
use jsonschema::Validator;
7+
use rmcp::model::{CallToolRequestParams, CallToolResult, Tool as McpTool};
78
use rmcp::service::{RoleClient, RunningService, ServiceExt};
89
use rmcp::transport::TokioChildProcess;
910
use serde_json::Value;
11+
use std::collections::HashMap;
1012
use tokio::io::{self, AsyncBufReadExt, BufReader};
1113
use tokio::process::Command;
1214

13-
const MODEL_ANTHROPIC: &str = "claude-sonnet-4-20250514";
15+
const MODEL_ANTHROPIC: &str = "claude-sonnet-5";
1416

1517
struct MCPClient {
1618
anthropic: Client,
1719
session: Option<RunningService<RoleClient, ()>>,
1820
tools: Vec<GenaiTool>,
21+
/// Compiled `outputSchema` per tool name. rmcp does not validate results,
22+
/// so this client does it with the `jsonschema` crate.
23+
output_schemas: HashMap<String, Validator>,
1924
}
2025

2126
impl MCPClient {
@@ -24,9 +29,28 @@ impl MCPClient {
2429
anthropic: Client::default(),
2530
session: None,
2631
tools: Vec::new(),
32+
output_schemas: HashMap::new(),
2733
})
2834
}
2935

36+
/// Check a result against its tool's declared `outputSchema`. Error results
37+
/// are exempt: they carry a message, not data.
38+
fn validate_tool_output(&self, name: &str, result: &CallToolResult) -> Result<()> {
39+
let Some(validator) = self.output_schemas.get(name) else {
40+
return Ok(());
41+
};
42+
if result.is_error.unwrap_or(false) {
43+
return Ok(());
44+
}
45+
let Some(structured) = &result.structured_content else {
46+
bail!("Tool {name} declares an output schema but returned no structured content");
47+
};
48+
if let Err(error) = validator.validate(structured) {
49+
bail!("Structured content from tool {name} does not match its output schema: {error}");
50+
}
51+
Ok(())
52+
}
53+
3054
async fn connect_to_server(&mut self, server_args: &[String]) -> Result<()> {
3155
if self.session.is_some() {
3256
bail!("Client is already connected to a server");
@@ -52,6 +76,18 @@ impl MCPClient {
5276

5377
println!("Connected to server with tools: {tool_names:?}");
5478

79+
// An outputSchema root may be any JSON Schema, not just an object.
80+
for tool in &rmcp_tools {
81+
let Some(schema) = &tool.output_schema else {
82+
continue;
83+
};
84+
let schema = Value::Object(schema.as_ref().clone());
85+
let validator = Validator::new(&schema).with_context(|| {
86+
format!("Failed to compile output schema of tool {}", tool.name)
87+
})?;
88+
self.output_schemas.insert(tool.name.to_string(), validator);
89+
}
90+
5591
self.tools = convert_tools(&rmcp_tools);
5692
self.session = Some(session);
5793
Ok(())
@@ -93,16 +129,33 @@ impl MCPClient {
93129
));
94130

95131
// Query the MCP server
132+
let mut params = CallToolRequestParams::new(tool_call.fn_name.clone());
133+
if let Some(arguments) = tool_call.fn_arguments.as_object().cloned() {
134+
params = params.with_arguments(arguments);
135+
}
96136
let tool_result = session
97-
.call_tool(CallToolRequestParam {
98-
name: tool_call.fn_name.clone().into(),
99-
arguments: tool_call.fn_arguments.as_object().cloned(),
100-
})
137+
.call_tool(params)
101138
.await
102139
.with_context(|| format!("Tool call {} failed", tool_call.fn_name))?;
103140

104-
let payload = serde_json::to_string(&tool_result)
105-
.context("Failed to serialize tool result")?;
141+
self.validate_tool_output(&tool_call.fn_name, &tool_result)?;
142+
143+
// structured_content is data the application can use directly.
144+
if let Some(Value::Array(items)) = &tool_result.structured_content {
145+
final_text.push(format!(
146+
"[{} returned {} items]",
147+
tool_call.fn_name,
148+
items.len()
149+
));
150+
}
151+
152+
// content is a list of block types; forward only the text ones.
153+
let payload = tool_result
154+
.content
155+
.iter()
156+
.filter_map(|block| block.as_text().map(|text| text.text.as_str()))
157+
.collect::<Vec<_>>()
158+
.join("\n");
106159

107160
tool_results.push(ContentPart::ToolResponse(ToolResponse::new(
108161
tool_call.call_id.clone(),
@@ -179,7 +232,8 @@ impl MCPClient {
179232

180233
#[tokio::main]
181234
async fn main() -> Result<()> {
182-
dotenvy::dotenv().context("Failed to load env file")?;
235+
// .env is optional; the key may come from the environment instead.
236+
let _ = dotenvy::dotenv();
183237

184238
let mut args = std::env::args();
185239
let _ = args.next();
@@ -194,6 +248,17 @@ async fn main() -> Result<()> {
194248

195249
let result = async {
196250
client.connect_to_server(&server_args).await?;
251+
252+
// Connecting and listing tools needs no credentials; querying them
253+
// does. Matching the Python and TypeScript clients, report and exit
254+
// rather than failing, so the connection itself can be exercised
255+
// without a key.
256+
if std::env::var("ANTHROPIC_API_KEY").is_err() {
257+
println!("\nNo ANTHROPIC_API_KEY found. To query these tools with Claude, set your API key:");
258+
println!(" export ANTHROPIC_API_KEY=your-api-key-here");
259+
return Ok(());
260+
}
261+
197262
client.chat_loop().await
198263
}
199264
.await;

0 commit comments

Comments
 (0)