@@ -3,19 +3,24 @@ use genai::Client;
33use 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 } ;
78use rmcp:: service:: { RoleClient , RunningService , ServiceExt } ;
89use rmcp:: transport:: TokioChildProcess ;
910use serde_json:: Value ;
11+ use std:: collections:: HashMap ;
1012use tokio:: io:: { self , AsyncBufReadExt , BufReader } ;
1113use tokio:: process:: Command ;
1214
13- const MODEL_ANTHROPIC : & str = "claude-sonnet-4-20250514 " ;
15+ const MODEL_ANTHROPIC : & str = "claude-sonnet-5 " ;
1416
1517struct 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
2126impl 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]
181234async 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 ! ( "\n No 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