Skip to content

Commit d0dd7fe

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. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 7b7f81e commit d0dd7fe

8 files changed

Lines changed: 808 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: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,14 @@
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+
When a tool declares an `outputSchema`, the spec says clients **SHOULD** validate the structured result against it. rmcp does not do this for you — its docs point at the [`jsonschema`](https://crates.io/crates/jsonschema) crate, which is what this client uses. It happens in two steps:
8+
9+
- At connect time each declared `outputSchema` is compiled once into a `Validator`, keyed by tool name.
10+
- After each call `validate_tool_output` checks the result: a non-error result from a schema-declaring tool must carry `structuredContent`, and that content must conform. Error results are exempt — they carry a message, not data.
11+
12+
Note that `outputSchema` is not necessarily an object schema. As of protocol revision `2026-07-28` its root may be any JSON Schema, so a tool may answer with a top-level array (the weather server's `get_alerts` does).
13+
14+
When a result carries `structuredContent`, the client forwards that to the model in preference to re-serializing the whole `CallToolResult` — it is the data itself rather than prose describing it.

mcp-client-rust/src/main.rs

Lines changed: 73 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,26 @@ 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+
/// The compiled `outputSchema` of every tool that declared one, keyed by
22+
/// tool name. The spec says clients SHOULD validate structured results
23+
/// against it, so we compile once at connect time and check on every call.
24+
/// rmcp does not do this for you — it points at the `jsonschema` crate.
25+
output_schemas: HashMap<String, Validator>,
1926
}
2027

2128
impl MCPClient {
@@ -24,9 +31,29 @@ impl MCPClient {
2431
anthropic: Client::default(),
2532
session: None,
2633
tools: Vec::new(),
34+
output_schemas: HashMap::new(),
2735
})
2836
}
2937

38+
/// Enforce the spec's contract on a tool that declared an `outputSchema`:
39+
/// the result MUST carry conforming structured content. Error results are
40+
/// exempt — they carry a message, not data.
41+
fn validate_tool_output(&self, name: &str, result: &CallToolResult) -> Result<()> {
42+
let Some(validator) = self.output_schemas.get(name) else {
43+
return Ok(());
44+
};
45+
if result.is_error.unwrap_or(false) {
46+
return Ok(());
47+
}
48+
let Some(structured) = &result.structured_content else {
49+
bail!("Tool {name} declares an output schema but returned no structured content");
50+
};
51+
if let Err(error) = validator.validate(structured) {
52+
bail!("Structured content from tool {name} does not match its output schema: {error}");
53+
}
54+
Ok(())
55+
}
56+
3057
async fn connect_to_server(&mut self, server_args: &[String]) -> Result<()> {
3158
if self.session.is_some() {
3259
bail!("Client is already connected to a server");
@@ -52,6 +79,19 @@ impl MCPClient {
5279

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

82+
// An outputSchema root may be any JSON Schema as of protocol revision
83+
// 2026-07-28 — an object, but equally an array or a primitive.
84+
for tool in &rmcp_tools {
85+
let Some(schema) = &tool.output_schema else {
86+
continue;
87+
};
88+
let schema = Value::Object(schema.as_ref().clone());
89+
let validator = Validator::new(&schema).with_context(|| {
90+
format!("Failed to compile output schema of tool {}", tool.name)
91+
})?;
92+
self.output_schemas.insert(tool.name.to_string(), validator);
93+
}
94+
5595
self.tools = convert_tools(&rmcp_tools);
5696
self.session = Some(session);
5797
Ok(())
@@ -93,16 +133,26 @@ impl MCPClient {
93133
));
94134

95135
// Query the MCP server
136+
let mut params = CallToolRequestParams::new(tool_call.fn_name.clone());
137+
if let Some(arguments) = tool_call.fn_arguments.as_object().cloned() {
138+
params = params.with_arguments(arguments);
139+
}
96140
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-
})
141+
.call_tool(params)
101142
.await
102143
.with_context(|| format!("Tool call {} failed", tool_call.fn_name))?;
103144

104-
let payload = serde_json::to_string(&tool_result)
105-
.context("Failed to serialize tool result")?;
145+
self.validate_tool_output(&tool_call.fn_name, &tool_result)?;
146+
147+
// Prefer the structured result when the tool provides one: it
148+
// is the data itself rather than prose describing it. Note that
149+
// it need not be a JSON object — get_alerts answers with a
150+
// top-level array.
151+
let payload = match &tool_result.structured_content {
152+
Some(structured) => serde_json::to_string(structured),
153+
None => serde_json::to_string(&tool_result),
154+
}
155+
.context("Failed to serialize tool result")?;
106156

107157
tool_results.push(ContentPart::ToolResponse(ToolResponse::new(
108158
tool_call.call_id.clone(),
@@ -179,7 +229,10 @@ impl MCPClient {
179229

180230
#[tokio::main]
181231
async fn main() -> Result<()> {
182-
dotenvy::dotenv().context("Failed to load env file")?;
232+
// Load .env if present. Its absence is not an error: the API key may come
233+
// from the environment, and connecting to a server does not need one at
234+
// all — which is what lets this client be smoke-tested without credentials.
235+
let _ = dotenvy::dotenv();
183236

184237
let mut args = std::env::args();
185238
let _ = args.next();
@@ -194,6 +247,17 @@ async fn main() -> Result<()> {
194247

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

0 commit comments

Comments
 (0)