Skip to content

Commit d06b6ac

Browse files
authored
Merge pull request #167 from olaservo/structured-output-2026-07-28/rust
Add structured content to the Rust weather server and client and update to v3
2 parents 6935039 + b625be7 commit d06b6ac

8 files changed

Lines changed: 746 additions & 215 deletions

File tree

mcp-client-rust/Cargo.lock

Lines changed: 418 additions & 104 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.1", 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 content
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/2026-07-28/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)