#![expect(
clippy::unused_async,
reason = "the generated handler awaits the call, and a plain value has nothing to await"
)]
#![expect(
clippy::unused_async_trait_impl,
reason = "the generated handler awaits the call, and a plain value has nothing to await"
)]
use std::sync::Arc;
use protocol_droid::application::server::context::{
CallbackExchange, ExchangeError, PeerSession, RequestContext,
};
use protocol_droid::application::server::{Dispatcher, Server};
use protocol_droid::application::session::{FrameSink, SinkError};
use protocol_droid::domain::feature::{Negotiation, resolve};
use protocol_droid::domain::ir::{Content, ImageContent, ToolResult};
use protocol_droid::domain::ir::{JsonObject, PromptMessage, ResourceContents, Role, TextContent};
use protocol_droid::domain::registry::{AuthContext, Prompt, PromptArgument, Resource};
use protocol_droid::domain::session::{CancellationToken, SessionId};
use protocol_droid::domain::{CapabilitySet, ProtocolVersion, RequestId, TransportKind};
use serde::Serialize;
#[derive(Debug, Clone)]
struct Greeting {
prefix: String,
}
#[derive(Default)]
struct Demo;
#[protocol_droid::server(name = "demo-server")]
impl Demo {
#[protocol_droid::tool]
async fn add(a: i32, b: i32) -> Result<AddAnswer, DemoError> {
Ok(AddAnswer { total: a + b })
}
#[protocol_droid::tool]
async fn shout(ctx: RequestContext, text: String) -> Result<String, DemoError> {
let _ = ctx;
Ok(text.to_uppercase())
}
#[protocol_droid::tool]
async fn ping() -> Result<String, DemoError> {
Ok("pong".to_string())
}
#[protocol_droid::tool]
async fn explode(what: String) -> Result<String, DemoError> {
Err(DemoError::Refused(what))
}
#[protocol_droid::tool(name = "renamed", description = "A named description.")]
async fn original_name() -> Result<String, DemoError> {
Ok("named".to_string())
}
#[protocol_droid::tool(name = "greet")]
async fn greet(ctx: RequestContext, who: String) -> Result<String, DemoError> {
let greeting = ctx
.state::<Greeting>()
.ok_or_else(|| DemoError::Refused("no greeting registered".to_owned()))?;
Ok(greeting.prefix.clone() + &who)
}
#[protocol_droid::resource(reader = read_readme)]
fn readme() -> Resource {
let mut resource = Resource::new("file:///README.md", "readme");
resource.description = Some("The project's readme.".to_string());
resource.mime_type = Some("text/markdown".to_string());
resource
}
fn read_readme(_context: RequestContext, uri: &str) -> Vec<ResourceContents> {
vec![ResourceContents::text(uri, "# Readme").with_mime_type("text/markdown")]
}
#[protocol_droid::resource(reader = read_licence)]
fn licence() -> Resource {
Resource::new("file:///LICENSE", "licence")
}
fn read_licence(_context: RequestContext, uri: &str) -> Vec<ResourceContents> {
vec![ResourceContents::text(uri, "MIT")]
}
#[protocol_droid::prompt(getter = get_review)]
fn review() -> Prompt {
let mut prompt = Prompt::new("review");
prompt.arguments.push(PromptArgument {
name: "diff".to_string(),
description: Some("The text to review.".to_string()),
title: None,
required: true,
});
prompt.arguments.push(PromptArgument {
name: "focus".to_string(),
description: None,
title: None,
required: false,
});
prompt
}
fn get_review(_context: RequestContext, arguments: &JsonObject) -> Vec<PromptMessage> {
let focus = serde_json::from_str::<serde_json::Value>(arguments.as_str())
.ok()
.and_then(|parsed| {
parsed
.get("focus")
.and_then(serde_json::Value::as_str)
.map(str::to_owned)
});
vec![PromptMessage::new(
Role::User,
Content::Text(TextContent::new(format!(
"Review this diff{}",
focus.map_or_else(String::new, |focus| format!(", focusing on {focus}")),
))),
)]
}
#[allow(dead_code, reason = "the test asserts it was not registered as one")]
async fn helper(&self) -> i32 {
7
}
}
struct Other;
#[protocol_droid::server(name = "other-server")]
impl Other {
#[protocol_droid::tool]
async fn add(a: i32, b: i32) -> Result<AddAnswer, DemoError> {
Ok(AddAnswer { total: a * b })
}
#[protocol_droid::resource(reader = read_readme)]
fn readme() -> Resource {
Resource::new("file:///other/README.md", "other-readme")
}
#[protocol_droid::prompt(getter = get_review)]
fn review() -> Prompt {
Prompt::new("other-review")
}
fn read_readme(_context: RequestContext, uri: &str) -> Vec<ResourceContents> {
vec![ResourceContents::text(uri, "# Other")]
}
fn get_review(_context: RequestContext, _arguments: &JsonObject) -> Vec<PromptMessage> {
vec![PromptMessage::new(
Role::User,
Content::Text(TextContent::new("review this")),
)]
}
}
#[derive(Serialize)]
struct AddAnswer {
total: i32,
}
#[derive(Debug)]
enum DemoError {
Refused(String),
}
impl core::fmt::Display for DemoError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::Refused(what) => write!(f, "refused: {what}"),
}
}
}
#[derive(Debug)]
enum Failure {
Message(String),
}
impl core::fmt::Display for Failure {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::Message(text) => f.write_str(text),
}
}
}
impl std::error::Error for Failure {}
impl From<String> for Failure {
fn from(text: String) -> Self {
Self::Message(text)
}
}
impl From<&str> for Failure {
fn from(text: &str) -> Self {
Self::Message(text.to_string())
}
}
impl From<protocol_droid::domain::ir::JsonObjectError> for Failure {
fn from(error: protocol_droid::domain::ir::JsonObjectError) -> Self {
Self::Message(format!("bad fixture: {error}"))
}
}
type TestError = Failure;
struct NoExchange;
impl CallbackExchange for NoExchange {
fn exchange(
&self,
method: &str,
_params: &JsonObject,
) -> impl std::future::Future<Output = Result<JsonObject, ExchangeError>> + Send {
let _ = method;
std::future::ready(Err(ExchangeError::new("no callbacks here")))
}
}
struct NoSink;
impl FrameSink for NoSink {
fn send(
&self,
_frame: &str,
) -> impl std::future::Future<Output = Result<(), SinkError>> + Send {
std::future::ready(Ok(()))
}
}
fn dispatcher(server: Server) -> Arc<Dispatcher> {
let version = ProtocolVersion::V2025_06_18;
let peer = PeerSession::new(
version,
resolve(Negotiation {
version,
client_caps: CapabilitySet::all_for(version),
server_caps: CapabilitySet::all_for(version),
transport: TransportKind::StreamableHttp,
}),
SessionId::new(1),
TransportKind::Stdio,
Arc::new(NoExchange),
Arc::new(NoSink),
)
.with_auth(AuthContext::unrestricted("tester"));
let ctx =
RequestContext::for_request(&peer, RequestId::text("r1"), CancellationToken::new(), None);
let _ = ctx;
Arc::new(Dispatcher::new(Arc::new(server)))
}
async fn list(dispatcher: &Arc<Dispatcher>) -> Result<serde_json::Value, TestError> {
listing(dispatcher, "tools/list").await
}
async fn listing(
dispatcher: &Arc<Dispatcher>,
method: &str,
) -> Result<serde_json::Value, TestError> {
let listed = dispatcher
.dispatch(&context(), method, &JsonObject::default())
.await
.map_err(|error| Failure::from(format!("{method} was refused: {error}")))?;
serde_json::from_str(listed.as_str())
.map_err(|error| Failure::from(format!("the listing is not JSON: {error}")))
}
fn tool<'a>(
listing: &'a serde_json::Value,
name: &str,
) -> Result<&'a serde_json::Value, TestError> {
entry(listing, "tools", "name", name)
}
fn entry<'a>(
listing: &'a serde_json::Value,
key: &str,
field: &str,
value: &str,
) -> Result<&'a serde_json::Value, TestError> {
listing
.get(key)
.and_then(serde_json::Value::as_array)
.ok_or_else(|| Failure::from(format!("the listing has no {key} array")))?
.iter()
.find(|entry| entry.get(field).and_then(serde_json::Value::as_str) == Some(value))
.ok_or_else(|| Failure::from(format!("no {key} entry whose {field} is {value}")))
}
fn ids(listing: &serde_json::Value, key: &str, field: &str) -> Result<Vec<String>, TestError> {
let mut found: Vec<String> = listing
.get(key)
.and_then(serde_json::Value::as_array)
.ok_or_else(|| Failure::from(format!("the listing has no {key} array")))?
.iter()
.filter_map(|entry| entry.get(field).and_then(serde_json::Value::as_str))
.map(ToString::to_string)
.collect();
found.sort();
Ok(found)
}
fn structured(answer: &JsonObject, member: &str) -> Result<serde_json::Value, TestError> {
let parsed: serde_json::Value = serde_json::from_str(answer.as_str())
.map_err(|error| Failure::from(format!("the answer is not JSON: {error}")))?;
parsed
.get("structuredContent")
.and_then(|content| content.get(member))
.cloned()
.ok_or_else(|| Failure::from(format!("the answer has no structuredContent.{member}")))
}
fn properties(schema: &serde_json::Value) -> Vec<String> {
let mut names = Vec::new();
if let Some(members) = schema
.get("properties")
.and_then(serde_json::Value::as_object)
{
for name in members.keys() {
names.push(name.clone());
}
}
names.sort();
names
}
#[test]
fn the_server_name_comes_from_the_attribute() -> Result<(), TestError> {
let server = Demo.into_server()?;
if server.name() != "demo-server" {
return Err(Failure::from(format!(
"the server is named {}",
server.name()
)));
}
Ok(())
}
#[tokio::test]
async fn a_tools_schema_carries_its_parameters() -> Result<(), TestError> {
let dispatcher = dispatcher(Demo.into_server()?);
let listing = list(&dispatcher).await?;
let add = tool(&listing, "add")?;
let schema = add
.get("inputSchema")
.ok_or("the tool has no inputSchema")?;
let names = properties(schema);
if names != ["a", "b"] {
return Err(Failure::from(format!(
"add's schema has properties {names:?}"
)));
}
Ok(())
}
#[tokio::test]
async fn a_tool_with_no_parameters_still_works() -> Result<(), TestError> {
let dispatcher = dispatcher(Demo.into_server()?);
let ctx = context();
let answer = dispatcher
.dispatch(&ctx, "tools/call", &call("ping", "{}"))
.await
.map_err(|error| Failure::from(error.to_string()))?;
assert_text(&answer, "pong")
}
#[tokio::test]
async fn the_doc_comment_becomes_the_description() -> Result<(), TestError> {
let dispatcher = dispatcher(Demo.into_server()?);
let listing = list(&dispatcher).await?;
let add = tool(&listing, "add")?;
if add.get("description").and_then(serde_json::Value::as_str) != Some("Adds two numbers.") {
return Err(Failure::from(format!(
"add is described as {:?}",
add.get("description")
)));
}
Ok(())
}
#[tokio::test]
async fn an_explicit_description_wins_over_the_doc_comment() -> Result<(), TestError> {
let dispatcher = dispatcher(Demo.into_server()?);
let listing = list(&dispatcher).await?;
let renamed = tool(&listing, "renamed")?;
if renamed
.get("description")
.and_then(serde_json::Value::as_str)
!= Some("A named description.")
{
return Err(Failure::from(format!(
"renamed is described as {:?}",
renamed.get("description")
)));
}
Ok(())
}
#[tokio::test]
async fn an_explicit_name_renames_the_tool() -> Result<(), TestError> {
let dispatcher = dispatcher(Demo.into_server()?);
let listing = list(&dispatcher).await?;
if tool(&listing, "renamed").is_err() {
return Err(Failure::from("the renamed tool is not in the listing"));
}
if tool(&listing, "original_name").is_ok() {
return Err(Failure::from(
"the Rust name is still on the wire after a rename",
));
}
Ok(())
}
#[tokio::test]
async fn a_context_parameter_is_not_part_of_the_schema() -> Result<(), TestError> {
let dispatcher = dispatcher(Demo.into_server()?);
let listing = list(&dispatcher).await?;
let shout = tool(&listing, "shout")?;
let schema = shout
.get("inputSchema")
.ok_or("the tool has no inputSchema")?;
let names = properties(schema);
if names != ["text"] {
return Err(Failure::from(format!(
"shout's schema has properties {names:?}"
)));
}
Ok(())
}
#[tokio::test]
async fn a_structured_answer_reaches_both_members() -> Result<(), TestError> {
let dispatcher = dispatcher(Demo.into_server()?);
let ctx = context();
let answer = dispatcher
.dispatch(&ctx, "tools/call", &call("add", r#"{"a":2,"b":3}"#))
.await
.map_err(|error| Failure::from(error.to_string()))?;
let parsed: serde_json::Value = serde_json::from_str(answer.as_str())
.map_err(|error| Failure::from(format!("the answer is not JSON: {error}")))?;
if parsed
.get("structuredContent")
.and_then(|value| value.get("total"))
!= Some(&serde_json::Value::from(5))
{
return Err(Failure::from(format!(
"the structured answer is wrong: {parsed}"
)));
}
Ok(())
}
#[tokio::test]
async fn arguments_reach_the_handler_as_its_own_types() -> Result<(), TestError> {
let dispatcher = dispatcher(Demo.into_server()?);
let ctx = context();
let answer = dispatcher
.dispatch(&ctx, "tools/call", &call("shout", r#"{"text":"hello"}"#))
.await
.map_err(|error| Failure::from(error.to_string()))?;
assert_text(&answer, "HELLO")
}
#[tokio::test]
async fn a_handler_error_is_an_error_result() -> Result<(), TestError> {
let dispatcher = dispatcher(Demo.into_server()?);
let ctx = context();
let answer = dispatcher
.dispatch(&ctx, "tools/call", &call("explode", r#"{"what":"nope"}"#))
.await
.map_err(|error| Failure::from(format!("the call itself failed: {error}")))?;
let parsed: serde_json::Value = serde_json::from_str(answer.as_str())
.map_err(|error| Failure::from(format!("the answer is not JSON: {error}")))?;
if parsed.get("isError") != Some(&serde_json::Value::Bool(true)) {
return Err(Failure::from(format!(
"the refusal is not flagged: {parsed}"
)));
}
Ok(())
}
#[tokio::test]
async fn wrong_arguments_are_refused_with_the_reason() -> Result<(), TestError> {
let dispatcher = dispatcher(Demo.into_server()?);
let ctx = context();
let answer = dispatcher
.dispatch(&ctx, "tools/call", &call("add", r#"{"a":"not a number"}"#))
.await
.map_err(|error| Failure::from(format!("the call itself failed: {error}")))?;
let parsed: serde_json::Value = serde_json::from_str(answer.as_str())
.map_err(|error| Failure::from(format!("the answer is not JSON: {error}")))?;
if parsed.get("isError") != Some(&serde_json::Value::Bool(true)) {
return Err(Failure::from(format!(
"the refusal is not flagged: {parsed}"
)));
}
let text = parsed
.get("content")
.and_then(serde_json::Value::as_array)
.and_then(|blocks| blocks.first())
.and_then(|block| block.get("text"))
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_string();
if !text.contains("arguments") {
return Err(Failure::from(format!(
"the refusal does not mention the arguments: {text}"
)));
}
Ok(())
}
#[tokio::test]
async fn an_unannotated_method_is_not_a_tool() -> Result<(), TestError> {
let dispatcher = dispatcher(Demo.into_server()?);
let listing = list(&dispatcher).await?;
let listed = listing
.get("tools")
.and_then(serde_json::Value::as_array)
.ok_or("the listing has no tools array")?;
if listed
.iter()
.filter_map(|entry| entry.get("name"))
.any(|name| name == "helper")
{
return Err(Failure::from("an unannotated method was registered"));
}
Ok(())
}
#[tokio::test]
async fn a_resource_lands_in_the_listing_as_its_method_returned_it() -> Result<(), TestError> {
let dispatcher = dispatcher(Demo.into_server()?);
let listed = listing(&dispatcher, "resources/list").await?;
let readme = entry(&listed, "resources", "uri", "file:///README.md")?;
let expected = serde_json::json!({
"uri": "file:///README.md",
"name": "readme",
"description": "The project's readme.",
"mimeType": "text/markdown",
});
if readme != &expected {
return Err(Failure::from(format!(
"the readme resource is listed as {readme}"
)));
}
Ok(())
}
#[tokio::test]
async fn every_marked_resource_is_listed() -> Result<(), TestError> {
let dispatcher = dispatcher(Demo.into_server()?);
let listed = listing(&dispatcher, "resources/list").await?;
let uris = ids(&listed, "resources", "uri")?;
if uris != ["file:///LICENSE", "file:///README.md"] {
return Err(Failure::from(format!("the resources listed are {uris:?}")));
}
Ok(())
}
#[tokio::test]
async fn a_prompt_lands_in_the_listing_with_its_arguments() -> Result<(), TestError> {
let dispatcher = dispatcher(Demo.into_server()?);
let listed = listing(&dispatcher, "prompts/list").await?;
let review = entry(&listed, "prompts", "name", "review")?;
let expected = serde_json::json!({
"name": "review",
"arguments": [
{"name": "diff", "description": "The text to review.", "required": true},
{"name": "focus", "required": false},
],
});
if review != &expected {
return Err(Failure::from(format!(
"the review prompt is listed as {review}"
)));
}
Ok(())
}
#[tokio::test]
async fn a_resource_and_a_tool_do_not_collide() -> Result<(), TestError> {
let dispatcher = dispatcher(Demo.into_server()?);
let tools = listing(&dispatcher, "tools/list").await?;
let resources = listing(&dispatcher, "resources/list").await?;
let prompts = listing(&dispatcher, "prompts/list").await?;
tool(&tools, "add")?;
let ctx = context();
let answer = dispatcher
.dispatch(&ctx, "tools/call", &call("add", r#"{"a":2,"b":3}"#))
.await
.map_err(|error| Failure::from(error.to_string()))?;
if structured(&answer, "total")? != 5 {
return Err(Failure::from("add did not answer with the sum"));
}
entry(&resources, "resources", "uri", "file:///README.md")?;
entry(&prompts, "prompts", "name", "review")?;
let names = ids(&tools, "tools", "name")?;
for primitive in ["readme", "review"] {
if names.iter().any(|name| name == primitive) {
return Err(Failure::from(format!("{primitive} was listed as a tool")));
}
}
if ids(&resources, "resources", "uri")?
.iter()
.any(|uri| uri == "add")
|| ids(&prompts, "prompts", "name")?
.iter()
.any(|name| name == "add")
{
return Err(Failure::from("the tool was listed as a primitive"));
}
Ok(())
}
#[tokio::test]
async fn two_servers_in_one_module_keep_their_own_primitives() -> Result<(), TestError> {
let dispatcher = dispatcher(Other.into_server()?);
let tools = listing(&dispatcher, "tools/list").await?;
let ctx = context();
let answer = dispatcher
.dispatch(&ctx, "tools/call", &call("add", r#"{"a":2,"b":3}"#))
.await
.map_err(|error| Failure::from(error.to_string()))?;
if structured(&answer, "total")? != 6 {
return Err(Failure::from(
"the other server's add did not answer with the product",
));
}
if ids(&tools, "tools", "name")? != ["add"] {
return Err(Failure::from("the other server's tool listing is wrong"));
}
let resources = listing(&dispatcher, "resources/list").await?;
if ids(&resources, "resources", "uri")? != ["file:///other/README.md"] {
return Err(Failure::from(
"the other server's resource listing is wrong",
));
}
let prompts = listing(&dispatcher, "prompts/list").await?;
if ids(&prompts, "prompts", "name")? != ["other-review"] {
return Err(Failure::from("the other server's prompt listing is wrong"));
}
Ok(())
}
fn context() -> RequestContext {
context_with_id("r1")
}
fn context_with_id(id: &str) -> RequestContext {
let version = ProtocolVersion::V2025_06_18;
let peer = PeerSession::new(
version,
resolve(Negotiation {
version,
client_caps: CapabilitySet::all_for(version),
server_caps: CapabilitySet::all_for(version),
transport: TransportKind::StreamableHttp,
}),
SessionId::new(1),
TransportKind::Stdio,
Arc::new(NoExchange),
Arc::new(NoSink),
)
.with_auth(AuthContext::unrestricted("tester"));
RequestContext::for_request(&peer, RequestId::text(id), CancellationToken::new(), None)
}
fn context_with_server_state(server: &Server) -> RequestContext {
let version = ProtocolVersion::V2025_06_18;
let peer = PeerSession::new(
version,
resolve(Negotiation {
version,
client_caps: CapabilitySet::all_for(version),
server_caps: CapabilitySet::all_for(version),
transport: TransportKind::StreamableHttp,
}),
SessionId::new(1),
TransportKind::Stdio,
Arc::new(NoExchange),
Arc::new(NoSink),
)
.with_auth(AuthContext::unrestricted("tester"));
RequestContext::for_request_with_state(
&peer,
RequestId::text("r1"),
CancellationToken::new(),
None,
Arc::clone(server.state()),
)
}
fn call(name: &str, arguments: &str) -> JsonObject {
let parsed: serde_json::Value =
serde_json::from_str(arguments).unwrap_or(serde_json::Value::Null);
let document = serde_json::json!({ "name": name, "arguments": parsed });
JsonObject::from_value(document).unwrap_or_default()
}
fn assert_text(answer: &JsonObject, expected: &str) -> Result<(), TestError> {
let parsed: serde_json::Value = serde_json::from_str(answer.as_str())
.map_err(|error| Failure::from(format!("the answer is not JSON: {error}")))?;
let text = parsed
.get("content")
.and_then(serde_json::Value::as_array)
.and_then(|blocks| blocks.first())
.and_then(|block| block.get("text"))
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_string();
if text != expected {
return Err(Failure::from(format!("got {text:?}, wanted {expected:?}")));
}
Ok(())
}
#[tokio::test]
async fn a_tool_handler_reads_state_from_the_context() -> Result<(), TestError> {
let mut server = Demo.into_server()?;
server.with_state(Greeting {
prefix: "hello, ".to_owned(),
});
let dispatcher = dispatcher(server);
let answer = dispatcher
.dispatch(
&context_with_server_state(dispatcher.server()),
"tools/call",
&call("greet", r#"{"who": "world"}"#),
)
.await
.map_err(|error| Failure::from(format!("greet was refused: {error}")))?;
assert_text(&answer, "hello, world")
}
#[tokio::test]
async fn a_tool_handler_with_no_state_registered_refuses() -> Result<(), TestError> {
let server = Demo.into_server()?;
let dispatcher = dispatcher(server);
let answer = dispatcher
.dispatch(
&context(),
"tools/call",
&call("greet", r#"{"who": "world"}"#),
)
.await
.map_err(|error| Failure::from(format!("greet was refused at dispatch: {error}")))?;
if !answer.as_str().contains("no greeting registered") {
return Err(Failure::from(format!(
"expected the refused-state message, got {}",
answer.as_str()
)));
}
if !answer.as_str().contains("\"isError\":true") {
return Err(Failure::from(format!(
"expected the answer to be marked isError, got {}",
answer.as_str()
)));
}
Ok(())
}
struct ContentDemo;
#[protocol_droid::server(name = "content-demo")]
impl ContentDemo {
#[protocol_droid::tool]
async fn picture() -> ToolResult {
ToolResult::new(vec![Content::Image(ImageContent::new("aGk=", "image/png"))])
}
#[protocol_droid::tool]
async fn message(should_fail: bool) -> Result<ToolResult, String> {
let result = ToolResult::text("hello");
Ok(if should_fail { result.error() } else { result })
}
}
fn blocks(answer: &JsonObject) -> Result<Vec<serde_json::Value>, Failure> {
let parsed: serde_json::Value = serde_json::from_str(answer.as_str())
.map_err(|error| Failure::from(format!("the answer is not JSON: {error}")))?;
parsed
.get("content")
.and_then(serde_json::Value::as_array)
.cloned()
.ok_or_else(|| Failure::from(format!("the answer has no content array: {parsed}")))
}
fn claims_error(answer: &JsonObject) -> Result<bool, Failure> {
let parsed: serde_json::Value = serde_json::from_str(answer.as_str())
.map_err(|error| Failure::from(format!("the answer is not JSON: {error}")))?;
Ok(matches!(
parsed.get("isError"),
Some(serde_json::Value::Bool(true))
))
}
#[tokio::test]
async fn a_tool_returning_a_tool_result_reaches_the_wire_as_content() -> Result<(), TestError> {
let server = ContentDemo.into_server()?;
let dispatcher = dispatcher(server);
let answer = dispatcher
.dispatch(&context(), "tools/call", &call("picture", "{}"))
.await
.map_err(|error| Failure::from(format!("picture was refused: {error}")))?;
let blocks = blocks(&answer)?;
let first = blocks
.first()
.ok_or_else(|| Failure::from("the answer had no blocks"))?;
if first.get("type").and_then(serde_json::Value::as_str) != Some("image") {
return Err(Failure::from(format!("the block was {first}")));
}
if first.get("data").and_then(serde_json::Value::as_str) != Some("aGk=") {
return Err(Failure::from(format!("the data was {first}")));
}
if answer.as_str().contains("structuredContent") {
return Err(Failure::from(format!(
"the answer was wrapped in structuredContent: {answer}"
)));
}
Ok(())
}
#[tokio::test]
async fn a_tool_returning_a_result_of_a_tool_result_is_marked_on_request() -> Result<(), TestError>
{
let server = ContentDemo.into_server()?;
let dispatcher = dispatcher(server);
let plain = dispatcher
.dispatch(
&context(),
"tools/call",
&call("message", r#"{"should_fail": false}"#),
)
.await
.map_err(|error| Failure::from(format!("message was refused: {error}")))?;
assert_text(&plain, "hello")?;
if claims_error(&plain)? {
return Err(Failure::from("a successful result claimed an error"));
}
let failed = dispatcher
.dispatch(
&context_with_id("r2"),
"tools/call",
&call("message", r#"{"should_fail": true}"#),
)
.await
.map_err(|error| Failure::from(format!("message was refused: {error}")))?;
if !claims_error(&failed)? {
return Err(Failure::from(format!("expected isError, got {failed}")));
}
Ok(())
}