From 94ca3dcfc6730c6024faba2334de0cd3724d25b6 Mon Sep 17 00:00:00 2001 From: Jean Mertz Date: Sat, 12 Sep 2026 10:10:20 +0200 Subject: [PATCH 01/29] refactor(attachment, cli): Retire MCP resource attachment resolution `mcp++://` attachments no longer fetch their contents. A conversation carrying one still loads, lists, and prints, and the attachment can still be removed, but resolving it for a query now fails naming the attachment and the `jp attachment rm` invocation that clears it. Every other scheme is unaffected. Attachments also all resolve while the CLI context is still in hand, rather than holding MCP-backed ones back until the MCP servers finish starting. Declaration order is unchanged: each attachment still reaches the provider as a document numbered by its position. This drops the MCP client parameter from `jp_attachment`'s `Handler` trait, so the base attachment crate no longer depends on `jp_mcp`. Per [RFD 109], that dependency is what keeps `jp_mcp` from owning tool execution: `jp_conversation` reaches `jp_mcp` through `jp_attachment` today, and a server living under `jp_mcp` cannot sit beneath a crate that depends on it. Plugin-based MCP attachments can be designed separately; MCP tools and their resource results are untouched. [RFD 109]: docs/rfd/109-in-process-jp-mcp-server.md Signed-off-by: Jean Mertz --- Cargo.lock | 12 +- crates/jp_attachment/Cargo.toml | 2 - crates/jp_attachment/src/lib.rs | 10 +- .../jp_attachment_agentic_shepherd/Cargo.toml | 1 - .../jp_attachment_agentic_shepherd/src/lib.rs | 7 +- crates/jp_attachment_bear_note/Cargo.toml | 1 - crates/jp_attachment_bear_note/src/lib.rs | 7 +- crates/jp_attachment_cmd_output/Cargo.toml | 2 - crates/jp_attachment_cmd_output/src/lib.rs | 7 +- .../jp_attachment_cmd_output/src/lib_tests.rs | 7 +- crates/jp_attachment_file_content/Cargo.toml | 2 - crates/jp_attachment_file_content/src/lib.rs | 7 +- .../src/lib_tests.rs | 19 +- crates/jp_attachment_github/Cargo.toml | 1 - crates/jp_attachment_github/src/lib.rs | 7 +- crates/jp_attachment_http_content/Cargo.toml | 1 - crates/jp_attachment_http_content/src/lib.rs | 13 +- crates/jp_attachment_mcp_resources/Cargo.toml | 6 +- crates/jp_attachment_mcp_resources/src/lib.rs | 84 ++- .../src/lib_tests.rs | 46 ++ crates/jp_cli/src/cmd/attachment.rs | 49 +- crates/jp_cli/src/cmd/attachment_tests.rs | 21 - crates/jp_cli/src/cmd/query.rs | 131 +---- crates/jp_cli/src/cmd/query_tests.rs | 62 +-- docs/architecture/ubiquitous-language.md | 4 +- docs/rfd/.priority.json | 2 +- ...scription-auth-with-credential-fallback.md | 2 + ...ional-jp-protocol-bridge-for-mcp-tools.md} | 41 +- docs/rfd/109-in-process-jp-mcp-server.md | 475 ++++++++++++++++ ...-anthropic-subscription-queries-via-acp.md | 506 ++++++++++++++++++ 30 files changed, 1117 insertions(+), 418 deletions(-) create mode 100644 crates/jp_attachment_mcp_resources/src/lib_tests.rs rename docs/rfd/{drafts/D31-transitional-jp-protocol-bridge-for-mcp-tools.md => 108-transitional-jp-protocol-bridge-for-mcp-tools.md} (93%) create mode 100644 docs/rfd/109-in-process-jp-mcp-server.md create mode 100644 docs/rfd/110-anthropic-subscription-queries-via-acp.md diff --git a/Cargo.lock b/Cargo.lock index 83292fbdd..baf05616e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2146,7 +2146,6 @@ dependencies = [ "camino", "dyn-clone", "dyn-hash", - "jp_mcp", "linkme", "percent-encoding", "serde", @@ -2163,7 +2162,6 @@ dependencies = [ "duct", "indoc", "jp_attachment", - "jp_mcp", "serde", "serde_json", "tokio", @@ -2180,7 +2178,6 @@ dependencies = [ "grizzly", "indoc", "jp_attachment", - "jp_mcp", "quick-xml", "serde", "test-log", @@ -2196,10 +2193,8 @@ dependencies = [ "camino", "camino-tempfile", "duct", - "indexmap", "indoc", "jp_attachment", - "jp_mcp", "quick-xml", "serde", "shlex", @@ -2218,10 +2213,8 @@ dependencies = [ "crossbeam-channel", "glob", "ignore", - "indexmap", "infer", "jp_attachment", - "jp_mcp", "serde", "test-log", "tokio", @@ -2238,7 +2231,6 @@ dependencies = [ "glob", "jp_attachment", "jp_github", - "jp_mcp", "serde", "tracing", "url", @@ -2252,7 +2244,6 @@ dependencies = [ "camino", "htmd", "jp_attachment", - "jp_mcp", "reqwest", "serde", "tracing", @@ -2285,9 +2276,8 @@ dependencies = [ "async-trait", "camino", "jp_attachment", - "jp_mcp", - "quick-xml", "serde", + "tokio", "url", ] diff --git a/crates/jp_attachment/Cargo.toml b/crates/jp_attachment/Cargo.toml index e787c8aa4..19337aaf5 100644 --- a/crates/jp_attachment/Cargo.toml +++ b/crates/jp_attachment/Cargo.toml @@ -13,8 +13,6 @@ repository.workspace = true version.workspace = true [dependencies] -jp_mcp = { workspace = true } - async-trait = { workspace = true } camino = { workspace = true } dyn-clone = { workspace = true } diff --git a/crates/jp_attachment/src/lib.rs b/crates/jp_attachment/src/lib.rs index 8f3365614..983e32b02 100644 --- a/crates/jp_attachment/src/lib.rs +++ b/crates/jp_attachment/src/lib.rs @@ -8,7 +8,6 @@ use async_trait::async_trait; use camino::Utf8Path; use dyn_clone::DynClone; use dyn_hash::DynHash; -use jp_mcp::Client; pub use linkme::{self, distributed_slice}; use serde::{Deserialize, Serialize}; pub use typetag; @@ -139,14 +138,7 @@ pub trait Handler: std::fmt::Debug + DynClone + DynHash + Send + Sync { /// /// The `cwd` parameter is the current working directory, and can be used to /// resolve relative paths. - /// - /// The `mcp_client` parameter is the MCP client to use for fetching - /// resources from MCP servers, if needed. - async fn get( - &self, - cwd: &Utf8Path, - mcp_client: Client, - ) -> Result, Box>; + async fn get(&self, cwd: &Utf8Path) -> Result, Box>; } dyn_clone::clone_trait_object!(Handler); diff --git a/crates/jp_attachment_agentic_shepherd/Cargo.toml b/crates/jp_attachment_agentic_shepherd/Cargo.toml index 60a55e451..64d0fb6cc 100644 --- a/crates/jp_attachment_agentic_shepherd/Cargo.toml +++ b/crates/jp_attachment_agentic_shepherd/Cargo.toml @@ -14,7 +14,6 @@ version.workspace = true [dependencies] jp_attachment = { workspace = true } -jp_mcp = { workspace = true } async-trait = { workspace = true } camino = { workspace = true } diff --git a/crates/jp_attachment_agentic_shepherd/src/lib.rs b/crates/jp_attachment_agentic_shepherd/src/lib.rs index 3e687e9f3..1f6904ae2 100644 --- a/crates/jp_attachment_agentic_shepherd/src/lib.rs +++ b/crates/jp_attachment_agentic_shepherd/src/lib.rs @@ -21,7 +21,6 @@ use camino::Utf8Path; use jp_attachment::{ Attachment, BoxedHandler, HANDLERS, Handler, distributed_slice, linkme, typetag, }; -use jp_mcp::Client; use serde::{Deserialize, Serialize}; use tracing::debug; use url::Url; @@ -79,11 +78,7 @@ impl Handler for AgenticShepherd { self.references.iter().map(Reference::to_url).collect() } - async fn get( - &self, - root: &Utf8Path, - _: Client, - ) -> Result, Box> { + async fn get(&self, root: &Utf8Path) -> Result, Box> { debug!( count = self.references.len(), "Fetching agentic-shepherd attachments." diff --git a/crates/jp_attachment_bear_note/Cargo.toml b/crates/jp_attachment_bear_note/Cargo.toml index b2aa75a8e..36e304b62 100644 --- a/crates/jp_attachment_bear_note/Cargo.toml +++ b/crates/jp_attachment_bear_note/Cargo.toml @@ -15,7 +15,6 @@ version.workspace = true [dependencies] grizzly = { workspace = true } jp_attachment = { workspace = true } -jp_mcp = { workspace = true } async-trait = { workspace = true } camino = { workspace = true } diff --git a/crates/jp_attachment_bear_note/src/lib.rs b/crates/jp_attachment_bear_note/src/lib.rs index 805884f64..a7bab4bf2 100644 --- a/crates/jp_attachment_bear_note/src/lib.rs +++ b/crates/jp_attachment_bear_note/src/lib.rs @@ -7,7 +7,6 @@ use jp_attachment::{ Attachment, BoxedHandler, HANDLERS, Handler, distributed_slice, linkme, percent_decode_str, percent_encode_str, typetag, }; -use jp_mcp::Client; use serde::{Deserialize, Serialize}; use tracing::debug; use url::Url; @@ -152,11 +151,7 @@ impl Handler for BearNotes { Ok(uris) } - async fn get( - &self, - _: &Utf8Path, - _: Client, - ) -> Result, Box> { + async fn get(&self, _: &Utf8Path) -> Result, Box> { let db = BearDb::open().map_err(|e| e.to_string())?; let mut attachments = vec![]; diff --git a/crates/jp_attachment_cmd_output/Cargo.toml b/crates/jp_attachment_cmd_output/Cargo.toml index 733c9582d..6068155c4 100644 --- a/crates/jp_attachment_cmd_output/Cargo.toml +++ b/crates/jp_attachment_cmd_output/Cargo.toml @@ -14,7 +14,6 @@ version.workspace = true [dependencies] jp_attachment = { workspace = true } -jp_mcp = { workspace = true } async-trait = { workspace = true } camino = { workspace = true } @@ -26,7 +25,6 @@ url = { workspace = true } [dev-dependencies] camino-tempfile = { workspace = true } -indexmap = { workspace = true } indoc = { workspace = true } test-log = { workspace = true } tokio = { workspace = true } diff --git a/crates/jp_attachment_cmd_output/src/lib.rs b/crates/jp_attachment_cmd_output/src/lib.rs index 88e7ae7ec..5d1cf05bb 100644 --- a/crates/jp_attachment_cmd_output/src/lib.rs +++ b/crates/jp_attachment_cmd_output/src/lib.rs @@ -6,7 +6,6 @@ use jp_attachment::{ Attachment, BoxedHandler, HANDLERS, Handler, distributed_slice, linkme, percent_decode_str, percent_encode_str, typetag, }; -use jp_mcp::Client; use serde::{Deserialize, Serialize}; use url::Url; @@ -106,11 +105,7 @@ impl Handler for Commands { Ok(commands) } - async fn get( - &self, - root: &Utf8Path, - _: Client, - ) -> Result, Box> { + async fn get(&self, root: &Utf8Path) -> Result, Box> { let mut attachments = vec![]; for command in &self.0 { let cmd_line = std::iter::once(command.cmd.clone()) diff --git a/crates/jp_attachment_cmd_output/src/lib_tests.rs b/crates/jp_attachment_cmd_output/src/lib_tests.rs index 3be90f9e6..838e572d4 100644 --- a/crates/jp_attachment_cmd_output/src/lib_tests.rs +++ b/crates/jp_attachment_cmd_output/src/lib_tests.rs @@ -1,4 +1,3 @@ -use indexmap::IndexMap; use test_log::test; use super::*; @@ -164,9 +163,8 @@ async fn test_commands_get_missing_binary_names_command() { ); let root = camino_tempfile::tempdir().unwrap(); - let client = Client::new(IndexMap::default()); let err = commands - .get(root.path(), client) + .get(root.path()) .await .expect_err("spawning a missing binary should error"); @@ -207,8 +205,7 @@ async fn test_commands_get() { std::fs::write(path.join("file1"), "").unwrap(); std::fs::write(path.join("file2"), "").unwrap(); - let client = Client::new(IndexMap::default()); - let attachments = commands.get(path, client).await.unwrap(); + let attachments = commands.get(path).await.unwrap(); assert_eq!(attachments, vec![ Attachment::text("false", indoc::indoc! {" diff --git a/crates/jp_attachment_file_content/Cargo.toml b/crates/jp_attachment_file_content/Cargo.toml index 2daae419a..fbe7f7dbe 100644 --- a/crates/jp_attachment_file_content/Cargo.toml +++ b/crates/jp_attachment_file_content/Cargo.toml @@ -14,7 +14,6 @@ version.workspace = true [dependencies] jp_attachment = { workspace = true } -jp_mcp = { workspace = true } async-trait = { workspace = true } camino = { workspace = true } @@ -29,7 +28,6 @@ url = { workspace = true } [dev-dependencies] camino-tempfile = { workspace = true } -indexmap = { workspace = true } test-log = { workspace = true } [lints] diff --git a/crates/jp_attachment_file_content/src/lib.rs b/crates/jp_attachment_file_content/src/lib.rs index cdc7f4bf8..fc18d4164 100644 --- a/crates/jp_attachment_file_content/src/lib.rs +++ b/crates/jp_attachment_file_content/src/lib.rs @@ -7,7 +7,6 @@ use ignore::{WalkBuilder, WalkState, overrides::OverrideBuilder}; use jp_attachment::{ Attachment, BoxedHandler, HANDLERS, Handler, distributed_slice, linkme, typetag, }; -use jp_mcp::Client; use serde::{Deserialize, Serialize}; use tracing::{debug, trace, warn}; use url::Url; @@ -93,11 +92,7 @@ impl Handler for FileContent { Ok(uris) } - async fn get( - &self, - cwd: &Utf8Path, - _: Client, - ) -> Result, Box> { + async fn get(&self, cwd: &Utf8Path) -> Result, Box> { debug!(id = self.scheme(), "Getting file attachment contents."); if self.includes.is_empty() { diff --git a/crates/jp_attachment_file_content/src/lib_tests.rs b/crates/jp_attachment_file_content/src/lib_tests.rs index e955cebab..9886ec934 100644 --- a/crates/jp_attachment_file_content/src/lib_tests.rs +++ b/crates/jp_attachment_file_content/src/lib_tests.rs @@ -1,6 +1,5 @@ use camino_tempfile::tempdir; use glob::Pattern; -use indexmap::IndexMap; use url::Url; use super::*; @@ -134,8 +133,7 @@ async fn test_file_get() -> Result<(), Box> { .add(&Url::parse("file:/file.txt")?, tmp.path()) .await?; - let client = Client::new(IndexMap::default()); - let attachments = handler.get(tmp.path(), client).await?; + let attachments = handler.get(tmp.path()).await?; assert_eq!(attachments.len(), 1); assert_eq!(attachments[0].source, "file.txt"); assert_eq!(attachments[0].as_text(), Some("content")); @@ -156,8 +154,7 @@ async fn test_file_get_image_png() -> Result<(), Box> { .add(&Url::parse("file:/screenshot.png")?, tmp.path()) .await?; - let client = Client::new(IndexMap::default()); - let attachments = handler.get(tmp.path(), client).await?; + let attachments = handler.get(tmp.path()).await?; assert_eq!(attachments.len(), 1); assert_eq!(attachments[0].source, "screenshot.png"); assert!(attachments[0].is_binary()); @@ -186,8 +183,7 @@ async fn test_file_get_image_jpeg() -> Result<(), Box> .add(&Url::parse("file:/photo.jpg")?, tmp.path()) .await?; - let client = Client::new(IndexMap::default()); - let attachments = handler.get(tmp.path(), client).await?; + let attachments = handler.get(tmp.path()).await?; assert_eq!(attachments.len(), 1); match &attachments[0].content { @@ -214,8 +210,7 @@ async fn test_file_get_pdf() -> Result<(), Box> { .add(&Url::parse("file:/doc.pdf")?, tmp.path()) .await?; - let client = Client::new(IndexMap::default()); - let attachments = handler.get(tmp.path(), client).await?; + let attachments = handler.get(tmp.path()).await?; assert_eq!(attachments.len(), 1); assert!(attachments[0].is_binary()); @@ -249,8 +244,7 @@ async fn test_file_get_mixed_text_and_binary() -> Result<(), Box Result, Box> { + async fn get(&self, _: &Utf8Path) -> Result, Box> { debug!(id = "gh", "Fetching GitHub attachments."); let mut attachments = Vec::with_capacity(self.urls.len()); diff --git a/crates/jp_attachment_http_content/Cargo.toml b/crates/jp_attachment_http_content/Cargo.toml index 5e1628e88..5d6aee69f 100644 --- a/crates/jp_attachment_http_content/Cargo.toml +++ b/crates/jp_attachment_http_content/Cargo.toml @@ -14,7 +14,6 @@ version.workspace = true [dependencies] jp_attachment = { workspace = true } -jp_mcp = { workspace = true } async-trait = { workspace = true } camino = { workspace = true } diff --git a/crates/jp_attachment_http_content/src/lib.rs b/crates/jp_attachment_http_content/src/lib.rs index 8c113e710..f8e67e7c7 100644 --- a/crates/jp_attachment_http_content/src/lib.rs +++ b/crates/jp_attachment_http_content/src/lib.rs @@ -6,7 +6,6 @@ use htmd::HtmlToMarkdown; use jp_attachment::{ Attachment, BoxedHandler, HANDLERS, Handler, distributed_slice, linkme, typetag, }; -use jp_mcp::Client; use reqwest::header::{CONTENT_TYPE, HeaderMap, HeaderValue, USER_AGENT}; use serde::{Deserialize, Serialize}; use tracing::{debug, error}; @@ -67,11 +66,7 @@ impl Handler for HttpContent { Ok(self.urls.iter().cloned().collect()) } - async fn get( - &self, - _: &Utf8Path, - _: Client, - ) -> Result, Box> { + async fn get(&self, _: &Utf8Path) -> Result, Box> { debug!(id = "http", "Getting http attachment contents."); fetch_all(&self.urls).await } @@ -104,11 +99,7 @@ impl Handler for HttpContent { Ok(self.urls.iter().cloned().collect()) } - async fn get( - &self, - _: &Utf8Path, - _: Client, - ) -> Result, Box> { + async fn get(&self, _: &Utf8Path) -> Result, Box> { debug!(id = "https", "Getting https attachment contents."); fetch_all(&self.urls).await } diff --git a/crates/jp_attachment_mcp_resources/Cargo.toml b/crates/jp_attachment_mcp_resources/Cargo.toml index 95803e0c3..95dbdbcb8 100644 --- a/crates/jp_attachment_mcp_resources/Cargo.toml +++ b/crates/jp_attachment_mcp_resources/Cargo.toml @@ -14,17 +14,17 @@ version.workspace = true [dependencies] jp_attachment = { workspace = true } -jp_mcp = { workspace = true } async-trait = { workspace = true } camino = { workspace = true } -quick-xml = { workspace = true, features = ["serialize"] } serde = { workspace = true } url = { workspace = true, features = ["serde"] } +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt"] } + [lints] workspace = true [lib] -test = false doctest = false diff --git a/crates/jp_attachment_mcp_resources/src/lib.rs b/crates/jp_attachment_mcp_resources/src/lib.rs index 7d431eb8a..4067ad7d4 100644 --- a/crates/jp_attachment_mcp_resources/src/lib.rs +++ b/crates/jp_attachment_mcp_resources/src/lib.rs @@ -1,11 +1,19 @@ -use std::{collections::BTreeSet, error::Error}; +//! The `mcp` attachment scheme, kept readable but no longer resolvable. +//! +//! Conversations recorded before MCP resource attachments were retired still +//! carry `mcp++://` entries under the `mcp` handler tag. +//! This handler keeps deserializing, listing, and removing them so those +//! conversations load, are inspectable, and can be edited. +//! Resolving one reports [`UnsupportedResolution`] instead of reading from an +//! MCP server. + +use std::{collections::BTreeSet, error::Error, fmt}; use async_trait::async_trait; use camino::Utf8Path; use jp_attachment::{ Attachment, BoxedHandler, HANDLERS, Handler, distributed_slice, linkme, typetag, }; -use jp_mcp::{Client, ResourceContents, id::McpServerId}; use serde::{Deserialize, Serialize}; use url::Url; @@ -20,34 +28,28 @@ fn handler() -> BoxedHandler { #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, Hash)] pub struct McpResources(BTreeSet); -/// Output from a command. -#[derive(Debug, Clone, PartialEq, Serialize)] -struct Resource(Vec); - -impl Resource { - pub fn try_to_xml(&self) -> Result> { - let mut buffer = String::new(); - let mut serializer = quick_xml::se::Serializer::new(&mut buffer); - serializer.indent(' ', 2); - self.serialize(serializer)?; - Ok(buffer) - } +/// Returned when an `mcp` attachment is asked for its contents. +/// +/// Names the attachment so a conversation carrying several of them says which +/// one to remove. +#[derive(Debug)] +pub struct UnsupportedResolution { + uri: Url, } -impl From> for Resource { - fn from(contents: Vec) -> Self { - Resource( - contents - .into_iter() - .filter_map(|c| match c { - ResourceContents::TextResourceContents { text, .. } => Some(text), - ResourceContents::BlobResourceContents { .. } => None, - }) - .collect(), +impl fmt::Display for UnsupportedResolution { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "MCP resource attachments are no longer resolved: `{}`. Remove it with `jp attachment \ + rm {}`.", + self.uri, self.uri ) } } +impl Error for UnsupportedResolution {} + #[typetag::serde(name = "mcp")] #[async_trait] impl Handler for McpResources { @@ -75,32 +77,14 @@ impl Handler for McpResources { Ok(self.0.clone().into_iter().collect()) } - async fn get( - &self, - _: &Utf8Path, - client: Client, - ) -> Result, Box> { - let mut attachments = vec![]; - for uri in &self.0 { - // "mcp+github-mcp-server+repo" -> ("mcp+github-mcp-server", "repo") - let (mcp, scheme) = uri.scheme().rsplit_once('+').unwrap_or(("", uri.scheme())); - - // "mcp+github-mcp-server" -> "github-mcp-server" - let server_id = McpServerId::new(mcp.split_once('+').unwrap_or(("", mcp)).1); - - let mut resource_uri = uri.clone(); - let _ = resource_uri.set_scheme(scheme); - - let resource = client - .get_resource_contents(&server_id, resource_uri) - .await?; - - attachments.push(Attachment::text( - uri.to_string(), - Resource::from(resource).try_to_xml()?, - )); + async fn get(&self, _: &Utf8Path) -> Result, Box> { + match self.0.iter().next() { + Some(uri) => Err(Box::new(UnsupportedResolution { uri: uri.clone() })), + None => Ok(vec![]), } - - Ok(attachments) } } + +#[cfg(test)] +#[path = "lib_tests.rs"] +mod tests; diff --git a/crates/jp_attachment_mcp_resources/src/lib_tests.rs b/crates/jp_attachment_mcp_resources/src/lib_tests.rs new file mode 100644 index 000000000..7f56f42d7 --- /dev/null +++ b/crates/jp_attachment_mcp_resources/src/lib_tests.rs @@ -0,0 +1,46 @@ +use jp_attachment::Handler as _; + +use super::*; + +fn uri() -> Url { + Url::parse("mcp+github-mcp-server+repo://owner/name").unwrap() +} + +#[tokio::test] +async fn stored_attachment_is_listed_and_removable() { + let mut handler = McpResources::default(); + handler.add(&uri(), Utf8Path::new("/")).await.unwrap(); + + assert_eq!(handler.list().await.unwrap(), vec![uri()]); + + handler.remove(&uri()).await.unwrap(); + assert_eq!(handler.list().await.unwrap(), Vec::::new()); +} + +#[tokio::test] +async fn resolving_a_stored_attachment_names_it_and_how_to_remove_it() { + let mut handler = McpResources::default(); + handler.add(&uri(), Utf8Path::new("/")).await.unwrap(); + + let error = handler + .get(Utf8Path::new("/")) + .await + .expect_err("mcp resource attachments no longer resolve"); + + assert_eq!( + error.to_string(), + "MCP resource attachments are no longer resolved: \ + `mcp+github-mcp-server+repo://owner/name`. Remove it with `jp attachment rm \ + mcp+github-mcp-server+repo://owner/name`." + ); +} + +/// A handler registered but never given a URI has nothing to refuse: the +/// scheme's presence in the registry must not fail a query that carries no +/// `mcp` attachment. +#[tokio::test] +async fn an_empty_handler_resolves_to_nothing() { + let handler = McpResources::default(); + + assert_eq!(handler.get(Utf8Path::new("/")).await.unwrap(), vec![]); +} diff --git a/crates/jp_cli/src/cmd/attachment.rs b/crates/jp_cli/src/cmd/attachment.rs index bdf853be1..a8fb67f38 100644 --- a/crates/jp_cli/src/cmd/attachment.rs +++ b/crates/jp_cli/src/cmd/attachment.rs @@ -1,4 +1,3 @@ -use camino::Utf8Path; use jp_attachment_agentic_shepherd as _; use jp_attachment_bear_note as _; use jp_attachment_cmd_output as _; @@ -103,52 +102,6 @@ pub(crate) fn validate_attachment(uri: &Url) -> Result<()> { Ok(()) } -/// Whether resolving this attachment reads from a running MCP server. -/// -/// Such an attachment can only be resolved once the server is up: -/// [`jp_mcp::Client::get_resource_contents`] reads the running-services map and -/// does not start a server on demand. -pub(crate) fn needs_mcp_server(uri: &Url) -> bool { - attachment_scheme(uri) == "mcp" -} - -/// Resolve attachments through their handlers, without a [`Ctx`]. -/// -/// Takes the two things a handler is given so a caller that no longer holds the -/// context can still resolve one. -/// `jp://` is not handled here: reading a conversation needs the workspace. -/// -/// Returns one group per URL, in the order the URLs were given. -/// A URL can yield several attachments, so a caller that has to place them back -/// among others needs the grouping to know where each one ends. -pub(crate) async fn resolve_attachments( - root: &Utf8Path, - mcp_client: &jp_mcp::Client, - urls: Vec, -) -> Result>> { - let futs = urls.into_iter().map(|uri| async move { - let scheme = attachment_scheme(&uri); - let Some(mut handler) = jp_attachment::find_handler_by_scheme(scheme) else { - return Err(Error::NotFound("Attachment handler", scheme.to_string())); - }; - - handler - .add(&uri, root) - .await - .map_err(|source| Error::AttachmentFailed { - uri: uri.clone(), - source, - })?; - - handler - .get(root, mcp_client.clone()) - .await - .map_err(|source| Error::AttachmentFailed { uri, source }) - }); - - futures::future::try_join_all(futs).await -} - pub(crate) async fn register_attachment( ctx: &Ctx, uri: Url, @@ -180,7 +133,7 @@ pub(crate) async fn register_attachment( })?; handler - .get(ctx.workspace.root(), ctx.mcp_client.clone()) + .get(ctx.workspace.root()) .await .map_err(|source| Error::AttachmentFailed { uri, source }) } diff --git a/crates/jp_cli/src/cmd/attachment_tests.rs b/crates/jp_cli/src/cmd/attachment_tests.rs index 882e5d2fd..1258d0790 100644 --- a/crates/jp_cli/src/cmd/attachment_tests.rs +++ b/crates/jp_cli/src/cmd/attachment_tests.rs @@ -9,27 +9,6 @@ use url::Url; use super::*; use crate::{Globals, ctx::Ctx, error::Error}; -/// An `mcp+…` attachment is the one kind that cannot resolve until its server -/// is running, so the query path holds it back until after the startup wait. -#[test] -fn only_mcp_attachments_wait_for_a_server() { - let mcp = Url::parse("mcp+github-mcp-server+repo://owner/repo/contents/README.md").unwrap(); - assert!(needs_mcp_server(&mcp)); - - for other in [ - "jp://17861332336", - "file://./README.md", - "https://example.com/page", - "cmd://git?arg=diff", - ] { - let url = Url::parse(other).unwrap(); - assert!( - !needs_mcp_server(&url), - "{other} resolves without a running MCP server" - ); - } -} - fn make_id(secs: u64) -> ConversationId { ConversationId::try_from( chrono::DateTime::::UNIX_EPOCH + std::time::Duration::from_secs(secs), diff --git a/crates/jp_cli/src/cmd/query.rs b/crates/jp_cli/src/cmd/query.rs index e2c350663..e77fd54e6 100644 --- a/crates/jp_cli/src/cmd/query.rs +++ b/crates/jp_cli/src/cmd/query.rs @@ -131,7 +131,7 @@ use url::Url; use super::{ ConversationLoadRequest, Output, - attachment::{load_conversation_attachments, needs_mcp_server, resolve_attachments}, + attachment::load_conversation_attachments, conversation_id::{ConversationIds, FlagIds}, lock::LockOutcome, target::TargetGrammar, @@ -1407,69 +1407,6 @@ impl AcquiredConversation { } } -/// One configured attachment, at the position the user declared it. -enum AttachmentSlot { - /// Resolved while the context was still in hand. - Ready(Vec), - - /// Read from an MCP server, so it waits for one to be running. - Deferred(Url), -} - -/// The turn's attachments, some of which cannot resolve yet. -/// -/// Resolving one can read a conversation out of the workspace, fetch over HTTP, -/// or read a resource from an MCP server. -/// The first needs a context the turn no longer holds and the last needs a -/// server that is still starting, so they are resolved at different points and -/// meet here. -/// -/// One slot per configured attachment, in declaration order. -/// The order reaches the provider: every attachment is sent as a document in -/// this order, numbered by its position. -struct PendingAttachments { - slots: Vec, -} - -impl PendingAttachments { - /// Resolve what is left and return the whole set, in declaration order. - async fn resolve( - self, - root: &Utf8Path, - mcp_client: &jp_mcp::Client, - ) -> Result> { - let deferred: Vec = self - .slots - .iter() - .filter_map(|slot| match slot { - AttachmentSlot::Deferred(url) => Some(url.clone()), - AttachmentSlot::Ready(_) => None, - }) - .collect(); - - let resolved = resolve_attachments(root, mcp_client, deferred).await?; - - Ok(splice(self.slots, resolved)) - } -} - -/// Flatten the slots, putting each resolved group back where its URL was. -/// -/// `deferred` holds one group per [`AttachmentSlot::Deferred`], in slot order: -/// the caller collects those URLs in that order and the resolver answers in -/// kind. -fn splice(slots: Vec, deferred: Vec>) -> Vec { - let mut deferred = deferred.into_iter(); - - slots - .into_iter() - .flat_map(|slot| match slot { - AttachmentSlot::Ready(attachments) => attachments, - AttachmentSlot::Deferred(_) => deferred.next().unwrap_or_default(), - }) - .collect() -} - /// Everything a turn needs, gathered in one place. /// /// Collecting reads the context; running does not. @@ -1492,8 +1429,12 @@ pub(crate) struct TurnInputs { /// Whether a user is there to answer a prompt or approve a tool call. interactive: bool, - /// What the assistant is given alongside the conversation. - attachments: PendingAttachments, + /// What the assistant is given alongside the conversation, in declaration + /// order. + /// + /// The order reaches the provider: every attachment is sent as a document + /// in this order, numbered by its position. + attachments: Vec, /// Where the turn's output goes. printer: Arc, @@ -1524,9 +1465,6 @@ impl TurnInputs { /// attachment here can fetch over HTTP, call the GitHub API, or shell out, /// and this waits for all of them. /// - /// An attachment that reads from an MCP server is the exception, held back - /// for [`Self::run`] to resolve once the servers it needs are up. - /// /// `printer` is where the turn's output goes: the terminal's printer for a /// turn typed there, or a sink printer, which writes nothing, for a turn /// started from somewhere with no terminal attached. @@ -1552,33 +1490,15 @@ impl TurnInputs { .map(AttachmentConfig::to_url) .collect::, _>>()?; - // Resolve what can be resolved now, then rebuild the declared order - // with a placeholder where each MCP-backed attachment goes. - let eager: Vec = urls - .iter() - .filter(|url| !needs_mcp_server(url)) - .cloned() - .collect(); - - let mut ready = load_conversation_attachments(ctx, eager).await?.into_iter(); - let slots: Vec = urls - .iter() - .map(|url| { - if needs_mcp_server(url) { - AttachmentSlot::Deferred(url.clone()) - } else { - AttachmentSlot::Ready(ready.next().unwrap_or_default()) - } - }) + // One group per URL, in declaration order, flattened into the order + // the provider receives them in. + let attachments: Vec = load_conversation_attachments(ctx, urls) + .await? + .into_iter() + .flatten() .collect(); - let deferred: Vec<&Url> = urls.iter().filter(|url| needs_mcp_server(url)).collect(); - debug!( - count = urls.len(), - deferred = deferred.len(), - deferred_uris = ?deferred.iter().map(|url| url.as_str()).collect::>(), - "Attachments loaded." - ); + debug!(count = attachments.len(), "Attachments loaded."); Ok(Self { workspace_root: ctx.workspace.root().to_path_buf(), @@ -1588,7 +1508,7 @@ impl TurnInputs { mcp_client: ctx.mcp_client.clone(), printer, interactive, - attachments: PendingAttachments { slots }, + attachments, mcp_servers, chat_request, pending_trim, @@ -1609,7 +1529,7 @@ impl TurnInputs { ) -> Result<()> { let cfg = &self.config; - let prepared = tokio::select! { + let tools = tokio::select! { result = async { // Wait for all MCP servers to finish loading, showing a timer line // when the wait takes long enough to be noticeable. @@ -1626,20 +1546,6 @@ impl TurnInputs { "MCP servers ready." ); - // Only now can the deferred ones resolve: the handler reads a - // resource from a running server, and until the wait above returns - // there is none. - let resolving = Instant::now(); - let attachments = self - .attachments - .resolve(&self.workspace_root, &self.mcp_client) - .await?; - debug!( - count = attachments.len(), - elapsed_ms = resolving.elapsed().as_millis(), - "Attachments resolved." - ); - let forced_tool = cfg.assistant.tool_choice.function_name(); let tools = tool_definitions( cfg.conversation.tools.iter(), @@ -1649,7 +1555,7 @@ impl TurnInputs { .await?; debug!(count = tools.len(), forced_tool, "Tools resolved."); - Ok::<_, Error>((attachments, tools)) + Ok::<_, Error>(tools) } => result?, notified = turn_interrupt.recv() => { @@ -1665,8 +1571,7 @@ impl TurnInputs { } }; - let (attachments, tools) = prepared; - let thread = build_thread(stream, attachments, &cfg.assistant, !tools.is_empty())?; + let thread = build_thread(stream, self.attachments, &cfg.assistant, !tools.is_empty())?; debug!( events = thread.events.len(), attachments = thread.attachments.len(), diff --git a/crates/jp_cli/src/cmd/query_tests.rs b/crates/jp_cli/src/cmd/query_tests.rs index cb5a65bc7..e7c5b1f12 100644 --- a/crates/jp_cli/src/cmd/query_tests.rs +++ b/crates/jp_cli/src/cmd/query_tests.rs @@ -344,7 +344,7 @@ async fn an_interrupt_during_mcp_startup_stops_the_turn_before_it_runs() { mcp_client: jp_mcp::Client::default(), workspace_root: tmp.path().to_path_buf(), interactive: false, - attachments: PendingAttachments { slots: vec![] }, + attachments: vec![], printer: Arc::new(printer), approvals: Arc::new(crate::access::approvals::ApprovalStore::default()), chat_request: ChatRequest::from("hello"), @@ -2020,66 +2020,6 @@ fn cleanup_any_removes_a_replaced_draft() { assert!(!path.exists()); } -fn attachment(source: &str) -> Attachment { - Attachment { - source: source.to_owned(), - description: None, - content: jp_attachment::AttachmentContent::Text(String::new()), - } -} - -/// An MCP attachment resolves later than the rest, but the assistant has to see -/// every attachment in the order the conversation declares them: each one is -/// sent as a document numbered by its position. -#[test] -fn attachments_keep_their_configured_order_across_deferral() { - let mcp = Url::parse("mcp+server+res://one").unwrap(); - - // Declared as `[mcp, file, mcp, file]`, so both MCP slots resolve after the - // two around them and every one of them has to land back in place. - let slots = vec![ - AttachmentSlot::Deferred(mcp.clone()), - AttachmentSlot::Ready(vec![attachment("file://second")]), - AttachmentSlot::Deferred(mcp), - AttachmentSlot::Ready(vec![attachment("file://fourth")]), - ]; - - let resolved = vec![vec![attachment("mcp://first")], vec![attachment( - "mcp://third", - )]]; - - let sources: Vec = splice(slots, resolved) - .into_iter() - .map(|attachment| attachment.source) - .collect(); - - assert_eq!(sources, [ - "mcp://first", - "file://second", - "mcp://third", - "file://fourth" - ]); -} - -/// One URL can yield several attachments, so a slot holds a group rather than a -/// single item and the whole group belongs at the slot's position. -#[test] -fn a_deferred_slot_keeps_its_whole_group_together() { - let slots = vec![ - AttachmentSlot::Deferred(Url::parse("mcp+server+res://dir").unwrap()), - AttachmentSlot::Ready(vec![attachment("file://last")]), - ]; - - let resolved = vec![vec![attachment("mcp://a"), attachment("mcp://b")]]; - - let sources: Vec = splice(slots, resolved) - .into_iter() - .map(|attachment| attachment.source) - .collect(); - - assert_eq!(sources, ["mcp://a", "mcp://b", "file://last"]); -} - #[test] fn resolve_new_title_uses_leading_heading() { assert_eq!( diff --git a/docs/architecture/ubiquitous-language.md b/docs/architecture/ubiquitous-language.md index 56c077ad4..316179033 100644 --- a/docs/architecture/ubiquitous-language.md +++ b/docs/architecture/ubiquitous-language.md @@ -55,8 +55,8 @@ In disagreements between code and docs, the code is authoritative. ### Attachment External content attached to a conversation to provide context: a file, URL -contents, command output, Bear note, MCP resource, etc. Implemented as -`Attachment` in `jp_attachment`. +contents, command output, Bear note, etc. Implemented as `Attachment` in +`jp_attachment`. Each attachment kind is a separate crate (`jp_attachment_file_content`, `jp_attachment_cmd_output`, and so on). diff --git a/docs/rfd/.priority.json b/docs/rfd/.priority.json index 61fe4a6cf..d69c25897 100644 --- a/docs/rfd/.priority.json +++ b/docs/rfd/.priority.json @@ -96,7 +96,7 @@ "D27", "D28", "D30", - "D31", + "108", "D35", "D36", "D39", diff --git a/docs/rfd/090-anthropic-subscription-auth-with-credential-fallback.md b/docs/rfd/090-anthropic-subscription-auth-with-credential-fallback.md index 319c31857..14872f783 100644 --- a/docs/rfd/090-anthropic-subscription-auth-with-credential-fallback.md +++ b/docs/rfd/090-anthropic-subscription-auth-with-credential-fallback.md @@ -5,6 +5,7 @@ - **Authors**: Jean Mertz - **Date**: 2026-07-03 - **Tracking Issue**: [\#875] +- **Extended by**: [RFD 110] - **Summary**: OAuth subscription auth for Anthropic with automatic fallback chain, credential store, and scoped cooldown tracking across profiles. @@ -909,6 +910,7 @@ Depends on Phases 1 and 2c; independent of Phases 2 and 3. - [Using Claude Code with your Pro or Max plan][claude-plans] [RFD 048]: 048-four-channel-output-model.md +[RFD 110]: 110-anthropic-subscription-queries-via-acp.md [\#875]: https://github.com/dcdpr/jp/issues/875 [claude-code-source]: https://github.com/alex000kim/claude-code [claude-plans]: https://support.claude.com/en/articles/11145838-using-claude-code-with-your-pro-or-max-plan diff --git a/docs/rfd/drafts/D31-transitional-jp-protocol-bridge-for-mcp-tools.md b/docs/rfd/108-transitional-jp-protocol-bridge-for-mcp-tools.md similarity index 93% rename from docs/rfd/drafts/D31-transitional-jp-protocol-bridge-for-mcp-tools.md rename to docs/rfd/108-transitional-jp-protocol-bridge-for-mcp-tools.md index f65dd58b5..12001b872 100644 --- a/docs/rfd/drafts/D31-transitional-jp-protocol-bridge-for-mcp-tools.md +++ b/docs/rfd/108-transitional-jp-protocol-bridge-for-mcp-tools.md @@ -1,6 +1,6 @@ -# RFD D31: Transitional JP Protocol Bridge for MCP Tools +# RFD 108: Transitional JP Protocol Bridge for MCP Tools -- **Status**: Draft +- **Status**: Discussion - **Category**: Design - **Authors**: Jean Mertz - **Date**: 2026-05-15 @@ -301,9 +301,8 @@ pattern. - **Stateful tool protocol status.** Out of scope; see [RFD 009] and the `computer.jp/status` field defined in [RFD 058]. - **Restructuring the three-way dispatch in `ToolDefinition::execute`.** Out of - scope; see [RFD D10]. - This RFD modifies `execute_mcp` in place; if [RFD D10] lands first the same - logic moves into `McpRuntime::execute`. + scope. + This RFD modifies `execute_mcp` in place. - **Promoting the transitional protocol to a permanent JP feature.** This RFD is explicitly transitional. If [RFD 058] is later rejected and the project decides to keep `Outcome` as @@ -341,21 +340,6 @@ disclaimer) are the primary mitigation, but they're not enforceable. Whether this is a problem depends on how aggressively external authors adopt the protocol before [RFD 058] is ready. -### Interaction with [RFD D10] - -[RFD D10] proposes extracting the three execute paths into a `ToolRuntime` -trait. -This RFD modifies `execute_mcp` directly. -Sequencing options: - -- This RFD lands first; [RFD D10] moves the logic into `McpRuntime::execute`. -- [RFD D10] lands first; this RFD adds the logic to the new - `McpRuntime::execute`. -- Both land in parallel; whoever merges second pays a small merge cost. - -None of these is harmful; they just need coordination in the implementation plan -if both are active. - ## Implementation Plan ### Phase 1: shared tool context builder @@ -442,17 +426,14 @@ This is the dogfooding check that proves the protocol is wired correctly. on [RFD 058]; out of scope here. - [RFD 009]: Stateful Tool Protocol (Accepted) — the stateful tool lifecycle is layered above the single-execution model this RFD touches. -- [RFD D10]: Unified Tool Execution Model (Draft) — structural refactor at the - dispatch layer; coordination noted in Risks. - [SEP-1319]: MCP request-params `_meta` field — the protocol surface this RFD attaches metadata to. -[RFD 009]: ../009-stateful-tool-protocol.md -[RFD 028]: ../028-structured-inquiry-system-for-tool-questions.md -[RFD 042]: ../042-tool-options.md -[RFD 058]: ../058-typed-content-blocks-for-tool-responses.md -[RFD 065]: ../065-typed-resource-model-for-attachments.md -[RFD 066]: ../066-content-addressable-blob-store.md -[RFD 067]: ../067-resource-deduplication-for-token-efficiency.md -[RFD D10]: D10-unified-tool-execution-model.md +[RFD 009]: 009-stateful-tool-protocol.md +[RFD 028]: 028-structured-inquiry-system-for-tool-questions.md +[RFD 042]: 042-tool-options.md +[RFD 058]: 058-typed-content-blocks-for-tool-responses.md +[RFD 065]: 065-typed-resource-model-for-attachments.md +[RFD 066]: 066-content-addressable-blob-store.md +[RFD 067]: 067-resource-deduplication-for-token-efficiency.md [SEP-1319]: https://github.com/modelcontextprotocol/modelcontextprotocol/pull/1319 diff --git a/docs/rfd/109-in-process-jp-mcp-server.md b/docs/rfd/109-in-process-jp-mcp-server.md new file mode 100644 index 000000000..5bad04772 --- /dev/null +++ b/docs/rfd/109-in-process-jp-mcp-server.md @@ -0,0 +1,475 @@ +# RFD 109: In-Process JP MCP Server + +- **Status**: Accepted +- **Category**: Design +- **Authors**: Jean Mertz +- **Date**: 2026-09-12 +- **Required by**: [RFD 110] + +## Summary + +JP moves per-call tool execution into `jp_mcp::server`, running inside the JP +CLI process. +JP and third-party MCP clients use the same MCP invocation path over loopback +Streamable HTTP; a private in-process channel connects the JP MCP Server to JP +for interactions and lifecycle control. +The coordinator, inquiry routing, and conversation storage remain outside the JP +MCP Server. + +## Motivation + +Tool execution is split between `jp_llm::tool` and `jp_cli::cmd::query::tool`. +Exposing it to Claude Code must not create another implementation of approvals, +questions, argument editing, or result delivery. +A wrapper that delegates the execution pipeline back into the CLI leaves that +ownership problem in place. + +This RFD extracts the execution service, not the entire coordinator or agent +loop from [RFD 026]. +It provides the MCP dependency needed by future RFDs without waiting for the +full typed-content and attachment migrations in [RFD 058] and [RFD 065]. + +## Design + +### User-facing behavior + +Users keep their existing `conversation.tools` and `providers.mcp` +configuration. +Local commands, built-in tools, and tools supplied by configured MCP servers +remain available through ordinary queries: + +```sh +jp query --new "Run the configured checks." +``` + +JP starts its execution service and HTTP endpoint automatically. +Users do not start a daemon, select a port, copy tool definitions, or configure +another MCP server to run a normal query. +The service adds no external runtime dependency; Claude Code installation and +subscription setup belong to a separate RFD. + +The initial deployment is exclusively in-process. +A future `jp mcp serve` command for long-running service deployment is outside +this RFD. +Third-party MCP servers retain their existing stdio configuration and +child-process lifecycle; no HTTP variant is added to `providers.mcp`. + +### Roles and terminology + +| Term | Meaning in this design | +| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| **MCP Host** | JP's CLI process. It boots the JP MCP Server, supplies trusted configuration and context, and handles interaction and recording requests. | +| **JP MCP Server** | `jp_mcp::server`, the in-process service responsible for per-call tool execution. | +| **JP MCP Client** | The upstream MCP client component owned by the JP MCP Server. It connects to and invokes third-party MCP servers. | +| **Third-party MCP Servers** | The MCP servers configured under `providers.mcp`, started and managed by the JP MCP Server through the JP MCP Client. | +| **Third-party MCP Client** | An external caller, initially Claude Code, requesting tools from the JP MCP Server. | + +The MCP Host also makes ordinary MCP requests when JP drives the model/tool loop +itself. +That Host-side connection is distinct from the upstream JP MCP Client. + +### One invocation path + +```text +MCP Host -- HTTP --> JP MCP Server <-- HTTP -- Third-party MCP Client + | + +------------+-------------+ + | | | + local command built-in JP MCP Client + | + stdio + | + third-party MCP server + +MCP Host <-------- private typed channels --------> JP MCP Server +``` + +Both HTTP callers use the same MCP handlers, catalog, validation, preparation, +execution, and result processing. +There is no Host-only invocation shortcut into an executor. +An in-memory transport can be added later if justified, but it must carry the +same MCP messages through those handlers, not introduce a second execution API. + +The private channel serves a different purpose: it supplies Host services while +an MCP call is being processed. +Third-party MCP clients cannot send Host commands over HTTP. +They can perform ordinary MCP initialization, discovery, calls, and cancellation +of their own requests. + +### Ownership and crate boundaries + +The JP MCP Server owns tool resolution, per-call requirements and state, +argument/answer validation, process invocation, accumulated answers, and the +final MCP response. +It executes configured custom argument formatters through the same controlled +command-launching path when presentation requests them; pure terminal formatting +stays with the MCP Host. + +The MCP Host owns query phases, the execution plan, terminal/editor interaction, +turn-scoped remembered decisions, inquiry routing, and conversation writes. +`ToolCoordinator` remains in `jp_cli` and delegates per-call execution. +Moving that coordinator belongs to [RFD 026], not this extraction. + +Extract the execution portions of `ToolDefinition::execute`, local command +handling, upstream dispatch, and the existing `BuiltinTool`/`BuiltinExecutors` +registry. +Tool descriptions and the minimum shared result/input contracts live in +`jp_tool`; configuration-dependent construction and execution do not move into +that lightweight SDK. +Provider-specific schema adaptation stays in `jp_llm`. + +The JP MCP Server does not depend on `jp_llm`, `jp_workspace`, or +`jp_conversation`. +Its inputs are resolved configuration and owned context data, not a Workspace, a +provider, or a ConversationStream. +Conversation event wrapping and inquiry provenance conversion belong at the MCP +Host boundary; genuinely shared payload information belongs below both +consumers. + +Introduce `client` and `server` features on `jp_mcp`. +The `server` feature enables the client machinery needed for upstream stdio +connections. +The MCP Host's HTTP connection uses the MCP transport library without +generalizing the upstream configuration surface. +Feature selection limits client-only dependencies; it is not a way to conceal +dependency cycles. + +Remove the MCP-client parameter from the base attachment-handler interface and +its callers when retiring `jp_attachment_mcp_resources` resolution. +Plugin-based MCP attachments can be designed separately. +Existing stored handler data must remain loadable, with a clear +unsupported-resolution error when used. +This small cleanup removes `jp_attachment`'s MCP dependency without implementing +an attachment redesign; it does not remove MCP tools or their resource results. + +### Host-only interaction + +The MCP Host supplies `conversation.tools`, `providers.mcp`, the working root, +invocation identity, and existing access-approval data for the active context. +The JP MCP Server does not discover or choose another workspace. +Bind each server instance to its supplied context; concurrent queries must not +share mutable configuration, answers, or pending interactions by accident. + +The private channel carries correlated requests and replies for: + +- Admission, approval, and argument editing. +- Tool input requests, including supporting content and sensitivity constraints. +- Result review, editing, and delivery decisions. +- Conversation recording acknowledgements. +- Execution release, cancellation, and shutdown. + +The JP MCP Server determines which per-call interaction is required and +validates its reply. +The MCP Host decides how to obtain that reply. +In particular, the JP MCP Server makes no distinction between user-targeted and +assistant-targeted inquiries. +The MCP Host applies configured answers, remembered answers, question targets, +and assistant overrides, presents prompts or calls an assistant, and records the +exchange. +Secret answers retain their existing routing and redaction rules and do not +enter ordinary progress events or logs. + +Remembering a decision for a Turn remains a Host responsibility. +The JP MCP Server can request an interaction for each invocation and receive an +automatic Host reply; it does not interpret the lifetime of an MCP connection as +a JP Turn. + +The JP MCP Server having no terminal does not authorize unattended execution. +The MCP Host applies existing non-interactive policy; changing that policy is +outside this RFD. + +The private interface is created by the MCP Host when it starts the JP MCP +Server. +Client names, MCP session IDs, and request metadata do not grant access to it. +Nor can a tool request override configuration, access grants, or accumulated +answers by supplying its own context metadata. + +### Preparation and release + +A call being prepared is not yet authorized to execute. +The common call path resolves the tool, validates its arguments, obtains +required Host decisions, and waits for release. +Edited arguments are validated again; configured formatter ordering and +visibility remain part of the interaction contract. + +For JP-driven model loops, the MCP Host can start preparation as a tool call +request finishes streaming while retaining the existing execution-phase barrier. +It releases calls according to the execution plan derived from the conversation, +not an independent list of queued HTTP requests. +For an external agent, the MCP Host can release an admitted call as soon as the +required preparation finishes. +The JP MCP Server uses the same path in both cases; the MCP Host controls +release timing. + +The MCP Host must keep servicing its private channel and model stream while MCP +requests are outstanding. +Awaiting a final HTTP result in the only task capable of releasing the call or +answering its inquiry would deadlock. + +### Inquiries re-run tools + +`Outcome::NeedsInput` ends an execution attempt. +It does not suspend a tool process for later resumption: + +```text +MCP tools/call + -> JP MCP Server executes tool + -> attempt finishes with NeedsInput + -> JP MCP Server requests an answer from MCP Host + -> MCP Host obtains and returns the answer + -> JP MCP Server executes tool again with accumulated answers + -> attempt finishes with the result + -> MCP Host handles result delivery and recording + -> final MCP response +``` + +Further questions repeat that sequence. +The enclosing MCP call can remain outstanding throughout; each tool execution +attempt has finished before its answer is obtained. +A persistent third-party MCP server can stay alive between attempts, but its +JP-aware tool is invoked again with the answers. +Built-ins are called again as well. + +The tool author remains responsible for making this re-execution safe, as under +the existing protocol. +This RFD does not add suspended tool invocations, stateful task handles, or a +new upstream MCP elicitation implementation. + +### Narrow content-model integration + +Use [RFD 058]'s ordered content and input-request representation for the service +boundary. +Introduce the minimum shared `ContentBlock` and `InputRequest` data and +conversions needed for text, resource data, questions, and structured error +information. +Retain other native MCP content variants and metadata for forwarding without +requiring the MCP Host to render them or persist them as typed blocks. +Carry the MCP-standard resource fields as data; do not require [RFD 065]'s +attachment placement, refresh, or canonicalization work to use them. + +This RFD does **not** require completion of RFD 058 or RFD 065. +The implemented shared definitions are the ones those migrations consume, not a +competing service-specific content model. + +| Included here | Deferred | +| -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | +| Shared ordered result content and schema-described input requests | Typed conversation-file migration and conversion of every provider/renderer | +| Conversions from existing `Outcome` and MCP results | Mandatory migration of local tools to a new stdout protocol | +| Retention of native MCP content and metadata at the JP MCP Server boundary | New binary tool-result rendering in JP's existing provider flows | +| Existing text/error projection for the MCP Host | Resource deduplication, blob storage, attachment refresh, and stateful tool lifecycle | + +Existing local and built-in tools keep working without changes. +New shared input requests retain the secrecy constraints of existing questions. +Malformed recognized envelopes are failures, not permission to silently discard +content. +The JP MCP Server must not lose mixed native MCP content while looking for a JP +result inside it. + +The MCP Host adapts results to the existing `ToolCallResponse` representation +and rendering where needed. +Ordinary text/error results retain their existing serialized shape. +This compatibility projection is explicit and shared across invocation paths; +full typed persistence remains RFD 058 work. +A result edited by the MCP Host replaces the delivered content, rather than +allowing the JP MCP Server to return an earlier unedited value. + +JP question blocks are not standard MCP result blocks. +Resolve them through the private Host interface before returning the final +standard MCP result. +Do not assume Claude Code interprets a raw `NeedsInput` envelope or a JP +question block as a request to the JP user. + +Retaining `Outcome` as an input decoder is a deliberate difference from RFD +058's coordinated removal of that decoder. +It avoids making tool migration a prerequisite for this service; a later removal +requires its own compatibility decision. + +### JP-aware upstream MCP tools + +Adopt the narrowly scoped interoperability from [RFD 108], within the new +execution path rather than as another implementation in `jp_llm`. + +For a result containing exactly one MCP text block, attempt to parse the entire +text as `jp_tool::Outcome`. +A successful parse uses Outcome semantics and is converted to the shared +representation. +No opt-in or protocol advertisement is required. +Do not concatenate mixed content to make it parseable or recursively unwrap +strings within a successful result. +An MCP error flag conflicting with `Outcome::Success` wins, as specified by 108. + +A literal JSON document can therefore be interpreted as an Outcome when its +shape matches. +This collision risk is explicitly accepted. +`Outcome` remains a tool-result envelope; it does not authorize execution or +change Host policy. +Otherwise, preserve the native MCP result. +Local stdout retains the existing Outcome and raw-text paths, with typed content +decoding added at the boundary. +A transient-error hint does not authorize blind replay after a lost connection. + +A shared context builder supplies local command templates and, for upstream MCP +calls, `_meta["computer.jp/tool"]` and `_meta["computer.jp/context"]`. +It includes the appropriate invoked name, validated execution arguments, +accumulated answers, options, and trusted invocation context. +Any duplicated arguments derive from the same post-edit value. +Build this metadata from Host configuration and replies; never trust incoming +metadata as those values. + +This reuses 108's request plumbing and Outcome compatibility without requiring +its separate implementation or broadening the first delivery into new MCP +features. + +### Recording, correlation, and lifetime + +The JP MCP Server assigns invocation identity independently of transport request +IDs and carries caller correlation metadata to the MCP Host. +The MCP Host associates it with the appropriate tool call and ensures each event +is recorded once. +Host communication distinguishes requested arguments from edited execution +arguments and raw results from approved or edited delivery content. +Neither matching arguments nor arrival order identifies a call: simultaneous +identical invocations are valid. +Interpreting Claude Code's `claudecode/toolUseId` metadata belongs to a future +RFD integration, not the execution policy. +Host-supplied tool-description metadata can likewise carry its result-size hints +without introducing an Anthropic dependency into the JP MCP Server. + +Execution release and final delivery respect Host recording acknowledgements. +The MCP Host applies its configured persistence policy; a non-persisting +invocation is not forced to write a conversation to disk. +The JP MCP Server owns no conversation lock. +Historical event replay does not submit new calls, and observed ACP tool events +must not enqueue a second execution of an MCP call. + +Use the existing cancellation pattern: the MCP Host sends a scoped stop command +or cancellation token and waits for cleanup. +Stopping current calls and shutting down the JP MCP Server are distinct +operations. +Shutdown stops admission, cancels pending interactions and calls, and closes +owned upstream clients. +The JP MCP Server's HTTP listener dies with the JP process; child-process +cleanup still follows JP's existing execution mechanisms. + +The JP MCP Server continues handling control messages while individual calls +wait on answers. +Bound progress buffering separately from required interactions so a slow display +does not block draining a tool's stderr. +A transient HTTP disconnection is not itself cancellation or authority to +execute again. +A crash after a side effect but before recording leaves an uncertain outcome, +not an exactly-once guarantee. + +### HTTP and initial security scope + +Use MCP [Streamable HTTP], not the legacy HTTP+SSE transport. +Bind to loopback on an OS-assigned port and supply the endpoint to callers +programmatically. +JP's stdin/stdout retain their CLI purpose. +The separate ACP connection used by a future RFD is outside the MCP transport. + +The initial endpoint has no authentication token or login flow. +This is an explicit local-access trade-off: loopback does not establish caller +identity, and another local process can submit requests under the bound tool +policies. +Validate Host and supplied Origin headers using the controls provided by +`rmcp`'s `StreamableHttpServerConfig`, with tests for rejected requests. + +Sandboxing stays at the current level. +Preserve access-policy compilation and cooperative enforcement; moving code into +a server does not create an OS sandbox. +Future confinement work can use these execution boundaries, but is not part of +this delivery. + +## Drawbacks + +Using HTTP for JP's own calls adds serialization and lifecycle work. +It buys one invocation path and avoids a second private execution API. +There is no latency benchmark gate; investigate an in-memory MCP transport only +if it solves a measured problem. + +The local endpoint accepts unauthenticated callers, and speculative Outcome +recognition can reinterpret text. +Both are explicit initial trade-offs, not claims of stronger isolation. +The compatibility result projection also does not deliver RFD 058's complete +typed-persistence benefits. + +## Alternatives + +**execution-host wrapper.** Exposes tools while leaving more execution ownership +in the existing CLI arrangement. +This RFD replaces that execution machinery and makes the MCP Host a caller of +the same service as external clients. + +**A separate runtime crate.** Unnecessary for the narrowed execution service. +`jp_mcp::server` and feature separation are sufficient without importing the +coordinator, LLM inference, or workspace storage. + +**A direct Host execution API plus MCP for external clients.** Creates another +invocation path. +Rejected; transport may vary later, execution semantics may not. + +**Require all of RFD 058 first.** Expands the prerequisite into storage, +provider, renderer, and attachment migrations. +The shared types and legacy conversion supply the required interface without +delaying a future RFD for that work. + +## Non-Goals + +- Extracting `ToolCoordinator` or implementing RFD 026. +- Implementing a future RFD's ACP provider flow, native transcript conversion, + or subscription authentication. +- Separate-process deployment, controller IPC, a long-running daemon, or `jp mcp + serve`. +- HTTP transport for configured third-party MCP servers. +- OS sandboxing, a new built-in plugin framework, suspended tool execution, MCP + tasks, sampling, or new upstream elicitation support. +- Completing RFD 058/065 or changing existing tool and conversation formats as a + prerequisite. + +## Implementation Plan + +1. **Shared contracts and dependency cleanup.** Introduce the minimum shared + result/input types and compatibility conversions. + Move tool descriptions and tool-domain errors out of their accidental LLM + ownership. + Remove the attachment-handler MCP coupling without a plugin redesign. + Keep existing execution working during these mechanical changes. +2. **Execution service and Host interaction.** Implement `jp_mcp::server` behind + feature flags. + Reuse command execution and the built-in registry; add the private Host + channel, preparation/release, Outcome re-execution, and scoped cancellation. + Tests use real executor creation and controlled tool fixtures. +3. **One HTTP path and CLI adoption.** Add the Streamable HTTP endpoint and make + JP's ordinary query path its MCP caller. + Keep the coordinator and event ownership in JP. + Add 108 metadata/Outcome handling to upstream stdio calls; do not maintain a + parallel production execution pipeline. +4. **future RFD readiness.** Exercise a third-party MCP client against the same + service while the MCP Host handles interactions. + Verify correlation metadata, result-size metadata, edited results, and + recording before final response. + No Anthropic credential or transcript implementation is needed to test this + contract; future RFD consumes the completed service afterward. + +Acceptance tests cover both MCP callers through the same handlers. +Force denied calls, malformed arguments, stale/duplicate interaction replies, +and cancelled prompts, and prove forbidden execution did not occur. +An inquiry fixture must prove separate executions with the accumulated answer, +one logical final result, and no claim of process resumption. +Pin exact CLI output, request/result pairing, and stored text/error results. +Exercise simultaneous identical calls, Host loss, shutdown, failed recording, +and HTTP disconnect without duplicate side effects. +Test client-only and server feature builds. + +This RFD can be implemented and used by future RFD without completing the +broader RFD 058, RFD 065, or RFD 026 migrations. +Their documents remain separate references; this delivery establishes only the +shared contracts and execution behavior specified here. + +[RFD 026]: 026-agent-loop-extraction.md +[RFD 058]: 058-typed-content-blocks-for-tool-responses.md +[RFD 065]: 065-typed-resource-model-for-attachments.md +[RFD 108]: 108-transitional-jp-protocol-bridge-for-mcp-tools.md +[RFD 110]: 110-anthropic-subscription-queries-via-acp.md +[Streamable HTTP]: https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http diff --git a/docs/rfd/110-anthropic-subscription-queries-via-acp.md b/docs/rfd/110-anthropic-subscription-queries-via-acp.md new file mode 100644 index 000000000..7aeb1960f --- /dev/null +++ b/docs/rfd/110-anthropic-subscription-queries-via-acp.md @@ -0,0 +1,506 @@ +# RFD 110: Anthropic Subscription Queries via ACP + +- **Status**: Accepted +- **Category**: Design +- **Authors**: Jean Mertz +- **Date**: 2026-09-11 +- **Extends**: [RFD 090] +- **Requires**: [RFD 109] + +## Summary + +The `anthropic` provider gains an ACP subscription flow through +`claude-agent-acp` and the unmodified Claude Code runtime. +It becomes the default for subscription authentication; the existing direct flow +remains available by explicit configuration. +Model IDs, the `--auth` interface, JP conversation ownership, and JP tool +policies remain unchanged. + +## Motivation + +[RFD 090] and [PR 1151] through [PR 1153] provide subscription credentials, +provider-owned credential selection, fallback, and the `--auth` flag. +The direct Anthropic subscription implementation sends requests from JP using +JP-stored Claude Code OAuth credentials. +It works, but is not a vendor-sanctioned third-party authentication path and +risks account restriction. + +Anthropic's June 15, 2026 [subscription clarification] states that Agent SDK, +`claude -p`, and third-party application usage continue to draw from +subscription allowances. +Running the official runtime avoids extracting its credentials or reimplementing +its authentication. +This is an additional subscription flow, not another provider or another route +to mandatory per-token billing. + +The probe harness in `.config/jp/experiments/` demonstrates role-bearing history +reconstruction, tool execution through MCP, and the controls needed for an +integration. +A separate public ACP provider would expose an implementation choice the user +need not make. +JP's MCP server, specified in [RFD 109], exposes JP's configured tool pipeline +to external clients; this flow consumes its hosted form. + +## Design + +### User interface and setup + +The following commands select billing without changing the provider or model: + +```sh +jp q -n --auth sub -m anthropic/claude-opus-5 "Review this change." +jp q -n --auth api -m anthropic/claude-opus-5 "Review this change." +``` + +For ACP subscription usage, install Node.js 22 or later and npm, then install +and authenticate the adapter release used in the experiments: + +```sh +npm install --global @agentclientprotocol/claude-agent-acp@0.76.0 +claude-agent-acp --cli auth login --claudeai +claude-agent-acp --cli --version +claude-agent-acp --cli auth status --json +``` + +Keep npm's optional dependencies enabled. +On supported platforms the SDK supplies Claude Code's native binary; a separate +Claude Code installation is not normally necessary. +`node` and `claude-agent-acp` must be on JP's `PATH`. +The measured baseline is adapter 0.76.0 with Claude Code 2.1.257, not an +unqualified promise about every later release. + +Login uses Claude Code's own browser flow and credential storage. +No token is copied into JP. +Disable paid **Usage credits** in Claude's **Settings > Usage** when only the +included subscription allowance may be used. +JP verifies effective subscription authentication before inference; an API key, +helper, or cloud configuration must not silently select another billing source. +Child-process configuration must not alter the parent environment or JP's API +flow. + +With the default ACP subscription flow, no TOML changes are required when the +command explicitly selects `--auth sub` and the model. +To make these the workspace defaults, merge this into `.jp/config.toml`: + +```toml +[providers.llm.anthropic] +auth = ["subscription"] +subscription_flow = "acp" + +[assistant.model] +id = "anthropic/claude-opus-5" +``` + +`subscription_flow` defaults to `acp`, so its line is optional. +After setup, ordinary queries suffice: + +```sh +jp query --new "Review the changes in this workspace." +jp query "Focus on error handling." +``` + +JP starts the adapter and its hosted MCP server, prepares the conversation, +handles tool interaction, and records the response. +The user runs no daemon, copies no tool configuration into Claude Code, and +manages no external session IDs. +Existing assistant settings, instructions, attachments, and tools stay in JP +configuration. + +### Flow selection and migration + +A *subscription flow* selects the implementation used for a subscription +authentication entry. +It does not select the billing kind, model, or service tier. +`subscription_flow` accepts `acp` and `direct`, rejects unknown values, and +follows ordinary scalar config layering and conversation deltas. + +| Authentication | `subscription_flow` | Implementation | +| ---------------------- | ------------------- | --------------------------------------------------------------------- | +| `api_key` / `api` | Either value | Existing JP Anthropic API implementation. | +| `subscription` / `sub` | `acp` (default) | Claude Code through the qualified ACP adapter. | +| `subscription` / `sub` | `direct` | Existing JP-stored subscription credentials and direct HTTP requests. | + +To retain the direct subscription flow: + +```toml +[providers.llm.anthropic] +subscription_flow = "direct" +``` + +This is explicit acceptance of that flow's policy and account risk, not a claim +that opting in makes it permitted. +JP never selects it automatically because ACP is missing, unsupported, or fails. +API-only users keep their behavior and need no Node or Claude Code installation. +The existing default auth chain stays `["api_key"]`; adding this field does not +move API users onto subscriptions. + +Subscription users who omit `subscription_flow` must install and authenticate +the external runtime or explicitly select `direct`. +Existing JP credentials are neither deleted nor imported into Claude Code. +Initial ACP support uses the runtime's active subscription login. +An unmapped named JP subscription credential must fail rather than silently use +that account; existing named credentials remain usable with `direct`. +Native-login name mapping and integration with `jp provider llm auth` are +follow-up work. + +The existing credential-chain and `--auth` semantics remain authoritative. +Listing an API entry explicitly authorizes the existing paid fallback policy; +this feature adds no API entry and no automatic switch between subscription +flows. +Subscription-only requests, including auxiliary requests using that +configuration, stop when their allowance is unavailable. +Paid usage credits in Claude's account are separate from JP's auth chain; the +setup requirement above is not replaceable by a cache-cost estimate. + +### Provider and execution boundaries + +```text +anthropic provider + +-- API-key authentication --> existing API implementation + +-- subscription/direct ----> existing direct subscription implementation + +-- subscription/acp -------> internal Claude integration + +-- ACP client and runtime lifecycle + +-- Thread-to-native-transcript conversion + +-- streamed events and notices + +-- JP MCP execution host +``` + +There is no public `acp` provider, agent selector, or change to `model.id`. +Internal ACP transport can be reusable, but this feature supports the qualified +Claude adapter, not arbitrary ACP executables. +The implementation owns the adapter command and version compatibility policy; it +does not expose an unrestricted SDK-options bag as a substitute for JP +configuration. + +Credential policy stays in the provider as in [PR 1151]. +Protocol and transcript conversion belong with the Claude integration, not in +command handlers. +[RFD 109] owns tool execution and the execution-host interface. +The query runner must service that host while the ACP prompt is in flight: +waiting for prompt completion before servicing MCP calls deadlocks. +Reuse the existing tool coordination and rendering rather than duplicate them in +the provider. + +An ACP prompt covers the external agent's model/tool continuation loop. +Internal orchestration must represent that explicitly rather than send its +observed tool calls through JP's API-style execution phase a second time. +How the internal provider/request interface carries this execution contract is +settled in the first vertical slice; the public provider and authentication +choice do not expose it. + +### JP remains the conversation authority + +The flow consumes the same Thread and provider-visible projection as the +Anthropic request builder, including the Compacted View. +It separates the pending input from prior history, constructs Claude-native +records preserving supported content, roles, order, and paired tool +calls/results, then loads them through ACP. +The pending input is submitted once, not also embedded in the loaded prefix. +Continuation without a new user request must preserve the existing provider's +continuation semantics rather than repeat an earlier request. +History is not flattened into a user-message memo. + +Native records are a derived provider representation. +The demonstrated encoder needs no seed response: record bookkeeping is authored +locally, while message content comes from JP. +Native storage formats are version-specific; their encoder and decoder need +fixtures and qualified runtime versions. +Opaque reasoning metadata follows existing Anthropic conversion rules, not +invented signatures or claims that every provider's reasoning is +interchangeable. + +Provider changes, replay, selected-turn forks, compaction, and attachment +changes all prepare the current Thread through this conversion. +Returning from an OpenAI turn therefore includes that turn without a manual +handoff: + +```sh +jp q -n --auth sub -m anthropic/claude-opus-5 "Review the design." +jp q --auth sub -m openai/gpt-6-astra "Check the assumptions." +jp q --auth sub -m anthropic/claude-opus-5 "Continue from that review." +``` + +The implementation can reuse native state only when it represents the current +Thread and configuration. +A saved session ID alone is insufficient. +Otherwise, create a separate native transcript and load it. +Keep immutable JP history separate from disposable provider files, preserve +input content when remapping record identifiers, and never edit a user's +unrelated Claude Code session. + +Import newly generated events once. +Load-time replay is historical observation, not new output or authorization to +execute a historical call. +Context isolation uses distinct ACP sessions for independent requests, including +auxiliary queries. +Tool callbacks and side queries must not share a mutex around one occupied +native session. +Conversation locking and durable writes remain JP's responsibility. + +### Tools, output, and model support + +The ACP flow supplies JP's MCP server with a stable tool namespace. +It disables Claude Code's native side-effectful tools and unconfigured MCP +servers, and suppresses optional hooks, skills, and background features through +qualified controls. +The runtime's actual tool surface is checked. +These controls are not an OS sandbox, and native runtime context must be +accounted for rather than mistaken for JP attachments. + +The hosted server executes through JP's policies: enablement, approval, argument +editing, tool options, access checks, inquiries, result editing, and recording. +ACP tool updates are observations. +The tested `_meta["claudecode/toolUseId"]` on MCP call requests correlates +execution with ACP's tool-call ID; request numbers or matching arguments are not +substitutes. +Preserve the distinction between requested and edited execution arguments. + +Sequential external tool dispatch is acceptable. +Correct pairing, JP's approval behavior, and isolation are not optional. +Forced tool selection retains JP's existing best-effort semantics; this flow +does not promise stronger enforcement. +Cancellation reaches pending interactions and running tools. +Disconnection after a possible side effect is not permission to repeat it +automatically. + +Use `ModelDetails.subscription` and the existing capability fields for the +selected flow's supported model set and controls. +Resolve canonical IDs against qualified model information, and retain the actual +response model in metadata. +Do not require ACP discovery for API-only requests or silently substitute a +different model. +Explicit unsupported controls need the existing capability handling, not silent +removal. +HTTP-specific transport settings remain scoped to the HTTP implementations. + +Apply the resolved system prompt and response schema when preparing a request. +The tested custom-prompt form uses `snapshot: false`; native prompt snapshots +must not suppress JP configuration changes. +Structured results arrive through the adapter's raw SDK result extension and +become JP structured responses. +Streamed text/thinking use JP's existing rendering. +A refusal maps to `FinishReason::Refused`, including its category when supplied; +an SDK result with `subtype: success` and `is_error: true` is not success. + +### Large tool results + +Claude Code can replace a large tool result with a file reference before the +next model request. +That is not acceptable merely because JP still stores the original: with native +file tools disabled, the model may not receive the data. + +The measured working configuration is: + +- `MAX_MCP_OUTPUT_TOKENS=100000` in the runtime environment. +- `_meta["anthropic/maxResultSizeChars"] = 500000` on each relevant JP tool's + `tools/list` entry. + +This preserves a 240,052-byte text result through the SDK-visible boundary and +lets the model answer from its footer. +The environment setting alone fails the same test. +Keep the text-preservation assertion; retrieving a substituted file through an +uncontrolled native tool is not an equivalent result. + +The [documented size override] has a maximum value of 500,000 characters. +This is an inline-text threshold for the result of one tool invocation, not a +limit on JP's stored conversation, the full request, or the model's context +window. +Larger results can still be produced, but the runtime can substitute a file +reference. +Multiple large results and other runtime context-management policies can impose +additional constraints. + +Handling results outside the qualified range remains an explicit compatibility +edge: establish a provider-controlled continuation/reconstruction mechanism that +preserves them, or agree a documented limitation before claiming parity. +Silent truncation, automatic `direct` fallback, and bypassing JP permissions are +not solutions. +A diagnostic avoids silent loss but is not proof that the original workflow is +supported. + +### Prompt caching and subscription usage + +Prompt caching is server-side reuse of an identical request prefix, not reuse of +a local session file. +[Claude Code's cache documentation] explains that matching requests can share a +cache across sessions. +Reconstructing a transcript therefore need not destroy caching, but changes in +rendered content can. + +The traces already demonstrate cache reads: an ordinary follow-up reads 836 +cached input tokens, and reconstructed-history requests each read 1,322. +These examples use different prompts/models and one-hour cache writes. +They prove reuse of some prefix, not equal cache efficiency or parity with +native continuation for an arbitrary JP Thread. + +Preserve stable tool names, definitions and ordering, message content, and +wire-visible tool-call IDs when the Thread is unchanged. +Avoid putting transient session IDs, listener addresses, or per-request +temporary working directories into the model-visible prefix. +Native file locations can vary without changing the agent's logical working +directory. +Account for runtime-added environment and git context when deciding whether a +prefix is stable. +Do not sacrifice current instructions or correct history to preserve a cache +entry. + +Honor `assistant.request.cache` through the qualified runtime controls: + +| JP policy | ACP flow mapping | +| --------------- | -------------------------------------------------------------------------------------------------------- | +| `off` | `DISABLE_PROMPT_CACHING=1`. | +| `short` | `CLAUDE_CODE_PROMPT_CACHE_TTL=5m`. | +| `long` | `CLAUDE_CODE_PROMPT_CACHE_TTL=1h`. | +| Custom duration | Existing Anthropic mapping: at least 30 minutes selects one hour; shorter durations select five minutes. | + +These published runtime controls require integration tests. +Isolate conflicting ambient runtime overrides; report a managed-policy conflict +rather than claim a JP setting was honored when it was not. +JP's default remains `short`, rather than silently adopting Claude Code's +subscription default of one hour. +JP-initiated auxiliary requests use their own resolved policy. +Cache breakpoint placement need not be byte-identical between flows, but +supported caching controls and unchanged-prefix reuse must remain useful. + +Record uncached input, cache creation, cache reads, and output separately. +Distinguish per-request usage from cumulative `modelUsage` snapshots and runtime +helper activity. +Switching models, accounts, or flows may change cache scope; sharing between +them is not guaranteed. +Configuration edits and compaction can legitimately invalidate a prefix. + +Anthropic's [usage guidance] identifies caching as a way to conserve plan +allowance. +For otherwise equivalent work, more cache hits and fewer repeated writes reduce +input-processing expense. +Cached context still occupies the context window, output/thinking still consumes +usage, and larger histories can consume more allowance even with a high hit +ratio. +API cache-price multipliers and the SDK's dollar estimate are not a published +formula for subscription window percentages. + +### Experimental evidence + +The September 2026 probes use adapter 0.76.0 and Claude Code 2.1.257 with +subscription authentication. +The probe harness records protocol traffic, exact outputs, native fixtures, and +failure details. +Representative retained run IDs are listed here; the `tmp/acp-probe/` artifacts +are investigation data, not a substitute for checked-in integration fixtures. + +| Observation | Run | +| ------------------------------------------------------------------------------------------- | ------------------ | +| Template-based history replacement and edited historical tool results, with no re-execution | `HYYmzJ`, `Ttcolz` | +| Canonical Opus 5 selection and seed-free native records | `ZjvkdU` | +| Denial, host-side argument/result editing, cancellation while a tool is blocked | `mz8fnc` | +| Changed system prompt/schema with retained invoice data | `GRQZsv` | +| Identical calls remain distinct and correctly paired | `6zuTpk` | +| Separate processes retain separate histories while one waits on a tool | `cZpjor` | +| Image input and exact color identification | `FcZXUF` | +| Complete large text result with both size controls | `K7dVmM` | + +The unsuccessful marker-based configuration request is a recorded provider +refusal, not evidence of a general reload failure. +The default-limit and environment-only large-result probes retain their failed +preservation checks. +No experiment establishes completed JP integration, universal runtime-version +compatibility, or a quantitative subscription-quota conversion. + +## Drawbacks + +ACP subscription usage adds Node and an external runtime, native transcript +format maintenance, and runtime behavior outside JP's direct control. +Hidden context and helper requests can increase allowance consumption. +Compatibility qualification must track the adapter and its bundled runtime +together. + +Changing the default subscription flow requires existing subscription users to +prepare that runtime or opt into `direct`. +Keeping direct access preserves a risky alternative that JP must label honestly +and maintain separately. + +## Alternatives + +**Keep direct as the default.** Requires fewer dependencies, but leaves the +policy risk on users who have not chosen it explicitly. + +**Expose an `acp` provider.** Useful for a generic external-agent product, but +unnecessary for selecting how this vendor serves subscription requests. +The Claude-specific implementation stays inside `anthropic`. + +**Resume the last native session or flatten JP history into a memo.** Neither +preserves normal provider switching and projected history. +Native transcript conversion provides the demonstrated alternative. + +**Implement the adapter in Rust immediately.** Removes Node but expands the +initial work. +A later replacement can use the same behavioral tests while continuing to run +the official Claude Code binary. + +## Non-Goals + +- Removing direct subscription access or changing API-key behavior. +- Adding a generic ACP provider or changing model-ID syntax. +- Replacing Claude Code's authentication, copying its credentials, or modifying + its binary. +- Implementing `jp provider llm auth` delegation or native-login profile mapping + in the first delivery. +- Giving tools weaker policies or adding a latency benchmark requirement. + +## Risks and Open Questions + +- **Large-result and aggregate limits:** qualify behavior outside the measured + fixture and resolve the handling decision above. +- **Cache preservation:** compare warm continuation, process restart, and + reconstruction of the same Thread within the TTL, holding directory, account, + model, effort, tool definitions, and input content fixed. + Measure the shared prefix's cache reads/writes; existing hits do not prove + equal cache reuse. + A separate comparison of actual plan usage needs a quiet account and no quota + reset during measurement; cache counters alone do not measure that deduction. +- **Runtime-added context and work:** identify what the qualified runtime adds + despite disabled discovery, and suppress or account for it without editing the + binary or pretending the Thread contains it. +- **Control and metadata fidelity:** finish mappings for reasoning, request + controls, attachments, abort/discard, and `--no-persist` against JP's actual + paths. + The small image probe is not historical binary-content coverage. +- **Version and policy changes:** publish the supported adapter/runtime + combinations. + Anthropic can change subscription allowances and permitted usage; an explicit + direct choice does not protect an account from enforcement. + +## Implementation Plan + +1. **Flow selection and compatibility.** Add the typed field, default and + migration diagnostics, isolate the retained HTTP implementations, and qualify + model/runtime support. + API construction must not initialize ACP. +2. **One vertical slice.** Convert a real Thread, run a subscription-backed + request, service [RFD 109]'s hosted tools concurrently with ACP, and record + through JP's actual stream/rendering path. + Give auxiliary requests isolated native state. +3. **Workflow and result parity.** Extend the provider-owned route tests from + [PR 1152]. + Cover alternating providers/flows, replay, forks, compaction, configuration + changes, tool edits, cancellation and refusals. + Resolve large results without weakening assertions or executing historical + calls. +4. **Caching and release qualification.** Add controlled cache comparisons, + usage accounting, runtime fixtures, and setup documentation. + Keep prompt correctness ahead of cache reuse; use subscription usage + observations rather than treating list-price dollars as quota units. + +These phases keep the initial delivery focused on the Anthropic subscription +flow. +Public auth-command integration and replacing Node are subsequent work. + +[Claude Code's cache documentation]: https://code.claude.com/docs/en/prompt-caching +[PR 1151]: https://github.com/dcdpr/jp/pull/1151 +[PR 1152]: https://github.com/dcdpr/jp/pull/1152 +[PR 1153]: https://github.com/dcdpr/jp/pull/1153 +[RFD 090]: 090-anthropic-subscription-auth-with-credential-fallback.md +[RFD 109]: 109-in-process-jp-mcp-server.md +[documented size override]: https://code.claude.com/docs/en/mcp#raise-the-limit-for-a-specific-tool +[subscription clarification]: https://support.claude.com/en/articles/15036540-use-the-claude-agent-sdk-with-your-claude-plan +[usage guidance]: https://code.claude.com/docs/en/costs#why-usage-climbs-in-a-long-session From 1685eab7128134789d992db5ac324eeb73afda84 Mon Sep 17 00:00:00 2001 From: Jean Mertz Date: Sat, 12 Sep 2026 10:32:49 +0200 Subject: [PATCH 02/29] refactor(tool, llm): Move tool descriptions and errors to `jp_tool` A tool's resolved description, its parameter schema, and the errors raised while resolving or running one are tool-domain concerns, not LLM ones. They move out of `jp_llm` into `jp_tool`: `ToolDefinition`, `ToolDocs`, and `ParameterDocs` with the argument coercion, defaulting, and validation that read a schema; `jp_tool::schema` for reading and validating a parameter schema; and `jp_tool::Error` for the whole tool error domain. Per [RFD 109], `jp_mcp::server` becomes the owner of tool execution, and it cannot depend on `jp_llm`. Lowering these contracts underneath both is what lets that server describe and check a tool call without pulling in an inference client. Building a schema from configuration stays with the configuration types in `jp_llm::tool::json_schema`, and execution stays in `jp_llm::tool` until that move. No behaviour changes. `ToolDefinition::execute` becomes the free function `jp_llm::tool::execute`, since the type it hung off no longer lives in that crate. Eight `ToolError` variants that nothing constructed are dropped, and the two MCP variants plus the template variant carry a boxed source so `jp_tool` names neither `jp_mcp` nor `minijinja`. [RFD 109]: docs/rfd/109-in-process-jp-mcp-server.md Signed-off-by: Jean Mertz --- Cargo.lock | 1 + crates/jp_cli/src/cmd.rs | 2 +- crates/jp_cli/src/cmd/query.rs | 4 +- crates/jp_cli/src/cmd/query/tool/executor.rs | 47 +- crates/jp_cli/src/cmd/query/tool/inquiry.rs | 3 +- crates/jp_cli/src/cmd/query/turn_loop.rs | 3 +- crates/jp_cli/src/error.rs | 2 +- crates/jp_llm/src/error.rs | 89 -- crates/jp_llm/src/lib.rs | 2 +- crates/jp_llm/src/provider/anthropic.rs | 2 +- crates/jp_llm/src/provider/anthropic_tests.rs | 10 +- crates/jp_llm/src/provider/cerebras.rs | 2 +- crates/jp_llm/src/provider/google.rs | 2 +- crates/jp_llm/src/provider/ollama.rs | 4 +- crates/jp_llm/src/provider/ollama_tests.rs | 3 +- crates/jp_llm/src/provider/openai.rs | 4 +- crates/jp_llm/src/provider/openai_compat.rs | 2 +- crates/jp_llm/src/provider/openai_tests.rs | 2 +- crates/jp_llm/src/provider/vllm_tests.rs | 6 +- crates/jp_llm/src/query.rs | 3 +- crates/jp_llm/src/test.rs | 2 +- crates/jp_llm/src/tool.rs | 880 ++++++------------ crates/jp_llm/src/tool/json_schema.rs | 635 +------------ crates/jp_llm/src/tool/json_schema_tests.rs | 524 +---------- crates/jp_llm/src/tool_tests.rs | 630 +------------ crates/jp_llm/src/window.rs | 3 +- crates/jp_llm/src/window_tests.rs | 2 +- crates/jp_tool/Cargo.toml | 1 + crates/jp_tool/src/definition.rs | 299 ++++++ crates/jp_tool/src/definition_tests.rs | 542 +++++++++++ crates/jp_tool/src/error.rs | 67 ++ crates/jp_tool/src/lib.rs | 6 + crates/jp_tool/src/schema.rs | 616 ++++++++++++ crates/jp_tool/src/schema_tests.rs | 524 +++++++++++ 34 files changed, 2488 insertions(+), 2436 deletions(-) create mode 100644 crates/jp_tool/src/definition.rs create mode 100644 crates/jp_tool/src/definition_tests.rs create mode 100644 crates/jp_tool/src/error.rs create mode 100644 crates/jp_tool/src/schema.rs create mode 100644 crates/jp_tool/src/schema_tests.rs diff --git a/Cargo.lock b/Cargo.lock index baf05616e..d94b87506 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2737,6 +2737,7 @@ version = "0.1.0" dependencies = [ "camino", "camino-tempfile", + "indexmap", "serde", "serde_json", "thiserror 2.0.20", diff --git a/crates/jp_cli/src/cmd.rs b/crates/jp_cli/src/cmd.rs index cbeb2a2e9..e2bd03331 100644 --- a/crates/jp_cli/src/cmd.rs +++ b/crates/jp_cli/src/cmd.rs @@ -719,7 +719,7 @@ impl_from_error!(jp_storage::LoadError, "Storage load error"); impl_from_error!(jp_config::ConfigError, "Config error"); impl_from_error!(jp_config::fs::ConfigLoaderError, "Config loader error"); impl_from_error!(jp_conversation::Error, "Conversation error"); -impl_from_error!(jp_llm::ToolError, "Tool error"); +impl_from_error!(jp_tool::Error, "Tool error"); impl_from_error!(jp_mcp::Error, "MCP error"); impl_from_error!(minijinja::Error, "Template error"); impl_from_error!(quick_xml::SeError, "XML serialization error"); diff --git a/crates/jp_cli/src/cmd/query.rs b/crates/jp_cli/src/cmd/query.rs index e77fd54e6..85bc5417f 100644 --- a/crates/jp_cli/src/cmd/query.rs +++ b/crates/jp_cli/src/cmd/query.rs @@ -103,11 +103,10 @@ use jp_conversation::{ }; use jp_inquire::prompt::{PromptBackend, TerminalPromptBackend}; use jp_llm::{ - ToolError, event::NoticeSink, provider, tool::{ - InvocationContext, ToolDefinition, ToolDocs, + InvocationContext, builtin::{BuiltinExecutors, describe_tools::DescribeTools}, tool_definitions, }, @@ -121,6 +120,7 @@ use jp_term::width::{display_width, truncate_to_width}; use jp_workspace::{ ConversationHandle, ConversationLock, ConversationMut, Id as WorkspaceId, Workspace, }; +use jp_tool::{Error as ToolError, ToolDefinition, ToolDocs}; use minijinja::{Environment, UndefinedBehavior}; use strip_ansi_escapes::strip_str; use tokio::sync::broadcast::error::RecvError; diff --git a/crates/jp_cli/src/cmd/query/tool/executor.rs b/crates/jp_cli/src/cmd/query/tool/executor.rs index f8f0a64b1..e92a930e8 100644 --- a/crates/jp_cli/src/cmd/query/tool/executor.rs +++ b/crates/jp_cli/src/cmd/query/tool/executor.rs @@ -52,12 +52,13 @@ use jp_conversation::event::{InquirySource, ToolCallRequest, ToolCallResponse}; use jp_llm::{ ExecutionOutcome, tool::{ - InvocationContext, StderrSink, ToolDefinition, + InvocationContext, StderrSink, builtin::BuiltinExecutors, executor::{Executor, ExecutorResult, ExecutorSource, PermissionInfo}, }, }; use jp_mcp::Client; +use jp_tool::ToolDefinition; use serde_json::Value; use tokio_util::sync::CancellationToken; @@ -117,14 +118,11 @@ impl ExecutorSource for TerminalExecutorSource { /// Executes a single tool call. /// -/// The executor handles the execution lifecycle including permission prompts, -/// input questions, and result formatting. -/// -/// # Note -/// -/// Interactive prompts currently happen inside `ToolDefinition::call()`. -/// In the future, prompts will be driven by the `ToolCoordinator`, and the -/// executor will only handle pure execution. +/// Each [`Executor::execute`] call is one execution attempt: it runs the tool +/// and reports what came back. +/// Permission prompts, question answering, and result editing are the +/// `ToolCoordinator`'s, which calls this again with accumulated answers when a +/// tool asks for input. pub struct ToolExecutor { request: ToolCallRequest, config: ToolConfigWithDefaults, @@ -237,22 +235,21 @@ impl Executor for ToolExecutor { } }; - let result = self - .definition - .execute( - self.request.id.clone(), - Value::Object(self.request.arguments.clone()), - answers, - &self.config, - mcp_client, - root, - cancellation_token, - &self.builtin_executors, - access.as_ref(), - &self.invocation, - stderr, - ) - .await; + let result = jp_llm::tool::execute( + &self.definition, + self.request.id.clone(), + Value::Object(self.request.arguments.clone()), + answers, + &self.config, + mcp_client, + root, + cancellation_token, + &self.builtin_executors, + access.as_ref(), + &self.invocation, + stderr, + ) + .await; match result { Ok(ExecutionOutcome::Completed { id, result }) => { diff --git a/crates/jp_cli/src/cmd/query/tool/inquiry.rs b/crates/jp_cli/src/cmd/query/tool/inquiry.rs index f127e6c24..3ae76181c 100644 --- a/crates/jp_cli/src/cmd/query/tool/inquiry.rs +++ b/crates/jp_cli/src/cmd/query/tool/inquiry.rs @@ -32,10 +32,9 @@ use jp_llm::{ model::ModelDetails, query::{ChatQuery, Truncation}, retry::{RetryConfig, collect_with_retry}, - tool::ToolDefinition, window, }; -use jp_tool::{AnswerType, Question}; +use jp_tool::{AnswerType, Question, ToolDefinition}; use serde_json::{Map, Value, json}; use tokio_util::sync::CancellationToken; use tracing::info; diff --git a/crates/jp_cli/src/cmd/query/turn_loop.rs b/crates/jp_cli/src/cmd/query/turn_loop.rs index 5e677b7fd..ea7c1b506 100644 --- a/crates/jp_cli/src/cmd/query/turn_loop.rs +++ b/crates/jp_cli/src/cmd/query/turn_loop.rs @@ -36,10 +36,11 @@ use jp_llm::{ model::ModelDetails, provider::get_provider, query::{ChatQuery, Truncation}, - tool::{InvocationContext, ToolDefinition, executor::Executor}, + tool::{InvocationContext, executor::Executor}, with_idle_timeout, with_output_limit, }; use jp_printer::{ErrChannel, Printer, RegionStyle, StatusRegion}; +use jp_tool::ToolDefinition; use jp_workspace::{ConversationLock, ConversationMut}; use tokio_stream::wrappers::ReceiverStream; use tracing::{debug, info, warn}; diff --git a/crates/jp_cli/src/error.rs b/crates/jp_cli/src/error.rs index a0ec92357..dd15ed40b 100644 --- a/crates/jp_cli/src/error.rs +++ b/crates/jp_cli/src/error.rs @@ -118,7 +118,7 @@ pub(crate) enum Error { Url(#[from] url::ParseError), #[error("Tool error")] - Tool(#[from] jp_llm::ToolError), + Tool(#[from] jp_tool::Error), #[error("Syntax highlighting error")] SyntaxHighlight(#[from] syntect::Error), diff --git a/crates/jp_llm/src/error.rs b/crates/jp_llm/src/error.rs index 6f3e41cdb..3972cc2a4 100644 --- a/crates/jp_llm/src/error.rs +++ b/crates/jp_llm/src/error.rs @@ -7,7 +7,6 @@ use async_anthropic::errors::AnthropicError; use chrono::{DateTime, Utc}; use jp_config::model::{id::ProviderId, parameters::ServiceTier}; use reqwest::header::{HeaderMap, RETRY_AFTER}; -use serde_json::Value; pub(crate) type Result = std::result::Result; @@ -618,82 +617,6 @@ impl PartialEq for Error { } } -#[derive(Debug, thiserror::Error)] -pub enum ToolError { - #[error("Tool not found: {name}")] - NotFound { name: String }, - - #[error("Tools not found: {}", names.join(", "))] - NotFoundN { names: Vec }, - - #[error("Disabled in configuration")] - Disabled, - - #[error("Command is only supported for local tools")] - UnexpectedCommand, - - #[error("Command missing for local tool")] - MissingCommand, - - #[error("Failed to fetch tool from MCP client")] - McpGetToolError(#[source] jp_mcp::Error), - - #[error("Failed to run tool from MCP client")] - McpRunToolError(#[source] jp_mcp::Error), - - #[error("Failed to serialize tool arguments")] - SerializeArgumentsError { - arguments: Value, - #[source] - error: serde_json::Error, - }, - - #[error("Tool call failed: {0}")] - ToolCallFailed(String), - - #[error("Failed to spawn command: {command}")] - SpawnError { - command: String, - #[source] - error: std::io::Error, - }, - - #[error("Failed to edit tool call")] - EditArgumentsError { - arguments: Value, - #[source] - error: serde_json::Error, - }, - - #[error("Template error")] - TemplateError { - data: String, - #[source] - error: minijinja::Error, - }, - - #[error("Invalid schema at `{path}`: {message}")] - InvalidSchema { path: String, message: String }, - - #[error("Needs input: {question:?}")] - NeedsInput { question: jp_tool::Question }, - - #[error("Skipped tool execution")] - Skipped { reason: Option }, - - #[error("Serialization error")] - Serde(#[from] serde_json::Error), - - #[error("Invalid arguments (missing: {missing:?}, unknown: {unknown:?})")] - Arguments { - /// Required arguments that were missing. - missing: Vec, - - /// Unknown arguments that were provided. - unknown: Vec, - }, -} - impl From for Error { fn from(error: jp_conversation::StreamError) -> Self { Self::Conversation(error.into()) @@ -739,18 +662,6 @@ impl From for Error { } } -#[cfg(test)] -impl PartialEq for ToolError { - fn eq(&self, other: &Self) -> bool { - if std::mem::discriminant(self) != std::mem::discriminant(other) { - return false; - } - - // Good enough for testing purposes - format!("{self:?}") == format!("{other:?}") - } -} - /// Heuristic check for quota/billing exhaustion based on error text. /// /// This catches the common patterns across providers: diff --git a/crates/jp_llm/src/lib.rs b/crates/jp_llm/src/lib.rs index 56223cc73..6241eb522 100644 --- a/crates/jp_llm/src/lib.rs +++ b/crates/jp_llm/src/lib.rs @@ -19,7 +19,7 @@ pub(crate) mod test; mod cross_route_tests; pub use credential::{AccountIdentity, Credential, ProviderAuth, provider_auth}; -pub use error::{Error, StreamError, StreamErrorKind, ToolError}; +pub use error::{Error, StreamError, StreamErrorKind}; pub use provider::Provider; pub use retry::{exponential_backoff, retry_delay}; pub use stream::{ diff --git a/crates/jp_llm/src/provider/anthropic.rs b/crates/jp_llm/src/provider/anthropic.rs index 816d47d8d..6a7a20d55 100644 --- a/crates/jp_llm/src/provider/anthropic.rs +++ b/crates/jp_llm/src/provider/anthropic.rs @@ -37,6 +37,7 @@ use jp_conversation::{ event::{ChatResponse, ConversationEvent, EventKind}, }; use jp_credentials::CredentialStore; +use jp_tool::ToolDefinition; use serde_json::{Map, Value, json}; use tracing::{debug, info, trace, warn}; @@ -52,7 +53,6 @@ use crate::{ model::{ModelDeprecation, ModelDetails, ReasoningDetails, ReasoningMode}, query::ChatQuery, stream::{EventStream, chain::find_merge_point, with_tool_call_keepalive}, - tool::ToolDefinition, }; static PROVIDER: ProviderId = ProviderId::Anthropic; diff --git a/crates/jp_llm/src/provider/anthropic_tests.rs b/crates/jp_llm/src/provider/anthropic_tests.rs index cca2191cf..c9972c22a 100644 --- a/crates/jp_llm/src/provider/anthropic_tests.rs +++ b/crates/jp_llm/src/provider/anthropic_tests.rs @@ -1828,7 +1828,7 @@ fn test_adaptive_thinking_with_structured_output() { /// thinking disabled. #[test] fn test_forced_tool_with_reasoning_returns_fallback() { - use crate::tool::{ToolDefinition, ToolDocs}; + use jp_tool::{ToolDefinition, ToolDocs}; let model = ModelDetails { id: (PROVIDER, "claude-sonnet-4-5").try_into().unwrap(), @@ -1902,7 +1902,7 @@ fn test_forced_tool_with_reasoning_returns_fallback() { /// up an escalating-nudge fallback that keeps thinking on. #[test] fn test_forced_tool_thinking_always_on_uses_escalating_nudge() { - use crate::tool::{ToolDefinition, ToolDocs}; + use jp_tool::{ToolDefinition, ToolDocs}; let model = ModelDetails { id: (PROVIDER, "claude-fable-5").try_into().unwrap(), @@ -1983,7 +1983,7 @@ fn test_forced_tool_thinking_always_on_uses_escalating_nudge() { /// of the reasoning config. #[test] fn test_forced_tool_thinking_always_on_reasoning_off_still_soft_forces() { - use crate::tool::{ToolDefinition, ToolDocs}; + use jp_tool::{ToolDefinition, ToolDocs}; let model = ModelDetails { id: (PROVIDER, "claude-fable-5").try_into().unwrap(), @@ -2048,7 +2048,7 @@ fn test_forced_tool_thinking_always_on_reasoning_off_still_soft_forces() { /// specific tool. #[test] fn test_forced_tool_function_multi_tool_preserves_name() { - use crate::tool::{ToolDefinition, ToolDocs}; + use jp_tool::{ToolDefinition, ToolDocs}; let model = ModelDetails { id: (PROVIDER, "claude-sonnet-4-5").try_into().unwrap(), @@ -2124,7 +2124,7 @@ fn test_fallback_any_satisfied_by_any_tool() { /// Without reasoning, forced `tool_choice` should NOT produce a fallback. #[test] fn test_forced_tool_without_reasoning_no_fallback() { - use crate::tool::{ToolDefinition, ToolDocs}; + use jp_tool::{ToolDefinition, ToolDocs}; let model = ModelDetails { id: (PROVIDER, "claude-3-haiku-20240307").try_into().unwrap(), diff --git a/crates/jp_llm/src/provider/cerebras.rs b/crates/jp_llm/src/provider/cerebras.rs index f9452ff5f..df24fe3ce 100644 --- a/crates/jp_llm/src/provider/cerebras.rs +++ b/crates/jp_llm/src/provider/cerebras.rs @@ -15,6 +15,7 @@ use jp_conversation::{ event::{ChatResponse, EventKind, ToolCallResponse}, thread::text_attachments_to_xml, }; +use jp_tool::ToolDefinition; use reqwest::header::{self, HeaderMap, HeaderValue}; use reqwest_eventsource::{Event as SseEvent, EventSource, retry::Never}; use serde::Deserialize; @@ -33,7 +34,6 @@ use crate::{ provider::trace_to_tmpfile, query::ChatQuery, stream::with_tool_call_keepalive, - tool::ToolDefinition, }; static PROVIDER: ProviderId = ProviderId::Cerebras; diff --git a/crates/jp_llm/src/provider/google.rs b/crates/jp_llm/src/provider/google.rs index 05a04eadd..da0908a2e 100644 --- a/crates/jp_llm/src/provider/google.rs +++ b/crates/jp_llm/src/provider/google.rs @@ -21,6 +21,7 @@ use jp_conversation::{ event::{ChatResponse, ConversationEvent, EventKind}, thread::{ThreadParts, text_attachments_to_xml}, }; +use jp_tool::ToolDefinition; use serde_json::{Map, Value}; use tracing::{debug, trace, warn}; @@ -34,7 +35,6 @@ use crate::{ event::{Event, EventMatcher, EventPatch, FinishReason, PatchAction}, model::{ModelDeprecation, ModelDetails, ReasoningDetails, ReasoningMode}, query::ChatQuery, - tool::ToolDefinition, }; static PROVIDER: ProviderId = ProviderId::Google; diff --git a/crates/jp_llm/src/provider/ollama.rs b/crates/jp_llm/src/provider/ollama.rs index cc0e18d01..df767148b 100644 --- a/crates/jp_llm/src/provider/ollama.rs +++ b/crates/jp_llm/src/provider/ollama.rs @@ -14,6 +14,7 @@ use jp_conversation::{ event::{ChatResponse, EventKind}, thread::text_attachments_to_xml, }; +use jp_tool::ToolDefinition; use ollama_rs::{ Ollama as Client, error::OllamaError, @@ -35,7 +36,6 @@ use crate::{ event::{Event, FinishReason}, model::ReasoningDetails, query::ChatQuery, - tool::{ToolDefinition, json_schema}, }; static PROVIDER: ProviderId = ProviderId::Ollama; @@ -423,7 +423,7 @@ fn convert_tools(tools: Vec) -> Result> { tools .into_iter() .map(|tool| { - let parameters = json_schema::inline(&tool.parameters) + let parameters = jp_tool::schema::inline(&tool.parameters) .as_object() .cloned() .unwrap_or_default(); diff --git a/crates/jp_llm/src/provider/ollama_tests.rs b/crates/jp_llm/src/provider/ollama_tests.rs index 74154c59b..1c1b0a1f3 100644 --- a/crates/jp_llm/src/provider/ollama_tests.rs +++ b/crates/jp_llm/src/provider/ollama_tests.rs @@ -1,7 +1,8 @@ +use jp_tool::{ToolDefinition, ToolDocs}; use serde_json::json; use super::*; -use crate::{query::Truncation, tool::ToolDocs}; +use crate::query::Truncation; /// Ollama drops `$ref` while decoding a tool's parameters, so a referenced type /// has to arrive expanded or the model sees a property with no type. diff --git a/crates/jp_llm/src/provider/openai.rs b/crates/jp_llm/src/provider/openai.rs index c5a8854a3..f324782e9 100644 --- a/crates/jp_llm/src/provider/openai.rs +++ b/crates/jp_llm/src/provider/openai.rs @@ -22,6 +22,7 @@ use jp_conversation::{ thread::text_attachments_to_xml, }; use jp_credentials::CredentialStore; +use jp_tool::ToolDefinition; use openai_responses::{ Client, CreateError, StreamError as OpenaiStreamError, types::{self, Include, Request, SummaryConfig}, @@ -44,7 +45,6 @@ use crate::{ provider::trace_to_tmpfile, query::{ChatQuery, Truncation}, stream::with_tool_call_keepalive, - tool::{ToolDefinition, json_schema}, }; pub mod auth; @@ -3238,7 +3238,7 @@ fn convert_tools(tools: Vec) -> Vec { // strict mode for that one tool costs its adherence guarantee; // sending it strict costs the whole request, and every other tool // in it. - let strict = !json_schema::has_unconstrained_node(&tool.parameters); + let strict = !jp_tool::schema::has_unconstrained_node(&tool.parameters); types::Tool::Function { name: tool.name, diff --git a/crates/jp_llm/src/provider/openai_compat.rs b/crates/jp_llm/src/provider/openai_compat.rs index 028142009..9fda49325 100644 --- a/crates/jp_llm/src/provider/openai_compat.rs +++ b/crates/jp_llm/src/provider/openai_compat.rs @@ -28,6 +28,7 @@ use jp_conversation::{ ConversationStream, event::{ChatResponse, EventKind, ToolCallResponse}, }; +use jp_tool::ToolDefinition; use reqwest_eventsource::Event as SseEvent; use serde::Deserialize; use serde_json::{Value, json}; @@ -38,7 +39,6 @@ use crate::{ error::StreamError, event::{Event, FinishReason}, stream::aggregator::reasoning::ReasoningExtractor, - tool::ToolDefinition, }; #[derive(Debug, Deserialize)] diff --git a/crates/jp_llm/src/provider/openai_tests.rs b/crates/jp_llm/src/provider/openai_tests.rs index 8d9ba9075..b6222b2c4 100644 --- a/crates/jp_llm/src/provider/openai_tests.rs +++ b/crates/jp_llm/src/provider/openai_tests.rs @@ -381,10 +381,10 @@ mod parameters_with_strict_mode { } mod convert_tools { + use jp_tool::{ToolDefinition, ToolDocs}; use serde_json::json; use super::super::convert_tools; - use crate::tool::{ToolDefinition, ToolDocs}; /// One converted tool, as it goes on the wire. fn converted(parameters: serde_json::Value) -> serde_json::Value { diff --git a/crates/jp_llm/src/provider/vllm_tests.rs b/crates/jp_llm/src/provider/vllm_tests.rs index dda98c3a5..58982554b 100644 --- a/crates/jp_llm/src/provider/vllm_tests.rs +++ b/crates/jp_llm/src/provider/vllm_tests.rs @@ -7,13 +7,11 @@ use jp_conversation::{ event::{ChatRequest, ChatResponse, ToolCallRequest, ToolCallResponse}, thread::Thread, }; +use jp_tool::{ToolDefinition, ToolDocs}; use serde_json::{Map, json}; use super::*; -use crate::{ - query::Truncation, - tool::{ToolDefinition, ToolDocs}, -}; +use crate::query::Truncation; fn qwen_model() -> VllmModel { serde_json::from_value(json!({ diff --git a/crates/jp_llm/src/query.rs b/crates/jp_llm/src/query.rs index 77957d589..1fa2a6e69 100644 --- a/crates/jp_llm/src/query.rs +++ b/crates/jp_llm/src/query.rs @@ -1,7 +1,6 @@ use jp_config::assistant::tool_choice::ToolChoice; use jp_conversation::thread::Thread; - -use crate::tool::ToolDefinition; +use jp_tool::ToolDefinition; /// Whether the provider may drop input to make a request fit the model's /// context window. diff --git a/crates/jp_llm/src/test.rs b/crates/jp_llm/src/test.rs index 60ac22a00..915b85056 100644 --- a/crates/jp_llm/src/test.rs +++ b/crates/jp_llm/src/test.rs @@ -20,6 +20,7 @@ use jp_conversation::{ thread::{Thread, ThreadBuilder}, }; use jp_test::mock::{Snap, Vcr}; +use jp_tool::{ToolDefinition, ToolDocs}; use crate::{ event::{Event, FinishReason}, @@ -27,7 +28,6 @@ use crate::{ model::ModelDetails, provider::{ProviderTestRoute, provider_test_support}, query::{ChatQuery, Truncation}, - tool::{ToolDefinition, ToolDocs}, }; /// Fail when a model calls a tool with arguments its schema does not declare. diff --git a/crates/jp_llm/src/tool.rs b/crates/jp_llm/src/tool.rs index da9db388f..3b15fcda7 100644 --- a/crates/jp_llm/src/tool.rs +++ b/crates/jp_llm/src/tool.rs @@ -18,10 +18,13 @@ use jp_mcp::{ RawContent, ResourceContents, id::{McpServerId, McpToolId}, }; -use jp_tool::{Action, Outcome, Question}; -use json_schema::{Node, merge_description}; +use jp_tool::{ + Action, Error as ToolError, Outcome, ParameterDocs, Question, ToolDefinition, ToolDocs, + definition::{apply_parameter_defaults, split_description, validate_tool_arguments}, + schema::{Node, merge_description}, +}; use minijinja::{Environment, ErrorKind as MinijinjaErrorKind, value::ValueKind}; -use serde_json::{Map, Value, json}; +use serde_json::{Value, json}; use tokio::{ io::{AsyncBufReadExt, AsyncReadExt, BufReader}, process::Command, @@ -29,85 +32,37 @@ use tokio::{ use tokio_util::sync::CancellationToken; use tracing::{error, info, trace, warn}; -use crate::error::ToolError; - -/// Documentation for a single tool parameter. -#[derive(Debug, Clone)] -pub struct ParameterDocs { - pub summary: Option, - pub description: Option, - pub examples: Option, -} - -impl ParameterDocs { - #[must_use] - pub fn is_empty(&self) -> bool { - self.description.is_none() && self.examples.is_none() - } -} - -/// Documentation for a single tool. -#[derive(Debug, Clone, Default)] -pub struct ToolDocs { - pub summary: Option, - pub description: Option, - pub examples: Option, - pub parameters: IndexMap, -} - -impl ToolDocs { - #[must_use] - pub fn is_empty(&self) -> bool { - self.description.is_none() - && self.examples.is_none() - && self.parameters.values().all(ParameterDocs::is_empty) - } - - /// The short description used for the tool schema sent to the LLM. - /// - /// Returns `summary` if set, otherwise falls back to `description`. - #[must_use] - pub fn schema_description(&self) -> Option<&str> { - self.summary.as_deref().or(self.description.as_deref()) - } - - /// Build `ToolDocs` from a tool's configuration. - #[must_use] - pub fn from_config(config: &ToolConfigWithDefaults) -> Self { - let summary = config.summary().map(str::to_owned); - let description = config.description().map(str::to_owned); - let examples = config.examples().map(str::to_owned); - - let parameters = config - .parameters() - .iter() - .filter_map(|(param_name, param_cfg)| { - let summary = param_cfg - .summary - .as_deref() - .or(param_cfg.description.as_deref()) - .map(str::to_owned); - let desc = param_cfg.description.as_deref().map(str::to_owned); - let ex = param_cfg.examples.as_deref().map(str::to_owned); +/// Read a tool's documentation out of its configuration. +fn tool_docs_from_config(config: &ToolConfigWithDefaults) -> ToolDocs { + let parameters = config + .parameters() + .iter() + .filter_map(|(param_name, param_cfg)| { + let summary = param_cfg + .summary + .as_deref() + .or(param_cfg.description.as_deref()) + .map(str::to_owned); + let desc = param_cfg.description.as_deref().map(str::to_owned); + let ex = param_cfg.examples.as_deref().map(str::to_owned); - if summary.is_none() && desc.is_none() && ex.is_none() { - return None; - } + if summary.is_none() && desc.is_none() && ex.is_none() { + return None; + } - Some((param_name.to_owned(), ParameterDocs { - summary, - description: desc, - examples: ex, - })) - }) - .collect(); + Some((param_name.to_owned(), ParameterDocs { + summary, + description: desc, + examples: ex, + })) + }) + .collect(); - Self { - summary, - description, - examples, - parameters, - } + ToolDocs { + summary: config.summary().map(str::to_owned), + description: config.description().map(str::to_owned), + examples: config.examples().map(str::to_owned), + parameters, } } @@ -117,8 +72,7 @@ impl ToolDocs { /// command or MCP call, without any interactive prompts. /// The caller is responsible for: /// -/// 1. Handling permission prompts **before** calling -/// [`ToolDefinition::execute()`]. +/// 1. Handling permission prompts **before** calling [`execute()`]. /// 2. Handling [`ExecutionOutcome::NeedsInput`] by prompting the user or /// assistant. /// 3. Handling result editing **after** receiving the outcome. @@ -126,7 +80,7 @@ impl ToolDocs { /// # Example Flow /// /// ```text -/// ToolExecutor (jp_cli) ToolDefinition (jp_llm) +/// ToolExecutor (jp_cli) execute() (jp_llm) /// ───────────────────── ────────────────────── /// │ /// ├── [AwaitingPermission] @@ -165,7 +119,7 @@ pub enum ExecutionOutcome { /// /// 1. Present the question to the user (or delegate to the assistant) /// 2. Collect the answer - /// 3. Call [`ToolDefinition::execute()`] again with the answer in `answers` + /// 3. Call [`execute()`] again with the answer in `answers` NeedsInput { /// The tool call ID. id: String, @@ -502,7 +456,7 @@ pub async fn run_tool_command( .render_str(&program, &ctx) .map_err(|error| ToolError::TemplateError { data: program.clone(), - error, + error: Box::new(error), })?; let args = args @@ -511,7 +465,7 @@ pub async fn run_tool_command( .collect::, _>>() .map_err(|error| ToolError::TemplateError { data: args.join(" "), - error, + error: Box::new(error), })?; let mut cmd = if shell { @@ -705,533 +659,297 @@ pub struct InvocationContext { pub conversation_id: String, } -/// The definition of a tool. +/// Execute a tool without any interactive prompts. /// -/// The definition source is either a [`ToolConfig`] for `local` tools, or a -/// combination of `ToolConfig` and MCP server information for `mcp` tools, or -/// hard-coded for definitions `builtin` tools. +/// This is a pure execution path that runs the tool's underlying command or MCP +/// call and returns an [`ExecutionOutcome`]. +/// All interactive decisions (permission prompts, result editing, question +/// handling) are the caller's responsibility. /// -/// [`ToolConfig`]: jp_config::conversation::tool::ToolConfig -#[derive(Debug, Clone)] -pub struct ToolDefinition { - pub name: String, - pub docs: ToolDocs, - - /// JSON Schema for the tool's arguments, as its source declared it, with - /// configuration overrides applied. - /// - /// Adapting this to what a given API accepts belongs to that provider. - pub parameters: Value, -} - -impl ToolDefinition { - /// Coerce JSON-encoded argument strings to non-string schema types. - /// - /// Strings stay unchanged when the schema accepts strings or their contents - /// do not parse to a declared type. - pub fn coerce_arguments(&self, arguments: &mut Map) { - coerce_arguments_to_schema(arguments, &self.parameters); - } - - /// Execute the tool without any interactive prompts. - /// - /// This is a pure execution method that runs the tool's underlying command - /// or MCP call and returns an [`ExecutionOutcome`]. - /// All interactive decisions (permission prompts, result editing, question - /// handling) are the caller's responsibility. - /// - /// # Arguments - /// - /// - `id` - The tool call ID for correlation with the request - /// - `arguments` - The tool arguments (caller is responsible for any - /// pre-processing) - /// - `answers` - Pre-provided answers to tool questions (from previous - /// `NeedsInput`) - /// - `config` - Tool configuration - /// - `mcp_client` - MCP client for MCP tool execution - /// - `root` - Working directory for local tool execution - /// - `cancellation_token` - Token to cancel long-running execution - /// - `builtin_executors` - Registry of builtin tools - /// - /// # Returns - /// - /// - [`ExecutionOutcome::Completed`] - Tool finished (check inner `Result` - /// for success/error) - /// - [`ExecutionOutcome::NeedsInput`] - Tool needs user input to continue - /// - [`ExecutionOutcome::Cancelled`] - Execution was cancelled via the - /// token - /// - /// # Errors - /// - /// Returns [`ToolError`] for infrastructure errors (spawn failure, missing - /// command, etc.). - /// Tool-level errors (command returned non-zero) are returned as - /// `Ok(ExecutionOutcome::Completed { result: Err(...) })`. - /// - /// # Example - /// - /// ```ignore - /// loop { - /// match definition.execute(id, &args, &answers, ...).await? { - /// ExecutionOutcome::Completed { result, .. } => { - /// // Handle success or tool error - /// break result; - /// } - /// ExecutionOutcome::NeedsInput { question, .. } => { - /// // Prompt user for input - /// let answer = prompt_user(&question)?; - /// answers.insert(question.id, answer); - /// // Loop to retry with answer - /// } - /// ExecutionOutcome::Cancelled { .. } => { - /// break Ok("Cancelled".into()); - /// } - /// } - /// } - /// ``` - #[expect(clippy::too_many_arguments)] - pub async fn execute( - &self, - id: String, - arguments: Value, - answers: &IndexMap, - config: &ToolConfigWithDefaults, - mcp_client: &jp_mcp::Client, - root: &Utf8Path, - cancellation_token: CancellationToken, - builtin_executors: &builtin::BuiltinExecutors, - access: Option<&jp_tool::AccessPolicy>, - invocation: &InvocationContext, - stderr: Option, - ) -> Result { - let mut arguments = arguments; - if let Some(arguments) = arguments.as_object_mut() { - self.coerce_arguments(arguments); - } - info!(tool = %self.name, arguments = ?arguments, "Executing tool."); - - match config.source() { - ToolSource::Local { tool } => { - self.execute_local( - id, - arguments, - answers, - config, - tool.as_deref(), - root, - cancellation_token, - access, - invocation, - stderr, - ) - .await - } - ToolSource::Mcp { server, tool } => { - self.execute_mcp( - id, - arguments, - mcp_client, - server, - tool.as_deref(), - cancellation_token, - ) - .await - } - ToolSource::Builtin { tool } => { - self.execute_builtin(id, &arguments, answers, tool.as_deref(), builtin_executors) - .await - } - } +/// # Arguments +/// +/// - `id` - The tool call ID for correlation with the request +/// - `arguments` - The tool arguments (caller is responsible for any +/// pre-processing) +/// - `answers` - Pre-provided answers to tool questions (from previous +/// `NeedsInput`) +/// - `config` - Tool configuration +/// - `mcp_client` - MCP client for MCP tool execution +/// - `root` - Working directory for local tool execution +/// - `cancellation_token` - Token to cancel long-running execution +/// - `builtin_executors` - Registry of builtin tools +/// +/// # Returns +/// +/// - [`ExecutionOutcome::Completed`] - Tool finished (check inner `Result` for +/// success/error) +/// - [`ExecutionOutcome::NeedsInput`] - Tool needs user input to continue +/// - [`ExecutionOutcome::Cancelled`] - Execution was cancelled via the token +/// +/// # Errors +/// +/// Returns [`ToolError`] for infrastructure errors (spawn failure, missing +/// command, etc.). +/// Tool-level errors (command returned non-zero) are returned as +/// `Ok(ExecutionOutcome::Completed { result: Err(...) })`. +/// +/// # Example +/// +/// ```ignore +/// loop { +/// match execute(&definition, id, args, &answers, ...).await? { +/// ExecutionOutcome::Completed { result, .. } => { +/// // Handle success or tool error +/// break result; +/// } +/// ExecutionOutcome::NeedsInput { question, .. } => { +/// // Prompt user for input +/// let answer = prompt_user(&question)?; +/// answers.insert(question.id, answer); +/// // Loop to retry with answer +/// } +/// ExecutionOutcome::Cancelled { .. } => { +/// break Ok("Cancelled".into()); +/// } +/// } +/// } +/// ``` +#[expect(clippy::too_many_arguments)] +pub async fn execute( + definition: &ToolDefinition, + id: String, + arguments: Value, + answers: &IndexMap, + config: &ToolConfigWithDefaults, + mcp_client: &jp_mcp::Client, + root: &Utf8Path, + cancellation_token: CancellationToken, + builtin_executors: &builtin::BuiltinExecutors, + access: Option<&jp_tool::AccessPolicy>, + invocation: &InvocationContext, + stderr: Option, +) -> Result { + let mut arguments = arguments; + if let Some(arguments) = arguments.as_object_mut() { + definition.coerce_arguments(arguments); } + info!(tool = %definition.name, arguments = ?arguments, "Executing tool."); - /// Execute a local tool and return the outcome. - /// - /// This is the pure execution path for local tools. - /// It validates arguments, runs the command, and converts the result to an - /// `ExecutionOutcome`. - #[expect(clippy::too_many_arguments)] - async fn execute_local( - &self, - id: String, - mut arguments: Value, - answers: &IndexMap, - config: &ToolConfigWithDefaults, - tool: Option<&str>, - root: &Utf8Path, - cancellation_token: CancellationToken, - access: Option<&jp_tool::AccessPolicy>, - invocation: &InvocationContext, - stderr: Option, - ) -> Result { - let name = tool.unwrap_or(&self.name); - - // Apply configured defaults for missing parameters, then validate. - if let Some(args) = arguments.as_object_mut() { - apply_parameter_defaults(args, &self.parameters); - - if let Err(error) = validate_tool_arguments(args, &self.parameters) { - return Ok(ExecutionOutcome::Completed { - id, - result: Err(format!( - "Invalid arguments: {error}\n\nYou can call `describe_tools(tools: \ - [\"{name}\"])` to learn more about how to use the tool correctly." - )), - }); - } - } - - let ctx = json!({ - "tool": { - "name": name, - "arguments": &arguments, - "answers": answers, - "options": config.options(), - }, - "context": { - "action": Action::Run, - "root": root.as_str(), - "access": access, - "workspace_id": &invocation.workspace_id, - "conversation_id": &invocation.conversation_id, - }, - }); - - let Some(command) = config.command() else { - return Err(ToolError::MissingCommand); - }; - - let trace_as = ToolTrace { - id: &id, - name, - stderr, - }; - - match run_tool_command(command, ctx, root, cancellation_token, Some(trace_as)).await? { - CommandResult::Success(content) => Ok(ExecutionOutcome::Completed { - id, - result: Ok(content), - }), - CommandResult::NeedsInput(question) => { - Ok(ExecutionOutcome::NeedsInput { id, question }) - } - CommandResult::Cancelled => Ok(ExecutionOutcome::Cancelled { id }), - other => Ok(ExecutionOutcome::Completed { + match config.source() { + ToolSource::Local { tool } => { + execute_local( + definition, id, - result: other.into_tool_result(name), - }), + arguments, + answers, + config, + tool.as_deref(), + root, + cancellation_token, + access, + invocation, + stderr, + ) + .await } - } - - /// Execute an MCP tool and return the outcome. - /// - /// This is the pure execution path for MCP tools. - /// It calls the MCP server and converts the result to an - /// `ExecutionOutcome`. - async fn execute_mcp( - &self, - id: String, - arguments: Value, - mcp_client: &jp_mcp::Client, - server: &str, - tool: Option<&str>, - cancellation_token: CancellationToken, - ) -> Result { - let name = tool.unwrap_or(&self.name); - - let call_future = mcp_client.call_tool(name, server, &arguments); - - tokio::select! { - biased; - () = cancellation_token.cancelled() => { - info!(tool = %self.name, "MCP tool call cancelled"); - Ok(ExecutionOutcome::Cancelled { id }) - } - result = call_future => { - let result = result.map_err(ToolError::McpRunToolError)?; - - let content = result - .content - .into_iter() - .filter_map(|v| match v.raw { - RawContent::Text(v) => Some(v.text), - RawContent::Resource(v) => match v.resource { - ResourceContents::TextResourceContents { text, .. } => Some(text), - ResourceContents::BlobResourceContents { blob, .. } => Some(blob), - }, - RawContent::Image(_) | RawContent::Audio(_) | RawContent::ResourceLink(_) => None, - }) - .collect::>() - .join("\n\n"); - - let result = if result.is_error.unwrap_or_default() { - Err(content) - } else { - Ok(content) - }; - - Ok(ExecutionOutcome::Completed { id, result }) - } + ToolSource::Mcp { server, tool } => { + execute_mcp( + definition, + id, + arguments, + mcp_client, + server, + tool.as_deref(), + cancellation_token, + ) + .await } - } - - /// Execute a builtin tool and return the outcome. - /// - /// `source_name` is the implementation named by `source = - /// "builtin."`, which the registry is keyed on. - /// When absent, the implementation shares the tool's own name. - async fn execute_builtin( - &self, - id: String, - arguments: &Value, - answers: &IndexMap, - source_name: Option<&str>, - builtin_executors: &builtin::BuiltinExecutors, - ) -> Result { - let name = source_name.unwrap_or(&self.name); - let executor = builtin_executors - .get(name) - .ok_or_else(|| ToolError::NotFound { - name: name.to_owned(), - })?; - - let outcome = executor.execute(arguments, answers).await; - - Ok(match outcome { - jp_tool::Outcome::Success { content } => ExecutionOutcome::Completed { + ToolSource::Builtin { tool } => { + execute_builtin( + definition, id, - result: Ok(content), - }, - jp_tool::Outcome::Error { - message, - trace, - transient: _, - } => { - let error_msg = if trace.is_empty() { - message - } else { - format!("{message}\n\nTrace:\n{}", trace.join("\n")) - }; - ExecutionOutcome::Completed { - id, - result: Err(error_msg), - } - } - jp_tool::Outcome::NeedsInput { question } => { - ExecutionOutcome::NeedsInput { id, question } - } - }) - } - - /// Return the JSON Schema for the tool's parameters. - #[must_use] - pub fn to_parameters_schema(&self) -> Value { - self.parameters.clone() + &arguments, + answers, + tool.as_deref(), + builtin_executors, + ) + .await + } } } -/// Split a description string into a short summary and remaining detail. -/// -/// If the text is short (single line, ≤120 chars), it is returned as the -/// summary with no remaining description. +/// Execute a local tool and return the outcome. /// -/// Otherwise, the first sentence is extracted as the summary. -/// A sentence ends at ` . ` or `.\n`. -/// The remainder becomes the description. -pub(crate) fn split_description(text: &str) -> (String, Option) { - let text = text.trim(); - - // Find the first sentence boundary. - // Look for ". " or ".\n" — a period followed by whitespace. - for (i, _) in text.match_indices('.') { - let after = i + 1; - if after >= text.len() { - // Period at end of string — the whole text is one sentence. - break; - } - - let next_byte = text.as_bytes()[after]; - if next_byte == b'\n' { - // Period followed by newline is always a sentence boundary. - } else if next_byte == b' ' { - // Period followed by space: only split if the next non-space - // character is uppercase (heuristic to skip abbreviations - // like "e.g. foo"). - let rest_after_space = text[after..].trim_start(); - if rest_after_space.is_empty() - || !rest_after_space - .chars() - .next() - .is_some_and(char::is_uppercase) - { - continue; - } - } else { - continue; - } - - { - let summary = text[..=i].trim().to_owned(); - let rest = text[after..].trim(); - - if rest.is_empty() { - return (summary, None); - } - - return (summary, Some(rest.to_owned())); - } - } - - // No sentence boundary found — take the first line. - if let Some(nl) = text.find('\n') { - let summary = text[..nl].trim().to_owned(); - let rest = text[nl..].trim(); - - if rest.is_empty() { - return (summary, None); +/// This is the pure execution path for local tools. +/// It validates arguments, runs the command, and converts the result to an +/// `ExecutionOutcome`. +#[expect(clippy::too_many_arguments)] +async fn execute_local( + definition: &ToolDefinition, + id: String, + mut arguments: Value, + answers: &IndexMap, + config: &ToolConfigWithDefaults, + tool: Option<&str>, + root: &Utf8Path, + cancellation_token: CancellationToken, + access: Option<&jp_tool::AccessPolicy>, + invocation: &InvocationContext, + stderr: Option, +) -> Result { + let name = tool.unwrap_or(&definition.name); + + // Apply configured defaults for missing parameters, then validate. + if let Some(args) = arguments.as_object_mut() { + apply_parameter_defaults(args, &definition.parameters); + + if let Err(error) = validate_tool_arguments(args, &definition.parameters) { + return Ok(ExecutionOutcome::Completed { + id, + result: Err(format!( + "Invalid arguments: {error}\n\nYou can call `describe_tools(tools: \ + [\"{name}\"])` to learn more about how to use the tool correctly." + )), + }); } - - return (summary, Some(rest.to_owned())); } - // Single long line, no period — return as-is. - (text.to_owned(), None) -} - -/// Coerce JSON-encoded argument strings to the types the schema declares. -fn coerce_arguments_to_schema(arguments: &mut Map, schema: &Value) { - coerce_object(arguments, &Node::root(schema)); -} + let ctx = json!({ + "tool": { + "name": name, + "arguments": &arguments, + "answers": answers, + "options": config.options(), + }, + "context": { + "action": Action::Run, + "root": root.as_str(), + "access": access, + "workspace_id": &invocation.workspace_id, + "conversation_id": &invocation.conversation_id, + }, + }); -fn coerce_object(arguments: &mut Map, node: &Node<'_>) { - for (name, property) in node.properties() { - if let Some(value) = arguments.get_mut(&name) { - coerce_value(value, &property); - } - } -} + let Some(command) = config.command() else { + return Err(ToolError::MissingCommand); + }; -fn coerce_value(value: &mut Value, node: &Node<'_>) { - // Coercion repairs an argument the schema cannot take as written. A - // parameter that permits the string has nothing to repair, so parsing it - // would hand the tool a number or an object where the model sent text. - if let Value::String(raw) = &*value - && !node.permits(value) - && let Ok(parsed) = serde_json::from_str::(raw) - && node.permits(&parsed) - { - *value = parsed; - } + let trace_as = ToolTrace { + id: &id, + name, + stderr, + }; - match value { - Value::Object(arguments) => coerce_object(arguments, node), - Value::Array(values) => { - let Some(items) = node.items() else { - return; - }; - for value in values { - coerce_value(value, &items); - } - } - _ => {} + match run_tool_command(command, ctx, root, cancellation_token, Some(trace_as)).await? { + CommandResult::Success(content) => Ok(ExecutionOutcome::Completed { + id, + result: Ok(content), + }), + CommandResult::NeedsInput(question) => Ok(ExecutionOutcome::NeedsInput { id, question }), + CommandResult::Cancelled => Ok(ExecutionOutcome::Cancelled { id }), + other => Ok(ExecutionOutcome::Completed { + id, + result: other.into_tool_result(name), + }), } } -/// Fill in configured default values for missing parameters. +/// Execute an MCP tool and return the outcome. /// -/// LLMs commonly omit parameters that have a `default` in the JSON schema, even -/// when those parameters are marked `required`. -/// This function patches the arguments map before validation so that such -/// omissions don't cause spurious "missing argument" errors and unnecessary LLM -/// retries. -fn apply_parameter_defaults(arguments: &mut Map, schema: &Value) { - apply_defaults_to(arguments, &Node::root(schema)); -} +/// This is the pure execution path for MCP tools. +/// It calls the MCP server and converts the result to an `ExecutionOutcome`. +async fn execute_mcp( + definition: &ToolDefinition, + id: String, + arguments: Value, + mcp_client: &jp_mcp::Client, + server: &str, + tool: Option<&str>, + cancellation_token: CancellationToken, +) -> Result { + let name = tool.unwrap_or(&definition.name); -fn apply_defaults_to(arguments: &mut Map, node: &Node<'_>) { - for (name, property) in node.properties() { - if !arguments.contains_key(&name) { - if let Some(default) = property.default() { - let default = default.clone(); - arguments.insert(name, default); - } - continue; - } + let call_future = mcp_client.call_tool(name, server, &arguments); - // Recurse into object fields. - if property.has_properties() - && let Some(object) = arguments.get_mut(&name).and_then(Value::as_object_mut) - { - apply_defaults_to(object, &property); + tokio::select! { + biased; + () = cancellation_token.cancelled() => { + info!(tool = %definition.name, "MCP tool call cancelled"); + Ok(ExecutionOutcome::Cancelled { id }) } + result = call_future => { + let result = result + .map_err(|error| ToolError::McpRunToolError(Box::new(error)))?; + + let content = result + .content + .into_iter() + .filter_map(|v| match v.raw { + RawContent::Text(v) => Some(v.text), + RawContent::Resource(v) => match v.resource { + ResourceContents::TextResourceContents { text, .. } => Some(text), + ResourceContents::BlobResourceContents { blob, .. } => Some(blob), + }, + RawContent::Image(_) | RawContent::Audio(_) | RawContent::ResourceLink(_) => None, + }) + .collect::>() + .join("\n\n"); + + let result = if result.is_error.unwrap_or_default() { + Err(content) + } else { + Ok(content) + }; - // Recurse into array elements. - if let Some(items) = property.items() - && items.has_properties() - && let Some(values) = arguments.get_mut(&name).and_then(Value::as_array_mut) - { - for value in values.iter_mut() { - if let Some(object) = value.as_object_mut() { - apply_defaults_to(object, &items); - } - } + Ok(ExecutionOutcome::Completed { id, result }) } } } -fn validate_tool_arguments( - arguments: &Map, - schema: &Value, -) -> Result<(), ToolError> { - validate_arguments_against(arguments, &Node::root(schema)) -} - -fn validate_arguments_against( - arguments: &Map, - node: &Node<'_>, -) -> Result<(), ToolError> { - let properties = node.properties(); - - let unknown = arguments - .keys() - .filter(|name| !properties.iter().any(|(known, _)| known == *name)) - .cloned() - .collect::>(); - - let missing = properties - .iter() - .filter(|(name, _)| node.is_required(name) && !arguments.contains_key(name)) - .map(|(name, _)| name.clone()) - .collect::>(); - - if !missing.is_empty() || !unknown.is_empty() { - return Err(ToolError::Arguments { missing, unknown }); - } - - // Recurse into nested structures. - for (name, property) in properties { - let Some(value) = arguments.get(&name) else { - continue; - }; +/// Execute a builtin tool and return the outcome. +/// +/// `source_name` is the implementation named by `source = "builtin."`, +/// which the registry is keyed on. +/// When absent, the implementation shares the tool's own name. +async fn execute_builtin( + definition: &ToolDefinition, + id: String, + arguments: &Value, + answers: &IndexMap, + source_name: Option<&str>, + builtin_executors: &builtin::BuiltinExecutors, +) -> Result { + let name = source_name.unwrap_or(&definition.name); + let executor = builtin_executors + .get(name) + .ok_or_else(|| ToolError::NotFound { + name: name.to_owned(), + })?; - if let Some(object) = value.as_object() - && property.has_properties() - { - validate_arguments_against(object, &property)?; - } + let outcome = executor.execute(arguments, answers).await; - if let Some(items) = property.items() - && items.has_properties() - && let Some(values) = value.as_array() - { - for value in values { - if let Some(object) = value.as_object() { - validate_arguments_against(object, &items)?; - } + Ok(match outcome { + Outcome::Success { content } => ExecutionOutcome::Completed { + id, + result: Ok(content), + }, + Outcome::Error { + message, + trace, + transient: _, + } => { + let error_msg = if trace.is_empty() { + message + } else { + format!("{message}\n\nTrace:\n{}", trace.join("\n")) + }; + ExecutionOutcome::Completed { + id, + result: Err(error_msg), } } - } - - Ok(()) + Outcome::NeedsInput { question } => ExecutionOutcome::NeedsInput { id, question }, + }) } /// Resolve all enabled tool definitions from config. @@ -1305,7 +1023,7 @@ async fn resolve_tool( let definition = match config.source() { ToolSource::Local { .. } | ToolSource::Builtin { .. } => ToolDefinition { name: name.to_owned(), - docs: ToolDocs::from_config(config), + docs: tool_docs_from_config(config), parameters: json_schema::from_config(&path, config.parameters())?, }, ToolSource::Mcp { server, tool } => { @@ -1313,7 +1031,7 @@ async fn resolve_tool( } }; - json_schema::validate(&path, &definition.parameters)?; + jp_tool::schema::validate(&path, &definition.parameters)?; Ok(definition) } @@ -1334,7 +1052,7 @@ async fn resolve_mcp_tool( mcp_client .get_tool(&McpToolId::new(source_name.unwrap_or(name)), &server_id) .await - .map_err(ToolError::McpGetToolError) + .map_err(|error| ToolError::McpGetToolError(Box::new(error))) }?; let user_overrides = config.parameters(); diff --git a/crates/jp_llm/src/tool/json_schema.rs b/crates/jp_llm/src/tool/json_schema.rs index 042210dff..8148ab456 100644 --- a/crates/jp_llm/src/tool/json_schema.rs +++ b/crates/jp_llm/src/tool/json_schema.rs @@ -1,43 +1,35 @@ -//! JSON Schema for tool parameters: construction, validation, and reading. +//! Building a tool's parameter schema from configuration. //! -//! A tool's parameters are one JSON Schema object, held exactly as its source -//! declared it. +//! A tool's parameters are one JSON Schema object. //! For an MCP tool that is the server's `inputSchema` with the user's //! configured overrides applied; for a local or built-in tool it is generated -//! from configuration. -//! Nothing else rewrites it: adapting a schema to what a given API accepts is -//! the responsibility of that provider. +//! from configuration alone. //! -//! [`Node`] is the read-only view used by argument handling and validation. -//! It follows same-document `$ref` pointers while reading, so a referenced enum -//! or nested object answers questions the same way an inline one does. - -use std::borrow::Cow; +//! Reading and validating the result lives in [`jp_tool::schema`], which knows +//! nothing about configuration. use indexmap::IndexMap; use jp_config::conversation::tool::{OneOrManyTypes, ToolParameterConfig}; +use jp_tool::{ + Error, + schema::{Node, format_types, merge_description, required_names, validate_types}, +}; use serde_json::{Map, Value, json}; -use crate::error::ToolError; - -/// JSON types a tool parameter may declare. -const SUPPORTED_TYPES: &[&str] = &[ - "array", "boolean", "integer", "null", "number", "object", "string", -]; - -/// Bound on `$ref` expansion while reading, so a self-referential schema -/// terminates. -const MAX_REF_HOPS: usize = 32; - /// Build the parameters schema for a tool whose shape is defined entirely in /// configuration. /// /// Local and built-in tools have no upstream schema, so every parameter must /// declare a type. +/// +/// # Errors +/// +/// Returns [`Error::InvalidSchema`] when a parameter declares no type, or one +/// the schema cannot carry. pub fn from_config( path: &str, parameters: &IndexMap, -) -> Result { +) -> Result { let mut properties = Map::new(); let mut required = vec![]; @@ -57,11 +49,16 @@ pub fn from_config( /// The server's document is preserved, including any `$defs` block. /// An override may narrow a parameter, but may not contradict the type the /// server declared. +/// +/// # Errors +/// +/// Returns [`Error::InvalidSchema`] when an override contradicts the type the +/// server declared, or declares one the schema cannot carry. pub fn with_overrides( path: &str, source: &Value, overrides: &IndexMap, -) -> Result { +) -> Result { let mut schema = source.as_object().cloned().unwrap_or_default(); let source_required = required_names(source); @@ -98,545 +95,6 @@ pub fn with_overrides( Ok(Value::Object(schema)) } -/// Validate a tool's parameters schema. -/// -/// Rejects the shapes that no provider can act on, and the ones that contradict -/// themselves: unusable types, arrays with no item schema, `items` or -/// `properties` on a type that cannot carry them, duplicate or ill-typed enum -/// values, and defaults the schema itself forbids. -pub fn validate(path: &str, schema: &Value) -> Result<(), ToolError> { - let root = Node::root(schema); - for (name, property) in root.properties() { - validate_node(&format!("{path}.{name}"), &property, &mut vec![])?; - } - - Ok(()) -} - -/// Validate one node, tracking which definitions the walk is already inside. -/// -/// A recursive schema is legal, and providers that reject it say so themselves. -/// Re-entering a definition already on the path adds nothing, so the walk stops -/// there instead of expanding forever. -fn validate_node(path: &str, node: &Node<'_>, visiting: &mut Vec) -> Result<(), ToolError> { - if let Some(origin) = node.origin() { - if visiting.iter().any(|seen| seen == origin) { - return Ok(()); - } - visiting.push(origin.to_owned()); - } - - let result = validate_node_inner(path, node, visiting); - - if node.origin().is_some() { - visiting.pop(); - } - - result -} - -fn validate_node_inner( - path: &str, - node: &Node<'_>, - visiting: &mut Vec, -) -> Result<(), ToolError> { - let types = node.types(); - // A schema with no `type` keyword accepts any value, and constrains - // nothing that could contradict its `items`, `properties`, `enum` or - // `default`. A `type` that is present but empty or unrecognised, and a - // `$ref` that could not be followed, remain malformed. - let unconstrained = node.is_unconstrained(); - if !unconstrained { - validate_types(path, &types)?; - } - - let items = node.items(); - if types.iter().any(|type_| type_ == "array") && items.is_none() { - return Err(ToolError::InvalidSchema { - path: format!("{path}.items"), - message: "array schemas must declare an item schema".to_owned(), - }); - } - - if let Some(items) = &items { - if !unconstrained && !types.iter().any(|type_| type_ == "array") { - return Err(ToolError::InvalidSchema { - path: format!("{path}.items"), - message: format!( - "`items` requires an array type, but the schema requires {}", - format_types(&types) - ), - }); - } - validate_node(&format!("{path}.items"), items, visiting)?; - } - - let properties = node.properties(); - if !unconstrained && !properties.is_empty() && !types.iter().any(|type_| type_ == "object") { - return Err(ToolError::InvalidSchema { - path: format!("{path}.properties"), - message: format!( - "`properties` requires an object type, but the schema requires {}", - format_types(&types) - ), - }); - } - for (name, property) in properties { - validate_node(&format!("{path}.properties.{name}"), &property, visiting)?; - } - - let enumeration = node.enumeration(); - for (index, value) in enumeration.iter().enumerate() { - if enumeration[..index].contains(value) { - return Err(ToolError::InvalidSchema { - path: format!("{path}.enum"), - message: format!("enum values must be unique; duplicate value {value}"), - }); - } - - if node.accepts_type(value) { - validate_value(&format!("{path}.enum[{index}]"), value, node, "enum value")?; - continue; - } - - let hint = if types.iter().any(|type_| type_ == "array") && !value.is_array() { - format!("; use `{path}.items.enum` to constrain array elements") - } else { - String::new() - }; - return Err(ToolError::InvalidSchema { - path: format!("{path}.enum"), - message: format!( - "enum value {value} has type {}, but the schema requires {}{hint}", - value_type(value), - format_types(&types), - ), - }); - } - - if let Some(default) = node.default() { - validate_value(&format!("{path}.default"), default, node, "default value")?; - } - - Ok(()) -} - -/// Validate a schema-declared value against the node it appears in. -/// -/// Applies the node's type and `enum`, then recurses into array elements and -/// object properties so nested constraints are enforced at every depth. -/// `subject` names what is being checked (`default value`, `enum value`) for -/// the error message. -fn validate_value( - path: &str, - value: &Value, - node: &Node<'_>, - subject: &str, -) -> Result<(), ToolError> { - if !node.accepts_type(value) { - return Err(ToolError::InvalidSchema { - path: path.to_owned(), - message: format!( - "{subject} {value} has type {}, but the schema requires {}", - value_type(value), - format_types(&node.types()) - ), - }); - } - - let enumeration = node.enumeration(); - if !enumeration.is_empty() && !enumeration.contains(value) { - return Err(ToolError::InvalidSchema { - path: path.to_owned(), - message: format!("{subject} {value} is not allowed by the enum"), - }); - } - - if let (Value::Array(values), Some(items)) = (value, node.items()) { - for (index, value) in values.iter().enumerate() { - validate_value(&format!("{path}[{index}]"), value, &items, subject)?; - } - } - - if let Value::Object(values) = value { - for (name, property) in node.properties() { - let Some(value) = values.get(&name) else { - if node.is_required(&name) { - return Err(ToolError::InvalidSchema { - path: format!("{path}.{name}"), - message: format!("{subject} is missing required property `{name}`"), - }); - } - continue; - }; - validate_value(&format!("{path}.{name}"), value, &property, subject)?; - } - } - - Ok(()) -} - -fn validate_types(path: &str, types: &[String]) -> Result<(), ToolError> { - if types.is_empty() { - return Err(ToolError::InvalidSchema { - path: format!("{path}.type"), - message: "schema does not declare a supported type".to_owned(), - }); - } - - for (index, type_) in types.iter().enumerate() { - if !SUPPORTED_TYPES.contains(&type_.as_str()) { - return Err(ToolError::InvalidSchema { - path: format!("{path}.type"), - message: format!("unsupported JSON type `{type_}`"), - }); - } - if types[..index].contains(type_) { - return Err(ToolError::InvalidSchema { - path: format!("{path}.type"), - message: format!("type values must be unique; duplicate type `{type_}`"), - }); - } - } - - Ok(()) -} - -/// Whether any node in the document leaves the JSON type of its value open. -/// -/// Walks properties and array items, reading through `$ref` the way [`Node`] -/// does, and stops at a definition already on the path so a recursive schema -/// terminates. -#[must_use] -pub fn has_unconstrained_node(schema: &Value) -> bool { - Node::root(schema) - .properties() - .iter() - .any(|(_, property)| is_open(property, &mut vec![])) -} - -fn is_open(node: &Node<'_>, visiting: &mut Vec) -> bool { - if let Some(origin) = node.origin() { - if visiting.iter().any(|seen| seen == origin) { - return false; - } - visiting.push(origin.to_owned()); - } - - let open = node.is_unconstrained() - || node.items().is_some_and(|items| is_open(&items, visiting)) - || node - .properties() - .iter() - .any(|(_, property)| is_open(property, visiting)); - - if node.origin().is_some() { - visiting.pop(); - } - - open -} - -/// Expand every same-document `$ref` and drop the definitions block. -/// -/// For providers that cannot follow references. -/// A reference that cannot be resolved, or one that revisits a definition -/// already being expanded, is left in place: a recursive type has no finite -/// expansion, and dropping the node would be worse than forwarding something -/// the API can reject. -#[must_use] -pub fn inline(schema: &Value) -> Value { - let mut inlined = inline_node(schema, schema, &mut vec![]); - if let Some(object) = inlined.as_object_mut() { - object.remove("$defs"); - object.remove("definitions"); - } - - inlined -} - -fn inline_node(node: &Value, root: &Value, expanding: &mut Vec) -> Value { - let pointer = pointer_of(node); - if let Some(pointer) = &pointer { - if expanding.contains(pointer) { - return node.clone(); - } - expanding.push(pointer.clone()); - } - - let resolved = resolve(node, root); - let expanded = match resolved.as_object() { - Some(object) => Value::Object( - object - .iter() - .map(|(key, value)| { - let value = match value { - Value::Object(_) => inline_node(value, root, expanding), - Value::Array(values) => Value::Array( - values - .iter() - .map(|value| inline_node(value, root, expanding)) - .collect(), - ), - other => other.clone(), - }; - (key.clone(), value) - }) - .collect(), - ), - None => resolved.into_owned(), - }; - - if pointer.is_some() { - expanding.pop(); - } - - expanded -} - -/// A read-only view of one schema node, resolving `$ref` as it reads. -#[derive(Debug, Clone)] -pub struct Node<'a> { - root: &'a Value, - node: Cow<'a, Value>, - origin: Option, -} - -impl<'a> Node<'a> { - /// View a whole parameters schema, where `$ref` pointers resolve against - /// the same document. - #[must_use] - pub fn root(schema: &'a Value) -> Self { - Self { - root: schema, - node: resolve(schema, schema), - origin: pointer_of(schema), - } - } - - /// The `$ref` pointer this node was reached through, when it was one. - #[must_use] - pub fn origin(&self) -> Option<&str> { - self.origin.as_deref() - } - - /// View a nested node, resolving it against the same document. - /// - /// The node is cloned because resolving a `$ref` produces a new value that - /// cannot borrow from the parent. - fn child(&self, node: &Value) -> Node<'a> { - Node { - root: self.root, - node: Cow::Owned(resolve(node, self.root).into_owned()), - origin: pointer_of(node), - } - } - - /// JSON types this node accepts. - #[must_use] - pub fn types(&self) -> Vec { - match self.node.get("type") { - Some(Value::String(type_)) => vec![type_.clone()], - Some(Value::Array(types)) => types - .iter() - .filter_map(Value::as_str) - .map(str::to_owned) - .collect(), - _ => vec![], - } - } - - /// Whether this node leaves the JSON type of its value open. - /// - /// A schema object with no `type` keyword accepts any value, which is how a - /// server declares a free-form parameter. - /// Anything else that reads as declaring no type is not open, because what - /// it declares is unknown rather than unrestricted: a `$ref` that could not - /// be followed, a boolean schema, or a non-schema value such as a `null` - /// left in a `properties` map. - #[must_use] - pub fn is_unconstrained(&self) -> bool { - self.node.is_object() && self.node.get("type").is_none() && self.node.get("$ref").is_none() - } - - /// Whether a value satisfies this node's declared types. - /// - /// Ignores every other constraint the node carries; [`permits`] applies - /// those too. - /// A node that declares no type accepts every value. - /// - /// [`permits`]: Self::permits - #[must_use] - pub fn accepts_type(&self, value: &Value) -> bool { - if self.is_unconstrained() { - return true; - } - - let types = self.types(); - let has = |type_: &str| types.iter().any(|candidate| candidate == type_); - - match value { - Value::Null => has("null"), - Value::Bool(_) => has("boolean"), - Value::Number(number) => { - has("number") || (has("integer") && (number.is_i64() || number.is_u64())) - } - Value::String(_) => has("string"), - Value::Array(_) => has("array"), - Value::Object(_) => has("object"), - } - } - - /// Whether a value satisfies every constraint this node declares. - /// - /// A value must match the declared types, and appear in the `enum` when - /// there is one. - #[must_use] - pub fn permits(&self, value: &Value) -> bool { - if !self.accepts_type(value) { - return false; - } - - let enumeration = self.enumeration(); - enumeration.is_empty() || enumeration.contains(value) - } - - /// The value inserted when the argument is omitted. - #[must_use] - pub fn default(&self) -> Option<&Value> { - // The borrow has to come from the node itself, which `Cow` owns when a - // `$ref` was inlined, so match rather than returning through the Cow. - match &self.node { - Cow::Borrowed(node) => node.get("default"), - Cow::Owned(node) => node.get("default"), - } - } - - /// Values this node accepts, empty when unconstrained. - #[must_use] - pub fn enumeration(&self) -> Vec { - self.node - .get("enum") - .and_then(Value::as_array) - .cloned() - .unwrap_or_default() - } - - /// The schema applied to each array element. - #[must_use] - pub fn items(&self) -> Option> { - self.node.get("items").map(|items| self.child(items)) - } - - /// The schemas for this node's object properties, in declaration order. - #[must_use] - pub fn properties(&self) -> Vec<(String, Node<'a>)> { - self.node - .get("properties") - .and_then(Value::as_object) - .map(|properties| { - properties - .iter() - .map(|(name, node)| (name.clone(), self.child(node))) - .collect() - }) - .unwrap_or_default() - } - - /// Whether this node lists `name` among its required properties. - #[must_use] - pub fn is_required(&self, name: &str) -> bool { - required_names(&self.node).contains(&name) - } - - /// The description sent to the model for this node. - #[must_use] - pub fn description(&self) -> Option<&str> { - match &self.node { - Cow::Borrowed(node) => node.get("description"), - Cow::Owned(node) => node.get("description"), - } - .and_then(Value::as_str) - } - - /// Whether this node declares any property. - #[must_use] - pub fn has_properties(&self) -> bool { - self.node - .get("properties") - .and_then(Value::as_object) - .is_some_and(|properties| !properties.is_empty()) - } -} - -impl PartialEq for Node<'_> { - fn eq(&self, other: &Self) -> bool { - self.node == other.node - } -} - -/// Follow same-document `$ref` pointers, merging sibling keys over the target. -/// -/// Sibling keys win, per JSON Schema 2020-12. -/// A pointer that leaves the document or revisits one already followed is left -/// in place, so reading degrades to "this node declares nothing" rather than -/// looping. -fn resolve<'a>(node: &'a Value, root: &Value) -> Cow<'a, Value> { - let mut current = Cow::Borrowed(node); - let mut seen: Vec = vec![]; - - while let Some(pointer) = current.get("$ref").and_then(Value::as_str) { - let pointer = pointer.to_owned(); - if seen.len() >= MAX_REF_HOPS || seen.contains(&pointer) { - break; - } - - let Some(target) = follow_pointer(&pointer, root) else { - break; - }; - - let mut merged = target; - for (key, value) in current.as_object().into_iter().flatten() { - if key != "$ref" { - merged.insert(key.clone(), value.clone()); - } - } - - seen.push(pointer); - current = Cow::Owned(Value::Object(merged)); - } - - current -} - -fn pointer_of(node: &Value) -> Option { - node.get("$ref").and_then(Value::as_str).map(str::to_owned) -} - -/// Look up a same-document JSON pointer, such as `#/$defs/EntryType`. -fn follow_pointer(pointer: &str, root: &Value) -> Option> { - if pointer == "#" { - return root.as_object().cloned(); - } - - let mut current = root; - for segment in pointer.strip_prefix("#/")?.split('/') { - current = current.get(segment.replace("~1", "/").replace("~0", "~"))?; - } - - current.as_object().cloned() -} - -fn required_names(schema: &Value) -> Vec<&str> { - schema - .get("required") - .and_then(Value::as_array) - .into_iter() - .flatten() - .filter_map(Value::as_str) - .collect() -} - fn object_schema(properties: Map, required: Vec) -> Value { json!({ "type": "object", @@ -646,14 +104,11 @@ fn object_schema(properties: Map, required: Vec) -> Value } /// Build one schema node from configuration alone. -fn node_from_config(path: &str, config: &ToolParameterConfig) -> Result { - let kind = config - .kind - .as_ref() - .ok_or_else(|| ToolError::InvalidSchema { - path: format!("{path}.type"), - message: "local and built-in tool parameters must declare a type".to_owned(), - })?; +fn node_from_config(path: &str, config: &ToolParameterConfig) -> Result { + let kind = config.kind.as_ref().ok_or_else(|| Error::InvalidSchema { + path: format!("{path}.type"), + message: "local and built-in tool parameters must declare a type".to_owned(), + })?; let mut node = Map::new(); node.insert("type".to_owned(), types_to_json(kind)); @@ -668,7 +123,7 @@ fn node_with_override( source: &Value, root: &Value, config: &ToolParameterConfig, -) -> Result { +) -> Result { let mut node = source.as_object().cloned().unwrap_or_default(); if let Some(kind) = &config.kind { @@ -677,7 +132,7 @@ fn node_with_override( // against the document is what lets a referenced type be compared. let declared = Node::root(root).child(source).types(); if !declared.is_empty() && !types_match(&declared, kind) { - return Err(ToolError::InvalidSchema { + return Err(Error::InvalidSchema { path: format!("{path}.type"), message: format!( "MCP declares {}, but the configuration declares {}", @@ -706,7 +161,7 @@ fn apply_config_fields( node: &mut Map, root: &Value, config: &ToolParameterConfig, -) -> Result<(), ToolError> { +) -> Result<(), Error> { if let Some(default) = &config.default { node.insert("default".to_owned(), default.clone()); } @@ -799,38 +254,6 @@ fn types_match(left: &[String], right: &OneOrManyTypes) -> bool { normalize(left.to_vec()) == normalize(type_names(right)) } -/// Merge a user-provided description with the one the source declared. -/// -/// A user description containing `{{description}}` has the source's text -/// substituted in; otherwise the user's text wins outright. -/// With no user description the source's is kept as-is. -#[must_use] -pub fn merge_description(user: Option, source: Option<&str>) -> Option { - match (user, source) { - (None, Some(source)) => Some(source.to_owned()), - // TODO: should use `minijinja` instead of raw string replacement. - (Some(user), Some(source)) => Some(user.replace("{{description}}", source)), - (Some(user), None) => Some(user), - (None, None) => None, - } -} - -fn value_type(value: &Value) -> &'static str { - match value { - Value::Null => "null", - Value::Bool(_) => "boolean", - Value::Number(number) if number.is_i64() || number.is_u64() => "integer", - Value::Number(_) => "number", - Value::String(_) => "string", - Value::Array(_) => "array", - Value::Object(_) => "object", - } -} - -fn format_types(types: &[String]) -> String { - types.join(" or ") -} - #[cfg(test)] #[path = "json_schema_tests.rs"] mod tests; diff --git a/crates/jp_llm/src/tool/json_schema_tests.rs b/crates/jp_llm/src/tool/json_schema_tests.rs index 6ff417510..7e1a5e153 100644 --- a/crates/jp_llm/src/tool/json_schema_tests.rs +++ b/crates/jp_llm/src/tool/json_schema_tests.rs @@ -1,5 +1,6 @@ use indexmap::IndexMap; use jp_config::conversation::tool::ToolParameterConfig; +use jp_tool::schema::validate; use serde_json::json; use super::*; @@ -16,7 +17,7 @@ fn configs(values: &[(&str, serde_json::Value)]) -> IndexMap) -> String { +fn error_of(result: Result) -> String { result.unwrap_err().to_string() } @@ -305,524 +306,3 @@ mod with_overrides { ); } } - -mod validate { - use super::*; - - fn validated(schema: &serde_json::Value) -> Result<(), ToolError> { - validate("tools.demo.parameters", schema) - } - - fn message_of(schema: &serde_json::Value) -> String { - validated(schema).unwrap_err().to_string() - } - - #[test] - fn an_array_must_declare_items() { - assert_eq!( - message_of(&json!({ - "type": "object", - "properties": { "tags": { "type": "array" } } - })), - "Invalid schema at `tools.demo.parameters.tags.items`: array schemas must declare an \ - item schema" - ); - } - - #[test] - fn items_require_an_array_type() { - assert_eq!( - message_of(&json!({ - "type": "object", - "properties": { "tags": { "type": "string", "items": { "type": "string" } } } - })), - "Invalid schema at `tools.demo.parameters.tags.items`: `items` requires an array \ - type, but the schema requires string" - ); - } - - #[test] - fn properties_require_an_object_type() { - assert_eq!( - message_of(&json!({ - "type": "object", - "properties": { - "target": { "type": "string", "properties": { "a": { "type": "string" } } } - } - })), - "Invalid schema at `tools.demo.parameters.target.properties`: `properties` requires \ - an object type, but the schema requires string" - ); - } - - #[test] - fn a_scalar_enum_on_an_array_points_at_items() { - assert_eq!( - message_of(&json!({ - "type": "object", - "properties": { - "tags": { - "type": "array", - "items": { "type": "string" }, - "enum": ["projects/jp"] - } - } - })), - "Invalid schema at `tools.demo.parameters.tags.enum`: enum value \"projects/jp\" has \ - type string, but the schema requires array; use \ - `tools.demo.parameters.tags.items.enum` to constrain array elements" - ); - } - - #[test] - fn enum_values_must_be_unique() { - assert_eq!( - message_of(&json!({ - "type": "object", - "properties": { "kind": { "type": "string", "enum": ["task", "task"] } } - })), - "Invalid schema at `tools.demo.parameters.kind.enum`: enum values must be unique; \ - duplicate value \"task\"" - ); - } - - #[test] - fn a_default_outside_the_enum_is_rejected() { - assert_eq!( - message_of(&json!({ - "type": "object", - "properties": { - "state": { "type": "string", "enum": ["open"], "default": "all" } - } - })), - "Invalid schema at `tools.demo.parameters.state.default`: default value \"all\" is \ - not allowed by the enum" - ); - } - - #[test] - fn a_default_must_match_the_item_schema() { - assert_eq!( - message_of(&json!({ - "type": "object", - "properties": { - "tags": { - "type": "array", - "items": { "type": "string" }, - "default": ["task", 1] - } - } - })), - "Invalid schema at `tools.demo.parameters.tags.default[1]`: default value 1 has type \ - integer, but the schema requires string" - ); - } - - #[test] - fn a_default_must_match_a_property_enum() { - assert_eq!( - message_of(&json!({ - "type": "object", - "properties": { - "target": { - "type": "object", - "properties": { "mode": { "type": "string", "enum": ["safe"] } }, - "default": { "mode": "fast" } - } - } - })), - "Invalid schema at `tools.demo.parameters.target.default.mode`: default value \ - \"fast\" is not allowed by the enum" - ); - } - - #[test] - fn an_unsupported_type_is_rejected() { - assert_eq!( - message_of(&json!({ - "type": "object", - "properties": { "name": { "type": "strng" } } - })), - "Invalid schema at `tools.demo.parameters.name.type`: unsupported JSON type `strng`" - ); - } - - #[test] - fn duplicate_types_are_rejected() { - assert_eq!( - message_of(&json!({ - "type": "object", - "properties": { "name": { "type": ["string", "string"] } } - })), - "Invalid schema at `tools.demo.parameters.name.type`: type values must be unique; \ - duplicate type `string`" - ); - } - - /// An unresolvable reference is not the same as an absent `type`: what the - /// node declares is unknown, not open, and forwarding a dangling pointer - /// gets the whole request rejected by the provider. - #[test] - fn a_node_without_a_usable_type_is_rejected() { - assert_eq!( - message_of(&json!({ - "type": "object", - "properties": { "thing": { "$ref": "https://example.com/schema.json#/Thing" } } - })), - "Invalid schema at `tools.demo.parameters.thing.type`: schema does not declare a \ - supported type" - ); - } - - /// A property with no `type` keyword is valid JSON Schema meaning "any - /// value", which is how a server declares a free-form parameter. - #[test] - fn a_property_with_no_type_is_unconstrained() { - assert!( - validated(&json!({ - "type": "object", - "properties": { - "key": { "type": "string" }, - "value": { "description": "Any JSON value." } - } - })) - .is_ok() - ); - } - - /// No declared type means no type for an enum value or a default to - /// contradict. - /// An `enum` still bounds the `default` it appears beside, which is why the - /// two are declared on separate properties here. - #[test] - fn an_unconstrained_property_accepts_any_enum_and_default() { - assert!( - validated(&json!({ - "type": "object", - "properties": { - "choice": { "enum": [1, "two", null, ["three"]] }, - "value": { "default": { "a": 1 } } - } - })) - .is_ok() - ); - } - - /// `items` and `properties` apply only when the instance is an array or an - /// object; neither needs a `type` to say so. - #[test] - fn an_unconstrained_property_may_carry_items_and_properties() { - assert!( - validated(&json!({ - "type": "object", - "properties": { - "list": { "items": { "type": "string" } }, - "target": { "properties": { "path": { "type": "string" } } } - } - })) - .is_ok() - ); - } - - /// A `properties` entry that is not a schema object declares nothing - /// usable. - /// JSON Schema's boolean form is legal, and `true` does mean "any value", - /// but no other keyword can be read from it, so it is rejected alongside - /// the shapes a schema-generation bug produces rather than forwarded to a - /// provider that will reject the whole request. - #[test] - fn a_property_that_is_not_a_schema_object_is_rejected() { - for value in [json!(null), json!("string"), json!(true), json!(false)] { - assert_eq!( - message_of(&json!({ - "type": "object", - "properties": { "value": value } - })), - "Invalid schema at `tools.demo.parameters.value.type`: schema does not declare a \ - supported type" - ); - } - } - - /// A `type` that is present but says nothing is malformed, not open. - #[test] - fn an_empty_type_list_is_rejected() { - assert_eq!( - message_of(&json!({ - "type": "object", - "properties": { "value": { "type": [] } } - })), - "Invalid schema at `tools.demo.parameters.value.type`: schema does not declare a \ - supported type" - ); - } - - /// Validation reads through references, so a constraint behind a `$ref` is - /// enforced exactly as an inline one would be. - #[test] - fn constraints_behind_a_reference_are_enforced() { - assert_eq!( - message_of(&json!({ - "type": "object", - "properties": { - "kind": { "$ref": "#/$defs/Kind", "default": "fast" } - }, - "$defs": { "Kind": { "type": "string", "enum": ["safe"] } } - })), - "Invalid schema at `tools.demo.parameters.kind.default`: default value \"fast\" is \ - not allowed by the enum" - ); - } - - /// A self-referential type is legal. - /// Providers that reject recursion say so themselves; validation must - /// terminate rather than expand forever. - #[test] - fn a_recursive_schema_is_accepted() { - assert!( - validated(&json!({ - "type": "object", - "properties": { "node": { "$ref": "#/$defs/Node" } }, - "$defs": { - "Node": { - "type": "object", - "properties": { - "value": { "type": "string" }, - "child": { "$ref": "#/$defs/Node" } - } - } - } - })) - .is_ok() - ); - } - - /// Mutually recursive definitions close the same loop through two pointers. - #[test] - fn mutually_recursive_definitions_are_accepted() { - assert!( - validated(&json!({ - "type": "object", - "properties": { "a": { "$ref": "#/$defs/A" } }, - "$defs": { - "A": { "type": "object", "properties": { "b": { "$ref": "#/$defs/B" } } }, - "B": { "type": "object", "properties": { "a": { "$ref": "#/$defs/A" } } } - } - })) - .is_ok() - ); - } -} - -mod has_unconstrained_node { - use super::*; - - #[test] - fn finds_a_free_form_property_behind_a_reference() { - assert!(has_unconstrained_node(&json!({ - "type": "object", - "properties": { "payload": { "$ref": "#/$defs/Payload" } }, - "$defs": { "Payload": { "description": "Any JSON value." } } - }))); - } - - #[test] - fn reports_a_fully_typed_document() { - assert!(!has_unconstrained_node(&json!({ - "type": "object", - "properties": { - "tags": { "type": "array", "items": { "type": "string" } } - } - }))); - } - - /// The walk closes the same loop validation does, rather than expanding a - /// self-referential type forever. - #[test] - fn a_recursive_schema_terminates() { - assert!(!has_unconstrained_node(&json!({ - "type": "object", - "properties": { "node": { "$ref": "#/$defs/Node" } }, - "$defs": { - "Node": { - "type": "object", - "properties": { "child": { "$ref": "#/$defs/Node" } } - } - } - }))); - } -} - -mod inline { - use super::*; - - #[test] - fn expands_references_and_drops_definitions() { - let expanded = inline(&json!({ - "type": "object", - "properties": { - "kinds": { "type": "array", "items": { "$ref": "#/$defs/EntryType" } } - }, - "$defs": { "EntryType": { "type": "string", "enum": ["Enum"] } } - })); - - assert_eq!( - expanded, - json!({ - "type": "object", - "properties": { - "kinds": { - "type": "array", - "items": { "type": "string", "enum": ["Enum"] } - } - } - }) - ); - } - - #[test] - fn sibling_keys_win_over_the_definition() { - let expanded = inline(&json!({ - "type": "object", - "properties": { - "mode": { "$ref": "#/$defs/Mode", "description": "from the parameter" } - }, - "$defs": { "Mode": { "type": "string", "description": "from defs" } } - })); - - assert_eq!( - expanded["properties"]["mode"], - json!({ "type": "string", "description": "from the parameter" }) - ); - } - - /// A recursive type has no finite expansion, so the innermost reference is - /// left as written rather than looping. - #[test] - fn a_recursive_reference_terminates() { - let expanded = inline(&json!({ - "type": "object", - "properties": { "node": { "$ref": "#/$defs/Node" } }, - "$defs": { - "Node": { - "type": "object", - "properties": { "child": { "$ref": "#/$defs/Node" } } - } - } - })); - - assert_eq!( - expanded["properties"]["node"], - json!({ - "type": "object", - "properties": { "child": { "$ref": "#/$defs/Node" } } - }) - ); - } - - #[test] - fn an_unresolvable_reference_is_left_in_place() { - let expanded = inline(&json!({ - "type": "object", - "properties": { "thing": { "$ref": "https://example.com/s.json#/Thing" } } - })); - - assert_eq!( - expanded["properties"]["thing"], - json!({ "$ref": "https://example.com/s.json#/Thing" }) - ); - } -} - -mod node { - use super::*; - - #[test] - fn reads_through_a_reference() { - let schema = json!({ - "type": "object", - "properties": { "kind": { "$ref": "#/$defs/Kind" } }, - "$defs": { "Kind": { "type": "string", "enum": ["a", "b"] } } - }); - - let root = Node::root(&schema); - let (_, kind) = root - .properties() - .into_iter() - .find(|(name, _)| name == "kind") - .expect("property"); - - assert_eq!(kind.types(), vec!["string".to_owned()]); - assert_eq!(kind.enumeration(), vec![json!("a"), json!("b")]); - assert!(kind.accepts_type(&json!("a"))); - assert!(!kind.accepts_type(&json!(1))); - } - - /// Sibling keys win over the referenced definition, per JSON Schema - /// 2020-12. - #[test] - fn sibling_keys_win_over_the_definition() { - let schema = json!({ - "type": "object", - "properties": { - "kind": { "$ref": "#/$defs/Kind", "description": "from the parameter" } - }, - "$defs": { "Kind": { "type": "string", "description": "from defs" } } - }); - - let root = Node::root(&schema); - let (_, kind) = root.properties().into_iter().next().expect("property"); - - assert_eq!(kind.origin(), Some("#/$defs/Kind")); - assert_eq!(kind.types(), vec!["string".to_owned()]); - } - - #[test] - fn a_node_without_a_type_accepts_every_value() { - let schema = json!({ - "type": "object", - "properties": { "value": { "description": "Any JSON value." } } - }); - - let root = Node::root(&schema); - let (_, value) = root.properties().into_iter().next().expect("property"); - - assert!(value.is_unconstrained()); - assert!(value.types().is_empty()); - assert!(value.accepts_type(&json!("a"))); - assert!(value.accepts_type(&json!(1))); - assert!(value.accepts_type(&json!(null))); - assert!(value.accepts_type(&json!({ "a": 1 }))); - } - - /// Leaving the type open leaves the `enum` in charge: every value is of an - /// acceptable type, and only the listed ones are permitted. - #[test] - fn an_enum_bounds_what_a_node_permits() { - let schema = json!({ - "type": "object", - "properties": { "value": { "enum": [3] } } - }); - - let root = Node::root(&schema); - let (_, value) = root.properties().into_iter().next().expect("property"); - - assert!(value.accepts_type(&json!("3"))); - assert!(!value.permits(&json!("3"))); - assert!(value.permits(&json!(3))); - } - - #[test] - fn reports_required_properties() { - let schema = json!({ - "type": "object", - "properties": { "a": { "type": "string" }, "b": { "type": "string" } }, - "required": ["a"] - }); - - let root = Node::root(&schema); - - assert!(root.is_required("a")); - assert!(!root.is_required("b")); - } -} diff --git a/crates/jp_llm/src/tool_tests.rs b/crates/jp_llm/src/tool_tests.rs index 5173063a8..38972e5df 100644 --- a/crates/jp_llm/src/tool_tests.rs +++ b/crates/jp_llm/src/tool_tests.rs @@ -4,7 +4,8 @@ use jp_config::{ conversation::tool::{PartialToolConfig, ToolConfig}, }; use jp_mcp::Client; -use jp_tool::Outcome; +use jp_tool::{Outcome, ToolDefinition, ToolDocs}; +use serde_json::Map; use super::*; @@ -263,112 +264,6 @@ async fn local_tool_rejects_scalar_enum_on_array_parameter() { ); } -#[test] -fn coerces_json_strings_to_declared_parameter_types() { - let parameters = schema([ - ("path", param("string"), true), - ("start_line", param("integer"), false), - ("enabled", param("boolean"), false), - ( - "string_or_integer", - json!({ "type": ["string", "integer"] }), - false, - ), - ( - "patterns", - json!({ - "type": "array", - "items": { - "type": "object", - "properties": { "count": { "type": "integer" } }, - "required": ["count"] - } - }), - false, - ), - ]); - let mut arguments = json!({ - "path": "README.md", - "start_line": "1", - "enabled": "true", - "string_or_integer": "3", - "patterns": "[{\"count\":\"2\"}]" - }) - .as_object() - .cloned() - .unwrap(); - - ToolDefinition { - name: "test".to_owned(), - docs: ToolDocs::default(), - parameters, - } - .coerce_arguments(&mut arguments); - - assert_eq!( - Value::Object(arguments), - json!({ - "path": "README.md", - "start_line": 1, - "enabled": true, - "string_or_integer": "3", - "patterns": [{"count": 2}] - }) - ); -} - -/// Coercion repairs a string the schema cannot accept. -/// A parameter that declares no type accepts the string as written, so a -/// JSON-looking string reaches the tool as the text the model sent. -#[test] -fn leaves_strings_alone_for_a_parameter_with_no_declared_type() { - let parameters = schema([("value", json!({ "description": "Any JSON value." }), false)]); - let mut arguments = json!({ "value": "3" }).as_object().cloned().unwrap(); - - ToolDefinition { - name: "test".to_owned(), - docs: ToolDocs::default(), - parameters, - } - .coerce_arguments(&mut arguments); - - assert_eq!(Value::Object(arguments), json!({ "value": "3" })); -} - -/// A property with an `enum` and no `type` still says what it takes: the string -/// the model sent is not a member, and the number it parses to is. -#[test] -fn coerces_a_string_the_enum_excludes_into_the_member_it_parses_to() { - let parameters = schema([("value", json!({ "enum": [3] }), false)]); - let mut arguments = json!({ "value": "3" }).as_object().cloned().unwrap(); - - ToolDefinition { - name: "test".to_owned(), - docs: ToolDocs::default(), - parameters, - } - .coerce_arguments(&mut arguments); - - assert_eq!(Value::Object(arguments), json!({ "value": 3 })); -} - -/// The mirror case: the enum lists the string itself, so parsing it would -/// produce the one value the schema forbids. -#[test] -fn leaves_a_string_alone_when_the_enum_lists_it() { - let parameters = schema([("value", json!({ "enum": ["3"] }), false)]); - let mut arguments = json!({ "value": "3" }).as_object().cloned().unwrap(); - - ToolDefinition { - name: "test".to_owned(), - docs: ToolDocs::default(), - parameters, - } - .coerce_arguments(&mut arguments); - - assert_eq!(Value::Object(arguments), json!({ "value": "3" })); -} - #[tokio::test] async fn execute_coerces_json_strings_before_calling_tool() { let partial: PartialToolConfig = serde_json::from_value(json!({ @@ -388,22 +283,22 @@ async fn execute_coerces_json_strings_before_calling_tool() { }; let builtins = builtin::BuiltinExecutors::new().register("echo_arguments", EchoArguments); - let outcome = definition - .execute( - "call_1".to_owned(), - json!({"start_line": "1"}), - &IndexMap::new(), - &config, - &Client::new(IndexMap::new()), - Utf8Path::new("/tmp"), - CancellationToken::new(), - &builtins, - None, - &InvocationContext::default(), - None, - ) - .await - .unwrap(); + let outcome = execute( + &definition, + "call_1".to_owned(), + json!({"start_line": "1"}), + &IndexMap::new(), + &config, + &Client::new(IndexMap::new()), + Utf8Path::new("/tmp"), + CancellationToken::new(), + &builtins, + None, + &InvocationContext::default(), + None, + ) + .await + .unwrap(); let ExecutionOutcome::Completed { id, result } = outcome else { panic!("expected completed tool call"); @@ -412,431 +307,6 @@ async fn execute_coerces_json_strings_before_calling_tool() { assert_eq!(result, Ok(r#"{"start_line":1}"#.to_owned())); } -#[test] -fn test_validate_tool_arguments() { - struct TestCase { - arguments: Map, - parameters: Value, - want: Result<(), ToolError>, - } - - let cases = vec![ - ("empty", TestCase { - arguments: Map::new(), - parameters: schema([]), - want: Ok(()), - }), - ("correct", TestCase { - arguments: Map::from_iter([("foo".to_owned(), json!("bar"))]), - parameters: schema([ - ("foo", param("string"), true), - ("bar", param("string"), false), - ]), - want: Ok(()), - }), - ("missing", TestCase { - arguments: Map::new(), - parameters: schema([("foo", param("string"), true)]), - want: Err(ToolError::Arguments { - missing: vec!["foo".to_owned()], - unknown: vec![], - }), - }), - ("unknown", TestCase { - arguments: Map::from_iter([("foo".to_owned(), json!("bar"))]), - parameters: schema([("bar", param("string"), false)]), - want: Err(ToolError::Arguments { - missing: vec![], - unknown: vec!["foo".to_owned()], - }), - }), - ("both", TestCase { - arguments: Map::from_iter([("foo".to_owned(), json!("bar"))]), - parameters: schema([("bar", param("string"), true)]), - want: Err(ToolError::Arguments { - missing: vec!["bar".to_owned()], - unknown: vec!["foo".to_owned()], - }), - }), - ]; - - for (name, test_case) in cases { - let result = validate_tool_arguments(&test_case.arguments, &test_case.parameters); - assert_eq!(result, test_case.want, "failed case: {name}"); - } -} - -#[test] -fn test_validate_nested_array_item_properties() { - // Mirrors the fs_modify_file schema: - // patterns: array of { old: string (required), new: string (required) } - let parameters = schema([ - ("path", param("string"), true), - ( - "patterns", - json!({ - "type": "array", - "items": { - "type": "object", - "properties": { - "old": { "type": "string" }, - "new": { "type": "string" } - }, - "required": ["old", "new"] - } - }), - true, - ), - ]); - - // Valid: correct inner fields. - let args = json!({ - "path": "src/lib.rs", - "patterns": [{"old": "foo", "new": "bar"}] - }); - assert_eq!( - validate_tool_arguments(args.as_object().unwrap(), ¶meters), - Ok(()) - ); - - // Valid: multiple items. - let args = json!({ - "path": "src/lib.rs", - "patterns": [ - {"old": "a", "new": "b"}, - {"old": "c", "new": "d"} - ] - }); - assert_eq!( - validate_tool_arguments(args.as_object().unwrap(), ¶meters), - Ok(()) - ); - - // Invalid: unknown inner field. - let args = json!({ - "path": "src/lib.rs", - "patterns": [{"old": "foo", "new": "bar", "extra": true}] - }); - assert_eq!( - validate_tool_arguments(args.as_object().unwrap(), ¶meters), - Err(ToolError::Arguments { - missing: vec![], - unknown: vec!["extra".to_owned()], - }) - ); - - // Invalid: missing required inner field. - let args = json!({ - "path": "src/lib.rs", - "patterns": [{"old": "foo"}] - }); - assert_eq!( - validate_tool_arguments(args.as_object().unwrap(), ¶meters), - Err(ToolError::Arguments { - missing: vec!["new".to_owned()], - unknown: vec![], - }) - ); - - // Invalid: wrong inner field names (the LLM hallucinated names). - let args = json!({ - "path": "src/lib.rs", - "patterns": [{"string_to_replace": "foo", "new_string": "bar"}] - }); - let err = validate_tool_arguments(args.as_object().unwrap(), ¶meters); - assert!(err.is_err()); - let ToolError::Arguments { missing, unknown } = err.unwrap_err() else { - panic!("expected Arguments error"); - }; - assert_eq!(missing, vec!["old".to_owned(), "new".to_owned()]); - // preserve_order: keys iterate in insertion order from json! macro - assert_eq!(unknown, vec![ - "string_to_replace".to_owned(), - "new_string".to_owned() - ]); - - // Valid: non-object array items are skipped (no crash). - let args = json!({ - "path": "src/lib.rs", - "patterns": ["not an object"] - }); - assert_eq!( - validate_tool_arguments(args.as_object().unwrap(), ¶meters), - Ok(()) - ); - - // Valid: parameter is not an array (type mismatch, but not our job to check types). - let args = json!({ - "path": "src/lib.rs", - "patterns": "not an array" - }); - assert_eq!( - validate_tool_arguments(args.as_object().unwrap(), ¶meters), - Ok(()) - ); -} - -#[test] -fn test_validate_nested_object_properties() { - let parameters = schema([ - ("name", param("string"), true), - ( - "config", - json!({ - "type": "object", - "properties": { - "verbose": { "type": "boolean" }, - "output": { "type": "string" } - }, - "required": ["output"] - }), - false, - ), - ]); - - // Valid. - let args = json!({ "name": "test", "config": { "verbose": true, "output": "out.txt" } }); - assert_eq!( - validate_tool_arguments(args.as_object().unwrap(), ¶meters), - Ok(()) - ); - - // Valid: optional object param omitted entirely. - let args = json!({ "name": "test" }); - assert_eq!( - validate_tool_arguments(args.as_object().unwrap(), ¶meters), - Ok(()) - ); - - // Invalid: unknown field inside the object. - let args = json!({ "name": "test", "config": { "output": "o", "bogus": 1 } }); - assert_eq!( - validate_tool_arguments(args.as_object().unwrap(), ¶meters), - Err(ToolError::Arguments { - missing: vec![], - unknown: vec!["bogus".to_owned()], - }) - ); - - // Invalid: missing required field inside the object. - let args = json!({ "name": "test", "config": { "verbose": true } }); - assert_eq!( - validate_tool_arguments(args.as_object().unwrap(), ¶meters), - Err(ToolError::Arguments { - missing: vec!["output".to_owned()], - unknown: vec![], - }) - ); -} - -/// A schema node of the given type, carrying a default value. -fn param_with_default(kind: &str, default: &Value) -> Value { - json!({ "type": kind, "default": default }) -} - -#[test] -fn test_apply_defaults_fills_missing_required_with_default() { - let parameters = schema([ - ("path", param("string"), true), - ( - "use_regex", - param_with_default("boolean", &json!(false)), - true, - ), - ]); - - let mut args: Map = Map::from_iter([("path".to_owned(), json!("src/lib.rs"))]); - - apply_parameter_defaults(&mut args, ¶meters); - - assert_eq!(args.get("path"), Some(&json!("src/lib.rs"))); - assert_eq!(args.get("use_regex"), Some(&json!(false))); -} - -#[test] -fn test_apply_defaults_does_not_overwrite_provided_values() { - let parameters = schema([( - "use_regex", - param_with_default("boolean", &json!(false)), - true, - )]); - - let mut args: Map = Map::from_iter([("use_regex".to_owned(), json!(true))]); - - apply_parameter_defaults(&mut args, ¶meters); - - assert_eq!(args.get("use_regex"), Some(&json!(true))); -} - -#[test] -fn test_apply_defaults_fills_optional_param_with_default() { - let parameters = schema([( - "verbose", - param_with_default("boolean", &json!(false)), - false, - )]); - - let mut args: Map = Map::new(); - apply_parameter_defaults(&mut args, ¶meters); - - assert_eq!(args.get("verbose"), Some(&json!(false))); -} - -#[test] -fn test_apply_defaults_skips_params_without_default() { - let parameters = schema([("path", param("string"), true)]); - - let mut args: Map = Map::new(); - apply_parameter_defaults(&mut args, ¶meters); - - assert!(!args.contains_key("path")); -} - -#[test] -fn test_apply_defaults_recurses_into_objects() { - let parameters = schema([( - "config", - json!({ - "type": "object", - "properties": { "verbose": { "type": "boolean", "default": true } } - }), - false, - )]); - - let mut args: Map = Map::from_iter([("config".to_owned(), json!({}))]); - - apply_parameter_defaults(&mut args, ¶meters); - - assert_eq!(args["config"]["verbose"], json!(true)); -} - -#[test] -fn test_apply_defaults_recurses_into_array_items() { - let parameters = schema([( - "items", - json!({ - "type": "array", - "items": { - "type": "object", - "properties": { "enabled": { "type": "boolean", "default": true } } - } - }), - true, - )]); - - let mut args: Map = Map::from_iter([( - "items".to_owned(), - json!([{"name": "a"}, {"name": "b", "enabled": false}]), - )]); - - apply_parameter_defaults(&mut args, ¶meters); - - let items = args["items"].as_array().unwrap(); - assert_eq!(items[0]["enabled"], json!(true)); - // Explicitly provided false is preserved. - assert_eq!(items[1]["enabled"], json!(false)); -} - -#[test] -fn test_apply_defaults_then_validate_passes() { - // Mirrors the fs_modify_file scenario: replace_using_regex is required - // with a default, and the LLM omits it. - let parameters = schema([ - ("path", param("string"), true), - ( - "replace_using_regex", - param_with_default("boolean", &json!(false)), - true, - ), - ]); - - let mut args: Map = Map::from_iter([("path".to_owned(), json!("README.md"))]); - - // Without defaults, validation would fail. - assert!(validate_tool_arguments(&args, ¶meters).is_err()); - - // After applying defaults, validation passes. - apply_parameter_defaults(&mut args, ¶meters); - assert!(validate_tool_arguments(&args, ¶meters).is_ok()); - assert_eq!(args["replace_using_regex"], json!(false)); -} - -#[test] -fn test_split_short_single_line() { - let (s, d) = split_description("Run cargo check."); - assert_eq!(s, "Run cargo check."); - assert_eq!(d, None); -} - -#[test] -fn test_split_short_no_period() { - let (s, d) = split_description("Run cargo check"); - assert_eq!(s, "Run cargo check"); - assert_eq!(d, None); -} - -#[test] -fn test_split_two_sentences() { - let (s, d) = split_description( - "Run cargo check on a package. Supports workspace packages and feature flags.", - ); - assert_eq!(s, "Run cargo check on a package."); - assert_eq!( - d, - Some("Supports workspace packages and feature flags.".to_owned()) - ); -} - -#[test] -fn test_split_multiline() { - let input = "Search for code in a repository.\n\nSupports regex and qualifiers."; - let (s, d) = split_description(input); - assert_eq!(s, "Search for code in a repository."); - assert_eq!(d, Some("Supports regex and qualifiers.".to_owned())); -} - -#[test] -fn test_split_multiline_no_period() { - let input = "First line without period\nSecond line here."; - let (s, d) = split_description(input); - assert_eq!(s, "First line without period"); - assert_eq!(d, Some("Second line here.".to_owned())); -} - -#[test] -fn test_split_preserves_abbreviations() { - // "e.g." should not be treated as a sentence boundary. - let (s, d) = split_description("Use e.g. foo or bar."); - assert_eq!(s, "Use e.g. foo or bar."); - assert_eq!(d, None); -} - -#[test] -fn test_split_long_single_line_with_period() { - let input = "This is a very long description that exceeds the threshold. It contains \ - additional details about the tool's behavior."; - let (s, d) = split_description(input); - assert_eq!( - s, - "This is a very long description that exceeds the threshold." - ); - assert!(d.is_some()); -} - -#[test] -fn test_split_empty() { - let (s, d) = split_description(""); - assert_eq!(s, ""); - assert_eq!(d, None); -} - -#[test] -fn test_split_trims_whitespace() { - let (s, d) = split_description(" hello "); - assert_eq!(s, "hello"); - assert_eq!(d, None); -} - /// Regression: `{{tool}}` must render as valid JSON, including `null` for null /// fields (not Jinja2's `none`). /// Originally fixed with `AutoEscape::Json`, now handled by the custom @@ -1070,22 +540,22 @@ async fn test_execute_local_exposes_invocation_ids_in_context() { let mcp_client = Client::new(IndexMap::new()); let builtins = builtin::BuiltinExecutors::new(); - let outcome = definition - .execute( - "call-1".to_owned(), - json!({}), - &IndexMap::new(), - &config, - &mcp_client, - Utf8Path::new("/tmp"), - CancellationToken::new(), - &builtins, - None, - &invocation, - None, - ) - .await - .expect("execution succeeds"); + let outcome = execute( + &definition, + "call-1".to_owned(), + json!({}), + &IndexMap::new(), + &config, + &mcp_client, + Utf8Path::new("/tmp"), + CancellationToken::new(), + &builtins, + None, + &invocation, + None, + ) + .await + .expect("execution succeeds"); match outcome { ExecutionOutcome::Completed { @@ -1137,22 +607,22 @@ async fn test_execute_builtin_dispatches_on_source_name() { let mcp_client = Client::new(IndexMap::new()); let builtins = builtin::BuiltinExecutors::new().register("describe_tools", ReachedBuiltin); - let outcome = definition - .execute( - "call-1".to_owned(), - json!({}), - &IndexMap::new(), - &config, - &mcp_client, - Utf8Path::new("/tmp"), - CancellationToken::new(), - &builtins, - None, - &InvocationContext::default(), - None, - ) - .await - .expect("execution succeeds"); + let outcome = execute( + &definition, + "call-1".to_owned(), + json!({}), + &IndexMap::new(), + &config, + &mcp_client, + Utf8Path::new("/tmp"), + CancellationToken::new(), + &builtins, + None, + &InvocationContext::default(), + None, + ) + .await + .expect("execution succeeds"); match outcome { ExecutionOutcome::Completed { diff --git a/crates/jp_llm/src/window.rs b/crates/jp_llm/src/window.rs index 014fcf80c..e36c92acf 100644 --- a/crates/jp_llm/src/window.rs +++ b/crates/jp_llm/src/window.rs @@ -14,10 +14,9 @@ use jp_attachment::Attachment; use jp_config::assistant::sections::SectionConfig; use jp_conversation::{ConversationEvent, ConversationStream, EventKind, event::ChatResponse}; +use jp_tool::ToolDefinition; use tracing::info; -use crate::tool::ToolDefinition; - /// Estimated chars-per-token ratio used for estimation. /// /// Measured against a real Anthropic request: a 4,220,150-byte serialized body diff --git a/crates/jp_llm/src/window_tests.rs b/crates/jp_llm/src/window_tests.rs index 82fccb528..f0a9f9b32 100644 --- a/crates/jp_llm/src/window_tests.rs +++ b/crates/jp_llm/src/window_tests.rs @@ -1,8 +1,8 @@ use jp_config::{PartialAppConfig, assistant::request::CachePolicy}; use jp_conversation::{Compaction, ConversationStream, SummaryPolicy, event::ChatResponse}; +use jp_tool::ToolDocs; use super::*; -use crate::tool::ToolDocs; fn tool(name: &str, summary: Option<&str>) -> ToolDefinition { ToolDefinition { diff --git a/crates/jp_tool/Cargo.toml b/crates/jp_tool/Cargo.toml index 972ef5a3b..b72f2c8ef 100644 --- a/crates/jp_tool/Cargo.toml +++ b/crates/jp_tool/Cargo.toml @@ -14,6 +14,7 @@ version.workspace = true [dependencies] camino = { workspace = true, features = ["serde1"] } +indexmap = { workspace = true } serde = { workspace = true, features = ["std", "derive"] } serde_json = { workspace = true, features = ["std", "preserve_order"] } thiserror = { workspace = true } diff --git a/crates/jp_tool/src/definition.rs b/crates/jp_tool/src/definition.rs new file mode 100644 index 000000000..d15391e41 --- /dev/null +++ b/crates/jp_tool/src/definition.rs @@ -0,0 +1,299 @@ +//! What a tool is called, what it accepts, and how it is described. +//! +//! A [`ToolDefinition`] is the resolved description of one tool, whatever its +//! source: a local command, a built-in implementation, or a tool a configured +//! MCP server declares. +//! Building one reads configuration, so that belongs with the configuration +//! types; this module holds the resolved shape and the argument handling that +//! reads its schema. + +use indexmap::IndexMap; +use serde_json::{Map, Value}; + +use crate::{Error, schema::Node}; + +/// Documentation for a single tool parameter. +#[derive(Debug, Clone)] +pub struct ParameterDocs { + pub summary: Option, + pub description: Option, + pub examples: Option, +} + +impl ParameterDocs { + #[must_use] + pub fn is_empty(&self) -> bool { + self.description.is_none() && self.examples.is_none() + } +} + +/// Documentation for a single tool. +#[derive(Debug, Clone, Default)] +pub struct ToolDocs { + pub summary: Option, + pub description: Option, + pub examples: Option, + pub parameters: IndexMap, +} + +impl ToolDocs { + #[must_use] + pub fn is_empty(&self) -> bool { + self.description.is_none() + && self.examples.is_none() + && self.parameters.values().all(ParameterDocs::is_empty) + } + + /// The short description used for the tool schema sent to the LLM. + /// + /// Returns `summary` if set, otherwise falls back to `description`. + #[must_use] + pub fn schema_description(&self) -> Option<&str> { + self.summary.as_deref().or(self.description.as_deref()) + } +} + +/// The definition of a tool. +#[derive(Debug, Clone)] +pub struct ToolDefinition { + pub name: String, + pub docs: ToolDocs, + + /// JSON Schema for the tool's arguments, as its source declared it, with + /// configuration overrides applied. + /// + /// Adapting this to what a given API accepts belongs to that provider. + pub parameters: Value, +} + +impl ToolDefinition { + /// Coerce JSON-encoded argument strings to non-string schema types. + /// + /// Strings stay unchanged when the schema accepts strings or their contents + /// do not parse to a declared type. + pub fn coerce_arguments(&self, arguments: &mut Map) { + coerce_arguments_to_schema(arguments, &self.parameters); + } + + /// Return the JSON Schema for the tool's parameters. + #[must_use] + pub fn to_parameters_schema(&self) -> Value { + self.parameters.clone() + } +} + +/// Split a description string into a short summary and remaining detail. +/// +/// If the text is short (single line, ≤120 chars), it is returned as the +/// summary with no remaining description. +/// +/// Otherwise, the first sentence is extracted as the summary. +/// A sentence ends at ` . ` or `.\n`. +/// The remainder becomes the description. +#[must_use] +pub fn split_description(text: &str) -> (String, Option) { + let text = text.trim(); + + // Find the first sentence boundary. + // Look for ". " or ".\n" — a period followed by whitespace. + for (i, _) in text.match_indices('.') { + let after = i + 1; + if after >= text.len() { + // Period at end of string — the whole text is one sentence. + break; + } + + let next_byte = text.as_bytes()[after]; + if next_byte == b'\n' { + // Period followed by newline is always a sentence boundary. + } else if next_byte == b' ' { + // Period followed by space: only split if the next non-space + // character is uppercase (heuristic to skip abbreviations + // like "e.g. foo"). + let rest_after_space = text[after..].trim_start(); + if rest_after_space.is_empty() + || !rest_after_space + .chars() + .next() + .is_some_and(char::is_uppercase) + { + continue; + } + } else { + continue; + } + + { + let summary = text[..=i].trim().to_owned(); + let rest = text[after..].trim(); + + if rest.is_empty() { + return (summary, None); + } + + return (summary, Some(rest.to_owned())); + } + } + + // No sentence boundary found — take the first line. + if let Some(nl) = text.find('\n') { + let summary = text[..nl].trim().to_owned(); + let rest = text[nl..].trim(); + + if rest.is_empty() { + return (summary, None); + } + + return (summary, Some(rest.to_owned())); + } + + // Single long line, no period — return as-is. + (text.to_owned(), None) +} + +/// Coerce JSON-encoded argument strings to the types the schema declares. +fn coerce_arguments_to_schema(arguments: &mut Map, schema: &Value) { + coerce_object(arguments, &Node::root(schema)); +} + +fn coerce_object(arguments: &mut Map, node: &Node<'_>) { + for (name, property) in node.properties() { + if let Some(value) = arguments.get_mut(&name) { + coerce_value(value, &property); + } + } +} + +fn coerce_value(value: &mut Value, node: &Node<'_>) { + // Coercion repairs an argument the schema cannot take as written. A + // parameter that permits the string has nothing to repair, so parsing it + // would hand the tool a number or an object where the model sent text. + if let Value::String(raw) = &*value + && !node.permits(value) + && let Ok(parsed) = serde_json::from_str::(raw) + && node.permits(&parsed) + { + *value = parsed; + } + + match value { + Value::Object(arguments) => coerce_object(arguments, node), + Value::Array(values) => { + let Some(items) = node.items() else { + return; + }; + for value in values { + coerce_value(value, &items); + } + } + _ => {} + } +} + +/// Fill in configured default values for missing parameters. +/// +/// LLMs commonly omit parameters that have a `default` in the JSON schema, even +/// when those parameters are marked `required`. +/// This function patches the arguments map before validation so that such +/// omissions don't cause spurious "missing argument" errors and unnecessary LLM +/// retries. +pub fn apply_parameter_defaults(arguments: &mut Map, schema: &Value) { + apply_defaults_to(arguments, &Node::root(schema)); +} + +fn apply_defaults_to(arguments: &mut Map, node: &Node<'_>) { + for (name, property) in node.properties() { + if !arguments.contains_key(&name) { + if let Some(default) = property.default() { + let default = default.clone(); + arguments.insert(name, default); + } + continue; + } + + // Recurse into object fields. + if property.has_properties() + && let Some(object) = arguments.get_mut(&name).and_then(Value::as_object_mut) + { + apply_defaults_to(object, &property); + } + + // Recurse into array elements. + if let Some(items) = property.items() + && items.has_properties() + && let Some(values) = arguments.get_mut(&name).and_then(Value::as_array_mut) + { + for value in values.iter_mut() { + if let Some(object) = value.as_object_mut() { + apply_defaults_to(object, &items); + } + } + } + } +} + +/// Check a call's arguments against the tool's parameters schema. +/// +/// # Errors +/// +/// Returns [`Error::Arguments`] naming every required argument that is absent +/// and every argument the schema does not declare. +pub fn validate_tool_arguments( + arguments: &Map, + schema: &Value, +) -> Result<(), Error> { + validate_arguments_against(arguments, &Node::root(schema)) +} + +fn validate_arguments_against( + arguments: &Map, + node: &Node<'_>, +) -> Result<(), Error> { + let properties = node.properties(); + + let unknown = arguments + .keys() + .filter(|name| !properties.iter().any(|(known, _)| known == *name)) + .cloned() + .collect::>(); + + let missing = properties + .iter() + .filter(|(name, _)| node.is_required(name) && !arguments.contains_key(name)) + .map(|(name, _)| name.clone()) + .collect::>(); + + if !missing.is_empty() || !unknown.is_empty() { + return Err(Error::Arguments { missing, unknown }); + } + + // Recurse into nested structures. + for (name, property) in properties { + let Some(value) = arguments.get(&name) else { + continue; + }; + + if let Some(object) = value.as_object() + && property.has_properties() + { + validate_arguments_against(object, &property)?; + } + + if let Some(items) = property.items() + && items.has_properties() + && let Some(values) = value.as_array() + { + for value in values { + if let Some(object) = value.as_object() { + validate_arguments_against(object, &items)?; + } + } + } + } + + Ok(()) +} + +#[cfg(test)] +#[path = "definition_tests.rs"] +mod tests; diff --git a/crates/jp_tool/src/definition_tests.rs b/crates/jp_tool/src/definition_tests.rs new file mode 100644 index 000000000..55adc0947 --- /dev/null +++ b/crates/jp_tool/src/definition_tests.rs @@ -0,0 +1,542 @@ +use serde_json::json; + +use super::*; + +/// Build a parameters schema from `(name, node, required)` triples. +fn schema(properties: [(&str, Value, bool); N]) -> Value { + let required = properties + .iter() + .filter(|(_, _, required)| *required) + .map(|(name, _, _)| Value::String((*name).to_owned())) + .collect::>(); + let properties = properties + .into_iter() + .map(|(name, node, _)| (name.to_owned(), node)) + .collect::>(); + + json!({ "type": "object", "properties": properties, "required": required }) +} + +/// A schema node of the given type. +fn param(kind: &str) -> Value { + json!({ "type": kind }) +} + +/// A schema node of the given type, carrying a default value. +fn param_with_default(kind: &str, default: &Value) -> Value { + json!({ "type": kind, "default": default }) +} + +fn definition(parameters: Value) -> ToolDefinition { + ToolDefinition { + name: "test".to_owned(), + docs: ToolDocs::default(), + parameters, + } +} + +#[test] +fn coerces_json_strings_to_declared_parameter_types() { + let parameters = schema([ + ("path", param("string"), true), + ("start_line", param("integer"), false), + ("enabled", param("boolean"), false), + ( + "string_or_integer", + json!({ "type": ["string", "integer"] }), + false, + ), + ( + "patterns", + json!({ + "type": "array", + "items": { + "type": "object", + "properties": { "count": { "type": "integer" } }, + "required": ["count"] + } + }), + false, + ), + ]); + let mut arguments = json!({ + "path": "README.md", + "start_line": "1", + "enabled": "true", + "string_or_integer": "3", + "patterns": "[{\"count\":\"2\"}]" + }) + .as_object() + .cloned() + .unwrap(); + + definition(parameters).coerce_arguments(&mut arguments); + + assert_eq!( + Value::Object(arguments), + json!({ + "path": "README.md", + "start_line": 1, + "enabled": true, + "string_or_integer": "3", + "patterns": [{"count": 2}] + }) + ); +} + +/// Coercion repairs a string the schema cannot accept. +/// A parameter that declares no type accepts the string as written, so a +/// JSON-looking string reaches the tool as the text the model sent. +#[test] +fn leaves_strings_alone_for_a_parameter_with_no_declared_type() { + let parameters = schema([("value", json!({ "description": "Any JSON value." }), false)]); + let mut arguments = json!({ "value": "3" }).as_object().cloned().unwrap(); + + definition(parameters).coerce_arguments(&mut arguments); + + assert_eq!(Value::Object(arguments), json!({ "value": "3" })); +} + +/// A property with an `enum` and no `type` still says what it takes: the string +/// the model sent is not a member, and the number it parses to is. +#[test] +fn coerces_a_string_the_enum_excludes_into_the_member_it_parses_to() { + let parameters = schema([("value", json!({ "enum": [3] }), false)]); + let mut arguments = json!({ "value": "3" }).as_object().cloned().unwrap(); + + definition(parameters).coerce_arguments(&mut arguments); + + assert_eq!(Value::Object(arguments), json!({ "value": 3 })); +} + +/// The mirror case: the enum lists the string itself, so parsing it would +/// produce the one value the schema forbids. +#[test] +fn leaves_a_string_alone_when_the_enum_lists_it() { + let parameters = schema([("value", json!({ "enum": ["3"] }), false)]); + let mut arguments = json!({ "value": "3" }).as_object().cloned().unwrap(); + + definition(parameters).coerce_arguments(&mut arguments); + + assert_eq!(Value::Object(arguments), json!({ "value": "3" })); +} + +#[test] +fn test_validate_tool_arguments() { + struct TestCase { + arguments: Map, + parameters: Value, + want: Result<(), Error>, + } + + let cases = vec![ + ("empty", TestCase { + arguments: Map::new(), + parameters: schema([]), + want: Ok(()), + }), + ("correct", TestCase { + arguments: Map::from_iter([("foo".to_owned(), json!("bar"))]), + parameters: schema([ + ("foo", param("string"), true), + ("bar", param("string"), false), + ]), + want: Ok(()), + }), + ("missing", TestCase { + arguments: Map::new(), + parameters: schema([("foo", param("string"), true)]), + want: Err(Error::Arguments { + missing: vec!["foo".to_owned()], + unknown: vec![], + }), + }), + ("unknown", TestCase { + arguments: Map::from_iter([("foo".to_owned(), json!("bar"))]), + parameters: schema([("bar", param("string"), false)]), + want: Err(Error::Arguments { + missing: vec![], + unknown: vec!["foo".to_owned()], + }), + }), + ("both", TestCase { + arguments: Map::from_iter([("foo".to_owned(), json!("bar"))]), + parameters: schema([("bar", param("string"), true)]), + want: Err(Error::Arguments { + missing: vec!["bar".to_owned()], + unknown: vec!["foo".to_owned()], + }), + }), + ]; + + for (name, test_case) in cases { + let result = validate_tool_arguments(&test_case.arguments, &test_case.parameters); + assert_eq!(result, test_case.want, "failed case: {name}"); + } +} + +#[test] +fn test_validate_nested_array_item_properties() { + // Mirrors the fs_modify_file schema: + // patterns: array of { old: string (required), new: string (required) } + let parameters = schema([ + ("path", param("string"), true), + ( + "patterns", + json!({ + "type": "array", + "items": { + "type": "object", + "properties": { + "old": { "type": "string" }, + "new": { "type": "string" } + }, + "required": ["old", "new"] + } + }), + true, + ), + ]); + + // Valid: correct inner fields. + let args = json!({ + "path": "src/lib.rs", + "patterns": [{"old": "foo", "new": "bar"}] + }); + assert_eq!( + validate_tool_arguments(args.as_object().unwrap(), ¶meters), + Ok(()) + ); + + // Valid: multiple items. + let args = json!({ + "path": "src/lib.rs", + "patterns": [ + {"old": "a", "new": "b"}, + {"old": "c", "new": "d"} + ] + }); + assert_eq!( + validate_tool_arguments(args.as_object().unwrap(), ¶meters), + Ok(()) + ); + + // Invalid: unknown inner field. + let args = json!({ + "path": "src/lib.rs", + "patterns": [{"old": "foo", "new": "bar", "extra": true}] + }); + assert_eq!( + validate_tool_arguments(args.as_object().unwrap(), ¶meters), + Err(Error::Arguments { + missing: vec![], + unknown: vec!["extra".to_owned()], + }) + ); + + // Invalid: missing required inner field. + let args = json!({ + "path": "src/lib.rs", + "patterns": [{"old": "foo"}] + }); + assert_eq!( + validate_tool_arguments(args.as_object().unwrap(), ¶meters), + Err(Error::Arguments { + missing: vec!["new".to_owned()], + unknown: vec![], + }) + ); + + // Invalid: wrong inner field names (the LLM hallucinated names). + let args = json!({ + "path": "src/lib.rs", + "patterns": [{"string_to_replace": "foo", "new_string": "bar"}] + }); + let err = validate_tool_arguments(args.as_object().unwrap(), ¶meters); + assert!(err.is_err()); + let Error::Arguments { missing, unknown } = err.unwrap_err() else { + panic!("expected Arguments error"); + }; + assert_eq!(missing, vec!["old".to_owned(), "new".to_owned()]); + // preserve_order: keys iterate in insertion order from json! macro + assert_eq!(unknown, vec![ + "string_to_replace".to_owned(), + "new_string".to_owned() + ]); + + // Valid: non-object array items are skipped (no crash). + let args = json!({ + "path": "src/lib.rs", + "patterns": ["not an object"] + }); + assert_eq!( + validate_tool_arguments(args.as_object().unwrap(), ¶meters), + Ok(()) + ); + + // Valid: parameter is not an array (type mismatch, but not our job to check types). + let args = json!({ + "path": "src/lib.rs", + "patterns": "not an array" + }); + assert_eq!( + validate_tool_arguments(args.as_object().unwrap(), ¶meters), + Ok(()) + ); +} + +#[test] +fn test_validate_nested_object_properties() { + let parameters = schema([ + ("name", param("string"), true), + ( + "config", + json!({ + "type": "object", + "properties": { + "verbose": { "type": "boolean" }, + "output": { "type": "string" } + }, + "required": ["output"] + }), + false, + ), + ]); + + // Valid. + let args = json!({ "name": "test", "config": { "verbose": true, "output": "out.txt" } }); + assert_eq!( + validate_tool_arguments(args.as_object().unwrap(), ¶meters), + Ok(()) + ); + + // Valid: optional object param omitted entirely. + let args = json!({ "name": "test" }); + assert_eq!( + validate_tool_arguments(args.as_object().unwrap(), ¶meters), + Ok(()) + ); + + // Invalid: unknown field inside the object. + let args = json!({ "name": "test", "config": { "output": "o", "bogus": 1 } }); + assert_eq!( + validate_tool_arguments(args.as_object().unwrap(), ¶meters), + Err(Error::Arguments { + missing: vec![], + unknown: vec!["bogus".to_owned()], + }) + ); + + // Invalid: missing required field inside the object. + let args = json!({ "name": "test", "config": { "verbose": true } }); + assert_eq!( + validate_tool_arguments(args.as_object().unwrap(), ¶meters), + Err(Error::Arguments { + missing: vec!["output".to_owned()], + unknown: vec![], + }) + ); +} + +#[test] +fn test_apply_defaults_fills_missing_required_with_default() { + let parameters = schema([ + ("path", param("string"), true), + ( + "use_regex", + param_with_default("boolean", &json!(false)), + true, + ), + ]); + + let mut args: Map = Map::from_iter([("path".to_owned(), json!("src/lib.rs"))]); + + apply_parameter_defaults(&mut args, ¶meters); + + assert_eq!(args.get("path"), Some(&json!("src/lib.rs"))); + assert_eq!(args.get("use_regex"), Some(&json!(false))); +} + +#[test] +fn test_apply_defaults_does_not_overwrite_provided_values() { + let parameters = schema([( + "use_regex", + param_with_default("boolean", &json!(false)), + true, + )]); + + let mut args: Map = Map::from_iter([("use_regex".to_owned(), json!(true))]); + + apply_parameter_defaults(&mut args, ¶meters); + + assert_eq!(args.get("use_regex"), Some(&json!(true))); +} + +#[test] +fn test_apply_defaults_fills_optional_param_with_default() { + let parameters = schema([( + "verbose", + param_with_default("boolean", &json!(false)), + false, + )]); + + let mut args: Map = Map::new(); + apply_parameter_defaults(&mut args, ¶meters); + + assert_eq!(args.get("verbose"), Some(&json!(false))); +} + +#[test] +fn test_apply_defaults_skips_params_without_default() { + let parameters = schema([("path", param("string"), true)]); + + let mut args: Map = Map::new(); + apply_parameter_defaults(&mut args, ¶meters); + + assert!(!args.contains_key("path")); +} + +#[test] +fn test_apply_defaults_recurses_into_objects() { + let parameters = schema([( + "config", + json!({ + "type": "object", + "properties": { "verbose": { "type": "boolean", "default": true } } + }), + false, + )]); + + let mut args: Map = Map::from_iter([("config".to_owned(), json!({}))]); + + apply_parameter_defaults(&mut args, ¶meters); + + assert_eq!(args["config"]["verbose"], json!(true)); +} + +#[test] +fn test_apply_defaults_recurses_into_array_items() { + let parameters = schema([( + "items", + json!({ + "type": "array", + "items": { + "type": "object", + "properties": { "enabled": { "type": "boolean", "default": true } } + } + }), + true, + )]); + + let mut args: Map = Map::from_iter([( + "items".to_owned(), + json!([{"name": "a"}, {"name": "b", "enabled": false}]), + )]); + + apply_parameter_defaults(&mut args, ¶meters); + + let items = args["items"].as_array().unwrap(); + assert_eq!(items[0]["enabled"], json!(true)); + // Explicitly provided false is preserved. + assert_eq!(items[1]["enabled"], json!(false)); +} + +#[test] +fn test_apply_defaults_then_validate_passes() { + // Mirrors the fs_modify_file scenario: replace_using_regex is required + // with a default, and the LLM omits it. + let parameters = schema([ + ("path", param("string"), true), + ( + "replace_using_regex", + param_with_default("boolean", &json!(false)), + true, + ), + ]); + + let mut args: Map = Map::from_iter([("path".to_owned(), json!("README.md"))]); + + // Without defaults, validation would fail. + assert!(validate_tool_arguments(&args, ¶meters).is_err()); + + // After applying defaults, validation passes. + apply_parameter_defaults(&mut args, ¶meters); + assert!(validate_tool_arguments(&args, ¶meters).is_ok()); + assert_eq!(args["replace_using_regex"], json!(false)); +} + +#[test] +fn test_split_short_single_line() { + let (s, d) = split_description("Run cargo check."); + assert_eq!(s, "Run cargo check."); + assert_eq!(d, None); +} + +#[test] +fn test_split_short_no_period() { + let (s, d) = split_description("Run cargo check"); + assert_eq!(s, "Run cargo check"); + assert_eq!(d, None); +} + +#[test] +fn test_split_two_sentences() { + let (s, d) = split_description( + "Run cargo check on a package. Supports workspace packages and feature flags.", + ); + assert_eq!(s, "Run cargo check on a package."); + assert_eq!( + d, + Some("Supports workspace packages and feature flags.".to_owned()) + ); +} + +#[test] +fn test_split_multiline() { + let input = "Search for code in a repository.\n\nSupports regex and qualifiers."; + let (s, d) = split_description(input); + assert_eq!(s, "Search for code in a repository."); + assert_eq!(d, Some("Supports regex and qualifiers.".to_owned())); +} + +#[test] +fn test_split_multiline_no_period() { + let input = "First line without period\nSecond line here."; + let (s, d) = split_description(input); + assert_eq!(s, "First line without period"); + assert_eq!(d, Some("Second line here.".to_owned())); +} + +#[test] +fn test_split_preserves_abbreviations() { + // "e.g." should not be treated as a sentence boundary. + let (s, d) = split_description("Use e.g. foo or bar."); + assert_eq!(s, "Use e.g. foo or bar."); + assert_eq!(d, None); +} + +#[test] +fn test_split_long_single_line_with_period() { + let input = "This is a very long description that exceeds the threshold. It contains \ + additional details about the tool's behavior."; + let (s, d) = split_description(input); + assert_eq!( + s, + "This is a very long description that exceeds the threshold." + ); + assert!(d.is_some()); +} + +#[test] +fn test_split_empty() { + let (s, d) = split_description(""); + assert_eq!(s, ""); + assert_eq!(d, None); +} + +#[test] +fn test_split_trims_whitespace() { + let (s, d) = split_description(" hello "); + assert_eq!(s, "hello"); + assert_eq!(d, None); +} diff --git a/crates/jp_tool/src/error.rs b/crates/jp_tool/src/error.rs new file mode 100644 index 000000000..0c0e48cf1 --- /dev/null +++ b/crates/jp_tool/src/error.rs @@ -0,0 +1,67 @@ +/// A failure in the tool domain: resolving a tool, reading its parameter +/// schema, checking the arguments a call carries, or running the tool itself. +/// +/// A tool that ran and reported a problem of its own is not an `Error`: that is +/// [`Outcome::Error`], which the caller hands back to the model. +/// +/// [`Outcome::Error`]: crate::Outcome::Error +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("Tool not found: {name}")] + NotFound { name: String }, + + #[error("Tools not found: {}", names.join(", "))] + NotFoundN { names: Vec }, + + #[error("Command missing for local tool")] + MissingCommand, + + /// Wraps the MCP client's own error, which this crate does not name so it + /// stays independent of the MCP implementation. + #[error("Failed to fetch tool from MCP client")] + McpGetToolError(#[source] Box), + + /// Wraps the MCP client's own error, which this crate does not name so it + /// stays independent of the MCP implementation. + #[error("Failed to run tool from MCP client")] + McpRunToolError(#[source] Box), + + #[error("Failed to spawn command: {command}")] + SpawnError { + command: String, + #[source] + error: std::io::Error, + }, + + /// `data` is the template that failed to render, kept for the diagnostic. + #[error("Template error")] + TemplateError { + data: String, + #[source] + error: Box, + }, + + #[error("Invalid schema at `{path}`: {message}")] + InvalidSchema { path: String, message: String }, + + #[error("Invalid arguments (missing: {missing:?}, unknown: {unknown:?})")] + Arguments { + /// Required arguments that were missing. + missing: Vec, + + /// Unknown arguments that were provided. + unknown: Vec, + }, +} + +#[cfg(test)] +impl PartialEq for Error { + fn eq(&self, other: &Self) -> bool { + if std::mem::discriminant(self) != std::mem::discriminant(other) { + return false; + } + + // Good enough for testing purposes + format!("{self:?}") == format!("{other:?}") + } +} diff --git a/crates/jp_tool/src/lib.rs b/crates/jp_tool/src/lib.rs index 6a884b293..112eeba08 100644 --- a/crates/jp_tool/src/lib.rs +++ b/crates/jp_tool/src/lib.rs @@ -5,10 +5,16 @@ use serde::{Deserialize, Deserializer, Serialize}; use serde_json::Value; mod access; +pub mod definition; +mod error; +pub mod schema; + pub use access::{ AccessPolicy, Capability, EnvRule, FsAccessError, FsRule, NetRule, canonicalize_workspace_target, lexical_workspace_relative, }; +pub use definition::{ParameterDocs, ToolDefinition, ToolDocs}; +pub use error::Error; /// The result of a tool call. #[derive(Debug, PartialEq, Serialize, Deserialize)] diff --git a/crates/jp_tool/src/schema.rs b/crates/jp_tool/src/schema.rs new file mode 100644 index 000000000..cd7d3a7bc --- /dev/null +++ b/crates/jp_tool/src/schema.rs @@ -0,0 +1,616 @@ +//! Reading and validating a tool's parameter schema. +//! +//! A tool's parameters are one JSON Schema object, held exactly as its source +//! declared it. +//! For an MCP tool that is the server's `inputSchema` with the user's +//! configured overrides applied; for a local or built-in tool it is generated +//! from configuration. +//! Nothing else rewrites it: adapting a schema to what a given API accepts is +//! the responsibility of that provider. +//! +//! [`Node`] is the read-only view used by argument handling and validation. +//! It follows same-document `$ref` pointers while reading, so a referenced enum +//! or nested object answers questions the same way an inline one does. +//! +//! Building a schema from configuration lives with the configuration types; +//! this module only reads and checks one that already exists. + +use std::borrow::Cow; + +use serde_json::{Map, Value}; + +use crate::Error; + +/// JSON types a tool parameter may declare. +const SUPPORTED_TYPES: &[&str] = &[ + "array", "boolean", "integer", "null", "number", "object", "string", +]; + +/// Bound on `$ref` expansion while reading, so a self-referential schema +/// terminates. +const MAX_REF_HOPS: usize = 32; + +/// Validate a tool's parameters schema. +/// +/// Rejects the shapes that no provider can act on, and the ones that contradict +/// themselves: unusable types, arrays with no item schema, `items` or +/// `properties` on a type that cannot carry them, duplicate or ill-typed enum +/// values, and defaults the schema itself forbids. +/// +/// # Errors +/// +/// Returns [`Error::InvalidSchema`], naming the offending path. +pub fn validate(path: &str, schema: &Value) -> Result<(), Error> { + let root = Node::root(schema); + for (name, property) in root.properties() { + validate_node(&format!("{path}.{name}"), &property, &mut vec![])?; + } + + Ok(()) +} + +/// Validate one node, tracking which definitions the walk is already inside. +/// +/// A recursive schema is legal, and providers that reject it say so themselves. +/// Re-entering a definition already on the path adds nothing, so the walk stops +/// there instead of expanding forever. +fn validate_node(path: &str, node: &Node<'_>, visiting: &mut Vec) -> Result<(), Error> { + if let Some(origin) = node.origin() { + if visiting.iter().any(|seen| seen == origin) { + return Ok(()); + } + visiting.push(origin.to_owned()); + } + + let result = validate_node_inner(path, node, visiting); + + if node.origin().is_some() { + visiting.pop(); + } + + result +} + +fn validate_node_inner( + path: &str, + node: &Node<'_>, + visiting: &mut Vec, +) -> Result<(), Error> { + let types = node.types(); + // A schema with no `type` keyword accepts any value, and constrains + // nothing that could contradict its `items`, `properties`, `enum` or + // `default`. A `type` that is present but empty or unrecognised, and a + // `$ref` that could not be followed, remain malformed. + let unconstrained = node.is_unconstrained(); + if !unconstrained { + validate_types(path, &types)?; + } + + let items = node.items(); + if types.iter().any(|type_| type_ == "array") && items.is_none() { + return Err(Error::InvalidSchema { + path: format!("{path}.items"), + message: "array schemas must declare an item schema".to_owned(), + }); + } + + if let Some(items) = &items { + if !unconstrained && !types.iter().any(|type_| type_ == "array") { + return Err(Error::InvalidSchema { + path: format!("{path}.items"), + message: format!( + "`items` requires an array type, but the schema requires {}", + format_types(&types) + ), + }); + } + validate_node(&format!("{path}.items"), items, visiting)?; + } + + let properties = node.properties(); + if !unconstrained && !properties.is_empty() && !types.iter().any(|type_| type_ == "object") { + return Err(Error::InvalidSchema { + path: format!("{path}.properties"), + message: format!( + "`properties` requires an object type, but the schema requires {}", + format_types(&types) + ), + }); + } + for (name, property) in properties { + validate_node(&format!("{path}.properties.{name}"), &property, visiting)?; + } + + let enumeration = node.enumeration(); + for (index, value) in enumeration.iter().enumerate() { + if enumeration[..index].contains(value) { + return Err(Error::InvalidSchema { + path: format!("{path}.enum"), + message: format!("enum values must be unique; duplicate value {value}"), + }); + } + + if node.accepts_type(value) { + validate_value(&format!("{path}.enum[{index}]"), value, node, "enum value")?; + continue; + } + + let hint = if types.iter().any(|type_| type_ == "array") && !value.is_array() { + format!("; use `{path}.items.enum` to constrain array elements") + } else { + String::new() + }; + return Err(Error::InvalidSchema { + path: format!("{path}.enum"), + message: format!( + "enum value {value} has type {}, but the schema requires {}{hint}", + value_type(value), + format_types(&types), + ), + }); + } + + if let Some(default) = node.default() { + validate_value(&format!("{path}.default"), default, node, "default value")?; + } + + Ok(()) +} + +/// Validate a schema-declared value against the node it appears in. +/// +/// Applies the node's type and `enum`, then recurses into array elements and +/// object properties so nested constraints are enforced at every depth. +/// `subject` names what is being checked (`default value`, `enum value`) for +/// the error message. +fn validate_value(path: &str, value: &Value, node: &Node<'_>, subject: &str) -> Result<(), Error> { + if !node.accepts_type(value) { + return Err(Error::InvalidSchema { + path: path.to_owned(), + message: format!( + "{subject} {value} has type {}, but the schema requires {}", + value_type(value), + format_types(&node.types()) + ), + }); + } + + let enumeration = node.enumeration(); + if !enumeration.is_empty() && !enumeration.contains(value) { + return Err(Error::InvalidSchema { + path: path.to_owned(), + message: format!("{subject} {value} is not allowed by the enum"), + }); + } + + if let (Value::Array(values), Some(items)) = (value, node.items()) { + for (index, value) in values.iter().enumerate() { + validate_value(&format!("{path}[{index}]"), value, &items, subject)?; + } + } + + if let Value::Object(values) = value { + for (name, property) in node.properties() { + let Some(value) = values.get(&name) else { + if node.is_required(&name) { + return Err(Error::InvalidSchema { + path: format!("{path}.{name}"), + message: format!("{subject} is missing required property `{name}`"), + }); + } + continue; + }; + validate_value(&format!("{path}.{name}"), value, &property, subject)?; + } + } + + Ok(()) +} + +/// Check that a type declaration names usable, non-repeating JSON types. +/// +/// # Errors +/// +/// Returns [`Error::InvalidSchema`] for an empty, unsupported, or duplicated +/// type. +pub fn validate_types(path: &str, types: &[String]) -> Result<(), Error> { + if types.is_empty() { + return Err(Error::InvalidSchema { + path: format!("{path}.type"), + message: "schema does not declare a supported type".to_owned(), + }); + } + + for (index, type_) in types.iter().enumerate() { + if !SUPPORTED_TYPES.contains(&type_.as_str()) { + return Err(Error::InvalidSchema { + path: format!("{path}.type"), + message: format!("unsupported JSON type `{type_}`"), + }); + } + if types[..index].contains(type_) { + return Err(Error::InvalidSchema { + path: format!("{path}.type"), + message: format!("type values must be unique; duplicate type `{type_}`"), + }); + } + } + + Ok(()) +} + +/// Whether any node in the document leaves the JSON type of its value open. +/// +/// Walks properties and array items, reading through `$ref` the way [`Node`] +/// does, and stops at a definition already on the path so a recursive schema +/// terminates. +#[must_use] +pub fn has_unconstrained_node(schema: &Value) -> bool { + Node::root(schema) + .properties() + .iter() + .any(|(_, property)| is_open(property, &mut vec![])) +} + +fn is_open(node: &Node<'_>, visiting: &mut Vec) -> bool { + if let Some(origin) = node.origin() { + if visiting.iter().any(|seen| seen == origin) { + return false; + } + visiting.push(origin.to_owned()); + } + + let open = node.is_unconstrained() + || node.items().is_some_and(|items| is_open(&items, visiting)) + || node + .properties() + .iter() + .any(|(_, property)| is_open(property, visiting)); + + if node.origin().is_some() { + visiting.pop(); + } + + open +} + +/// Expand every same-document `$ref` and drop the definitions block. +/// +/// For providers that cannot follow references. +/// A reference that cannot be resolved, or one that revisits a definition +/// already being expanded, is left in place: a recursive type has no finite +/// expansion, and dropping the node would be worse than forwarding something +/// the API can reject. +#[must_use] +pub fn inline(schema: &Value) -> Value { + let mut inlined = inline_node(schema, schema, &mut vec![]); + if let Some(object) = inlined.as_object_mut() { + object.remove("$defs"); + object.remove("definitions"); + } + + inlined +} + +fn inline_node(node: &Value, root: &Value, expanding: &mut Vec) -> Value { + let pointer = pointer_of(node); + if let Some(pointer) = &pointer { + if expanding.contains(pointer) { + return node.clone(); + } + expanding.push(pointer.clone()); + } + + let resolved = resolve(node, root); + let expanded = match resolved.as_object() { + Some(object) => Value::Object( + object + .iter() + .map(|(key, value)| { + let value = match value { + Value::Object(_) => inline_node(value, root, expanding), + Value::Array(values) => Value::Array( + values + .iter() + .map(|value| inline_node(value, root, expanding)) + .collect(), + ), + other => other.clone(), + }; + (key.clone(), value) + }) + .collect(), + ), + None => resolved.into_owned(), + }; + + if pointer.is_some() { + expanding.pop(); + } + + expanded +} + +/// A read-only view of one schema node, resolving `$ref` as it reads. +#[derive(Debug, Clone)] +pub struct Node<'a> { + root: &'a Value, + node: Cow<'a, Value>, + origin: Option, +} + +impl<'a> Node<'a> { + /// View a whole parameters schema, where `$ref` pointers resolve against + /// the same document. + #[must_use] + pub fn root(schema: &'a Value) -> Self { + Self { + root: schema, + node: resolve(schema, schema), + origin: pointer_of(schema), + } + } + + /// The `$ref` pointer this node was reached through, when it was one. + #[must_use] + pub fn origin(&self) -> Option<&str> { + self.origin.as_deref() + } + + /// View a nested node, resolving it against the same document. + /// + /// The node is cloned because resolving a `$ref` produces a new value that + /// cannot borrow from the parent. + #[must_use] + pub fn child(&self, node: &Value) -> Node<'a> { + Node { + root: self.root, + node: Cow::Owned(resolve(node, self.root).into_owned()), + origin: pointer_of(node), + } + } + + /// JSON types this node accepts. + #[must_use] + pub fn types(&self) -> Vec { + match self.node.get("type") { + Some(Value::String(type_)) => vec![type_.clone()], + Some(Value::Array(types)) => types + .iter() + .filter_map(Value::as_str) + .map(str::to_owned) + .collect(), + _ => vec![], + } + } + + /// Whether this node leaves the JSON type of its value open. + /// + /// A schema object with no `type` keyword accepts any value, which is how a + /// server declares a free-form parameter. + /// Anything else that reads as declaring no type is not open, because what + /// it declares is unknown rather than unrestricted: a `$ref` that could not + /// be followed, a boolean schema, or a non-schema value such as a `null` + /// left in a `properties` map. + #[must_use] + pub fn is_unconstrained(&self) -> bool { + self.node.is_object() && self.node.get("type").is_none() && self.node.get("$ref").is_none() + } + + /// Whether a value satisfies this node's declared types. + /// + /// Ignores every other constraint the node carries; [`permits`] applies + /// those too. + /// A node that declares no type accepts every value. + /// + /// [`permits`]: Self::permits + #[must_use] + pub fn accepts_type(&self, value: &Value) -> bool { + if self.is_unconstrained() { + return true; + } + + let types = self.types(); + let has = |type_: &str| types.iter().any(|candidate| candidate == type_); + + match value { + Value::Null => has("null"), + Value::Bool(_) => has("boolean"), + Value::Number(number) => { + has("number") || (has("integer") && (number.is_i64() || number.is_u64())) + } + Value::String(_) => has("string"), + Value::Array(_) => has("array"), + Value::Object(_) => has("object"), + } + } + + /// Whether a value satisfies every constraint this node declares. + /// + /// A value must match the declared types, and appear in the `enum` when + /// there is one. + #[must_use] + pub fn permits(&self, value: &Value) -> bool { + if !self.accepts_type(value) { + return false; + } + + let enumeration = self.enumeration(); + enumeration.is_empty() || enumeration.contains(value) + } + + /// The value inserted when the argument is omitted. + #[must_use] + pub fn default(&self) -> Option<&Value> { + // The borrow has to come from the node itself, which `Cow` owns when a + // `$ref` was inlined, so match rather than returning through the Cow. + match &self.node { + Cow::Borrowed(node) => node.get("default"), + Cow::Owned(node) => node.get("default"), + } + } + + /// Values this node accepts, empty when unconstrained. + #[must_use] + pub fn enumeration(&self) -> Vec { + self.node + .get("enum") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default() + } + + /// The schema applied to each array element. + #[must_use] + pub fn items(&self) -> Option> { + self.node.get("items").map(|items| self.child(items)) + } + + /// The schemas for this node's object properties, in declaration order. + #[must_use] + pub fn properties(&self) -> Vec<(String, Node<'a>)> { + self.node + .get("properties") + .and_then(Value::as_object) + .map(|properties| { + properties + .iter() + .map(|(name, node)| (name.clone(), self.child(node))) + .collect() + }) + .unwrap_or_default() + } + + /// Whether this node lists `name` among its required properties. + #[must_use] + pub fn is_required(&self, name: &str) -> bool { + required_names(&self.node).contains(&name) + } + + /// The description sent to the model for this node. + #[must_use] + pub fn description(&self) -> Option<&str> { + match &self.node { + Cow::Borrowed(node) => node.get("description"), + Cow::Owned(node) => node.get("description"), + } + .and_then(Value::as_str) + } + + /// Whether this node declares any property. + #[must_use] + pub fn has_properties(&self) -> bool { + self.node + .get("properties") + .and_then(Value::as_object) + .is_some_and(|properties| !properties.is_empty()) + } +} + +impl PartialEq for Node<'_> { + fn eq(&self, other: &Self) -> bool { + self.node == other.node + } +} + +/// Follow same-document `$ref` pointers, merging sibling keys over the target. +/// +/// Sibling keys win, per JSON Schema 2020-12. +/// A pointer that leaves the document or revisits one already followed is left +/// in place, so reading degrades to "this node declares nothing" rather than +/// looping. +fn resolve<'a>(node: &'a Value, root: &Value) -> Cow<'a, Value> { + let mut current = Cow::Borrowed(node); + let mut seen: Vec = vec![]; + + while let Some(pointer) = current.get("$ref").and_then(Value::as_str) { + let pointer = pointer.to_owned(); + if seen.len() >= MAX_REF_HOPS || seen.contains(&pointer) { + break; + } + + let Some(target) = follow_pointer(&pointer, root) else { + break; + }; + + let mut merged = target; + for (key, value) in current.as_object().into_iter().flatten() { + if key != "$ref" { + merged.insert(key.clone(), value.clone()); + } + } + + seen.push(pointer); + current = Cow::Owned(Value::Object(merged)); + } + + current +} + +fn pointer_of(node: &Value) -> Option { + node.get("$ref").and_then(Value::as_str).map(str::to_owned) +} + +/// Look up a same-document JSON pointer, such as `#/$defs/EntryType`. +fn follow_pointer(pointer: &str, root: &Value) -> Option> { + if pointer == "#" { + return root.as_object().cloned(); + } + + let mut current = root; + for segment in pointer.strip_prefix("#/")?.split('/') { + current = current.get(segment.replace("~1", "/").replace("~0", "~"))?; + } + + current.as_object().cloned() +} + +/// The property names a schema object lists as required. +#[must_use] +pub fn required_names(schema: &Value) -> Vec<&str> { + schema + .get("required") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .collect() +} + +/// Merge a user-provided description with the one the source declared. +/// +/// A user description containing `{{description}}` has the source's text +/// substituted in; otherwise the user's text wins outright. +/// With no user description the source's is kept as-is. +#[must_use] +pub fn merge_description(user: Option, source: Option<&str>) -> Option { + match (user, source) { + (None, Some(source)) => Some(source.to_owned()), + // TODO: should use `minijinja` instead of raw string replacement. + (Some(user), Some(source)) => Some(user.replace("{{description}}", source)), + (Some(user), None) => Some(user), + (None, None) => None, + } +} + +fn value_type(value: &Value) -> &'static str { + match value { + Value::Null => "null", + Value::Bool(_) => "boolean", + Value::Number(number) if number.is_i64() || number.is_u64() => "integer", + Value::Number(_) => "number", + Value::String(_) => "string", + Value::Array(_) => "array", + Value::Object(_) => "object", + } +} + +/// Render a type declaration for a diagnostic, as `string or null`. +#[must_use] +pub fn format_types(types: &[String]) -> String { + types.join(" or ") +} + +#[cfg(test)] +#[path = "schema_tests.rs"] +mod tests; diff --git a/crates/jp_tool/src/schema_tests.rs b/crates/jp_tool/src/schema_tests.rs new file mode 100644 index 000000000..639441ae9 --- /dev/null +++ b/crates/jp_tool/src/schema_tests.rs @@ -0,0 +1,524 @@ +use serde_json::json; + +use super::*; + +mod validate { + use super::*; + + fn validated(schema: &serde_json::Value) -> Result<(), Error> { + validate("tools.demo.parameters", schema) + } + + fn message_of(schema: &serde_json::Value) -> String { + validated(schema).unwrap_err().to_string() + } + + #[test] + fn an_array_must_declare_items() { + assert_eq!( + message_of(&json!({ + "type": "object", + "properties": { "tags": { "type": "array" } } + })), + "Invalid schema at `tools.demo.parameters.tags.items`: array schemas must declare an \ + item schema" + ); + } + + #[test] + fn items_require_an_array_type() { + assert_eq!( + message_of(&json!({ + "type": "object", + "properties": { "tags": { "type": "string", "items": { "type": "string" } } } + })), + "Invalid schema at `tools.demo.parameters.tags.items`: `items` requires an array \ + type, but the schema requires string" + ); + } + + #[test] + fn properties_require_an_object_type() { + assert_eq!( + message_of(&json!({ + "type": "object", + "properties": { + "target": { "type": "string", "properties": { "a": { "type": "string" } } } + } + })), + "Invalid schema at `tools.demo.parameters.target.properties`: `properties` requires \ + an object type, but the schema requires string" + ); + } + + #[test] + fn a_scalar_enum_on_an_array_points_at_items() { + assert_eq!( + message_of(&json!({ + "type": "object", + "properties": { + "tags": { + "type": "array", + "items": { "type": "string" }, + "enum": ["projects/jp"] + } + } + })), + "Invalid schema at `tools.demo.parameters.tags.enum`: enum value \"projects/jp\" has \ + type string, but the schema requires array; use \ + `tools.demo.parameters.tags.items.enum` to constrain array elements" + ); + } + + #[test] + fn enum_values_must_be_unique() { + assert_eq!( + message_of(&json!({ + "type": "object", + "properties": { "kind": { "type": "string", "enum": ["task", "task"] } } + })), + "Invalid schema at `tools.demo.parameters.kind.enum`: enum values must be unique; \ + duplicate value \"task\"" + ); + } + + #[test] + fn a_default_outside_the_enum_is_rejected() { + assert_eq!( + message_of(&json!({ + "type": "object", + "properties": { + "state": { "type": "string", "enum": ["open"], "default": "all" } + } + })), + "Invalid schema at `tools.demo.parameters.state.default`: default value \"all\" is \ + not allowed by the enum" + ); + } + + #[test] + fn a_default_must_match_the_item_schema() { + assert_eq!( + message_of(&json!({ + "type": "object", + "properties": { + "tags": { + "type": "array", + "items": { "type": "string" }, + "default": ["task", 1] + } + } + })), + "Invalid schema at `tools.demo.parameters.tags.default[1]`: default value 1 has type \ + integer, but the schema requires string" + ); + } + + #[test] + fn a_default_must_match_a_property_enum() { + assert_eq!( + message_of(&json!({ + "type": "object", + "properties": { + "target": { + "type": "object", + "properties": { "mode": { "type": "string", "enum": ["safe"] } }, + "default": { "mode": "fast" } + } + } + })), + "Invalid schema at `tools.demo.parameters.target.default.mode`: default value \ + \"fast\" is not allowed by the enum" + ); + } + + #[test] + fn an_unsupported_type_is_rejected() { + assert_eq!( + message_of(&json!({ + "type": "object", + "properties": { "name": { "type": "strng" } } + })), + "Invalid schema at `tools.demo.parameters.name.type`: unsupported JSON type `strng`" + ); + } + + #[test] + fn duplicate_types_are_rejected() { + assert_eq!( + message_of(&json!({ + "type": "object", + "properties": { "name": { "type": ["string", "string"] } } + })), + "Invalid schema at `tools.demo.parameters.name.type`: type values must be unique; \ + duplicate type `string`" + ); + } + + /// An unresolvable reference is not the same as an absent `type`: what the + /// node declares is unknown, not open, and forwarding a dangling pointer + /// gets the whole request rejected by the provider. + #[test] + fn a_node_without_a_usable_type_is_rejected() { + assert_eq!( + message_of(&json!({ + "type": "object", + "properties": { "thing": { "$ref": "https://example.com/schema.json#/Thing" } } + })), + "Invalid schema at `tools.demo.parameters.thing.type`: schema does not declare a \ + supported type" + ); + } + + /// A property with no `type` keyword is valid JSON Schema meaning "any + /// value", which is how a server declares a free-form parameter. + #[test] + fn a_property_with_no_type_is_unconstrained() { + assert!( + validated(&json!({ + "type": "object", + "properties": { + "key": { "type": "string" }, + "value": { "description": "Any JSON value." } + } + })) + .is_ok() + ); + } + + /// No declared type means no type for an enum value or a default to + /// contradict. + /// An `enum` still bounds the `default` it appears beside, which is why the + /// two are declared on separate properties here. + #[test] + fn an_unconstrained_property_accepts_any_enum_and_default() { + assert!( + validated(&json!({ + "type": "object", + "properties": { + "choice": { "enum": [1, "two", null, ["three"]] }, + "value": { "default": { "a": 1 } } + } + })) + .is_ok() + ); + } + + /// `items` and `properties` apply only when the instance is an array or an + /// object; neither needs a `type` to say so. + #[test] + fn an_unconstrained_property_may_carry_items_and_properties() { + assert!( + validated(&json!({ + "type": "object", + "properties": { + "list": { "items": { "type": "string" } }, + "target": { "properties": { "path": { "type": "string" } } } + } + })) + .is_ok() + ); + } + + /// A `properties` entry that is not a schema object declares nothing + /// usable. + /// JSON Schema's boolean form is legal, and `true` does mean "any value", + /// but no other keyword can be read from it, so it is rejected alongside + /// the shapes a schema-generation bug produces rather than forwarded to a + /// provider that will reject the whole request. + #[test] + fn a_property_that_is_not_a_schema_object_is_rejected() { + for value in [json!(null), json!("string"), json!(true), json!(false)] { + assert_eq!( + message_of(&json!({ + "type": "object", + "properties": { "value": value } + })), + "Invalid schema at `tools.demo.parameters.value.type`: schema does not declare a \ + supported type" + ); + } + } + + /// A `type` that is present but says nothing is malformed, not open. + #[test] + fn an_empty_type_list_is_rejected() { + assert_eq!( + message_of(&json!({ + "type": "object", + "properties": { "value": { "type": [] } } + })), + "Invalid schema at `tools.demo.parameters.value.type`: schema does not declare a \ + supported type" + ); + } + + /// Validation reads through references, so a constraint behind a `$ref` is + /// enforced exactly as an inline one would be. + #[test] + fn constraints_behind_a_reference_are_enforced() { + assert_eq!( + message_of(&json!({ + "type": "object", + "properties": { + "kind": { "$ref": "#/$defs/Kind", "default": "fast" } + }, + "$defs": { "Kind": { "type": "string", "enum": ["safe"] } } + })), + "Invalid schema at `tools.demo.parameters.kind.default`: default value \"fast\" is \ + not allowed by the enum" + ); + } + + /// A self-referential type is legal. + /// Providers that reject recursion say so themselves; validation must + /// terminate rather than expand forever. + #[test] + fn a_recursive_schema_is_accepted() { + assert!( + validated(&json!({ + "type": "object", + "properties": { "node": { "$ref": "#/$defs/Node" } }, + "$defs": { + "Node": { + "type": "object", + "properties": { + "value": { "type": "string" }, + "child": { "$ref": "#/$defs/Node" } + } + } + } + })) + .is_ok() + ); + } + + /// Mutually recursive definitions close the same loop through two pointers. + #[test] + fn mutually_recursive_definitions_are_accepted() { + assert!( + validated(&json!({ + "type": "object", + "properties": { "a": { "$ref": "#/$defs/A" } }, + "$defs": { + "A": { "type": "object", "properties": { "b": { "$ref": "#/$defs/B" } } }, + "B": { "type": "object", "properties": { "a": { "$ref": "#/$defs/A" } } } + } + })) + .is_ok() + ); + } +} + +mod has_unconstrained_node { + use super::*; + + #[test] + fn finds_a_free_form_property_behind_a_reference() { + assert!(has_unconstrained_node(&json!({ + "type": "object", + "properties": { "payload": { "$ref": "#/$defs/Payload" } }, + "$defs": { "Payload": { "description": "Any JSON value." } } + }))); + } + + #[test] + fn reports_a_fully_typed_document() { + assert!(!has_unconstrained_node(&json!({ + "type": "object", + "properties": { + "tags": { "type": "array", "items": { "type": "string" } } + } + }))); + } + + /// The walk closes the same loop validation does, rather than expanding a + /// self-referential type forever. + #[test] + fn a_recursive_schema_terminates() { + assert!(!has_unconstrained_node(&json!({ + "type": "object", + "properties": { "node": { "$ref": "#/$defs/Node" } }, + "$defs": { + "Node": { + "type": "object", + "properties": { "child": { "$ref": "#/$defs/Node" } } + } + } + }))); + } +} + +mod inline { + use super::*; + + #[test] + fn expands_references_and_drops_definitions() { + let expanded = inline(&json!({ + "type": "object", + "properties": { + "kinds": { "type": "array", "items": { "$ref": "#/$defs/EntryType" } } + }, + "$defs": { "EntryType": { "type": "string", "enum": ["Enum"] } } + })); + + assert_eq!( + expanded, + json!({ + "type": "object", + "properties": { + "kinds": { + "type": "array", + "items": { "type": "string", "enum": ["Enum"] } + } + } + }) + ); + } + + #[test] + fn sibling_keys_win_over_the_definition() { + let expanded = inline(&json!({ + "type": "object", + "properties": { + "mode": { "$ref": "#/$defs/Mode", "description": "from the parameter" } + }, + "$defs": { "Mode": { "type": "string", "description": "from defs" } } + })); + + assert_eq!( + expanded["properties"]["mode"], + json!({ "type": "string", "description": "from the parameter" }) + ); + } + + /// A recursive type has no finite expansion, so the innermost reference is + /// left as written rather than looping. + #[test] + fn a_recursive_reference_terminates() { + let expanded = inline(&json!({ + "type": "object", + "properties": { "node": { "$ref": "#/$defs/Node" } }, + "$defs": { + "Node": { + "type": "object", + "properties": { "child": { "$ref": "#/$defs/Node" } } + } + } + })); + + assert_eq!( + expanded["properties"]["node"], + json!({ + "type": "object", + "properties": { "child": { "$ref": "#/$defs/Node" } } + }) + ); + } + + #[test] + fn an_unresolvable_reference_is_left_in_place() { + let expanded = inline(&json!({ + "type": "object", + "properties": { "thing": { "$ref": "https://example.com/s.json#/Thing" } } + })); + + assert_eq!( + expanded["properties"]["thing"], + json!({ "$ref": "https://example.com/s.json#/Thing" }) + ); + } +} + +mod node { + use super::*; + + #[test] + fn reads_through_a_reference() { + let schema = json!({ + "type": "object", + "properties": { "kind": { "$ref": "#/$defs/Kind" } }, + "$defs": { "Kind": { "type": "string", "enum": ["a", "b"] } } + }); + + let root = Node::root(&schema); + let (_, kind) = root + .properties() + .into_iter() + .find(|(name, _)| name == "kind") + .expect("property"); + + assert_eq!(kind.types(), vec!["string".to_owned()]); + assert_eq!(kind.enumeration(), vec![json!("a"), json!("b")]); + assert!(kind.accepts_type(&json!("a"))); + assert!(!kind.accepts_type(&json!(1))); + } + + /// Sibling keys win over the referenced definition, per JSON Schema + /// 2020-12. + #[test] + fn sibling_keys_win_over_the_definition() { + let schema = json!({ + "type": "object", + "properties": { + "kind": { "$ref": "#/$defs/Kind", "description": "from the parameter" } + }, + "$defs": { "Kind": { "type": "string", "description": "from defs" } } + }); + + let root = Node::root(&schema); + let (_, kind) = root.properties().into_iter().next().expect("property"); + + assert_eq!(kind.origin(), Some("#/$defs/Kind")); + assert_eq!(kind.types(), vec!["string".to_owned()]); + } + + #[test] + fn a_node_without_a_type_accepts_every_value() { + let schema = json!({ + "type": "object", + "properties": { "value": { "description": "Any JSON value." } } + }); + + let root = Node::root(&schema); + let (_, value) = root.properties().into_iter().next().expect("property"); + + assert!(value.is_unconstrained()); + assert!(value.types().is_empty()); + assert!(value.accepts_type(&json!("a"))); + assert!(value.accepts_type(&json!(1))); + assert!(value.accepts_type(&json!(null))); + assert!(value.accepts_type(&json!({ "a": 1 }))); + } + + /// Leaving the type open leaves the `enum` in charge: every value is of an + /// acceptable type, and only the listed ones are permitted. + #[test] + fn an_enum_bounds_what_a_node_permits() { + let schema = json!({ + "type": "object", + "properties": { "value": { "enum": [3] } } + }); + + let root = Node::root(&schema); + let (_, value) = root.properties().into_iter().next().expect("property"); + + assert!(value.accepts_type(&json!("3"))); + assert!(!value.permits(&json!("3"))); + assert!(value.permits(&json!(3))); + } + + #[test] + fn reports_required_properties() { + let schema = json!({ + "type": "object", + "properties": { "a": { "type": "string" }, "b": { "type": "string" } }, + "required": ["a"] + }); + + let root = Node::root(&schema); + + assert!(root.is_required("a")); + assert!(!root.is_required("b")); + } +} From 8acb6d25d20a854fa8af40dd86b0534247031ac1 Mon Sep 17 00:00:00 2001 From: Jean Mertz Date: Sat, 12 Sep 2026 10:37:05 +0200 Subject: [PATCH 03/29] feat(tool): Add the shared tool result and input-request types `jp_tool::content` introduces the representation a tool result takes at JP's internal boundaries: an ordered `Vec` of text, resources, and input requests, alongside whether the tool failed and the transience and trace it reported. The shape follows MCP's, so a result arriving from an MCP server crosses into JP without being flattened to a string first, and one JP assembles can be handed back out. Fields MCP defines and JP does not act on (annotations, resource titles and descriptions) are carried as data rather than dropped. `ToolResult::to_text` flattens the whole thing for a caller that wants the string a provider receives today. An input request describes its answer with JSON Schema rather than JP's closed `AnswerType`, which is what lets an MCP elicitation request and a local tool's question become the same thing. Secrecy stays a typed field instead of a schema keyword: a consumer that rewrites the schema for a provider must not be able to drop the rule that the answer stays off disk. `From` converts results from tools speaking the existing protocol. Nothing calls these yet. They are the contracts [RFD 109]'s execution service is built against, introduced first so that phase moves execution rather than also inventing the shape it speaks. Existing tools, stored conversations, and rendering are untouched; the wider typed-content migration remains [RFD 058]'s. [RFD 058]: docs/rfd/058-typed-content-blocks-for-tool-responses.md [RFD 109]: docs/rfd/109-in-process-jp-mcp-server.md Signed-off-by: Jean Mertz --- crates/jp_tool/src/content.rs | 341 ++++++++++++++++++++++++++++ crates/jp_tool/src/content_tests.rs | 169 ++++++++++++++ crates/jp_tool/src/lib.rs | 2 + 3 files changed, 512 insertions(+) create mode 100644 crates/jp_tool/src/content.rs create mode 100644 crates/jp_tool/src/content_tests.rs diff --git a/crates/jp_tool/src/content.rs b/crates/jp_tool/src/content.rs new file mode 100644 index 000000000..c89a43074 --- /dev/null +++ b/crates/jp_tool/src/content.rs @@ -0,0 +1,341 @@ +//! What a tool execution attempt produced, and what it is asking for. +//! +//! [`ToolResult`] is the ordered content one attempt returned, plus whether it +//! failed. +//! A [`ContentBlock`] is one piece of that content: text, a resource, or a +//! request for input. +//! +//! The shape follows MCP's, so a result that arrives from an MCP server carries +//! across without being flattened on the way in, and one JP assembles itself +//! can be handed back out. +//! Fields MCP defines and JP does not act on are carried as data rather than +//! dropped. +//! +//! Tools speaking the [`Outcome`] protocol are converted at the boundary; see +//! the `From` implementations below. + +use serde_json::{Map, Value, json}; + +use crate::{AnswerType, Outcome, Question, QuestionId}; + +/// What one tool execution attempt produced. +/// +/// An attempt that ends by asking for input is not a failure: its content +/// carries a [`ContentBlock::Question`], and the caller runs the tool again +/// once it has the answer. +#[derive(Debug, Clone, PartialEq)] +pub struct ToolResult { + /// The blocks the tool produced, in the order it produced them. + pub content: Vec, + + /// Whether the tool reported a failure. + pub is_error: bool, + + /// Extra detail about a failure, when the tool supplied it. + /// + /// Always `None` when `is_error` is `false`. + pub error: Option, +} + +impl ToolResult { + /// A successful result carrying one text block. + #[must_use] + pub fn text(text: impl Into) -> Self { + Self { + content: vec![ContentBlock::text(text)], + is_error: false, + error: None, + } + } + + /// A failed result carrying one text block and no further detail. + #[must_use] + pub fn error(text: impl Into) -> Self { + Self { + content: vec![ContentBlock::text(text)], + is_error: true, + error: Some(ErrorDetails::default()), + } + } + + /// The first input request in the content, when the tool is asking for one. + #[must_use] + pub fn input_request(&self) -> Option<&InputRequest> { + self.content.iter().find_map(|block| match block { + ContentBlock::Question(request) => Some(request), + ContentBlock::Text { .. } | ContentBlock::Resource(_) => None, + }) + } + + /// Flatten the content to the text a provider receives. + /// + /// Text blocks and the text side of resources are joined with a blank line, + /// in content order; a binary resource contributes its URI, since its bytes + /// are not text. + /// An error's trace is appended after the message. + /// + /// Callers that render blocks themselves should read [`content`] instead. + /// + /// [`content`]: Self::content + #[must_use] + pub fn to_text(&self) -> String { + let mut out = self + .content + .iter() + .filter_map(ContentBlock::as_text) + .collect::>() + .join("\n\n"); + + let trace = self + .error + .as_ref() + .map(|error| error.trace.as_slice()) + .unwrap_or_default(); + + if !trace.is_empty() { + out.push_str(&format!("\n\nTrace:\n{}", trace.join("\n"))); + } + + out + } +} + +/// Detail a tool attached to a failure. +/// +/// Arrives as `_meta["computer.jp/error"]` on an MCP-shaped result. +/// A failure without it is non-transient with no trace. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct ErrorDetails { + /// Whether running the tool again could succeed. + pub transient: bool, + + /// The error's source chain, outermost first. + pub trace: Vec, +} + +/// One piece of a tool's output. +#[derive(Debug, Clone, PartialEq)] +pub enum ContentBlock { + /// Text for the model to read. + Text { + text: String, + + /// The format of the text, when the tool declared one. + /// + /// MCP tools never set it; `None` means plain text. + mime_type: Option, + + /// MCP annotations, carried but not acted on. + annotations: Option, + }, + + /// A resource the tool produced or read. + Resource(Resource), + + /// Input the tool needs before it can finish. + Question(InputRequest), +} + +impl ContentBlock { + /// A plain text block. + #[must_use] + pub fn text(text: impl Into) -> Self { + Self::Text { + text: text.into(), + mime_type: None, + annotations: None, + } + } + + /// The block's text, for a caller assembling a plain-text result. + /// + /// A resource contributes its text content, or its URI when the content is + /// binary. + /// A question contributes nothing: it is answered, not read. + #[must_use] + pub fn as_text(&self) -> Option<&str> { + match self { + Self::Text { text, .. } => Some(text), + Self::Resource(resource) => match &resource.content { + ResourceContent::Text(text) => Some(text), + ResourceContent::Blob(_) => Some(&resource.uri), + }, + Self::Question(_) => None, + } + } +} + +/// A resource, identified by URI and carrying its content. +/// +/// The first four fields are MCP's; the rest are JP's, and an MCP-sourced +/// resource leaves them empty. +#[derive(Debug, Clone, PartialEq)] +pub struct Resource { + /// The URI identifying this resource. + pub uri: String, + + /// The resource's content. + pub content: ResourceContent, + + /// The content's media type, such as `text/rust` or `image/png`. + pub mime_type: Option, + + /// MCP annotations, carried but not acted on. + pub annotations: Option, + + /// A short name for the resource. + pub name: Option, + + /// A human-readable title, falling back to `name` and then `uri`. + pub title: Option, + + /// What the resource is. + pub description: Option, + + /// Content already formatted for the model. + /// + /// When set, it is what the model sees; `content` remains the resource's + /// actual bytes. + pub formatted: Option, +} + +impl Resource { + /// A text resource with no metadata beyond its URI. + #[must_use] + pub fn text(uri: impl Into, text: impl Into) -> Self { + Self { + uri: uri.into(), + content: ResourceContent::Text(text.into()), + mime_type: None, + annotations: None, + name: None, + title: None, + description: None, + formatted: None, + } + } +} + +/// A resource's content, matching MCP's text-or-blob model. +#[derive(Debug, Clone, PartialEq)] +pub enum ResourceContent { + /// UTF-8 text. + Text(String), + + /// Bytes, such as an image or a PDF. + Blob(Vec), +} + +/// MCP annotations on a block or resource. +/// +/// Carried so a result that arrives with them can be handed back out intact. +/// Nothing in JP reads them. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct Annotations { + /// Who the content is meant for. + pub audience: Vec, + + /// How important the content is, from `0.0` to `1.0`. + pub priority: Option, + + /// When the content last changed, as an ISO 8601 timestamp. + pub last_modified: Option, +} + +/// A party in the conversation, as MCP names them. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Role { + User, + Assistant, +} + +/// Input a tool needs before it can finish. +/// +/// Who answers is the host's decision, not the tool's: the same request can be +/// put to the user, answered from configuration, or sent to an assistant. +#[derive(Debug, Clone, PartialEq)] +pub struct InputRequest { + /// Identifies the request, and keys the answer on re-execution. + pub id: QuestionId, + + /// A one-line prompt shown beside the input. + /// + /// Supporting material belongs in the content blocks preceding this one. + pub label: String, + + /// JSON Schema the answer must satisfy. + pub schema: Map, + + /// The answer used when none is given. + pub default: Option, + + /// Whether the answer must not be written to disk. + /// + /// A secret answer is not echoed while it is typed, and the recorded + /// inquiry response holds a redaction marker rather than the answer. + pub secret: bool, +} + +impl From for InputRequest { + fn from(question: Question) -> Self { + let Question { + id, + text, + pre_amble: _, + answer_type, + default, + } = question; + + Self { + id, + label: text, + secret: matches!(answer_type, AnswerType::Secret), + schema: answer_type.to_schema(), + default, + } + } +} + +impl AnswerType { + /// The JSON Schema an answer of this type must satisfy. + /// + /// A secret answer is a string like any other; that it must not be + /// persisted is carried by [`InputRequest::secret`], not by the schema, so + /// the rule cannot be lost by rewriting the schema. + #[must_use] + pub fn to_schema(&self) -> Map { + let schema = match self { + Self::Boolean => json!({ "type": "boolean" }), + Self::Select { options } => json!({ "type": "string", "enum": options }), + Self::Text | Self::Secret => json!({ "type": "string" }), + }; + + schema.as_object().cloned().unwrap_or_default() + } +} + +impl From for ToolResult { + fn from(outcome: Outcome) -> Self { + match outcome { + Outcome::Success { content } => Self::text(content), + Outcome::Error { + message, + trace, + transient, + } => Self { + content: vec![ContentBlock::text(message)], + is_error: true, + error: Some(ErrorDetails { transient, trace }), + }, + Outcome::NeedsInput { question } => Self { + content: vec![ContentBlock::Question(question.into())], + is_error: false, + error: None, + }, + } + } +} + +#[cfg(test)] +#[path = "content_tests.rs"] +mod tests; diff --git a/crates/jp_tool/src/content_tests.rs b/crates/jp_tool/src/content_tests.rs new file mode 100644 index 000000000..cf4a7e56c --- /dev/null +++ b/crates/jp_tool/src/content_tests.rs @@ -0,0 +1,169 @@ +use serde_json::json; + +use super::*; + +fn question(id: &str, answer_type: AnswerType) -> Question { + Question { + id: id.parse().unwrap(), + text: "Which branch?".to_owned(), + pre_amble: Some("A preamble the request does not carry.".to_owned()), + answer_type, + default: Some(json!("main")), + } +} + +#[test] +fn a_successful_outcome_becomes_one_text_block() { + let result = ToolResult::from(Outcome::Success { + content: "done".to_owned(), + }); + + assert_eq!(result, ToolResult { + content: vec![ContentBlock::text("done")], + is_error: false, + error: None, + }); +} + +#[test] +fn a_failed_outcome_keeps_its_trace_and_transience() { + let result = ToolResult::from(Outcome::Error { + message: "File not found: foo.rs".to_owned(), + trace: vec!["io error: No such file or directory".to_owned()], + transient: true, + }); + + assert_eq!(result, ToolResult { + content: vec![ContentBlock::text("File not found: foo.rs")], + is_error: true, + error: Some(ErrorDetails { + transient: true, + trace: vec!["io error: No such file or directory".to_owned()], + }), + }); +} + +/// A tool that stops to ask something has not failed: the caller answers and +/// runs it again. +#[test] +fn a_needs_input_outcome_is_not_an_error() { + let result = ToolResult::from(Outcome::NeedsInput { + question: question("target", AnswerType::Text), + }); + + assert!(!result.is_error); + assert_eq!(result.error, None); + assert_eq!( + result.input_request().map(|r| r.id.as_str()), + Some("target") + ); +} + +#[test] +fn a_select_question_becomes_an_enum_schema() { + let request = InputRequest::from(question("branch", AnswerType::Select { + options: vec!["main".to_owned(), "develop".to_owned()], + })); + + assert_eq!(request, InputRequest { + id: "branch".parse().unwrap(), + label: "Which branch?".to_owned(), + schema: json!({ "type": "string", "enum": ["main", "develop"] }) + .as_object() + .cloned() + .unwrap(), + default: Some(json!("main")), + secret: false, + }); +} + +#[test] +fn a_boolean_question_becomes_a_boolean_schema() { + let request = InputRequest::from(question("proceed", AnswerType::Boolean)); + + assert_eq!( + request.schema, + json!({ "type": "boolean" }).as_object().cloned().unwrap() + ); +} + +/// Secrecy is a typed field, not a schema keyword: a consumer that rewrites the +/// schema for a provider cannot drop the rule that the answer stays off disk. +#[test] +fn a_secret_question_is_a_plain_string_schema_and_a_set_flag() { + let request = InputRequest::from(question("token", AnswerType::Secret)); + + assert!(request.secret); + assert_eq!( + request.schema, + json!({ "type": "string" }).as_object().cloned().unwrap() + ); +} + +#[test] +fn an_ordinary_text_question_is_not_secret() { + assert!(!InputRequest::from(question("name", AnswerType::Text)).secret); +} + +#[test] +fn flattening_joins_blocks_in_order_with_a_blank_line() { + let result = ToolResult { + content: vec![ + ContentBlock::text("first"), + ContentBlock::Resource(Resource::text("file:///a.rs", "second")), + ContentBlock::text("third"), + ], + is_error: false, + error: None, + }; + + assert_eq!(result.to_text(), "first\n\nsecond\n\nthird"); +} + +/// A blob has no text to contribute, so its URI stands in for it rather than +/// its bytes reaching the model as mojibake. +#[test] +fn flattening_names_a_binary_resource_by_its_uri() { + let result = ToolResult { + content: vec![ContentBlock::Resource(Resource { + content: ResourceContent::Blob(vec![0x89, 0x50, 0x4e, 0x47]), + ..Resource::text("file:///shot.png", "") + })], + is_error: false, + error: None, + }; + + assert_eq!(result.to_text(), "file:///shot.png"); +} + +/// The question is answered, not read: flattening a result that carries one +/// must not put the prompt in front of the model as output. +#[test] +fn flattening_omits_a_question_but_keeps_its_context() { + let result = ToolResult { + content: vec![ + ContentBlock::text("Two hunks remain."), + ContentBlock::Question(InputRequest::from(question("stage", AnswerType::Boolean))), + ], + is_error: false, + error: None, + }; + + assert_eq!(result.to_text(), "Two hunks remain."); +} + +#[test] +fn flattening_an_error_appends_its_trace() { + let result = ToolResult::from(Outcome::Error { + message: "failed".to_owned(), + trace: vec!["inner".to_owned(), "innermost".to_owned()], + transient: false, + }); + + assert_eq!(result.to_text(), "failed\n\nTrace:\ninner\ninnermost"); +} + +#[test] +fn flattening_an_error_without_a_trace_is_just_the_message() { + assert_eq!(ToolResult::error("failed").to_text(), "failed"); +} diff --git a/crates/jp_tool/src/lib.rs b/crates/jp_tool/src/lib.rs index 112eeba08..c2cf8c15e 100644 --- a/crates/jp_tool/src/lib.rs +++ b/crates/jp_tool/src/lib.rs @@ -5,6 +5,7 @@ use serde::{Deserialize, Deserializer, Serialize}; use serde_json::Value; mod access; +pub mod content; pub mod definition; mod error; pub mod schema; @@ -13,6 +14,7 @@ pub use access::{ AccessPolicy, Capability, EnvRule, FsAccessError, FsRule, NetRule, canonicalize_workspace_target, lexical_workspace_relative, }; +pub use content::{ContentBlock, InputRequest, Resource, ResourceContent, ToolResult}; pub use definition::{ParameterDocs, ToolDefinition, ToolDocs}; pub use error::Error; From 43bd9df5c8cfe29e031f09427aadcf6a5a065538 Mon Sep 17 00:00:00 2001 From: Jean Mertz Date: Sat, 12 Sep 2026 13:58:08 +0200 Subject: [PATCH 04/29] refactor(mcp): Extract tool execution service Keep ordinary queries working while moving tool resolution, command execution, upstream dispatch, and the built-in registry into `jp_mcp::server`. The coordinator retains conversation ownership and inquiry routing. The default `client` feature stays usable without the server's execution dependencies. Add the Phase 2 service from RFD 109: private, single-use Host replies control admission, execution release, input, result review, and final recording. Questions re-run tools with accumulated answers. Edited arguments are revalidated, formatter execution respects approval and visibility, and final results wait for recording acknowledgement. Calls have separate identities and cancellation scopes. Host loss stops work, progress cannot block required interactions, and shutdown drains calls before closing upstream services. Dropping a result receiver does not retry or cancel execution. HTTP transport and CLI adoption of this service remain Phase 3 work. Signed-off-by: Jean Mertz --- Cargo.lock | 10 +- crates/jp_cli/Cargo.toml | 2 +- crates/jp_cli/src/cmd/conversation/print.rs | 2 +- crates/jp_cli/src/cmd/query.rs | 10 +- .../jp_cli/src/cmd/query/tool/coordinator.rs | 7 +- .../src/cmd/query/tool/coordinator_tests.rs | 18 +- crates/jp_cli/src/cmd/query/tool/executor.rs | 40 +- crates/jp_cli/src/cmd/query/tool/pending.rs | 2 +- .../src/cmd/query/tool/pending_tests.rs | 2 +- crates/jp_cli/src/cmd/query/tool/prompter.rs | 2 +- crates/jp_cli/src/cmd/query/turn_loop.rs | 3 +- .../jp_cli/src/cmd/query/turn_loop_tests.rs | 16 +- crates/jp_cli/src/cmd/query_tests.rs | 9 +- crates/jp_cli/src/render/tool.rs | 2 +- crates/jp_cli/src/render/tool_tests.rs | 18 +- crates/jp_cli/src/render/turn.rs | 2 +- crates/jp_llm/Cargo.toml | 9 +- crates/jp_llm/src/lib.rs | 1 - crates/jp_llm/src/tool.rs | 1352 ++++------------- crates/jp_llm/src/tool/executor.rs | 366 ----- crates/jp_mcp/Cargo.toml | 30 +- crates/jp_mcp/README.md | 26 +- crates/jp_mcp/src/client.rs | 20 + crates/jp_mcp/src/lib.rs | 14 +- crates/jp_mcp/src/server.rs | 1132 ++++++++++++++ .../src/tool => jp_mcp/src/server}/builtin.rs | 12 - .../src/server}/builtin/describe_tools.rs | 4 +- .../server}/builtin/describe_tools_tests.rs | 3 +- .../tool => jp_mcp/src/server}/json_schema.rs | 0 .../src/server}/json_schema_tests.rs | 0 crates/jp_mcp/src/server/service.rs | 686 +++++++++ crates/jp_mcp/src/server/service_tests.rs | 665 ++++++++ .../src/server_tests.rs} | 58 +- 33 files changed, 2921 insertions(+), 1602 deletions(-) delete mode 100644 crates/jp_llm/src/tool/executor.rs create mode 100644 crates/jp_mcp/src/server.rs rename crates/{jp_llm/src/tool => jp_mcp/src/server}/builtin.rs (73%) rename crates/{jp_llm/src/tool => jp_mcp/src/server}/builtin/describe_tools.rs (98%) rename crates/{jp_llm/src/tool => jp_mcp/src/server}/builtin/describe_tools_tests.rs (99%) rename crates/{jp_llm/src/tool => jp_mcp/src/server}/json_schema.rs (100%) rename crates/{jp_llm/src/tool => jp_mcp/src/server}/json_schema_tests.rs (100%) create mode 100644 crates/jp_mcp/src/server/service.rs create mode 100644 crates/jp_mcp/src/server/service_tests.rs rename crates/{jp_llm/src/tool_tests.rs => jp_mcp/src/server_tests.rs} (93%) diff --git a/Cargo.lock b/Cargo.lock index d94b87506..a070d13fc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2548,7 +2548,6 @@ dependencies = [ "jp_storage", "jp_test", "jp_tool", - "minijinja", "ollama-rs", "openai_responses", "paste", @@ -2577,15 +2576,24 @@ version = "0.1.0" name = "jp_mcp" version = "0.1.0" dependencies = [ + "assert_matches", + "async-trait", + "camino", + "camino-tempfile", "indexmap", "jp_config", + "jp_test", + "jp_tool", + "minijinja", "rmcp", "serde", "serde_json", "sha1", "sha2", + "test-log", "thiserror 2.0.20", "tokio", + "tokio-util", "tracing", "which", ] diff --git a/crates/jp_cli/Cargo.toml b/crates/jp_cli/Cargo.toml index 606b67ab2..3740a88c9 100644 --- a/crates/jp_cli/Cargo.toml +++ b/crates/jp_cli/Cargo.toml @@ -31,7 +31,7 @@ jp_id = { workspace = true } jp_inquire = { workspace = true } jp_llm = { workspace = true } jp_macro = { workspace = true } -jp_mcp = { workspace = true } +jp_mcp = { workspace = true, features = ["server"] } jp_md = { workspace = true } jp_openrouter = { workspace = true } jp_plugin = { workspace = true } diff --git a/crates/jp_cli/src/cmd/conversation/print.rs b/crates/jp_cli/src/cmd/conversation/print.rs index 1c4ede7e3..eabd4f702 100644 --- a/crates/jp_cli/src/cmd/conversation/print.rs +++ b/crates/jp_cli/src/cmd/conversation/print.rs @@ -5,7 +5,7 @@ use jp_config::{ style::{reasoning::ReasoningDisplayConfig, typewriter::DelayDuration}, }; use jp_conversation::stream::TurnOrigin; -use jp_llm::tool::InvocationContext; +use jp_mcp::server::InvocationContext; use jp_workspace::ConversationHandle; use crate::{ diff --git a/crates/jp_cli/src/cmd/query.rs b/crates/jp_cli/src/cmd/query.rs index 85bc5417f..e97cf330c 100644 --- a/crates/jp_cli/src/cmd/query.rs +++ b/crates/jp_cli/src/cmd/query.rs @@ -102,16 +102,16 @@ use jp_conversation::{ thread::{Thread, ThreadBuilder}, }; use jp_inquire::prompt::{PromptBackend, TerminalPromptBackend}; -use jp_llm::{ - event::NoticeSink, - provider, - tool::{ +use jp_llm::{event::NoticeSink, provider}; +use jp_mcp::{ + StartupSet, + id::McpServerId, + server::{ InvocationContext, builtin::{BuiltinExecutors, describe_tools::DescribeTools}, tool_definitions, }, }; -use jp_mcp::{StartupSet, id::McpServerId}; use jp_md::format::Formatter; use jp_printer::{LineSink, PrintableExt as _, Printer, RegionStyle, StatusRegion}; use jp_storage::backend::{FsStorageBackend, Projection}; diff --git a/crates/jp_cli/src/cmd/query/tool/coordinator.rs b/crates/jp_cli/src/cmd/query/tool/coordinator.rs index 2e51af647..7f568e112 100644 --- a/crates/jp_cli/src/cmd/query/tool/coordinator.rs +++ b/crates/jp_cli/src/cmd/query/tool/coordinator.rs @@ -101,11 +101,8 @@ use jp_conversation::{ }; use jp_editor::EditorBackend; use jp_inquire::{ReplyEditMode, prompt::PromptBackend}; -use jp_llm::tool::{ - StderrSink, - executor::{Executor, ExecutorResult, ExecutorSource, PermissionInfo}, -}; -use jp_mcp::Client; +use jp_llm::tool::{Executor, ExecutorResult, ExecutorSource, PermissionInfo}; +use jp_mcp::{Client, server::StderrSink}; use jp_printer::Printer; use jp_tool::{AnswerType, Question}; use jp_workspace::ConversationMut; diff --git a/crates/jp_cli/src/cmd/query/tool/coordinator_tests.rs b/crates/jp_cli/src/cmd/query/tool/coordinator_tests.rs index 0cb1b65ce..8d40ecb30 100644 --- a/crates/jp_cli/src/cmd/query/tool/coordinator_tests.rs +++ b/crates/jp_cli/src/cmd/query/tool/coordinator_tests.rs @@ -2,19 +2,19 @@ use async_trait::async_trait; use camino_tempfile::Utf8TempDir; use jp_config::conversation::tool::{ToolConfig, ToolSource, style::PartialDisplayStyleConfig}; use jp_inquire::{ReplyOutcome, prompt::MockPromptBackend}; -use jp_llm::tool::executor::MockExecutor; +use jp_llm::tool::MockExecutor; use jp_printer::{ErrChannel, OutputFormat, Printer}; use schematic::Config as _; use super::{super::executor::TerminalExecutorSource, *}; use crate::render::tool::ToolRenderer; -fn empty_executor_source() -> Box { +fn empty_executor_source() -> Box { Box::new(TerminalExecutorSource::new( - jp_llm::tool::builtin::BuiltinExecutors::new(), + jp_mcp::server::builtin::BuiltinExecutors::new(), &[], std::sync::Arc::new(crate::access::approvals::ApprovalStore::default()), - jp_llm::tool::InvocationContext::default(), + jp_mcp::server::InvocationContext::default(), )) } @@ -316,7 +316,7 @@ async fn test_pre_render_for_prompt_function_call_fires_before_approval() { ErrChannel::new(printer.clone()), style_config, root.path().to_owned(), - jp_llm::tool::InvocationContext::default(), + jp_mcp::server::InvocationContext::default(), ); let mut args = Map::new(); @@ -381,7 +381,7 @@ async fn test_pre_render_for_prompt_custom_ask_defers_rendering() { ErrChannel::new(printer.clone()), style_config, root.path().to_owned(), - jp_llm::tool::InvocationContext::default(), + jp_mcp::server::InvocationContext::default(), ); let result = coordinator @@ -438,7 +438,7 @@ impl Executor for EditableExecutor { _mcp_client: &jp_mcp::Client, _root: &camino::Utf8Path, _cancellation_token: tokio_util::sync::CancellationToken, - _stderr: Option, + _stderr: Option, ) -> ExecutorResult { unreachable!("resolve_tool_call_decision does not invoke execute()") } @@ -480,7 +480,7 @@ async fn test_resolve_tool_call_decision_invalidates_prerender_on_edit() { ErrChannel::new(printer.clone()), style_config, root.path().to_owned(), - jp_llm::tool::InvocationContext::default(), + jp_mcp::server::InvocationContext::default(), ); let mut pre_edit_args = Map::new(); @@ -833,7 +833,7 @@ async fn custom_formatter_receives_the_invoked_tool_name() { ErrChannel::new(printer.clone()), jp_config::AppConfig::new_test().style, root.path().to_owned(), - jp_llm::tool::InvocationContext::default(), + jp_mcp::server::InvocationContext::default(), ); let outcome = coordinator diff --git a/crates/jp_cli/src/cmd/query/tool/executor.rs b/crates/jp_cli/src/cmd/query/tool/executor.rs index e92a930e8..4048dee77 100644 --- a/crates/jp_cli/src/cmd/query/tool/executor.rs +++ b/crates/jp_cli/src/cmd/query/tool/executor.rs @@ -40,24 +40,20 @@ //! The [`Executor`] trait allows for mock implementations in tests. //! See [`MockExecutor`] for testing parallel execution behavior. //! -//! [`MockExecutor`]: jp_llm::tool::executor::MockExecutor +//! [`MockExecutor`]: jp_llm::tool::MockExecutor use std::sync::Arc; use async_trait::async_trait; use camino::Utf8Path; use indexmap::IndexMap; -use jp_config::conversation::tool::{RunMode, ToolConfigWithDefaults, ToolSource}; +use jp_config::conversation::tool::{RunMode, ToolConfigWithDefaults}; use jp_conversation::event::{InquirySource, ToolCallRequest, ToolCallResponse}; -use jp_llm::{ - ExecutionOutcome, - tool::{ - InvocationContext, StderrSink, - builtin::BuiltinExecutors, - executor::{Executor, ExecutorResult, ExecutorSource, PermissionInfo}, - }, +use jp_llm::tool::{Executor, ExecutorResult, ExecutorSource, PermissionInfo}; +use jp_mcp::{ + Client, + server::{ExecutionOutcome, InvocationContext, StderrSink, builtin::BuiltinExecutors, execute}, }; -use jp_mcp::Client; use jp_tool::ToolDefinition; use serde_json::Value; use tokio_util::sync::CancellationToken; @@ -150,26 +146,6 @@ impl ToolExecutor { invocation, } } - - /// Resolve the persisted `InquirySource` recorded for a question this tool - /// emits. - /// - /// Built-in tools may override their source via - /// `BuiltinTool::inquiry_source`; local and MCP tools always attribute the - /// question to the tool by name. - fn inquiry_source(&self) -> InquirySource { - match self.config.source() { - ToolSource::Builtin { .. } => { - self.builtin_executors.get(&self.request.name).map_or_else( - || InquirySource::tool(self.request.name.as_str()), - |tool| tool.inquiry_source(&self.request.name), - ) - } - ToolSource::Local { .. } | ToolSource::Mcp { .. } => { - InquirySource::tool(self.request.name.as_str()) - } - } - } } #[async_trait] @@ -235,7 +211,7 @@ impl Executor for ToolExecutor { } }; - let result = jp_llm::tool::execute( + let result = execute( &self.definition, self.request.id.clone(), Value::Object(self.request.arguments.clone()), @@ -263,7 +239,7 @@ impl Executor for ToolExecutor { tool_id: self.request.id.clone(), tool_name: self.request.name.clone(), question, - source: self.inquiry_source(), + source: InquirySource::tool(self.request.name.as_str()), accumulated_answers: answers.clone(), }, Err(e) => ExecutorResult::Completed(ToolCallResponse { diff --git a/crates/jp_cli/src/cmd/query/tool/pending.rs b/crates/jp_cli/src/cmd/query/tool/pending.rs index 28278ac72..d4534b575 100644 --- a/crates/jp_cli/src/cmd/query/tool/pending.rs +++ b/crates/jp_cli/src/cmd/query/tool/pending.rs @@ -24,7 +24,7 @@ use jp_conversation::{ ConversationStream, event::{ToolCallRequest, ToolCallResponse}, }; -use jp_llm::tool::executor::Executor; +use jp_llm::tool::Executor; /// The work product for a single tool call, as decided during the streaming /// phase. diff --git a/crates/jp_cli/src/cmd/query/tool/pending_tests.rs b/crates/jp_cli/src/cmd/query/tool/pending_tests.rs index 2ae352082..16641f1cc 100644 --- a/crates/jp_cli/src/cmd/query/tool/pending_tests.rs +++ b/crates/jp_cli/src/cmd/query/tool/pending_tests.rs @@ -2,7 +2,7 @@ use jp_conversation::{ ConversationStream, event::{ChatRequest, ChatResponse, ToolCallRequest, ToolCallResponse}, }; -use jp_llm::tool::executor::MockExecutor; +use jp_llm::tool::MockExecutor; use serde_json::Map; use super::*; diff --git a/crates/jp_cli/src/cmd/query/tool/prompter.rs b/crates/jp_cli/src/cmd/query/tool/prompter.rs index 229c17b50..ab83b61e6 100644 --- a/crates/jp_cli/src/cmd/query/tool/prompter.rs +++ b/crates/jp_cli/src/cmd/query/tool/prompter.rs @@ -21,7 +21,7 @@ use jp_config::conversation::tool::{RunMode, ToolSource}; use jp_conversation::event::SelectOption; use jp_editor::{EditOutcome, EditorBackend}; use jp_inquire::{InlineOption, ReplyEditMode, ReplyOutcome, prompt::PromptBackend}; -use jp_llm::tool::executor::PermissionInfo; +use jp_llm::tool::PermissionInfo; use jp_printer::{Printer, PromptWriter}; use jp_term::{background::DefaultBackground, shade::ShadedWriter}; use jp_tool::AnswerType; diff --git a/crates/jp_cli/src/cmd/query/turn_loop.rs b/crates/jp_cli/src/cmd/query/turn_loop.rs index ea7c1b506..632283339 100644 --- a/crates/jp_cli/src/cmd/query/turn_loop.rs +++ b/crates/jp_cli/src/cmd/query/turn_loop.rs @@ -36,9 +36,10 @@ use jp_llm::{ model::ModelDetails, provider::get_provider, query::{ChatQuery, Truncation}, - tool::{InvocationContext, executor::Executor}, + tool::Executor, with_idle_timeout, with_output_limit, }; +use jp_mcp::server::InvocationContext; use jp_printer::{ErrChannel, Printer, RegionStyle, StatusRegion}; use jp_tool::ToolDefinition; use jp_workspace::{ConversationLock, ConversationMut}; diff --git a/crates/jp_cli/src/cmd/query/turn_loop_tests.rs b/crates/jp_cli/src/cmd/query/turn_loop_tests.rs index fb943061c..e40d3c5ba 100644 --- a/crates/jp_cli/src/cmd/query/turn_loop_tests.rs +++ b/crates/jp_cli/src/cmd/query/turn_loop_tests.rs @@ -51,14 +51,10 @@ use jp_llm::{ provider::mock::MockProvider, query::ChatQuery, tool::{ - InvocationContext, - builtin::BuiltinExecutors, - executor::{ - Executor, ExecutorResult, ExecutorSource, MockExecutor, PermissionInfo, - TestExecutorSource, - }, + Executor, ExecutorResult, ExecutorSource, MockExecutor, PermissionInfo, TestExecutorSource, }, }; +use jp_mcp::server::{InvocationContext, builtin::BuiltinExecutors}; use jp_printer::{OutputFormat, Printer, TerminalCapability}; use jp_storage::backend::FsStorageBackend; use jp_tool::Question; @@ -1264,7 +1260,7 @@ impl Executor for SleepingExecutor { _mcp_client: &jp_mcp::Client, _root: &Utf8Path, cancellation_token: CancellationToken, - _stderr: Option, + _stderr: Option, ) -> ExecutorResult { if let Some(started) = &self.started { started.notify_one(); @@ -4896,7 +4892,7 @@ impl Executor for TalkingExecutor { _mcp_client: &jp_mcp::Client, _root: &Utf8Path, _cancellation_token: CancellationToken, - stderr: Option, + stderr: Option, ) -> ExecutorResult { if let Some(sink) = stderr { self.got_sink.store(true, Ordering::Relaxed); @@ -5839,7 +5835,7 @@ impl Executor for AskingTalkingExecutor { _mcp_client: &jp_mcp::Client, _root: &Utf8Path, _cancellation_token: CancellationToken, - stderr: Option, + stderr: Option, ) -> ExecutorResult { if answers.contains_key("which") { if let Some(sink) = stderr { @@ -5913,7 +5909,7 @@ impl Executor for InquiryMockExecutor { _mcp_client: &jp_mcp::Client, _root: &camino::Utf8Path, _cancellation_token: tokio_util::sync::CancellationToken, - _stderr: Option, + _stderr: Option, ) -> ExecutorResult { for q in &self.questions { if !answers.contains_key(q.id.as_str()) { diff --git a/crates/jp_cli/src/cmd/query_tests.rs b/crates/jp_cli/src/cmd/query_tests.rs index e7c5b1f12..e561d66be 100644 --- a/crates/jp_cli/src/cmd/query_tests.rs +++ b/crates/jp_cli/src/cmd/query_tests.rs @@ -21,12 +21,11 @@ use jp_conversation::{ event::{ChatRequest, ChatResponse}, }; use jp_inquire::prompt::MockPromptBackend; -use jp_llm::{ - Provider, - provider::mock::MockProvider, - tool::{InvocationContext, builtin::BuiltinExecutors, executor::ExecutorSource}, +use jp_llm::{Provider, provider::mock::MockProvider, tool::ExecutorSource}; +use jp_mcp::{ + Startup, StderrLine, + server::{InvocationContext, builtin::BuiltinExecutors}, }; -use jp_mcp::{Startup, StderrLine}; use jp_printer::{OutputFormat, Printer, SharedBuffer, TerminalCapability}; use jp_storage::{ backend::{ConversationFilter, FsStorageBackend, LoadBackend}, diff --git a/crates/jp_cli/src/render/tool.rs b/crates/jp_cli/src/render/tool.rs index 1c71dd8d9..9a19e56ad 100644 --- a/crates/jp_cli/src/render/tool.rs +++ b/crates/jp_cli/src/render/tool.rs @@ -21,7 +21,7 @@ use jp_config::{ style::{StyleConfig, stderr_rows::StderrRows}, }; use jp_conversation::event::ToolCallResponse; -use jp_llm::{CommandResult, run_tool_command, tool::InvocationContext}; +use jp_mcp::server::{CommandResult, InvocationContext, run_tool_command}; use jp_md::format::Formatter; use jp_printer::{ErrChannel, LineSink, OutputLines, RegionStyle, StatusRegion}; use jp_term::{background::DefaultBackground, osc::hyperlink, shade::ShadedWriter}; diff --git a/crates/jp_cli/src/render/tool_tests.rs b/crates/jp_cli/src/render/tool_tests.rs index 36e7c678d..a080c4135 100644 --- a/crates/jp_cli/src/render/tool_tests.rs +++ b/crates/jp_cli/src/render/tool_tests.rs @@ -92,7 +92,7 @@ fn create_renderer() -> (ToolRenderer, SharedBuffer, SharedBuffer) { ErrChannel::new(Arc::new(printer)), config, "/tmp".into(), - jp_llm::tool::InvocationContext::default(), + jp_mcp::server::InvocationContext::default(), ); (renderer, err, out) } @@ -117,7 +117,7 @@ fn create_renderer_with_show(show: bool) -> (ToolRenderer, SharedBuffer) { ErrChannel::new(Arc::new(printer)), config, "/tmp".into(), - jp_llm::tool::InvocationContext::default(), + jp_mcp::server::InvocationContext::default(), ); (renderer, err) } @@ -192,7 +192,7 @@ async fn test_render_custom_arguments_after_approval() { ErrChannel::new(Arc::new(printer)), config, root.path().to_owned(), - jp_llm::tool::InvocationContext::default(), + jp_mcp::server::InvocationContext::default(), ); let mut args = Map::new(); @@ -409,7 +409,7 @@ fn progress_window_is_off_without_print_stderr() { ErrChannel::new(Arc::new(printer)), config, "/tmp".into(), - jp_llm::tool::InvocationContext::default(), + jp_mcp::server::InvocationContext::default(), ); assert!(renderer.progress_source("cargo_test").is_none()); @@ -530,7 +530,7 @@ fn test_completing_one_pending_tool_does_not_collide_with_header() { ErrChannel::new(Arc::new(printer)), config, "/tmp".into(), - jp_llm::tool::InvocationContext::default(), + jp_mcp::server::InvocationContext::default(), ); renderer.register("id1", "fs_read_file"); @@ -606,7 +606,7 @@ fn test_show_false_suppresses_preparing_output() { ErrChannel::new(Arc::new(Printer::sink())), config, "/tmp".into(), - jp_llm::tool::InvocationContext::default(), + jp_mcp::server::InvocationContext::default(), ); renderer.register("id1", "tool_a"); @@ -622,7 +622,7 @@ fn test_tool_call_show_false_suppresses_output() { ErrChannel::new(Arc::new(Printer::sink())), config, "/tmp".into(), - jp_llm::tool::InvocationContext::default(), + jp_mcp::server::InvocationContext::default(), ); let mut args = Map::new(); args.insert("key".into(), Value::String("value".into())); @@ -678,7 +678,7 @@ async fn test_format_custom_content_returns_raw_content() { &args, cmd, root.path(), - &jp_llm::tool::InvocationContext::default(), + &jp_mcp::server::InvocationContext::default(), ) .await .unwrap(); @@ -699,7 +699,7 @@ async fn test_format_args_custom_exposes_invocation_ids() { "echo {{context.workspace_id}}/{{context.conversation_id}}".into(), ) .command(); - let invocation = jp_llm::tool::InvocationContext { + let invocation = jp_mcp::server::InvocationContext { workspace_id: "ws-abc".into(), conversation_id: "conv-xyz".into(), }; diff --git a/crates/jp_cli/src/render/turn.rs b/crates/jp_cli/src/render/turn.rs index 768e6a631..3a9d4d898 100644 --- a/crates/jp_cli/src/render/turn.rs +++ b/crates/jp_cli/src/render/turn.rs @@ -23,7 +23,7 @@ use jp_conversation::{ EventKind, stream::{TurnOrigin, turn_iter::Turn}, }; -use jp_llm::tool::InvocationContext; +use jp_mcp::server::InvocationContext; use jp_printer::{ErrChannel, Printer}; use tracing::warn; diff --git a/crates/jp_llm/Cargo.toml b/crates/jp_llm/Cargo.toml index db7b92e87..d14065252 100644 --- a/crates/jp_llm/Cargo.toml +++ b/crates/jp_llm/Cargo.toml @@ -17,7 +17,7 @@ jp_attachment = { workspace = true } jp_config = { workspace = true } jp_conversation = { workspace = true } jp_credentials = { workspace = true } -jp_mcp = { workspace = true } +jp_mcp = { workspace = true, features = ["server"] } jp_openrouter = { workspace = true } jp_tool = { workspace = true } @@ -31,13 +31,6 @@ futures = { workspace = true } gemini_client_rs = { workspace = true } getrandom = { workspace = true } indexmap = { workspace = true } -minijinja = { workspace = true, features = [ - "builtins", - "json", - "preserve_order", - "serde", - "unicode", -] } ollama-rs = { workspace = true, features = ["rustls", "stream"] } openai_responses = { workspace = true, features = ["stream"] } quick-xml = { workspace = true, features = ["serialize"] } diff --git a/crates/jp_llm/src/lib.rs b/crates/jp_llm/src/lib.rs index 6241eb522..a629d9df4 100644 --- a/crates/jp_llm/src/lib.rs +++ b/crates/jp_llm/src/lib.rs @@ -25,4 +25,3 @@ pub use retry::{exponential_backoff, retry_delay}; pub use stream::{ EventStream, chain::EventChain, with_idle_timeout, with_output_limit, with_tool_call_keepalive, }; -pub use tool::{CommandResult, ExecutionOutcome, ToolTrace, run_tool_command}; diff --git a/crates/jp_llm/src/tool.rs b/crates/jp_llm/src/tool.rs index 3b15fcda7..6d6fe8028 100644 --- a/crates/jp_llm/src/tool.rs +++ b/crates/jp_llm/src/tool.rs @@ -1,1149 +1,371 @@ -//! Tool call utilities. - -pub mod builtin; -pub mod executor; -pub mod json_schema; - -use std::{ffi::OsStr, fmt, process::Stdio, sync::Arc}; - -pub use builtin::BuiltinTool; +//! The seam a turn loop runs one tool call through. +//! +//! [`Executor`] is one execution attempt: it runs the tool and reports what +//! came back, without deciding whether the call may run or who answers a +//! question it asks. +//! [`ExecutorSource`] builds one per tool call, so a test can supply +//! [`MockExecutor`] where production supplies a real one. +//! +//! The execution itself lives in [`jp_mcp::server`]. + +use std::sync::Mutex; + +use async_trait::async_trait; use camino::Utf8Path; use indexmap::IndexMap; -use jp_config::{ - conversation::tool::{CommandConfig, ToolConfigWithDefaults, ToolSource}, - types::command::shell_command_line, -}; -use jp_conversation::event::ToolCallResponse; -use jp_mcp::{ - RawContent, ResourceContents, - id::{McpServerId, McpToolId}, -}; -use jp_tool::{ - Action, Error as ToolError, Outcome, ParameterDocs, Question, ToolDefinition, ToolDocs, - definition::{apply_parameter_defaults, split_description, validate_tool_arguments}, - schema::{Node, merge_description}, -}; -use minijinja::{Environment, ErrorKind as MinijinjaErrorKind, value::ValueKind}; -use serde_json::{Value, json}; -use tokio::{ - io::{AsyncBufReadExt, AsyncReadExt, BufReader}, - process::Command, -}; +use jp_config::conversation::tool::{RunMode, ToolConfigWithDefaults, ToolSource}; +use jp_conversation::event::{InquirySource, ToolCallRequest, ToolCallResponse}; +use jp_mcp::{Client, server::StderrSink}; +use jp_tool::{Question, ToolDefinition}; +use serde_json::{Map, Value}; use tokio_util::sync::CancellationToken; -use tracing::{error, info, trace, warn}; - -/// Read a tool's documentation out of its configuration. -fn tool_docs_from_config(config: &ToolConfigWithDefaults) -> ToolDocs { - let parameters = config - .parameters() - .iter() - .filter_map(|(param_name, param_cfg)| { - let summary = param_cfg - .summary - .as_deref() - .or(param_cfg.description.as_deref()) - .map(str::to_owned); - let desc = param_cfg.description.as_deref().map(str::to_owned); - let ex = param_cfg.examples.as_deref().map(str::to_owned); - - if summary.is_none() && desc.is_none() && ex.is_none() { - return None; - } - - Some((param_name.to_owned(), ParameterDocs { - summary, - description: desc, - examples: ex, - })) - }) - .collect(); - - ToolDocs { - summary: config.summary().map(str::to_owned), - description: config.description().map(str::to_owned), - examples: config.examples().map(str::to_owned), - parameters, - } -} -/// The outcome of a tool execution. -/// -/// This type represents the possible results of executing a tool's underlying -/// command or MCP call, without any interactive prompts. -/// The caller is responsible for: +/// Trait for tool execution, enabling mock implementations for testing. /// -/// 1. Handling permission prompts **before** calling [`execute()`]. -/// 2. Handling [`ExecutionOutcome::NeedsInput`] by prompting the user or -/// assistant. -/// 3. Handling result editing **after** receiving the outcome. +/// This trait abstracts the execution of a single tool call, allowing the +/// `ToolCoordinator` to work with both real and mock executors. /// -/// # Example Flow +/// # Design /// -/// ```text -/// ToolExecutor (jp_cli) execute() (jp_llm) -/// ───────────────────── ────────────────────── -/// │ -/// ├── [AwaitingPermission] -/// │ prompt_permission() -/// │ -/// ├── [Running] -/// │ ────────────────────────────► execute() -/// │ │ -/// │ ◄──────────────────────────── ExecutionOutcome -/// ├── [AwaitingInput] (if NeedsInput) -/// │ prompt_question() -/// │ ────────────────────────────► execute() (with answer) -/// │ │ -/// │ ◄──────────────────────────── ExecutionOutcome -/// ├── [AwaitingResultEdit] -/// │ prompt_result_edit() -/// │ -/// └── [Completed] -/// ``` -#[derive(Debug)] -pub enum ExecutionOutcome { - /// Tool executed and produced a result. - Completed { - /// The tool call ID (for correlation with the request). - id: String, +/// The executor is intentionally simple - it just executes tools with given +/// answers. +/// All decision-making about question targets, static answers, and how to +/// handle `NeedsInput` is done by the coordinator, which has access to the tool +/// configuration. +#[async_trait] +pub trait Executor: Send + Sync { + /// Returns the tool call ID. + fn tool_id(&self) -> &str; - /// The execution result. - /// - /// If an error occurred, it means the tool ran, but reported an error. - result: Result, - }, + /// Returns the tool name. + fn tool_name(&self) -> &str; - /// Tool needs additional input before it can complete. + /// Returns the tool call arguments. /// - /// The caller should: + /// This is separate from [`permission_info()`] because arguments are always + /// available, while permission info is only present for tools that require + /// a permission prompt. /// - /// 1. Present the question to the user (or delegate to the assistant) - /// 2. Collect the answer - /// 3. Call [`execute()`] again with the answer in `answers` - NeedsInput { - /// The tool call ID. - id: String, + /// [`permission_info()`]: Self::permission_info + fn arguments(&self) -> &Map; - /// The question to ask. - question: Question, - }, + /// Returns information needed for permission prompting. + /// + /// Returns `None` if the tool doesn't need a permission prompt (e.g., + /// `RunMode::Unattended` or `RunMode::Skip`). + fn permission_info(&self) -> Option; - /// Tool execution was cancelled via the cancellation token. + /// Updates the arguments to use for execution. /// - /// This occurs when the user interrupts tool execution (e.g., Ctrl+C during - /// a long-running command). - Cancelled { - /// The tool call ID. - id: String, - }, -} + /// This is called after permission prompting if the user edited the + /// arguments (via `RunMode::Edit`). + /// The new arguments replace the original arguments from the tool call + /// request. + fn set_arguments(&mut self, args: Value); -impl ExecutionOutcome { - /// Convert the outcome to a [`ToolCallResponse`]. + /// Executes the tool once with the given answers. /// - /// This is useful for building the final response to send to the LLM after - /// any post-processing (e.g., result editing) is complete. + /// This method performs a single execution pass. + /// If the tool needs additional input, it returns + /// `ExecutorResult::NeedsInput` and the coordinator handles prompting and + /// retrying. /// - /// # Note + /// The executor doesn't know how questions should be answered - it just + /// reports that input is needed. + /// The coordinator looks up the tool configuration to determine whether to + /// prompt the user or ask the LLM. /// - /// For [`ExecutionOutcome::NeedsInput`], this returns a placeholder - /// response. - /// The caller should typically handle `NeedsInput` specially rather than - /// converting it directly to a response. - #[must_use] - pub fn into_response(self) -> ToolCallResponse { - match self { - Self::Completed { id, result } => ToolCallResponse { id, result }, - Self::NeedsInput { id, question } => ToolCallResponse { - id, - result: Ok(format!("Tool requires additional input: {}", question.text)), - }, - Self::Cancelled { id } => ToolCallResponse { - id, - result: Ok("Tool execution cancelled by user.".to_string()), - }, - } - } - - /// Returns the tool call ID. - #[must_use] - pub fn id(&self) -> &str { - match self { - Self::Completed { id, .. } | Self::NeedsInput { id, .. } | Self::Cancelled { id } => id, - } - } - - /// Returns `true` if this is a `NeedsInput` outcome. - #[must_use] - pub fn needs_input(&self) -> bool { - matches!(self, Self::NeedsInput { .. }) - } - - /// Returns `true` if this is a `Cancelled` outcome. - #[must_use] - pub fn is_cancelled(&self) -> bool { - matches!(self, Self::Cancelled { .. }) - } + /// # Arguments + /// + /// - `answers` - Accumulated answers from previous `NeedsInput` responses + /// - `mcp_client` - MCP client for remote tool execution + /// - `root` - Project root directory + /// - `cancellation_token` - Token to cancel execution + /// - `stderr` - Receives the tool's stderr lines as they arrive, for a + /// caller showing progress while it runs. + /// `None` when nothing is watching; the lines still reach tracing and the + /// accumulated buffer either way. + async fn execute( + &self, + answers: &IndexMap, + mcp_client: &Client, + root: &Utf8Path, + cancellation_token: CancellationToken, + stderr: Option, + ) -> ExecutorResult; +} - /// Returns `true` if this is a `Completed` outcome with a successful - /// result. - #[must_use] - pub fn is_success(&self) -> bool { - matches!(self, Self::Completed { result: Ok(_), .. }) - } +/// Abstraction over how executors are created for tool calls. +/// +/// This trait enables dependency injection of executor creation, allowing tests +/// to use mock executors without executing real shell commands. +pub trait ExecutorSource: Send + Sync { + /// Creates an executor for the given tool call request. + /// + /// Returns `None` if the tool cannot be resolved (e.g. missing from the + /// definitions). + fn create( + &self, + request: ToolCallRequest, + config: ToolConfigWithDefaults, + ) -> Option>; } -/// Result of running a tool command. +/// Result of a tool execution attempt. /// -/// This is the single parsing point for all tool command output. -/// Both tool execution and argument formatting go through this type, ensuring -/// consistent handling of `Outcome` variants (including error traces). +/// Tools may need multiple rounds of execution if they require additional +/// input. +/// This enum allows the executor to return control to the coordinator, which +/// decides how to handle the `NeedsInput` case by looking up the question +/// configuration. #[derive(Debug)] -pub enum CommandResult { - /// Tool produced content. - Success(String), - - /// Tool reported a transient error (can be retried). - TransientError { - /// The error message. - message: String, - - /// The error trace (source chain from the tool process). - trace: Vec, - }, - - /// Tool reported a fatal error. - FatalError(String), +#[allow(clippy::large_enum_variant)] // NeedsInput variant is larger but rarely used +pub enum ExecutorResult { + /// Tool completed (success or error). + Completed(ToolCallResponse), /// Tool needs additional input before it can continue. - NeedsInput(Question), - - /// Tool was cancelled via the cancellation token. - Cancelled, - - /// stdout wasn't valid `Outcome` JSON. /// - /// Falls back to treating stdout as plain text. - /// The `success` flag indicates the process exit status. - RawOutput { - /// Raw stdout content. - stdout: String, + /// The executor doesn't know who should answer - it just reports that input + /// is needed. + /// The coordinator looks up the question configuration to determine the + /// target: + /// + /// - `User`: Prompt the user interactively, then restart the tool + /// - `Assistant`: Format a response asking the LLM to re-run with answers + NeedsInput { + /// Tool call ID. + tool_id: String, - /// Raw stderr content. - stderr: String, + /// Tool name (for persisting answers). + tool_name: String, - /// Whether the process exited successfully. - success: bool, - }, + /// The question that needs to be answered. + question: Question, - /// Tool emitted a well-formed `needs_input` whose question id is invalid - /// (empty, or contains a `.`, which is reserved as the inquiry-id - /// separator). - /// - /// Surfaced as a tool-level error so the malformed inquiry is dropped - /// before any inquiry event is constructed. - InvalidInquiry { - /// The offending question id, for the diagnostic trace. - question_id: String, - }, + /// Resolved provenance for the persisted `InquiryRequest`. + source: InquirySource, - /// Tool emitted a payload shaped like a `needs_input` outcome (top-level - /// `"type": "needs_input"`) that failed to deserialize for a reason other - /// than an invalid question id: a field with the wrong shape, a missing - /// field, or a local-tool binary emitting an older wire protocol than this - /// build parses. - /// - /// Surfaced as a tool-level error rather than [`Self::RawOutput`] so a - /// protocol mismatch is loud, instead of silently handing the raw JSON to - /// the model as tool output. - MalformedInquiry { - /// The deserialization error, for the diagnostic trace and the - /// model-facing message. - detail: String, + /// Accumulated answers so far (for retry). + accumulated_answers: IndexMap, }, } -impl CommandResult { - /// Format a transient error message including trace details. - /// - /// If the trace is empty, returns just the message. - /// Otherwise appends the trace entries so the LLM (or user) can see the - /// root cause. +/// A mock executor for testing that returns pre-configured results. +/// +/// This executor doesn't execute any real commands - it simply returns whatever +/// result is configured, making it ideal for testing tool coordination flows +/// without side effects. +/// +/// # Example +/// +/// ```ignore +/// let executor = MockExecutor::completed("call_1", "my_tool", "success output"); +/// let result = executor.execute(&answers, &client, &root, token).await; +/// assert!(result.is_completed()); +/// ``` +pub struct MockExecutor { + tool_id: String, + tool_name: String, + arguments: Map, + permission_info: Option, + result: Mutex>, +} + +impl MockExecutor { + /// Creates a mock executor that returns a successful completion. #[must_use] - pub fn format_error(message: &str, trace: &[String]) -> String { - if trace.is_empty() { - message.to_owned() - } else { - format!("{message}\n\nTrace:\n{}", trace.join("\n")) + pub fn completed(tool_id: &str, tool_name: &str, output: &str) -> Self { + Self { + tool_id: tool_id.to_string(), + tool_name: tool_name.to_string(), + arguments: Map::new(), + permission_info: None, + result: Mutex::new(Some(ExecutorResult::Completed(ToolCallResponse { + id: tool_id.to_string(), + result: Ok(output.to_string()), + }))), } } - /// Convert to a `Result` suitable for tool call responses. - /// - /// - `Success` → `Ok(content)` - /// - `TransientError` → `Err(json with message + trace)` - /// - `FatalError` → `Err(raw json)` - /// - `NeedsInput` → handled separately by callers (this panics) - /// - `Cancelled` → `Ok(cancellation message)` - /// - `RawOutput` → `Ok(stdout)` if success, `Err(json)` if failure - pub fn into_tool_result(self, name: &str) -> Result { - match self { - Self::Success(content) => Ok(content), - Self::TransientError { message, trace } => Err(json!({ - "message": message, - "trace": trace, - }) - .to_string()), - Self::FatalError(raw) => Err(raw), - Self::Cancelled => Ok("Tool execution cancelled by user.".to_string()), - Self::RawOutput { - stdout, - stderr, - success, - } => { - if success { - Ok(stdout) - } else { - Err(json!({ - "message": format!("Tool '{name}' execution failed."), - "stderr": stderr, - "stdout": stdout, - }) - .to_string()) - } - } - Self::InvalidInquiry { question_id } => { - error!( - tool = name, - question_id = %question_id, - "tool produced an invalid inquiry: question id must be non-empty and must not \ - contain '.'" - ); - Err( - "tool produced an invalid inquiry: question id must be non-empty and must not \ - contain '.'" - .to_owned(), - ) - } - Self::MalformedInquiry { detail } => { - error!( - tool = name, - %detail, - "tool produced a malformed inquiry that could not be parsed" - ); - Err(format!( - "tool '{name}' produced a malformed inquiry that could not be parsed: {detail}" - )) - } - Self::NeedsInput(_) => { - unreachable!("NeedsInput should be handled by the caller") - } + /// Creates a mock executor that returns an error. + #[must_use] + pub fn error(tool_id: &str, tool_name: &str, error: &str) -> Self { + Self { + tool_id: tool_id.to_string(), + tool_name: tool_name.to_string(), + arguments: Map::new(), + permission_info: None, + result: Mutex::new(Some(ExecutorResult::Completed(ToolCallResponse { + id: tool_id.to_string(), + result: Err(error.to_string()), + }))), } } -} -/// Receives a running tool's stderr lines as they arrive. -/// -/// Called from the forwarder's read loop, so it must not block: the loop has to -/// keep draining or the child fills its pipe and the tool call never completes. -/// A consumer that falls behind drops rather than stalls. -pub type StderrSink = Arc; - -/// Identity of a tool invocation, used to tag stderr lines forwarded to -/// tracing. -/// -/// Pass `None` to disable stderr forwarding (e.g. for argument-formatting -/// invocations where stderr is not meaningful to the user). -#[derive(Clone)] -pub struct ToolTrace<'a> { - pub id: &'a str, - pub name: &'a str, + /// Sets the arguments for this executor. + #[must_use] + pub fn with_arguments(mut self, args: Map) -> Self { + self.arguments = args; + self + } - /// Where to send each line for display, in addition to tracing. + /// Sets the permission info for this executor. /// - /// `None` when nothing is watching, which is the common case: tracing and - /// the accumulated buffer are unaffected either way. - pub stderr: Option, -} + /// If set, the executor will require permission prompting based on the + /// configured `RunMode`. + #[must_use] + pub fn with_permission_info(mut self, info: PermissionInfo) -> Self { + self.permission_info = Some(info); + self + } -impl fmt::Debug for ToolTrace<'_> { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("ToolTrace") - .field("id", &self.id) - .field("name", &self.name) - .field("stderr", &self.stderr.is_some()) - .finish() + /// Sets a custom result for this executor. + #[must_use] + pub fn with_result(mut self, result: ExecutorResult) -> Self { + self.result = Mutex::new(Some(result)); + self } } -/// Custom minijinja formatter used by [`run_tool_command`]. -/// -/// Scalars (strings, numbers, booleans) render raw — a template like -/// `{{tool.arguments.title}}` produces the bare string, not a JSON-quoted one. -/// Composites (sequences, maps, other iterables) serialize as JSON, so -/// `{{tool}}` and `{{context}}` produce valid JSON blobs without needing an -/// explicit `| tojson` filter at every call site. -/// `null`/undefined render as the literal `null`, matching the JSON convention -/// used by tool authors. -/// -/// Safe strings (e.g. the output of the `tojson` filter) pass through unchanged -/// so explicit opt-in JSON rendering continues to work. -fn format_tool_template_value( - out: &mut minijinja::Output<'_>, - _state: &minijinja::State<'_, '_>, - value: &minijinja::value::Value, -) -> Result<(), minijinja::Error> { - if value.is_safe() { - return write!(out, "{value}").map_err(Into::into); +#[async_trait] +impl Executor for MockExecutor { + fn tool_id(&self) -> &str { + &self.tool_id } - match value.kind() { - ValueKind::None | ValueKind::Undefined => write!(out, "null").map_err(Into::into), - ValueKind::String | ValueKind::Bool | ValueKind::Number => { - write!(out, "{value}").map_err(Into::into) - } - // Composites serialize as JSON so tool authors don't have to remember - // `| tojson` for every `{{tool}}` / `{{context}}` interpolation. - _ => { - let json = serde_json::to_string(value).map_err(|error| { - minijinja::Error::new( - MinijinjaErrorKind::BadSerialization, - "failed to serialize value as JSON", - ) - .with_source(error) - })?; - out.write_str(&json).map_err(Into::into) - } + fn tool_name(&self) -> &str { + &self.tool_name } -} - -/// Run a tool command asynchronously with cancellation support. -/// -/// This is the **single entry point** for running tool commands (both execution -/// and argument formatting). -/// It handles: -/// -/// 1. Template rendering via [`minijinja`] -/// 2. Process spawning via Tokio's [`Command`] -/// 3. Cancellation via [`CancellationToken`] -/// 4. Parsing stdout as [`jp_tool::Outcome`] -/// 5. Forwarding the child's stderr to tracing (when `trace_as` is `Some`) -/// -/// # Panics -/// -/// Panics if tokio fails to attach the piped stdout/stderr handles to the -/// spawned child. -/// Both are requested via `Stdio::piped()`, so this is not expected to happen -/// in practice. -pub async fn run_tool_command( - command: CommandConfig, - ctx: Value, - root: &Utf8Path, - cancellation_token: CancellationToken, - trace_as: Option>, -) -> Result { - let CommandConfig { - program, - args, - shell, - } = command; - - let mut env = Environment::new(); - env.set_formatter(format_tool_template_value); - let tmpl = Arc::new(env); - - let program = tmpl - .render_str(&program, &ctx) - .map_err(|error| ToolError::TemplateError { - data: program.clone(), - error: Box::new(error), - })?; - - let args = args - .iter() - .map(|s| tmpl.render_str(s, &ctx)) - .collect::, _>>() - .map_err(|error| ToolError::TemplateError { - data: args.join(" "), - error: Box::new(error), - })?; - - let mut cmd = if shell { - // `program` is shell syntax and used verbatim; `args` are shell-quoted - // so multi-word arguments keep their boundaries. - let shell_cmd = shell_command_line(&program, &args); - - let mut cmd = Command::new("sh"); - cmd.arg("-c").arg(&shell_cmd); - cmd - } else { - let mut cmd = Command::new(&program); - cmd.args(&args); - cmd - }; - - // Isolate the child from JP's process group so terminal signals - // (Ctrl+C / SIGINT) don't kill it. JP manages tool lifecycle via - // the cancellation token, not Unix signals. - #[cfg(unix)] - cmd.process_group(0); - - // Ensure the child is killed when the tokio task is aborted on - // cancellation. Without this the process would be orphaned. - cmd.kill_on_drop(true); - let mut child = cmd - .current_dir(root.as_std_path()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .map_err(|error| ToolError::SpawnError { - command: format!( - "{} {}", - cmd.as_std().get_program().to_string_lossy(), - cmd.as_std() - .get_args() - .filter_map(OsStr::to_str) - .collect::>() - .join(" ") - ), - error, - })?; - - let stdout = child.stdout.take().expect("stdout piped"); - let stderr = child.stderr.take().expect("stderr piped"); - - let run = async { - tokio::try_join!( - read_all(stdout), - forward_stderr(stderr, trace_as), - child.wait(), - ) - }; - - tokio::select! { - biased; - () = cancellation_token.cancelled() => Ok(CommandResult::Cancelled), - result = run => Ok(match result { - Ok((stdout, stderr, status)) => { - parse_command_output(&stdout, &stderr, status.success()) - } - Err(error) => CommandResult::RawOutput { - stdout: String::new(), - stderr: error.to_string(), - success: false, - }, - }), + fn arguments(&self) -> &Map { + &self.arguments } -} - -/// Drain a child pipe into a byte buffer. -async fn read_all(mut pipe: impl tokio::io::AsyncRead + Unpin) -> std::io::Result> { - let mut buf = Vec::new(); - pipe.read_to_end(&mut buf).await?; - Ok(buf) -} -/// Drain a child's stderr into a byte buffer, optionally forwarding each line -/// to tracing as it arrives. -/// -/// Uses byte-level line reading so non-UTF-8 stderr doesn't terminate the -/// forwarder. -async fn forward_stderr( - pipe: impl tokio::io::AsyncRead + Unpin, - trace_as: Option>, -) -> std::io::Result> { - let mut reader = BufReader::new(pipe); - let mut all = Vec::new(); - let mut line = Vec::new(); - - loop { - line.clear(); - if reader.read_until(b'\n', &mut line).await? == 0 { - break; - } - - if let Some(ToolTrace { id, name, stderr }) = &trace_as { - let text = String::from_utf8_lossy(&line); - let trimmed = text.trim_end_matches(['\n', '\r']); - if !trimmed.is_empty() { - trace!(target: "tool::stderr", tool_id = id, tool_name = name, "{trimmed}"); - - if let Some(sink) = stderr { - sink(trimmed); - } - } - } - - all.extend_from_slice(&line); + fn permission_info(&self) -> Option { + self.permission_info.clone() } - Ok(all) -} - -/// Parse raw command output into a [`CommandResult`]. -/// -/// Tries to deserialize stdout as [`jp_tool::Outcome`]. -/// If that fails, falls back to [`CommandResult::RawOutput`]. -fn parse_command_output(stdout: &[u8], stderr: &[u8], success: bool) -> CommandResult { - let stdout_str = String::from_utf8_lossy(stdout); - - match serde_json::from_str::(&stdout_str) { - Ok(Outcome::Success { content }) => CommandResult::Success(content), - Ok(Outcome::Error { - transient, - message, - trace, - }) => { - if transient { - CommandResult::TransientError { message, trace } - } else { - CommandResult::FatalError(stdout_str.into_owned()) - } - } - Ok(Outcome::NeedsInput { question }) => CommandResult::NeedsInput(question), - // A payload shaped like a `needs_input` outcome that fails to - // deserialize must become a tool-level error, not `RawOutput`: - // silently handing the raw JSON to the model hides the failure (a - // stale local-tool binary emitting an older wire shape than this build - // parses, an invalid question id, a missing field) and leaves the - // model to invent an explanation. Output that is not an `Outcome` at - // all stays `RawOutput`. - Err(error) => { - let value = serde_json::from_str::(&stdout_str).ok(); - let is_needs_input = value - .as_ref() - .and_then(|v| v.get("type")) - .and_then(Value::as_str) - == Some("needs_input"); - - if !is_needs_input { - return CommandResult::RawOutput { - stdout: stdout_str.into_owned(), - stderr: String::from_utf8_lossy(stderr).into_owned(), - success, - }; - } - - let question_id = value - .as_ref() - .and_then(|v| v.get("question")) - .and_then(|q| q.get("id")) - .and_then(Value::as_str); - - match question_id { - // The id itself is the problem: empty, or containing the `.` - // reserved as the inquiry-id separator (`QuestionId` rejects - // both). - Some(id) if id.is_empty() || id.contains('.') => CommandResult::InvalidInquiry { - question_id: id.to_owned(), - }, - // Some other field failed to parse (wrong shape, missing - // field, protocol skew). - _ => CommandResult::MalformedInquiry { - detail: error.to_string(), - }, - } - } + fn set_arguments(&mut self, _args: Value) { + // No-op for mock executor - arguments don't affect the pre-configured + // result } -} -/// Identity of the conversation an invocation belongs to. -/// -/// Surfaced to local tools through the rendered template `context` (as -/// `context.workspace_id` and `context.conversation_id`) so a tool can scope -/// any state it persists to the originating workspace and conversation. -#[derive(Debug, Clone, Default)] -pub struct InvocationContext { - pub workspace_id: String, - pub conversation_id: String, + async fn execute( + &self, + _answers: &IndexMap, + _mcp_client: &Client, + _root: &Utf8Path, + _cancellation_token: CancellationToken, + _stderr: Option, + ) -> ExecutorResult { + self.result.lock().unwrap().take().unwrap_or_else(|| { + ExecutorResult::Completed(ToolCallResponse { + id: self.tool_id.clone(), + result: Err("MockExecutor: result already consumed".to_string()), + }) + }) + } } -/// Execute a tool without any interactive prompts. -/// -/// This is a pure execution path that runs the tool's underlying command or MCP -/// call and returns an [`ExecutionOutcome`]. -/// All interactive decisions (permission prompts, result editing, question -/// handling) are the caller's responsibility. -/// -/// # Arguments +/// An executor source for testing that returns pre-registered mock executors. /// -/// - `id` - The tool call ID for correlation with the request -/// - `arguments` - The tool arguments (caller is responsible for any -/// pre-processing) -/// - `answers` - Pre-provided answers to tool questions (from previous -/// `NeedsInput`) -/// - `config` - Tool configuration -/// - `mcp_client` - MCP client for MCP tool execution -/// - `root` - Working directory for local tool execution -/// - `cancellation_token` - Token to cancel long-running execution -/// - `builtin_executors` - Registry of builtin tools -/// -/// # Returns -/// -/// - [`ExecutionOutcome::Completed`] - Tool finished (check inner `Result` for -/// success/error) -/// - [`ExecutionOutcome::NeedsInput`] - Tool needs user input to continue -/// - [`ExecutionOutcome::Cancelled`] - Execution was cancelled via the token -/// -/// # Errors -/// -/// Returns [`ToolError`] for infrastructure errors (spawn failure, missing -/// command, etc.). -/// Tool-level errors (command returned non-zero) are returned as -/// `Ok(ExecutionOutcome::Completed { result: Err(...) })`. +/// This allows tests to inject mock executors for specific tool names without +/// executing any real shell commands. /// /// # Example /// /// ```ignore -/// loop { -/// match execute(&definition, id, args, &answers, ...).await? { -/// ExecutionOutcome::Completed { result, .. } => { -/// // Handle success or tool error -/// break result; -/// } -/// ExecutionOutcome::NeedsInput { question, .. } => { -/// // Prompt user for input -/// let answer = prompt_user(&question)?; -/// answers.insert(question.id, answer); -/// // Loop to retry with answer -/// } -/// ExecutionOutcome::Cancelled { .. } => { -/// break Ok("Cancelled".into()); -/// } -/// } -/// } +/// let source = TestExecutorSource::new() +/// .with_executor("my_tool", |req| { +/// Box::new(MockExecutor::completed(&req.id, &req.name, "mock output")) +/// }); +/// +/// let coordinator = ToolCoordinator::new(tools_config, Arc::new(source)); /// ``` -#[expect(clippy::too_many_arguments)] -pub async fn execute( - definition: &ToolDefinition, - id: String, - arguments: Value, - answers: &IndexMap, - config: &ToolConfigWithDefaults, - mcp_client: &jp_mcp::Client, - root: &Utf8Path, - cancellation_token: CancellationToken, - builtin_executors: &builtin::BuiltinExecutors, - access: Option<&jp_tool::AccessPolicy>, - invocation: &InvocationContext, - stderr: Option, -) -> Result { - let mut arguments = arguments; - if let Some(arguments) = arguments.as_object_mut() { - definition.coerce_arguments(arguments); - } - info!(tool = %definition.name, arguments = ?arguments, "Executing tool."); - - match config.source() { - ToolSource::Local { tool } => { - execute_local( - definition, - id, - arguments, - answers, - config, - tool.as_deref(), - root, - cancellation_token, - access, - invocation, - stderr, - ) - .await - } - ToolSource::Mcp { server, tool } => { - execute_mcp( - definition, - id, - arguments, - mcp_client, - server, - tool.as_deref(), - cancellation_token, - ) - .await - } - ToolSource::Builtin { tool } => { - execute_builtin( - definition, - id, - &arguments, - answers, - tool.as_deref(), - builtin_executors, - ) - .await - } - } +pub struct TestExecutorSource { + #[allow(clippy::type_complexity)] + factories: std::collections::HashMap< + String, + Box Box + Send + Sync>, + >, } -/// Execute a local tool and return the outcome. -/// -/// This is the pure execution path for local tools. -/// It validates arguments, runs the command, and converts the result to an -/// `ExecutionOutcome`. -#[expect(clippy::too_many_arguments)] -async fn execute_local( - definition: &ToolDefinition, - id: String, - mut arguments: Value, - answers: &IndexMap, - config: &ToolConfigWithDefaults, - tool: Option<&str>, - root: &Utf8Path, - cancellation_token: CancellationToken, - access: Option<&jp_tool::AccessPolicy>, - invocation: &InvocationContext, - stderr: Option, -) -> Result { - let name = tool.unwrap_or(&definition.name); - - // Apply configured defaults for missing parameters, then validate. - if let Some(args) = arguments.as_object_mut() { - apply_parameter_defaults(args, &definition.parameters); - - if let Err(error) = validate_tool_arguments(args, &definition.parameters) { - return Ok(ExecutionOutcome::Completed { - id, - result: Err(format!( - "Invalid arguments: {error}\n\nYou can call `describe_tools(tools: \ - [\"{name}\"])` to learn more about how to use the tool correctly." - )), - }); +impl TestExecutorSource { + /// Creates a new empty test executor source. + #[must_use] + pub fn new() -> Self { + Self { + factories: std::collections::HashMap::new(), } } - let ctx = json!({ - "tool": { - "name": name, - "arguments": &arguments, - "answers": answers, - "options": config.options(), - }, - "context": { - "action": Action::Run, - "root": root.as_str(), - "access": access, - "workspace_id": &invocation.workspace_id, - "conversation_id": &invocation.conversation_id, - }, - }); - - let Some(command) = config.command() else { - return Err(ToolError::MissingCommand); - }; - - let trace_as = ToolTrace { - id: &id, - name, - stderr, - }; - - match run_tool_command(command, ctx, root, cancellation_token, Some(trace_as)).await? { - CommandResult::Success(content) => Ok(ExecutionOutcome::Completed { - id, - result: Ok(content), - }), - CommandResult::NeedsInput(question) => Ok(ExecutionOutcome::NeedsInput { id, question }), - CommandResult::Cancelled => Ok(ExecutionOutcome::Cancelled { id }), - other => Ok(ExecutionOutcome::Completed { - id, - result: other.into_tool_result(name), - }), + /// Registers a factory function for a tool name. + /// + /// When `create()` is called for this tool name, the factory will be + /// invoked to create the executor. + #[must_use] + pub fn with_executor(mut self, tool_name: &str, factory: F) -> Self + where + F: Fn(ToolCallRequest) -> Box + Send + Sync + 'static, + { + self.factories + .insert(tool_name.to_string(), Box::new(factory)); + self } -} - -/// Execute an MCP tool and return the outcome. -/// -/// This is the pure execution path for MCP tools. -/// It calls the MCP server and converts the result to an `ExecutionOutcome`. -async fn execute_mcp( - definition: &ToolDefinition, - id: String, - arguments: Value, - mcp_client: &jp_mcp::Client, - server: &str, - tool: Option<&str>, - cancellation_token: CancellationToken, -) -> Result { - let name = tool.unwrap_or(&definition.name); - let call_future = mcp_client.call_tool(name, server, &arguments); - - tokio::select! { - biased; - () = cancellation_token.cancelled() => { - info!(tool = %definition.name, "MCP tool call cancelled"); - Ok(ExecutionOutcome::Cancelled { id }) - } - result = call_future => { - let result = result - .map_err(|error| ToolError::McpRunToolError(Box::new(error)))?; - - let content = result - .content - .into_iter() - .filter_map(|v| match v.raw { - RawContent::Text(v) => Some(v.text), - RawContent::Resource(v) => match v.resource { - ResourceContents::TextResourceContents { text, .. } => Some(text), - ResourceContents::BlobResourceContents { blob, .. } => Some(blob), - }, - RawContent::Image(_) | RawContent::Audio(_) | RawContent::ResourceLink(_) => None, - }) - .collect::>() - .join("\n\n"); - - let result = if result.is_error.unwrap_or_default() { - Err(content) - } else { - Ok(content) - }; - - Ok(ExecutionOutcome::Completed { id, result }) - } + /// Returns stub [`ToolDefinition`]s for all registered tool names. + /// + /// Useful for passing to `run_turn_loop` so the availability check accepts + /// the tools this source can handle. + #[must_use] + pub fn tool_definitions(&self) -> Vec { + self.factories + .keys() + .map(|name| ToolDefinition { + name: name.clone(), + docs: jp_tool::ToolDocs::default(), + parameters: serde_json::json!({ "type": "object", "properties": {} }), + }) + .collect() } } -/// Execute a builtin tool and return the outcome. -/// -/// `source_name` is the implementation named by `source = "builtin."`, -/// which the registry is keyed on. -/// When absent, the implementation shares the tool's own name. -async fn execute_builtin( - definition: &ToolDefinition, - id: String, - arguments: &Value, - answers: &IndexMap, - source_name: Option<&str>, - builtin_executors: &builtin::BuiltinExecutors, -) -> Result { - let name = source_name.unwrap_or(&definition.name); - let executor = builtin_executors - .get(name) - .ok_or_else(|| ToolError::NotFound { - name: name.to_owned(), - })?; - - let outcome = executor.execute(arguments, answers).await; - - Ok(match outcome { - Outcome::Success { content } => ExecutionOutcome::Completed { - id, - result: Ok(content), - }, - Outcome::Error { - message, - trace, - transient: _, - } => { - let error_msg = if trace.is_empty() { - message - } else { - format!("{message}\n\nTrace:\n{}", trace.join("\n")) - }; - ExecutionOutcome::Completed { - id, - result: Err(error_msg), - } - } - Outcome::NeedsInput { question } => ExecutionOutcome::NeedsInput { id, question }, - }) -} - -/// Resolve all enabled tool definitions from config. -/// -/// If `forced_tool` is provided (e.g. from `ToolChoice::Function`), that tool -/// is included even when it is disabled, preventing a mismatch between -/// `tool_choice` and the declared tools list that some providers (notably -/// Google/Gemini) reject outright. -/// -/// A locked-off tool (`state = false`, `allow_toggle = never`) is the -/// exception: it is always dropped, even when named by `forced_tool`. -pub async fn tool_definitions( - configs: impl Iterator, - mcp_client: &jp_mcp::Client, - forced_tool: Option<&str>, -) -> Result, ToolError> { - let mut definitions = Vec::new(); - - for (name, config) in configs { - let enable = config.effective_enable(); - let forced = forced_tool.is_some_and(|f| f == name); - // Drop disabled tools, but keep a forced tool unless it is locked-off. - if !enable.is_enabled() && (!forced || enable.is_locked()) { - continue; - } - - // Drop MCP-backed tools whose server failed to start while marked - // optional. The server is absent from the running services map, and - // we don't want to hand the LLM a tool it cannot invoke. - if let ToolSource::Mcp { server, .. } = config.source() { - let server_id = McpServerId::new(server); - if !mcp_client.is_running(&server_id).await { - warn!( - tool = name, - server = %server, - "Skipping MCP tool: backing server is not running." - ); - continue; - } - } - - // A tool JP cannot describe to the provider is dropped rather than - // failing the query, matching the unavailable-server case above. A tool - // the caller named explicitly is the exception: silently omitting it - // would leave `tool_choice` pointing at a tool the provider never saw. - let definition = match resolve_tool(name, &config, mcp_client).await { - Ok(definition) => definition, - Err(error) if !forced => { - warn!( - tool = name, - %error, - "Skipping tool: its parameter schema could not be resolved." - ); - continue; - } - Err(error) => return Err(error), - }; - definitions.push(definition); +impl Default for TestExecutorSource { + fn default() -> Self { + Self::new() } - - Ok(definitions) } -/// Resolve a single tool definition and its documentation. -async fn resolve_tool( - name: &str, - config: &ToolConfigWithDefaults, - mcp_client: &jp_mcp::Client, -) -> Result { - let path = format!("conversation.tools.{name}.parameters"); - let definition = match config.source() { - ToolSource::Local { .. } | ToolSource::Builtin { .. } => ToolDefinition { - name: name.to_owned(), - docs: tool_docs_from_config(config), - parameters: json_schema::from_config(&path, config.parameters())?, - }, - ToolSource::Mcp { server, tool } => { - resolve_mcp_tool(server, name, tool.as_deref(), config, mcp_client).await? - } - }; - - jp_tool::schema::validate(&path, &definition.parameters)?; - - Ok(definition) +impl ExecutorSource for TestExecutorSource { + fn create( + &self, + request: ToolCallRequest, + _config: ToolConfigWithDefaults, + ) -> Option> { + let factory = self.factories.get(&request.name)?; + Some(factory(request)) + } } -/// Resolve an MCP tool: fetch from server, merge config overrides, auto-split -/// descriptions into summary + detail. -async fn resolve_mcp_tool( - server: &str, - name: &str, - source_name: Option<&str>, - config: &ToolConfigWithDefaults, - mcp_client: &jp_mcp::Client, -) -> Result { - let mcp_tool = { - trace!(server = %server, tool = %name, "Fetching tool from MCP server"); - - let server_id = McpServerId::new(server); - mcp_client - .get_tool(&McpToolId::new(source_name.unwrap_or(name)), &server_id) - .await - .map_err(|error| ToolError::McpGetToolError(Box::new(error))) - }?; - - let user_overrides = config.parameters(); - - // Merge tool-level description. - let merged_description = merge_description( - config.description().map(str::to_owned), - mcp_tool.description.as_deref(), - ); - - // The server's document is the source of truth; configuration may narrow - // it, and nothing else touches it. - let source = Value::Object(mcp_tool.input_schema.as_ref().clone()); - let parameters = json_schema::with_overrides( - &format!("conversation.tools.{name}.parameters"), - &source, - user_overrides, - )?; - - // Build docs with auto-split heuristic. - let has_user_summary = config.summary().is_some(); - - let (summary, description) = if has_user_summary { - // User provided explicit summary -- use config fields as-is. - ( - config.summary().map(str::to_owned), - config.description().map(str::to_owned), - ) - } else if let Some(ref desc) = merged_description { - let (s, d) = split_description(desc); - (Some(s), d) - } else { - (None, None) - }; - - let examples = config.examples().map(str::to_owned); - - // Per-parameter docs: auto-split MCP descriptions when user didn't override. - let param_docs = Node::root(¶meters) - .properties() - .into_iter() - .filter_map(|(pname, pnode)| { - let user_override = user_overrides.get(&pname); - let has_user_param_summary = user_override.and_then(|o| o.summary.as_ref()).is_some(); - - let (summary, desc) = if has_user_param_summary { - let summary = user_override - .and_then(|o| o.summary.as_deref()) - .or(user_override.and_then(|o| o.description.as_deref())) - .map(str::to_owned); - let desc = user_override - .and_then(|o| o.description.as_deref()) - .map(str::to_owned); - (summary, desc) - } else if let Some(resolved) = pnode.description() { - let (s, d) = split_description(resolved); - (Some(s), d) - } else { - (None, None) - }; +/// Information needed to prompt for tool execution permission. +/// +/// This struct contains all the data the `ToolPrompter` needs to show a +/// permission prompt to the user. +#[derive(Debug, Clone)] +pub struct PermissionInfo { + /// The tool call ID. + pub tool_id: String, - let ex = user_override - .and_then(|o| o.examples.as_deref()) - .map(str::to_owned); + /// The tool name. + pub tool_name: String, - if summary.is_none() && desc.is_none() && ex.is_none() { - return None; - } + /// The tool source (builtin, local, MCP). + pub tool_source: ToolSource, - Some((pname, ParameterDocs { - summary, - description: desc, - examples: ex, - })) - }) - .collect(); + /// The configured run mode. + pub run_mode: RunMode, - let docs = ToolDocs { - summary, - description, - examples, - parameters: param_docs, - }; - - Ok(ToolDefinition { - name: name.to_owned(), - docs, - parameters, - }) + /// The arguments to pass to the tool. + pub arguments: Value, } - -#[cfg(test)] -#[path = "tool_tests.rs"] -mod tests; diff --git a/crates/jp_llm/src/tool/executor.rs b/crates/jp_llm/src/tool/executor.rs deleted file mode 100644 index adcf78946..000000000 --- a/crates/jp_llm/src/tool/executor.rs +++ /dev/null @@ -1,366 +0,0 @@ -use std::sync::Mutex; - -use async_trait::async_trait; -use camino::Utf8Path; -use indexmap::IndexMap; -use jp_config::conversation::tool::{RunMode, ToolConfigWithDefaults, ToolSource}; -use jp_conversation::event::{InquirySource, ToolCallRequest, ToolCallResponse}; -use jp_mcp::Client; -use jp_tool::Question; -use serde_json::{Map, Value}; -use tokio_util::sync::CancellationToken; - -use super::{StderrSink, ToolDefinition}; - -/// Trait for tool execution, enabling mock implementations for testing. -/// -/// This trait abstracts the execution of a single tool call, allowing the -/// `ToolCoordinator` to work with both real and mock executors. -/// -/// # Design -/// -/// The executor is intentionally simple - it just executes tools with given -/// answers. -/// All decision-making about question targets, static answers, and how to -/// handle `NeedsInput` is done by the coordinator, which has access to the tool -/// configuration. -#[async_trait] -pub trait Executor: Send + Sync { - /// Returns the tool call ID. - fn tool_id(&self) -> &str; - - /// Returns the tool name. - fn tool_name(&self) -> &str; - - /// Returns the tool call arguments. - /// - /// This is separate from [`permission_info()`] because arguments are always - /// available, while permission info is only present for tools that require - /// a permission prompt. - /// - /// [`permission_info()`]: Self::permission_info - fn arguments(&self) -> &Map; - - /// Returns information needed for permission prompting. - /// - /// Returns `None` if the tool doesn't need a permission prompt (e.g., - /// `RunMode::Unattended` or `RunMode::Skip`). - fn permission_info(&self) -> Option; - - /// Updates the arguments to use for execution. - /// - /// This is called after permission prompting if the user edited the - /// arguments (via `RunMode::Edit`). - /// The new arguments replace the original arguments from the tool call - /// request. - fn set_arguments(&mut self, args: Value); - - /// Executes the tool once with the given answers. - /// - /// This method performs a single execution pass. - /// If the tool needs additional input, it returns - /// `ExecutorResult::NeedsInput` and the coordinator handles prompting and - /// retrying. - /// - /// The executor doesn't know how questions should be answered - it just - /// reports that input is needed. - /// The coordinator looks up the tool configuration to determine whether to - /// prompt the user or ask the LLM. - /// - /// # Arguments - /// - /// - `answers` - Accumulated answers from previous `NeedsInput` responses - /// - `mcp_client` - MCP client for remote tool execution - /// - `root` - Project root directory - /// - `cancellation_token` - Token to cancel execution - /// - `stderr` - Receives the tool's stderr lines as they arrive, for a - /// caller showing progress while it runs. - /// `None` when nothing is watching; the lines still reach tracing and the - /// accumulated buffer either way. - async fn execute( - &self, - answers: &IndexMap, - mcp_client: &Client, - root: &Utf8Path, - cancellation_token: CancellationToken, - stderr: Option, - ) -> ExecutorResult; -} - -/// Abstraction over how executors are created for tool calls. -/// -/// This trait enables dependency injection of executor creation, allowing tests -/// to use mock executors without executing real shell commands. -pub trait ExecutorSource: Send + Sync { - /// Creates an executor for the given tool call request. - /// - /// Returns `None` if the tool cannot be resolved (e.g. missing from the - /// definitions). - fn create( - &self, - request: ToolCallRequest, - config: ToolConfigWithDefaults, - ) -> Option>; -} - -/// Result of a tool execution attempt. -/// -/// Tools may need multiple rounds of execution if they require additional -/// input. -/// This enum allows the executor to return control to the coordinator, which -/// decides how to handle the `NeedsInput` case by looking up the question -/// configuration. -#[derive(Debug)] -#[allow(clippy::large_enum_variant)] // NeedsInput variant is larger but rarely used -pub enum ExecutorResult { - /// Tool completed (success or error). - Completed(ToolCallResponse), - - /// Tool needs additional input before it can continue. - /// - /// The executor doesn't know who should answer - it just reports that input - /// is needed. - /// The coordinator looks up the question configuration to determine the - /// target: - /// - /// - `User`: Prompt the user interactively, then restart the tool - /// - `Assistant`: Format a response asking the LLM to re-run with answers - NeedsInput { - /// Tool call ID. - tool_id: String, - - /// Tool name (for persisting answers). - tool_name: String, - - /// The question that needs to be answered. - question: Question, - - /// Resolved provenance for the persisted `InquiryRequest`. - /// - /// Built-in tools may override this via `BuiltinTool::inquiry_source`; - /// local and MCP tools attribute the question to the tool by name. - source: InquirySource, - - /// Accumulated answers so far (for retry). - accumulated_answers: IndexMap, - }, -} - -/// A mock executor for testing that returns pre-configured results. -/// -/// This executor doesn't execute any real commands - it simply returns whatever -/// result is configured, making it ideal for testing tool coordination flows -/// without side effects. -/// -/// # Example -/// -/// ```ignore -/// let executor = MockExecutor::completed("call_1", "my_tool", "success output"); -/// let result = executor.execute(&answers, &client, &root, token).await; -/// assert!(result.is_completed()); -/// ``` -pub struct MockExecutor { - tool_id: String, - tool_name: String, - arguments: Map, - permission_info: Option, - result: Mutex>, -} - -impl MockExecutor { - /// Creates a mock executor that returns a successful completion. - #[must_use] - pub fn completed(tool_id: &str, tool_name: &str, output: &str) -> Self { - Self { - tool_id: tool_id.to_string(), - tool_name: tool_name.to_string(), - arguments: Map::new(), - permission_info: None, - result: Mutex::new(Some(ExecutorResult::Completed(ToolCallResponse { - id: tool_id.to_string(), - result: Ok(output.to_string()), - }))), - } - } - - /// Creates a mock executor that returns an error. - #[must_use] - pub fn error(tool_id: &str, tool_name: &str, error: &str) -> Self { - Self { - tool_id: tool_id.to_string(), - tool_name: tool_name.to_string(), - arguments: Map::new(), - permission_info: None, - result: Mutex::new(Some(ExecutorResult::Completed(ToolCallResponse { - id: tool_id.to_string(), - result: Err(error.to_string()), - }))), - } - } - - /// Sets the arguments for this executor. - #[must_use] - pub fn with_arguments(mut self, args: Map) -> Self { - self.arguments = args; - self - } - - /// Sets the permission info for this executor. - /// - /// If set, the executor will require permission prompting based on the - /// configured `RunMode`. - #[must_use] - pub fn with_permission_info(mut self, info: PermissionInfo) -> Self { - self.permission_info = Some(info); - self - } - - /// Sets a custom result for this executor. - #[must_use] - pub fn with_result(mut self, result: ExecutorResult) -> Self { - self.result = Mutex::new(Some(result)); - self - } -} - -#[async_trait] -impl Executor for MockExecutor { - fn tool_id(&self) -> &str { - &self.tool_id - } - - fn tool_name(&self) -> &str { - &self.tool_name - } - - fn arguments(&self) -> &Map { - &self.arguments - } - - fn permission_info(&self) -> Option { - self.permission_info.clone() - } - - fn set_arguments(&mut self, _args: Value) { - // No-op for mock executor - arguments don't affect the pre-configured - // result - } - - async fn execute( - &self, - _answers: &IndexMap, - _mcp_client: &Client, - _root: &Utf8Path, - _cancellation_token: CancellationToken, - _stderr: Option, - ) -> ExecutorResult { - self.result.lock().unwrap().take().unwrap_or_else(|| { - ExecutorResult::Completed(ToolCallResponse { - id: self.tool_id.clone(), - result: Err("MockExecutor: result already consumed".to_string()), - }) - }) - } -} - -/// An executor source for testing that returns pre-registered mock executors. -/// -/// This allows tests to inject mock executors for specific tool names without -/// executing any real shell commands. -/// -/// # Example -/// -/// ```ignore -/// let source = TestExecutorSource::new() -/// .with_executor("my_tool", |req| { -/// Box::new(MockExecutor::completed(&req.id, &req.name, "mock output")) -/// }); -/// -/// let coordinator = ToolCoordinator::new(tools_config, Arc::new(source)); -/// ``` -pub struct TestExecutorSource { - #[allow(clippy::type_complexity)] - factories: std::collections::HashMap< - String, - Box Box + Send + Sync>, - >, -} - -impl TestExecutorSource { - /// Creates a new empty test executor source. - #[must_use] - pub fn new() -> Self { - Self { - factories: std::collections::HashMap::new(), - } - } - - /// Registers a factory function for a tool name. - /// - /// When `create()` is called for this tool name, the factory will be - /// invoked to create the executor. - #[must_use] - pub fn with_executor(mut self, tool_name: &str, factory: F) -> Self - where - F: Fn(ToolCallRequest) -> Box + Send + Sync + 'static, - { - self.factories - .insert(tool_name.to_string(), Box::new(factory)); - self - } - - /// Returns stub [`ToolDefinition`]s for all registered tool names. - /// - /// Useful for passing to `run_turn_loop` so the availability check accepts - /// the tools this source can handle. - #[must_use] - pub fn tool_definitions(&self) -> Vec { - self.factories - .keys() - .map(|name| ToolDefinition { - name: name.clone(), - docs: super::ToolDocs::default(), - parameters: serde_json::json!({ "type": "object", "properties": {} }), - }) - .collect() - } -} - -impl Default for TestExecutorSource { - fn default() -> Self { - Self::new() - } -} - -impl ExecutorSource for TestExecutorSource { - fn create( - &self, - request: ToolCallRequest, - _config: ToolConfigWithDefaults, - ) -> Option> { - let factory = self.factories.get(&request.name)?; - Some(factory(request)) - } -} - -/// Information needed to prompt for tool execution permission. -/// -/// This struct contains all the data the `ToolPrompter` needs to show a -/// permission prompt to the user. -#[derive(Debug, Clone)] -pub struct PermissionInfo { - /// The tool call ID. - pub tool_id: String, - - /// The tool name. - pub tool_name: String, - - /// The tool source (builtin, local, MCP). - pub tool_source: ToolSource, - - /// The configured run mode. - pub run_mode: RunMode, - - /// The arguments to pass to the tool. - pub arguments: Value, -} diff --git a/crates/jp_mcp/Cargo.toml b/crates/jp_mcp/Cargo.toml index f8b26a929..bb83a1953 100644 --- a/crates/jp_mcp/Cargo.toml +++ b/crates/jp_mcp/Cargo.toml @@ -12,20 +12,48 @@ readme.workspace = true repository.workspace = true version.workspace = true +[features] +default = ["client"] + +# Connect to the MCP servers named in `providers.mcp`. +client = ["rmcp/client", "rmcp/transport-child-process", "rmcp/transport-io"] + +# Run JP's tools: local commands, built-ins, and the tools those MCP servers +# declare. Implies `client`, because running an MCP tool means calling one. +server = ["client", "dep:async-trait", "dep:camino", "dep:jp_tool", "dep:minijinja", "dep:tokio-util"] + [dependencies] jp_config = { workspace = true } +jp_tool = { workspace = true, optional = true } +async-trait = { workspace = true, optional = true } +camino = { workspace = true, optional = true } indexmap = { workspace = true } -rmcp = { workspace = true, features = ["client", "transport-child-process", "transport-io"] } +minijinja = { workspace = true, optional = true, features = [ + "builtins", + "json", + "preserve_order", + "serde", + "unicode", +] } +rmcp = { workspace = true } serde = { workspace = true } serde_json = { workspace = true, features = ["preserve_order"] } sha1 = { workspace = true } sha2 = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true } +tokio-util = { workspace = true, optional = true } tracing = { workspace = true } which = { workspace = true } +[dev-dependencies] +camino-tempfile = { workspace = true } +assert_matches = { workspace = true } +jp_test = { workspace = true } +test-log = { workspace = true } +tokio = { workspace = true, features = ["test-util"] } + [lints] workspace = true diff --git a/crates/jp_mcp/README.md b/crates/jp_mcp/README.md index 5d02456ac..09e1acb66 100644 --- a/crates/jp_mcp/README.md +++ b/crates/jp_mcp/README.md @@ -1,4 +1,24 @@ -# Model-Context-Protocol (MCP) Client +# Model Context Protocol (MCP) -This crate provides a client to handle multiple Model-Context-Protocol (MCP) -servers. +The default `client` feature manages configured upstream stdio MCP servers. +The `server` feature also enables tool resolution, local command execution, and +the built-in tool registry. + +`server::service::Service` manages individual calls against an immutable tool +catalog and working context. +Its private Host receiver carries admission, execution release, input, result +review, and recording requests. +The MCP Host must service those requests while calls run. +Questions finish an execution attempt; answers trigger a new attempt with +accumulated input. + +Calls have independent cancellation tokens. +Dropping a result receiver does not cancel or retry a call. +`cancel_current` stops current work while allowing later calls; `shutdown` stops +admission, cancels calls, waits for cleanup, and closes owned upstream +connections. +Stderr progress uses a separate bounded channel. + +This library service does not start an HTTP listener. +MCP transport integration and CLI adoption use this service in RFD 109's next +phase. diff --git a/crates/jp_mcp/src/client.rs b/crates/jp_mcp/src/client.rs index 2850a7300..6de334a2e 100644 --- a/crates/jp_mcp/src/client.rs +++ b/crates/jp_mcp/src/client.rs @@ -260,6 +260,26 @@ impl Client { .contents) } + /// Close the owned upstream connections and wait for their service tasks. + /// + /// Callers must stop admitting work before shutdown. + /// All clones share these connections, so this also disconnects users of a + /// cloned client. + pub async fn shutdown(&self) { + let services = { + let mut services = self.services.write().await; + services + .drain() + .map(|(_, service)| service) + .collect::>() + }; + for service in services { + if let Err(error) = service.cancel().await { + warn!(%error, "MCP service failed during shutdown"); + } + } + } + pub async fn run_services( &mut self, server_ids: HashSet, diff --git a/crates/jp_mcp/src/lib.rs b/crates/jp_mcp/src/lib.rs index 0c9175527..46949fa01 100644 --- a/crates/jp_mcp/src/lib.rs +++ b/crates/jp_mcp/src/lib.rs @@ -1,9 +1,21 @@ -//! MCP (Model Context Protocol) client integration for JP. +//! MCP (Model Context Protocol) integration for JP. +//! +//! Two halves, separately selectable: +//! +//! - `client` connects to the MCP servers named in `providers.mcp`. +//! - `server` runs JP's tools, whatever their source, and needs the client to +//! reach the MCP-backed ones. +#[cfg(feature = "client")] mod client; +#[cfg(feature = "client")] pub mod error; pub mod id; +#[cfg(feature = "server")] +pub mod server; +#[cfg(feature = "client")] pub use client::{Client, Startup, StartupSet, StderrLine}; +#[cfg(feature = "client")] pub use error::Error; pub use rmcp::model::{CallToolResult, Content, RawContent, ResourceContents, Tool}; diff --git a/crates/jp_mcp/src/server.rs b/crates/jp_mcp/src/server.rs new file mode 100644 index 000000000..2f0061ea3 --- /dev/null +++ b/crates/jp_mcp/src/server.rs @@ -0,0 +1,1132 @@ +//! Running the tools JP makes available. +//! +//! Resolves a tool from configuration into a [`ToolDefinition`], then runs it: +//! a local command, a built-in Rust implementation, or a call to one of the +//! configured MCP servers, dispatched through the [`Client`] this crate already +//! owns. +//! +//! Each call to [`execute`] runs one execution attempt. +//! The caller handles approvals, input requests, result editing, and +//! conversation recording. + +pub mod builtin; +pub mod json_schema; +pub mod service; + +use std::{ffi::OsStr, fmt, process::Stdio, sync::Arc}; + +pub use builtin::BuiltinTool; +use camino::Utf8Path; +use indexmap::IndexMap; +use jp_config::{ + conversation::tool::{CommandConfig, ToolConfigWithDefaults, ToolSource}, + types::command::shell_command_line, +}; +use jp_tool::{ + Action, Error as ToolError, Outcome, ParameterDocs, Question, ToolDefinition, ToolDocs, + definition::{apply_parameter_defaults, split_description, validate_tool_arguments}, + schema::{Node, merge_description}, +}; +use minijinja::{Environment, ErrorKind as MinijinjaErrorKind, value::ValueKind}; +use serde_json::{Value, json}; +use tokio::{ + io::{AsyncBufReadExt, AsyncReadExt, BufReader}, + process::Command, +}; +use tokio_util::sync::CancellationToken; +use tracing::{error, info, trace, warn}; + +use crate::{ + Client, RawContent, ResourceContents, + id::{McpServerId, McpToolId}, +}; + +/// Read a tool's documentation out of its configuration. +fn tool_docs_from_config(config: &ToolConfigWithDefaults) -> ToolDocs { + let parameters = config + .parameters() + .iter() + .filter_map(|(param_name, param_cfg)| { + let summary = param_cfg + .summary + .as_deref() + .or(param_cfg.description.as_deref()) + .map(str::to_owned); + let desc = param_cfg.description.as_deref().map(str::to_owned); + let ex = param_cfg.examples.as_deref().map(str::to_owned); + + if summary.is_none() && desc.is_none() && ex.is_none() { + return None; + } + + Some((param_name.to_owned(), ParameterDocs { + summary, + description: desc, + examples: ex, + })) + }) + .collect(); + + ToolDocs { + summary: config.summary().map(str::to_owned), + description: config.description().map(str::to_owned), + examples: config.examples().map(str::to_owned), + parameters, + } +} + +/// The outcome of a tool execution. +/// +/// This type represents the possible results of executing a tool's underlying +/// command or MCP call, without any interactive prompts. +/// The caller is responsible for: +/// +/// 1. Handling permission prompts **before** calling [`execute()`]. +/// 2. Handling [`ExecutionOutcome::NeedsInput`] by prompting the user or +/// assistant. +/// 3. Handling result editing **after** receiving the outcome. +/// +/// # Example Flow +/// +/// ```text +/// host execute() +/// ───────────────────── ────────────────────── +/// │ +/// ├── [AwaitingPermission] +/// │ prompt_permission() +/// │ +/// ├── [Running] +/// │ ────────────────────────────► execute() +/// │ │ +/// │ ◄──────────────────────────── ExecutionOutcome +/// ├── [AwaitingInput] (if NeedsInput) +/// │ prompt_question() +/// │ ────────────────────────────► execute() (with answer) +/// │ │ +/// │ ◄──────────────────────────── ExecutionOutcome +/// ├── [AwaitingResultEdit] +/// │ prompt_result_edit() +/// │ +/// └── [Completed] +/// ``` +#[derive(Debug)] +pub enum ExecutionOutcome { + /// Tool executed and produced a result. + Completed { + /// The tool call ID (for correlation with the request). + id: String, + + /// The execution result. + /// + /// If an error occurred, it means the tool ran, but reported an error. + result: Result, + }, + + /// Tool needs additional input before it can complete. + /// + /// The caller should: + /// + /// 1. Present the question to the user (or delegate to the assistant) + /// 2. Collect the answer + /// 3. Call [`execute()`] again with the answer in `answers` + NeedsInput { + /// The tool call ID. + id: String, + + /// The question to ask. + question: Question, + }, + + /// Tool execution was cancelled via the cancellation token. + /// + /// This occurs when the user interrupts tool execution (e.g., Ctrl+C during + /// a long-running command). + Cancelled { + /// The tool call ID. + id: String, + }, +} + +impl ExecutionOutcome { + /// Returns the tool call ID. + #[must_use] + pub fn id(&self) -> &str { + match self { + Self::Completed { id, .. } | Self::NeedsInput { id, .. } | Self::Cancelled { id } => id, + } + } + + /// Returns `true` if this is a `NeedsInput` outcome. + #[must_use] + pub fn needs_input(&self) -> bool { + matches!(self, Self::NeedsInput { .. }) + } + + /// Returns `true` if this is a `Cancelled` outcome. + #[must_use] + pub fn is_cancelled(&self) -> bool { + matches!(self, Self::Cancelled { .. }) + } + + /// Returns `true` if this is a `Completed` outcome with a successful + /// result. + #[must_use] + pub fn is_success(&self) -> bool { + matches!(self, Self::Completed { result: Ok(_), .. }) + } +} + +/// Result of running a tool command. +/// +/// This is the single parsing point for all tool command output. +/// Both tool execution and argument formatting go through this type, ensuring +/// consistent handling of `Outcome` variants (including error traces). +#[derive(Debug)] +pub enum CommandResult { + /// Tool produced content. + Success(String), + + /// Tool reported a transient error (can be retried). + TransientError { + /// The error message. + message: String, + + /// The error trace (source chain from the tool process). + trace: Vec, + }, + + /// Tool reported a fatal error. + FatalError(String), + + /// Tool needs additional input before it can continue. + NeedsInput(Question), + + /// Tool was cancelled via the cancellation token. + Cancelled, + + /// stdout wasn't valid `Outcome` JSON. + /// + /// Falls back to treating stdout as plain text. + /// The `success` flag indicates the process exit status. + RawOutput { + /// Raw stdout content. + stdout: String, + + /// Raw stderr content. + stderr: String, + + /// Whether the process exited successfully. + success: bool, + }, + + /// Tool emitted a well-formed `needs_input` whose question id is invalid + /// (empty, or contains a `.`, which is reserved as the inquiry-id + /// separator). + /// + /// Surfaced as a tool-level error so the malformed inquiry is dropped + /// before any inquiry event is constructed. + InvalidInquiry { + /// The offending question id, for the diagnostic trace. + question_id: String, + }, + + /// Tool emitted a payload shaped like a `needs_input` outcome (top-level + /// `"type": "needs_input"`) that failed to deserialize for a reason other + /// than an invalid question id: a field with the wrong shape, a missing + /// field, or a local-tool binary emitting an older wire protocol than this + /// build parses. + /// + /// Surfaced as a tool-level error rather than [`Self::RawOutput`] so a + /// protocol mismatch is loud, instead of silently handing the raw JSON to + /// the model as tool output. + MalformedInquiry { + /// The deserialization error, for the diagnostic trace and the + /// model-facing message. + detail: String, + }, +} + +impl CommandResult { + /// Format a transient error message including trace details. + /// + /// If the trace is empty, returns just the message. + /// Otherwise appends the trace entries so the LLM (or user) can see the + /// root cause. + #[must_use] + pub fn format_error(message: &str, trace: &[String]) -> String { + if trace.is_empty() { + message.to_owned() + } else { + format!("{message}\n\nTrace:\n{}", trace.join("\n")) + } + } + + /// Convert to a `Result` suitable for tool call responses. + /// + /// - `Success` → `Ok(content)` + /// - `TransientError` → `Err(json with message + trace)` + /// - `FatalError` → `Err(raw json)` + /// - `NeedsInput` → handled separately by callers (this panics) + /// - `Cancelled` → `Ok(cancellation message)` + /// - `RawOutput` → `Ok(stdout)` if success, `Err(json)` if failure + pub fn into_tool_result(self, name: &str) -> Result { + match self { + Self::Success(content) => Ok(content), + Self::TransientError { message, trace } => Err(json!({ + "message": message, + "trace": trace, + }) + .to_string()), + Self::FatalError(raw) => Err(raw), + Self::Cancelled => Ok("Tool execution cancelled by user.".to_string()), + Self::RawOutput { + stdout, + stderr, + success, + } => { + if success { + Ok(stdout) + } else { + Err(json!({ + "message": format!("Tool '{name}' execution failed."), + "stderr": stderr, + "stdout": stdout, + }) + .to_string()) + } + } + Self::InvalidInquiry { question_id } => { + error!( + tool = name, + question_id = %question_id, + "tool produced an invalid inquiry: question id must be non-empty and must not \ + contain '.'" + ); + Err( + "tool produced an invalid inquiry: question id must be non-empty and must not \ + contain '.'" + .to_owned(), + ) + } + Self::MalformedInquiry { detail } => { + error!( + tool = name, + %detail, + "tool produced a malformed inquiry that could not be parsed" + ); + Err(format!( + "tool '{name}' produced a malformed inquiry that could not be parsed: {detail}" + )) + } + Self::NeedsInput(_) => { + unreachable!("NeedsInput should be handled by the caller") + } + } + } +} + +/// Receives a running tool's stderr lines as they arrive. +/// +/// Called from the forwarder's read loop, so it must not block: the loop has to +/// keep draining or the child fills its pipe and the tool call never completes. +/// A consumer that falls behind drops rather than stalls. +pub type StderrSink = Arc; + +/// Identity of a tool invocation, used to tag stderr lines forwarded to +/// tracing. +/// +/// Pass `None` to disable stderr forwarding (e.g. for argument-formatting +/// invocations where stderr is not meaningful to the user). +#[derive(Clone)] +pub struct ToolTrace<'a> { + pub id: &'a str, + pub name: &'a str, + + /// Where to send each line for display, in addition to tracing. + /// + /// `None` when nothing is watching, which is the common case: tracing and + /// the accumulated buffer are unaffected either way. + pub stderr: Option, +} + +impl fmt::Debug for ToolTrace<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ToolTrace") + .field("id", &self.id) + .field("name", &self.name) + .field("stderr", &self.stderr.is_some()) + .finish() + } +} + +/// Custom minijinja formatter used by [`run_tool_command`]. +/// +/// Scalars (strings, numbers, booleans) render raw — a template like +/// `{{tool.arguments.title}}` produces the bare string, not a JSON-quoted one. +/// Composites (sequences, maps, other iterables) serialize as JSON, so +/// `{{tool}}` and `{{context}}` produce valid JSON blobs without needing an +/// explicit `| tojson` filter at every call site. +/// `null`/undefined render as the literal `null`, matching the JSON convention +/// used by tool authors. +/// +/// Safe strings (e.g. the output of the `tojson` filter) pass through unchanged +/// so explicit opt-in JSON rendering continues to work. +fn format_tool_template_value( + out: &mut minijinja::Output<'_>, + _state: &minijinja::State<'_, '_>, + value: &minijinja::value::Value, +) -> Result<(), minijinja::Error> { + if value.is_safe() { + return write!(out, "{value}").map_err(Into::into); + } + + match value.kind() { + ValueKind::None | ValueKind::Undefined => write!(out, "null").map_err(Into::into), + ValueKind::String | ValueKind::Bool | ValueKind::Number => { + write!(out, "{value}").map_err(Into::into) + } + // Composites serialize as JSON so tool authors don't have to remember + // `| tojson` for every `{{tool}}` / `{{context}}` interpolation. + _ => { + let json = serde_json::to_string(value).map_err(|error| { + minijinja::Error::new( + MinijinjaErrorKind::BadSerialization, + "failed to serialize value as JSON", + ) + .with_source(error) + })?; + out.write_str(&json).map_err(Into::into) + } + } +} + +/// Run a tool command asynchronously with cancellation support. +/// +/// This is the **single entry point** for running tool commands (both execution +/// and argument formatting). +/// It handles: +/// +/// 1. Template rendering via [`minijinja`] +/// 2. Process spawning via Tokio's [`Command`] +/// 3. Cancellation via [`CancellationToken`] +/// 4. Parsing stdout as [`jp_tool::Outcome`] +/// 5. Forwarding the child's stderr to tracing (when `trace_as` is `Some`) +/// +/// # Panics +/// +/// Panics if tokio fails to attach the piped stdout/stderr handles to the +/// spawned child. +/// Both are requested via `Stdio::piped()`, so this is not expected to happen +/// in practice. +pub async fn run_tool_command( + command: CommandConfig, + ctx: Value, + root: &Utf8Path, + cancellation_token: CancellationToken, + trace_as: Option>, +) -> Result { + let CommandConfig { + program, + args, + shell, + } = command; + + let mut env = Environment::new(); + env.set_formatter(format_tool_template_value); + let tmpl = Arc::new(env); + + let program = tmpl + .render_str(&program, &ctx) + .map_err(|error| ToolError::TemplateError { + data: program.clone(), + error: Box::new(error), + })?; + + let args = args + .iter() + .map(|s| tmpl.render_str(s, &ctx)) + .collect::, _>>() + .map_err(|error| ToolError::TemplateError { + data: args.join(" "), + error: Box::new(error), + })?; + + let mut cmd = if shell { + // `program` is shell syntax and used verbatim; `args` are shell-quoted + // so multi-word arguments keep their boundaries. + let shell_cmd = shell_command_line(&program, &args); + + let mut cmd = Command::new("sh"); + cmd.arg("-c").arg(&shell_cmd); + cmd + } else { + let mut cmd = Command::new(&program); + cmd.args(&args); + cmd + }; + + // Isolate the child from JP's process group so terminal signals + // (Ctrl+C / SIGINT) don't kill it. JP manages tool lifecycle via + // the cancellation token, not Unix signals. + #[cfg(unix)] + cmd.process_group(0); + + // Ensure the child is killed when the tokio task is aborted on + // cancellation. Without this the process would be orphaned. + cmd.kill_on_drop(true); + + let mut child = cmd + .current_dir(root.as_std_path()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|error| ToolError::SpawnError { + command: format!( + "{} {}", + cmd.as_std().get_program().to_string_lossy(), + cmd.as_std() + .get_args() + .filter_map(OsStr::to_str) + .collect::>() + .join(" ") + ), + error, + })?; + + let stdout = child.stdout.take().expect("stdout piped"); + let stderr = child.stderr.take().expect("stderr piped"); + + let run = async { + tokio::try_join!( + read_all(stdout), + forward_stderr(stderr, trace_as), + child.wait(), + ) + }; + + tokio::select! { + biased; + () = cancellation_token.cancelled() => Ok(CommandResult::Cancelled), + result = run => Ok(match result { + Ok((stdout, stderr, status)) => { + parse_command_output(&stdout, &stderr, status.success()) + } + Err(error) => CommandResult::RawOutput { + stdout: String::new(), + stderr: error.to_string(), + success: false, + }, + }), + } +} + +/// Drain a child pipe into a byte buffer. +async fn read_all(mut pipe: impl tokio::io::AsyncRead + Unpin) -> std::io::Result> { + let mut buf = Vec::new(); + pipe.read_to_end(&mut buf).await?; + Ok(buf) +} + +/// Drain a child's stderr into a byte buffer, optionally forwarding each line +/// to tracing as it arrives. +/// +/// Uses byte-level line reading so non-UTF-8 stderr doesn't terminate the +/// forwarder. +async fn forward_stderr( + pipe: impl tokio::io::AsyncRead + Unpin, + trace_as: Option>, +) -> std::io::Result> { + let mut reader = BufReader::new(pipe); + let mut all = Vec::new(); + let mut line = Vec::new(); + + loop { + line.clear(); + if reader.read_until(b'\n', &mut line).await? == 0 { + break; + } + + if let Some(ToolTrace { id, name, stderr }) = &trace_as { + let text = String::from_utf8_lossy(&line); + let trimmed = text.trim_end_matches(['\n', '\r']); + if !trimmed.is_empty() { + trace!(target: "tool::stderr", tool_id = id, tool_name = name, "{trimmed}"); + + if let Some(sink) = stderr { + sink(trimmed); + } + } + } + + all.extend_from_slice(&line); + } + + Ok(all) +} + +/// Parse raw command output into a [`CommandResult`]. +/// +/// Tries to deserialize stdout as [`jp_tool::Outcome`]. +/// If that fails, falls back to [`CommandResult::RawOutput`]. +fn parse_command_output(stdout: &[u8], stderr: &[u8], success: bool) -> CommandResult { + let stdout_str = String::from_utf8_lossy(stdout); + + match serde_json::from_str::(&stdout_str) { + Ok(Outcome::Success { content }) => CommandResult::Success(content), + Ok(Outcome::Error { + transient, + message, + trace, + }) => { + if transient { + CommandResult::TransientError { message, trace } + } else { + CommandResult::FatalError(stdout_str.into_owned()) + } + } + Ok(Outcome::NeedsInput { question }) => CommandResult::NeedsInput(question), + // A payload shaped like a `needs_input` outcome that fails to + // deserialize must become a tool-level error, not `RawOutput`: + // silently handing the raw JSON to the model hides the failure (a + // stale local-tool binary emitting an older wire shape than this build + // parses, an invalid question id, a missing field) and leaves the + // model to invent an explanation. Output that is not an `Outcome` at + // all stays `RawOutput`. + Err(error) => { + let value = serde_json::from_str::(&stdout_str).ok(); + let is_needs_input = value + .as_ref() + .and_then(|v| v.get("type")) + .and_then(Value::as_str) + == Some("needs_input"); + + if !is_needs_input { + return CommandResult::RawOutput { + stdout: stdout_str.into_owned(), + stderr: String::from_utf8_lossy(stderr).into_owned(), + success, + }; + } + + let question_id = value + .as_ref() + .and_then(|v| v.get("question")) + .and_then(|q| q.get("id")) + .and_then(Value::as_str); + + match question_id { + // The id itself is the problem: empty, or containing the `.` + // reserved as the inquiry-id separator (`QuestionId` rejects + // both). + Some(id) if id.is_empty() || id.contains('.') => CommandResult::InvalidInquiry { + question_id: id.to_owned(), + }, + // Some other field failed to parse (wrong shape, missing + // field, protocol skew). + _ => CommandResult::MalformedInquiry { + detail: error.to_string(), + }, + } + } + } +} + +/// Identity of the conversation an invocation belongs to. +/// +/// Surfaced to local tools through the rendered template `context` (as +/// `context.workspace_id` and `context.conversation_id`) so a tool can scope +/// any state it persists to the originating workspace and conversation. +#[derive(Debug, Clone, Default)] +pub struct InvocationContext { + pub workspace_id: String, + pub conversation_id: String, +} + +/// Execute a tool without any interactive prompts. +/// +/// Runs one attempt through the tool's command or MCP call and returns an +/// [`ExecutionOutcome`]. +/// All interactive decisions (permission prompts, result editing, question +/// handling) are the caller's responsibility. +/// +/// # Arguments +/// +/// - `id` - The tool call ID for correlation with the request +/// - `arguments` - The tool arguments (caller is responsible for any +/// pre-processing) +/// - `answers` - Pre-provided answers to tool questions (from previous +/// `NeedsInput`) +/// - `config` - Tool configuration +/// - `mcp_client` - MCP client for MCP tool execution +/// - `root` - Working directory for local tool execution +/// - `cancellation_token` - Token to cancel long-running execution +/// - `builtin_executors` - Registry of builtin tools +/// +/// # Returns +/// +/// - [`ExecutionOutcome::Completed`] - Tool finished (check inner `Result` for +/// success/error) +/// - [`ExecutionOutcome::NeedsInput`] - Tool needs user input to continue +/// - [`ExecutionOutcome::Cancelled`] - Execution was cancelled via the token +/// +/// # Errors +/// +/// Returns [`ToolError`] for infrastructure errors (spawn failure, missing +/// command, etc.). +/// Tool-level errors (command returned non-zero) are returned as +/// `Ok(ExecutionOutcome::Completed { result: Err(...) })`. +/// +/// # Example +/// +/// ```ignore +/// loop { +/// match execute(&definition, id, args, &answers, ...).await? { +/// ExecutionOutcome::Completed { result, .. } => { +/// // Handle success or tool error +/// break result; +/// } +/// ExecutionOutcome::NeedsInput { question, .. } => { +/// // Prompt user for input +/// let answer = prompt_user(&question)?; +/// answers.insert(question.id, answer); +/// // Loop to retry with answer +/// } +/// ExecutionOutcome::Cancelled { .. } => { +/// break Ok("Cancelled".into()); +/// } +/// } +/// } +/// ``` +#[expect(clippy::too_many_arguments)] +pub async fn execute( + definition: &ToolDefinition, + id: String, + arguments: Value, + answers: &IndexMap, + config: &ToolConfigWithDefaults, + mcp_client: &Client, + root: &Utf8Path, + cancellation_token: CancellationToken, + builtin_executors: &builtin::BuiltinExecutors, + access: Option<&jp_tool::AccessPolicy>, + invocation: &InvocationContext, + stderr: Option, +) -> Result { + let mut arguments = arguments; + if let Some(arguments) = arguments.as_object_mut() { + definition.coerce_arguments(arguments); + } + info!(tool = %definition.name, arguments = ?arguments, "Executing tool."); + + match config.source() { + ToolSource::Local { tool } => { + execute_local( + definition, + id, + arguments, + answers, + config, + tool.as_deref(), + root, + cancellation_token, + access, + invocation, + stderr, + ) + .await + } + ToolSource::Mcp { server, tool } => { + execute_mcp( + definition, + id, + arguments, + mcp_client, + server, + tool.as_deref(), + cancellation_token, + ) + .await + } + ToolSource::Builtin { tool } => { + execute_builtin( + definition, + id, + &arguments, + answers, + tool.as_deref(), + builtin_executors, + ) + .await + } + } +} + +/// Execute a local tool and return the outcome. +/// +/// Runs one local command attempt. +/// It validates arguments, runs the command, and converts the result to an +/// `ExecutionOutcome`. +#[expect(clippy::too_many_arguments)] +async fn execute_local( + definition: &ToolDefinition, + id: String, + mut arguments: Value, + answers: &IndexMap, + config: &ToolConfigWithDefaults, + tool: Option<&str>, + root: &Utf8Path, + cancellation_token: CancellationToken, + access: Option<&jp_tool::AccessPolicy>, + invocation: &InvocationContext, + stderr: Option, +) -> Result { + let name = tool.unwrap_or(&definition.name); + + // Apply configured defaults for missing parameters, then validate. + if let Some(args) = arguments.as_object_mut() { + apply_parameter_defaults(args, &definition.parameters); + + if let Err(error) = validate_tool_arguments(args, &definition.parameters) { + return Ok(ExecutionOutcome::Completed { + id, + result: Err(format!( + "Invalid arguments: {error}\n\nYou can call `describe_tools(tools: \ + [\"{name}\"])` to learn more about how to use the tool correctly." + )), + }); + } + } + + let ctx = json!({ + "tool": { + "name": name, + "arguments": &arguments, + "answers": answers, + "options": config.options(), + }, + "context": { + "action": Action::Run, + "root": root.as_str(), + "access": access, + "workspace_id": &invocation.workspace_id, + "conversation_id": &invocation.conversation_id, + }, + }); + + let Some(command) = config.command() else { + return Err(ToolError::MissingCommand); + }; + + let trace_as = ToolTrace { + id: &id, + name, + stderr, + }; + + match run_tool_command(command, ctx, root, cancellation_token, Some(trace_as)).await? { + CommandResult::Success(content) => Ok(ExecutionOutcome::Completed { + id, + result: Ok(content), + }), + CommandResult::NeedsInput(question) => Ok(ExecutionOutcome::NeedsInput { id, question }), + CommandResult::Cancelled => Ok(ExecutionOutcome::Cancelled { id }), + other => Ok(ExecutionOutcome::Completed { + id, + result: other.into_tool_result(name), + }), + } +} + +/// Execute an MCP tool and return the outcome. +/// +/// Runs one upstream MCP call. +/// It calls the MCP server and converts the result to an `ExecutionOutcome`. +async fn execute_mcp( + definition: &ToolDefinition, + id: String, + arguments: Value, + mcp_client: &Client, + server: &str, + tool: Option<&str>, + cancellation_token: CancellationToken, +) -> Result { + let name = tool.unwrap_or(&definition.name); + + let call_future = mcp_client.call_tool(name, server, &arguments); + + tokio::select! { + biased; + () = cancellation_token.cancelled() => { + info!(tool = %definition.name, "MCP tool call cancelled"); + Ok(ExecutionOutcome::Cancelled { id }) + } + result = call_future => { + let result = result + .map_err(|error| ToolError::McpRunToolError(Box::new(error)))?; + + let content = result + .content + .into_iter() + .filter_map(|v| match v.raw { + RawContent::Text(v) => Some(v.text), + RawContent::Resource(v) => match v.resource { + ResourceContents::TextResourceContents { text, .. } => Some(text), + ResourceContents::BlobResourceContents { blob, .. } => Some(blob), + }, + RawContent::Image(_) | RawContent::Audio(_) | RawContent::ResourceLink(_) => None, + }) + .collect::>() + .join("\n\n"); + + let result = if result.is_error.unwrap_or_default() { + Err(content) + } else { + Ok(content) + }; + + Ok(ExecutionOutcome::Completed { id, result }) + } + } +} + +/// Execute a builtin tool and return the outcome. +/// +/// `source_name` is the implementation named by `source = "builtin."`, +/// which the registry is keyed on. +/// When absent, the implementation shares the tool's own name. +async fn execute_builtin( + definition: &ToolDefinition, + id: String, + arguments: &Value, + answers: &IndexMap, + source_name: Option<&str>, + builtin_executors: &builtin::BuiltinExecutors, +) -> Result { + let name = source_name.unwrap_or(&definition.name); + let executor = builtin_executors + .get(name) + .ok_or_else(|| ToolError::NotFound { + name: name.to_owned(), + })?; + + let outcome = executor.execute(arguments, answers).await; + + Ok(match outcome { + Outcome::Success { content } => ExecutionOutcome::Completed { + id, + result: Ok(content), + }, + Outcome::Error { + message, + trace, + transient: _, + } => { + let error_msg = if trace.is_empty() { + message + } else { + format!("{message}\n\nTrace:\n{}", trace.join("\n")) + }; + ExecutionOutcome::Completed { + id, + result: Err(error_msg), + } + } + Outcome::NeedsInput { question } => ExecutionOutcome::NeedsInput { id, question }, + }) +} + +/// Resolve all enabled tool definitions from config. +/// +/// If `forced_tool` is provided (e.g. from `ToolChoice::Function`), that tool +/// is included even when it is disabled, preventing a mismatch between +/// `tool_choice` and the declared tools list that some providers (notably +/// Google/Gemini) reject outright. +/// +/// A locked-off tool (`state = false`, `allow_toggle = never`) is the +/// exception: it is always dropped, even when named by `forced_tool`. +pub async fn tool_definitions( + configs: impl Iterator, + mcp_client: &Client, + forced_tool: Option<&str>, +) -> Result, ToolError> { + let mut definitions = Vec::new(); + + for (name, config) in configs { + let enable = config.effective_enable(); + let forced = forced_tool.is_some_and(|f| f == name); + // Drop disabled tools, but keep a forced tool unless it is locked-off. + if !enable.is_enabled() && (!forced || enable.is_locked()) { + continue; + } + + // Drop MCP-backed tools whose server failed to start while marked + // optional. The server is absent from the running services map, and + // we don't want to hand the LLM a tool it cannot invoke. + if let ToolSource::Mcp { server, .. } = config.source() { + let server_id = McpServerId::new(server); + if !mcp_client.is_running(&server_id).await { + warn!( + tool = name, + server = %server, + "Skipping MCP tool: backing server is not running." + ); + continue; + } + } + + // A tool JP cannot describe to the provider is dropped rather than + // failing the query, matching the unavailable-server case above. A tool + // the caller named explicitly is the exception: silently omitting it + // would leave `tool_choice` pointing at a tool the provider never saw. + let definition = match resolve_tool(name, &config, mcp_client).await { + Ok(definition) => definition, + Err(error) if !forced => { + warn!( + tool = name, + %error, + "Skipping tool: its parameter schema could not be resolved." + ); + continue; + } + Err(error) => return Err(error), + }; + definitions.push(definition); + } + + Ok(definitions) +} + +/// Resolve a single tool definition and its documentation. +async fn resolve_tool( + name: &str, + config: &ToolConfigWithDefaults, + mcp_client: &Client, +) -> Result { + let path = format!("conversation.tools.{name}.parameters"); + let definition = match config.source() { + ToolSource::Local { .. } | ToolSource::Builtin { .. } => ToolDefinition { + name: name.to_owned(), + docs: tool_docs_from_config(config), + parameters: json_schema::from_config(&path, config.parameters())?, + }, + ToolSource::Mcp { server, tool } => { + resolve_mcp_tool(server, name, tool.as_deref(), config, mcp_client).await? + } + }; + + jp_tool::schema::validate(&path, &definition.parameters)?; + + Ok(definition) +} + +/// Resolve an MCP tool: fetch from server, merge config overrides, auto-split +/// descriptions into summary + detail. +async fn resolve_mcp_tool( + server: &str, + name: &str, + source_name: Option<&str>, + config: &ToolConfigWithDefaults, + mcp_client: &Client, +) -> Result { + let mcp_tool = { + trace!(server = %server, tool = %name, "Fetching tool from MCP server"); + + let server_id = McpServerId::new(server); + mcp_client + .get_tool(&McpToolId::new(source_name.unwrap_or(name)), &server_id) + .await + .map_err(|error| ToolError::McpGetToolError(Box::new(error))) + }?; + + let user_overrides = config.parameters(); + + // Merge tool-level description. + let merged_description = merge_description( + config.description().map(str::to_owned), + mcp_tool.description.as_deref(), + ); + + // The server's document is the source of truth; configuration may narrow + // it, and nothing else touches it. + let source = Value::Object(mcp_tool.input_schema.as_ref().clone()); + let parameters = json_schema::with_overrides( + &format!("conversation.tools.{name}.parameters"), + &source, + user_overrides, + )?; + + // Build docs with auto-split heuristic. + let has_user_summary = config.summary().is_some(); + + let (summary, description) = if has_user_summary { + // User provided explicit summary -- use config fields as-is. + ( + config.summary().map(str::to_owned), + config.description().map(str::to_owned), + ) + } else if let Some(ref desc) = merged_description { + let (s, d) = split_description(desc); + (Some(s), d) + } else { + (None, None) + }; + + let examples = config.examples().map(str::to_owned); + + // Per-parameter docs: auto-split MCP descriptions when user didn't override. + let param_docs = Node::root(¶meters) + .properties() + .into_iter() + .filter_map(|(pname, pnode)| { + let user_override = user_overrides.get(&pname); + let has_user_param_summary = user_override.and_then(|o| o.summary.as_ref()).is_some(); + + let (summary, desc) = if has_user_param_summary { + let summary = user_override + .and_then(|o| o.summary.as_deref()) + .or(user_override.and_then(|o| o.description.as_deref())) + .map(str::to_owned); + let desc = user_override + .and_then(|o| o.description.as_deref()) + .map(str::to_owned); + (summary, desc) + } else if let Some(resolved) = pnode.description() { + let (s, d) = split_description(resolved); + (Some(s), d) + } else { + (None, None) + }; + + let ex = user_override + .and_then(|o| o.examples.as_deref()) + .map(str::to_owned); + + if summary.is_none() && desc.is_none() && ex.is_none() { + return None; + } + + Some((pname, ParameterDocs { + summary, + description: desc, + examples: ex, + })) + }) + .collect(); + + let docs = ToolDocs { + summary, + description, + examples, + parameters: param_docs, + }; + + Ok(ToolDefinition { + name: name.to_owned(), + docs, + parameters, + }) +} + +#[cfg(test)] +#[path = "server_tests.rs"] +mod tests; diff --git a/crates/jp_llm/src/tool/builtin.rs b/crates/jp_mcp/src/server/builtin.rs similarity index 73% rename from crates/jp_llm/src/tool/builtin.rs rename to crates/jp_mcp/src/server/builtin.rs index 538e8a28b..b247c43cd 100644 --- a/crates/jp_llm/src/tool/builtin.rs +++ b/crates/jp_mcp/src/server/builtin.rs @@ -8,7 +8,6 @@ use std::{collections::HashMap, sync::Arc}; use async_trait::async_trait; use indexmap::IndexMap; -use jp_conversation::event::InquirySource; use jp_tool::Outcome; use serde_json::Value; @@ -17,17 +16,6 @@ use serde_json::Value; pub trait BuiltinTool: Send + Sync { /// Execute the tool with the given arguments and accumulated answers. async fn execute(&self, arguments: &Value, answers: &IndexMap) -> Outcome; - - /// The persisted `InquirySource` for questions emitted by this tool. - /// - /// Default: `InquirySource::Tool { name }`. - /// Override for tools whose questions are semantically the assistant's, not - /// the tool's (e.g. `ask_user`). - fn inquiry_source(&self, name: &str) -> InquirySource { - InquirySource::Tool { - name: name.to_owned(), - } - } } /// Registry mapping builtin tool names to their executors. diff --git a/crates/jp_llm/src/tool/builtin/describe_tools.rs b/crates/jp_mcp/src/server/builtin/describe_tools.rs similarity index 98% rename from crates/jp_llm/src/tool/builtin/describe_tools.rs rename to crates/jp_mcp/src/server/builtin/describe_tools.rs index 2d4ab9071..9c55fd631 100644 --- a/crates/jp_llm/src/tool/builtin/describe_tools.rs +++ b/crates/jp_mcp/src/server/builtin/describe_tools.rs @@ -2,10 +2,10 @@ use async_trait::async_trait; use indexmap::IndexMap; -use jp_tool::Outcome; +use jp_tool::{Outcome, ToolDocs}; use serde_json::Value; -use crate::tool::{BuiltinTool, ToolDocs}; +use crate::server::BuiltinTool; pub struct DescribeTools { docs: IndexMap, diff --git a/crates/jp_llm/src/tool/builtin/describe_tools_tests.rs b/crates/jp_mcp/src/server/builtin/describe_tools_tests.rs similarity index 99% rename from crates/jp_llm/src/tool/builtin/describe_tools_tests.rs rename to crates/jp_mcp/src/server/builtin/describe_tools_tests.rs index e250ef257..5dda78db6 100644 --- a/crates/jp_llm/src/tool/builtin/describe_tools_tests.rs +++ b/crates/jp_mcp/src/server/builtin/describe_tools_tests.rs @@ -1,9 +1,8 @@ use indexmap::IndexMap; -use jp_tool::Outcome; +use jp_tool::{Outcome, ParameterDocs, ToolDocs}; use serde_json::{Value, json}; use super::*; -use crate::tool::{ParameterDocs, ToolDocs}; fn empty_tool_docs() -> ToolDocs { ToolDocs { diff --git a/crates/jp_llm/src/tool/json_schema.rs b/crates/jp_mcp/src/server/json_schema.rs similarity index 100% rename from crates/jp_llm/src/tool/json_schema.rs rename to crates/jp_mcp/src/server/json_schema.rs diff --git a/crates/jp_llm/src/tool/json_schema_tests.rs b/crates/jp_mcp/src/server/json_schema_tests.rs similarity index 100% rename from crates/jp_llm/src/tool/json_schema_tests.rs rename to crates/jp_mcp/src/server/json_schema_tests.rs diff --git a/crates/jp_mcp/src/server/service.rs b/crates/jp_mcp/src/server/service.rs new file mode 100644 index 000000000..72f52fae1 --- /dev/null +++ b/crates/jp_mcp/src/server/service.rs @@ -0,0 +1,686 @@ +//! Per-call execution and private MCP Host interactions. +//! +//! [`Service::start_call`] is the execution entry point for the MCP handler. +//! Calls run independently of their result receivers. +//! Dropping a receiver does not cancel or retry work; use [`Call::cancel`] or +//! [`Service::cancel_current`]. +//! The MCP Host must drain [`HostReceiver`] while calls are outstanding. + +use std::{ + collections::HashMap, + sync::{Arc, Mutex, MutexGuard, PoisonError}, +}; + +use camino::Utf8PathBuf; +use indexmap::IndexMap; +use jp_config::conversation::tool::{ + FormatMode, ResultMode, RunMode, ToolConfigWithDefaults, ToolSource, style::ParametersStyle, +}; +use jp_tool::{ + AccessPolicy, Action, ContentBlock, Error as ToolError, InputRequest, ToolDefinition, + definition::{apply_parameter_defaults, validate_tool_arguments}, + schema::Node, +}; +use serde_json::{Map, Value, json}; +use tokio::sync::{Notify, broadcast, mpsc, oneshot}; +use tokio_util::sync::CancellationToken; + +use super::{ + CommandResult, ExecutionOutcome, InvocationContext, builtin::BuiltinExecutors, execute, + run_tool_command, +}; +use crate::Client; + +/// A tool resolved under trusted MCP Host configuration. +#[derive(Clone, Debug)] +pub struct ConfiguredTool { + /// The name and source-neutral argument schema advertised to callers. + pub definition: ToolDefinition, + /// Execution and interaction requirements, including source selection. + pub config: ToolConfigWithDefaults, + /// Compiled access grants supplied by the MCP Host, never by an MCP caller. + pub access: Option, +} + +/// An invocation received by the MCP handler. +/// Contains no execution authority. +#[derive(Clone, Debug)] +pub struct CallRequest { + /// The advertised tool name, not the upstream implementation name. + pub name: String, + /// Arguments supplied by the caller. + pub arguments: Map, + /// Opaque caller metadata for Host-side correlation only. + pub correlation: Map, +} + +/// Service-assigned identity, distinct from caller-supplied protocol IDs. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct InvocationId(u64); + +/// Identity and original input accompanying each private Host interaction. +#[derive(Clone, Debug)] +pub struct CallInfo { + /// The service-assigned invocation ID. + pub id: InvocationId, + /// Original caller input; edits do not change it. + pub request: CallRequest, +} + +/// A required interaction for one invocation. +/// +/// Replies are single-use and bound to this request. +/// Secret answers and accumulated input are deliberately not exposed through a +/// `Debug` impl. +pub struct HostRequest { + /// Identity and requested arguments for recording/correlation. + pub call: CallInfo, + /// The operation the MCP Host must complete. + pub interaction: Interaction, +} + +/// Receiver held exclusively by the MCP Host. +pub type HostReceiver = mpsc::Receiver; + +/// A Host reply that may fail, for example when recording could not complete. +pub type HostReply = Result; + +/// A failure reported by the MCP Host, without a persisted conversation type. +#[derive(Debug, Clone, thiserror::Error)] +#[error("MCP Host operation failed: {0}")] +pub struct HostError(pub String); + +/// Whether the Host approved execution, and which edited arguments to use. +#[derive(Debug)] +pub enum Admission { + /// Approved arguments. + /// They are validated again before release. + Run { arguments: Map }, + /// Do not execute. + /// Deliver and record this explanation. + Skip { reason: String }, +} + +/// Host-only services needed by the per-call execution state machine. +pub enum Interaction { + /// Ask whether argument presentation is wanted. + /// This does not authorize an approval-gated formatter to run early. + RenderArguments { + /// True when the MCP Host needs the custom representation. + reply: oneshot::Sender>, + }, + /// Apply admission policy and argument editing. + /// A successful reply also acknowledges recording/preparation of the + /// request. + Prepare { + /// Resolved interaction requirements, including formatter policy. + config: Box, + /// Coerced/defaulted arguments presented for approval. + arguments: Map, + /// Custom formatter output, if formatting was permitted before + /// approval. + formatted_arguments: Option>, + /// One reply for this preparation operation. + reply: oneshot::Sender>, + }, + /// Wait for the Host's execution phase and recording barrier. + Release { + /// Validated arguments that will actually execute. + arguments: Map, + /// Custom representation of the approved arguments, if requested. + formatted_arguments: Option>, + /// Acknowledgement permitting the first execution attempt. + reply: oneshot::Sender>, + }, + /// Obtain and record input before the next execution attempt. + Input { + /// The expected answer shape and secrecy constraints. + request: InputRequest, + /// Context shown with the input request. + supporting: Vec, + /// Accumulated answers. + /// These may contain secrets and must not be logged. + answers: IndexMap, + /// The answer, after Host routing and recording/redaction. + reply: oneshot::Sender>, + }, + /// Review/edit a completed result under the configured delivery policy. + Review { + /// The required delivery interaction. + mode: ResultMode, + /// Unedited execution result. + result: Result, + /// The content approved for delivery, including skip explanations. + reply: oneshot::Sender>>, + }, + /// Acknowledge final recording before returning the result to the caller. + Record { + /// Post-edit execution arguments, separate from `CallInfo::request`. + arguments: Map, + /// Original completed result; absent for skipped calls. + raw_result: Option>, + /// Content approved for delivery. + result: Result, + /// Acknowledges the Host's configured persistence policy, not an + /// unconditional disk write. + reply: oneshot::Sender>, + }, +} + +/// Bounded, best-effort progress. +/// It is independent of required Host requests. +#[derive(Clone, Debug)] +pub struct Progress { + /// Invocation emitting the line. + pub id: InvocationId, + /// A tool stderr line, without its newline terminator. + pub line: String, +} + +/// Failure of the service protocol or execution infrastructure. +#[derive(Debug, thiserror::Error)] +pub enum ServiceError { + /// The service no longer admits calls. + #[error("JP MCP Server is stopped")] + Stopped, + /// Work was explicitly cancelled before delivery. + #[error("Tool invocation cancelled")] + Cancelled, + /// The required Host interaction connection was lost. + #[error("MCP Host disconnected before completing the interaction")] + HostDisconnected, + /// The Host declined an operation, including failed recording. + #[error(transparent)] + Host(#[from] HostError), + /// Tool lookup, validation, or execution failed. + #[error(transparent)] + Tool(#[from] ToolError), + /// The Host returned data outside the tool's requested answer shape. + #[error("Invalid answer for tool question `{0}`")] + InvalidAnswer(String), + /// An argument violates the schema's type or enumeration. + #[error("Invalid tool argument at `{path}`: value violates its type or enum")] + InvalidArgument { path: String }, + /// A configured name cannot select multiple tool implementations. + #[error("Duplicate tool configured: {0}")] + DuplicateTool(String), + /// Restricted configuration must have a compiled policy. + #[error("Missing compiled access policy for tool `{0}`")] + MissingAccessPolicy(String), + /// IDs must not wrap and alias an earlier invocation. + #[error("Tool invocation identifiers exhausted")] + IdExhausted, + /// An execution task failed without producing a result. + #[error("Tool execution task ended without a result")] + TaskLost, +} + +/// Handle to a submitted call. +/// Dropping it leaves execution running. +#[derive(Debug)] +pub struct Call { + id: InvocationId, + cancellation: CancellationToken, + result: oneshot::Receiver, ServiceError>>, +} + +impl Call { + /// Service identity to correlate with Host interactions. + #[must_use] + pub fn id(&self) -> InvocationId { + self.id + } + + /// Whether the final result or task failure is ready to receive. + #[must_use] + pub fn is_finished(&self) -> bool { + !self.result.is_empty() || self.result.is_terminated() + } + + /// Cancel this invocation, including a pending Host interaction. + pub fn cancel(&self) { + self.cancellation.cancel(); + } + + /// Wait for execution and the final Host recording acknowledgement. + pub async fn finish(self) -> Result, ServiceError> { + self.result.await.map_err(|_| ServiceError::TaskLost)? + } +} + +/// In-process tool service with immutable Host-bound execution context. +/// +/// The upstream client must be owned by this service: shutdown closes its +/// services, including connections visible through any clones of that client. +/// Dropping this owner signals cancellation; [`shutdown`] additionally waits +/// for cleanup. +/// +/// [`shutdown`]: Self::shutdown +pub struct Service { + inner: Arc, +} + +struct Inner { + tools: HashMap, + upstream: Client, + builtins: BuiltinExecutors, + root: Utf8PathBuf, + invocation: InvocationContext, + host: mpsc::Sender, + progress: broadcast::Sender, + state: Mutex, + idle: Notify, +} + +#[derive(Default)] +struct State { + stopped: bool, + next_id: u64, + active: HashMap, +} + +impl Inner { + fn state(&self) -> MutexGuard<'_, State> { + // No caller code runs under this lock. Recovering it permits cleanup + // after an unrelated panic rather than orphaning active calls. + self.state.lock().unwrap_or_else(PoisonError::into_inner) + } +} + +struct ActiveCall { + inner: Arc, + id: InvocationId, +} + +impl Drop for ActiveCall { + fn drop(&mut self) { + self.inner.state().active.remove(&self.id); + self.inner.idle.notify_waiters(); + } +} + +impl Service { + /// Bind resolved tools, access policies, and working context to the + /// service. + /// + /// No tool code is run. + /// The returned receiver is the private Host interface. + pub fn new( + tools: Vec, + upstream: Client, + builtins: BuiltinExecutors, + root: Utf8PathBuf, + invocation: InvocationContext, + ) -> Result<(Self, HostReceiver), ServiceError> { + let mut catalog = HashMap::new(); + for tool in tools { + if tool.config.access().is_some() && tool.access.is_none() { + return Err(ServiceError::MissingAccessPolicy(tool.definition.name)); + } + let name = tool.definition.name.clone(); + if catalog.insert(name.clone(), tool).is_some() { + return Err(ServiceError::DuplicateTool(name)); + } + } + let (host, receiver) = mpsc::channel(32); + let (progress, _) = broadcast::channel(64); + Ok(( + Self { + inner: Arc::new(Inner { + tools: catalog, + upstream, + builtins, + root, + invocation, + host, + progress, + state: Mutex::new(State::default()), + idle: Notify::new(), + }), + }, + receiver, + )) + } + + /// Subscribe to stderr progress without slowing execution or Host replies. + /// A lagging subscriber receives the broadcast channel's lag error. + #[must_use] + pub fn subscribe_progress(&self) -> broadcast::Receiver { + self.inner.progress.subscribe() + } + + /// Submit work from the MCP handler and allocate an independent invocation + /// ID. + /// + /// The caller must be inside a Tokio runtime. + /// Caller metadata is forwarded only for correlation; it never sets the + /// root, policy, or answers. + pub fn start_call(&self, request: CallRequest) -> Result { + let inner = self.inner.clone(); + let mut state = inner.state(); + if state.stopped { + return Err(ServiceError::Stopped); + } + let tool = inner + .tools + .get(&request.name) + .cloned() + .ok_or_else(|| ToolError::NotFound { + name: request.name.clone(), + })?; + state.next_id = state + .next_id + .checked_add(1) + .ok_or(ServiceError::IdExhausted)?; + let id = InvocationId(state.next_id); + let cancellation = CancellationToken::new(); + state.active.insert(id, cancellation.clone()); + drop(state); + let (sender, result) = oneshot::channel(); + let task_token = cancellation.clone(); + let active = ActiveCall { + inner: inner.clone(), + id, + }; + tokio::spawn(async move { + let _active = active; + let call = CallInfo { id, request }; + let result = tokio::select! { + biased; + () = task_token.cancelled() => Err(ServiceError::Cancelled), + () = inner.host.closed() => Err(ServiceError::HostDisconnected), + result = run_call(&inner, &call, tool, &task_token) => result, + }; + drop(sender.send(result)); + }); + Ok(Call { + id, + cancellation, + result, + }) + } + + /// Stop current calls without preventing admission of later work. + pub fn cancel_current(&self) { + for token in self.inner.state().active.values() { + token.cancel(); + } + } + + /// Stop admission, cancel outstanding calls, wait for their cleanup, and + /// close owned upstream services. + /// Safe to call more than once. + pub async fn shutdown(&self) -> Result<(), ServiceError> { + { + let mut state = self.inner.state(); + state.stopped = true; + for token in state.active.values() { + token.cancel(); + } + } + loop { + let idle = self.inner.idle.notified(); + tokio::pin!(idle); + idle.as_mut().enable(); + if self.inner.state().active.is_empty() { + break; + } + idle.await; + } + self.inner.upstream.shutdown().await; + Ok(()) + } +} + +impl Drop for Service { + fn drop(&mut self) { + let mut state = self.inner.state(); + state.stopped = true; + for token in state.active.values() { + token.cancel(); + } + } +} + +async fn ask( + inner: &Inner, + call: &CallInfo, + interaction: impl FnOnce(oneshot::Sender>) -> Interaction, +) -> Result { + let (reply, receiver) = oneshot::channel(); + inner + .host + .send(HostRequest { + call: call.clone(), + interaction: interaction(reply), + }) + .await + .map_err(|_| ServiceError::HostDisconnected)?; + receiver + .await + .map_err(|_| ServiceError::HostDisconnected)? + .map_err(Into::into) +} + +fn validate_arguments( + tool: &ConfiguredTool, + arguments: &mut Map, +) -> Result<(), ServiceError> { + tool.definition.coerce_arguments(arguments); + apply_parameter_defaults(arguments, &tool.definition.parameters); + validate_tool_arguments(arguments, &tool.definition.parameters)?; + for (name, node) in Node::root(&tool.definition.parameters).properties() { + if let Some(value) = arguments.get(&name) { + validate_value(&name, value, &node)?; + } + } + Ok(()) +} + +fn validate_value(path: &str, value: &Value, node: &Node<'_>) -> Result<(), ServiceError> { + if !node.permits(value) { + return Err(ServiceError::InvalidArgument { path: path.into() }); + } + if let Some(object) = value.as_object() { + for (name, child) in node.properties() { + if let Some(value) = object.get(&name) { + validate_value(&format!("{path}.{name}"), value, &child)?; + } + } + } + if let (Some(values), Some(items)) = (value.as_array(), node.items()) { + for (index, value) in values.iter().enumerate() { + validate_value(&format!("{path}[{index}]"), value, &items)?; + } + } + Ok(()) +} + +async fn run_call( + inner: &Inner, + call: &CallInfo, + tool: ConfiguredTool, + cancellation: &CancellationToken, +) -> Result, ServiceError> { + let mut arguments = call.request.arguments.clone(); + validate_arguments(&tool, &mut arguments)?; + let wants_format = if tool.config.run() != RunMode::Skip + && !tool.config.style().hidden + && matches!(tool.config.style().parameters, ParametersStyle::Custom(_)) + { + ask(inner, call, |reply| Interaction::RenderArguments { reply }).await? + } else { + false + }; + let mut formatted_arguments = if wants_format && tool.config.format() == FormatMode::Unattended + { + Some(format_arguments(inner, &tool, &arguments, cancellation).await?) + } else { + None + }; + let original_arguments = arguments.clone(); + let admission = if tool.config.run() == RunMode::Skip { + Admission::Skip { + reason: "Tool execution skipped by configuration.".into(), + } + } else { + ask(inner, call, |reply| Interaction::Prepare { + config: Box::new(tool.config.clone()), + arguments: arguments.clone(), + formatted_arguments: formatted_arguments.clone(), + reply, + }) + .await? + }; + arguments = match admission { + Admission::Run { arguments } => arguments, + Admission::Skip { reason } => { + let result = Ok(reason); + ask(inner, call, |reply| Interaction::Record { + arguments, + raw_result: None, + result: result.clone(), + reply, + }) + .await?; + return Ok(result); + } + }; + validate_arguments(&tool, &mut arguments)?; + if wants_format && (formatted_arguments.is_none() || arguments != original_arguments) { + formatted_arguments = Some(format_arguments(inner, &tool, &arguments, cancellation).await?); + } + ask(inner, call, |reply| Interaction::Release { + arguments: arguments.clone(), + formatted_arguments, + reply, + }) + .await?; + let raw_result = execute_with_answers(inner, call, &tool, &arguments, cancellation).await?; + let result = match tool.config.result() { + ResultMode::Skip => Ok("Result delivery skipped by configuration.".into()), + ResultMode::Unattended => raw_result.clone(), + mode @ (ResultMode::Ask | ResultMode::Edit) => { + ask(inner, call, |reply| Interaction::Review { + mode, + result: raw_result.clone(), + reply, + }) + .await? + } + }; + ask(inner, call, |reply| Interaction::Record { + arguments, + raw_result: Some(raw_result), + result: result.clone(), + reply, + }) + .await?; + Ok(result) +} + +async fn execute_with_answers( + inner: &Inner, + call: &CallInfo, + tool: &ConfiguredTool, + arguments: &Map, + cancellation: &CancellationToken, +) -> Result, ServiceError> { + let mut answers = IndexMap::new(); + loop { + let progress = inner.progress.clone(); + let id = call.id; + let stderr = Arc::new(move |line: &str| { + drop(progress.send(Progress { + id, + line: line.into(), + })); + }); + let outcome = execute( + &tool.definition, + call.id.0.to_string(), + Value::Object(arguments.clone()), + &answers, + &tool.config, + &inner.upstream, + &inner.root, + cancellation.clone(), + &inner.builtins, + tool.access.as_ref(), + &inner.invocation, + Some(stderr), + ) + .await?; + match outcome { + ExecutionOutcome::Cancelled { .. } => return Err(ServiceError::Cancelled), + ExecutionOutcome::Completed { result, .. } => return Ok(result), + ExecutionOutcome::NeedsInput { mut question, .. } => { + let supporting = question + .pre_amble + .take() + .into_iter() + .map(ContentBlock::text) + .collect(); + let request = InputRequest::from(question); + let answer = ask(inner, call, |reply| Interaction::Input { + request: request.clone(), + supporting, + answers: answers.clone(), + reply, + }) + .await?; + if !Node::root(&Value::Object(request.schema)).permits(&answer) { + return Err(ServiceError::InvalidAnswer(request.id.to_string())); + } + answers.insert(request.id.to_string(), answer); + } + } + } +} + +async fn format_arguments( + inner: &Inner, + tool: &ConfiguredTool, + arguments: &Map, + cancellation: &CancellationToken, +) -> Result, ServiceError> { + let ParametersStyle::Custom(command) = &tool.config.style().parameters else { + return Ok(Ok(String::new())); + }; + let name = match tool.config.source() { + ToolSource::Local { tool: name } + | ToolSource::Builtin { tool: name } + | ToolSource::Mcp { tool: name, .. } => name.as_deref().unwrap_or(&tool.definition.name), + }; + let context = json!({ + "tool": {"name":name, "arguments":arguments, "options":tool.config.options()}, + "context": {"action":Action::FormatArguments, "root":inner.root, "workspace_id":inner.invocation.workspace_id, "conversation_id":inner.invocation.conversation_id, "access":tool.access}, + }); + let result = match run_tool_command( + command.clone().command(), + context, + &inner.root, + cancellation.clone(), + None, + ) + .await + { + Ok(result) => result, + Err(error) => return Ok(Err(error.to_string())), + }; + match result { + CommandResult::NeedsInput(_) => { + Ok(Err("Custom arguments formatter requested input.".into())) + } + CommandResult::Cancelled => Err(ServiceError::Cancelled), + CommandResult::Success(text) => Ok(Ok(text.trim().into())), + CommandResult::TransientError { message, trace } => { + Ok(Err(CommandResult::format_error(&message, &trace))) + } + other => Ok(other.into_tool_result(name).map(|text| text.trim().into())), + } +} + +#[cfg(test)] +#[path = "service_tests.rs"] +mod tests; diff --git a/crates/jp_mcp/src/server/service_tests.rs b/crates/jp_mcp/src/server/service_tests.rs new file mode 100644 index 000000000..9c7f0241c --- /dev/null +++ b/crates/jp_mcp/src/server/service_tests.rs @@ -0,0 +1,665 @@ +#[cfg(unix)] +use std::fs; +use std::{ + future::pending, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, +}; + +use async_trait::async_trait; +#[cfg(unix)] +use camino_tempfile::{Utf8TempDir, tempdir}; +use jp_config::{ + AppConfig, Config as _, + conversation::tool::{PartialToolConfig, ToolConfig}, +}; +use jp_tool::{Outcome, Question, ToolDefinition, ToolDocs}; +use serde_json::{Value, json}; +use tokio::{ + sync::Notify, + time::{Duration, timeout}, +}; + +use super::*; +use crate::server::builtin::BuiltinTool; + +struct CountingTool(Arc); + +#[async_trait] +impl BuiltinTool for CountingTool { + async fn execute(&self, arguments: &Value, answers: &IndexMap) -> Outcome { + self.0.fetch_add(1, Ordering::SeqCst); + match answers.get("confirm") { + Some(answer) => Outcome::Success { + content: json!({"arguments": arguments, "answer": answer}).to_string(), + }, + None => Question::boolean("confirm", "Proceed?") + .unwrap() + .with_preamble("Review this operation.") + .into(), + } + } +} + +fn fixture(run: &str, result: &str) -> (Service, HostReceiver, Arc) { + let count = Arc::new(AtomicUsize::new(0)); + let partial: PartialToolConfig = + serde_json::from_value(json!({"source":"builtin", "run":run, "result":result})).unwrap(); + let mut config = AppConfig::new_test(); + config.conversation.tools.insert( + "count".into(), + ToolConfig::from_partial(partial, vec![]).unwrap(), + ); + let tool = ConfiguredTool { + definition: ToolDefinition { + name: "count".into(), + docs: ToolDocs::default(), + parameters: json!({"type":"object", "properties":{"path":{"type":"string"}}, "required":["path"]}), + }, + config: config.conversation.tools.get("count").unwrap(), + access: None, + }; + let (service, host) = Service::new( + vec![tool], + Client::default(), + BuiltinExecutors::new().register("count", CountingTool(count.clone())), + "/tmp".into(), + InvocationContext::default(), + ) + .unwrap(); + (service, host, count) +} + +async fn release(host: &mut HostReceiver) { + let Interaction::Prepare { + arguments, reply, .. + } = next(host).await.interaction + else { + panic!("expected preparation") + }; + reply.send(Ok(Admission::Run { arguments })).unwrap(); + let Interaction::Release { reply, .. } = next(host).await.interaction else { + panic!("expected release") + }; + reply.send(Ok(())).unwrap(); +} + +async fn next(host: &mut HostReceiver) -> HostRequest { + timeout(Duration::from_secs(2), host.recv()) + .await + .unwrap() + .unwrap() +} + +fn request() -> CallRequest { + CallRequest { + name: "count".into(), + arguments: json!({"path":"original"}).as_object().unwrap().clone(), + correlation: Map::new(), + } +} + +#[tokio::test] +async fn preparation_release_input_and_delivery_use_distinct_acknowledgements() { + let (service, mut host, count) = fixture("edit", "edit"); + let call = service.start_call(request()).unwrap(); + let id = call.id(); + let prepared = next(&mut host).await; + assert_eq!(prepared.call.id, id); + let Interaction::Prepare { + arguments, reply, .. + } = prepared.interaction + else { + panic!("expected preparation") + }; + assert_eq!(arguments, request().arguments); + assert_eq!(count.load(Ordering::SeqCst), 0); + reply + .send(Ok(Admission::Run { + arguments: json!({"path":"edited"}).as_object().unwrap().clone(), + })) + .unwrap(); + let Interaction::Release { + arguments, reply, .. + } = next(&mut host).await.interaction + else { + panic!("expected release") + }; + assert_eq!(arguments["path"], "edited"); + assert_eq!(count.load(Ordering::SeqCst), 0); + reply.send(Ok(())).unwrap(); + let Interaction::Input { + request, + supporting, + answers, + reply, + } = next(&mut host).await.interaction + else { + panic!("expected input") + }; + assert_eq!(request.id.as_str(), "confirm"); + assert_eq!(supporting, vec![ContentBlock::text( + "Review this operation." + )]); + assert!(answers.is_empty()); + assert_eq!(count.load(Ordering::SeqCst), 1); + reply.send(Ok(json!(true))).unwrap(); + let Interaction::Review { result, reply, .. } = next(&mut host).await.interaction else { + panic!("expected review") + }; + assert_eq!( + result, + Ok(r#"{"arguments":{"path":"edited"},"answer":true}"#.into()) + ); + assert_eq!(count.load(Ordering::SeqCst), 2); + reply.send(Ok(Ok("edited result".into()))).unwrap(); + let Interaction::Record { result, reply, .. } = next(&mut host).await.interaction else { + panic!("expected recording") + }; + assert_eq!(result, Ok("edited result".into())); + assert!(!call.is_finished()); + reply.send(Ok(())).unwrap(); + assert_eq!(call.finish().await.unwrap(), Ok("edited result".into())); + service.shutdown().await.unwrap(); +} + +#[tokio::test] +async fn denied_call_never_executes() { + let (service, mut host, count) = fixture("ask", "unattended"); + let call = service.start_call(request()).unwrap(); + let Interaction::Prepare { reply, .. } = next(&mut host).await.interaction else { + panic!("expected preparation") + }; + reply + .send(Ok(Admission::Skip { + reason: "denied".into(), + })) + .unwrap(); + let Interaction::Record { reply, result, .. } = next(&mut host).await.interaction else { + panic!("expected recording") + }; + assert_eq!(result, Ok("denied".into())); + reply.send(Ok(())).unwrap(); + assert_eq!(call.finish().await.unwrap(), Ok("denied".into())); + assert_eq!(count.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn invalid_edited_arguments_do_not_reach_execution() { + let (service, mut host, count) = fixture("edit", "unattended"); + let call = service.start_call(request()).unwrap(); + let Interaction::Prepare { reply, .. } = next(&mut host).await.interaction else { + panic!("expected preparation") + }; + reply + .send(Ok(Admission::Run { + arguments: Map::new(), + })) + .unwrap(); + assert!(matches!( + call.finish().await, + Err(ServiceError::Tool(ToolError::Arguments { .. })) + )); + assert_eq!(count.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn host_loss_fails_closed() { + let (service, host, count) = fixture("ask", "unattended"); + drop(host); + let call = service.start_call(request()).unwrap(); + assert!(matches!( + call.finish().await, + Err(ServiceError::HostDisconnected) + )); + assert_eq!(count.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn shutdown_cancels_pending_release_and_rejects_late_reply() { + let (service, mut host, count) = fixture("ask", "unattended"); + let call = service.start_call(request()).unwrap(); + let Interaction::Prepare { reply, .. } = next(&mut host).await.interaction else { + panic!("expected preparation") + }; + reply + .send(Ok(Admission::Run { + arguments: request().arguments, + })) + .unwrap(); + let Interaction::Release { reply, .. } = next(&mut host).await.interaction else { + panic!("expected release") + }; + timeout(Duration::from_secs(2), service.shutdown()) + .await + .unwrap() + .unwrap(); + assert!(reply.send(Ok(())).is_err()); + assert!(matches!(call.finish().await, Err(ServiceError::Cancelled))); + assert!(matches!( + service.start_call(request()), + Err(ServiceError::Stopped) + )); + assert_eq!(count.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn invalid_answer_prevents_a_second_attempt() { + let (service, mut host, count) = fixture("ask", "unattended"); + let call = service.start_call(request()).unwrap(); + release(&mut host).await; + let Interaction::Input { reply, .. } = next(&mut host).await.interaction else { + panic!("expected input") + }; + assert_eq!(count.load(Ordering::SeqCst), 1); + reply.send(Ok(json!("not a boolean"))).unwrap(); + assert!(matches!(call.finish().await, Err(ServiceError::InvalidAnswer(id)) if id == "confirm")); + assert_eq!(count.load(Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn failed_recording_prevents_result_delivery() { + let (service, mut host, count) = fixture("ask", "unattended"); + let call = service.start_call(request()).unwrap(); + release(&mut host).await; + let Interaction::Input { reply, .. } = next(&mut host).await.interaction else { + panic!("expected input") + }; + reply.send(Ok(json!(true))).unwrap(); + let Interaction::Record { reply, .. } = next(&mut host).await.interaction else { + panic!("expected recording") + }; + reply.send(Err(HostError("disk full".into()))).unwrap(); + assert!( + matches!(call.finish().await, Err(ServiceError::Host(HostError(reason))) if reason == "disk full") + ); + assert_eq!(count.load(Ordering::SeqCst), 2); +} + +#[tokio::test] +async fn current_call_cancellation_does_not_poison_later_calls() { + let (service, mut host, count) = fixture("ask", "unattended"); + let first = service.start_call(request()).unwrap(); + release(&mut host).await; + let Interaction::Input { reply: stale, .. } = next(&mut host).await.interaction else { + panic!("expected input") + }; + service.cancel_current(); + assert!(matches!(first.finish().await, Err(ServiceError::Cancelled))); + assert!(stale.send(Ok(json!(true))).is_err()); + let second = service.start_call(request()).unwrap(); + release(&mut host).await; + let Interaction::Input { answers, reply, .. } = next(&mut host).await.interaction else { + panic!("expected fresh input") + }; + assert!(answers.is_empty()); + reply.send(Ok(json!(false))).unwrap(); + let Interaction::Record { reply, .. } = next(&mut host).await.interaction else { + panic!("expected recording") + }; + reply.send(Ok(())).unwrap(); + assert_eq!( + second.finish().await.unwrap(), + Ok(r#"{"arguments":{"path":"original"},"answer":false}"#.into()) + ); + assert_eq!(count.load(Ordering::SeqCst), 3); +} + +#[tokio::test] +async fn identical_calls_have_independent_answers_and_out_of_order_delivery() { + let (service, mut host, count) = fixture("ask", "unattended"); + let first = service.start_call(request()).unwrap(); + release(&mut host).await; + let first_input = next(&mut host).await; + assert_eq!(first_input.call.id, first.id()); + let Interaction::Input { + reply: first_answer, + .. + } = first_input.interaction + else { + panic!("expected first input") + }; + let second = service.start_call(request()).unwrap(); + assert_ne!(first.id(), second.id()); + release(&mut host).await; + let second_input = next(&mut host).await; + assert_eq!(second_input.call.id, second.id()); + let Interaction::Input { + reply: second_answer, + answers, + .. + } = second_input.interaction + else { + panic!("expected second input") + }; + assert!(answers.is_empty()); + second_answer.send(Ok(json!(false))).unwrap(); + let record = next(&mut host).await; + assert_eq!(record.call.id, second.id()); + let Interaction::Record { reply, .. } = record.interaction else { + panic!("expected second recording") + }; + reply.send(Ok(())).unwrap(); + assert_eq!( + second.finish().await.unwrap(), + Ok(r#"{"arguments":{"path":"original"},"answer":false}"#.into()) + ); + assert!(!first.is_finished()); + first_answer.send(Ok(json!(true))).unwrap(); + let record = next(&mut host).await; + assert_eq!(record.call.id, first.id()); + let Interaction::Record { reply, .. } = record.interaction else { + panic!("expected first recording") + }; + reply.send(Ok(())).unwrap(); + assert_eq!( + first.finish().await.unwrap(), + Ok(r#"{"arguments":{"path":"original"},"answer":true}"#.into()) + ); + assert_eq!(count.load(Ordering::SeqCst), 4); +} + +#[tokio::test] +async fn configured_skip_never_requests_execution_release() { + let (service, mut host, count) = fixture("skip", "unattended"); + let call = service.start_call(request()).unwrap(); + let Interaction::Record { + reply, raw_result, .. + } = next(&mut host).await.interaction + else { + panic!("skip must go directly to recording") + }; + assert_eq!(raw_result, None); + reply.send(Ok(())).unwrap(); + assert_eq!( + call.finish().await.unwrap(), + Ok("Tool execution skipped by configuration.".into()) + ); + assert_eq!(count.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn skipped_delivery_records_original_without_delivering_it() { + let (service, mut host, count) = fixture("ask", "skip"); + let call = service.start_call(request()).unwrap(); + release(&mut host).await; + let Interaction::Input { reply, .. } = next(&mut host).await.interaction else { + panic!("expected input") + }; + reply.send(Ok(json!(true))).unwrap(); + let Interaction::Record { + reply, + raw_result, + result, + .. + } = next(&mut host).await.interaction + else { + panic!("expected recording, no review") + }; + assert_eq!( + raw_result, + Some(Ok( + r#"{"arguments":{"path":"original"},"answer":true}"#.into() + )) + ); + assert_eq!( + result, + Ok("Result delivery skipped by configuration.".into()) + ); + reply.send(Ok(())).unwrap(); + assert_eq!( + call.finish().await.unwrap(), + Ok("Result delivery skipped by configuration.".into()) + ); + assert_eq!(count.load(Ordering::SeqCst), 2); +} + +#[tokio::test] +#[cfg(unix)] +async fn local_inquiry_exits_and_runs_a_new_process_with_the_answer() { + let root = tempdir().unwrap(); + let partial: PartialToolConfig = serde_json::from_value(json!({ + "source":"local", "run":"ask", + "command": {"program":"sh", "args":["-c", "printf 'run\\n' >> attempts; if [ \"$1\" = null ]; then printf '%s' '{\"type\":\"needs_input\",\"question\":{\"id\":\"confirm\",\"text\":\"Proceed?\",\"answer_type\":{\"type\":\"boolean\"},\"pre_amble\":null,\"default\":null}}'; else printf '%s' \"$1\"; fi", "probe", "{{tool.answers.confirm | default('null')}}"], "shell":false} + })).unwrap(); + let mut cfg = AppConfig::new_test(); + cfg.conversation.tools.insert( + "local".into(), + ToolConfig::from_partial(partial, vec![]).unwrap(), + ); + let tool = ConfiguredTool { + definition: ToolDefinition { + name: "local".into(), + docs: ToolDocs::default(), + parameters: json!({"type":"object","properties":{}}), + }, + config: cfg.conversation.tools.get("local").unwrap(), + access: None, + }; + let (service, mut host) = Service::new( + vec![tool], + Client::default(), + BuiltinExecutors::new(), + root.path().to_owned(), + InvocationContext::default(), + ) + .unwrap(); + let call = service + .start_call(CallRequest { + name: "local".into(), + arguments: Map::new(), + correlation: Map::new(), + }) + .unwrap(); + release(&mut host).await; + let Interaction::Input { reply, .. } = next(&mut host).await.interaction else { + panic!("expected local input") + }; + assert_eq!( + fs::read_to_string(root.path().join("attempts")).unwrap(), + "run\n" + ); + reply.send(Ok(json!(true))).unwrap(); + let Interaction::Record { reply, .. } = next(&mut host).await.interaction else { + panic!("expected recording") + }; + assert_eq!( + fs::read_to_string(root.path().join("attempts")).unwrap(), + "run\nrun\n" + ); + reply.send(Ok(())).unwrap(); + assert_eq!(call.finish().await.unwrap(), Ok("true".into())); +} + +#[tokio::test] +async fn wrong_argument_type_fails_before_host_approval() { + let (service, _host, count) = fixture("ask", "unattended"); + let mut input = request(); + input.arguments.insert("path".into(), json!(42)); + let call = service.start_call(input).unwrap(); + assert!( + matches!(timeout(Duration::from_secs(2), call.finish()).await.unwrap(), Err(ServiceError::InvalidArgument { path }) if path == "path") + ); + assert_eq!(count.load(Ordering::SeqCst), 0); +} + +struct BlockedTool { + entered: Arc, + dropped: Arc, +} +struct RunningAttempt(Arc); +impl Drop for RunningAttempt { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::SeqCst); + } +} + +#[async_trait] +impl BuiltinTool for BlockedTool { + async fn execute(&self, _: &Value, _: &IndexMap) -> Outcome { + let _attempt = RunningAttempt(self.dropped.clone()); + self.entered.notify_one(); + pending().await + } +} + +#[tokio::test] +async fn cancellation_drops_an_in_flight_builtin_attempt() { + let (mut service, mut host, _) = fixture("ask", "unattended"); + let entered = Arc::new(Notify::new()); + let dropped = Arc::new(AtomicUsize::new(0)); + Arc::get_mut(&mut service.inner).unwrap().builtins = + BuiltinExecutors::new().register("count", BlockedTool { + entered: entered.clone(), + dropped: dropped.clone(), + }); + let call = service.start_call(request()).unwrap(); + release(&mut host).await; + timeout(Duration::from_secs(2), entered.notified()) + .await + .unwrap(); + assert_eq!(dropped.load(Ordering::SeqCst), 0); + call.cancel(); + assert!(matches!(call.finish().await, Err(ServiceError::Cancelled))); + assert_eq!(dropped.load(Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn dropping_result_receiver_does_not_cancel_or_reexecute() { + let (service, mut host, count) = fixture("ask", "unattended"); + let call = service.start_call(request()).unwrap(); + release(&mut host).await; + let Interaction::Input { reply, .. } = next(&mut host).await.interaction else { + panic!("expected input") + }; + drop(call); + reply.send(Ok(json!(true))).unwrap(); + let Interaction::Record { reply, .. } = next(&mut host).await.interaction else { + panic!("expected recording") + }; + reply.send(Ok(())).unwrap(); + assert_eq!(count.load(Ordering::SeqCst), 2); + service.shutdown().await.unwrap(); +} + +#[cfg(unix)] +fn formatter_fixture(mode: &str) -> (Service, HostReceiver, Utf8TempDir) { + let (mut service, host, _) = fixture("ask", "unattended"); + let root = tempdir().unwrap(); + let partial: PartialToolConfig = serde_json::from_value(json!({ + "source":"builtin", "run":"ask", "format":mode, + "style":{"parameters":{"program":"sh", "args":["-c", "printf 'formatted' > formatter-ran; printf '%s' '{{context.action}}:{{tool.arguments.path}}'"], "shell":false}} + })).unwrap(); + let mut cfg = AppConfig::new_test(); + cfg.conversation.tools.insert( + "count".into(), + ToolConfig::from_partial(partial, vec![]).unwrap(), + ); + let inner = Arc::get_mut(&mut service.inner).unwrap(); + inner.root = root.path().to_owned(); + inner.tools.get_mut("count").unwrap().config = cfg.conversation.tools.get("count").unwrap(); + (service, host, root) +} + +#[tokio::test] +#[cfg(unix)] +async fn formatter_asks_for_visibility_and_waits_for_approval() { + let (service, mut host, root) = formatter_fixture("ask"); + let call = service.start_call(request()).unwrap(); + let Interaction::RenderArguments { reply } = next(&mut host).await.interaction else { + panic!("expected visibility request") + }; + reply.send(Ok(true)).unwrap(); + let Interaction::Prepare { + reply, + formatted_arguments, + .. + } = next(&mut host).await.interaction + else { + panic!("expected approval") + }; + assert_eq!(formatted_arguments, None); + assert!(!root.path().join("formatter-ran").exists()); + reply + .send(Ok(Admission::Run { + arguments: request().arguments, + })) + .unwrap(); + let Interaction::Release { + reply, + formatted_arguments, + .. + } = next(&mut host).await.interaction + else { + panic!("expected release") + }; + assert_eq!( + formatted_arguments, + Some(Ok("format_arguments:original".into())) + ); + assert_eq!( + fs::read_to_string(root.path().join("formatter-ran")).unwrap(), + "formatted" + ); + call.cancel(); + assert!(matches!(call.finish().await, Err(ServiceError::Cancelled))); + assert!(reply.send(Ok(())).is_err()); +} + +#[tokio::test] +#[cfg(unix)] +async fn unattended_formatter_is_available_before_approval() { + let (service, mut host, root) = formatter_fixture("unattended"); + let call = service.start_call(request()).unwrap(); + let Interaction::RenderArguments { reply } = next(&mut host).await.interaction else { + panic!("expected visibility request") + }; + reply.send(Ok(true)).unwrap(); + let Interaction::Prepare { + formatted_arguments, + .. + } = next(&mut host).await.interaction + else { + panic!("expected preparation") + }; + assert_eq!( + formatted_arguments, + Some(Ok("format_arguments:original".into())) + ); + assert!(root.path().join("formatter-ran").exists()); + call.cancel(); + assert!(matches!(call.finish().await, Err(ServiceError::Cancelled))); +} + +#[tokio::test] +#[cfg(unix)] +async fn hidden_presentation_never_executes_formatter() { + let (service, mut host, root) = formatter_fixture("unattended"); + let call = service.start_call(request()).unwrap(); + let Interaction::RenderArguments { reply } = next(&mut host).await.interaction else { + panic!("expected visibility request") + }; + reply.send(Ok(false)).unwrap(); + let Interaction::Prepare { + formatted_arguments, + reply, + .. + } = next(&mut host).await.interaction + else { + panic!("expected preparation") + }; + assert_eq!(formatted_arguments, None); + reply + .send(Ok(Admission::Skip { + reason: "denied".into(), + })) + .unwrap(); + let Interaction::Record { reply, .. } = next(&mut host).await.interaction else { + panic!("expected recording") + }; + reply.send(Ok(())).unwrap(); + assert_eq!(call.finish().await.unwrap(), Ok("denied".into())); + assert!(!root.path().join("formatter-ran").exists()); +} diff --git a/crates/jp_llm/src/tool_tests.rs b/crates/jp_mcp/src/server_tests.rs similarity index 93% rename from crates/jp_llm/src/tool_tests.rs rename to crates/jp_mcp/src/server_tests.rs index 38972e5df..943f7d9af 100644 --- a/crates/jp_llm/src/tool_tests.rs +++ b/crates/jp_mcp/src/server_tests.rs @@ -3,11 +3,11 @@ use jp_config::{ AppConfig, Config as _, conversation::tool::{PartialToolConfig, ToolConfig}, }; -use jp_mcp::Client; use jp_tool::{Outcome, ToolDefinition, ToolDocs}; use serde_json::Map; use super::*; +use crate::Client; struct EchoArguments; @@ -20,62 +20,6 @@ impl BuiltinTool for EchoArguments { } } -#[test] -fn test_execution_outcome_completed_success_into_response() { - let outcome = ExecutionOutcome::Completed { - id: "call_123".to_string(), - result: Ok("Tool output".to_string()), - }; - - let response = outcome.into_response(); - assert_eq!(response.id, "call_123"); - assert_eq!(response.result, Ok("Tool output".to_string())); -} - -#[test] -fn test_execution_outcome_completed_error_into_response() { - let outcome = ExecutionOutcome::Completed { - id: "call_456".to_string(), - result: Err("Tool failed".to_string()), - }; - - let response = outcome.into_response(); - assert_eq!(response.id, "call_456"); - assert_eq!(response.result, Err("Tool failed".to_string())); -} - -#[test] -fn test_execution_outcome_needs_input_into_response() { - let question = Question::text("q1", "What is your name?").unwrap(); - - let outcome = ExecutionOutcome::NeedsInput { - id: "call_789".to_string(), - question, - }; - - let response = outcome.into_response(); - assert_eq!(response.id, "call_789"); - assert!(response.result.is_ok()); - assert!( - response - .result - .unwrap() - .contains("requires additional input") - ); -} - -#[test] -fn test_execution_outcome_cancelled_into_response() { - let outcome = ExecutionOutcome::Cancelled { - id: "call_abc".to_string(), - }; - - let response = outcome.into_response(); - assert_eq!(response.id, "call_abc"); - assert!(response.result.is_ok()); - assert!(response.result.unwrap().contains("cancelled")); -} - #[test] fn test_execution_outcome_id() { let completed = ExecutionOutcome::Completed { From 13d5bc5260b4a9076ff3c21be5adcf626454539f Mon Sep 17 00:00:00 2001 From: Jean Mertz Date: Sat, 12 Sep 2026 17:00:20 +0200 Subject: [PATCH 05/29] feat(mcp): Route tools through the HTTP server JP's ordinary query path uses its in-process MCP server for local, built-in, and upstream tools. Approvals, inquiry routing, result editing, and conversation ownership stay with the MCP Host. One MCP call spans input requests and tool re-execution, and final delivery waits for the Host to flush the recorded response. Upstream stdio tools receive trusted context, options, and accumulated answers through the `computer.jp/*` metadata keys. Recognize single-text `Outcome` envelopes without flattening mixed native content or losing unedited result metadata. Keep the existing text/error projection for conversation storage. Complete RFD 109 Phase 3 with a loopback Streamable HTTP endpoint, Host and Origin checks, scoped cancellation, and shutdown cleanup. The Host connection disables proxies, redirects, and transparent session reinitialization. Authentication and the third-party-client readiness work remain separate; no Anthropic subscription flow is enabled here. Signed-off-by: Jean Mertz --- .config/supply-chain/audits.toml | 39 + .config/supply-chain/config.toml | 4 - .config/supply-chain/imports.lock | 45 ++ Cargo.lock | 101 ++- Cargo.toml | 1 + crates/jp_cli/Cargo.toml | 3 + crates/jp_cli/src/cmd.rs | 4 + crates/jp_cli/src/cmd/query.rs | 25 +- .../jp_cli/src/cmd/query/tool/coordinator.rs | 102 ++- .../src/cmd/query/tool/coordinator_tests.rs | 94 ++- crates/jp_cli/src/cmd/query/tool/executor.rs | 694 +++++++++++++----- .../src/cmd/query/tool/executor_tests.rs | 272 +++++++ crates/jp_cli/src/cmd/query/turn_loop.rs | 16 +- .../jp_cli/src/cmd/query/turn_loop_tests.rs | 151 +++- crates/jp_cli/src/cmd/query_tests.rs | 16 +- crates/jp_cli/src/error.rs | 7 + crates/jp_cli/src/render/tool.rs | 13 +- crates/jp_llm/src/tool.rs | 47 +- crates/jp_mcp/Cargo.toml | 20 +- crates/jp_mcp/README.md | 26 +- crates/jp_mcp/src/client.rs | 8 +- crates/jp_mcp/src/client_protocol_tests.rs | 151 ++++ crates/jp_mcp/src/server.rs | 145 ++-- crates/jp_mcp/src/server/http.rs | 254 +++++++ crates/jp_mcp/src/server/http/client.rs | 236 ++++++ crates/jp_mcp/src/server/http/client_tests.rs | 105 +++ crates/jp_mcp/src/server/http_tests.rs | 259 +++++++ crates/jp_mcp/src/server/service.rs | 244 ++++-- crates/jp_mcp/src/server/service_tests.rs | 32 +- crates/jp_mcp/src/server/upstream.rs | 92 +++ crates/jp_mcp/src/server/upstream_tests.rs | 100 +++ crates/jp_mcp/src/server_tests.rs | 13 +- crates/jp_tool/src/error.rs | 4 + 33 files changed, 2963 insertions(+), 360 deletions(-) create mode 100644 crates/jp_cli/src/cmd/query/tool/executor_tests.rs create mode 100644 crates/jp_mcp/src/client_protocol_tests.rs create mode 100644 crates/jp_mcp/src/server/http.rs create mode 100644 crates/jp_mcp/src/server/http/client.rs create mode 100644 crates/jp_mcp/src/server/http/client_tests.rs create mode 100644 crates/jp_mcp/src/server/http_tests.rs create mode 100644 crates/jp_mcp/src/server/upstream.rs create mode 100644 crates/jp_mcp/src/server/upstream_tests.rs diff --git a/.config/supply-chain/audits.toml b/.config/supply-chain/audits.toml index 1cf4ca9fa..017fb5655 100644 --- a/.config/supply-chain/audits.toml +++ b/.config/supply-chain/audits.toml @@ -81,6 +81,11 @@ who = "Jean Mertz " criteria = "safe-to-deploy" version = "0.1.5" +[[audits.getrandom]] +who = "Jean Mertz " +criteria = "safe-to-deploy" +delta = "0.4.2 -> 0.4.3" + [[audits.hashlink]] who = "Jean Mertz " criteria = "safe-to-deploy" @@ -196,6 +201,11 @@ who = "Jean Mertz " criteria = "safe-to-deploy" delta = "0.39.2 -> 0.41.0" +[[audits.r-efi]] +who = "Jean Mertz " +criteria = "safe-to-deploy" +delta = "5.3.0 -> 6.0.0" + [[audits.ra-ap-rustc_lexer]] who = "Jean Mertz " criteria = "safe-to-deploy" @@ -321,6 +331,11 @@ who = "Jean Mertz " criteria = "safe-to-deploy" version = "0.5.2" +[[audits.sse-stream]] +who = "Jean Mertz " +criteria = "safe-to-deploy" +version = "0.2.6" + [[audits.string_cache]] who = "Jean Mertz " criteria = "safe-to-deploy" @@ -533,6 +548,12 @@ trusted-publisher = "github:rust-lang/cc-rs" start = "2025-09-01" end = "2027-03-04" +[[trusted.chacha20]] +criteria = "safe-to-deploy" +trusted-publisher = "github:RustCrypto/stream-ciphers" +start = "2026-02-06" +end = "2027-09-23" + [[trusted.clap]] criteria = "safe-to-deploy" user-id = 6743 # Ed Page (epage) @@ -575,6 +596,12 @@ user-id = 696 # Nick Fitzgerald (fitzgen) start = "2019-07-30" end = "2027-08-19" +[[trusted.cpufeatures]] +criteria = "safe-to-deploy" +user-id = 267 # Tony Arcieri (tarcieri) +start = "2021-04-26" +end = "2027-09-23" + [[trusted.dtoa]] criteria = "safe-to-deploy" user-id = 3618 # David Tolnay (dtolnay) @@ -923,6 +950,18 @@ user-id = 3618 # David Tolnay (dtolnay) start = "2019-04-09" end = "2027-02-13" +[[trusted.rand]] +criteria = "safe-to-deploy" +trusted-publisher = "github:rust-random/rand" +start = "2026-01-26" +end = "2027-09-23" + +[[trusted.rand_core]] +criteria = "safe-to-deploy" +trusted-publisher = "github:rust-random/rand_core" +start = "2026-01-20" +end = "2027-09-23" + [[trusted.ref-cast]] criteria = "safe-to-deploy" user-id = 3618 # David Tolnay (dtolnay) diff --git a/.config/supply-chain/config.toml b/.config/supply-chain/config.toml index e7b654e1e..30e3ecc37 100644 --- a/.config/supply-chain/config.toml +++ b/.config/supply-chain/config.toml @@ -153,10 +153,6 @@ criteria = "safe-to-deploy" version = "0.10.1" criteria = "safe-to-deploy" -[[exemptions.cpufeatures]] -version = "0.2.17" -criteria = "safe-to-deploy" - [[exemptions.crc32fast]] version = "1.5.0" criteria = "safe-to-deploy" diff --git a/.config/supply-chain/imports.lock b/.config/supply-chain/imports.lock index 07d124a3b..4b7665dd9 100644 --- a/.config/supply-chain/imports.lock +++ b/.config/supply-chain/imports.lock @@ -130,6 +130,11 @@ version = "1.2.56" when = "2026-02-13" trusted-publisher = "github:rust-lang/cc-rs" +[[publisher.chacha20]] +version = "0.10.2" +when = "2026-08-27" +trusted-publisher = "github:RustCrypto/stream-ciphers" + [[publisher.clap]] version = "4.5.48" when = "2025-09-19" @@ -179,6 +184,20 @@ user-id = 696 user-login = "fitzgen" user-name = "Nick Fitzgerald" +[[publisher.cpufeatures]] +version = "0.2.17" +when = "2025-01-25" +user-id = 267 +user-login = "tarcieri" +user-name = "Tony Arcieri" + +[[publisher.cpufeatures]] +version = "0.3.1" +when = "2026-08-26" +user-id = 267 +user-login = "tarcieri" +user-name = "Tony Arcieri" + [[publisher.dtoa]] version = "1.0.11" when = "2025-12-27" @@ -552,6 +571,16 @@ user-id = 3618 user-login = "dtolnay" user-name = "David Tolnay" +[[publisher.rand]] +version = "0.10.2" +when = "2026-07-02" +trusted-publisher = "github:rust-random/rand" + +[[publisher.rand_core]] +version = "0.10.1" +when = "2026-04-13" +trusted-publisher = "github:rust-random/rand_core" + [[publisher.ref-cast]] version = "1.0.24" when = "2025-03-03" @@ -1425,6 +1454,12 @@ who = "Pat Hickey " criteria = "safe-to-deploy" delta = "0.3.28 -> 0.3.31" +[[audits.bytecode-alliance.audits.getrandom]] +who = "Alex Crichton " +criteria = "safe-to-deploy" +delta = "0.4.1 -> 0.4.2" +notes = "Nothing awry in this update, standard updates for some platforms and other misc things." + [[audits.bytecode-alliance.audits.gimli]] who = "Alex Crichton " criteria = "safe-to-deploy" @@ -2732,6 +2767,16 @@ who = "J.C. Jones " criteria = "safe-to-deploy" delta = "1.0.1 -> 1.0.3" +[[audits.isrg.audits.getrandom]] +who = "David Cook " +criteria = "safe-to-deploy" +delta = "0.3.4 -> 0.4.0" + +[[audits.isrg.audits.getrandom]] +who = "David Cook " +criteria = "safe-to-deploy" +delta = "0.4.0 -> 0.4.1" + [[audits.isrg.audits.libbz2-rs-sys]] who = "Ameer Ghani " criteria = "safe-to-deploy" diff --git a/Cargo.lock b/Cargo.lock index a070d13fc..d3d0a096b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -564,6 +564,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + [[package]] name = "chrono" version = "0.4.42" @@ -772,6 +783,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -1490,11 +1510,23 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", +] + [[package]] name = "gimli" version = "0.31.1" @@ -2338,10 +2370,12 @@ dependencies = [ "minijinja", "pretty_assertions", "quick-xml", + "rand 0.9.5", "rayon", "regex", "relative-path", "reqwest", + "rmcp", "schemars", "schematic", "serde", @@ -2552,7 +2586,7 @@ dependencies = [ "openai_responses", "paste", "quick-xml", - "rand", + "rand 0.9.5", "reqwest", "reqwest-eventsource", "saphyr", @@ -2578,18 +2612,22 @@ version = "0.1.0" dependencies = [ "assert_matches", "async-trait", + "axum", "camino", "camino-tempfile", + "futures", "indexmap", "jp_config", "jp_test", "jp_tool", "minijinja", + "reqwest", "rmcp", "serde", "serde_json", "sha1", "sha2", + "sse-stream", "test-log", "thiserror 2.0.20", "tokio", @@ -3497,7 +3535,7 @@ checksum = "bee689443a2bd0a16ab0348b52ee43e3b2d1b1f931c8aa5c9f8de4c86fbe8c40" dependencies = [ "bitflags 2.9.4", "num-traits", - "rand", + "rand 0.9.5", "rand_chacha", "rand_xorshift", "regex-syntax", @@ -3544,7 +3582,7 @@ dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", - "rand", + "rand 0.9.5", "ring", "rustc-hash 2.1.1", "rustls", @@ -3585,6 +3623,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "ra-ap-rustc_lexer" version = "0.167.0" @@ -3603,7 +3647,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha", - "rand_core", + "rand_core 0.9.3", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", ] [[package]] @@ -3613,7 +3668,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.9.3", ] [[package]] @@ -3625,13 +3680,19 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "rand_xorshift" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" dependencies = [ - "rand_core", + "rand_core 0.9.3", ] [[package]] @@ -3844,20 +3905,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d1f571c72940a19d9532fe52dbea8bc9912bf1d766c2970bb824056b86f3f59" dependencies = [ "async-trait", + "bytes", "chrono", "futures", + "http", + "http-body", + "http-body-util", "pastey", "pin-project-lite", "process-wrap", + "rand 0.10.2", "rmcp-macros", "schemars", "serde", "serde_json", + "sse-stream", "thiserror 2.0.20", "tokio", "tokio-stream", "tokio-util", + "tower-service", "tracing", + "uuid", ] [[package]] @@ -4408,7 +4477,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -4425,7 +4494,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -4556,6 +4625,19 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "sse-stream" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c25ac7aff0abd1dbc474536e40416e1102c7dd9bfba0b9861c6d357f835dcfb4" +dependencies = [ + "bytes", + "futures-util", + "http-body", + "http-body-util", + "pin-project-lite", +] + [[package]] name = "stable_deref_trait" version = "1.2.0" @@ -5345,6 +5427,7 @@ version = "1.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" dependencies = [ + "getrandom 0.4.3", "js-sys", "sha1_smol", "wasm-bindgen", diff --git a/Cargo.toml b/Cargo.toml index 3d42c3358..23344418f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -147,6 +147,7 @@ sha1 = { version = "0.10", default-features = false } sha2 = { version = "0.10", default-features = false } shlex = { version = "1", default-features = false } similar = { version = "2", default-features = false } +sse-stream = { version = "0.2", default-features = false } strip-ansi-escapes = { version = "0.2", default-features = false } syn = { version = "2", default-features = false } syntect = { version = "5.3", default-features = false } diff --git a/crates/jp_cli/Cargo.toml b/crates/jp_cli/Cargo.toml index 3740a88c9..29c168b9e 100644 --- a/crates/jp_cli/Cargo.toml +++ b/crates/jp_cli/Cargo.toml @@ -72,10 +72,12 @@ indoc = { workspace = true } inquire = { workspace = true, features = ["crossterm"] } minijinja = { workspace = true } quick-xml = { workspace = true, features = ["serialize"] } +rand = { workspace = true, features = ["thread_rng"] } rayon = { workspace = true } regex = { workspace = true, features = ["perf", "std", "unicode"] } relative-path = { workspace = true } reqwest = { workspace = true } +rmcp = { workspace = true, features = ["client"] } schemars = { workspace = true } schematic = { workspace = true, features = ["schema_serde", "renderer_template", "toml"] } serde = { workspace = true } @@ -125,6 +127,7 @@ insta = { workspace = true } pretty_assertions = { workspace = true, features = ["std"] } serial_test = { workspace = true } test-log = { workspace = true } +tokio = { workspace = true, features = ["test-util"] } [lints] workspace = true diff --git a/crates/jp_cli/src/cmd.rs b/crates/jp_cli/src/cmd.rs index e2bd03331..3296dd3be 100644 --- a/crates/jp_cli/src/cmd.rs +++ b/crates/jp_cli/src/cmd.rs @@ -448,6 +448,10 @@ impl From for Error { Workspace(error) => return error.into(), Conversation(error) => return error.into(), Mcp(error) => return error.into(), + McpEndpoint(error) => [("message", error.to_string())].into(), + McpRecording(error) => { + [("message", format!("MCP Host recording failed: {error}"))].into() + } Llm(error) => return error.into(), Io(error) => return error.into(), Url(error) => return error.into(), diff --git a/crates/jp_cli/src/cmd/query.rs b/crates/jp_cli/src/cmd/query.rs index e97cf330c..6605fb924 100644 --- a/crates/jp_cli/src/cmd/query.rs +++ b/crates/jp_cli/src/cmd/query.rs @@ -117,10 +117,10 @@ use jp_printer::{LineSink, PrintableExt as _, Printer, RegionStyle, StatusRegion use jp_storage::backend::{FsStorageBackend, Projection}; use jp_task::task::TitleGeneratorTask; use jp_term::width::{display_width, truncate_to_width}; +use jp_tool::{Error as ToolError, ToolDefinition, ToolDocs}; use jp_workspace::{ ConversationHandle, ConversationLock, ConversationMut, Id as WorkspaceId, Workspace, }; -use jp_tool::{Error as ToolError, ToolDefinition, ToolDocs}; use minijinja::{Environment, UndefinedBehavior}; use strip_ansi_escapes::strip_str; use tokio::sync::broadcast::error::RecvError; @@ -1170,14 +1170,22 @@ impl Query { .collect(); let builtin_executors = BuiltinExecutors::new().register("describe_tools", DescribeTools::new(docs_map)); - let executor_source = - TerminalExecutorSource::new(builtin_executors, tools, approvals, invocation.clone()); + let (executor_source, execution_owner) = TerminalExecutorSource::start( + builtin_executors, + tools, + &cfg.conversation.tools, + approvals, + invocation.clone(), + mcp_client, + root.clone(), + ) + .await?; let tool_coordinator = ToolCoordinator::new(cfg.conversation.tools.clone(), Box::new(executor_source)) .with_interrupt(cfg.interrupt.tool_call.clone()); let prompt_backend = Arc::new(TerminalPromptBackend); - run_turn_loop( + let result = run_turn_loop( provider, &model, cfg, @@ -1197,7 +1205,14 @@ impl Query { pending_trim, turn_interrupt, ) - .await + .await; + if let Err(error) = execution_owner.shutdown().await { + if result.is_ok() { + return Err(error.into()); + } + warn!(%error, "MCP execution service cleanup failed"); + } + result } /// Whether the chat request should be echoed to the terminal before the diff --git a/crates/jp_cli/src/cmd/query/tool/coordinator.rs b/crates/jp_cli/src/cmd/query/tool/coordinator.rs index 7f568e112..a00732a46 100644 --- a/crates/jp_cli/src/cmd/query/tool/coordinator.rs +++ b/crates/jp_cli/src/cmd/query/tool/coordinator.rs @@ -545,7 +545,7 @@ impl ToolCoordinator { /// 4. Return [`ToolCallDecision::Approved`], `Skipped`, or `Failed`. pub(crate) async fn resolve_tool_call_decision( &mut self, - executor: Box, + mut executor: Box, prompter: &ToolPrompter, interactive: bool, turn_state: &mut TurnState, @@ -558,13 +558,36 @@ impl ToolCoordinator { // it. prompter.set_background(tool_renderer.current_region()); + let remembered_denial = interactive + && executor.permission_info().is_some_and(|info| { + turn_state + .remembered_permission_decisions + .get(&PermissionCacheKey::new(&info.tool_name)) + == Some(&false) + }); + let render_arguments = !self.is_hidden(executor.tool_name()) && !remembered_denial; + match executor.prepare(render_arguments).await { + Ok(Some(response)) => { + self.set_tool_state(&response.id, ToolCallState::Completed); + return ToolCallDecision::Skipped(response); + } + Ok(None) => {} + Err(error) => { + self.set_tool_state(executor.tool_id(), ToolCallState::Completed); + return ToolCallDecision::Failed(ToolCallResponse { + id: executor.tool_id().into(), + result: Err(error), + }); + } + } + // Step 1: decide. let decision = self.decide_permission(executor, interactive, turn_state); // Step 2: handle prompt path. After this match, `executor` is // approved and `pre_rendered` is `Some(content)` if pre-rendering // already happened, `None` if a post-render is still needed. - let (executor, pre_rendered) = match decision { + let (mut executor, pre_rendered) = match decision { PermissionDecision::Approved(executor) => (executor, None), PermissionDecision::Skipped(response) => { return ToolCallDecision::Skipped(response); @@ -578,7 +601,7 @@ impl ToolCoordinator { // formatters are gated on `format = "unattended"` // because they shell out to a user-controlled command. let pre = match self - .pre_render_for_prompt(&info.tool_name, executor.arguments(), tool_renderer) + .pre_render_executor_for_prompt(executor.as_ref(), tool_renderer) .await { Ok(maybe_content) => maybe_content, @@ -613,16 +636,20 @@ impl ToolCoordinator { } }; + if let Err(error) = executor.approve().await { + self.set_tool_state(executor.tool_id(), ToolCallState::Completed); + return ToolCallDecision::Failed(ToolCallResponse { + id: executor.tool_id().into(), + result: Err(error), + }); + } + // Step 3: render. If pre-rendered, use that; otherwise render now. let rendered_arguments = if let Some(pre) = pre_rendered { pre } else { let tool_name = executor.tool_name().to_owned(); - let args = executor.arguments().clone(); - match self - .render_approved_tool(&tool_name, &args, tool_renderer) - .await - { + match self.render_executor(executor.as_ref(), tool_renderer).await { RenderOutcome::Rendered { content } => content, RenderOutcome::Suppressed { error } => { let id = executor.tool_id().to_owned(); @@ -641,6 +668,65 @@ impl ToolCoordinator { } } + async fn pre_render_executor_for_prompt( + &self, + executor: &dyn Executor, + renderer: &ToolRenderer, + ) -> Result>, String> { + if !executor.formats_arguments() + || !matches!( + self.parameter_style(executor.tool_name()), + ParametersStyle::Custom(_) + ) + { + return self + .pre_render_for_prompt(executor.tool_name(), executor.arguments(), renderer) + .await; + } + if executor.formatted_arguments().is_none() { + return Ok(None); + } + match self.render_executor(executor, renderer).await { + RenderOutcome::Rendered { content } => Ok(Some(content)), + RenderOutcome::Suppressed { error } => Err(error), + } + } + + async fn render_executor( + &self, + executor: &dyn Executor, + renderer: &ToolRenderer, + ) -> RenderOutcome { + let name = executor.tool_name(); + if self.is_hidden(name) { + return RenderOutcome::Rendered { content: None }; + } + if executor.formats_arguments() + && matches!(self.parameter_style(name), ParametersStyle::Custom(_)) + { + return renderer.render_custom_result( + name, + executor + .formatted_arguments() + .cloned() + .unwrap_or_else(|| Ok(String::new())), + ); + } + self.render_approved_tool(name, executor.arguments(), renderer) + .await + } + + /// Acknowledge the execution service after the conversation owner flushes. + pub async fn acknowledge_responses( + &self, + responses: Vec, + ) -> Result<(), String> { + for response in responses { + self.executor_source.acknowledge(response).await?; + } + Ok(()) + } + pub fn question_target(&self, tool_name: &str, question_id: &str) -> Option { self.tools_config .get(tool_name) diff --git a/crates/jp_cli/src/cmd/query/tool/coordinator_tests.rs b/crates/jp_cli/src/cmd/query/tool/coordinator_tests.rs index 8d40ecb30..bf6066acd 100644 --- a/crates/jp_cli/src/cmd/query/tool/coordinator_tests.rs +++ b/crates/jp_cli/src/cmd/query/tool/coordinator_tests.rs @@ -1,21 +1,29 @@ use async_trait::async_trait; use camino_tempfile::Utf8TempDir; +#[cfg(unix)] +use jp_config::AppConfig; use jp_config::conversation::tool::{ToolConfig, ToolSource, style::PartialDisplayStyleConfig}; use jp_inquire::{ReplyOutcome, prompt::MockPromptBackend}; -use jp_llm::tool::MockExecutor; +use jp_llm::tool::{MockExecutor, TestExecutorSource}; +#[cfg(unix)] +use jp_mcp::{ + Client, + server::{InvocationContext, builtin::BuiltinExecutors}, +}; use jp_printer::{ErrChannel, OutputFormat, Printer}; +#[cfg(unix)] +use jp_tool::{ToolDefinition, ToolDocs}; use schematic::Config as _; +#[cfg(unix)] +use serde_json::json; -use super::{super::executor::TerminalExecutorSource, *}; +use super::*; use crate::render::tool::ToolRenderer; +#[cfg(unix)] +use crate::{access::approvals::ApprovalStore, cmd::query::tool::executor::TerminalExecutorSource}; -fn empty_executor_source() -> Box { - Box::new(TerminalExecutorSource::new( - jp_mcp::server::builtin::BuiltinExecutors::new(), - &[], - std::sync::Arc::new(crate::access::approvals::ApprovalStore::default()), - jp_mcp::server::InvocationContext::default(), - )) +fn empty_executor_source() -> Box { + Box::new(TestExecutorSource::new()) } #[test] @@ -853,3 +861,71 @@ async fn custom_formatter_receives_the_invoked_tool_name() { .expect("valid utf-8 after stripping ANSI"); assert_eq!(output, "Calling tool ls\n\nfs_list_files\n"); } + +#[tokio::test] +#[cfg(unix)] +async fn remembered_denial_does_not_run_http_argument_formatter() { + let root = Utf8TempDir::new().unwrap(); + let mut config = AppConfig::new_test(); + let partial = serde_json::from_value(json!({ + "source":"builtin", "run":"ask", "format":"unattended", + "style":{"parameters":{"program":"sh", "args":["-c","printf formatted > formatted"], "shell":false}} + })).unwrap(); + config.conversation.tools.insert( + "example".into(), + ToolConfig::from_partial(partial, vec![]).unwrap(), + ); + let definitions = vec![ToolDefinition { + name: "example".into(), + docs: ToolDocs::default(), + parameters: json!({"type":"object","properties":{}}), + }]; + let (source, owner) = TerminalExecutorSource::start( + BuiltinExecutors::new(), + &definitions, + &config.conversation.tools, + Arc::new(ApprovalStore::default()), + InvocationContext::default(), + &Client::default(), + root.path().to_owned(), + ) + .await + .unwrap(); + let mut coordinator = ToolCoordinator::new(config.conversation.tools.clone(), Box::new(source)); + let executor = coordinator + .prepare_one(ToolCallRequest { + id: "call-1".into(), + name: "example".into(), + arguments: Map::new(), + }) + .unwrap(); + let printer = Arc::new(Printer::sink()); + let prompter = ToolPrompter::with_prompt_backend( + printer.clone(), + None, + Arc::new(MockPromptBackend::new()), + ReplyEditMode::default(), + ); + let renderer = ToolRenderer::new( + ErrChannel::new(printer), + config.style, + root.path().to_owned(), + InvocationContext::default(), + ); + let mut state = TurnState::default(); + state + .remembered_permission_decisions + .insert(PermissionCacheKey::new("example"), false); + let decision = coordinator + .resolve_tool_call_decision(executor, &prompter, true, &mut state, &renderer) + .await; + let ToolCallDecision::Skipped(response) = decision else { + panic!("expected remembered denial") + }; + assert!(!root.path().join("formatted").exists()); + coordinator + .acknowledge_responses(vec![response]) + .await + .unwrap(); + owner.shutdown().await.unwrap(); +} diff --git a/crates/jp_cli/src/cmd/query/tool/executor.rs b/crates/jp_cli/src/cmd/query/tool/executor.rs index 4048dee77..bc47ed96b 100644 --- a/crates/jp_cli/src/cmd/query/tool/executor.rs +++ b/crates/jp_cli/src/cmd/query/tool/executor.rs @@ -1,175 +1,393 @@ -//! Single tool execution for the query stream pipeline. +//! MCP Host adapter for tool calls through JP's loopback HTTP endpoint. //! -//! The `ToolExecutor` handles execution of a single tool call, including: -//! -//! - Permission prompts (run mode configuration) -//! - Input prompts (tool-specific questions) -//! - Result formatting -//! -//! # Lifecycle State Machine -//! -//! ```text -//! ┌─────────────────────────────────────────────────────┐ -//! │ ToolExecutor │ -//! │ │ -//! ┌─────────┐ │ ┌─────────┐ ┌──────────────────┐ ┌─────────┐ │ -//! │ new() │──────▶│ │ Pending │───▶│AwaitingPermission│───▶│ Running │ │ -//! └─────────┘ │ └─────────┘ └──────────────────┘ └────┬────┘ │ -//! │ │ │ │ -//! │ │ (skip) │ │ -//! │ ▼ ▼ │ -//! │ ┌───────────┐ ┌─────────────┐│ -//! │ │ Completed │◀─────│AwaitingInput││ -//! │ └───────────┘ └─────────────┘│ -//! │ ▲ │ │ -//! │ │ │ │ -//! │ ┌───────────────────┐ │ │ -//! │ │AwaitingResultEdit │◀─────┘ │ -//! │ └───────────────────┘ │ -//! └─────────────────────────────────────────────────────┘ -//! ``` -//! -//! # Thread Safety -//! -//! The executor works with `SharedTurnState` (`Arc>`) to -//! support parallel execution. -//! Lock durations are minimized to avoid blocking other executors. -//! -//! # Testing -//! -//! The [`Executor`] trait allows for mock implementations in tests. -//! See [`MockExecutor`] for testing parallel execution behavior. -//! -//! [`MockExecutor`]: jp_llm::tool::MockExecutor +//! The coordinator resolves interactions; this adapter holds their single-use +//! replies across preparation, execution, and final conversation recording. -use std::sync::Arc; +use std::{ + collections::HashMap, + sync::{Arc, Mutex as SyncMutex, MutexGuard, PoisonError}, +}; use async_trait::async_trait; -use camino::Utf8Path; +use camino::{Utf8Path, Utf8PathBuf}; +use futures::future::BoxFuture; use indexmap::IndexMap; -use jp_config::conversation::tool::{RunMode, ToolConfigWithDefaults}; +use jp_config::conversation::tool::{RunMode, ToolConfigWithDefaults, ToolsConfig}; use jp_conversation::event::{InquirySource, ToolCallRequest, ToolCallResponse}; use jp_llm::tool::{Executor, ExecutorResult, ExecutorSource, PermissionInfo}; use jp_mcp::{ - Client, - server::{ExecutionOutcome, InvocationContext, StderrSink, builtin::BuiltinExecutors, execute}, + CallToolResult, Client, + server::{ + InvocationContext, StderrSink, + builtin::BuiltinExecutors, + http::{Endpoint, EndpointError}, + service::{ + Admission, ConfiguredTool, HostReply, HostRequest, InputAnswer, Interaction, + InvocationId, ReleaseDecision, Service, + }, + text_result, + }, +}; +use jp_tool::{AnswerType, ContentBlock, InputRequest, Question, ToolDefinition}; +use rand::random; +use rmcp::{ + Peer, ServiceError as McpCallError, + model::{CallToolRequestParams, Meta}, + service::{RoleClient, RunningService}, +}; +use serde_json::{Map, Value}; +use tokio::{ + sync::{Mutex, broadcast::error::RecvError as ProgressError, mpsc, oneshot}, + task::JoinHandle, }; -use jp_tool::ToolDefinition; -use serde_json::Value; use tokio_util::sync::CancellationToken; +use tracing::debug; use crate::access::{approvals::ApprovalStore, compile::compile_tool_policy}; -/// Terminal executor source that creates real [`ToolExecutor`] instances. -/// -/// Holds pre-resolved tool definitions so executors don't need to re-resolve -/// (avoiding redundant MCP server fetches). +const CORRELATION_KEY: &str = "computer.jp/hostCall"; + +type TextResult = Result; +type Reply = oneshot::Sender>; +type Calls = Arc>>>>; + +struct Route { + request: ToolCallRequest, + invocation: Option, + sender: mpsc::Sender, +} + +type Routes = Arc>>; +type Sinks = Arc>>; + +fn locked(value: &SyncMutex) -> MutexGuard<'_, T> { + value.lock().unwrap_or_else(PoisonError::into_inner) +} + +/// Creates MCP-backed executors; configuration and execution context are fixed +/// by the MCP Host when starting the endpoint. pub struct TerminalExecutorSource { - builtin_executors: BuiltinExecutors, + peer: Peer, + service: Arc, definitions: IndexMap, - approvals: Arc, - invocation: InvocationContext, + calls: Calls, + routes: Routes, + sinks: Sinks, +} + +/// Keeps the listener, MCP connection, and Host routing tasks alive for a turn. +pub struct ExecutionOwner { + endpoint: Option, + client: Option>, + router: JoinHandle<()>, + progress: JoinHandle<()>, +} + +impl ExecutionOwner { + /// Cancel pending work and wait for listener/connection cleanup. + pub async fn shutdown(mut self) -> Result<(), EndpointError> { + if let Some(endpoint) = self.endpoint.take() { + endpoint.shutdown().await?; + } + if let Some(client) = self.client.take() { + client.cancel().await?; + } + self.router.abort(); + self.progress.abort(); + Ok(()) + } +} + +impl Drop for ExecutionOwner { + fn drop(&mut self) { + self.router.abort(); + self.progress.abort(); + } } impl TerminalExecutorSource { - #[must_use] - pub fn new( - builtin_executors: BuiltinExecutors, + /// Start the common MCP execution path and its private Host connection. + pub async fn start( + builtins: BuiltinExecutors, definitions: &[ToolDefinition], + tools: &ToolsConfig, approvals: Arc, invocation: InvocationContext, - ) -> Self { - let definitions = definitions + upstream: &Client, + root: Utf8PathBuf, + ) -> Result<(Self, ExecutionOwner), EndpointError> { + let configured = definitions .iter() - .map(|d| (d.name.clone(), d.clone())) + .filter_map(|definition| { + let config = tools.get(&definition.name)?; + let access = + compile_tool_policy(config.access(), &root, &approvals).map_err(|error| { + format!( + "invalid access policy for tool '{}': {error}", + definition.name + ) + }); + Some(ConfiguredTool { + definition: definition.clone(), + config, + access, + }) + }) .collect(); - Self { - builtin_executors, - definitions, - approvals, - invocation, - } + let (service, mut host) = + Service::new(configured, upstream.clone(), builtins, root, invocation)?; + let mut stderr = service.subscribe_progress(); + let endpoint = Endpoint::start(service).await?; + let client = endpoint.connect().await?; + let routes = Routes::default(); + let router_routes = routes.clone(); + let router = tokio::spawn(async move { + while let Some(request) = host.recv().await { + let sender = { + let key = request + .call + .request + .correlation + .get(CORRELATION_KEY) + .and_then(Value::as_str); + let mut routes = locked(&router_routes); + key.and_then(|key| routes.get_mut(key)).and_then(|route| { + // Correlation associates an existing Host call, not + // authority from caller-supplied execution metadata. + if route.request.name != request.call.request.name + || route.request.arguments != request.call.request.arguments + || route.invocation.is_some_and(|id| id != request.call.id) + { + return None; + } + if route.invocation.is_none() { + debug!(invocation = ?request.call.id, tool_call_id = %route.request.id, tool = %route.request.name, "Associated MCP invocation with Host tool call"); + } + route.invocation = Some(request.call.id); + Some(route.sender.clone()) + }) + }; + if let Some(sender) = sender { + drop(sender.send(request).await); + } + // An unassociated call loses its reply sender and fails closed. + } + }); + let sinks = Sinks::default(); + let progress_sinks = sinks.clone(); + let progress = tokio::spawn(async move { + loop { + match stderr.recv().await { + Ok(line) => { + let sink = locked(&progress_sinks).get(&line.id).cloned(); + if let Some(sink) = sink { + sink(&line.line); + } + } + Err(ProgressError::Lagged(_)) => {} + Err(ProgressError::Closed) => break, + } + } + }); + let source = Self { + peer: client.peer().clone(), + service: endpoint.service(), + definitions: definitions + .iter() + .map(|d| (d.name.clone(), d.clone())) + .collect(), + calls: Calls::default(), + routes, + sinks, + }; + Ok((source, ExecutionOwner { + endpoint: Some(endpoint), + client: Some(client), + router, + progress, + })) } } impl ExecutorSource for TerminalExecutorSource { fn create( &self, - mut request: ToolCallRequest, + request: ToolCallRequest, config: ToolConfigWithDefaults, ) -> Option> { - let definition = self.definitions.get(&request.name)?.clone(); - definition.coerce_arguments(&mut request.arguments); - - Some(Box::new(ToolExecutor::new( + self.definitions.get(&request.name)?; + let (sender, receiver) = mpsc::channel(8); + let key = format!("{:032x}", random::()); + locked(&self.routes).insert(key.clone(), Route { + request: request.clone(), + invocation: None, + sender, + }); + let state = Arc::new(Mutex::new(PendingCall { + receiver, + task: None, + input: None, + prepare: None, + release: None, + review: None, + record: None, + id: None, + finished: false, + })); + locked(&self.calls).insert(request.id.clone(), state.clone()); + Some(Box::new(ToolExecutor { request, config, - definition, - Arc::new(self.builtin_executors.clone()), - self.approvals.clone(), - self.invocation.clone(), - ))) + key, + peer: self.peer.clone(), + service: self.service.clone(), + state, + formatted: None, + sinks: self.sinks.clone(), + })) + } + + fn acknowledge(&self, response: ToolCallResponse) -> BoxFuture<'_, Result<(), String>> { + Box::pin(async move { + let call = locked(&self.calls).remove(&response.id); + let Some(call) = call else { + return Ok(()); + }; + let mut call = call.lock().await; + let result = call.acknowledge(response.result).await; + if let Some(id) = call.id { + locked(&self.sinks).remove(&id); + } + locked(&self.routes).retain(|_, route| route.request.id != response.id); + result + }) } } -/// Executes a single tool call. -/// -/// Each [`Executor::execute`] call is one execution attempt: it runs the tool -/// and reports what came back. -/// Permission prompts, question answering, and result editing are the -/// `ToolCoordinator`'s, which calls this again with accumulated answers when a -/// tool asks for input. -pub struct ToolExecutor { - request: ToolCallRequest, - config: ToolConfigWithDefaults, - definition: ToolDefinition, - builtin_executors: Arc, - approvals: Arc, - invocation: InvocationContext, +struct PendingCall { + receiver: mpsc::Receiver, + task: Option>>, + input: Option<(String, Reply)>, + prepare: Option>, + release: Option>, + review: Option>, + record: Option>, + id: Option, + finished: bool, } -impl ToolExecutor { - fn new( - request: ToolCallRequest, - config: ToolConfigWithDefaults, - definition: ToolDefinition, - builtin_executors: Arc, - approvals: Arc, - invocation: InvocationContext, - ) -> Self { - Self { - request, - config, - definition, - builtin_executors, - approvals, - invocation, +#[expect( + clippy::large_enum_variant, + reason = "Each Host interaction is consumed immediately without an additional allocation" +)] +enum Received { + Interaction(Interaction), + Finished(TextResult), +} + +impl PendingCall { + async fn next(&mut self) -> Result { + let task = self.task.as_mut().ok_or("MCP call has not started")?; + tokio::select! { + request = self.receiver.recv() => { + let request = request.ok_or("MCP Host interaction channel closed")?; + self.id = Some(request.call.id); + Ok(Received::Interaction(request.interaction)) + } + result = task => { + self.task = None; + self.finished = true; + let result = result.map_err(|error| error.to_string())?; + Ok(Received::Finished(result.map_or_else(|error| Err(error.to_string()), |result| text_result(&result)))) + } + } + } + + async fn acknowledge(&mut self, result: TextResult) -> Result<(), String> { + if self.finished { + return Ok(()); + } + if let Some(reply) = self.prepare.take() { + drop(reply.send(Ok(Admission::Complete { + result: result.clone(), + }))); + } else if let Some(reply) = self.release.take() { + drop(reply.send(Ok(ReleaseDecision::Complete { + result: result.clone(), + }))); + } else if let Some((_, reply)) = self.input.take() { + drop(reply.send(Ok(InputAnswer::Complete { + result: result.clone(), + }))); + } else if let Some(reply) = self.review.take() { + drop(reply.send(Ok(result.clone()))); + } + if let Some(reply) = self.record.take() { + drop(reply.send(Ok(()))); + } + loop { + match self.next().await? { + Received::Interaction(Interaction::Review { reply, .. }) => { + drop(reply.send(Ok(result.clone()))); + } + Received::Interaction(Interaction::Record { + reply, + result: delivered, + .. + }) => { + if delivered != result { + return Err("MCP result differs from the recorded response".into()); + } + drop(reply.send(Ok(()))); + } + Received::Finished(delivered) => { + if delivered != result { + return Err("MCP response differs from the recorded response".into()); + } + return Ok(()); + } + Received::Interaction(_) => { + return Err("Unexpected MCP interaction during recording".into()); + } + } } } } +/// Represents one logical MCP call, including its pending Host interactions. +pub struct ToolExecutor { + request: ToolCallRequest, + config: ToolConfigWithDefaults, + key: String, + peer: Peer, + service: Arc, + state: Arc>, + formatted: Option>, + sinks: Sinks, +} + #[async_trait] impl Executor for ToolExecutor { fn tool_id(&self) -> &str { &self.request.id } - fn tool_name(&self) -> &str { &self.request.name } - - fn arguments(&self) -> &serde_json::Map { + fn arguments(&self) -> &Map { &self.request.arguments } - + fn formats_arguments(&self) -> bool { + true + } + fn formatted_arguments(&self) -> Option<&TextResult> { + self.formatted.as_ref() + } fn permission_info(&self) -> Option { let run_mode = self.config.run(); - - // No prompt needed for these modes if matches!(run_mode, RunMode::Unattended | RunMode::Skip) { return None; } - Some(PermissionInfo { tool_id: self.request.id.clone(), tool_name: self.request.name.clone(), @@ -178,74 +396,210 @@ impl Executor for ToolExecutor { arguments: self.request.arguments.clone().into(), }) } - fn set_arguments(&mut self, args: Value) { - if let Value::Object(map) = args { - self.request.arguments = map; + if let Value::Object(arguments) = args { + self.request.arguments = arguments; + } + } + + async fn prepare( + &mut self, + render_arguments: bool, + ) -> Result, String> { + let mut state = self.state.lock().await; + if state.task.is_some() || state.finished { + return Err("MCP call was prepared twice".into()); + } + let mut params = CallToolRequestParams::new(self.request.name.clone()); + params.arguments = Some(self.request.arguments.clone()); + params.meta = Some(Meta(Map::from_iter([( + CORRELATION_KEY.into(), + self.key.clone().into(), + )]))); + let peer = self.peer.clone(); + state.task = Some(tokio::spawn(async move { peer.call_tool(params).await })); + loop { + match state.next().await? { + Received::Interaction(Interaction::RenderArguments { reply }) => { + drop(reply.send(Ok(render_arguments))); + } + Received::Interaction(Interaction::Prepare { + arguments, + formatted_arguments, + reply, + .. + }) => { + self.request.arguments = arguments; + self.formatted = formatted_arguments; + state.prepare = Some(reply); + return Ok(None); + } + Received::Interaction(Interaction::Record { result, reply, .. }) => { + state.record = Some(reply); + return Ok(Some(ToolCallResponse { + id: self.request.id.clone(), + result, + })); + } + Received::Finished(result) => { + return Ok(Some(ToolCallResponse { + id: self.request.id.clone(), + result, + })); + } + Received::Interaction(_) => { + return Err("Unexpected MCP preparation interaction".into()); + } + } + } + } + + async fn approve(&mut self) -> Result<(), String> { + let mut state = self.state.lock().await; + let reply = state + .prepare + .take() + .ok_or("MCP call is not awaiting approval")?; + reply + .send(Ok(Admission::Run { + arguments: self.request.arguments.clone(), + })) + .map_err(|_| "MCP approval expired")?; + match state.next().await? { + Received::Interaction(Interaction::Release { + arguments, + formatted_arguments, + reply, + }) => { + self.request.arguments = arguments; + self.formatted = formatted_arguments; + state.release = Some(reply); + Ok(()) + } + Received::Finished(Err(error)) => Err(error), + _ => Err("MCP call did not reach the release barrier".into()), } - // If not an object, ignore (preserve original arguments) } async fn execute( &self, answers: &IndexMap, - mcp_client: &Client, - root: &Utf8Path, - cancellation_token: CancellationToken, + _: &Client, + _: &Utf8Path, + cancellation: CancellationToken, stderr: Option, ) -> ExecutorResult { - // Compile this tool's access grants into a runtime policy, baking - // approved external targets in. The policy travels to the tool in its - // context so the tool can self-enforce. A policy that fails to compile - // (invalid config) fails the tool rather than running it unenforced. - let access = match compile_tool_policy(self.config.access(), root, &self.approvals) { - Ok(access) => access, - Err(error) => { - return ExecutorResult::Completed(ToolCallResponse { + let mut state = self.state.lock().await; + let result = async { + if let (Some(id), Some(stderr)) = (state.id, stderr) { + locked(&self.sinks).insert(id, stderr); + } + if let Some(reply) = state.release.take() { + reply + .send(Ok(ReleaseDecision::Execute)) + .map_err(|_| "MCP release expired")?; + } + if let Some((id, reply)) = state.input.take() { + let answer = answers + .get(&id) + .ok_or("Missing answer to pending MCP inquiry")? + .clone(); + reply + .send(Ok(InputAnswer::Answer(answer))) + .map_err(|_| "MCP inquiry expired")?; + } + match state.next().await? { + Received::Interaction(Interaction::Input { + request, + supporting, + answers, + reply, + }) => { + let question = question(request, &supporting)?; + state.input = Some((question.id.to_string(), reply)); + Ok(ExecutorResult::NeedsInput { + tool_id: self.request.id.clone(), + tool_name: self.request.name.clone(), + source: InquirySource::tool(&self.request.name), + question, + accumulated_answers: answers, + }) + } + Received::Interaction(Interaction::Review { result, reply, .. }) => { + state.review = Some(reply); + Ok(ExecutorResult::Completed(ToolCallResponse { + id: self.request.id.clone(), + result, + })) + } + Received::Interaction(Interaction::Record { result, reply, .. }) => { + state.record = Some(reply); + Ok(ExecutorResult::Completed(ToolCallResponse { + id: self.request.id.clone(), + result, + })) + } + Received::Finished(result) => Ok(ExecutorResult::Completed(ToolCallResponse { id: self.request.id.clone(), - result: Err(format!( - "invalid access policy for tool '{}': {error}", - self.request.name - )), - }); + result, + })), + Received::Interaction(_) => Err("Unexpected MCP execution interaction".into()), } }; - - let result = execute( - &self.definition, - self.request.id.clone(), - Value::Object(self.request.arguments.clone()), - answers, - &self.config, - mcp_client, - root, - cancellation_token, - &self.builtin_executors, - access.as_ref(), - &self.invocation, - stderr, - ) - .await; - - match result { - Ok(ExecutionOutcome::Completed { id, result }) => { - ExecutorResult::Completed(ToolCallResponse { id, result }) + let result: Result = tokio::select! { + biased; + () = cancellation.cancelled() => Err("Tool execution cancelled.".into()), + result = result => result, + }; + if result.is_err() { + if let Some(id) = state.id { + self.service.cancel_call(id); } - Ok(ExecutionOutcome::Cancelled { id }) => ExecutorResult::Completed(ToolCallResponse { - id, - result: Ok("Tool execution cancelled.".to_string()), - }), - Ok(ExecutionOutcome::NeedsInput { id: _, question }) => ExecutorResult::NeedsInput { - tool_id: self.request.id.clone(), - tool_name: self.request.name.clone(), - question, - source: InquirySource::tool(self.request.name.as_str()), - accumulated_answers: answers.clone(), - }, - Err(e) => ExecutorResult::Completed(ToolCallResponse { - id: self.request.id.clone(), - result: Err(e.to_string()), - }), + state.finished = true; } + result.unwrap_or_else(|error| { + ExecutorResult::Completed(ToolCallResponse { + id: self.request.id.clone(), + result: Err(error), + }) + }) } } + +fn question(request: InputRequest, supporting: &[ContentBlock]) -> Result { + let answer_type = if request.secret { + AnswerType::Secret + } else if request.schema.get("type").and_then(Value::as_str) == Some("boolean") { + AnswerType::Boolean + } else if let Some(options) = request.schema.get("enum").and_then(Value::as_array) { + AnswerType::Select { + options: options + .iter() + .map(|v| { + v.as_str() + .map(str::to_owned) + .ok_or("Non-string inquiry choice".to_owned()) + }) + .collect::>()?, + } + } else if request.schema.get("type").and_then(Value::as_str) == Some("string") { + AnswerType::Text + } else { + return Err("Unsupported tool inquiry schema".into()); + }; + let preamble = supporting + .iter() + .filter_map(ContentBlock::as_text) + .collect::>() + .join("\n\n"); + let mut question = Question::text(request.id.to_string(), request.label) + .map_err(|error| error.to_string())? + .with_answer_type(answer_type); + question.pre_amble = (!preamble.is_empty()).then_some(preamble); + question.default = request.default; + Ok(question) +} + +#[cfg(test)] +#[path = "executor_tests.rs"] +mod tests; diff --git a/crates/jp_cli/src/cmd/query/tool/executor_tests.rs b/crates/jp_cli/src/cmd/query/tool/executor_tests.rs new file mode 100644 index 000000000..95cdd75a3 --- /dev/null +++ b/crates/jp_cli/src/cmd/query/tool/executor_tests.rs @@ -0,0 +1,272 @@ +use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, +}; + +use async_trait::async_trait; +use jp_config::{ + AppConfig, Config as _, + conversation::tool::{PartialToolConfig, ToolConfig}, +}; +use jp_mcp::server::BuiltinTool; +use jp_tool::{Outcome, ToolDocs}; +use serde_json::json; +use tokio::time::{Duration, advance, pause, resume, timeout}; + +use super::*; + +struct InquiringTool(Arc); +#[async_trait] +impl BuiltinTool for InquiringTool { + async fn execute(&self, arguments: &Value, answers: &IndexMap) -> Outcome { + self.0.fetch_add(1, Ordering::SeqCst); + if let Some(answer) = answers.get("confirm") { + return Outcome::Success { + content: json!({"arguments":arguments,"answer":answer}).to_string(), + }; + } + Question::boolean("confirm", "Continue?").unwrap().into() + } +} + +#[tokio::test] +async fn http_executor_keeps_one_call_through_input_and_recording() { + let mut cfg = AppConfig::new_test(); + let partial: PartialToolConfig = + serde_json::from_value(json!({"source":"builtin", "run":"ask", "result":"edit"})).unwrap(); + cfg.conversation.tools.insert( + "example".into(), + ToolConfig::from_partial(partial, vec![]).unwrap(), + ); + let count = Arc::new(AtomicUsize::new(0)); + let definitions = vec![ToolDefinition { + name: "example".into(), + docs: ToolDocs::default(), + parameters: json!({"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}), + }]; + let (source, owner) = TerminalExecutorSource::start( + BuiltinExecutors::new().register("example", InquiringTool(count.clone())), + &definitions, + &cfg.conversation.tools, + Arc::new(ApprovalStore::default()), + InvocationContext::default(), + &Client::default(), + "/tmp".into(), + ) + .await + .unwrap(); + let mut executor = source + .create( + ToolCallRequest { + id: "call-1".into(), + name: "example".into(), + arguments: json!({"name":"original"}).as_object().unwrap().clone(), + }, + cfg.conversation.tools.get("example").unwrap(), + ) + .unwrap(); + assert_eq!(executor.prepare(false).await.unwrap(), None); + assert_eq!(count.load(Ordering::SeqCst), 0); + executor.set_arguments(json!({"name":"edited"})); + executor.approve().await.unwrap(); + assert_eq!(count.load(Ordering::SeqCst), 0); + let first = executor + .execute( + &IndexMap::new(), + &Client::default(), + "/tmp".into(), + CancellationToken::new(), + None, + ) + .await; + let ExecutorResult::NeedsInput { question, .. } = first else { + panic!("expected question") + }; + assert_eq!(question, Question::boolean("confirm", "Continue?").unwrap()); + assert_eq!(count.load(Ordering::SeqCst), 1); + let second = executor + .execute( + &IndexMap::from_iter([("confirm".into(), json!(true))]), + &Client::default(), + "/tmp".into(), + CancellationToken::new(), + None, + ) + .await; + let ExecutorResult::Completed(response) = second else { + panic!("expected result review") + }; + assert_eq!( + response.result, + Ok(r#"{"arguments":{"name":"edited"},"answer":true}"#.into()) + ); + assert_eq!(count.load(Ordering::SeqCst), 2); + timeout( + Duration::from_secs(2), + source.acknowledge(ToolCallResponse { + id: "call-1".into(), + result: Ok("reviewed".into()), + }), + ) + .await + .unwrap() + .unwrap(); + owner.shutdown().await.unwrap(); +} + +async fn fixture( + result_mode: &str, +) -> ( + TerminalExecutorSource, + ExecutionOwner, + Box, + Arc, +) { + let mut cfg = AppConfig::new_test(); + let partial: PartialToolConfig = + serde_json::from_value(json!({"source":"builtin", "run":"ask", "result":result_mode})) + .unwrap(); + cfg.conversation.tools.insert( + "example".into(), + ToolConfig::from_partial(partial, vec![]).unwrap(), + ); + let count = Arc::new(AtomicUsize::new(0)); + let definitions = vec![ToolDefinition { + name: "example".into(), + docs: ToolDocs::default(), + parameters: json!({"type":"object","properties":{}}), + }]; + let (source, owner) = TerminalExecutorSource::start( + BuiltinExecutors::new().register("example", InquiringTool(count.clone())), + &definitions, + &cfg.conversation.tools, + Arc::new(ApprovalStore::default()), + InvocationContext::default(), + &Client::default(), + "/tmp".into(), + ) + .await + .unwrap(); + let executor = source + .create( + ToolCallRequest { + id: "call-1".into(), + name: "example".into(), + arguments: Map::new(), + }, + cfg.conversation.tools.get("example").unwrap(), + ) + .unwrap(); + (source, owner, executor, count) +} + +#[tokio::test] +async fn denied_http_call_never_reaches_execution() { + let (source, owner, mut executor, count) = fixture("unattended").await; + executor.prepare(false).await.unwrap(); + source + .acknowledge(ToolCallResponse { + id: "call-1".into(), + result: Ok("not approved".into()), + }) + .await + .unwrap(); + assert_eq!(count.load(Ordering::SeqCst), 0); + owner.shutdown().await.unwrap(); +} + +#[tokio::test] +async fn failure_after_approval_resolves_without_release_or_delivery_override() { + let (source, owner, mut executor, count) = fixture("skip").await; + executor.prepare(false).await.unwrap(); + executor.approve().await.unwrap(); + source + .acknowledge(ToolCallResponse { + id: "call-1".into(), + result: Err("formatter failed".into()), + }) + .await + .unwrap(); + assert_eq!(count.load(Ordering::SeqCst), 0); + owner.shutdown().await.unwrap(); +} + +#[tokio::test] +async fn declined_inquiry_finishes_without_another_attempt_or_delivery_override() { + let (source, owner, mut executor, count) = fixture("skip").await; + executor.prepare(false).await.unwrap(); + executor.approve().await.unwrap(); + let result = executor + .execute( + &IndexMap::new(), + &Client::default(), + "/tmp".into(), + CancellationToken::new(), + None, + ) + .await; + assert!(matches!(result, ExecutorResult::NeedsInput { .. })); + source + .acknowledge(ToolCallResponse { + id: "call-1".into(), + result: Ok("question declined".into()), + }) + .await + .unwrap(); + assert_eq!(count.load(Ordering::SeqCst), 1); + owner.shutdown().await.unwrap(); +} + +#[tokio::test] +async fn cancellation_before_release_does_not_execute() { + let (source, owner, mut executor, count) = fixture("unattended").await; + executor.prepare(false).await.unwrap(); + executor.approve().await.unwrap(); + let token = CancellationToken::new(); + token.cancel(); + let result = executor + .execute( + &IndexMap::new(), + &Client::default(), + "/tmp".into(), + token, + None, + ) + .await; + let ExecutorResult::Completed(response) = result else { + panic!("expected cancelled response") + }; + assert_eq!(response.result, Err("Tool execution cancelled.".into())); + source.acknowledge(response).await.unwrap(); + assert_eq!(count.load(Ordering::SeqCst), 0); + owner.shutdown().await.unwrap(); +} + +#[tokio::test] +async fn host_approval_can_wait_without_rpc_timeout() { + let (source, owner, mut executor, count) = fixture("unattended").await; + executor.prepare(false).await.unwrap(); + pause(); + advance(Duration::from_secs(121)).await; + resume(); + executor.approve().await.unwrap(); + let result = executor + .execute( + &IndexMap::new(), + &Client::default(), + "/tmp".into(), + CancellationToken::new(), + None, + ) + .await; + assert!(matches!(result, ExecutorResult::NeedsInput { .. })); + source + .acknowledge(ToolCallResponse { + id: "call-1".into(), + result: Ok("declined after waiting".into()), + }) + .await + .unwrap(); + assert_eq!(count.load(Ordering::SeqCst), 1); + owner.shutdown().await.unwrap(); +} diff --git a/crates/jp_cli/src/cmd/query/turn_loop.rs b/crates/jp_cli/src/cmd/query/turn_loop.rs index 632283339..3442c5937 100644 --- a/crates/jp_cli/src/cmd/query/turn_loop.rs +++ b/crates/jp_cli/src/cmd/query/turn_loop.rs @@ -893,7 +893,8 @@ pub(super) async fn run_turn_loop( &mut tool_coordinator, &mut turn_coordinator, &mut conv, - )?; + ) + .await?; return Err(cmd::Error::interrupted().into()); } @@ -910,7 +911,8 @@ pub(super) async fn run_turn_loop( &mut tool_coordinator, &mut turn_coordinator, &mut conv, - )?; + ) + .await?; break; } @@ -927,7 +929,9 @@ pub(super) async fn run_turn_loop( &mut tool_coordinator, &mut turn_coordinator, &mut conv, - )? { + ) + .await? + { tool_choice = ToolChoice::Auto; } } @@ -1154,7 +1158,7 @@ async fn build_inquiry_overrides( /// /// Returns `true` if a follow-up LLM cycle is needed (i.e. tool responses were /// added and the coordinator wants to continue). -fn commit_tool_responses( +async fn commit_tool_responses( result: ExecutionResult, pre_resolved: Vec<(usize, ToolCallResponse)>, tool: &mut ToolCoordinator, @@ -1173,8 +1177,12 @@ fn commit_tool_responses( indexed.sort_by_key(|(idx, _)| *idx); let responses: Vec<_> = indexed.into_iter().map(|(_, r)| r).collect(); + let recorded = responses.clone(); let action = conv.update_events(|stream| turn.handle_tool_responses(stream, responses)); conv.flush()?; + tool.acknowledge_responses(recorded) + .await + .map_err(Error::McpRecording)?; Ok(matches!(action, Action::SendFollowUp)) } diff --git a/crates/jp_cli/src/cmd/query/turn_loop_tests.rs b/crates/jp_cli/src/cmd/query/turn_loop_tests.rs index e40d3c5ba..58dc4db80 100644 --- a/crates/jp_cli/src/cmd/query/turn_loop_tests.rs +++ b/crates/jp_cli/src/cmd/query/turn_loop_tests.rs @@ -16,13 +16,14 @@ use futures::{StreamExt as _, stream}; use indexmap::IndexMap; use inquire::InquireError; use jp_config::{ - AppConfig, PartialAppConfig, + AppConfig, Config as _, PartialAppConfig, assistant::{ PartialAssistantConfig, request::{CachePolicy, MaxResponseBytes, PartialRequestConfig}, }, conversation::tool::{ - CommandConfigOrString, QuestionConfig, QuestionTarget, RunMode, ToolConfig, ToolSource, + CommandConfigOrString, PartialToolConfig, QuestionConfig, QuestionTarget, RunMode, + ToolConfig, ToolSource, style::{ DisplayStyleConfig, ErrorStyleConfig, InlineResults, LinkStyle, ParametersStyle, TruncateLines, @@ -54,10 +55,16 @@ use jp_llm::{ Executor, ExecutorResult, ExecutorSource, MockExecutor, PermissionInfo, TestExecutorSource, }, }; -use jp_mcp::server::{InvocationContext, builtin::BuiltinExecutors}; +use jp_mcp::{ + Client, + server::{ + InvocationContext, + builtin::{BuiltinExecutors, BuiltinTool}, + }, +}; use jp_printer::{OutputFormat, Printer, TerminalCapability}; use jp_storage::backend::FsStorageBackend; -use jp_tool::Question; +use jp_tool::{Outcome, Question, ToolDocs}; use jp_workspace::Workspace; use serde_json::{Map, Value, json}; use tokio::{sync::Notify, time::timeout}; @@ -65,6 +72,7 @@ use tokio_util::sync::CancellationToken; use super::*; use crate::{ + access::approvals::ApprovalStore, cmd::query::{ stream::retry::MAX_CONSECUTIVE_REBUILDS, tool::{ToolCoordinator, executor::TerminalExecutorSource}, @@ -73,12 +81,7 @@ use crate::{ }; fn empty_executor_source() -> Box { - Box::new(TerminalExecutorSource::new( - BuiltinExecutors::new(), - &[], - std::sync::Arc::new(crate::access::approvals::ApprovalStore::default()), - InvocationContext::default(), - )) + Box::new(TestExecutorSource::new()) } /// A mock provider that returns different responses on each call. @@ -8519,3 +8522,131 @@ async fn test_refused_rebuild_persists_streamed_content() { "streamed content must survive the abort.\nFile contents:\n{content}" ); } + +struct HttpInquiryTool(Arc); + +#[async_trait] +impl BuiltinTool for HttpInquiryTool { + async fn execute(&self, _: &Value, answers: &IndexMap) -> Outcome { + self.0.fetch_add(1, Ordering::SeqCst); + if answers.get("confirm") == Some(&json!(true)) { + return "confirmed".into(); + } + Question::boolean("confirm", "Continue?").unwrap().into() + } +} + +#[tokio::test] +#[expect( + clippy::too_many_lines, + reason = "Keep the end-to-end setup and persisted assertions in one scenario" +)] +async fn http_tool_cycle_persists_inquiry_and_response_before_followup() { + timeout(Duration::from_secs(10), async { + let tmp = tempdir().unwrap(); + let root = tmp.path(); + let mut config = AppConfig::new_test(); + let partial: PartialToolConfig = serde_json::from_value(json!({ + "source":"builtin", "run":"unattended", "style":{"hidden":true}, + "questions":{"confirm":{"answer":true}} + })) + .unwrap(); + config.conversation.tools.insert( + "http_tool".into(), + ToolConfig::from_partial(partial, vec![]).unwrap(), + ); + let storage = Arc::new(FsStorageBackend::new(&root.join(".jp")).unwrap()); + let mut workspace = Workspace::in_memory(root).with_backend(storage.clone()); + let lock = workspace + .create_and_lock_conversation(Conversation::default(), config.clone().into(), None) + .unwrap(); + let definitions = vec![ToolDefinition { + name: "http_tool".into(), + docs: ToolDocs::default(), + parameters: json!({"type":"object","properties":{}}), + }]; + let count = Arc::new(AtomicUsize::new(0)); + let client = Client::default(); + let (source, owner) = TerminalExecutorSource::start( + BuiltinExecutors::new().register("http_tool", HttpInquiryTool(count.clone())), + &definitions, + &config.conversation.tools, + Arc::new(ApprovalStore::default()), + InvocationContext::default(), + &client, + root.to_owned(), + ) + .await + .unwrap(); + let provider = Arc::new(SequentialMockProvider::with_tool_then_message( + "http-call", + "http_tool", + "Finished.", + )); + let model = provider + .model_details(&"test-model".parse().unwrap()) + .await + .unwrap(); + let router = detached_router(); + let (printer, output, chrome) = Printer::memory(OutputFormat::TextPretty); + let printer = Arc::new(printer); + run_turn_loop( + provider.clone(), + &model, + &config, + &router, + &client, + root, + false, + &[], + &lock, + ToolChoice::Auto, + &definitions, + printer.clone(), + Arc::new(MockPromptBackend::new()), + ToolCoordinator::new(config.conversation.tools.clone(), Box::new(source)), + ChatRequest::from("Run the tool."), + InvocationContext::default(), + PendingStreamTrim::default(), + router.turn_interrupt(lock.id()), + ) + .await + .unwrap(); + // Storage encodes tool content; use the production decoder before + // comparing domain events rather than deserializing individual records. + let stored = + serde_json::from_str(&storage.read_test_events_raw(&lock.id()).unwrap()).unwrap(); + let events = + ConversationStream::from_parts(json!({}), stored, &config.clone().into()).unwrap(); + let responses = events + .iter() + .filter_map(|event| event.event.as_tool_call_response()) + .cloned() + .collect::>(); + assert_eq!(responses, vec![ToolCallResponse { + id: "http-call".into(), + result: Ok("confirmed".into()) + }]); + let answers = events + .iter() + .filter_map(|event| event.event.as_inquiry_response()) + .filter_map(|answer| match answer { + InquiryResponse::Answered { answer, .. } => Some(answer.clone()), + _ => None, + }) + .collect::>(); + assert_eq!(answers, vec![json!(true)]); + assert_eq!(count.load(Ordering::SeqCst), 2); + assert_eq!(provider.call_index.load(Ordering::SeqCst), 2); + printer.flush(); + assert_eq!(output.lock().as_str(), "Finished.\n\n"); + assert_eq!( + chrome.lock().as_str(), + "\n── \x1b[1mjp\x1b[0m \x1b[2m(anthropic/test)\x1b[0m \ + ─────────────────────────────────────────────────────────\n\n" + ); + owner.shutdown().await.unwrap(); + }) + .await + .unwrap(); +} diff --git a/crates/jp_cli/src/cmd/query_tests.rs b/crates/jp_cli/src/cmd/query_tests.rs index e561d66be..33f78a899 100644 --- a/crates/jp_cli/src/cmd/query_tests.rs +++ b/crates/jp_cli/src/cmd/query_tests.rs @@ -21,11 +21,12 @@ use jp_conversation::{ event::{ChatRequest, ChatResponse}, }; use jp_inquire::prompt::MockPromptBackend; -use jp_llm::{Provider, provider::mock::MockProvider, tool::ExecutorSource}; -use jp_mcp::{ - Startup, StderrLine, - server::{InvocationContext, builtin::BuiltinExecutors}, +use jp_llm::{ + Provider, + provider::mock::MockProvider, + tool::{ExecutorSource, TestExecutorSource}, }; +use jp_mcp::{Startup, StderrLine, server::InvocationContext}; use jp_printer::{OutputFormat, Printer, SharedBuffer, TerminalCapability}; use jp_storage::{ backend::{ConversationFilter, FsStorageBackend, LoadBackend}, @@ -257,12 +258,7 @@ fn config_with_model(provider: ProviderId, name: &str) -> AppConfig { } fn empty_executor_source() -> Box { - Box::new(tool::executor::TerminalExecutorSource::new( - BuiltinExecutors::new(), - &[], - std::sync::Arc::new(crate::access::approvals::ApprovalStore::default()), - InvocationContext::default(), - )) + Box::new(TestExecutorSource::new()) } fn build_query_config( diff --git a/crates/jp_cli/src/error.rs b/crates/jp_cli/src/error.rs index dd15ed40b..591900684 100644 --- a/crates/jp_cli/src/error.rs +++ b/crates/jp_cli/src/error.rs @@ -2,6 +2,7 @@ use std::io; use camino::Utf8PathBuf; use jp_conversation::ConversationId; +use jp_mcp::server::http::EndpointError; use url::Url; use crate::cmd; @@ -65,6 +66,12 @@ pub(crate) enum Error { #[error("MCP error")] Mcp(#[from] jp_mcp::Error), + #[error(transparent)] + McpEndpoint(#[from] EndpointError), + + #[error("MCP Host recording failed: {0}")] + McpRecording(String), + #[error("LLM error")] Llm(#[from] jp_llm::Error), diff --git a/crates/jp_cli/src/render/tool.rs b/crates/jp_cli/src/render/tool.rs index 9a19e56ad..7e15a61d6 100644 --- a/crates/jp_cli/src/render/tool.rs +++ b/crates/jp_cli/src/render/tool.rs @@ -319,7 +319,18 @@ impl ToolRenderer { arguments: &Map, cmd: CommandConfig, ) -> RenderOutcome { - match format_args_custom(invoked_name, arguments, cmd, &self.root, &self.invocation).await { + let result = + format_args_custom(invoked_name, arguments, cmd, &self.root, &self.invocation).await; + self.render_custom_result(name, result) + } + + /// Render custom arguments already formatted by the execution service. + pub(crate) fn render_custom_result( + &self, + name: &str, + result: Result, + ) -> RenderOutcome { + match result { Ok(content) if !content.is_empty() => { let styled_name = name.yellow().bold(); self.write_chrome(self.current_region.as_ref(), |w| { diff --git a/crates/jp_llm/src/tool.rs b/crates/jp_llm/src/tool.rs index 6d6fe8028..61f0872e4 100644 --- a/crates/jp_llm/src/tool.rs +++ b/crates/jp_llm/src/tool.rs @@ -1,8 +1,9 @@ //! The seam a turn loop runs one tool call through. //! -//! [`Executor`] is one execution attempt: it runs the tool and reports what -//! came back, without deciding whether the call may run or who answers a -//! question it asks. +//! [`Executor`] is the Host-facing view of a tool call. +//! Preparation and approval precede execution release. +//! An input request returns control to the Host; supplying an answer advances +//! the same logical call. //! [`ExecutorSource`] builds one per tool call, so a test can supply //! [`MockExecutor`] where production supplies a real one. //! @@ -12,6 +13,7 @@ use std::sync::Mutex; use async_trait::async_trait; use camino::Utf8Path; +use futures::future::BoxFuture; use indexmap::IndexMap; use jp_config::conversation::tool::{RunMode, ToolConfigWithDefaults, ToolSource}; use jp_conversation::event::{InquirySource, ToolCallRequest, ToolCallResponse}; @@ -34,6 +36,29 @@ use tokio_util::sync::CancellationToken; /// configuration. #[async_trait] pub trait Executor: Send + Sync { + /// Prepare an invocation, or return a response resolved without execution. + async fn prepare( + &mut self, + _render_arguments: bool, + ) -> Result, String> { + Ok(None) + } + + /// Apply Host approval and wait until the invocation is ready for release. + async fn approve(&mut self) -> Result<(), String> { + Ok(()) + } + + /// Custom argument rendering provided by the execution service. + fn formatted_arguments(&self) -> Option<&Result> { + None + } + + /// Whether custom formatting is owned by the execution service. + fn formats_arguments(&self) -> bool { + false + } + /// Returns the tool call ID. fn tool_id(&self) -> &str; @@ -63,12 +88,13 @@ pub trait Executor: Send + Sync { /// request. fn set_arguments(&mut self, args: Value); - /// Executes the tool once with the given answers. + /// Advance the call to its next input request or result. /// - /// This method performs a single execution pass. - /// If the tool needs additional input, it returns - /// `ExecutorResult::NeedsInput` and the coordinator handles prompting and - /// retrying. + /// An MCP-backed executor releases prepared work or answers the pending + /// inquiry on its existing MCP call. + /// The server re-executes a tool that returned `NeedsInput`; the executor + /// does not submit another MCP call. + /// The result remains subject to Host review and recording. /// /// The executor doesn't know how questions should be answered - it just /// reports that input is needed. @@ -100,6 +126,11 @@ pub trait Executor: Send + Sync { /// This trait enables dependency injection of executor creation, allowing tests /// to use mock executors without executing real shell commands. pub trait ExecutorSource: Send + Sync { + /// Release a final delivery barrier after the response has been recorded. + fn acknowledge(&self, _response: ToolCallResponse) -> BoxFuture<'_, Result<(), String>> { + Box::pin(async { Ok(()) }) + } + /// Creates an executor for the given tool call request. /// /// Returns `None` if the tool cannot be resolved (e.g. missing from the diff --git a/crates/jp_mcp/Cargo.toml b/crates/jp_mcp/Cargo.toml index bb83a1953..231f7bc12 100644 --- a/crates/jp_mcp/Cargo.toml +++ b/crates/jp_mcp/Cargo.toml @@ -20,14 +20,30 @@ client = ["rmcp/client", "rmcp/transport-child-process", "rmcp/transport-io"] # Run JP's tools: local commands, built-ins, and the tools those MCP servers # declare. Implies `client`, because running an MCP tool means calling one. -server = ["client", "dep:async-trait", "dep:camino", "dep:jp_tool", "dep:minijinja", "dep:tokio-util"] +server = [ + "client", + "dep:async-trait", + "dep:axum", + "dep:camino", + "dep:futures", + "dep:jp_tool", + "dep:minijinja", + "dep:reqwest", + "dep:sse-stream", + "dep:tokio-util", + "rmcp/server", + "rmcp/transport-streamable-http-server", + "rmcp/transport-streamable-http-client", +] [dependencies] jp_config = { workspace = true } jp_tool = { workspace = true, optional = true } async-trait = { workspace = true, optional = true } +axum = { workspace = true, optional = true, features = ["http1", "tokio"] } camino = { workspace = true, optional = true } +futures = { workspace = true, optional = true } indexmap = { workspace = true } minijinja = { workspace = true, optional = true, features = [ "builtins", @@ -36,11 +52,13 @@ minijinja = { workspace = true, optional = true, features = [ "serde", "unicode", ] } +reqwest = { workspace = true, optional = true, features = ["stream"] } rmcp = { workspace = true } serde = { workspace = true } serde_json = { workspace = true, features = ["preserve_order"] } sha1 = { workspace = true } sha2 = { workspace = true } +sse-stream = { workspace = true, optional = true } thiserror = { workspace = true } tokio = { workspace = true } tokio-util = { workspace = true, optional = true } diff --git a/crates/jp_mcp/README.md b/crates/jp_mcp/README.md index 09e1acb66..430ccf615 100644 --- a/crates/jp_mcp/README.md +++ b/crates/jp_mcp/README.md @@ -19,6 +19,26 @@ admission, cancels calls, waits for cleanup, and closes owned upstream connections. Stderr progress uses a separate bounded channel. -This library service does not start an HTTP listener. -MCP transport integration and CLI adoption use this service in RFD 109's next -phase. +`server::http::Endpoint` exposes the service through MCP Streamable HTTP on an +OS-assigned loopback port. +It validates Host and supplied Origin headers. +Its `connect` method creates an ordinary MCP client connection through that HTTP +endpoint; the private Host channel remains separate. + +Upstream stdio calls carry trusted execution context and accumulated answers +under `_meta["computer.jp/tool"]` and `_meta["computer.jp/context"]`. +Single text results are recognized as legacy `Outcome` envelopes when their +shape matches. +Mixed native content and result metadata are retained for forwarding. + +The ordinary CLI query runner submits calls through the HTTP endpoint. +Its executor adapter holds pending Host replies across preparation, release, +input, and result review. +After the conversation owner flushes the recorded response, the adapter +acknowledges final delivery and consumes the MCP response. + +The Host connection disables environment proxies, redirects, and transparent +session reinitialization. +It does not resubmit a tool call on transport failure. +The HTTP endpoint has no authentication; its loopback binding and header checks +are not a claim that the caller is a particular local application. diff --git a/crates/jp_mcp/src/client.rs b/crates/jp_mcp/src/client.rs index 6de334a2e..a545054ef 100644 --- a/crates/jp_mcp/src/client.rs +++ b/crates/jp_mcp/src/client.rs @@ -11,7 +11,7 @@ use indexmap::IndexMap; use jp_config::providers::mcp::{AlgorithmConfig, McpProviderConfig}; use rmcp::{ model::{ - CallToolRequestParams, CallToolResult, ReadResourceRequestParams, Resource, + CallToolRequestParams, CallToolResult, Meta, ReadResourceRequestParams, Resource, ResourceContents, Tool, }, service::{RoleClient, RunningService, ServiceExt}, @@ -212,6 +212,7 @@ impl Client { tool_name: &str, server_name: &str, params: &serde_json::Value, + meta: Option>, ) -> Result { let server_id = McpServerId::new(server_name); let services = self.services.read().await; @@ -221,6 +222,7 @@ impl Client { let mut call_params = CallToolRequestParams::new(tool_name.to_owned()); call_params.arguments = params.as_object().cloned(); + call_params.meta = meta.filter(|meta| !meta.is_empty()).map(Meta); client .peer() @@ -669,6 +671,10 @@ fn spawn_stderr_forwarder( #[path = "client_tests.rs"] mod tests; +#[cfg(all(test, feature = "server"))] +#[path = "client_protocol_tests.rs"] +mod protocol_tests; + pub fn verify_file_checksum( server: &str, command: &Path, diff --git a/crates/jp_mcp/src/client_protocol_tests.rs b/crates/jp_mcp/src/client_protocol_tests.rs new file mode 100644 index 000000000..d045c42da --- /dev/null +++ b/crates/jp_mcp/src/client_protocol_tests.rs @@ -0,0 +1,151 @@ +use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, +}; + +use indexmap::IndexMap; +use jp_config::{ + AppConfig, Config as _, + conversation::tool::{PartialToolConfig, ToolConfig}, +}; +use jp_tool::{Outcome, Question, ToolDefinition, ToolDocs}; +use rmcp::{ + ErrorData, ServerHandler, + model::{CallToolRequestParams, CallToolResult, ServerCapabilities, ServerInfo}, + service::{RequestContext, RoleServer, ServiceExt as _}, +}; +use serde_json::{Value, json}; +use tokio::io::duplex; +use tokio_util::sync::CancellationToken; + +use super::{Client, McpServerId}; +use crate::{ + Content, + server::{ + ExecutionOutcome, InvocationContext, builtin::BuiltinExecutors, execute, text_result, + }, +}; + +struct Upstream(Arc); + +impl ServerHandler for Upstream { + fn get_info(&self) -> ServerInfo { + let mut info = ServerInfo::default(); + info.capabilities = ServerCapabilities::builder().enable_tools().build(); + info + } + + async fn call_tool( + &self, + request: CallToolRequestParams, + context: RequestContext, + ) -> Result { + self.0.fetch_add(1, Ordering::SeqCst); + assert_eq!(request.name, "actual_tool"); + assert_eq!( + context.meta.0["computer.jp/tool"]["arguments"], + Value::Object(request.arguments.unwrap()) + ); + let outcome = if context.meta.0["computer.jp/tool"]["answers"] + .get("confirm") + .is_some() + { + Outcome::Success { + content: serde_json::to_string(&context.meta.0).unwrap(), + } + } else { + Question::boolean("confirm", "Continue?").unwrap().into() + }; + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string(&outcome).unwrap(), + )])) + } +} + +#[tokio::test] +async fn upstream_receives_context_options_and_accumulated_answers() { + let count = Arc::new(AtomicUsize::new(0)); + let (client_transport, server_transport) = duplex(8192); + let handler = Upstream(count.clone()); + let server = tokio::spawn(async move { handler.serve(server_transport).await.unwrap() }); + let running = ().serve(client_transport).await.unwrap(); + let server = server.await.unwrap(); + let client = Client::default(); + client + .services + .write() + .await + .insert(McpServerId::new("upstream"), running); + let partial: PartialToolConfig = + serde_json::from_value(json!({"source":"mcp.upstream.actual_tool", "options":{"limit":7}})) + .unwrap(); + let mut cfg = AppConfig::new_test(); + cfg.conversation.tools.insert( + "alias".into(), + ToolConfig::from_partial(partial, vec![]).unwrap(), + ); + let config = cfg.conversation.tools.get("alias").unwrap(); + let definition = ToolDefinition { + name: "alias".into(), + docs: ToolDocs::default(), + parameters: json!({"type":"object","properties":{"value":{"type":"string"}}}), + }; + let invocation = InvocationContext { + workspace_id: "workspace-1".into(), + conversation_id: "conversation-1".into(), + }; + let first = execute( + &definition, + "call-1".into(), + json!({"value":"edited"}), + &IndexMap::new(), + &config, + &client, + "/work".into(), + CancellationToken::new(), + &BuiltinExecutors::new(), + None, + &invocation, + None, + ) + .await + .unwrap(); + let ExecutionOutcome::NeedsInput { question, .. } = first else { + panic!("expected decoded question") + }; + assert_eq!(question, Question::boolean("confirm", "Continue?").unwrap()); + let answers = IndexMap::from_iter([("confirm".into(), json!(true))]); + let second = execute( + &definition, + "call-1".into(), + json!({"value":"edited"}), + &answers, + &config, + &client, + "/work".into(), + CancellationToken::new(), + &BuiltinExecutors::new(), + None, + &invocation, + None, + ) + .await + .unwrap(); + let ExecutionOutcome::Completed { result, native, .. } = second else { + panic!("expected final result") + }; + let native = native.expect("unwrapped text retains native metadata"); + assert_eq!(native.is_error, Some(false)); + assert_eq!(text_result(&native), result); + assert_eq!( + serde_json::from_str::(&result.unwrap()).unwrap(), + json!({ + "computer.jp/tool":{"name":"actual_tool", "arguments":{"value":"edited"}, "answers":{"confirm":true}, "options":{"limit":7}}, + "computer.jp/context":{"action":"run", "root":"/work", "access":null, "workspace_id":"workspace-1", "conversation_id":"conversation-1"}, + "progressToken":1 + }) + ); + assert_eq!(count.load(Ordering::SeqCst), 2); + client.shutdown().await; + server.cancel().await.unwrap(); +} diff --git a/crates/jp_mcp/src/server.rs b/crates/jp_mcp/src/server.rs index 2f0061ea3..a457e01b5 100644 --- a/crates/jp_mcp/src/server.rs +++ b/crates/jp_mcp/src/server.rs @@ -1,19 +1,20 @@ -//! Running the tools JP makes available. +//! JP tool execution and MCP serving. //! -//! Resolves a tool from configuration into a [`ToolDefinition`], then runs it: -//! a local command, a built-in Rust implementation, or a call to one of the -//! configured MCP servers, dispatched through the [`Client`] this crate already -//! owns. +//! [`service::Service`] coordinates tool execution with the MCP Host through +//! private interaction channels. +//! [`http::Endpoint`] exposes that service over loopback Streamable HTTP. //! -//! Each call to [`execute`] runs one execution attempt. -//! The caller handles approvals, input requests, result editing, and -//! conversation recording. +//! [`tool_definitions`] resolves the configured catalog. +//! [`execute`] runs one attempt of a local command, built-in implementation, or +//! upstream stdio MCP tool; the service handles input-driven re-execution and +//! delivery barriers. pub mod builtin; +pub mod http; pub mod json_schema; pub mod service; - -use std::{ffi::OsStr, fmt, process::Stdio, sync::Arc}; +mod upstream; +use std::{convert::identity, ffi::OsStr, fmt, process::Stdio, sync::Arc}; pub use builtin::BuiltinTool; use camino::Utf8Path; @@ -23,24 +24,46 @@ use jp_config::{ types::command::shell_command_line, }; use jp_tool::{ - Action, Error as ToolError, Outcome, ParameterDocs, Question, ToolDefinition, ToolDocs, + AccessPolicy, Action, Error as ToolError, Outcome, ParameterDocs, Question, ToolDefinition, + ToolDocs, definition::{apply_parameter_defaults, split_description, validate_tool_arguments}, schema::{Node, merge_description}, }; use minijinja::{Environment, ErrorKind as MinijinjaErrorKind, value::ValueKind}; -use serde_json::{Value, json}; +use serde_json::{Map, Value, json}; use tokio::{ io::{AsyncBufReadExt, AsyncReadExt, BufReader}, process::Command, }; use tokio_util::sync::CancellationToken; use tracing::{error, info, trace, warn}; +pub use upstream::text_result; +use upstream::{UpstreamResult, decode_result, replace_envelope}; use crate::{ - Client, RawContent, ResourceContents, + CallToolResult, Client, id::{McpServerId, McpToolId}, }; +/// Build trusted execution context for local templates and upstream MCP +/// metadata. +pub(crate) fn tool_context( + name: &str, + arguments: &Value, + answers: &IndexMap, + config: &ToolConfigWithDefaults, + root: &Utf8Path, + action: &Action, + access: Option<&AccessPolicy>, + invocation: &InvocationContext, +) -> Value { + json!({ + "tool": { "name":name, "arguments":arguments, "answers":answers, "options":config.options() }, + "context": { "action":action, "root":root.as_str(), "access":access, + "workspace_id":invocation.workspace_id, "conversation_id":invocation.conversation_id } + }) +} + /// Read a tool's documentation out of its configuration. fn tool_docs_from_config(config: &ToolConfigWithDefaults) -> ToolDocs { let parameters = config @@ -120,6 +143,8 @@ pub enum ExecutionOutcome { /// /// If an error occurred, it means the tool ran, but reported an error. result: Result, + /// Full upstream MCP result before the Host's compatibility projection. + native: Option, }, /// Tool needs additional input before it can complete. @@ -743,6 +768,11 @@ pub async fn execute( mcp_client, server, tool.as_deref(), + answers, + config, + root, + access, + invocation, cancellation_token, ) .await @@ -788,6 +818,7 @@ async fn execute_local( if let Err(error) = validate_tool_arguments(args, &definition.parameters) { return Ok(ExecutionOutcome::Completed { + native: None, id, result: Err(format!( "Invalid arguments: {error}\n\nYou can call `describe_tools(tools: \ @@ -797,21 +828,16 @@ async fn execute_local( } } - let ctx = json!({ - "tool": { - "name": name, - "arguments": &arguments, - "answers": answers, - "options": config.options(), - }, - "context": { - "action": Action::Run, - "root": root.as_str(), - "access": access, - "workspace_id": &invocation.workspace_id, - "conversation_id": &invocation.conversation_id, - }, - }); + let ctx = tool_context( + name, + &arguments, + answers, + config, + root, + &Action::Run, + access, + invocation, + ); let Some(command) = config.command() else { return Err(ToolError::MissingCommand); @@ -825,12 +851,14 @@ async fn execute_local( match run_tool_command(command, ctx, root, cancellation_token, Some(trace_as)).await? { CommandResult::Success(content) => Ok(ExecutionOutcome::Completed { + native: None, id, result: Ok(content), }), CommandResult::NeedsInput(question) => Ok(ExecutionOutcome::NeedsInput { id, question }), CommandResult::Cancelled => Ok(ExecutionOutcome::Cancelled { id }), other => Ok(ExecutionOutcome::Completed { + native: None, id, result: other.into_tool_result(name), }), @@ -841,6 +869,7 @@ async fn execute_local( /// /// Runs one upstream MCP call. /// It calls the MCP server and converts the result to an `ExecutionOutcome`. +#[expect(clippy::too_many_arguments)] async fn execute_mcp( definition: &ToolDefinition, id: String, @@ -848,11 +877,30 @@ async fn execute_mcp( mcp_client: &Client, server: &str, tool: Option<&str>, + answers: &IndexMap, + config: &ToolConfigWithDefaults, + root: &Utf8Path, + access: Option<&AccessPolicy>, + invocation: &InvocationContext, cancellation_token: CancellationToken, ) -> Result { let name = tool.unwrap_or(&definition.name); - let call_future = mcp_client.call_tool(name, server, &arguments); + let context = tool_context( + name, + &arguments, + answers, + config, + root, + &Action::Run, + access, + invocation, + ); + let meta = Map::from_iter([ + ("computer.jp/tool".into(), context["tool"].clone()), + ("computer.jp/context".into(), context["context"].clone()), + ]); + let call_future = mcp_client.call_tool(name, server, &arguments, Some(meta)); tokio::select! { biased; @@ -864,27 +912,24 @@ async fn execute_mcp( let result = result .map_err(|error| ToolError::McpRunToolError(Box::new(error)))?; - let content = result - .content - .into_iter() - .filter_map(|v| match v.raw { - RawContent::Text(v) => Some(v.text), - RawContent::Resource(v) => match v.resource { - ResourceContents::TextResourceContents { text, .. } => Some(text), - ResourceContents::BlobResourceContents { blob, .. } => Some(blob), - }, - RawContent::Image(_) | RawContent::Audio(_) | RawContent::ResourceLink(_) => None, - }) - .collect::>() - .join("\n\n"); - - let result = if result.is_error.unwrap_or_default() { - Err(content) - } else { - Ok(content) + let result = match decode_result(result).map_err(ToolError::MalformedOutput)? { + UpstreamResult::Outcome { outcome, response } => return Ok(match outcome { + Outcome::Success {content} => ExecutionOutcome::Completed {id, native:Some(replace_envelope(response, &content, false)), result:Ok(content)}, + Outcome::NeedsInput {question} => ExecutionOutcome::NeedsInput {id, question}, + Outcome::Error {message, trace, transient} => { + let text = if transient { + json!({"message":message, "trace":trace}).to_string() + } else { + text_result(&response).unwrap_or_else(identity) + }; + let native = Some(replace_envelope(response, &text, true)); + ExecutionOutcome::Completed {id, result:Err(text), native} + } + }), + UpstreamResult::Native(result) => result, }; - - Ok(ExecutionOutcome::Completed { id, result }) + let text = text_result(&result); + Ok(ExecutionOutcome::Completed { id, result: text, native: Some(result) }) } } } @@ -913,6 +958,7 @@ async fn execute_builtin( Ok(match outcome { Outcome::Success { content } => ExecutionOutcome::Completed { + native: None, id, result: Ok(content), }, @@ -927,6 +973,7 @@ async fn execute_builtin( format!("{message}\n\nTrace:\n{}", trace.join("\n")) }; ExecutionOutcome::Completed { + native: None, id, result: Err(error_msg), } diff --git a/crates/jp_mcp/src/server/http.rs b/crates/jp_mcp/src/server/http.rs new file mode 100644 index 000000000..d12e3101d --- /dev/null +++ b/crates/jp_mcp/src/server/http.rs @@ -0,0 +1,254 @@ +//! Loopback Streamable HTTP transport for the JP MCP Server. +//! +//! [`Endpoint`] owns its listener and execution service. +//! The private Host receiver returned when constructing the service remains +//! with the MCP Host. + +mod client; + +use std::{ + error::Error as StdError, + io, + net::{Ipv4Addr, SocketAddr}, + sync::Arc, +}; + +use axum::Router; +use client::LoopbackClient; +use jp_tool::Error as ToolError; +use reqwest::{Client as HttpClient, redirect::Policy}; +use rmcp::{ + ErrorData, ServerHandler, ServiceExt as _, + model::{ + CallToolRequestParams, CallToolResult, ListToolsResult, PaginatedRequestParams, + ServerCapabilities, ServerInfo, Tool, + }, + service::{RequestContext, RoleClient, RoleServer, RunningService}, + transport::{ + StreamableHttpClientTransport, + streamable_http_client::StreamableHttpClientTransportConfig, + streamable_http_server::{ + session::local::LocalSessionManager, + tower::{StreamableHttpServerConfig, StreamableHttpService}, + }, + }, +}; +use tokio::{ + net::TcpListener, + task::{JoinError, JoinHandle}, +}; +use tokio_util::sync::CancellationToken; + +use super::service::{CallRequest, Service, ServiceError}; + +/// Failure starting, connecting to, or stopping the in-process endpoint. +#[derive(Debug, thiserror::Error)] +pub enum EndpointError { + /// Listener or HTTP server I/O failed. + #[error(transparent)] + Io(#[from] io::Error), + /// The HTTP task failed. + #[error(transparent)] + Task(#[from] JoinError), + /// Execution service shutdown failed. + #[error(transparent)] + Service(#[from] ServiceError), + /// The MCP handshake failed. + #[error("Could not connect to JP MCP Server: {0}")] + Connect(Box), +} + +/// Owns a loopback listener with an OS-assigned port. +pub struct Endpoint { + url: String, + service: Arc, + cancellation: CancellationToken, + task: Option>>, +} + +impl Endpoint { + /// Start the endpoint. + /// Does not consume or drive the private Host receiver. + pub async fn start(service: Service) -> Result { + let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))).await?; + let address = listener.local_addr()?; + let origin = format!("http://{address}"); + let url = format!("{origin}/mcp"); + let service = Arc::new(service); + let factory = service.clone(); + let cancellation = CancellationToken::new(); + let mut config = StreamableHttpServerConfig::default(); + config.allowed_hosts = vec![address.to_string()]; + config.allowed_origins = vec![origin]; + config.cancellation_token = cancellation.clone(); + let transport = StreamableHttpService::new( + move || { + Ok(Handler { + service: factory.clone(), + }) + }, + Arc::new(LocalSessionManager::default()), + config, + ); + let router = Router::new().nest_service("/mcp", transport); + let shutdown = cancellation.clone(); + let task = tokio::spawn(async move { + axum::serve(listener, router) + .with_graceful_shutdown(shutdown.cancelled_owned()) + .await + }); + Ok(Self { + url, + service, + cancellation, + task: Some(task), + }) + } + + /// URL provided to MCP callers; JP's terminal stdio is not used. + #[must_use] + pub fn url(&self) -> &str { + &self.url + } + + /// Establish the MCP Host's ordinary HTTP connection to this endpoint. + pub async fn connect(&self) -> Result, EndpointError> { + let client = HttpClient::builder() + .no_proxy() + .redirect(Policy::none()) + .build() + .map_err(|error| EndpointError::Connect(Box::new(error)))?; + let config = StreamableHttpClientTransportConfig::with_uri(self.url.clone()) + .reinit_on_expired_session(false); + ().serve(StreamableHttpClientTransport::with_client( + LoopbackClient(client), + config, + )) + .await + .map_err(|error| EndpointError::Connect(Box::new(error))) + } + + /// Private in-process control for the MCP Host, not exposed through HTTP. + #[must_use] + pub fn service(&self) -> Arc { + self.service.clone() + } + + /// Signal cancellation of current calls without closing the endpoint. + pub fn cancel_current(&self) { + self.service.cancel_current(); + } + + /// Stop tool work, close upstream services, and join the HTTP listener. + pub async fn shutdown(mut self) -> Result<(), EndpointError> { + self.service.shutdown().await?; + self.cancellation.cancel(); + if let Some(task) = self.task.take() { + task.await??; + } + Ok(()) + } +} + +impl Drop for Endpoint { + fn drop(&mut self) { + self.service.stop(); + self.cancellation.cancel(); + } +} + +#[derive(Clone)] +struct Handler { + service: Arc, +} + +impl ServerHandler for Handler { + fn get_info(&self) -> ServerInfo { + let mut info = ServerInfo::default(); + info.server_info.name = "jp".into(); + info.server_info.version = env!("CARGO_PKG_VERSION").into(); + info.capabilities = ServerCapabilities::builder().enable_tools().build(); + info + } + + async fn list_tools( + &self, + _: Option, + _: RequestContext, + ) -> Result { + let tools = self + .service + .definitions() + .map(|definition| { + Tool::new( + definition.name.clone(), + definition + .docs + .schema_description() + .unwrap_or_default() + .to_owned(), + Arc::new( + definition + .parameters + .as_object() + .cloned() + .unwrap_or_default(), + ), + ) + }) + .collect(); + Ok(ListToolsResult { + tools, + ..Default::default() + }) + } + + async fn call_tool( + &self, + request: CallToolRequestParams, + context: RequestContext, + ) -> Result { + if request.task.is_some() { + return Err(ErrorData::invalid_params( + "JP tool calls do not support MCP tasks", + None, + )); + } + let call = self + .service + .start_call(CallRequest { + name: request.name.into_owned(), + arguments: request.arguments.unwrap_or_default(), + correlation: context.meta.0, + }) + .map_err(protocol_error)?; + let cancellation = call.cancellation_token(); + // Explicit MCP cancellation or handler destruction must not orphan the + // separately owned invocation. A dropped HTTP response stream alone + // does not destroy a stateful session's request handler. + let _guard = cancellation.clone().drop_guard(); + let result = call.finish_mcp(); + tokio::pin!(result); + tokio::select! { + biased; + () = context.ct.cancelled() => { cancellation.cancel(); result.await.map_err(protocol_error) }, + result = &mut result => result.map_err(protocol_error), + } + } +} + +fn protocol_error(error: ServiceError) -> ErrorData { + match error { + ServiceError::Tool(ToolError::NotFound { name }) => { + ErrorData::invalid_params(format!("Unknown tool: {name}"), None) + } + ServiceError::InvalidArgument { path } => { + ErrorData::invalid_params(format!("Invalid tool argument at `{path}`"), None) + } + other => ErrorData::internal_error(other.to_string(), None), + } +} + +#[cfg(test)] +#[path = "http_tests.rs"] +mod tests; diff --git a/crates/jp_mcp/src/server/http/client.rs b/crates/jp_mcp/src/server/http/client.rs new file mode 100644 index 000000000..2dfb1a8b0 --- /dev/null +++ b/crates/jp_mcp/src/server/http/client.rs @@ -0,0 +1,236 @@ +//! The MCP Host's HTTP client for the loopback endpoint. +//! +//! rmcp ships a Streamable HTTP client for `reqwest` 0.13, while the rest of JP +//! uses 0.12. +//! This implements rmcp's [`StreamableHttpClient`] over the `reqwest` JP +//! already depends on, so the Host's connection to its own endpoint does not +//! pull in a second HTTP stack. +//! +//! It talks to one server, the endpoint in the same process, which sends no +//! `WWW-Authenticate` challenges: a `401` or `403` is reported as an unexpected +//! response rather than as an authorization flow. + +use std::{borrow::Cow, collections::HashMap, sync::Arc}; + +use futures::{StreamExt as _, stream::BoxStream}; +use reqwest::{ + Client, RequestBuilder, StatusCode, + header::{ACCEPT, CONTENT_TYPE, HeaderName, HeaderValue}, +}; +use rmcp::{ + model::{ClientJsonRpcMessage, JsonRpcMessage, ServerJsonRpcMessage}, + transport::{ + common::http_header::{ + EVENT_STREAM_MIME_TYPE, HEADER_LAST_EVENT_ID, HEADER_SESSION_ID, JSON_MIME_TYPE, + }, + streamable_http_client::{ + SseError, StreamableHttpClient, StreamableHttpError, StreamableHttpPostResponse, + }, + }, +}; +use sse_stream::{Sse, SseStream}; +use tracing::warn; + +type Error = StreamableHttpError; + +/// A `reqwest` client speaking MCP Streamable HTTP. +#[derive(Debug, Clone)] +pub(super) struct LoopbackClient(pub(super) Client); + +/// Add the headers rmcp's worker supplies to every request. +fn with_headers( + builder: RequestBuilder, + auth_token: Option, + custom_headers: HashMap, +) -> RequestBuilder { + // rmcp's worker uses custom headers only to carry the negotiated + // `MCP-Protocol-Version`; the Host configures none of its own. + let builder = custom_headers + .into_iter() + .fold(builder, |builder, (name, value)| { + builder.header(name, value) + }); + + match auth_token { + Some(token) => builder.bearer_auth(token), + None => builder, + } +} + +impl StreamableHttpClient for LoopbackClient { + type Error = reqwest::Error; + + async fn get_stream( + &self, + uri: Arc, + session_id: Arc, + last_event_id: Option, + auth_token: Option, + custom_headers: HashMap, + ) -> Result>, Error> { + let mut builder = self + .0 + .get(uri.as_ref()) + .header(ACCEPT, accept()) + .header(HEADER_SESSION_ID, session_id.as_ref()); + if let Some(last_event_id) = last_event_id { + builder = builder.header(HEADER_LAST_EVENT_ID, last_event_id); + } + + let response = with_headers(builder, auth_token, custom_headers) + .send() + .await + .map_err(Error::Client)?; + + if response.status() == StatusCode::METHOD_NOT_ALLOWED { + return Err(Error::ServerDoesNotSupportSse); + } + let response = response.error_for_status().map_err(Error::Client)?; + + match content_type(&response) { + Some(ct) if is(&ct, EVENT_STREAM_MIME_TYPE) || is(&ct, JSON_MIME_TYPE) => {} + other => return Err(Error::UnexpectedContentType(other)), + } + + Ok(SseStream::from_bytes_stream(response.bytes_stream()).boxed()) + } + + async fn delete_session( + &self, + uri: Arc, + session_id: Arc, + auth_token: Option, + custom_headers: HashMap, + ) -> Result<(), Error> { + let builder = self + .0 + .delete(uri.as_ref()) + .header(HEADER_SESSION_ID, session_id.as_ref()); + + let response = with_headers(builder, auth_token, custom_headers) + .send() + .await + .map_err(Error::Client)?; + + if response.status() == StatusCode::METHOD_NOT_ALLOWED { + return Ok(()); + } + response.error_for_status().map_err(Error::Client)?; + + Ok(()) + } + + async fn post_message( + &self, + uri: Arc, + message: ClientJsonRpcMessage, + session_id: Option>, + auth_token: Option, + custom_headers: HashMap, + ) -> Result { + let body = serde_json::to_vec(&message)?; + let mut builder = self + .0 + .post(uri.as_ref()) + .header(ACCEPT, accept()) + .header(CONTENT_TYPE, JSON_MIME_TYPE) + .body(body); + let session_was_attached = session_id.is_some(); + if let Some(session_id) = session_id { + builder = builder.header(HEADER_SESSION_ID, session_id.as_ref()); + } + + let response = with_headers(builder, auth_token, custom_headers) + .send() + .await + .map_err(Error::Client)?; + + let status = response.status(); + if matches!(status, StatusCode::ACCEPTED | StatusCode::NO_CONTENT) { + return Ok(StreamableHttpPostResponse::Accepted); + } + if status == StatusCode::NOT_FOUND && session_was_attached { + return Err(Error::SessionExpired); + } + + let content_type = content_type(&response); + let session_id = response + .headers() + .get(HEADER_SESSION_ID) + .and_then(|v| v.to_str().ok()) + .map(str::to_owned); + + // The spec answers notifications and responses with `202`, but an + // empty `200` means the same thing. + if status.is_success() + && response.content_length() == Some(0) + && !matches!(message, ClientJsonRpcMessage::Request(_)) + { + return Ok(StreamableHttpPostResponse::Accepted); + } + + // A failure status can still carry a JSON-RPC error, which the caller + // should see as the MCP error it is rather than a transport failure. + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + if content_type + .as_deref() + .is_some_and(|ct| is(ct, JSON_MIME_TYPE)) + && let Some(error) = json_rpc_error(&body) + { + return Ok(StreamableHttpPostResponse::Json(error, session_id)); + } + + return Err(Error::UnexpectedServerResponse(Cow::Owned(format!( + "HTTP {status}: {body}" + )))); + } + + match content_type.as_deref() { + Some(ct) if is(ct, EVENT_STREAM_MIME_TYPE) => { + let stream = SseStream::from_bytes_stream(response.bytes_stream()).boxed(); + Ok(StreamableHttpPostResponse::Sse(stream, session_id)) + } + Some(ct) if is(ct, JSON_MIME_TYPE) => { + let body = response.bytes().await.map_err(Error::Client)?; + match serde_json::from_slice::(&body) { + Ok(message) => Ok(StreamableHttpPostResponse::Json(message, session_id)), + Err(error) => { + warn!(%error, "Unparseable JSON-RPC response; treating it as accepted."); + Ok(StreamableHttpPostResponse::Accepted) + } + } + } + _ => Err(Error::UnexpectedContentType(content_type)), + } + } +} + +/// The `Accept` value every MCP request carries. +fn accept() -> String { + format!("{EVENT_STREAM_MIME_TYPE}, {JSON_MIME_TYPE}") +} + +fn content_type(response: &reqwest::Response) -> Option { + response + .headers() + .get(CONTENT_TYPE) + .map(|ct| String::from_utf8_lossy(ct.as_bytes()).into_owned()) +} + +/// Whether a `Content-Type` value names `mime`, ignoring any parameters. +fn is(content_type: &str, mime: &str) -> bool { + content_type.starts_with(mime) +} + +/// `body` as a JSON-RPC error, when it is one. +fn json_rpc_error(body: &str) -> Option { + match serde_json::from_str::(body) { + Ok(message @ JsonRpcMessage::Error(_)) => Some(message), + _ => None, + } +} + +#[cfg(test)] +#[path = "client_tests.rs"] +mod tests; diff --git a/crates/jp_mcp/src/server/http/client_tests.rs b/crates/jp_mcp/src/server/http/client_tests.rs new file mode 100644 index 000000000..9987996f3 --- /dev/null +++ b/crates/jp_mcp/src/server/http/client_tests.rs @@ -0,0 +1,105 @@ +use jp_test::mock::{MockServer, POST}; +use serde_json::json; + +use super::*; + +fn client() -> LoopbackClient { + LoopbackClient(Client::builder().no_proxy().build().unwrap()) +} + +fn ping() -> ClientJsonRpcMessage { + serde_json::from_value(json!({"jsonrpc": "2.0", "id": 1, "method": "ping"})).unwrap() +} + +fn initialized() -> ClientJsonRpcMessage { + serde_json::from_value(json!({"jsonrpc": "2.0", "method": "notifications/initialized"})) + .unwrap() +} + +async fn post( + server: &MockServer, + message: ClientJsonRpcMessage, + session_id: Option<&str>, +) -> Result { + client() + .post_message( + server.url("/mcp").into(), + message, + session_id.map(Into::into), + None, + HashMap::new(), + ) + .await +} + +/// A `404` for a request that carried a session means the server dropped the +/// session, which rmcp's worker handles differently from a missing endpoint. +#[tokio::test] +async fn a_404_with_a_session_is_an_expired_session() { + let server = MockServer::start_async().await; + server + .mock_async(|when, then| { + when.method(POST).path("/mcp"); + then.status(404); + }) + .await; + + assert!(matches!( + post(&server, ping(), Some("session-1")).await, + Err(StreamableHttpError::SessionExpired) + )); + assert!(matches!( + post(&server, ping(), None).await, + Err(StreamableHttpError::UnexpectedServerResponse(_)) + )); +} + +/// A JSON-RPC error on a failure status reaches the caller as an MCP error. +#[tokio::test] +async fn a_json_rpc_error_on_a_failure_status_is_returned_as_a_message() { + let server = MockServer::start_async().await; + server + .mock_async(|when, then| { + when.method(POST).path("/mcp"); + then.status(400) + .header("content-type", "application/json") + .json_body(json!({ + "jsonrpc": "2.0", + "id": 1, + "error": {"code": -32600, "message": "Invalid Request"} + })); + }) + .await; + + let result = post(&server, ping(), Some("session-1")).await; + assert!( + matches!( + result, + Ok(StreamableHttpPostResponse::Json( + JsonRpcMessage::Error(_), + _ + )) + ), + "{result:?}" + ); +} + +/// An empty `200` answers a notification the same way a `202` does. +#[tokio::test] +async fn an_empty_200_to_a_notification_is_accepted() { + let server = MockServer::start_async().await; + server + .mock_async(|when, then| { + when.method(POST) + .path("/mcp") + .header("mcp-session-id", "session-1") + .header("accept", "text/event-stream, application/json"); + then.status(200); + }) + .await; + + assert!(matches!( + post(&server, initialized(), Some("session-1")).await, + Ok(StreamableHttpPostResponse::Accepted) + )); +} diff --git a/crates/jp_mcp/src/server/http_tests.rs b/crates/jp_mcp/src/server/http_tests.rs new file mode 100644 index 000000000..978c7a270 --- /dev/null +++ b/crates/jp_mcp/src/server/http_tests.rs @@ -0,0 +1,259 @@ +use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, +}; + +use async_trait::async_trait; +use indexmap::IndexMap; +use jp_config::{ + AppConfig, Config as _, + conversation::tool::{PartialToolConfig, ToolConfig}, +}; +use jp_tool::{Outcome, ToolDefinition, ToolDocs}; +use rmcp::model::CallToolRequestParams; +use serde_json::{Map, Value, json}; +use tokio::time::{Duration, timeout}; + +use super::*; +use crate::{ + Client, Content, + server::{ + InvocationContext, + builtin::{BuiltinExecutors, BuiltinTool}, + service::{ + Admission, CallRequest, ConfiguredTool, HostReceiver, Interaction, ReleaseDecision, + ServiceError, + }, + }, +}; + +struct Count(Arc); +#[async_trait] +impl BuiltinTool for Count { + async fn execute(&self, _: &Value, _: &IndexMap) -> Outcome { + self.0.fetch_add(1, Ordering::SeqCst); + "raw".into() + } +} + +fn setup() -> (Service, HostReceiver, Arc) { + let count = Arc::new(AtomicUsize::new(0)); + let partial: PartialToolConfig = + serde_json::from_value(json!({"source":"builtin", "run":"ask", "result":"edit"})).unwrap(); + let mut cfg = AppConfig::new_test(); + cfg.conversation.tools.insert( + "count".into(), + ToolConfig::from_partial(partial, vec![]).unwrap(), + ); + let (service, host) = Service::new( + vec![ConfiguredTool { + definition: ToolDefinition { + name: "count".into(), + docs: ToolDocs::default(), + parameters: json!({"type":"object","properties":{}}), + }, + config: cfg.conversation.tools.get("count").unwrap(), + access: Ok(None), + }], + Client::default(), + BuiltinExecutors::new().register("count", Count(count.clone())), + "/tmp".into(), + InvocationContext::default(), + ) + .unwrap(); + (service, host, count) +} + +#[tokio::test] +async fn http_call_waits_for_host_release_and_records_edited_result() { + let (service, mut host, count) = setup(); + let endpoint = Endpoint::start(service).await.unwrap(); + let client = endpoint.connect().await.unwrap(); + let tools = client.peer().list_all_tools().await.unwrap(); + assert_eq!( + tools + .iter() + .map(|tool| tool.name.as_ref()) + .collect::>(), + ["count"] + ); + let peer = client.peer().clone(); + let task = + tokio::spawn(async move { peer.call_tool(CallToolRequestParams::new("count")).await }); + let Interaction::Prepare { + arguments, reply, .. + } = timeout(Duration::from_secs(2), host.recv()) + .await + .unwrap() + .unwrap() + .interaction + else { + panic!("expected preparation") + }; + assert_eq!(count.load(Ordering::SeqCst), 0); + reply.send(Ok(Admission::Run { arguments })).unwrap(); + let Interaction::Release { reply, .. } = host.recv().await.unwrap().interaction else { + panic!("expected release") + }; + assert_eq!(count.load(Ordering::SeqCst), 0); + reply.send(Ok(ReleaseDecision::Execute)).unwrap(); + let Interaction::Review { result, reply, .. } = host.recv().await.unwrap().interaction else { + panic!("expected review") + }; + assert_eq!(result, Ok("raw".into())); + reply.send(Ok(Ok("edited".into()))).unwrap(); + let Interaction::Record { result, reply, .. } = host.recv().await.unwrap().interaction else { + panic!("expected record") + }; + assert_eq!(result, Ok("edited".into())); + assert!(!task.is_finished()); + reply.send(Ok(())).unwrap(); + let result = timeout(Duration::from_secs(2), task) + .await + .unwrap() + .unwrap() + .unwrap(); + assert_eq!(result.content, vec![Content::text("edited")]); + assert_eq!(count.load(Ordering::SeqCst), 1); + client.cancel().await.unwrap(); + endpoint.shutdown().await.unwrap(); +} + +#[tokio::test] +async fn http_rejects_untrusted_host_and_origin_before_dispatch() { + let (service, _host, count) = setup(); + let endpoint = Endpoint::start(service).await.unwrap(); + let client = HttpClient::builder().no_proxy().build().unwrap(); + let response = client + .post(endpoint.url()) + .header("host", "evil.example") + .body("{}") + .send() + .await + .unwrap(); + assert_eq!(response.status().as_u16(), 403); + let response = client + .post(endpoint.url()) + .header("origin", "https://evil.example") + .body("{}") + .send() + .await + .unwrap(); + assert_eq!(response.status().as_u16(), 403); + assert_eq!(count.load(Ordering::SeqCst), 0); + endpoint.shutdown().await.unwrap(); +} + +#[tokio::test] +async fn http_denial_is_recorded_without_execution() { + let (service, mut host, count) = setup(); + let endpoint = Endpoint::start(service).await.unwrap(); + let client = endpoint.connect().await.unwrap(); + let peer = client.peer().clone(); + let task = + tokio::spawn(async move { peer.call_tool(CallToolRequestParams::new("count")).await }); + let Interaction::Prepare { reply, .. } = timeout(Duration::from_secs(2), host.recv()) + .await + .unwrap() + .unwrap() + .interaction + else { + panic!("expected preparation") + }; + reply + .send(Ok(Admission::Skip { + reason: "not approved".into(), + })) + .unwrap(); + let Interaction::Record { + reply, raw_result, .. + } = timeout(Duration::from_secs(2), host.recv()) + .await + .unwrap() + .unwrap() + .interaction + else { + panic!("expected recording") + }; + assert_eq!(raw_result, None); + assert_eq!(count.load(Ordering::SeqCst), 0); + reply.send(Ok(())).unwrap(); + let result = timeout(Duration::from_secs(2), task) + .await + .unwrap() + .unwrap() + .unwrap(); + assert_eq!(result.content, vec![Content::text("not approved")]); + assert_eq!(count.load(Ordering::SeqCst), 0); + client.cancel().await.unwrap(); + endpoint.shutdown().await.unwrap(); +} + +#[tokio::test] +async fn endpoint_shutdown_cancels_waiting_call_and_closes_listener() { + let (service, mut host, count) = setup(); + let endpoint = Endpoint::start(service).await.unwrap(); + let url = endpoint.url().to_owned(); + let client = endpoint.connect().await.unwrap(); + let peer = client.peer().clone(); + let task = + tokio::spawn(async move { peer.call_tool(CallToolRequestParams::new("count")).await }); + let Interaction::Prepare { reply, .. } = timeout(Duration::from_secs(2), host.recv()) + .await + .unwrap() + .unwrap() + .interaction + else { + panic!("expected preparation") + }; + timeout(Duration::from_secs(2), endpoint.shutdown()) + .await + .unwrap() + .unwrap(); + assert!( + reply + .send(Ok(Admission::Run { + arguments: Map::new() + })) + .is_err() + ); + assert!( + timeout(Duration::from_secs(2), task) + .await + .unwrap() + .unwrap() + .is_err() + ); + assert!( + HttpClient::builder() + .no_proxy() + .build() + .unwrap() + .post(url) + .body("{}") + .send() + .await + .unwrap_err() + .is_connect() + ); + assert_eq!(count.load(Ordering::SeqCst), 0); + client.cancel().await.unwrap(); +} + +#[tokio::test] +async fn dropping_endpoint_stops_admission_even_if_host_is_still_connected() { + let (service, _host, count) = setup(); + let endpoint = Endpoint::start(service).await.unwrap(); + let service = endpoint.service(); + drop(endpoint); + assert!(matches!( + service.start_call(CallRequest { + name: "count".into(), + arguments: Map::new(), + correlation: Map::new() + }), + Err(ServiceError::Stopped) + )); + assert_eq!(count.load(Ordering::SeqCst), 0); + service.shutdown().await.unwrap(); +} diff --git a/crates/jp_mcp/src/server/service.rs b/crates/jp_mcp/src/server/service.rs index 72f52fae1..0b0c3772c 100644 --- a/crates/jp_mcp/src/server/service.rs +++ b/crates/jp_mcp/src/server/service.rs @@ -21,15 +21,15 @@ use jp_tool::{ definition::{apply_parameter_defaults, validate_tool_arguments}, schema::Node, }; -use serde_json::{Map, Value, json}; +use serde_json::{Map, Value}; use tokio::sync::{Notify, broadcast, mpsc, oneshot}; use tokio_util::sync::CancellationToken; use super::{ CommandResult, ExecutionOutcome, InvocationContext, builtin::BuiltinExecutors, execute, - run_tool_command, + run_tool_command, tool_context, }; -use crate::Client; +use crate::{CallToolResult, Client, Content}; /// A tool resolved under trusted MCP Host configuration. #[derive(Clone, Debug)] @@ -39,7 +39,8 @@ pub struct ConfiguredTool { /// Execution and interaction requirements, including source selection. pub config: ToolConfigWithDefaults, /// Compiled access grants supplied by the MCP Host, never by an MCP caller. - pub access: Option, + /// A compilation failure is delivered as a tool error without execution. + pub access: Result, String>, } /// An invocation received by the MCP handler. @@ -99,6 +100,33 @@ pub enum Admission { /// Do not execute. /// Deliver and record this explanation. Skip { reason: String }, + /// Resolve a call without execution, preserving an error response if + /// needed. + Complete { result: Result }, +} + +/// The Host may answer a question or resolve the call without another attempt. +#[derive(Debug)] +pub enum InputAnswer { + /// Validated by the service before another execution attempt. + Answer(Value), + /// A declined or cancelled inquiry resolves the logical call. + Complete { result: Result }, +} + +impl From for InputAnswer { + fn from(value: Value) -> Self { + Self::Answer(value) + } +} + +/// The Host releases a prepared call or resolves it without execution. +#[derive(Debug)] +pub enum ReleaseDecision { + /// Begin execution with the approved arguments. + Execute, + /// Preparation failed or the Host stopped the call before execution. + Complete { result: Result }, } /// Host-only services needed by the per-call execution state machine. @@ -129,8 +157,8 @@ pub enum Interaction { arguments: Map, /// Custom representation of the approved arguments, if requested. formatted_arguments: Option>, - /// Acknowledgement permitting the first execution attempt. - reply: oneshot::Sender>, + /// Permission to execute, or a final response without execution. + reply: oneshot::Sender>, }, /// Obtain and record input before the next execution attempt. Input { @@ -142,7 +170,7 @@ pub enum Interaction { /// These may contain secrets and must not be logged. answers: IndexMap, /// The answer, after Host routing and recording/redaction. - reply: oneshot::Sender>, + reply: oneshot::Sender>, }, /// Review/edit a completed result under the configured delivery policy. Review { @@ -221,7 +249,7 @@ pub enum ServiceError { pub struct Call { id: InvocationId, cancellation: CancellationToken, - result: oneshot::Receiver, ServiceError>>, + result: oneshot::Receiver>, } impl Call { @@ -237,6 +265,10 @@ impl Call { !self.result.is_empty() || self.result.is_terminated() } + pub(super) fn cancellation_token(&self) -> CancellationToken { + self.cancellation.clone() + } + /// Cancel this invocation, including a pending Host interaction. pub fn cancel(&self) { self.cancellation.cancel(); @@ -244,7 +276,28 @@ impl Call { /// Wait for execution and the final Host recording acknowledgement. pub async fn finish(self) -> Result, ServiceError> { - self.result.await.map_err(|_| ServiceError::TaskLost)? + self.result + .await + .map_err(|_| ServiceError::TaskLost)? + .map(|output| output.text) + } +} + +#[derive(Debug)] +struct CallOutput { + text: Result, + native: Option, + delivery_decided: bool, +} + +impl Call { + /// Receive the complete MCP result, retaining unedited upstream content. + pub async fn finish_mcp(self) -> Result { + let output = self.result.await.map_err(|_| ServiceError::TaskLost)??; + Ok(output.native.unwrap_or_else(|| match output.text { + Ok(text) => CallToolResult::success(vec![Content::text(text)]), + Err(text) => CallToolResult::error(vec![Content::text(text)]), + })) } } @@ -261,7 +314,7 @@ pub struct Service { } struct Inner { - tools: HashMap, + tools: IndexMap, upstream: Client, builtins: BuiltinExecutors, root: Utf8PathBuf, @@ -312,9 +365,9 @@ impl Service { root: Utf8PathBuf, invocation: InvocationContext, ) -> Result<(Self, HostReceiver), ServiceError> { - let mut catalog = HashMap::new(); + let mut catalog = IndexMap::new(); for tool in tools { - if tool.config.access().is_some() && tool.access.is_none() { + if tool.config.access().is_some() && matches!(tool.access, Ok(None)) { return Err(ServiceError::MissingAccessPolicy(tool.definition.name)); } let name = tool.definition.name.clone(); @@ -342,6 +395,11 @@ impl Service { )) } + /// Advertised definitions in their configured order. + pub fn definitions(&self) -> impl Iterator { + self.inner.tools.values().map(|tool| &tool.definition) + } + /// Subscribe to stderr progress without slowing execution or Host replies. /// A lagging subscriber receives the broadcast channel's lag error. #[must_use] @@ -400,6 +458,13 @@ impl Service { }) } + /// Cancel an invocation identified through the private Host channel. + pub fn cancel_call(&self, id: InvocationId) { + if let Some(token) = self.inner.state().active.get(&id) { + token.cancel(); + } + } + /// Stop current calls without preventing admission of later work. pub fn cancel_current(&self) { for token in self.inner.state().active.values() { @@ -407,17 +472,20 @@ impl Service { } } + /// Stop admission and signal cancellation without waiting for cleanup. + pub fn stop(&self) { + let mut state = self.inner.state(); + state.stopped = true; + for token in state.active.values() { + token.cancel(); + } + } + /// Stop admission, cancel outstanding calls, wait for their cleanup, and /// close owned upstream services. /// Safe to call more than once. pub async fn shutdown(&self) -> Result<(), ServiceError> { - { - let mut state = self.inner.state(); - state.stopped = true; - for token in state.active.values() { - token.cancel(); - } - } + self.stop(); loop { let idle = self.inner.idle.notified(); tokio::pin!(idle); @@ -434,11 +502,7 @@ impl Service { impl Drop for Service { fn drop(&mut self) { - let mut state = self.inner.state(); - state.stopped = true; - for token in state.active.values() { - token.cancel(); - } + self.stop(); } } @@ -501,7 +565,7 @@ async fn run_call( call: &CallInfo, tool: ConfiguredTool, cancellation: &CancellationToken, -) -> Result, ServiceError> { +) -> Result { let mut arguments = call.request.arguments.clone(); validate_arguments(&tool, &mut arguments)?; let wants_format = if tool.config.run() != RunMode::Skip @@ -532,10 +596,13 @@ async fn run_call( }) .await? }; + let admission = match admission { + Admission::Skip { reason } => Admission::Complete { result: Ok(reason) }, + other => other, + }; arguments = match admission { Admission::Run { arguments } => arguments, - Admission::Skip { reason } => { - let result = Ok(reason); + Admission::Complete { result } => { ask(inner, call, |reply| Interaction::Record { arguments, raw_result: None, @@ -543,40 +610,84 @@ async fn run_call( reply, }) .await?; - return Ok(result); + return Ok(CallOutput { + text: result, + native: None, + delivery_decided: true, + }); } + Admission::Skip { .. } => unreachable!("skip was normalized above"), }; validate_arguments(&tool, &mut arguments)?; if wants_format && (formatted_arguments.is_none() || arguments != original_arguments) { formatted_arguments = Some(format_arguments(inner, &tool, &arguments, cancellation).await?); } - ask(inner, call, |reply| Interaction::Release { + let release = ask(inner, call, |reply| Interaction::Release { arguments: arguments.clone(), formatted_arguments, reply, }) .await?; - let raw_result = execute_with_answers(inner, call, &tool, &arguments, cancellation).await?; - let result = match tool.config.result() { - ResultMode::Skip => Ok("Result delivery skipped by configuration.".into()), - ResultMode::Unattended => raw_result.clone(), - mode @ (ResultMode::Ask | ResultMode::Edit) => { - ask(inner, call, |reply| Interaction::Review { - mode, - result: raw_result.clone(), - reply, - }) - .await? + let (output, executed) = match release { + ReleaseDecision::Execute => ( + execute_with_answers(inner, call, &tool, &arguments, cancellation).await?, + true, + ), + ReleaseDecision::Complete { result } => ( + CallOutput { + text: result, + native: None, + delivery_decided: true, + }, + false, + ), + }; + deliver_result(inner, call, &tool, arguments, output, executed).await +} + +async fn deliver_result( + inner: &Inner, + call: &CallInfo, + tool: &ConfiguredTool, + arguments: Map, + output: CallOutput, + executed: bool, +) -> Result { + let CallOutput { + text: raw_result, + native, + delivery_decided, + } = output; + let result = if delivery_decided { + raw_result.clone() + } else { + match tool.config.result() { + ResultMode::Skip => Ok("Result delivery skipped by configuration.".into()), + ResultMode::Unattended => raw_result.clone(), + mode @ (ResultMode::Ask | ResultMode::Edit) => { + ask(inner, call, |reply| Interaction::Review { + mode, + result: raw_result.clone(), + reply, + }) + .await? + } } }; + let native = + native.filter(|_| result == raw_result && tool.config.result() != ResultMode::Skip); ask(inner, call, |reply| Interaction::Record { arguments, - raw_result: Some(raw_result), + raw_result: (executed && !delivery_decided).then_some(raw_result), result: result.clone(), reply, }) .await?; - Ok(result) + Ok(CallOutput { + text: result, + native, + delivery_decided: true, + }) } async fn execute_with_answers( @@ -585,7 +696,17 @@ async fn execute_with_answers( tool: &ConfiguredTool, arguments: &Map, cancellation: &CancellationToken, -) -> Result, ServiceError> { +) -> Result { + let access = match &tool.access { + Ok(access) => access.as_ref(), + Err(error) => { + return Ok(CallOutput { + text: Err(error.clone()), + native: None, + delivery_decided: false, + }); + } + }; let mut answers = IndexMap::new(); loop { let progress = inner.progress.clone(); @@ -606,14 +727,20 @@ async fn execute_with_answers( &inner.root, cancellation.clone(), &inner.builtins, - tool.access.as_ref(), + access, &inner.invocation, Some(stderr), ) .await?; match outcome { ExecutionOutcome::Cancelled { .. } => return Err(ServiceError::Cancelled), - ExecutionOutcome::Completed { result, .. } => return Ok(result), + ExecutionOutcome::Completed { result, native, .. } => { + return Ok(CallOutput { + text: result, + native, + delivery_decided: false, + }); + } ExecutionOutcome::NeedsInput { mut question, .. } => { let supporting = question .pre_amble @@ -629,6 +756,16 @@ async fn execute_with_answers( reply, }) .await?; + let answer = match answer { + InputAnswer::Answer(answer) => answer, + InputAnswer::Complete { result } => { + return Ok(CallOutput { + text: result, + native: None, + delivery_decided: true, + }); + } + }; if !Node::root(&Value::Object(request.schema)).permits(&answer) { return Err(ServiceError::InvalidAnswer(request.id.to_string())); } @@ -652,10 +789,19 @@ async fn format_arguments( | ToolSource::Builtin { tool: name } | ToolSource::Mcp { tool: name, .. } => name.as_deref().unwrap_or(&tool.definition.name), }; - let context = json!({ - "tool": {"name":name, "arguments":arguments, "options":tool.config.options()}, - "context": {"action":Action::FormatArguments, "root":inner.root, "workspace_id":inner.invocation.workspace_id, "conversation_id":inner.invocation.conversation_id, "access":tool.access}, - }); + let context = tool_context( + name, + &Value::Object(arguments.clone()), + &IndexMap::new(), + &tool.config, + &inner.root, + &Action::FormatArguments, + tool.access + .as_ref() + .map_err(|error| ServiceError::Host(HostError(error.clone())))? + .as_ref(), + &inner.invocation, + ); let result = match run_tool_command( command.clone().command(), context, diff --git a/crates/jp_mcp/src/server/service_tests.rs b/crates/jp_mcp/src/server/service_tests.rs index 9c7f0241c..7535eac30 100644 --- a/crates/jp_mcp/src/server/service_tests.rs +++ b/crates/jp_mcp/src/server/service_tests.rs @@ -59,7 +59,7 @@ fn fixture(run: &str, result: &str) -> (Service, HostReceiver, Arc) parameters: json!({"type":"object", "properties":{"path":{"type":"string"}}, "required":["path"]}), }, config: config.conversation.tools.get("count").unwrap(), - access: None, + access: Ok(None), }; let (service, host) = Service::new( vec![tool], @@ -83,7 +83,7 @@ async fn release(host: &mut HostReceiver) { let Interaction::Release { reply, .. } = next(host).await.interaction else { panic!("expected release") }; - reply.send(Ok(())).unwrap(); + reply.send(Ok(ReleaseDecision::Execute)).unwrap(); } async fn next(host: &mut HostReceiver) -> HostRequest { @@ -129,7 +129,7 @@ async fn preparation_release_input_and_delivery_use_distinct_acknowledgements() }; assert_eq!(arguments["path"], "edited"); assert_eq!(count.load(Ordering::SeqCst), 0); - reply.send(Ok(())).unwrap(); + reply.send(Ok(ReleaseDecision::Execute)).unwrap(); let Interaction::Input { request, supporting, @@ -145,7 +145,7 @@ async fn preparation_release_input_and_delivery_use_distinct_acknowledgements() )]); assert!(answers.is_empty()); assert_eq!(count.load(Ordering::SeqCst), 1); - reply.send(Ok(json!(true))).unwrap(); + reply.send(Ok(json!(true).into())).unwrap(); let Interaction::Review { result, reply, .. } = next(&mut host).await.interaction else { panic!("expected review") }; @@ -236,7 +236,7 @@ async fn shutdown_cancels_pending_release_and_rejects_late_reply() { .await .unwrap() .unwrap(); - assert!(reply.send(Ok(())).is_err()); + assert!(reply.send(Ok(ReleaseDecision::Execute)).is_err()); assert!(matches!(call.finish().await, Err(ServiceError::Cancelled))); assert!(matches!( service.start_call(request()), @@ -254,7 +254,7 @@ async fn invalid_answer_prevents_a_second_attempt() { panic!("expected input") }; assert_eq!(count.load(Ordering::SeqCst), 1); - reply.send(Ok(json!("not a boolean"))).unwrap(); + reply.send(Ok(json!("not a boolean").into())).unwrap(); assert!(matches!(call.finish().await, Err(ServiceError::InvalidAnswer(id)) if id == "confirm")); assert_eq!(count.load(Ordering::SeqCst), 1); } @@ -267,7 +267,7 @@ async fn failed_recording_prevents_result_delivery() { let Interaction::Input { reply, .. } = next(&mut host).await.interaction else { panic!("expected input") }; - reply.send(Ok(json!(true))).unwrap(); + reply.send(Ok(json!(true).into())).unwrap(); let Interaction::Record { reply, .. } = next(&mut host).await.interaction else { panic!("expected recording") }; @@ -288,14 +288,14 @@ async fn current_call_cancellation_does_not_poison_later_calls() { }; service.cancel_current(); assert!(matches!(first.finish().await, Err(ServiceError::Cancelled))); - assert!(stale.send(Ok(json!(true))).is_err()); + assert!(stale.send(Ok(json!(true).into())).is_err()); let second = service.start_call(request()).unwrap(); release(&mut host).await; let Interaction::Input { answers, reply, .. } = next(&mut host).await.interaction else { panic!("expected fresh input") }; assert!(answers.is_empty()); - reply.send(Ok(json!(false))).unwrap(); + reply.send(Ok(json!(false).into())).unwrap(); let Interaction::Record { reply, .. } = next(&mut host).await.interaction else { panic!("expected recording") }; @@ -335,7 +335,7 @@ async fn identical_calls_have_independent_answers_and_out_of_order_delivery() { panic!("expected second input") }; assert!(answers.is_empty()); - second_answer.send(Ok(json!(false))).unwrap(); + second_answer.send(Ok(json!(false).into())).unwrap(); let record = next(&mut host).await; assert_eq!(record.call.id, second.id()); let Interaction::Record { reply, .. } = record.interaction else { @@ -347,7 +347,7 @@ async fn identical_calls_have_independent_answers_and_out_of_order_delivery() { Ok(r#"{"arguments":{"path":"original"},"answer":false}"#.into()) ); assert!(!first.is_finished()); - first_answer.send(Ok(json!(true))).unwrap(); + first_answer.send(Ok(json!(true).into())).unwrap(); let record = next(&mut host).await; assert_eq!(record.call.id, first.id()); let Interaction::Record { reply, .. } = record.interaction else { @@ -388,7 +388,7 @@ async fn skipped_delivery_records_original_without_delivering_it() { let Interaction::Input { reply, .. } = next(&mut host).await.interaction else { panic!("expected input") }; - reply.send(Ok(json!(true))).unwrap(); + reply.send(Ok(json!(true).into())).unwrap(); let Interaction::Record { reply, raw_result, @@ -436,7 +436,7 @@ async fn local_inquiry_exits_and_runs_a_new_process_with_the_answer() { parameters: json!({"type":"object","properties":{}}), }, config: cfg.conversation.tools.get("local").unwrap(), - access: None, + access: Ok(None), }; let (service, mut host) = Service::new( vec![tool], @@ -461,7 +461,7 @@ async fn local_inquiry_exits_and_runs_a_new_process_with_the_answer() { fs::read_to_string(root.path().join("attempts")).unwrap(), "run\n" ); - reply.send(Ok(json!(true))).unwrap(); + reply.send(Ok(json!(true).into())).unwrap(); let Interaction::Record { reply, .. } = next(&mut host).await.interaction else { panic!("expected recording") }; @@ -535,7 +535,7 @@ async fn dropping_result_receiver_does_not_cancel_or_reexecute() { panic!("expected input") }; drop(call); - reply.send(Ok(json!(true))).unwrap(); + reply.send(Ok(json!(true).into())).unwrap(); let Interaction::Record { reply, .. } = next(&mut host).await.interaction else { panic!("expected recording") }; @@ -605,7 +605,7 @@ async fn formatter_asks_for_visibility_and_waits_for_approval() { ); call.cancel(); assert!(matches!(call.finish().await, Err(ServiceError::Cancelled))); - assert!(reply.send(Ok(())).is_err()); + assert!(reply.send(Ok(ReleaseDecision::Execute)).is_err()); } #[tokio::test] diff --git a/crates/jp_mcp/src/server/upstream.rs b/crates/jp_mcp/src/server/upstream.rs new file mode 100644 index 000000000..9c755e105 --- /dev/null +++ b/crates/jp_mcp/src/server/upstream.rs @@ -0,0 +1,92 @@ +//! JP-aware result decoding for upstream MCP tools. + +use jp_tool::Outcome; +use serde_json::Value; +use tracing::warn; + +use crate::{CallToolResult, RawContent, ResourceContents}; + +pub(super) enum UpstreamResult { + Outcome { + outcome: Outcome, + response: CallToolResult, + }, + Native(CallToolResult), +} + +/// Recognize one complete legacy envelope without flattening native content. +pub(super) fn decode_result(result: CallToolResult) -> Result { + if let [content] = result.content.as_slice() + && let RawContent::Text(text) = &content.raw + { + match serde_json::from_str::(&text.text) { + Ok(Outcome::Success { .. }) if result.is_error == Some(true) => { + warn!("MCP error flag conflicts with an Outcome::Success envelope"); + } + Ok(outcome) => { + return Ok(UpstreamResult::Outcome { + outcome, + response: result, + }); + } + Err(error) => { + let value = serde_json::from_str::(&text.text).ok(); + if matches!( + value + .as_ref() + .and_then(|v| v.get("type")) + .and_then(Value::as_str), + Some("needs_input") + ) { + return Err(error); + } + } + } + } + Ok(UpstreamResult::Native(result)) +} + +/// Replace an unwrapped envelope while retaining its native result metadata. +pub(super) fn replace_envelope( + mut response: CallToolResult, + text: &str, + is_error: bool, +) -> CallToolResult { + if let Some(content) = response.content.first_mut() + && let RawContent::Text(content) = &mut content.raw + { + content.text = text.into(); + } + response.is_error = Some(is_error); + response +} + +/// Project an MCP result for the existing text-only conversation format. +/// +/// Text and embedded resource content contribute to the result. +/// Image, audio, and resource-link blocks remain available only in the original +/// MCP result. +pub fn text_result(result: &CallToolResult) -> Result { + let text = result + .content + .iter() + .filter_map(|content| match &content.raw { + RawContent::Text(text) => Some(text.text.as_str()), + RawContent::Resource(resource) => match &resource.resource { + ResourceContents::TextResourceContents { text, .. } => Some(text.as_str()), + ResourceContents::BlobResourceContents { blob, .. } => Some(blob.as_str()), + }, + RawContent::Image(_) | RawContent::Audio(_) | RawContent::ResourceLink(_) => None, + }) + .collect::>() + .join("\n\n"); + if result.is_error.unwrap_or_default() { + Err(text) + } else { + Ok(text) + } +} + +#[cfg(test)] +#[path = "upstream_tests.rs"] +mod tests; diff --git a/crates/jp_mcp/src/server/upstream_tests.rs b/crates/jp_mcp/src/server/upstream_tests.rs new file mode 100644 index 000000000..21bd4c806 --- /dev/null +++ b/crates/jp_mcp/src/server/upstream_tests.rs @@ -0,0 +1,100 @@ +use jp_tool::Outcome; +use serde_json::json; + +use super::*; +use crate::Content; + +#[test] +fn single_text_outcome_is_unwrapped_once() { + let text = r#"{"type":"success","content":"{\"type\":\"success\",\"content\":\"nested\"}"}"#; + let output = decode_result(CallToolResult::success(vec![Content::text(text)])).unwrap(); + let UpstreamResult::Outcome { + outcome: Outcome::Success { content }, + .. + } = output + else { + panic!("expected Outcome") + }; + assert_eq!(content, r#"{"type":"success","content":"nested"}"#); +} + +#[test] +fn mixed_content_and_metadata_are_preserved() { + let input: CallToolResult = serde_json::from_value(json!({ + "content":[{"type":"text","text":"{\"type\":\"success\",\"content\":\"plain\"}"},{"type":"image","data":"AA==","mimeType":"image/png"}], + "isError":false,"structuredContent":{"answer":42},"_meta":{"custom":"retained"} + })).unwrap(); + let expected = serde_json::to_value(&input).unwrap(); + let UpstreamResult::Native(result) = decode_result(input).unwrap() else { + panic!("expected native result") + }; + assert_eq!(serde_json::to_value(result).unwrap(), expected); +} + +#[test] +fn mcp_error_flag_wins_over_success_envelope() { + let input = CallToolResult::error(vec![Content::text( + r#"{"type":"success","content":"done"}"#, + )]); + let UpstreamResult::Native(result) = decode_result(input).unwrap() else { + panic!("expected native error") + }; + assert_eq!(result.is_error, Some(true)); + assert_eq!(result.content, vec![Content::text( + r#"{"type":"success","content":"done"}"# + )]); +} + +#[test] +fn malformed_recognized_inquiry_is_not_plain_output() { + assert!( + decode_result(CallToolResult::success(vec![Content::text( + r#"{"type":"needs_input","question":{"id":"bad.id"}}"# + )])) + .is_err() + ); +} + +#[test] +fn ordinary_text_is_native() { + let UpstreamResult::Native(result) = + decode_result(CallToolResult::success(vec![Content::text("hello")])).unwrap() + else { + panic!("expected text") + }; + assert_eq!(result.content, vec![Content::text("hello")]); +} + +#[test] +fn unwrapped_envelope_preserves_annotations_and_result_metadata() { + let input: CallToolResult = serde_json::from_value(json!({ + "content":[{"type":"text","text":"{\"type\":\"success\",\"content\":\"done\"}","annotations":{"audience":["assistant"]}}], + "structuredContent":{"answer":42},"_meta":{"custom":"retained"} + })).unwrap(); + let UpstreamResult::Outcome { + outcome: Outcome::Success { content }, + response, + } = decode_result(input).unwrap() + else { + panic!("expected envelope") + }; + let result = replace_envelope(response, &content, false); + assert_eq!( + serde_json::to_value(result).unwrap(), + json!({ + "content":[{"type":"text","text":"done","annotations":{"audience":["assistant"]}}], + "isError":false,"structuredContent":{"answer":42},"_meta":{"custom":"retained"} + }) + ); +} + +#[test] +fn unrelated_error_json_is_not_a_malformed_outcome() { + let result = CallToolResult::success(vec![Content::text(r#"{"type":"error","code":42}"#)]); + let UpstreamResult::Native(result) = decode_result(result).unwrap() else { + panic!("expected native data") + }; + assert_eq!(result.content, vec![Content::text( + r#"{"type":"error","code":42}"# + )]); +} diff --git a/crates/jp_mcp/src/server_tests.rs b/crates/jp_mcp/src/server_tests.rs index 943f7d9af..9577e4ae4 100644 --- a/crates/jp_mcp/src/server_tests.rs +++ b/crates/jp_mcp/src/server_tests.rs @@ -23,6 +23,7 @@ impl BuiltinTool for EchoArguments { #[test] fn test_execution_outcome_id() { let completed = ExecutionOutcome::Completed { + native: None, id: "id1".to_string(), result: Ok(String::new()), }; @@ -43,6 +44,7 @@ fn test_execution_outcome_id() { #[test] fn test_execution_outcome_helper_methods() { let success = ExecutionOutcome::Completed { + native: None, id: "1".to_string(), result: Ok("output".to_string()), }; @@ -51,6 +53,7 @@ fn test_execution_outcome_helper_methods() { assert!(!success.is_cancelled()); let failure = ExecutionOutcome::Completed { + native: None, id: "2".to_string(), result: Err("error".to_string()), }; @@ -244,7 +247,7 @@ async fn execute_coerces_json_strings_before_calling_tool() { .await .unwrap(); - let ExecutionOutcome::Completed { id, result } = outcome else { + let ExecutionOutcome::Completed { id, result, .. } = outcome else { panic!("expected completed tool call"); }; assert_eq!(id, "call_1"); @@ -503,7 +506,9 @@ async fn test_execute_local_exposes_invocation_ids_in_context() { match outcome { ExecutionOutcome::Completed { - result: Ok(out), .. + native: None, + result: Ok(out), + .. } => assert!( out.contains("ws-abc-conv-xyz"), "expected workspace/conversation IDs in tool output, got: {out:?}" @@ -570,7 +575,9 @@ async fn test_execute_builtin_dispatches_on_source_name() { match outcome { ExecutionOutcome::Completed { - result: Ok(out), .. + native: None, + result: Ok(out), + .. } => assert_eq!(out, "reached"), other => panic!("expected completed success, got: {other:?}"), } diff --git a/crates/jp_tool/src/error.rs b/crates/jp_tool/src/error.rs index 0c0e48cf1..c5f64fdcf 100644 --- a/crates/jp_tool/src/error.rs +++ b/crates/jp_tool/src/error.rs @@ -7,6 +7,10 @@ /// [`Outcome::Error`]: crate::Outcome::Error #[derive(Debug, thiserror::Error)] pub enum Error { + /// A recognized tool-result envelope is malformed. + #[error("Malformed tool output: {0}")] + MalformedOutput(#[source] serde_json::Error), + #[error("Tool not found: {name}")] NotFound { name: String }, From bf6a2b0a805004b88c89c0e75761211e322a02c7 Mon Sep 17 00:00:00 2001 From: Jean Mertz Date: Sat, 12 Sep 2026 18:21:40 +0200 Subject: [PATCH 06/29] feat(mcp): Add Host tool-description metadata External MCP clients can read Host-supplied tool metadata through `tools/list`, including opaque result-size hints. Empty metadata stays omitted for ordinary JP calls, and incoming call metadata cannot change descriptions or execution policy. Complete RFD 109 Phase 4 with an independent JSON-RPC/SSE client and a scripted MCP Host. Verify inquiry re-execution, argument and result edits, recording barriers, concurrent callers, scoped cancellation, and Host loss. Exercise response resumption through `Last-Event-ID` without a second execution, and preserve large results and native upstream content through the HTTP path. No Claude Code process, subscription credentials, or transcript conversion is involved. Agent-specific integration remains RFD 110 work. Signed-off-by: Jean Mertz --- crates/jp_cli/src/cmd/query/tool/executor.rs | 1 + crates/jp_mcp/README.md | 23 + crates/jp_mcp/src/client_protocol_tests.rs | 106 ++- crates/jp_mcp/src/server/conformance_tests.rs | 898 ++++++++++++++++++ crates/jp_mcp/src/server/http.rs | 16 +- crates/jp_mcp/src/server/http_tests.rs | 2 + crates/jp_mcp/src/server/service.rs | 12 + crates/jp_mcp/src/server/service_tests.rs | 2 + 8 files changed, 1053 insertions(+), 7 deletions(-) create mode 100644 crates/jp_mcp/src/server/conformance_tests.rs diff --git a/crates/jp_cli/src/cmd/query/tool/executor.rs b/crates/jp_cli/src/cmd/query/tool/executor.rs index bc47ed96b..4001f028d 100644 --- a/crates/jp_cli/src/cmd/query/tool/executor.rs +++ b/crates/jp_cli/src/cmd/query/tool/executor.rs @@ -131,6 +131,7 @@ impl TerminalExecutorSource { definition: definition.clone(), config, access, + metadata: Map::new(), }) }) .collect(); diff --git a/crates/jp_mcp/README.md b/crates/jp_mcp/README.md index 430ccf615..52a90758e 100644 --- a/crates/jp_mcp/README.md +++ b/crates/jp_mcp/README.md @@ -25,6 +25,12 @@ It validates Host and supplied Origin headers. Its `connect` method creates an ordinary MCP client connection through that HTTP endpoint; the private Host channel remains separate. +The MCP Host can set `ConfiguredTool.metadata` before starting the service. +It is advertised as each tool's `_meta` object, including opaque result-size +hints for external clients. +Incoming call metadata remains correlation data; it cannot change these +descriptions, execution context, options, or answers. + Upstream stdio calls carry trusted execution context and accumulated answers under `_meta["computer.jp/tool"]` and `_meta["computer.jp/context"]`. Single text results are recognized as legacy `Outcome` envelopes when their @@ -42,3 +48,20 @@ session reinitialization. It does not resubmit a tool call on transport failure. The HTTP endpoint has no authentication; its loopback binding and header checks are not a claim that the caller is a particular local application. + +## Conformance checks + +The server tests include an independent JSON-RPC/SSE client, without using +`Endpoint::connect` for third-party calls. +A scripted MCP Host handles approval, input, result editing, and recording +through the private channel. +The tests exercise concurrent callers, scoped cancellation, failed recording, +large results, and resumption through `Last-Event-ID` after dropping an HTTP +response. +They also check that an upstream result's native content and metadata survive +the Host's text projection. + +These tests do not launch Claude Code, consume subscription quota, or guarantee +exactly-once execution after a process crash. +Agent-specific correlation and configuration belong to the integration consuming +this service. diff --git a/crates/jp_mcp/src/client_protocol_tests.rs b/crates/jp_mcp/src/client_protocol_tests.rs index d045c42da..17c123809 100644 --- a/crates/jp_mcp/src/client_protocol_tests.rs +++ b/crates/jp_mcp/src/client_protocol_tests.rs @@ -7,22 +7,34 @@ use indexmap::IndexMap; use jp_config::{ AppConfig, Config as _, conversation::tool::{PartialToolConfig, ToolConfig}, + providers::mcp::{McpProviderConfig, StdioConfig}, }; use jp_tool::{Outcome, Question, ToolDefinition, ToolDocs}; use rmcp::{ ErrorData, ServerHandler, - model::{CallToolRequestParams, CallToolResult, ServerCapabilities, ServerInfo}, + model::{ + CallToolRequestParams, CallToolResult, ListToolsResult, PaginatedRequestParams, + ServerCapabilities, ServerInfo, Tool, + }, service::{RequestContext, RoleServer, ServiceExt as _}, }; -use serde_json::{Value, json}; -use tokio::io::duplex; +use serde_json::{Map, Value, json}; +use tokio::{ + io::duplex, + time::{Duration, timeout}, +}; use tokio_util::sync::CancellationToken; use super::{Client, McpServerId}; use crate::{ Content, server::{ - ExecutionOutcome, InvocationContext, builtin::BuiltinExecutors, execute, text_result, + ExecutionOutcome, InvocationContext, + builtin::BuiltinExecutors, + execute, + http::Endpoint, + service::{Admission, ConfiguredTool, Interaction, ReleaseDecision, Service}, + text_result, tool_definitions, }, }; @@ -149,3 +161,89 @@ async fn upstream_receives_context_options_and_accumulated_answers() { client.shutdown().await; server.cancel().await.unwrap(); } + +struct NativeUpstream(Arc); + +impl ServerHandler for NativeUpstream { + fn get_info(&self) -> ServerInfo { + let mut info = ServerInfo::default(); + info.capabilities = ServerCapabilities::builder().enable_tools().build(); + info + } + + async fn list_tools( + &self, + _: Option, + _: RequestContext, + ) -> Result { + Ok(ListToolsResult { + tools: vec![Tool::new( + "native", + "Native result", + Arc::new( + json!({"type":"object","properties":{}}) + .as_object() + .unwrap() + .clone(), + ), + )], + ..Default::default() + }) + } + + async fn call_tool( + &self, + request: CallToolRequestParams, + _: RequestContext, + ) -> Result { + assert_eq!(request.name, "native"); + self.0.fetch_add(1, Ordering::SeqCst); + Ok(serde_json::from_value(json!({ + "content":[{"type":"text","text":"alpha"},{"type":"image","data":"AA==","mimeType":"image/png"},{"type":"resource","resource":{"uri":"fixture:///resource","text":"resource","mimeType":"text/plain"}}], + "isError":false,"structuredContent":{"answer":42},"_meta":{"fixture/source":"upstream"} + })).unwrap()) + } +} + +#[tokio::test] +async fn native_upstream_result_survives_host_projection_and_http_delivery() { + timeout(Duration::from_secs(10), async { + let count = Arc::new(AtomicUsize::new(0)); + // Use the stdio codec without starting an extra fixture executable. + let (client_transport, server_transport) = duplex(8192); + let handler = NativeUpstream(count.clone()); + let server = tokio::spawn(async move {handler.serve(server_transport).await.unwrap()}); + let running = ().serve(client_transport).await.unwrap(); + let server = server.await.unwrap(); + let upstream = Client::new(IndexMap::from_iter([("upstream".into(), McpProviderConfig::Stdio(StdioConfig { + command:"unused-fixture".into(), arguments:vec![], variables:vec![], checksum:None, optional:false, startup_timeout_secs:60, + }))])); + upstream.services.write().await.insert(McpServerId::new("upstream"), running); + let mut cfg = AppConfig::new_test(); + let partial: PartialToolConfig = serde_json::from_value(json!({"source":"mcp.upstream.native","run":"unattended","result":"unattended"})).unwrap(); + cfg.conversation.tools.insert("alias".into(), ToolConfig::from_partial(partial, vec![]).unwrap()); + let definitions = tool_definitions(cfg.conversation.tools.iter(), &upstream, None).await.unwrap(); + let configured = definitions.into_iter().map(|definition| ConfiguredTool {config:cfg.conversation.tools.get(&definition.name).unwrap(), definition, access:Ok(None), metadata:Map::new()}).collect(); + let (service, mut host) = Service::new(configured, upstream, BuiltinExecutors::new(), "/work".into(), InvocationContext::default()).unwrap(); + let endpoint = Endpoint::start(service).await.unwrap(); + let client = endpoint.connect().await.unwrap(); + let peer = client.peer().clone(); + let result = tokio::spawn(async move {peer.call_tool(CallToolRequestParams::new("alias")).await.unwrap()}); + let Interaction::Prepare {arguments,reply,..} = host.recv().await.unwrap().interaction else {panic!("expected preparation")}; + reply.send(Ok(Admission::Run {arguments})).unwrap(); + let Interaction::Release {reply,..} = host.recv().await.unwrap().interaction else {panic!("expected release")}; + reply.send(Ok(ReleaseDecision::Execute)).unwrap(); + let Interaction::Record {result:projected,reply,..} = host.recv().await.unwrap().interaction else {panic!("expected recording")}; + assert_eq!(projected, Ok("alpha\n\nresource".into())); + assert!(!reply.is_closed()); + reply.send(Ok(())).unwrap(); + assert_eq!(serde_json::to_value(result.await.unwrap()).unwrap(), json!({ + "content":[{"type":"text","text":"alpha"},{"type":"image","data":"AA==","mimeType":"image/png"},{"type":"resource","resource":{"uri":"fixture:///resource","text":"resource","mimeType":"text/plain"}}], + "isError":false,"structuredContent":{"answer":42},"_meta":{"fixture/source":"upstream"} + })); + assert_eq!(count.load(Ordering::SeqCst), 1); + client.cancel().await.unwrap(); + endpoint.shutdown().await.unwrap(); + server.cancel().await.unwrap(); + }).await.unwrap(); +} diff --git a/crates/jp_mcp/src/server/conformance_tests.rs b/crates/jp_mcp/src/server/conformance_tests.rs new file mode 100644 index 000000000..8ddc5aa8f --- /dev/null +++ b/crates/jp_mcp/src/server/conformance_tests.rs @@ -0,0 +1,898 @@ +//! Independent HTTP client fixtures for the MCP Host/third-party boundary. + +#[cfg(unix)] +use std::fs; +use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, +}; + +use async_trait::async_trait; +use camino_tempfile::{Utf8TempDir, tempdir}; +use indexmap::IndexMap; +use jp_config::{ + AppConfig, Config as _, + conversation::tool::{PartialToolConfig, ToolConfig}, +}; +use jp_tool::{Outcome, Question}; +use reqwest_mcp::{Client as HttpClient, Response, redirect::Policy}; +use rmcp::model::{CallToolRequestParams, Meta}; +use serde_json::{Map, Value, json}; +use tokio::{ + sync::mpsc::error::TryRecvError, + time::{Duration, timeout}, +}; + +use super::Endpoint; +use crate::{ + Client, Content, + server::{ + InvocationContext, + builtin::{BuiltinExecutors, BuiltinTool}, + service::{ + Admission, ConfiguredTool, HostError, HostReceiver, HostRequest, InputAnswer, + Interaction, ReleaseDecision, Service, + }, + tool_definitions, + }, +}; + +const PROTOCOL_VERSION: &str = "2025-11-25"; + +#[derive(Clone)] +struct ExternalClient { + http: HttpClient, + url: String, + session: String, +} + +impl ExternalClient { + async fn connect(url: &str) -> Self { + let http = HttpClient::builder() + .no_proxy() + .redirect(Policy::none()) + .timeout(Duration::from_secs(10)) + .build() + .unwrap(); + let response = http.post(url).header("accept", "application/json, text/event-stream").json(&json!({ + "jsonrpc":"2.0", "id":0, "method":"initialize", + "params":{"protocolVersion":PROTOCOL_VERSION,"capabilities":{},"clientInfo":{"name":"third-party-fixture","version":"1"}} + })).send().await.unwrap(); + assert_eq!(response.status().as_u16(), 200); + let session = response + .headers() + .get("mcp-session-id") + .unwrap() + .to_str() + .unwrap() + .to_owned(); + let initialized = SseReader::new(response).reply(0).await; + assert_eq!(initialized["result"]["protocolVersion"], PROTOCOL_VERSION); + let client = Self { + http, + url: url.into(), + session, + }; + client.notify("notifications/initialized", json!({})).await; + client + } + + async fn request(&self, id: u64, method: &str, params: Value) -> SseReader { + let response = self + .http + .post(&self.url) + .header("accept", "application/json, text/event-stream") + .header("mcp-session-id", &self.session) + .header("mcp-protocol-version", PROTOCOL_VERSION) + .json(&json!({"jsonrpc":"2.0","id":id,"method":method,"params":params})) + .send() + .await + .unwrap(); + assert_eq!(response.status().as_u16(), 200); + SseReader::new(response) + } + + async fn notify(&self, method: &str, params: Value) { + let response = self + .http + .post(&self.url) + .header("accept", "application/json, text/event-stream") + .header("mcp-session-id", &self.session) + .header("mcp-protocol-version", PROTOCOL_VERSION) + .json(&json!({"jsonrpc":"2.0","method":method,"params":params})) + .send() + .await + .unwrap(); + assert_eq!(response.status().as_u16(), 202); + assert_eq!(response.bytes().await.unwrap().as_ref(), b""); + } + + async fn resume(&self, event_id: &str) -> SseReader { + let response = self + .http + .get(&self.url) + .header("accept", "text/event-stream") + .header("mcp-session-id", &self.session) + .header("mcp-protocol-version", PROTOCOL_VERSION) + .header("last-event-id", event_id) + .send() + .await + .unwrap(); + assert_eq!(response.status().as_u16(), 200); + SseReader::new(response) + } + + async fn close(self) { + let response = self + .http + .delete(&self.url) + .header("mcp-session-id", &self.session) + .header("mcp-protocol-version", PROTOCOL_VERSION) + .send() + .await + .unwrap(); + assert_eq!(response.status().as_u16(), 202); + } +} + +struct SseReader { + response: Response, + buffer: Vec, +} + +impl SseReader { + fn new(response: Response) -> Self { + Self { + response, + buffer: Vec::new(), + } + } + + // Parse complete LF-framed events, including priming events with no data. + // Decoding after finding the delimiter handles split UTF-8 code points. + async fn frame(&mut self) -> (Option, Option) { + loop { + if let Some(offset) = self.buffer.windows(2).position(|bytes| bytes == b"\n\n") { + let bytes = self.buffer.drain(..offset + 2).collect::>(); + let frame = String::from_utf8(bytes).unwrap(); + let id = frame + .lines() + .find_map(|line| line.strip_prefix("id:")) + .map(|value| value.trim().to_owned()); + let data = frame + .lines() + .filter_map(|line| line.strip_prefix("data:")) + .map(str::trim_start) + .collect::>() + .join("\n"); + return ( + id, + (!data.is_empty()).then(|| serde_json::from_str(&data).unwrap()), + ); + } + let chunk = self + .response + .chunk() + .await + .unwrap() + .expect("SSE ended before the response"); + self.buffer.extend_from_slice(&chunk); + } + } + + async fn reply(mut self, id: u64) -> Value { + loop { + if let (_, Some(message)) = self.frame().await { + assert_eq!(message["jsonrpc"], "2.0"); + assert_eq!(message["id"], id); + return message; + } + } + } +} + +struct Fixture { + endpoint: Endpoint, + host: HostReceiver, + root: Utf8TempDir, +} + +impl Fixture { + async fn shutdown(self) { + self.endpoint.shutdown().await.unwrap(); + // The working directory must outlive service cleanup. + drop(self.root); + } +} + +async fn fixture(config: Value, builtins: BuiltinExecutors) -> Fixture { + let root = tempdir().unwrap(); + let mut cfg = AppConfig::new_test(); + let config: PartialToolConfig = serde_json::from_value(config).unwrap(); + cfg.conversation.tools.insert( + "probe".into(), + ToolConfig::from_partial(config, vec![]).unwrap(), + ); + let upstream = Client::default(); + let definitions = tool_definitions(cfg.conversation.tools.iter(), &upstream, None) + .await + .unwrap(); + let tools = definitions + .into_iter() + .map(|definition| ConfiguredTool { + config: cfg.conversation.tools.get(&definition.name).unwrap(), + definition, + access: Ok(None), + metadata: + json!({"anthropic/maxResultSizeChars":500_000,"fixture/hint":{"opaque":true}}) + .as_object() + .unwrap() + .clone(), + }) + .collect(); + let (service, host) = Service::new( + tools, + upstream, + builtins, + root.path().to_owned(), + InvocationContext { + workspace_id: "workspace-1".into(), + conversation_id: "conversation-1".into(), + }, + ) + .unwrap(); + Fixture { + endpoint: Endpoint::start(service).await.unwrap(), + host, + root, + } +} + +struct Ordinal(Arc); +#[async_trait] +impl BuiltinTool for Ordinal { + async fn execute(&self, _: &Value, _: &IndexMap) -> Outcome { + format!("execution-{}", self.0.fetch_add(1, Ordering::SeqCst) + 1).into() + } +} + +async fn counting_fixture() -> (Fixture, Arc) { + let count = Arc::new(AtomicUsize::new(0)); + let fixture = fixture( + json!({"source":"builtin", "summary":"Probe", "run":"ask", "result":"edit"}), + BuiltinExecutors::new().register("probe", Ordinal(count.clone())), + ) + .await; + (fixture, count) +} + +async fn next(host: &mut HostReceiver) -> HostRequest { + timeout(Duration::from_secs(5), host.recv()) + .await + .unwrap() + .expect("Host channel closed") +} + +#[tokio::test] +async fn external_discovery_preserves_host_metadata_without_executing() { + let (mut fixture, count) = counting_fixture().await; + let client = ExternalClient::connect(fixture.endpoint.url()).await; + let result = client + .request(1, "tools/list", json!({})) + .await + .reply(1) + .await; + assert_eq!( + result, + json!({"jsonrpc":"2.0","id":1,"result":{"tools":[{ + "name":"probe","description":"Probe","inputSchema":{"type":"object","properties":{},"required":[]}, + "_meta":{"anthropic/maxResultSizeChars":500_000,"fixture/hint":{"opaque":true}} + }]}}) + ); + assert_eq!(count.load(Ordering::SeqCst), 0); + assert!(matches!(fixture.host.try_recv(), Err(TryRecvError::Empty))); + client.close().await; + fixture.shutdown().await; +} + +#[tokio::test] +#[cfg(unix)] +#[expect( + clippy::too_many_lines, + reason = "Keep the inquiry, re-execution, and recording assertions in one linear scenario" +)] +async fn external_inquiry_reexecutes_with_host_answers_and_records_edited_output() { + let mut fixture = fixture(json!({ + "source":"local", "run":"ask", "result":"edit", "options":{"marker":"configured"}, + "parameters":{"value":{"type":"string","required":true}}, + "command":{"program":"sh","shell":false,"args":["-c", + "printf 'attempt\\n' >> attempts; if [ \"$1\" = null ]; then printf '%s' '{\"type\":\"needs_input\",\"question\":{\"id\":\"confirm\",\"text\":\"Continue?\",\"answer_type\":{\"type\":\"boolean\"}}}'; else printf '%s' \"$2\"; fi", + "fixture", "{{tool.answers.confirm}}", + "{{ {'value':tool.arguments.value,'answer':tool.answers.confirm,'action':context.action,'workspace':context.workspace_id,'conversation':context.conversation_id,'marker':tool.options.marker} | tojson }}" + ]} + }), BuiltinExecutors::new()).await; + let attacker = fixture.root.path().join("attacker"); + fs::create_dir(&attacker).unwrap(); + let client = ExternalClient::connect(fixture.endpoint.url()).await; + let meta = json!({ + "claudecode/toolUseId":"external-1", + "computer.jp/context":{"root":attacker,"workspace_id":"forged","conversation_id":"forged","action":"format_arguments"}, + "computer.jp/tool":{"answers":{"confirm":true},"options":{"marker":"forged"}}, + "anthropic/maxResultSizeChars":0 + }); + let response = client + .request( + 11, + "tools/call", + json!({"name":"probe","arguments":{"value":"requested"},"_meta":meta}), + ) + .await; + let pending = next(&mut fixture.host).await; + let id = pending.call.id; + assert_eq!(Value::Object(pending.call.request.correlation), meta); + let Interaction::Prepare { + arguments, reply, .. + } = pending.interaction + else { + panic!("expected preparation") + }; + assert_eq!( + arguments, + json!({"value":"requested"}).as_object().unwrap().clone() + ); + assert!(!fixture.root.path().join("attempts").exists()); + reply + .send(Ok(Admission::Run { + arguments: json!({"value":"edited"}).as_object().unwrap().clone(), + })) + .unwrap(); + let pending = next(&mut fixture.host).await; + assert_eq!(pending.call.id, id); + let Interaction::Release { reply, .. } = pending.interaction else { + panic!("expected release") + }; + reply.send(Ok(ReleaseDecision::Execute)).unwrap(); + let pending = next(&mut fixture.host).await; + assert_eq!(pending.call.id, id); + let Interaction::Input { + request, + answers, + reply, + .. + } = pending.interaction + else { + panic!("caller metadata must not answer the inquiry") + }; + assert_eq!(request.id.as_str(), "confirm"); + assert_eq!( + request.schema, + json!({"type":"boolean"}).as_object().unwrap().clone() + ); + assert!(answers.is_empty()); + assert_eq!( + fs::read_to_string(fixture.root.path().join("attempts")).unwrap(), + "attempt\n" + ); + reply.send(Ok(InputAnswer::Answer(json!(false)))).unwrap(); + let pending = next(&mut fixture.host).await; + assert_eq!(pending.call.id, id); + let Interaction::Review { result, reply, .. } = pending.interaction else { + panic!("expected review") + }; + let raw = result.unwrap(); + assert_eq!( + serde_json::from_str::(&raw).unwrap(), + json!({"value":"edited","answer":false,"action":"run","workspace":"workspace-1","conversation":"conversation-1","marker":"configured"}) + ); + assert_eq!( + fs::read_to_string(fixture.root.path().join("attempts")).unwrap(), + "attempt\nattempt\n" + ); + assert!(!attacker.join("attempts").exists()); + reply.send(Ok(Ok("approved output".into()))).unwrap(); + let pending = next(&mut fixture.host).await; + assert_eq!(pending.call.id, id); + assert_eq!( + pending.call.request.arguments, + json!({"value":"requested"}).as_object().unwrap().clone() + ); + let Interaction::Record { + arguments, + raw_result, + result, + reply, + } = pending.interaction + else { + panic!("expected record barrier") + }; + assert_eq!( + arguments, + json!({"value":"edited"}).as_object().unwrap().clone() + ); + assert_eq!(raw_result, Some(Ok(raw))); + assert_eq!(result, Ok("approved output".into())); + let mut returned = tokio::spawn(response.reply(11)); + assert!( + timeout(Duration::from_millis(40), &mut returned) + .await + .is_err() + ); + assert!(!reply.is_closed()); + fs::write(fixture.root.path().join("record.json"), serde_json::to_vec(&json!({"requested":pending.call.request.arguments,"executed":arguments,"result":result.unwrap()})).unwrap()).unwrap(); + reply.send(Ok(())).unwrap(); + assert_eq!( + returned.await.unwrap(), + json!({"jsonrpc":"2.0","id":11,"result":{"content":[{"type":"text","text":"approved output"}],"isError":false}}) + ); + let stored: Value = + serde_json::from_slice(&fs::read(fixture.root.path().join("record.json")).unwrap()) + .unwrap(); + assert_eq!( + stored, + json!({"requested":{"value":"requested"},"executed":{"value":"edited"},"result":"approved output"}) + ); + let listing = client + .request(12, "tools/list", json!({})) + .await + .reply(12) + .await; + assert_eq!( + listing["result"]["tools"][0]["_meta"], + json!({"anthropic/maxResultSizeChars":500_000,"fixture/hint":{"opaque":true}}) + ); + client.close().await; + fixture.shutdown().await; +} + +#[tokio::test] +#[expect( + clippy::too_many_lines, + reason = "The interleaved calls and their replies are asserted in protocol order" +)] +async fn host_and_external_client_share_handlers_without_sharing_call_identity() { + let (mut fixture, count) = counting_fixture().await; + let host_client = fixture.endpoint.connect().await.unwrap(); + let external = ExternalClient::connect(fixture.endpoint.url()).await; + let mut params = CallToolRequestParams::new("probe"); + params.arguments = Some(Map::new()); + params.meta = Some(Meta( + json!({"fixture/call":"host"}).as_object().unwrap().clone(), + )); + let peer = host_client.peer().clone(); + let host_result = tokio::spawn(async move { peer.call_tool(params).await.unwrap() }); + let first = next(&mut fixture.host).await; + let first_id = first.call.id; + assert_eq!(first.call.request.correlation["fixture/call"], "host"); + let Interaction::Prepare { + reply: first_reply, .. + } = first.interaction + else { + panic!("expected first preparation") + }; + let external_response = external + .request( + 17, + "tools/call", + json!({"name":"probe","arguments":{},"_meta":{"fixture/call":"external"}}), + ) + .await; + let second = next(&mut fixture.host).await; + let second_id = second.call.id; + assert_ne!(first_id, second_id); + assert_eq!( + second.call.request.correlation, + json!({"fixture/call":"external"}) + .as_object() + .unwrap() + .clone() + ); + assert_eq!(first.call.request.arguments, second.call.request.arguments); + assert_eq!(count.load(Ordering::SeqCst), 0); + let Interaction::Prepare { + arguments, reply, .. + } = second.interaction + else { + panic!("expected second preparation while first waits") + }; + reply.send(Ok(Admission::Run { arguments })).unwrap(); + let release = next(&mut fixture.host).await; + assert_eq!(release.call.id, second_id); + let Interaction::Release { reply, .. } = release.interaction else { + panic!("expected second release") + }; + reply.send(Ok(ReleaseDecision::Execute)).unwrap(); + let review = next(&mut fixture.host).await; + assert_eq!(review.call.id, second_id); + let Interaction::Review { result, reply, .. } = review.interaction else { + panic!("expected second review") + }; + assert_eq!(result, Ok("execution-1".into())); + reply.send(Ok(Ok("external result".into()))).unwrap(); + let record = next(&mut fixture.host).await; + assert_eq!(record.call.id, second_id); + let Interaction::Record { reply, .. } = record.interaction else { + panic!("expected second record") + }; + reply.send(Ok(())).unwrap(); + assert_eq!( + external_response.reply(17).await, + json!({"jsonrpc":"2.0","id":17,"result":{"content":[{"type":"text","text":"external result"}],"isError":false}}) + ); + assert!(!host_result.is_finished()); + assert!(!first_reply.is_closed()); + first_reply + .send(Ok(Admission::Run { + arguments: Map::new(), + })) + .unwrap(); + let release = next(&mut fixture.host).await; + assert_eq!(release.call.id, first_id); + let Interaction::Release { reply, .. } = release.interaction else { + panic!("expected first release") + }; + reply.send(Ok(ReleaseDecision::Execute)).unwrap(); + let review = next(&mut fixture.host).await; + assert_eq!(review.call.id, first_id); + let Interaction::Review { result, reply, .. } = review.interaction else { + panic!("expected first review") + }; + assert_eq!(result, Ok("execution-2".into())); + reply.send(Ok(Ok("host result".into()))).unwrap(); + let record = next(&mut fixture.host).await; + assert_eq!(record.call.id, first_id); + let Interaction::Record { reply, .. } = record.interaction else { + panic!("expected first record") + }; + reply.send(Ok(())).unwrap(); + assert_eq!(host_result.await.unwrap().content, vec![Content::text( + "host result" + )]); + assert_eq!(count.load(Ordering::SeqCst), 2); + external.close().await; + host_client.cancel().await.unwrap(); + fixture.shutdown().await; +} + +#[tokio::test] +async fn disconnected_response_resumes_without_reexecuting_tool() { + let (mut fixture, count) = counting_fixture().await; + let external = ExternalClient::connect(fixture.endpoint.url()).await; + let mut response = external + .request(21, "tools/call", json!({"name":"probe","arguments":{}})) + .await; + let (event_id, data) = response.frame().await; + assert_eq!(data, None); + let event_id = event_id.expect("request stream must provide a resumption cursor"); + let prepared = next(&mut fixture.host).await; + let id = prepared.call.id; + let Interaction::Prepare { + arguments, reply, .. + } = prepared.interaction + else { + panic!("expected preparation") + }; + reply.send(Ok(Admission::Run { arguments })).unwrap(); + let Interaction::Release { reply, .. } = next(&mut fixture.host).await.interaction else { + panic!("expected release") + }; + reply.send(Ok(ReleaseDecision::Execute)).unwrap(); + let Interaction::Review { result, reply, .. } = next(&mut fixture.host).await.interaction + else { + panic!("expected review") + }; + assert_eq!(result, Ok("execution-1".into())); + reply.send(Ok(Ok("recorded result".into()))).unwrap(); + let record = next(&mut fixture.host).await; + assert_eq!(record.call.id, id); + let Interaction::Record { reply, .. } = record.interaction else { + panic!("expected recording") + }; + drop(response); + let resumed = external.resume(&event_id).await; + assert!( + !reply.is_closed(), + "HTTP disconnection must not cancel the invocation" + ); + reply.send(Ok(())).unwrap(); + assert_eq!( + resumed.reply(21).await, + json!({"jsonrpc":"2.0","id":21,"result":{"content":[{"type":"text","text":"recorded result"}],"isError":false}}) + ); + assert_eq!(count.load(Ordering::SeqCst), 1); + assert!(matches!(fixture.host.try_recv(), Err(TryRecvError::Empty))); + external.close().await; + fixture.shutdown().await; +} + +#[tokio::test] +async fn failed_recording_returns_error_instead_of_the_tool_result() { + let (mut fixture, count) = counting_fixture().await; + let external = ExternalClient::connect(fixture.endpoint.url()).await; + let response = external + .request(23, "tools/call", json!({"name":"probe","arguments":{}})) + .await; + let Interaction::Prepare { + arguments, reply, .. + } = next(&mut fixture.host).await.interaction + else { + panic!("expected preparation") + }; + reply.send(Ok(Admission::Run { arguments })).unwrap(); + let Interaction::Release { reply, .. } = next(&mut fixture.host).await.interaction else { + panic!("expected release") + }; + reply.send(Ok(ReleaseDecision::Execute)).unwrap(); + let Interaction::Review { reply, .. } = next(&mut fixture.host).await.interaction else { + panic!("expected review") + }; + reply.send(Ok(Ok("approved result".into()))).unwrap(); + let Interaction::Record { reply, .. } = next(&mut fixture.host).await.interaction else { + panic!("expected recording") + }; + reply.send(Err(HostError("disk full".into()))).unwrap(); + assert_eq!( + response.reply(23).await, + json!({"jsonrpc":"2.0","id":23,"error":{"code":-32603,"message":"MCP Host operation failed: disk full"}}) + ); + assert_eq!(count.load(Ordering::SeqCst), 1); + external.close().await; + fixture.shutdown().await; +} + +#[tokio::test] +async fn host_loss_closes_an_outstanding_approval_without_execution() { + let (mut fixture, count) = counting_fixture().await; + let external = ExternalClient::connect(fixture.endpoint.url()).await; + let response = external + .request(25, "tools/call", json!({"name":"probe","arguments":{}})) + .await; + let Interaction::Prepare { mut reply, .. } = next(&mut fixture.host).await.interaction else { + panic!("expected preparation") + }; + drop(fixture.host); + timeout(Duration::from_secs(5), reply.closed()) + .await + .unwrap(); + assert!( + reply + .send(Ok(Admission::Run { + arguments: Map::new() + })) + .is_err() + ); + assert_eq!( + response.reply(25).await, + json!({"jsonrpc":"2.0","id":25,"error":{"code":-32603,"message":"MCP Host disconnected before completing the interaction"}}) + ); + assert_eq!(count.load(Ordering::SeqCst), 0); + external.close().await; + fixture.endpoint.shutdown().await.unwrap(); +} + +#[tokio::test] +async fn malformed_arguments_are_rejected_before_approval() { + let count = Arc::new(AtomicUsize::new(0)); + let mut fixture = fixture(json!({"source":"builtin", "run":"ask", "parameters":{"value":{"type":"integer","required":true}}}), BuiltinExecutors::new().register("probe", Ordinal(count.clone()))).await; + let external = ExternalClient::connect(fixture.endpoint.url()).await; + let response = external + .request( + 27, + "tools/call", + json!({"name":"probe","arguments":{"value":"not an integer"}}), + ) + .await + .reply(27) + .await; + assert_eq!( + response, + json!({"jsonrpc":"2.0","id":27,"error":{"code":-32602,"message":"Invalid tool argument at `value`"}}) + ); + assert_eq!(count.load(Ordering::SeqCst), 0); + assert!(matches!(fixture.host.try_recv(), Err(TryRecvError::Empty))); + external.close().await; + fixture.shutdown().await; +} + +struct Inquiring(Arc); +#[async_trait] +impl BuiltinTool for Inquiring { + async fn execute(&self, _: &Value, answers: &IndexMap) -> Outcome { + self.0.fetch_add(1, Ordering::SeqCst); + if answers.get("confirm") == Some(&json!(true)) { + return "answered".into(); + } + Question::boolean("confirm", "Continue?").unwrap().into() + } +} + +async fn release(host: &mut HostReceiver) { + let request = next(host).await; + let id = request.call.id; + let Interaction::Prepare { + arguments, reply, .. + } = request.interaction + else { + panic!("expected preparation") + }; + reply.send(Ok(Admission::Run { arguments })).unwrap(); + let request = next(host).await; + assert_eq!(request.call.id, id); + let Interaction::Release { reply, .. } = request.interaction else { + panic!("expected release") + }; + reply.send(Ok(ReleaseDecision::Execute)).unwrap(); +} + +#[tokio::test] +async fn cancellation_is_scoped_to_the_requesting_client_session() { + let count = Arc::new(AtomicUsize::new(0)); + let mut fixture = fixture( + json!({"source":"builtin", "run":"ask", "result":"unattended"}), + BuiltinExecutors::new().register("probe", Inquiring(count.clone())), + ) + .await; + let first = ExternalClient::connect(fixture.endpoint.url()).await; + let second = ExternalClient::connect(fixture.endpoint.url()).await; + assert_ne!(first.session, second.session); + let first_response = first + .request(31, "tools/call", json!({"name":"probe","arguments":{}})) + .await; + release(&mut fixture.host).await; + let first_input = next(&mut fixture.host).await; + let Interaction::Input { + reply: mut first_reply, + .. + } = first_input.interaction + else { + panic!("expected first input") + }; + let second_response = second + .request(31, "tools/call", json!({"name":"probe","arguments":{}})) + .await; + release(&mut fixture.host).await; + let second_input = next(&mut fixture.host).await; + assert_ne!(first_input.call.id, second_input.call.id); + let Interaction::Input { + reply: second_reply, + .. + } = second_input.interaction + else { + panic!("expected second input") + }; + assert_eq!(count.load(Ordering::SeqCst), 2); + first + .notify( + "notifications/cancelled", + json!({"requestId":31,"reason":"fixture cancellation"}), + ) + .await; + timeout(Duration::from_secs(5), first_reply.closed()) + .await + .unwrap(); + assert!( + first_reply + .send(Ok(InputAnswer::Answer(json!(true)))) + .is_err() + ); + assert!(!second_reply.is_closed()); + second_reply + .send(Ok(InputAnswer::Answer(json!(true)))) + .unwrap(); + let record = next(&mut fixture.host).await; + assert_eq!(record.call.id, second_input.call.id); + let Interaction::Record { result, reply, .. } = record.interaction else { + panic!("expected only the second result") + }; + assert_eq!(result, Ok("answered".into())); + reply.send(Ok(())).unwrap(); + assert_eq!( + second_response.reply(31).await, + json!({"jsonrpc":"2.0","id":31,"result":{"content":[{"type":"text","text":"answered"}],"isError":false}}) + ); + assert_eq!(count.load(Ordering::SeqCst), 3); + drop(first_response); + first.close().await; + second.close().await; + fixture.shutdown().await; +} + +struct LargeResult(String); +#[async_trait] +impl BuiltinTool for LargeResult { + async fn execute(&self, _: &Value, _: &IndexMap) -> Outcome { + self.0.clone().into() + } +} + +#[tokio::test] +async fn large_result_reaches_external_client_byte_for_byte() { + // A repetitive fixed payload avoids a large checked-in fixture. Comparing + // the entire value catches truncation, duplication, and newline changes. + let payload = "line\n".repeat(48_000); + assert_eq!(payload.len(), 240_000); + let mut fixture = fixture( + json!({"source":"builtin", "run":"ask", "result":"unattended"}), + BuiltinExecutors::new().register("probe", LargeResult(payload.clone())), + ) + .await; + let external = ExternalClient::connect(fixture.endpoint.url()).await; + let response = external + .request(33, "tools/call", json!({"name":"probe","arguments":{}})) + .await; + release(&mut fixture.host).await; + let Interaction::Record { result, reply, .. } = next(&mut fixture.host).await.interaction + else { + panic!("expected record") + }; + assert_eq!(result, Ok(payload.clone())); + reply.send(Ok(())).unwrap(); + let result = response.reply(33).await; + assert_eq!( + result, + json!({"jsonrpc":"2.0","id":33,"result":{"content":[{"type":"text","text":payload}],"isError":false}}) + ); + external.close().await; + fixture.shutdown().await; +} + +#[tokio::test] +async fn external_denial_never_executes_the_tool() { + let (mut fixture, count) = counting_fixture().await; + let external = ExternalClient::connect(fixture.endpoint.url()).await; + let response = external + .request(35, "tools/call", json!({"name":"probe","arguments":{}})) + .await; + let Interaction::Prepare { reply, .. } = next(&mut fixture.host).await.interaction else { + panic!("expected approval") + }; + reply + .send(Ok(Admission::Skip { + reason: "denied by Host".into(), + })) + .unwrap(); + let Interaction::Record { + raw_result, reply, .. + } = next(&mut fixture.host).await.interaction + else { + panic!("expected recording without release") + }; + assert_eq!(raw_result, None); + reply.send(Ok(())).unwrap(); + assert_eq!( + response.reply(35).await, + json!({"jsonrpc":"2.0","id":35,"result":{"content":[{"type":"text","text":"denied by Host"}],"isError":false}}) + ); + assert_eq!(count.load(Ordering::SeqCst), 0); + external.close().await; + fixture.shutdown().await; +} + +#[tokio::test] +async fn edited_arguments_are_checked_before_execution_release() { + let count = Arc::new(AtomicUsize::new(0)); + let mut fixture = fixture(json!({"source":"builtin", "run":"ask", "parameters":{"value":{"type":"integer","required":true}}}), BuiltinExecutors::new().register("probe", Ordinal(count.clone()))).await; + let external = ExternalClient::connect(fixture.endpoint.url()).await; + let response = external + .request( + 37, + "tools/call", + json!({"name":"probe","arguments":{"value":1}}), + ) + .await; + let Interaction::Prepare { reply, .. } = next(&mut fixture.host).await.interaction else { + panic!("expected valid initial arguments") + }; + reply + .send(Ok(Admission::Run { + arguments: json!({"value":"invalid edit"}).as_object().unwrap().clone(), + })) + .unwrap(); + assert_eq!( + response.reply(37).await, + json!({"jsonrpc":"2.0","id":37,"error":{"code":-32602,"message":"Invalid tool argument at `value`"}}) + ); + assert_eq!(count.load(Ordering::SeqCst), 0); + assert!(matches!(fixture.host.try_recv(), Err(TryRecvError::Empty))); + external.close().await; + fixture.shutdown().await; +} diff --git a/crates/jp_mcp/src/server/http.rs b/crates/jp_mcp/src/server/http.rs index d12e3101d..22d79ffa0 100644 --- a/crates/jp_mcp/src/server/http.rs +++ b/crates/jp_mcp/src/server/http.rs @@ -20,7 +20,7 @@ use reqwest::{Client as HttpClient, redirect::Policy}; use rmcp::{ ErrorData, ServerHandler, ServiceExt as _, model::{ - CallToolRequestParams, CallToolResult, ListToolsResult, PaginatedRequestParams, + CallToolRequestParams, CallToolResult, ListToolsResult, Meta, PaginatedRequestParams, ServerCapabilities, ServerInfo, Tool, }, service::{RequestContext, RoleClient, RoleServer, RunningService}, @@ -180,7 +180,7 @@ impl ServerHandler for Handler { .service .definitions() .map(|definition| { - Tool::new( + let mut tool = Tool::new( definition.name.clone(), definition .docs @@ -194,7 +194,13 @@ impl ServerHandler for Handler { .cloned() .unwrap_or_default(), ), - ) + ); + tool.meta = self + .service + .tool_metadata(&definition.name) + .cloned() + .map(Meta); + tool }) .collect(); Ok(ListToolsResult { @@ -252,3 +258,7 @@ fn protocol_error(error: ServiceError) -> ErrorData { #[cfg(test)] #[path = "http_tests.rs"] mod tests; + +#[cfg(test)] +#[path = "conformance_tests.rs"] +mod conformance_tests; diff --git a/crates/jp_mcp/src/server/http_tests.rs b/crates/jp_mcp/src/server/http_tests.rs index 978c7a270..23872dd09 100644 --- a/crates/jp_mcp/src/server/http_tests.rs +++ b/crates/jp_mcp/src/server/http_tests.rs @@ -54,6 +54,7 @@ fn setup() -> (Service, HostReceiver, Arc) { }, config: cfg.conversation.tools.get("count").unwrap(), access: Ok(None), + metadata: Map::new(), }], Client::default(), BuiltinExecutors::new().register("count", Count(count.clone())), @@ -70,6 +71,7 @@ async fn http_call_waits_for_host_release_and_records_edited_result() { let endpoint = Endpoint::start(service).await.unwrap(); let client = endpoint.connect().await.unwrap(); let tools = client.peer().list_all_tools().await.unwrap(); + assert_eq!(tools[0].meta, None); assert_eq!( tools .iter() diff --git a/crates/jp_mcp/src/server/service.rs b/crates/jp_mcp/src/server/service.rs index 0b0c3772c..9e4e05790 100644 --- a/crates/jp_mcp/src/server/service.rs +++ b/crates/jp_mcp/src/server/service.rs @@ -41,6 +41,9 @@ pub struct ConfiguredTool { /// Compiled access grants supplied by the MCP Host, never by an MCP caller. /// A compilation failure is delivered as a tool error without execution. pub access: Result, String>, + /// Opaque Host-supplied metadata advertised on this tool's MCP description. + /// It does not change execution policy or interpret vendor-specific hints. + pub metadata: Map, } /// An invocation received by the MCP handler. @@ -400,6 +403,15 @@ impl Service { self.inner.tools.values().map(|tool| &tool.definition) } + /// Metadata supplied by the Host; empty maps are omitted from descriptions. + pub(super) fn tool_metadata(&self, name: &str) -> Option<&Map> { + self.inner + .tools + .get(name) + .map(|tool| &tool.metadata) + .filter(|meta| !meta.is_empty()) + } + /// Subscribe to stderr progress without slowing execution or Host replies. /// A lagging subscriber receives the broadcast channel's lag error. #[must_use] diff --git a/crates/jp_mcp/src/server/service_tests.rs b/crates/jp_mcp/src/server/service_tests.rs index 7535eac30..6705b6e65 100644 --- a/crates/jp_mcp/src/server/service_tests.rs +++ b/crates/jp_mcp/src/server/service_tests.rs @@ -60,6 +60,7 @@ fn fixture(run: &str, result: &str) -> (Service, HostReceiver, Arc) }, config: config.conversation.tools.get("count").unwrap(), access: Ok(None), + metadata: Map::new(), }; let (service, host) = Service::new( vec![tool], @@ -437,6 +438,7 @@ async fn local_inquiry_exits_and_runs_a_new_process_with_the_answer() { }, config: cfg.conversation.tools.get("local").unwrap(), access: Ok(None), + metadata: Map::new(), }; let (service, mut host) = Service::new( vec![tool], From 579ae382a10a411dd12c2d45e6a5d2b9f5c62850 Mon Sep 17 00:00:00 2001 From: Jean Mertz Date: Sat, 12 Sep 2026 21:58:21 +0200 Subject: [PATCH 07/29] fix(mcp, rfd): Preserve typed tool results Tool results retain ordered content, resources, annotations, structured data, and error details through Host review and recording. Unchanged reviews preserve that data; text edits replace the delivered content. Existing conversation files and terminal output keep their text/error projection. Use typed execution, formatter, and recording errors, validated question IDs, and distinct Host correlation keys. Retain original error sources until rendering diagnostics, and preserve question context when decoding legacy tool outcomes. Remove Reqwest 0.13 and use the workspace's 0.12 client through rmcp's HTTP transport interface. Session handling and SSE resumption remain in rmcp. Mark RFD 109 Implemented and clear its satisfied dependency from RFD 110. Signed-off-by: Jean Mertz --- Cargo.lock | 1 + .../jp_cli/src/cmd/query/tool/coordinator.rs | 11 +- crates/jp_cli/src/cmd/query/tool/executor.rs | 150 ++++++----- crates/jp_cli/src/error.rs | 3 +- crates/jp_cli/src/render/tool.rs | 2 +- crates/jp_llm/src/tool.rs | 37 ++- crates/jp_llm/src/tool_error.rs | 74 +++++ crates/jp_mcp/Cargo.toml | 4 +- crates/jp_mcp/README.md | 11 +- crates/jp_mcp/src/client_protocol_tests.rs | 25 +- crates/jp_mcp/src/server.rs | 208 +++++++-------- crates/jp_mcp/src/server/conformance_tests.rs | 48 ++-- crates/jp_mcp/src/server/http.rs | 25 +- crates/jp_mcp/src/server/http/client.rs | 236 ---------------- crates/jp_mcp/src/server/http/client_tests.rs | 105 -------- crates/jp_mcp/src/server/http_client.rs | 200 ++++++++++++++ crates/jp_mcp/src/server/http_client_tests.rs | 156 +++++++++++ crates/jp_mcp/src/server/http_tests.rs | 9 +- crates/jp_mcp/src/server/result.rs | 252 ++++++++++++++++++ crates/jp_mcp/src/server/result_tests.rs | 88 ++++++ crates/jp_mcp/src/server/service.rs | 151 +++++++---- crates/jp_mcp/src/server/service_tests.rs | 52 ++-- crates/jp_mcp/src/server/upstream.rs | 28 +- crates/jp_mcp/src/server_tests.rs | 55 ++-- crates/jp_tool/src/content.rs | 235 ++++++++++++---- crates/jp_tool/src/content_tests.rs | 44 ++- crates/jp_tool/src/definition.rs | 16 +- crates/jp_tool/src/lib.rs | 24 +- docs/rfd/109-in-process-jp-mcp-server.md | 3 +- ...-anthropic-subscription-queries-via-acp.md | 1 - 30 files changed, 1458 insertions(+), 796 deletions(-) create mode 100644 crates/jp_llm/src/tool_error.rs delete mode 100644 crates/jp_mcp/src/server/http/client.rs delete mode 100644 crates/jp_mcp/src/server/http/client_tests.rs create mode 100644 crates/jp_mcp/src/server/http_client.rs create mode 100644 crates/jp_mcp/src/server/http_client_tests.rs create mode 100644 crates/jp_mcp/src/server/result.rs create mode 100644 crates/jp_mcp/src/server/result_tests.rs diff --git a/Cargo.lock b/Cargo.lock index d3d0a096b..8392e402e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2613,6 +2613,7 @@ dependencies = [ "assert_matches", "async-trait", "axum", + "base64", "camino", "camino-tempfile", "futures", diff --git a/crates/jp_cli/src/cmd/query/tool/coordinator.rs b/crates/jp_cli/src/cmd/query/tool/coordinator.rs index a00732a46..2110449e9 100644 --- a/crates/jp_cli/src/cmd/query/tool/coordinator.rs +++ b/crates/jp_cli/src/cmd/query/tool/coordinator.rs @@ -101,7 +101,7 @@ use jp_conversation::{ }; use jp_editor::EditorBackend; use jp_inquire::{ReplyEditMode, prompt::PromptBackend}; -use jp_llm::tool::{Executor, ExecutorResult, ExecutorSource, PermissionInfo}; +use jp_llm::tool::{Executor, ExecutorError, ExecutorResult, ExecutorSource, PermissionInfo}; use jp_mcp::{Client, server::StderrSink}; use jp_printer::Printer; use jp_tool::{AnswerType, Question}; @@ -576,7 +576,7 @@ impl ToolCoordinator { self.set_tool_state(executor.tool_id(), ToolCallState::Completed); return ToolCallDecision::Failed(ToolCallResponse { id: executor.tool_id().into(), - result: Err(error), + result: Err(error.to_string()), }); } } @@ -640,7 +640,7 @@ impl ToolCoordinator { self.set_tool_state(executor.tool_id(), ToolCallState::Completed); return ToolCallDecision::Failed(ToolCallResponse { id: executor.tool_id().into(), - result: Err(error), + result: Err(error.to_string()), }); } @@ -709,7 +709,8 @@ impl ToolCoordinator { executor .formatted_arguments() .cloned() - .unwrap_or_else(|| Ok(String::new())), + .unwrap_or_else(|| Ok(String::new())) + .map_err(|error| error.to_string()), ); } self.render_approved_tool(name, executor.arguments(), renderer) @@ -720,7 +721,7 @@ impl ToolCoordinator { pub async fn acknowledge_responses( &self, responses: Vec, - ) -> Result<(), String> { + ) -> Result<(), ExecutorError> { for response in responses { self.executor_source.acknowledge(response).await?; } diff --git a/crates/jp_cli/src/cmd/query/tool/executor.rs b/crates/jp_cli/src/cmd/query/tool/executor.rs index 4001f028d..c67d51986 100644 --- a/crates/jp_cli/src/cmd/query/tool/executor.rs +++ b/crates/jp_cli/src/cmd/query/tool/executor.rs @@ -14,21 +14,23 @@ use futures::future::BoxFuture; use indexmap::IndexMap; use jp_config::conversation::tool::{RunMode, ToolConfigWithDefaults, ToolsConfig}; use jp_conversation::event::{InquirySource, ToolCallRequest, ToolCallResponse}; -use jp_llm::tool::{Executor, ExecutorResult, ExecutorSource, PermissionInfo}; +use jp_llm::tool::{Executor, ExecutorError, ExecutorResult, ExecutorSource, PermissionInfo}; use jp_mcp::{ CallToolResult, Client, server::{ InvocationContext, StderrSink, builtin::BuiltinExecutors, http::{Endpoint, EndpointError}, + result::{from_mcp, to_legacy}, service::{ - Admission, ConfiguredTool, HostReply, HostRequest, InputAnswer, Interaction, - InvocationId, ReleaseDecision, Service, + AccessPolicyError, Admission, ConfiguredTool, FormatterError, HostReply, HostRequest, + InputAnswer, Interaction, InvocationId, ReleaseDecision, Service, }, - text_result, }, }; -use jp_tool::{AnswerType, ContentBlock, InputRequest, Question, ToolDefinition}; +use jp_tool::{ + AnswerType, ContentBlock, InputRequest, Question, QuestionId, ToolDefinition, ToolResult, +}; use rand::random; use rmcp::{ Peer, ServiceError as McpCallError, @@ -57,7 +59,12 @@ struct Route { sender: mpsc::Sender, } -type Routes = Arc>>; +/// Private correlation value generated by the Host, not a provider tool-call +/// ID. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct HostCallKey(String); + +type Routes = Arc>>; type Sinks = Arc>>; fn locked(value: &SyncMutex) -> MutexGuard<'_, T> { @@ -122,10 +129,10 @@ impl TerminalExecutorSource { let config = tools.get(&definition.name)?; let access = compile_tool_policy(config.access(), &root, &approvals).map_err(|error| { - format!( - "invalid access policy for tool '{}': {error}", - definition.name - ) + AccessPolicyError { + tool: definition.name.clone(), + source: Arc::new(error), + } }); Some(ConfiguredTool { definition: definition.clone(), @@ -150,9 +157,10 @@ impl TerminalExecutorSource { .request .correlation .get(CORRELATION_KEY) - .and_then(Value::as_str); + .and_then(Value::as_str) + .map(|value| HostCallKey(value.to_owned())); let mut routes = locked(&router_routes); - key.and_then(|key| routes.get_mut(key)).and_then(|route| { + key.and_then(|key| routes.get_mut(&key)).and_then(|route| { // Correlation associates an existing Host call, not // authority from caller-supplied execution metadata. if route.request.name != request.call.request.name @@ -218,7 +226,7 @@ impl ExecutorSource for TerminalExecutorSource { ) -> Option> { self.definitions.get(&request.name)?; let (sender, receiver) = mpsc::channel(8); - let key = format!("{:032x}", random::()); + let key = HostCallKey(format!("{:032x}", random::())); locked(&self.routes).insert(key.clone(), Route { request: request.clone(), invocation: None, @@ -248,7 +256,7 @@ impl ExecutorSource for TerminalExecutorSource { })) } - fn acknowledge(&self, response: ToolCallResponse) -> BoxFuture<'_, Result<(), String>> { + fn acknowledge(&self, response: ToolCallResponse) -> BoxFuture<'_, Result<(), ExecutorError>> { Box::pin(async move { let call = locked(&self.calls).remove(&response.id); let Some(call) = call else { @@ -268,10 +276,10 @@ impl ExecutorSource for TerminalExecutorSource { struct PendingCall { receiver: mpsc::Receiver, task: Option>>, - input: Option<(String, Reply)>, + input: Option<(QuestionId, Reply)>, prepare: Option>, release: Option>, - review: Option>, + review: Option<(ToolResult, Reply)>, record: Option>, id: Option, finished: bool, @@ -287,68 +295,83 @@ enum Received { } impl PendingCall { - async fn next(&mut self) -> Result { - let task = self.task.as_mut().ok_or("MCP call has not started")?; + async fn next(&mut self) -> Result { + let task = self.task.as_mut().ok_or(ExecutorError::NotStarted)?; tokio::select! { request = self.receiver.recv() => { - let request = request.ok_or("MCP Host interaction channel closed")?; + let request = request.ok_or(ExecutorError::HostDisconnected)?; self.id = Some(request.call.id); Ok(Received::Interaction(request.interaction)) } result = task => { self.task = None; self.finished = true; - let result = result.map_err(|error| error.to_string())?; - Ok(Received::Finished(result.map_or_else(|error| Err(error.to_string()), |result| text_result(&result)))) + let result = result?; + let result = result.map_err(|error| ExecutorError::Transport(Box::new(error)))?; + Ok(Received::Finished(to_legacy(&from_mcp(result).map_err(ExecutorError::MalformedResult)?))) } } } - async fn acknowledge(&mut self, result: TextResult) -> Result<(), String> { + async fn acknowledge(&mut self, result: TextResult) -> Result<(), ExecutorError> { if self.finished { return Ok(()); } if let Some(reply) = self.prepare.take() { drop(reply.send(Ok(Admission::Complete { - result: result.clone(), + result: result.clone().into(), }))); } else if let Some(reply) = self.release.take() { drop(reply.send(Ok(ReleaseDecision::Complete { - result: result.clone(), + result: result.clone().into(), }))); } else if let Some((_, reply)) = self.input.take() { drop(reply.send(Ok(InputAnswer::Complete { - result: result.clone(), + result: result.clone().into(), }))); - } else if let Some(reply) = self.review.take() { - drop(reply.send(Ok(result.clone()))); + } else if let Some((original, reply)) = self.review.take() { + let approved = if to_legacy(&original) == result { + original + } else { + result.clone().into() + }; + drop(reply.send(Ok(approved))); } if let Some(reply) = self.record.take() { drop(reply.send(Ok(()))); } loop { match self.next().await? { - Received::Interaction(Interaction::Review { reply, .. }) => { - drop(reply.send(Ok(result.clone()))); + Received::Interaction(Interaction::Review { + result: original, + reply, + .. + }) => { + let approved = if to_legacy(&original) == result { + original + } else { + result.clone().into() + }; + drop(reply.send(Ok(approved))); } Received::Interaction(Interaction::Record { reply, result: delivered, .. }) => { - if delivered != result { - return Err("MCP result differs from the recorded response".into()); + if to_legacy(&delivered) != result { + return Err(ExecutorError::RecordingMismatch); } drop(reply.send(Ok(()))); } Received::Finished(delivered) => { if delivered != result { - return Err("MCP response differs from the recorded response".into()); + return Err(ExecutorError::DeliveryMismatch); } return Ok(()); } Received::Interaction(_) => { - return Err("Unexpected MCP interaction during recording".into()); + return Err(ExecutorError::UnexpectedRecording); } } } @@ -359,11 +382,11 @@ impl PendingCall { pub struct ToolExecutor { request: ToolCallRequest, config: ToolConfigWithDefaults, - key: String, + key: HostCallKey, peer: Peer, service: Arc, state: Arc>, - formatted: Option>, + formatted: Option>, sinks: Sinks, } @@ -381,7 +404,7 @@ impl Executor for ToolExecutor { fn formats_arguments(&self) -> bool { true } - fn formatted_arguments(&self) -> Option<&TextResult> { + fn formatted_arguments(&self) -> Option<&Result> { self.formatted.as_ref() } fn permission_info(&self) -> Option { @@ -406,16 +429,16 @@ impl Executor for ToolExecutor { async fn prepare( &mut self, render_arguments: bool, - ) -> Result, String> { + ) -> Result, ExecutorError> { let mut state = self.state.lock().await; if state.task.is_some() || state.finished { - return Err("MCP call was prepared twice".into()); + return Err(ExecutorError::AlreadyPrepared); } let mut params = CallToolRequestParams::new(self.request.name.clone()); params.arguments = Some(self.request.arguments.clone()); params.meta = Some(Meta(Map::from_iter([( CORRELATION_KEY.into(), - self.key.clone().into(), + self.key.0.clone().into(), )]))); let peer = self.peer.clone(); state.task = Some(tokio::spawn(async move { peer.call_tool(params).await })); @@ -439,7 +462,7 @@ impl Executor for ToolExecutor { state.record = Some(reply); return Ok(Some(ToolCallResponse { id: self.request.id.clone(), - result, + result: to_legacy(&result), })); } Received::Finished(result) => { @@ -449,23 +472,23 @@ impl Executor for ToolExecutor { })); } Received::Interaction(_) => { - return Err("Unexpected MCP preparation interaction".into()); + return Err(ExecutorError::UnexpectedPreparation); } } } } - async fn approve(&mut self) -> Result<(), String> { + async fn approve(&mut self) -> Result<(), ExecutorError> { let mut state = self.state.lock().await; let reply = state .prepare .take() - .ok_or("MCP call is not awaiting approval")?; + .ok_or(ExecutorError::NotAwaitingApproval)?; reply .send(Ok(Admission::Run { arguments: self.request.arguments.clone(), })) - .map_err(|_| "MCP approval expired")?; + .map_err(|_| ExecutorError::ApprovalExpired)?; match state.next().await? { Received::Interaction(Interaction::Release { arguments, @@ -477,8 +500,8 @@ impl Executor for ToolExecutor { state.release = Some(reply); Ok(()) } - Received::Finished(Err(error)) => Err(error), - _ => Err("MCP call did not reach the release barrier".into()), + Received::Finished(Err(message)) => Err(ExecutorError::Rejected { message }), + _ => Err(ExecutorError::MissingRelease), } } @@ -498,16 +521,16 @@ impl Executor for ToolExecutor { if let Some(reply) = state.release.take() { reply .send(Ok(ReleaseDecision::Execute)) - .map_err(|_| "MCP release expired")?; + .map_err(|_| ExecutorError::ReleaseExpired)?; } if let Some((id, reply)) = state.input.take() { let answer = answers - .get(&id) - .ok_or("Missing answer to pending MCP inquiry")? + .get(id.as_str()) + .ok_or(ExecutorError::MissingAnswer)? .clone(); reply .send(Ok(InputAnswer::Answer(answer))) - .map_err(|_| "MCP inquiry expired")?; + .map_err(|_| ExecutorError::InquiryExpired)?; } match state.next().await? { Received::Interaction(Interaction::Input { @@ -517,7 +540,7 @@ impl Executor for ToolExecutor { reply, }) => { let question = question(request, &supporting)?; - state.input = Some((question.id.to_string(), reply)); + state.input = Some((question.id.clone(), reply)); Ok(ExecutorResult::NeedsInput { tool_id: self.request.id.clone(), tool_name: self.request.name.clone(), @@ -527,29 +550,30 @@ impl Executor for ToolExecutor { }) } Received::Interaction(Interaction::Review { result, reply, .. }) => { - state.review = Some(reply); + let text = to_legacy(&result); + state.review = Some((result, reply)); Ok(ExecutorResult::Completed(ToolCallResponse { id: self.request.id.clone(), - result, + result: text, })) } Received::Interaction(Interaction::Record { result, reply, .. }) => { state.record = Some(reply); Ok(ExecutorResult::Completed(ToolCallResponse { id: self.request.id.clone(), - result, + result: to_legacy(&result), })) } Received::Finished(result) => Ok(ExecutorResult::Completed(ToolCallResponse { id: self.request.id.clone(), result, })), - Received::Interaction(_) => Err("Unexpected MCP execution interaction".into()), + Received::Interaction(_) => Err(ExecutorError::UnexpectedExecution), } }; - let result: Result = tokio::select! { + let result: Result = tokio::select! { biased; - () = cancellation.cancelled() => Err("Tool execution cancelled.".into()), + () = cancellation.cancelled() => Err(ExecutorError::Cancelled), result = result => result, }; if result.is_err() { @@ -561,13 +585,13 @@ impl Executor for ToolExecutor { result.unwrap_or_else(|error| { ExecutorResult::Completed(ToolCallResponse { id: self.request.id.clone(), - result: Err(error), + result: Err(error.to_string()), }) }) } } -fn question(request: InputRequest, supporting: &[ContentBlock]) -> Result { +fn question(request: InputRequest, supporting: &[ContentBlock]) -> Result { let answer_type = if request.secret { AnswerType::Secret } else if request.schema.get("type").and_then(Value::as_str) == Some("boolean") { @@ -579,23 +603,21 @@ fn question(request: InputRequest, supporting: &[ContentBlock]) -> Result>()?, } } else if request.schema.get("type").and_then(Value::as_str) == Some("string") { AnswerType::Text } else { - return Err("Unsupported tool inquiry schema".into()); + return Err(ExecutorError::UnsupportedInquirySchema); }; let preamble = supporting .iter() .filter_map(ContentBlock::as_text) .collect::>() .join("\n\n"); - let mut question = Question::text(request.id.to_string(), request.label) - .map_err(|error| error.to_string())? - .with_answer_type(answer_type); + let mut question = Question::new(request.id, request.label, answer_type); question.pre_amble = (!preamble.is_empty()).then_some(preamble); question.default = request.default; Ok(question) diff --git a/crates/jp_cli/src/error.rs b/crates/jp_cli/src/error.rs index 591900684..c83048eae 100644 --- a/crates/jp_cli/src/error.rs +++ b/crates/jp_cli/src/error.rs @@ -2,6 +2,7 @@ use std::io; use camino::Utf8PathBuf; use jp_conversation::ConversationId; +use jp_llm::tool::ExecutorError; use jp_mcp::server::http::EndpointError; use url::Url; @@ -70,7 +71,7 @@ pub(crate) enum Error { McpEndpoint(#[from] EndpointError), #[error("MCP Host recording failed: {0}")] - McpRecording(String), + McpRecording(#[source] ExecutorError), #[error("LLM error")] Llm(#[from] jp_llm::Error), diff --git a/crates/jp_cli/src/render/tool.rs b/crates/jp_cli/src/render/tool.rs index 7e15a61d6..8eec5481d 100644 --- a/crates/jp_cli/src/render/tool.rs +++ b/crates/jp_cli/src/render/tool.rs @@ -864,7 +864,7 @@ async fn format_args_custom( ); Err(detail) } - CommandResult::FatalError(raw) => { + CommandResult::FatalError { raw, .. } => { warn!( command = %cmd, "Custom parameters formatter returned fatal error" diff --git a/crates/jp_llm/src/tool.rs b/crates/jp_llm/src/tool.rs index 61f0872e4..3b11a162a 100644 --- a/crates/jp_llm/src/tool.rs +++ b/crates/jp_llm/src/tool.rs @@ -17,40 +17,40 @@ use futures::future::BoxFuture; use indexmap::IndexMap; use jp_config::conversation::tool::{RunMode, ToolConfigWithDefaults, ToolSource}; use jp_conversation::event::{InquirySource, ToolCallRequest, ToolCallResponse}; -use jp_mcp::{Client, server::StderrSink}; +use jp_mcp::{ + Client, + server::{StderrSink, service::FormatterError}, +}; use jp_tool::{Question, ToolDefinition}; use serde_json::{Map, Value}; use tokio_util::sync::CancellationToken; -/// Trait for tool execution, enabling mock implementations for testing. -/// -/// This trait abstracts the execution of a single tool call, allowing the -/// `ToolCoordinator` to work with both real and mock executors. -/// -/// # Design +#[path = "tool_error.rs"] +mod error; +pub use error::ExecutorError; + +/// The MCP Host's view of a logical tool call. /// -/// The executor is intentionally simple - it just executes tools with given -/// answers. -/// All decision-making about question targets, static answers, and how to -/// handle `NeedsInput` is done by the coordinator, which has access to the tool -/// configuration. +/// Preparation and approval precede release. +/// Input and completed results return control to the Host for inquiry routing, +/// result review, and recording. #[async_trait] pub trait Executor: Send + Sync { /// Prepare an invocation, or return a response resolved without execution. async fn prepare( &mut self, _render_arguments: bool, - ) -> Result, String> { + ) -> Result, ExecutorError> { Ok(None) } /// Apply Host approval and wait until the invocation is ready for release. - async fn approve(&mut self) -> Result<(), String> { + async fn approve(&mut self) -> Result<(), ExecutorError> { Ok(()) } /// Custom argument rendering provided by the execution service. - fn formatted_arguments(&self) -> Option<&Result> { + fn formatted_arguments(&self) -> Option<&Result> { None } @@ -121,13 +121,10 @@ pub trait Executor: Send + Sync { ) -> ExecutorResult; } -/// Abstraction over how executors are created for tool calls. -/// -/// This trait enables dependency injection of executor creation, allowing tests -/// to use mock executors without executing real shell commands. +/// Creates Host-facing tool calls and acknowledges their recorded responses. pub trait ExecutorSource: Send + Sync { /// Release a final delivery barrier after the response has been recorded. - fn acknowledge(&self, _response: ToolCallResponse) -> BoxFuture<'_, Result<(), String>> { + fn acknowledge(&self, _response: ToolCallResponse) -> BoxFuture<'_, Result<(), ExecutorError>> { Box::pin(async { Ok(()) }) } diff --git a/crates/jp_llm/src/tool_error.rs b/crates/jp_llm/src/tool_error.rs new file mode 100644 index 000000000..3bff55cd9 --- /dev/null +++ b/crates/jp_llm/src/tool_error.rs @@ -0,0 +1,74 @@ +//! Failures while the MCP Host advances a logical tool call. + +use std::error::Error as StdError; + +use serde_json::Error as JsonError; +use tokio::task::JoinError; + +/// A tool-call adapter failed before completing its Host protocol. +#[derive(Debug, thiserror::Error)] +pub enum ExecutorError { + /// Execution was requested before submitting a call. + #[error("MCP call has not started")] + NotStarted, + /// A call was submitted more than once. + #[error("MCP call was prepared twice")] + AlreadyPrepared, + /// The service lost its Host interaction channel. + #[error("MCP Host interaction channel closed")] + HostDisconnected, + /// Result metadata could not be decoded into the shared result contract. + #[error("Invalid tool result: {0}")] + MalformedResult(#[source] JsonError), + /// The transport task terminated unexpectedly. + #[error(transparent)] + Task(#[from] JoinError), + /// An MCP request failed. + #[error("{0}")] + Transport(#[source] Box), + /// Approval is not the next operation for this call. + #[error("MCP call is not awaiting approval")] + NotAwaitingApproval, + /// A prepared call stopped before the Host replied. + #[error("MCP approval expired")] + ApprovalExpired, + /// The call stopped before execution was released. + #[error("MCP release expired")] + ReleaseExpired, + /// The call stopped before an input answer was supplied. + #[error("MCP inquiry expired")] + InquiryExpired, + /// No answer exists for the outstanding question. + #[error("Missing answer to pending MCP inquiry")] + MissingAnswer, + /// The service rejected execution with a tool diagnostic. + #[error("{message}")] + Rejected { message: String }, + /// The call was cancelled by the Host. + #[error("Tool execution cancelled.")] + Cancelled, + /// The service produced an interaction outside the recording protocol. + #[error("Unexpected MCP interaction during recording")] + UnexpectedRecording, + /// The service produced an interaction outside the preparation protocol. + #[error("Unexpected MCP preparation interaction")] + UnexpectedPreparation, + /// Approval did not reach a release barrier. + #[error("MCP call did not reach the release barrier")] + MissingRelease, + /// The service produced an interaction outside the execution protocol. + #[error("Unexpected MCP execution interaction")] + UnexpectedExecution, + /// The result approved by the service differs from recorded content. + #[error("MCP result differs from the recorded response")] + RecordingMismatch, + /// The delivered response differs from recorded content. + #[error("MCP response differs from the recorded response")] + DeliveryMismatch, + /// The legacy inquiry interface accepts only textual choices. + #[error("Non-string inquiry choice")] + NonStringChoice, + /// The legacy inquiry interface cannot present this schema. + #[error("Unsupported tool inquiry schema")] + UnsupportedInquirySchema, +} diff --git a/crates/jp_mcp/Cargo.toml b/crates/jp_mcp/Cargo.toml index 231f7bc12..15673c177 100644 --- a/crates/jp_mcp/Cargo.toml +++ b/crates/jp_mcp/Cargo.toml @@ -24,6 +24,7 @@ server = [ "client", "dep:async-trait", "dep:axum", + "dep:base64", "dep:camino", "dep:futures", "dep:jp_tool", @@ -42,6 +43,7 @@ jp_tool = { workspace = true, optional = true } async-trait = { workspace = true, optional = true } axum = { workspace = true, optional = true, features = ["http1", "tokio"] } +base64 = { workspace = true, optional = true, features = ["std"] } camino = { workspace = true, optional = true } futures = { workspace = true, optional = true } indexmap = { workspace = true } @@ -52,7 +54,7 @@ minijinja = { workspace = true, optional = true, features = [ "serde", "unicode", ] } -reqwest = { workspace = true, optional = true, features = ["stream"] } +reqwest = { workspace = true, optional = true, features = ["json", "stream"] } rmcp = { workspace = true } serde = { workspace = true } serde_json = { workspace = true, features = ["preserve_order"] } diff --git a/crates/jp_mcp/README.md b/crates/jp_mcp/README.md index 52a90758e..47ed05ff8 100644 --- a/crates/jp_mcp/README.md +++ b/crates/jp_mcp/README.md @@ -35,7 +35,12 @@ Upstream stdio calls carry trusted execution context and accumulated answers under `_meta["computer.jp/tool"]` and `_meta["computer.jp/context"]`. Single text results are recognized as legacy `Outcome` envelopes when their shape matches. -Mixed native content and result metadata are retained for forwarding. +Mixed native content and result metadata are retained in `jp_tool::ToolResult`. +Execution, Host review, and recording carry that ordered representation; the +HTTP handler converts it back to MCP content after recording is acknowledged. +The CLI explicitly projects it to the existing text/error conversation format. +An unchanged review retains resources, annotations, structured content, and +metadata; a text edit replaces the delivered content. The ordinary CLI query runner submits calls through the HTTP endpoint. Its executor adapter holds pending Host replies across preparation, release, @@ -46,6 +51,10 @@ acknowledges final delivery and consumes the MCP response. The Host connection disables environment proxies, redirects, and transparent session reinitialization. It does not resubmit a tool call on transport failure. +`http_client` implements rmcp's HTTP-client trait using the workspace Reqwest +version. +The rmcp worker owns MCP sessions and SSE resumption; the adapter does not +implement another request retry loop. The HTTP endpoint has no authentication; its loopback binding and header checks are not a claim that the caller is a particular local application. diff --git a/crates/jp_mcp/src/client_protocol_tests.rs b/crates/jp_mcp/src/client_protocol_tests.rs index 17c123809..79c6e63f9 100644 --- a/crates/jp_mcp/src/client_protocol_tests.rs +++ b/crates/jp_mcp/src/client_protocol_tests.rs @@ -9,7 +9,7 @@ use jp_config::{ conversation::tool::{PartialToolConfig, ToolConfig}, providers::mcp::{McpProviderConfig, StdioConfig}, }; -use jp_tool::{Outcome, Question, ToolDefinition, ToolDocs}; +use jp_tool::{ContentBlock, Outcome, Question, ToolDefinition, ToolDocs}; use rmcp::{ ErrorData, ServerHandler, model::{ @@ -34,7 +34,7 @@ use crate::{ execute, http::Endpoint, service::{Admission, ConfiguredTool, Interaction, ReleaseDecision, Service}, - text_result, tool_definitions, + tool_definitions, }, }; @@ -143,14 +143,12 @@ async fn upstream_receives_context_options_and_accumulated_answers() { ) .await .unwrap(); - let ExecutionOutcome::Completed { result, native, .. } = second else { + let ExecutionOutcome::Completed { result, .. } = second else { panic!("expected final result") }; - let native = native.expect("unwrapped text retains native metadata"); - assert_eq!(native.is_error, Some(false)); - assert_eq!(text_result(&native), result); + assert!(!result.is_error()); assert_eq!( - serde_json::from_str::(&result.unwrap()).unwrap(), + serde_json::from_str::(&result.to_text()).unwrap(), json!({ "computer.jp/tool":{"name":"actual_tool", "arguments":{"value":"edited"}, "answers":{"confirm":true}, "options":{"limit":7}}, "computer.jp/context":{"action":"run", "root":"/work", "access":null, "workspace_id":"workspace-1", "conversation_id":"conversation-1"}, @@ -220,7 +218,7 @@ async fn native_upstream_result_survives_host_projection_and_http_delivery() { }))])); upstream.services.write().await.insert(McpServerId::new("upstream"), running); let mut cfg = AppConfig::new_test(); - let partial: PartialToolConfig = serde_json::from_value(json!({"source":"mcp.upstream.native","run":"unattended","result":"unattended"})).unwrap(); + let partial: PartialToolConfig = serde_json::from_value(json!({"source":"mcp.upstream.native","run":"unattended","result":"ask"})).unwrap(); cfg.conversation.tools.insert("alias".into(), ToolConfig::from_partial(partial, vec![]).unwrap()); let definitions = tool_definitions(cfg.conversation.tools.iter(), &upstream, None).await.unwrap(); let configured = definitions.into_iter().map(|definition| ConfiguredTool {config:cfg.conversation.tools.get(&definition.name).unwrap(), definition, access:Ok(None), metadata:Map::new()}).collect(); @@ -233,8 +231,15 @@ async fn native_upstream_result_survives_host_projection_and_http_delivery() { reply.send(Ok(Admission::Run {arguments})).unwrap(); let Interaction::Release {reply,..} = host.recv().await.unwrap().interaction else {panic!("expected release")}; reply.send(Ok(ReleaseDecision::Execute)).unwrap(); - let Interaction::Record {result:projected,reply,..} = host.recv().await.unwrap().interaction else {panic!("expected recording")}; - assert_eq!(projected, Ok("alpha\n\nresource".into())); + let Interaction::Review {result: reviewed, reply, ..} = host.recv().await.unwrap().interaction else {panic!("expected review")}; + assert!(matches!(&reviewed.content[1], ContentBlock::Image(image) if image.data == "AA==" && image.mime_type == "image/png")); + assert_eq!(reviewed.structured_content, Some(json!({"answer":42}))); + assert_eq!(reviewed.metadata, Some(json!({"fixture/source":"upstream"}).as_object().unwrap().clone())); + reply.send(Ok(reviewed.clone())).unwrap(); + let Interaction::Record {result:projected,raw_result,reply,..} = host.recv().await.unwrap().interaction else {panic!("expected recording")}; + assert_eq!(projected, reviewed); + assert_eq!(raw_result, Some(reviewed)); + assert_eq!(projected.to_text(), "alpha\n\nresource"); assert!(!reply.is_closed()); reply.send(Ok(())).unwrap(); assert_eq!(serde_json::to_value(result.await.unwrap()).unwrap(), json!({ diff --git a/crates/jp_mcp/src/server.rs b/crates/jp_mcp/src/server.rs index a457e01b5..e44296ca8 100644 --- a/crates/jp_mcp/src/server.rs +++ b/crates/jp_mcp/src/server.rs @@ -11,7 +11,9 @@ pub mod builtin; pub mod http; +mod http_client; pub mod json_schema; +pub mod result; pub mod service; mod upstream; use std::{convert::identity, ffi::OsStr, fmt, process::Stdio, sync::Arc}; @@ -25,23 +27,24 @@ use jp_config::{ }; use jp_tool::{ AccessPolicy, Action, Error as ToolError, Outcome, ParameterDocs, Question, ToolDefinition, - ToolDocs, + ToolDocs, ToolResult, + content::{ErrorDetails, ToolStatus}, definition::{apply_parameter_defaults, split_description, validate_tool_arguments}, schema::{Node, merge_description}, }; use minijinja::{Environment, ErrorKind as MinijinjaErrorKind, value::ValueKind}; -use serde_json::{Map, Value, json}; +use result::{from_mcp, to_legacy}; +use serde_json::{Error as JsonError, Map, Value, json}; use tokio::{ io::{AsyncBufReadExt, AsyncReadExt, BufReader}, process::Command, }; use tokio_util::sync::CancellationToken; use tracing::{error, info, trace, warn}; -pub use upstream::text_result; use upstream::{UpstreamResult, decode_result, replace_envelope}; use crate::{ - CallToolResult, Client, + Client, id::{McpServerId, McpToolId}, }; @@ -108,30 +111,6 @@ fn tool_docs_from_config(config: &ToolConfigWithDefaults) -> ToolDocs { /// 2. Handling [`ExecutionOutcome::NeedsInput`] by prompting the user or /// assistant. /// 3. Handling result editing **after** receiving the outcome. -/// -/// # Example Flow -/// -/// ```text -/// host execute() -/// ───────────────────── ────────────────────── -/// │ -/// ├── [AwaitingPermission] -/// │ prompt_permission() -/// │ -/// ├── [Running] -/// │ ────────────────────────────► execute() -/// │ │ -/// │ ◄──────────────────────────── ExecutionOutcome -/// ├── [AwaitingInput] (if NeedsInput) -/// │ prompt_question() -/// │ ────────────────────────────► execute() (with answer) -/// │ │ -/// │ ◄──────────────────────────── ExecutionOutcome -/// ├── [AwaitingResultEdit] -/// │ prompt_result_edit() -/// │ -/// └── [Completed] -/// ``` #[derive(Debug)] pub enum ExecutionOutcome { /// Tool executed and produced a result. @@ -142,9 +121,7 @@ pub enum ExecutionOutcome { /// The execution result. /// /// If an error occurred, it means the tool ran, but reported an error. - result: Result, - /// Full upstream MCP result before the Host's compatibility projection. - native: Option, + result: ToolResult, }, /// Tool needs additional input before it can complete. @@ -197,7 +174,7 @@ impl ExecutionOutcome { /// result. #[must_use] pub fn is_success(&self) -> bool { - matches!(self, Self::Completed { result: Ok(_), .. }) + matches!(self, Self::Completed { result, .. } if !result.is_error()) } } @@ -221,7 +198,13 @@ pub enum CommandResult { }, /// Tool reported a fatal error. - FatalError(String), + FatalError { + /// Original error envelope, retained for the current conversation + /// format. + raw: String, + /// Source chain reported by the tool. + trace: Vec, + }, /// Tool needs additional input before it can continue. NeedsInput(Question), @@ -267,7 +250,7 @@ pub enum CommandResult { MalformedInquiry { /// The deserialization error, for the diagnostic trace and the /// model-facing message. - detail: String, + detail: JsonError, }, } @@ -286,38 +269,48 @@ impl CommandResult { } } - /// Convert to a `Result` suitable for tool call responses. + /// Convert command output to ordered content with a typed status. + /// + /// # Panics /// - /// - `Success` → `Ok(content)` - /// - `TransientError` → `Err(json with message + trace)` - /// - `FatalError` → `Err(raw json)` - /// - `NeedsInput` → handled separately by callers (this panics) - /// - `Cancelled` → `Ok(cancellation message)` - /// - `RawOutput` → `Ok(stdout)` if success, `Err(json)` if failure - pub fn into_tool_result(self, name: &str) -> Result { + /// Panics on `NeedsInput`, which must be handled before final delivery. + pub fn into_tool_result(self, name: &str) -> ToolResult { match self { - Self::Success(content) => Ok(content), - Self::TransientError { message, trace } => Err(json!({ - "message": message, - "trace": trace, - }) - .to_string()), - Self::FatalError(raw) => Err(raw), - Self::Cancelled => Ok("Tool execution cancelled by user.".to_string()), + Self::Success(content) => ToolResult::text(content), + Self::TransientError { message, trace } => { + let mut result = + ToolResult::error(json!({"message": message, "trace": trace}).to_string()); + result.status = ToolStatus::Error(ErrorDetails { + transient: true, + trace, + }); + result + } + Self::FatalError { raw, trace } => { + let mut result = ToolResult::error(raw); + result.status = ToolStatus::Error(ErrorDetails { + transient: false, + trace, + }); + result + } + Self::Cancelled => ToolResult::text("Tool execution cancelled by user."), Self::RawOutput { stdout, stderr, success, } => { if success { - Ok(stdout) + ToolResult::text(stdout) } else { - Err(json!({ - "message": format!("Tool '{name}' execution failed."), - "stderr": stderr, - "stdout": stdout, - }) - .to_string()) + ToolResult::error( + json!({ + "message": format!("Tool '{name}' execution failed."), + "stderr": stderr, + "stdout": stdout, + }) + .to_string(), + ) } } Self::InvalidInquiry { question_id } => { @@ -327,7 +320,7 @@ impl CommandResult { "tool produced an invalid inquiry: question id must be non-empty and must not \ contain '.'" ); - Err( + ToolResult::error( "tool produced an invalid inquiry: question id must be non-empty and must not \ contain '.'" .to_owned(), @@ -339,7 +332,7 @@ impl CommandResult { %detail, "tool produced a malformed inquiry that could not be parsed" ); - Err(format!( + ToolResult::error(format!( "tool '{name}' produced a malformed inquiry that could not be parsed: {detail}" )) } @@ -606,7 +599,10 @@ fn parse_command_output(stdout: &[u8], stderr: &[u8], success: bool) -> CommandR if transient { CommandResult::TransientError { message, trace } } else { - CommandResult::FatalError(stdout_str.into_owned()) + CommandResult::FatalError { + raw: stdout_str.into_owned(), + trace, + } } } Ok(Outcome::NeedsInput { question }) => CommandResult::NeedsInput(question), @@ -648,9 +644,7 @@ fn parse_command_output(stdout: &[u8], stderr: &[u8], success: bool) -> CommandR }, // Some other field failed to parse (wrong shape, missing // field, protocol skew). - _ => CommandResult::MalformedInquiry { - detail: error.to_string(), - }, + _ => CommandResult::MalformedInquiry { detail: error }, } } } @@ -818,9 +812,8 @@ async fn execute_local( if let Err(error) = validate_tool_arguments(args, &definition.parameters) { return Ok(ExecutionOutcome::Completed { - native: None, id, - result: Err(format!( + result: ToolResult::error(format!( "Invalid arguments: {error}\n\nYou can call `describe_tools(tools: \ [\"{name}\"])` to learn more about how to use the tool correctly." )), @@ -851,14 +844,12 @@ async fn execute_local( match run_tool_command(command, ctx, root, cancellation_token, Some(trace_as)).await? { CommandResult::Success(content) => Ok(ExecutionOutcome::Completed { - native: None, id, - result: Ok(content), + result: ToolResult::text(content), }), CommandResult::NeedsInput(question) => Ok(ExecutionOutcome::NeedsInput { id, question }), CommandResult::Cancelled => Ok(ExecutionOutcome::Cancelled { id }), other => Ok(ExecutionOutcome::Completed { - native: None, id, result: other.into_tool_result(name), }), @@ -902,36 +893,44 @@ async fn execute_mcp( ]); let call_future = mcp_client.call_tool(name, server, &arguments, Some(meta)); - tokio::select! { + let response = tokio::select! { biased; () = cancellation_token.cancelled() => { info!(tool = %definition.name, "MCP tool call cancelled"); - Ok(ExecutionOutcome::Cancelled { id }) + return Ok(ExecutionOutcome::Cancelled { id }); } - result = call_future => { - let result = result - .map_err(|error| ToolError::McpRunToolError(Box::new(error)))?; - - let result = match decode_result(result).map_err(ToolError::MalformedOutput)? { - UpstreamResult::Outcome { outcome, response } => return Ok(match outcome { - Outcome::Success {content} => ExecutionOutcome::Completed {id, native:Some(replace_envelope(response, &content, false)), result:Ok(content)}, - Outcome::NeedsInput {question} => ExecutionOutcome::NeedsInput {id, question}, - Outcome::Error {message, trace, transient} => { - let text = if transient { - json!({"message":message, "trace":trace}).to_string() - } else { - text_result(&response).unwrap_or_else(identity) - }; - let native = Some(replace_envelope(response, &text, true)); - ExecutionOutcome::Completed {id, result:Err(text), native} - } - }), - UpstreamResult::Native(result) => result, - }; - let text = text_result(&result); - Ok(ExecutionOutcome::Completed { id, result: text, native: Some(result) }) + result = call_future => result.map_err(|error| ToolError::McpRunToolError(Box::new(error)))?, + }; + + let result = match decode_result(response).map_err(ToolError::MalformedOutput)? { + UpstreamResult::Native(response) => { + from_mcp(response).map_err(ToolError::MalformedOutput)? } - } + UpstreamResult::Outcome { outcome, response } => match outcome { + Outcome::NeedsInput { question } => { + return Ok(ExecutionOutcome::NeedsInput { id, question }); + } + Outcome::Success { content } => from_mcp(replace_envelope(response, &content, false)) + .map_err(ToolError::MalformedOutput)?, + Outcome::Error { + message, + trace, + transient, + } => { + let text = if transient { + json!({"message":message, "trace":trace}).to_string() + } else { + to_legacy(&from_mcp(response.clone()).map_err(ToolError::MalformedOutput)?) + .unwrap_or_else(identity) + }; + let mut result = from_mcp(replace_envelope(response, &text, true)) + .map_err(ToolError::MalformedOutput)?; + result.status = ToolStatus::Error(ErrorDetails { transient, trace }); + result + } + }, + }; + Ok(ExecutionOutcome::Completed { id, result }) } /// Execute a builtin tool and return the outcome. @@ -958,26 +957,13 @@ async fn execute_builtin( Ok(match outcome { Outcome::Success { content } => ExecutionOutcome::Completed { - native: None, id, - result: Ok(content), + result: ToolResult::text(content), + }, + outcome @ Outcome::Error { .. } => ExecutionOutcome::Completed { + id, + result: outcome.into(), }, - Outcome::Error { - message, - trace, - transient: _, - } => { - let error_msg = if trace.is_empty() { - message - } else { - format!("{message}\n\nTrace:\n{}", trace.join("\n")) - }; - ExecutionOutcome::Completed { - native: None, - id, - result: Err(error_msg), - } - } Outcome::NeedsInput { question } => ExecutionOutcome::NeedsInput { id, question }, }) } diff --git a/crates/jp_mcp/src/server/conformance_tests.rs b/crates/jp_mcp/src/server/conformance_tests.rs index 8ddc5aa8f..81a2d3776 100644 --- a/crates/jp_mcp/src/server/conformance_tests.rs +++ b/crates/jp_mcp/src/server/conformance_tests.rs @@ -2,9 +2,12 @@ #[cfg(unix)] use std::fs; -use std::sync::{ - Arc, - atomic::{AtomicUsize, Ordering}, +use std::{ + io, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, }; use async_trait::async_trait; @@ -14,8 +17,8 @@ use jp_config::{ AppConfig, Config as _, conversation::tool::{PartialToolConfig, ToolConfig}, }; -use jp_tool::{Outcome, Question}; -use reqwest_mcp::{Client as HttpClient, Response, redirect::Policy}; +use jp_tool::{Outcome, Question, ToolResult}; +use reqwest::{Client as HttpClient, Response, redirect::Policy}; use rmcp::model::{CallToolRequestParams, Meta}; use serde_json::{Map, Value, json}; use tokio::{ @@ -379,7 +382,8 @@ async fn external_inquiry_reexecutes_with_host_answers_and_records_edited_output let Interaction::Review { result, reply, .. } = pending.interaction else { panic!("expected review") }; - let raw = result.unwrap(); + assert!(!result.is_error()); + let raw = result.to_text(); assert_eq!( serde_json::from_str::(&raw).unwrap(), json!({"value":"edited","answer":false,"action":"run","workspace":"workspace-1","conversation":"conversation-1","marker":"configured"}) @@ -389,7 +393,7 @@ async fn external_inquiry_reexecutes_with_host_answers_and_records_edited_output "attempt\nattempt\n" ); assert!(!attacker.join("attempts").exists()); - reply.send(Ok(Ok("approved output".into()))).unwrap(); + reply.send(Ok(ToolResult::text("approved output"))).unwrap(); let pending = next(&mut fixture.host).await; assert_eq!(pending.call.id, id); assert_eq!( @@ -409,8 +413,8 @@ async fn external_inquiry_reexecutes_with_host_answers_and_records_edited_output arguments, json!({"value":"edited"}).as_object().unwrap().clone() ); - assert_eq!(raw_result, Some(Ok(raw))); - assert_eq!(result, Ok("approved output".into())); + assert_eq!(raw_result, Some(ToolResult::text(raw))); + assert_eq!(result, ToolResult::text("approved output")); let mut returned = tokio::spawn(response.reply(11)); assert!( timeout(Duration::from_millis(40), &mut returned) @@ -418,7 +422,7 @@ async fn external_inquiry_reexecutes_with_host_answers_and_records_edited_output .is_err() ); assert!(!reply.is_closed()); - fs::write(fixture.root.path().join("record.json"), serde_json::to_vec(&json!({"requested":pending.call.request.arguments,"executed":arguments,"result":result.unwrap()})).unwrap()).unwrap(); + fs::write(fixture.root.path().join("record.json"), serde_json::to_vec(&json!({"requested":pending.call.request.arguments,"executed":arguments,"result":result.to_text()})).unwrap()).unwrap(); reply.send(Ok(())).unwrap(); assert_eq!( returned.await.unwrap(), @@ -506,8 +510,8 @@ async fn host_and_external_client_share_handlers_without_sharing_call_identity() let Interaction::Review { result, reply, .. } = review.interaction else { panic!("expected second review") }; - assert_eq!(result, Ok("execution-1".into())); - reply.send(Ok(Ok("external result".into()))).unwrap(); + assert_eq!(result, ToolResult::text("execution-1")); + reply.send(Ok(ToolResult::text("external result"))).unwrap(); let record = next(&mut fixture.host).await; assert_eq!(record.call.id, second_id); let Interaction::Record { reply, .. } = record.interaction else { @@ -536,8 +540,8 @@ async fn host_and_external_client_share_handlers_without_sharing_call_identity() let Interaction::Review { result, reply, .. } = review.interaction else { panic!("expected first review") }; - assert_eq!(result, Ok("execution-2".into())); - reply.send(Ok(Ok("host result".into()))).unwrap(); + assert_eq!(result, ToolResult::text("execution-2")); + reply.send(Ok(ToolResult::text("host result"))).unwrap(); let record = next(&mut fixture.host).await; assert_eq!(record.call.id, first_id); let Interaction::Record { reply, .. } = record.interaction else { @@ -580,8 +584,8 @@ async fn disconnected_response_resumes_without_reexecuting_tool() { else { panic!("expected review") }; - assert_eq!(result, Ok("execution-1".into())); - reply.send(Ok(Ok("recorded result".into()))).unwrap(); + assert_eq!(result, ToolResult::text("execution-1")); + reply.send(Ok(ToolResult::text("recorded result"))).unwrap(); let record = next(&mut fixture.host).await; assert_eq!(record.call.id, id); let Interaction::Record { reply, .. } = record.interaction else { @@ -625,11 +629,15 @@ async fn failed_recording_returns_error_instead_of_the_tool_result() { let Interaction::Review { reply, .. } = next(&mut fixture.host).await.interaction else { panic!("expected review") }; - reply.send(Ok(Ok("approved result".into()))).unwrap(); + reply.send(Ok(ToolResult::text("approved result"))).unwrap(); let Interaction::Record { reply, .. } = next(&mut fixture.host).await.interaction else { panic!("expected recording") }; - reply.send(Err(HostError("disk full".into()))).unwrap(); + reply + .send(Err(HostError::Recording(Arc::new(io::Error::other( + "disk full", + ))))) + .unwrap(); assert_eq!( response.reply(23).await, json!({"jsonrpc":"2.0","id":23,"error":{"code":-32603,"message":"MCP Host operation failed: disk full"}}) @@ -783,7 +791,7 @@ async fn cancellation_is_scoped_to_the_requesting_client_session() { let Interaction::Record { result, reply, .. } = record.interaction else { panic!("expected only the second result") }; - assert_eq!(result, Ok("answered".into())); + assert_eq!(result, ToolResult::text("answered")); reply.send(Ok(())).unwrap(); assert_eq!( second_response.reply(31).await, @@ -824,7 +832,7 @@ async fn large_result_reaches_external_client_byte_for_byte() { else { panic!("expected record") }; - assert_eq!(result, Ok(payload.clone())); + assert_eq!(result, ToolResult::text(payload.clone())); reply.send(Ok(())).unwrap(); let result = response.reply(33).await; assert_eq!( diff --git a/crates/jp_mcp/src/server/http.rs b/crates/jp_mcp/src/server/http.rs index 22d79ffa0..83af202fd 100644 --- a/crates/jp_mcp/src/server/http.rs +++ b/crates/jp_mcp/src/server/http.rs @@ -4,8 +4,6 @@ //! The private Host receiver returned when constructing the service remains //! with the MCP Host. -mod client; - use std::{ error::Error as StdError, io, @@ -14,9 +12,7 @@ use std::{ }; use axum::Router; -use client::LoopbackClient; use jp_tool::Error as ToolError; -use reqwest::{Client as HttpClient, redirect::Policy}; use rmcp::{ ErrorData, ServerHandler, ServiceExt as _, model::{ @@ -39,7 +35,10 @@ use tokio::{ }; use tokio_util::sync::CancellationToken; -use super::service::{CallRequest, Service, ServiceError}; +use super::{ + http_client::LoopbackClient, + service::{CallRequest, Service, ServiceError}, +}; /// Failure starting, connecting to, or stopping the in-process endpoint. #[derive(Debug, thiserror::Error)] @@ -113,19 +112,13 @@ impl Endpoint { /// Establish the MCP Host's ordinary HTTP connection to this endpoint. pub async fn connect(&self) -> Result, EndpointError> { - let client = HttpClient::builder() - .no_proxy() - .redirect(Policy::none()) - .build() - .map_err(|error| EndpointError::Connect(Box::new(error)))?; + let client = + LoopbackClient::new().map_err(|error| EndpointError::Connect(Box::new(error)))?; let config = StreamableHttpClientTransportConfig::with_uri(self.url.clone()) .reinit_on_expired_session(false); - ().serve(StreamableHttpClientTransport::with_client( - LoopbackClient(client), - config, - )) - .await - .map_err(|error| EndpointError::Connect(Box::new(error))) + ().serve(StreamableHttpClientTransport::with_client(client, config)) + .await + .map_err(|error| EndpointError::Connect(Box::new(error))) } /// Private in-process control for the MCP Host, not exposed through HTTP. diff --git a/crates/jp_mcp/src/server/http/client.rs b/crates/jp_mcp/src/server/http/client.rs deleted file mode 100644 index 2dfb1a8b0..000000000 --- a/crates/jp_mcp/src/server/http/client.rs +++ /dev/null @@ -1,236 +0,0 @@ -//! The MCP Host's HTTP client for the loopback endpoint. -//! -//! rmcp ships a Streamable HTTP client for `reqwest` 0.13, while the rest of JP -//! uses 0.12. -//! This implements rmcp's [`StreamableHttpClient`] over the `reqwest` JP -//! already depends on, so the Host's connection to its own endpoint does not -//! pull in a second HTTP stack. -//! -//! It talks to one server, the endpoint in the same process, which sends no -//! `WWW-Authenticate` challenges: a `401` or `403` is reported as an unexpected -//! response rather than as an authorization flow. - -use std::{borrow::Cow, collections::HashMap, sync::Arc}; - -use futures::{StreamExt as _, stream::BoxStream}; -use reqwest::{ - Client, RequestBuilder, StatusCode, - header::{ACCEPT, CONTENT_TYPE, HeaderName, HeaderValue}, -}; -use rmcp::{ - model::{ClientJsonRpcMessage, JsonRpcMessage, ServerJsonRpcMessage}, - transport::{ - common::http_header::{ - EVENT_STREAM_MIME_TYPE, HEADER_LAST_EVENT_ID, HEADER_SESSION_ID, JSON_MIME_TYPE, - }, - streamable_http_client::{ - SseError, StreamableHttpClient, StreamableHttpError, StreamableHttpPostResponse, - }, - }, -}; -use sse_stream::{Sse, SseStream}; -use tracing::warn; - -type Error = StreamableHttpError; - -/// A `reqwest` client speaking MCP Streamable HTTP. -#[derive(Debug, Clone)] -pub(super) struct LoopbackClient(pub(super) Client); - -/// Add the headers rmcp's worker supplies to every request. -fn with_headers( - builder: RequestBuilder, - auth_token: Option, - custom_headers: HashMap, -) -> RequestBuilder { - // rmcp's worker uses custom headers only to carry the negotiated - // `MCP-Protocol-Version`; the Host configures none of its own. - let builder = custom_headers - .into_iter() - .fold(builder, |builder, (name, value)| { - builder.header(name, value) - }); - - match auth_token { - Some(token) => builder.bearer_auth(token), - None => builder, - } -} - -impl StreamableHttpClient for LoopbackClient { - type Error = reqwest::Error; - - async fn get_stream( - &self, - uri: Arc, - session_id: Arc, - last_event_id: Option, - auth_token: Option, - custom_headers: HashMap, - ) -> Result>, Error> { - let mut builder = self - .0 - .get(uri.as_ref()) - .header(ACCEPT, accept()) - .header(HEADER_SESSION_ID, session_id.as_ref()); - if let Some(last_event_id) = last_event_id { - builder = builder.header(HEADER_LAST_EVENT_ID, last_event_id); - } - - let response = with_headers(builder, auth_token, custom_headers) - .send() - .await - .map_err(Error::Client)?; - - if response.status() == StatusCode::METHOD_NOT_ALLOWED { - return Err(Error::ServerDoesNotSupportSse); - } - let response = response.error_for_status().map_err(Error::Client)?; - - match content_type(&response) { - Some(ct) if is(&ct, EVENT_STREAM_MIME_TYPE) || is(&ct, JSON_MIME_TYPE) => {} - other => return Err(Error::UnexpectedContentType(other)), - } - - Ok(SseStream::from_bytes_stream(response.bytes_stream()).boxed()) - } - - async fn delete_session( - &self, - uri: Arc, - session_id: Arc, - auth_token: Option, - custom_headers: HashMap, - ) -> Result<(), Error> { - let builder = self - .0 - .delete(uri.as_ref()) - .header(HEADER_SESSION_ID, session_id.as_ref()); - - let response = with_headers(builder, auth_token, custom_headers) - .send() - .await - .map_err(Error::Client)?; - - if response.status() == StatusCode::METHOD_NOT_ALLOWED { - return Ok(()); - } - response.error_for_status().map_err(Error::Client)?; - - Ok(()) - } - - async fn post_message( - &self, - uri: Arc, - message: ClientJsonRpcMessage, - session_id: Option>, - auth_token: Option, - custom_headers: HashMap, - ) -> Result { - let body = serde_json::to_vec(&message)?; - let mut builder = self - .0 - .post(uri.as_ref()) - .header(ACCEPT, accept()) - .header(CONTENT_TYPE, JSON_MIME_TYPE) - .body(body); - let session_was_attached = session_id.is_some(); - if let Some(session_id) = session_id { - builder = builder.header(HEADER_SESSION_ID, session_id.as_ref()); - } - - let response = with_headers(builder, auth_token, custom_headers) - .send() - .await - .map_err(Error::Client)?; - - let status = response.status(); - if matches!(status, StatusCode::ACCEPTED | StatusCode::NO_CONTENT) { - return Ok(StreamableHttpPostResponse::Accepted); - } - if status == StatusCode::NOT_FOUND && session_was_attached { - return Err(Error::SessionExpired); - } - - let content_type = content_type(&response); - let session_id = response - .headers() - .get(HEADER_SESSION_ID) - .and_then(|v| v.to_str().ok()) - .map(str::to_owned); - - // The spec answers notifications and responses with `202`, but an - // empty `200` means the same thing. - if status.is_success() - && response.content_length() == Some(0) - && !matches!(message, ClientJsonRpcMessage::Request(_)) - { - return Ok(StreamableHttpPostResponse::Accepted); - } - - // A failure status can still carry a JSON-RPC error, which the caller - // should see as the MCP error it is rather than a transport failure. - if !status.is_success() { - let body = response.text().await.unwrap_or_default(); - if content_type - .as_deref() - .is_some_and(|ct| is(ct, JSON_MIME_TYPE)) - && let Some(error) = json_rpc_error(&body) - { - return Ok(StreamableHttpPostResponse::Json(error, session_id)); - } - - return Err(Error::UnexpectedServerResponse(Cow::Owned(format!( - "HTTP {status}: {body}" - )))); - } - - match content_type.as_deref() { - Some(ct) if is(ct, EVENT_STREAM_MIME_TYPE) => { - let stream = SseStream::from_bytes_stream(response.bytes_stream()).boxed(); - Ok(StreamableHttpPostResponse::Sse(stream, session_id)) - } - Some(ct) if is(ct, JSON_MIME_TYPE) => { - let body = response.bytes().await.map_err(Error::Client)?; - match serde_json::from_slice::(&body) { - Ok(message) => Ok(StreamableHttpPostResponse::Json(message, session_id)), - Err(error) => { - warn!(%error, "Unparseable JSON-RPC response; treating it as accepted."); - Ok(StreamableHttpPostResponse::Accepted) - } - } - } - _ => Err(Error::UnexpectedContentType(content_type)), - } - } -} - -/// The `Accept` value every MCP request carries. -fn accept() -> String { - format!("{EVENT_STREAM_MIME_TYPE}, {JSON_MIME_TYPE}") -} - -fn content_type(response: &reqwest::Response) -> Option { - response - .headers() - .get(CONTENT_TYPE) - .map(|ct| String::from_utf8_lossy(ct.as_bytes()).into_owned()) -} - -/// Whether a `Content-Type` value names `mime`, ignoring any parameters. -fn is(content_type: &str, mime: &str) -> bool { - content_type.starts_with(mime) -} - -/// `body` as a JSON-RPC error, when it is one. -fn json_rpc_error(body: &str) -> Option { - match serde_json::from_str::(body) { - Ok(message @ JsonRpcMessage::Error(_)) => Some(message), - _ => None, - } -} - -#[cfg(test)] -#[path = "client_tests.rs"] -mod tests; diff --git a/crates/jp_mcp/src/server/http/client_tests.rs b/crates/jp_mcp/src/server/http/client_tests.rs deleted file mode 100644 index 9987996f3..000000000 --- a/crates/jp_mcp/src/server/http/client_tests.rs +++ /dev/null @@ -1,105 +0,0 @@ -use jp_test::mock::{MockServer, POST}; -use serde_json::json; - -use super::*; - -fn client() -> LoopbackClient { - LoopbackClient(Client::builder().no_proxy().build().unwrap()) -} - -fn ping() -> ClientJsonRpcMessage { - serde_json::from_value(json!({"jsonrpc": "2.0", "id": 1, "method": "ping"})).unwrap() -} - -fn initialized() -> ClientJsonRpcMessage { - serde_json::from_value(json!({"jsonrpc": "2.0", "method": "notifications/initialized"})) - .unwrap() -} - -async fn post( - server: &MockServer, - message: ClientJsonRpcMessage, - session_id: Option<&str>, -) -> Result { - client() - .post_message( - server.url("/mcp").into(), - message, - session_id.map(Into::into), - None, - HashMap::new(), - ) - .await -} - -/// A `404` for a request that carried a session means the server dropped the -/// session, which rmcp's worker handles differently from a missing endpoint. -#[tokio::test] -async fn a_404_with_a_session_is_an_expired_session() { - let server = MockServer::start_async().await; - server - .mock_async(|when, then| { - when.method(POST).path("/mcp"); - then.status(404); - }) - .await; - - assert!(matches!( - post(&server, ping(), Some("session-1")).await, - Err(StreamableHttpError::SessionExpired) - )); - assert!(matches!( - post(&server, ping(), None).await, - Err(StreamableHttpError::UnexpectedServerResponse(_)) - )); -} - -/// A JSON-RPC error on a failure status reaches the caller as an MCP error. -#[tokio::test] -async fn a_json_rpc_error_on_a_failure_status_is_returned_as_a_message() { - let server = MockServer::start_async().await; - server - .mock_async(|when, then| { - when.method(POST).path("/mcp"); - then.status(400) - .header("content-type", "application/json") - .json_body(json!({ - "jsonrpc": "2.0", - "id": 1, - "error": {"code": -32600, "message": "Invalid Request"} - })); - }) - .await; - - let result = post(&server, ping(), Some("session-1")).await; - assert!( - matches!( - result, - Ok(StreamableHttpPostResponse::Json( - JsonRpcMessage::Error(_), - _ - )) - ), - "{result:?}" - ); -} - -/// An empty `200` answers a notification the same way a `202` does. -#[tokio::test] -async fn an_empty_200_to_a_notification_is_accepted() { - let server = MockServer::start_async().await; - server - .mock_async(|when, then| { - when.method(POST) - .path("/mcp") - .header("mcp-session-id", "session-1") - .header("accept", "text/event-stream, application/json"); - then.status(200); - }) - .await; - - assert!(matches!( - post(&server, initialized(), Some("session-1")).await, - Ok(StreamableHttpPostResponse::Accepted) - )); -} diff --git a/crates/jp_mcp/src/server/http_client.rs b/crates/jp_mcp/src/server/http_client.rs new file mode 100644 index 000000000..fc9f9f0bd --- /dev/null +++ b/crates/jp_mcp/src/server/http_client.rs @@ -0,0 +1,200 @@ +//! Reqwest transport for the MCP Host's connection to its own HTTP endpoint. +//! +//! MCP session handling, cancellation, and SSE resumption belong to rmcp's +//! transport worker. +//! This adapter sends HTTP requests and decodes responses. + +use std::{collections::HashMap, sync::Arc}; + +use futures::{StreamExt as _, stream::BoxStream}; +use reqwest::{ + Client, Error, RequestBuilder, Response, StatusCode, + header::{ACCEPT, CONTENT_TYPE, HeaderName, HeaderValue}, + redirect::Policy, +}; +use rmcp::{ + model::ClientJsonRpcMessage, + transport::streamable_http_client::{ + StreamableHttpClient, StreamableHttpError, StreamableHttpPostResponse, + }, +}; +use sse_stream::{Error as SseError, Sse, SseStream}; + +/// HTTP client for JP's private loopback connection, without proxies or +/// redirects. +#[derive(Clone)] +pub(super) struct LoopbackClient(Client); + +impl LoopbackClient { + /// Construct a client that cannot route the local connection through a + /// proxy. + pub(super) fn new() -> Result { + Ok(Self( + Client::builder() + .no_proxy() + .redirect(Policy::none()) + .build()?, + )) + } +} + +fn request_headers( + mut request: RequestBuilder, + session: Option<&str>, + auth: Option, + headers: HashMap, +) -> Result> { + for (name, value) in headers { + if matches!( + name.as_str(), + "accept" + | "content-type" + | "mcp-session-id" + | "last-event-id" + | "authorization" + | "host" + ) { + return Err(StreamableHttpError::ReservedHeaderConflict( + name.to_string(), + )); + } + request = request.header(name, value); + } + if let Some(session) = session { + request = request.header("mcp-session-id", session); + } + if let Some(auth) = auth { + request = request.bearer_auth(auth); + } + Ok(request) +} + +fn content_type(response: &Response) -> Result<&str, StreamableHttpError> { + let value = response + .headers() + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()); + value + .map(|value| value.split(';').next().unwrap_or(value).trim()) + .ok_or(StreamableHttpError::UnexpectedContentType(None)) +} + +fn event_stream(response: Response) -> BoxStream<'static, Result> { + SseStream::from_bytes_stream(response.bytes_stream()).boxed() +} + +impl StreamableHttpClient for LoopbackClient { + type Error = Error; + + async fn post_message( + &self, + uri: Arc, + message: ClientJsonRpcMessage, + session_id: Option>, + auth_header: Option, + custom_headers: HashMap, + ) -> Result> { + let request = self + .0 + .post(uri.as_ref()) + .header(ACCEPT, "application/json, text/event-stream") + .json(&message); + let response = + request_headers(request, session_id.as_deref(), auth_header, custom_headers)? + .send() + .await + .map_err(StreamableHttpError::Client)?; + if response.status() == StatusCode::NOT_FOUND && session_id.is_some() { + return Err(StreamableHttpError::SessionExpired); + } + let response = response + .error_for_status() + .map_err(StreamableHttpError::Client)?; + if matches!( + response.status(), + StatusCode::ACCEPTED | StatusCode::NO_CONTENT + ) { + return Ok(StreamableHttpPostResponse::Accepted); + } + let session = response + .headers() + .get("mcp-session-id") + .map(|value| value.to_str().map(str::to_owned)) + .transpose() + .map_err(|_| { + StreamableHttpError::UnexpectedServerResponse("invalid MCP session header".into()) + })?; + match content_type(&response)? { + "application/json" => Ok(StreamableHttpPostResponse::Json( + response.json().await.map_err(StreamableHttpError::Client)?, + session, + )), + "text/event-stream" => Ok(StreamableHttpPostResponse::Sse( + event_stream(response), + session, + )), + other => Err(StreamableHttpError::UnexpectedContentType(Some( + other.into(), + ))), + } + } + + async fn delete_session( + &self, + uri: Arc, + session_id: Arc, + auth_header: Option, + custom_headers: HashMap, + ) -> Result<(), StreamableHttpError> { + let request = self.0.delete(uri.as_ref()); + let response = request_headers(request, Some(&session_id), auth_header, custom_headers)? + .send() + .await + .map_err(StreamableHttpError::Client)?; + if response.status() == StatusCode::METHOD_NOT_ALLOWED { + return Err(StreamableHttpError::ServerDoesNotSupportDeleteSession); + } + response + .error_for_status() + .map_err(StreamableHttpError::Client)?; + Ok(()) + } + + async fn get_stream( + &self, + uri: Arc, + session_id: Arc, + last_event_id: Option, + auth_header: Option, + custom_headers: HashMap, + ) -> Result>, StreamableHttpError> { + let mut request = self.0.get(uri.as_ref()).header(ACCEPT, "text/event-stream"); + if let Some(id) = last_event_id { + request = request.header("last-event-id", id); + } + let response = request_headers(request, Some(&session_id), auth_header, custom_headers)? + .send() + .await + .map_err(StreamableHttpError::Client)?; + match response.status() { + StatusCode::METHOD_NOT_ALLOWED => { + return Err(StreamableHttpError::ServerDoesNotSupportSse); + } + StatusCode::NOT_FOUND => return Err(StreamableHttpError::SessionExpired), + _ => {} + } + let response = response + .error_for_status() + .map_err(StreamableHttpError::Client)?; + if content_type(&response)? != "text/event-stream" { + return Err(StreamableHttpError::UnexpectedContentType(Some( + content_type(&response)?.into(), + ))); + } + Ok(event_stream(response)) + } +} + +#[cfg(test)] +#[path = "http_client_tests.rs"] +mod tests; diff --git a/crates/jp_mcp/src/server/http_client_tests.rs b/crates/jp_mcp/src/server/http_client_tests.rs new file mode 100644 index 000000000..514dc009e --- /dev/null +++ b/crates/jp_mcp/src/server/http_client_tests.rs @@ -0,0 +1,156 @@ +use axum::{Router, body::Body, extract::Request, http::Response, routing::any}; +use reqwest::StatusCode; +use serde_json::json; +use tokio::{net::TcpListener, task::JoinHandle}; + +use super::*; + +async fn fixture( + status: StatusCode, + content_type: &'static str, + body: &'static str, +) -> (Arc, JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url: Arc = format!("http://{}/mcp", listener.local_addr().unwrap()).into(); + let router = Router::new().route( + "/mcp", + any(move |request: Request| async move { + assert_eq!(request.headers()["mcp-session-id"], "session-1"); + assert_eq!(request.headers()["mcp-protocol-version"], "2025-11-25"); + Response::builder() + .status(status) + .header("content-type", content_type) + .body(Body::from(body)) + .unwrap() + }), + ); + let task = tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + (url, task) +} + +fn headers() -> HashMap { + HashMap::from([( + HeaderName::from_static("mcp-protocol-version"), + HeaderValue::from_static("2025-11-25"), + )]) +} + +#[tokio::test] +async fn post_decodes_json_response() { + let (url, server) = fixture( + StatusCode::OK, + "application/json; charset=utf-8", + r#"{"jsonrpc":"2.0","id":1,"result":{"tools":[]}}"#, + ) + .await; + let message = + serde_json::from_value(json!({"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}})) + .unwrap(); + let result = LoopbackClient::new() + .unwrap() + .post_message(url, message, Some("session-1".into()), None, headers()) + .await + .unwrap(); + let StreamableHttpPostResponse::Json(message, session) = result else { + panic!("expected JSON response") + }; + assert_eq!(session, None); + assert_eq!( + serde_json::to_value(message).unwrap(), + json!({"jsonrpc":"2.0","id":1,"result":{"tools":[]}}) + ); + server.abort(); +} + +#[tokio::test] +async fn expired_session_is_not_a_new_request() { + let (url, server) = fixture(StatusCode::NOT_FOUND, "text/plain", "expired").await; + let message = + serde_json::from_value(json!({"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}})) + .unwrap(); + let result = LoopbackClient::new() + .unwrap() + .post_message(url, message, Some("session-1".into()), None, headers()) + .await; + assert!(matches!(result, Err(StreamableHttpError::SessionExpired))); + server.abort(); +} + +#[tokio::test] +async fn unsupported_stream_is_explicit() { + let (url, server) = fixture(StatusCode::METHOD_NOT_ALLOWED, "text/plain", "unsupported").await; + let result = LoopbackClient::new() + .unwrap() + .get_stream(url, "session-1".into(), None, None, headers()) + .await; + assert!(matches!( + result, + Err(StreamableHttpError::ServerDoesNotSupportSse) + )); + server.abort(); +} + +#[tokio::test] +async fn unsupported_deletion_is_explicit() { + let (url, server) = fixture(StatusCode::METHOD_NOT_ALLOWED, "text/plain", "unsupported").await; + let result = LoopbackClient::new() + .unwrap() + .delete_session(url, "session-1".into(), None, headers()) + .await; + assert!(matches!( + result, + Err(StreamableHttpError::ServerDoesNotSupportDeleteSession) + )); + server.abort(); +} + +#[tokio::test] +async fn get_stream_preserves_event_ids_and_data() { + let (url, server) = fixture( + StatusCode::OK, + "text/event-stream", + "id: event-1\nevent: message\ndata: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{}}\n\n", + ) + .await; + let mut stream = LoopbackClient::new() + .unwrap() + .get_stream(url, "session-1".into(), None, None, headers()) + .await + .unwrap(); + let event = stream.next().await.unwrap().unwrap(); + assert_eq!(event.id.as_deref(), Some("event-1")); + assert_eq!( + event.data.as_deref(), + Some(r#"{"jsonrpc":"2.0","id":1,"result":{}}"#) + ); + assert!(stream.next().await.is_none()); + server.abort(); +} + +#[test] +fn caller_headers_cannot_override_the_session() { + let request = Client::new().get("http://127.0.0.1/mcp"); + let headers = HashMap::from([( + HeaderName::from_static("mcp-session-id"), + HeaderValue::from_static("wrong-session"), + )]); + let result = request_headers(request, Some("session-1"), None, headers); + assert!( + matches!(result, Err(StreamableHttpError::ReservedHeaderConflict(name)) if name == "mcp-session-id") + ); +} + +#[tokio::test] +async fn unexpected_content_type_is_rejected() { + let (url, server) = fixture(StatusCode::OK, "text/html", "not MCP").await; + let result = LoopbackClient::new() + .unwrap() + .get_stream(url, "session-1".into(), None, None, headers()) + .await; + assert!( + matches!(result, Err(StreamableHttpError::UnexpectedContentType(Some(value))) if value == "text/html") + ); + server.abort(); +} diff --git a/crates/jp_mcp/src/server/http_tests.rs b/crates/jp_mcp/src/server/http_tests.rs index 23872dd09..61ba27df9 100644 --- a/crates/jp_mcp/src/server/http_tests.rs +++ b/crates/jp_mcp/src/server/http_tests.rs @@ -9,7 +9,8 @@ use jp_config::{ AppConfig, Config as _, conversation::tool::{PartialToolConfig, ToolConfig}, }; -use jp_tool::{Outcome, ToolDefinition, ToolDocs}; +use jp_tool::{Outcome, ToolDefinition, ToolDocs, ToolResult}; +use reqwest::Client as HttpClient; use rmcp::model::CallToolRequestParams; use serde_json::{Map, Value, json}; use tokio::time::{Duration, timeout}; @@ -102,12 +103,12 @@ async fn http_call_waits_for_host_release_and_records_edited_result() { let Interaction::Review { result, reply, .. } = host.recv().await.unwrap().interaction else { panic!("expected review") }; - assert_eq!(result, Ok("raw".into())); - reply.send(Ok(Ok("edited".into()))).unwrap(); + assert_eq!(result, ToolResult::text("raw")); + reply.send(Ok(ToolResult::text("edited"))).unwrap(); let Interaction::Record { result, reply, .. } = host.recv().await.unwrap().interaction else { panic!("expected record") }; - assert_eq!(result, Ok("edited".into())); + assert_eq!(result, ToolResult::text("edited")); assert!(!task.is_finished()); reply.send(Ok(())).unwrap(); let result = timeout(Duration::from_secs(2), task) diff --git a/crates/jp_mcp/src/server/result.rs b/crates/jp_mcp/src/server/result.rs new file mode 100644 index 000000000..eeb094711 --- /dev/null +++ b/crates/jp_mcp/src/server/result.rs @@ -0,0 +1,252 @@ +//! Conversions between ordered tool results and MCP wire content. + +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use jp_tool::{ + ContentBlock, ToolResult, + content::{ + Annotations, ErrorDetails, ImageContent, Resource, ResourceContent, ResourceLink, + ToolStatus, + }, +}; +use rmcp::model::{ + AnnotateAble as _, Meta, RawAudioContent, RawContent, RawTextContent, ResourceContents, +}; +use serde::{Serialize, de::DeserializeOwned}; +use serde_json::{Error as JsonError, Map, Value}; + +use crate::{CallToolResult, Content}; + +const ERROR_METADATA: &str = "computer.jp/error"; + +/// A result cannot be represented at the MCP boundary. +#[derive(Debug, thiserror::Error)] +pub enum ResultError { + /// Typed protocol metadata is malformed or incompatible. + #[error("Invalid tool result metadata: {0}")] + Metadata(#[from] JsonError), + /// Questions must be answered before a final MCP result is delivered. + #[error("Tool result still requires input")] + UnansweredQuestion, +} + +fn convert(value: T) -> Result { + serde_json::from_value(serde_json::to_value(value)?) +} + +/// Decode a native result without projecting away non-text content. +pub fn from_mcp(result: CallToolResult) -> Result { + let metadata = result.meta.map(|meta| meta.0); + let status = match result.is_error { + None => ToolStatus::Unspecified, + Some(false) => ToolStatus::Success, + Some(true) => ToolStatus::Error( + match metadata.as_ref().and_then(|meta| meta.get(ERROR_METADATA)) { + Some(value) => serde_json::from_value(value.clone())?, + None => ErrorDetails::default(), + }, + ), + }; + let content = result + .content + .into_iter() + .map(from_content) + .collect::>()?; + Ok(ToolResult { + content, + status, + structured_content: result.structured_content, + metadata, + }) +} + +fn from_content(content: Content) -> Result { + let annotations: Option = content.annotations.map(convert).transpose()?; + Ok(match content.raw { + RawContent::Text(text) => ContentBlock::Text { + text: text.text, + mime_type: None, + annotations, + metadata: text.meta.map(|meta| meta.0), + }, + RawContent::Image(image) => ContentBlock::Image(ImageContent { + data: image.data, + mime_type: image.mime_type, + annotations, + metadata: image.meta.map(|meta| meta.0), + }), + RawContent::Audio(audio) => ContentBlock::Audio { + data: audio.data, + mime_type: audio.mime_type, + annotations, + }, + RawContent::Resource(embedded) => { + let (uri, mime_type, content, content_metadata) = match embedded.resource { + ResourceContents::TextResourceContents { + uri, + mime_type, + text, + meta, + } => (uri, mime_type, ResourceContent::Text(text), meta), + ResourceContents::BlobResourceContents { + uri, + mime_type, + blob, + meta, + } => (uri, mime_type, ResourceContent::EncodedBlob(blob), meta), + }; + ContentBlock::Resource(Resource { + uri, + content, + mime_type, + annotations, + metadata: embedded.meta.map(|meta| meta.0), + content_metadata: content_metadata.map(|meta| meta.0), + name: None, + title: None, + description: None, + formatted: None, + }) + } + RawContent::ResourceLink(link) => { + let mut link: ResourceLink = convert(link)?; + link.annotations = annotations; + ContentBlock::ResourceLink(link) + } + }) +} + +/// Encode the final result; an unresolved question is a protocol error. +pub fn to_mcp(result: ToolResult) -> Result { + let ToolResult { + content, + status, + structured_content, + mut metadata, + } = result; + let is_error = match status { + ToolStatus::Unspecified => None, + ToolStatus::Success => Some(false), + ToolStatus::Error(error) => { + if error != ErrorDetails::default() + || metadata + .as_ref() + .is_some_and(|meta| meta.contains_key(ERROR_METADATA)) + { + let metadata = metadata.get_or_insert_with(Map::new); + let mut details: Map = match metadata.remove(ERROR_METADATA) { + Some(value) => serde_json::from_value(value)?, + None => Map::new(), + }; + let encoded: Map = convert(error)?; + details.extend(encoded); + metadata.insert(ERROR_METADATA.into(), Value::Object(details)); + } + Some(true) + } + }; + let mut result = CallToolResult::success( + content + .into_iter() + .map(to_content) + .collect::>()?, + ); + result.structured_content = structured_content; + result.is_error = is_error; + result.meta = metadata.map(Meta); + Ok(result) +} + +fn to_content(block: ContentBlock) -> Result { + let (mut content, annotations) = match block { + ContentBlock::Text { + text, + annotations, + metadata, + .. + } => ( + RawContent::Text(RawTextContent { + text, + meta: metadata.map(Meta), + }) + .no_annotation(), + annotations, + ), + ContentBlock::Image(media) => { + let mut content = Content::image(media.data, media.mime_type); + if let RawContent::Image(image) = &mut content.raw { + image.meta = media.metadata.map(Meta); + } + (content, media.annotations) + } + ContentBlock::Audio { + data, + mime_type, + annotations, + } => { + let raw = RawAudioContent { data, mime_type }; + (RawContent::Audio(raw).no_annotation(), annotations) + } + ContentBlock::Resource(resource) => { + let embedded = match resource.content { + ResourceContent::Text(text) => ResourceContents::TextResourceContents { + uri: resource.uri, + mime_type: resource.mime_type, + text, + meta: resource.content_metadata.map(Meta), + }, + ResourceContent::EncodedBlob(blob) => ResourceContents::BlobResourceContents { + uri: resource.uri, + mime_type: resource.mime_type, + blob, + meta: resource.content_metadata.map(Meta), + }, + ResourceContent::Blob(bytes) => ResourceContents::BlobResourceContents { + uri: resource.uri, + mime_type: resource.mime_type, + blob: STANDARD.encode(bytes), + meta: resource.content_metadata.map(Meta), + }, + }; + let mut content = Content::resource(embedded); + if let RawContent::Resource(embedded) = &mut content.raw { + embedded.meta = resource.metadata.map(Meta); + } + (content, resource.annotations) + } + ContentBlock::ResourceLink(mut link) => { + let annotations = link.annotations.take(); + (Content::resource_link(convert(link)?), annotations) + } + ContentBlock::Question(_) => return Err(ResultError::UnansweredQuestion), + }; + content.annotations = annotations.map(convert).transpose()?; + Ok(content) +} + +/// Existing conversation-format projection, applied by the MCP Host only. +/// Image, audio, and links contribute no text; embedded blobs retain their +/// base64 form. +pub fn to_legacy(result: &ToolResult) -> Result { + let text = result + .content + .iter() + .filter_map(|block| match block { + ContentBlock::Text { text, .. } => Some(text.clone()), + ContentBlock::Resource(resource) => Some(match &resource.content { + ResourceContent::Text(text) | ResourceContent::EncodedBlob(text) => text.clone(), + ResourceContent::Blob(bytes) => STANDARD.encode(bytes), + }), + _ => None, + }) + .collect::>() + .join("\n\n"); + if result.is_error() { + Err(text) + } else { + Ok(text) + } +} + +#[cfg(test)] +#[path = "result_tests.rs"] +mod tests; diff --git a/crates/jp_mcp/src/server/result_tests.rs b/crates/jp_mcp/src/server/result_tests.rs new file mode 100644 index 000000000..d88176882 --- /dev/null +++ b/crates/jp_mcp/src/server/result_tests.rs @@ -0,0 +1,88 @@ +use jp_tool::{Outcome, Question}; +use serde_json::json; + +use super::*; + +#[test] +fn native_content_round_trips_through_shared_result() { + let wire = json!({ + "content": [ + {"type":"text", "text":"first", "_meta":{"vendor":"text"}, "annotations":{"audience":["assistant"],"priority":0.5}}, + {"type":"image", "data":"aW1hZ2U=", "mimeType":"image/png", "_meta":{"vendor":"image"}}, + {"type":"audio", "data":"YXVkaW8=", "mimeType":"audio/wav"}, + {"type":"resource", "resource":{"uri":"file:///a", "text":"embedded", "mimeType":"text/plain", "_meta":{"inner":true}}, "_meta":{"outer":true}}, + {"type":"resource", "resource":{"uri":"file:///b", "blob":"YmxvYg=="}}, + {"type":"resource_link", "uri":"file:///c", "name":"c", "size":42, "icons":[{"src":"file:///icon", "theme":"dark"}]} + ], + "structuredContent":{"number":42}, + "_meta":{"vendor":{"preserved":true}}, + "isError":false + }); + let native = serde_json::from_value(wire.clone()).unwrap(); + let result = from_mcp(native).unwrap(); + assert_eq!(result.content.len(), 6); + assert!(matches!(result.content[1], ContentBlock::Image(_))); + assert_eq!( + to_legacy(&result), + Ok("first\n\nembedded\n\nYmxvYg==".into()) + ); + assert_eq!(serde_json::to_value(to_mcp(result).unwrap()).unwrap(), wire); +} + +#[test] +fn error_details_survive_mcp_encoding() { + let result = ToolResult::from(Outcome::Error { + message: "busy".into(), + trace: vec!["upstream".into()], + transient: true, + }); + let native = to_mcp(result.clone()).unwrap(); + assert_eq!( + serde_json::to_value(&native).unwrap(), + json!({ + "content":[{"type":"text","text":"busy\n\nTrace:\nupstream"}], + "isError":true, + "_meta":{"computer.jp/error":{"transient":true,"trace":["upstream"]}} + }) + ); + let decoded = from_mcp(native).unwrap(); + assert_eq!(decoded.status, result.status); + assert_eq!(to_legacy(&decoded), Err("busy\n\nTrace:\nupstream".into())); +} + +#[test] +fn unresolved_input_cannot_be_sent_as_final_output() { + let result = ToolResult::from(Outcome::NeedsInput { + question: Question::boolean("confirm", "Proceed?").unwrap(), + }); + assert!(matches!( + to_mcp(result), + Err(ResultError::UnansweredQuestion) + )); +} + +#[test] +fn error_metadata_extensions_and_empty_audience_survive() { + let wire = json!({ + "content":[{"type":"text","text":"failed", "annotations":{"audience":[]}}], + "isError":true, + "_meta":{"computer.jp/error":{"transient":false,"trace":[],"vendorCode":17}} + }); + let result = from_mcp(serde_json::from_value(wire.clone()).unwrap()).unwrap(); + assert_eq!(serde_json::to_value(to_mcp(result).unwrap()).unwrap(), wire); +} + +#[test] +fn malformed_error_metadata_is_rejected() { + let wire = json!({"content":[], "isError":true, "_meta":{"computer.jp/error":{"transient":"yes", "trace":[]}}}); + let error = from_mcp(serde_json::from_value(wire).unwrap()).unwrap_err(); + assert!(error.is_data()); +} + +#[test] +fn omitted_status_is_preserved() { + let wire = json!({"content":[]}); + let result = from_mcp(serde_json::from_value(wire.clone()).unwrap()).unwrap(); + assert_eq!(result.status, ToolStatus::Unspecified); + assert_eq!(serde_json::to_value(to_mcp(result).unwrap()).unwrap(), wire); +} diff --git a/crates/jp_mcp/src/server/service.rs b/crates/jp_mcp/src/server/service.rs index 9e4e05790..e0881c200 100644 --- a/crates/jp_mcp/src/server/service.rs +++ b/crates/jp_mcp/src/server/service.rs @@ -8,6 +8,7 @@ use std::{ collections::HashMap, + error::Error as StdError, sync::{Arc, Mutex, MutexGuard, PoisonError}, }; @@ -17,7 +18,8 @@ use jp_config::conversation::tool::{ FormatMode, ResultMode, RunMode, ToolConfigWithDefaults, ToolSource, style::ParametersStyle, }; use jp_tool::{ - AccessPolicy, Action, ContentBlock, Error as ToolError, InputRequest, ToolDefinition, + AccessPolicy, Action, ContentBlock, Error as ToolError, InputRequest, QuestionId, + ToolDefinition, ToolResult, definition::{apply_parameter_defaults, validate_tool_arguments}, schema::Node, }; @@ -26,10 +28,13 @@ use tokio::sync::{Notify, broadcast, mpsc, oneshot}; use tokio_util::sync::CancellationToken; use super::{ - CommandResult, ExecutionOutcome, InvocationContext, builtin::BuiltinExecutors, execute, + CommandResult, ExecutionOutcome, InvocationContext, + builtin::BuiltinExecutors, + execute, + result::{ResultError, to_mcp}, run_tool_command, tool_context, }; -use crate::{CallToolResult, Client, Content}; +use crate::{CallToolResult, Client}; /// A tool resolved under trusted MCP Host configuration. #[derive(Clone, Debug)] @@ -40,7 +45,7 @@ pub struct ConfiguredTool { pub config: ToolConfigWithDefaults, /// Compiled access grants supplied by the MCP Host, never by an MCP caller. /// A compilation failure is delivered as a tool error without execution. - pub access: Result, String>, + pub access: Result, AccessPolicyError>, /// Opaque Host-supplied metadata advertised on this tool's MCP description. /// It does not change execution policy or interpret vendor-specific hints. pub metadata: Map, @@ -91,8 +96,39 @@ pub type HostReply = Result; /// A failure reported by the MCP Host, without a persisted conversation type. #[derive(Debug, Clone, thiserror::Error)] -#[error("MCP Host operation failed: {0}")] -pub struct HostError(pub String); +pub enum HostError { + /// The Host could not record the event under its persistence policy. + #[error("MCP Host operation failed: {0}")] + Recording(#[source] Arc), +} + +/// Compilation failed before a call could obtain its access policy. +#[derive(Debug, Clone, thiserror::Error)] +#[error("invalid access policy for tool '{tool}': {source}")] +pub struct AccessPolicyError { + /// The configured tool whose policy failed. + pub tool: String, + /// The original compiler error, retained for diagnostics. + #[source] + pub source: Arc, +} + +/// An argument formatter failed without producing presentation text. +#[derive(Debug, Clone, thiserror::Error)] +pub enum FormatterError { + /// The command could not execute. + #[error("{0}")] + Execution(#[source] Arc), + /// Formatters cannot invoke an inquiry cycle. + #[error("Custom arguments formatter requested input.")] + InputRequired, + /// The formatter ran and reported a tool error. + #[error("{message}")] + Reported { + /// Formatter diagnostic text, including any tool-supplied trace. + message: String, + }, +} /// Whether the Host approved execution, and which edited arguments to use. #[derive(Debug)] @@ -105,7 +141,7 @@ pub enum Admission { Skip { reason: String }, /// Resolve a call without execution, preserving an error response if /// needed. - Complete { result: Result }, + Complete { result: ToolResult }, } /// The Host may answer a question or resolve the call without another attempt. @@ -114,7 +150,7 @@ pub enum InputAnswer { /// Validated by the service before another execution attempt. Answer(Value), /// A declined or cancelled inquiry resolves the logical call. - Complete { result: Result }, + Complete { result: ToolResult }, } impl From for InputAnswer { @@ -129,7 +165,7 @@ pub enum ReleaseDecision { /// Begin execution with the approved arguments. Execute, /// Preparation failed or the Host stopped the call before execution. - Complete { result: Result }, + Complete { result: ToolResult }, } /// Host-only services needed by the per-call execution state machine. @@ -150,7 +186,7 @@ pub enum Interaction { arguments: Map, /// Custom formatter output, if formatting was permitted before /// approval. - formatted_arguments: Option>, + formatted_arguments: Option>, /// One reply for this preparation operation. reply: oneshot::Sender>, }, @@ -159,7 +195,7 @@ pub enum Interaction { /// Validated arguments that will actually execute. arguments: Map, /// Custom representation of the approved arguments, if requested. - formatted_arguments: Option>, + formatted_arguments: Option>, /// Permission to execute, or a final response without execution. reply: oneshot::Sender>, }, @@ -180,18 +216,18 @@ pub enum Interaction { /// The required delivery interaction. mode: ResultMode, /// Unedited execution result. - result: Result, + result: ToolResult, /// The content approved for delivery, including skip explanations. - reply: oneshot::Sender>>, + reply: oneshot::Sender>, }, /// Acknowledge final recording before returning the result to the caller. Record { /// Post-edit execution arguments, separate from `CallInfo::request`. arguments: Map, /// Original completed result; absent for skipped calls. - raw_result: Option>, + raw_result: Option, /// Content approved for delivery. - result: Result, + result: ToolResult, /// Acknowledges the Host's configured persistence policy, not an /// unconditional disk write. reply: oneshot::Sender>, @@ -223,12 +259,18 @@ pub enum ServiceError { /// The Host declined an operation, including failed recording. #[error(transparent)] Host(#[from] HostError), + /// Access policy compilation failed before formatting or execution. + #[error(transparent)] + Access(#[from] AccessPolicyError), + /// A final result cannot be represented by the MCP transport. + #[error(transparent)] + Result(#[from] ResultError), /// Tool lookup, validation, or execution failed. #[error(transparent)] Tool(#[from] ToolError), /// The Host returned data outside the tool's requested answer shape. #[error("Invalid answer for tool question `{0}`")] - InvalidAnswer(String), + InvalidAnswer(QuestionId), /// An argument violates the schema's type or enumeration. #[error("Invalid tool argument at `{path}`: value violates its type or enum")] InvalidArgument { path: String }, @@ -278,18 +320,17 @@ impl Call { } /// Wait for execution and the final Host recording acknowledgement. - pub async fn finish(self) -> Result, ServiceError> { + pub async fn finish(self) -> Result { self.result .await .map_err(|_| ServiceError::TaskLost)? - .map(|output| output.text) + .map(|output| output.result) } } #[derive(Debug)] struct CallOutput { - text: Result, - native: Option, + result: ToolResult, delivery_decided: bool, } @@ -297,10 +338,7 @@ impl Call { /// Receive the complete MCP result, retaining unedited upstream content. pub async fn finish_mcp(self) -> Result { let output = self.result.await.map_err(|_| ServiceError::TaskLost)??; - Ok(output.native.unwrap_or_else(|| match output.text { - Ok(text) => CallToolResult::success(vec![Content::text(text)]), - Err(text) => CallToolResult::error(vec![Content::text(text)]), - })) + Ok(to_mcp(output.result)?) } } @@ -609,7 +647,9 @@ async fn run_call( .await? }; let admission = match admission { - Admission::Skip { reason } => Admission::Complete { result: Ok(reason) }, + Admission::Skip { reason } => Admission::Complete { + result: ToolResult::text(reason), + }, other => other, }; arguments = match admission { @@ -623,8 +663,7 @@ async fn run_call( }) .await?; return Ok(CallOutput { - text: result, - native: None, + result, delivery_decided: true, }); } @@ -647,8 +686,7 @@ async fn run_call( ), ReleaseDecision::Complete { result } => ( CallOutput { - text: result, - native: None, + result, delivery_decided: true, }, false, @@ -666,15 +704,14 @@ async fn deliver_result( executed: bool, ) -> Result { let CallOutput { - text: raw_result, - native, + result: raw_result, delivery_decided, } = output; let result = if delivery_decided { raw_result.clone() } else { match tool.config.result() { - ResultMode::Skip => Ok("Result delivery skipped by configuration.".into()), + ResultMode::Skip => ToolResult::text("Result delivery skipped by configuration."), ResultMode::Unattended => raw_result.clone(), mode @ (ResultMode::Ask | ResultMode::Edit) => { ask(inner, call, |reply| Interaction::Review { @@ -686,8 +723,6 @@ async fn deliver_result( } } }; - let native = - native.filter(|_| result == raw_result && tool.config.result() != ResultMode::Skip); ask(inner, call, |reply| Interaction::Record { arguments, raw_result: (executed && !delivery_decided).then_some(raw_result), @@ -696,8 +731,7 @@ async fn deliver_result( }) .await?; Ok(CallOutput { - text: result, - native, + result, delivery_decided: true, }) } @@ -713,8 +747,7 @@ async fn execute_with_answers( Ok(access) => access.as_ref(), Err(error) => { return Ok(CallOutput { - text: Err(error.clone()), - native: None, + result: ToolResult::error(error.to_string()), delivery_decided: false, }); } @@ -746,10 +779,9 @@ async fn execute_with_answers( .await?; match outcome { ExecutionOutcome::Cancelled { .. } => return Err(ServiceError::Cancelled), - ExecutionOutcome::Completed { result, native, .. } => { + ExecutionOutcome::Completed { result, .. } => { return Ok(CallOutput { - text: result, - native, + result, delivery_decided: false, }); } @@ -772,14 +804,13 @@ async fn execute_with_answers( InputAnswer::Answer(answer) => answer, InputAnswer::Complete { result } => { return Ok(CallOutput { - text: result, - native: None, + result, delivery_decided: true, }); } }; if !Node::root(&Value::Object(request.schema)).permits(&answer) { - return Err(ServiceError::InvalidAnswer(request.id.to_string())); + return Err(ServiceError::InvalidAnswer(request.id.clone())); } answers.insert(request.id.to_string(), answer); } @@ -792,7 +823,7 @@ async fn format_arguments( tool: &ConfiguredTool, arguments: &Map, cancellation: &CancellationToken, -) -> Result, ServiceError> { +) -> Result, ServiceError> { let ParametersStyle::Custom(command) = &tool.config.style().parameters else { return Ok(Ok(String::new())); }; @@ -808,10 +839,7 @@ async fn format_arguments( &tool.config, &inner.root, &Action::FormatArguments, - tool.access - .as_ref() - .map_err(|error| ServiceError::Host(HostError(error.clone())))? - .as_ref(), + tool.access.as_ref().map_err(Clone::clone)?.as_ref(), &inner.invocation, ); let result = match run_tool_command( @@ -824,18 +852,29 @@ async fn format_arguments( .await { Ok(result) => result, - Err(error) => return Ok(Err(error.to_string())), + Err(error) => return Ok(Err(FormatterError::Execution(Arc::new(error)))), }; match result { - CommandResult::NeedsInput(_) => { - Ok(Err("Custom arguments formatter requested input.".into())) - } + CommandResult::NeedsInput(_) => Ok(Err(FormatterError::InputRequired)), CommandResult::Cancelled => Err(ServiceError::Cancelled), CommandResult::Success(text) => Ok(Ok(text.trim().into())), - CommandResult::TransientError { message, trace } => { - Ok(Err(CommandResult::format_error(&message, &trace))) + CommandResult::TransientError { message, trace } => Ok(Err(FormatterError::Reported { + message: CommandResult::format_error(&message, &trace), + })), + other => { + let result = other.into_tool_result(name); + let message = result + .content + .iter() + .filter_map(ContentBlock::as_text) + .collect::>() + .join("\n\n"); + if result.is_error() { + Ok(Err(FormatterError::Reported { message })) + } else { + Ok(Ok(message.trim().into())) + } } - other => Ok(other.into_tool_result(name).map(|text| text.trim().into())), } } diff --git a/crates/jp_mcp/src/server/service_tests.rs b/crates/jp_mcp/src/server/service_tests.rs index 6705b6e65..5e716997c 100644 --- a/crates/jp_mcp/src/server/service_tests.rs +++ b/crates/jp_mcp/src/server/service_tests.rs @@ -2,6 +2,7 @@ use std::fs; use std::{ future::pending, + io, sync::{ Arc, atomic::{AtomicUsize, Ordering}, @@ -152,17 +153,20 @@ async fn preparation_release_input_and_delivery_use_distinct_acknowledgements() }; assert_eq!( result, - Ok(r#"{"arguments":{"path":"edited"},"answer":true}"#.into()) + ToolResult::text(r#"{"arguments":{"path":"edited"},"answer":true}"#) ); assert_eq!(count.load(Ordering::SeqCst), 2); - reply.send(Ok(Ok("edited result".into()))).unwrap(); + reply.send(Ok(ToolResult::text("edited result"))).unwrap(); let Interaction::Record { result, reply, .. } = next(&mut host).await.interaction else { panic!("expected recording") }; - assert_eq!(result, Ok("edited result".into())); + assert_eq!(result, ToolResult::text("edited result")); assert!(!call.is_finished()); reply.send(Ok(())).unwrap(); - assert_eq!(call.finish().await.unwrap(), Ok("edited result".into())); + assert_eq!( + call.finish().await.unwrap(), + ToolResult::text("edited result") + ); service.shutdown().await.unwrap(); } @@ -181,9 +185,9 @@ async fn denied_call_never_executes() { let Interaction::Record { reply, result, .. } = next(&mut host).await.interaction else { panic!("expected recording") }; - assert_eq!(result, Ok("denied".into())); + assert_eq!(result, ToolResult::text("denied")); reply.send(Ok(())).unwrap(); - assert_eq!(call.finish().await.unwrap(), Ok("denied".into())); + assert_eq!(call.finish().await.unwrap(), ToolResult::text("denied")); assert_eq!(count.load(Ordering::SeqCst), 0); } @@ -272,9 +276,13 @@ async fn failed_recording_prevents_result_delivery() { let Interaction::Record { reply, .. } = next(&mut host).await.interaction else { panic!("expected recording") }; - reply.send(Err(HostError("disk full".into()))).unwrap(); + reply + .send(Err(HostError::Recording(Arc::new(io::Error::other( + "disk full", + ))))) + .unwrap(); assert!( - matches!(call.finish().await, Err(ServiceError::Host(HostError(reason))) if reason == "disk full") + matches!(call.finish().await, Err(ServiceError::Host(HostError::Recording(source))) if source.to_string() == "disk full") ); assert_eq!(count.load(Ordering::SeqCst), 2); } @@ -303,7 +311,7 @@ async fn current_call_cancellation_does_not_poison_later_calls() { reply.send(Ok(())).unwrap(); assert_eq!( second.finish().await.unwrap(), - Ok(r#"{"arguments":{"path":"original"},"answer":false}"#.into()) + ToolResult::text(r#"{"arguments":{"path":"original"},"answer":false}"#) ); assert_eq!(count.load(Ordering::SeqCst), 3); } @@ -345,7 +353,7 @@ async fn identical_calls_have_independent_answers_and_out_of_order_delivery() { reply.send(Ok(())).unwrap(); assert_eq!( second.finish().await.unwrap(), - Ok(r#"{"arguments":{"path":"original"},"answer":false}"#.into()) + ToolResult::text(r#"{"arguments":{"path":"original"},"answer":false}"#) ); assert!(!first.is_finished()); first_answer.send(Ok(json!(true).into())).unwrap(); @@ -357,7 +365,7 @@ async fn identical_calls_have_independent_answers_and_out_of_order_delivery() { reply.send(Ok(())).unwrap(); assert_eq!( first.finish().await.unwrap(), - Ok(r#"{"arguments":{"path":"original"},"answer":true}"#.into()) + ToolResult::text(r#"{"arguments":{"path":"original"},"answer":true}"#) ); assert_eq!(count.load(Ordering::SeqCst), 4); } @@ -376,7 +384,7 @@ async fn configured_skip_never_requests_execution_release() { reply.send(Ok(())).unwrap(); assert_eq!( call.finish().await.unwrap(), - Ok("Tool execution skipped by configuration.".into()) + ToolResult::text("Tool execution skipped by configuration.") ); assert_eq!(count.load(Ordering::SeqCst), 0); } @@ -401,18 +409,18 @@ async fn skipped_delivery_records_original_without_delivering_it() { }; assert_eq!( raw_result, - Some(Ok( - r#"{"arguments":{"path":"original"},"answer":true}"#.into() + Some(ToolResult::text( + r#"{"arguments":{"path":"original"},"answer":true}"# )) ); assert_eq!( result, - Ok("Result delivery skipped by configuration.".into()) + ToolResult::text("Result delivery skipped by configuration.") ); reply.send(Ok(())).unwrap(); assert_eq!( call.finish().await.unwrap(), - Ok("Result delivery skipped by configuration.".into()) + ToolResult::text("Result delivery skipped by configuration.") ); assert_eq!(count.load(Ordering::SeqCst), 2); } @@ -472,7 +480,7 @@ async fn local_inquiry_exits_and_runs_a_new_process_with_the_answer() { "run\nrun\n" ); reply.send(Ok(())).unwrap(); - assert_eq!(call.finish().await.unwrap(), Ok("true".into())); + assert_eq!(call.finish().await.unwrap(), ToolResult::text("true")); } #[tokio::test] @@ -582,7 +590,7 @@ async fn formatter_asks_for_visibility_and_waits_for_approval() { else { panic!("expected approval") }; - assert_eq!(formatted_arguments, None); + assert!(formatted_arguments.is_none()); assert!(!root.path().join("formatter-ran").exists()); reply .send(Ok(Admission::Run { @@ -598,7 +606,7 @@ async fn formatter_asks_for_visibility_and_waits_for_approval() { panic!("expected release") }; assert_eq!( - formatted_arguments, + formatted_arguments.map(|result| result.map_err(|error| error.to_string())), Some(Ok("format_arguments:original".into())) ); assert_eq!( @@ -627,7 +635,7 @@ async fn unattended_formatter_is_available_before_approval() { panic!("expected preparation") }; assert_eq!( - formatted_arguments, + formatted_arguments.map(|result| result.map_err(|error| error.to_string())), Some(Ok("format_arguments:original".into())) ); assert!(root.path().join("formatter-ran").exists()); @@ -652,7 +660,7 @@ async fn hidden_presentation_never_executes_formatter() { else { panic!("expected preparation") }; - assert_eq!(formatted_arguments, None); + assert!(formatted_arguments.is_none()); reply .send(Ok(Admission::Skip { reason: "denied".into(), @@ -662,6 +670,6 @@ async fn hidden_presentation_never_executes_formatter() { panic!("expected recording") }; reply.send(Ok(())).unwrap(); - assert_eq!(call.finish().await.unwrap(), Ok("denied".into())); + assert_eq!(call.finish().await.unwrap(), ToolResult::text("denied")); assert!(!root.path().join("formatter-ran").exists()); } diff --git a/crates/jp_mcp/src/server/upstream.rs b/crates/jp_mcp/src/server/upstream.rs index 9c755e105..c63a9354a 100644 --- a/crates/jp_mcp/src/server/upstream.rs +++ b/crates/jp_mcp/src/server/upstream.rs @@ -4,7 +4,7 @@ use jp_tool::Outcome; use serde_json::Value; use tracing::warn; -use crate::{CallToolResult, RawContent, ResourceContents}; +use crate::{CallToolResult, RawContent}; pub(super) enum UpstreamResult { Outcome { @@ -61,32 +61,6 @@ pub(super) fn replace_envelope( response } -/// Project an MCP result for the existing text-only conversation format. -/// -/// Text and embedded resource content contribute to the result. -/// Image, audio, and resource-link blocks remain available only in the original -/// MCP result. -pub fn text_result(result: &CallToolResult) -> Result { - let text = result - .content - .iter() - .filter_map(|content| match &content.raw { - RawContent::Text(text) => Some(text.text.as_str()), - RawContent::Resource(resource) => match &resource.resource { - ResourceContents::TextResourceContents { text, .. } => Some(text.as_str()), - ResourceContents::BlobResourceContents { blob, .. } => Some(blob.as_str()), - }, - RawContent::Image(_) | RawContent::Audio(_) | RawContent::ResourceLink(_) => None, - }) - .collect::>() - .join("\n\n"); - if result.is_error.unwrap_or_default() { - Err(text) - } else { - Ok(text) - } -} - #[cfg(test)] #[path = "upstream_tests.rs"] mod tests; diff --git a/crates/jp_mcp/src/server_tests.rs b/crates/jp_mcp/src/server_tests.rs index 9577e4ae4..142e19f30 100644 --- a/crates/jp_mcp/src/server_tests.rs +++ b/crates/jp_mcp/src/server_tests.rs @@ -20,12 +20,28 @@ impl BuiltinTool for EchoArguments { } } +#[test] +fn command_error_keeps_details_and_its_conversation_projection() { + let output = br#"{"type":"error","message":"busy","trace":["upstream"],"transient":true}"#; + let result = parse_command_output(output, b"", false).into_tool_result("test"); + assert_eq!( + result.status, + ToolStatus::Error(ErrorDetails { + transient: true, + trace: vec!["upstream".into()], + }) + ); + assert_eq!( + to_legacy(&result), + Err(r#"{"message":"busy","trace":["upstream"]}"#.into()) + ); +} + #[test] fn test_execution_outcome_id() { let completed = ExecutionOutcome::Completed { - native: None, id: "id1".to_string(), - result: Ok(String::new()), + result: ToolResult::text(""), }; assert_eq!(completed.id(), "id1"); @@ -44,18 +60,16 @@ fn test_execution_outcome_id() { #[test] fn test_execution_outcome_helper_methods() { let success = ExecutionOutcome::Completed { - native: None, id: "1".to_string(), - result: Ok("output".to_string()), + result: ToolResult::text("output"), }; assert!(success.is_success()); assert!(!success.needs_input()); assert!(!success.is_cancelled()); let failure = ExecutionOutcome::Completed { - native: None, id: "2".to_string(), - result: Err("error".to_string()), + result: ToolResult::error("error"), }; assert!(!failure.is_success()); assert!(!failure.needs_input()); @@ -95,7 +109,7 @@ fn parse_command_output_dotted_question_id_is_invalid_inquiry() { CommandResult::InvalidInquiry { ref question_id } if question_id == "a.b" )); // Renders as a tool-level error, not raw text. - assert!(result.into_tool_result("t").is_err()); + assert!(result.into_tool_result("t").is_error()); } #[test] @@ -106,7 +120,7 @@ fn parse_command_output_empty_question_id_is_invalid_inquiry() { result, CommandResult::InvalidInquiry { ref question_id } if question_id.is_empty() )); - assert!(result.into_tool_result("t").is_err()); + assert!(result.into_tool_result("t").is_error()); } #[test] @@ -123,7 +137,7 @@ fn parse_command_output_legacy_answer_type_shape_is_malformed_inquiry() { "expected MalformedInquiry, got {result:?}" ); // Renders as a tool-level error, not raw text. - assert!(result.into_tool_result("fs_modify_file").is_err()); + assert!(result.into_tool_result("fs_modify_file").is_error()); } #[test] @@ -136,7 +150,7 @@ fn parse_command_output_needs_input_missing_field_is_malformed_inquiry() { matches!(result, CommandResult::MalformedInquiry { .. }), "expected MalformedInquiry, got {result:?}" ); - assert!(result.into_tool_result("t").is_err()); + assert!(result.into_tool_result("t").is_error()); } #[test] @@ -251,7 +265,7 @@ async fn execute_coerces_json_strings_before_calling_tool() { panic!("expected completed tool call"); }; assert_eq!(id, "call_1"); - assert_eq!(result, Ok(r#"{"start_line":1}"#.to_owned())); + assert_eq!(result, ToolResult::text(r#"{"start_line":1}"#)); } /// Regression: `{{tool}}` must render as valid JSON, including `null` for null @@ -505,14 +519,9 @@ async fn test_execute_local_exposes_invocation_ids_in_context() { .expect("execution succeeds"); match outcome { - ExecutionOutcome::Completed { - native: None, - result: Ok(out), - .. - } => assert!( - out.contains("ws-abc-conv-xyz"), - "expected workspace/conversation IDs in tool output, got: {out:?}" - ), + ExecutionOutcome::Completed { result, .. } => { + assert_eq!(result, ToolResult::text("ws-abc-conv-xyz\n")); + } other => panic!("expected completed success, got: {other:?}"), } } @@ -574,11 +583,9 @@ async fn test_execute_builtin_dispatches_on_source_name() { .expect("execution succeeds"); match outcome { - ExecutionOutcome::Completed { - native: None, - result: Ok(out), - .. - } => assert_eq!(out, "reached"), + ExecutionOutcome::Completed { result, .. } => { + assert_eq!(result, ToolResult::text("reached")); + } other => panic!("expected completed success, got: {other:?}"), } } diff --git a/crates/jp_tool/src/content.rs b/crates/jp_tool/src/content.rs index c89a43074..15184aeb1 100644 --- a/crates/jp_tool/src/content.rs +++ b/crates/jp_tool/src/content.rs @@ -14,6 +14,7 @@ //! Tools speaking the [`Outcome`] protocol are converted at the boundary; see //! the `From` implementations below. +use serde::{Deserialize, Serialize}; use serde_json::{Map, Value, json}; use crate::{AnswerType, Outcome, Question, QuestionId}; @@ -28,23 +29,40 @@ pub struct ToolResult { /// The blocks the tool produced, in the order it produced them. pub content: Vec, - /// Whether the tool reported a failure. - pub is_error: bool, + /// Whether the tool reported success or failure. + pub status: ToolStatus, - /// Extra detail about a failure, when the tool supplied it. - /// - /// Always `None` when `is_error` is `false`. - pub error: Option, + /// Structured data supplied alongside the ordered content. + pub structured_content: Option, + + /// Opaque protocol metadata, preserved across forwarding. + pub metadata: Option>, } impl ToolResult { + /// Whether the result reports a tool failure rather than a service failure. + #[must_use] + pub fn is_error(&self) -> bool { + matches!(self.status, ToolStatus::Error(_)) + } + + /// Failure details supplied by the tool, if any. + #[must_use] + pub fn error_details(&self) -> Option<&ErrorDetails> { + match &self.status { + ToolStatus::Error(error) => Some(error), + ToolStatus::Success | ToolStatus::Unspecified => None, + } + } + /// A successful result carrying one text block. #[must_use] pub fn text(text: impl Into) -> Self { Self { content: vec![ContentBlock::text(text)], - is_error: false, - error: None, + status: ToolStatus::Success, + structured_content: None, + metadata: None, } } @@ -53,8 +71,9 @@ impl ToolResult { pub fn error(text: impl Into) -> Self { Self { content: vec![ContentBlock::text(text)], - is_error: true, - error: Some(ErrorDetails::default()), + status: ToolStatus::Error(ErrorDetails::default()), + structured_content: None, + metadata: None, } } @@ -63,7 +82,7 @@ impl ToolResult { pub fn input_request(&self) -> Option<&InputRequest> { self.content.iter().find_map(|block| match block { ContentBlock::Question(request) => Some(request), - ContentBlock::Text { .. } | ContentBlock::Resource(_) => None, + _ => None, }) } @@ -72,39 +91,37 @@ impl ToolResult { /// Text blocks and the text side of resources are joined with a blank line, /// in content order; a binary resource contributes its URI, since its bytes /// are not text. - /// An error's trace is appended after the message. + /// Error metadata is not appended to the tool's content. /// /// Callers that render blocks themselves should read [`content`] instead. /// /// [`content`]: Self::content #[must_use] pub fn to_text(&self) -> String { - let mut out = self - .content + self.content .iter() .filter_map(ContentBlock::as_text) .collect::>() - .join("\n\n"); - - let trace = self - .error - .as_ref() - .map(|error| error.trace.as_slice()) - .unwrap_or_default(); - - if !trace.is_empty() { - out.push_str(&format!("\n\nTrace:\n{}", trace.join("\n"))); - } - - out + .join("\n\n") } } +/// The status reported by a tool, with failure details attached only to errors. +#[derive(Debug, Clone, PartialEq)] +pub enum ToolStatus { + /// The upstream protocol omitted its optional status field. + Unspecified, + /// The tool explicitly reported success. + Success, + /// The tool reported failure, with optional details. + Error(ErrorDetails), +} + /// Detail a tool attached to a failure. /// /// Arrives as `_meta["computer.jp/error"]` on an MCP-shaped result. /// A failure without it is non-transient with no trace. -#[derive(Debug, Clone, Default, PartialEq)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub struct ErrorDetails { /// Whether running the tool again could succeed. pub transient: bool, @@ -127,11 +144,30 @@ pub enum ContentBlock { /// MCP annotations, carried but not acted on. annotations: Option, + + /// Opaque protocol metadata for this text block. + metadata: Option>, }, /// A resource the tool produced or read. Resource(Resource), + /// Base64-encoded image content. + Image(ImageContent), + + /// Base64-encoded audio content. + Audio { + /// Base64-encoded audio bytes. + data: String, + /// Media type of the audio data. + mime_type: String, + /// Audience, priority, and modification time. + annotations: Option, + }, + + /// A resource reference without embedded content. + ResourceLink(ResourceLink), + /// Input the tool needs before it can finish. Question(InputRequest), } @@ -144,6 +180,7 @@ impl ContentBlock { text: text.into(), mime_type: None, annotations: None, + metadata: None, } } @@ -158,17 +195,17 @@ impl ContentBlock { Self::Text { text, .. } => Some(text), Self::Resource(resource) => match &resource.content { ResourceContent::Text(text) => Some(text), - ResourceContent::Blob(_) => Some(&resource.uri), + ResourceContent::Blob(_) | ResourceContent::EncodedBlob(_) => Some(&resource.uri), }, - Self::Question(_) => None, + Self::Question(_) | Self::Image(_) | Self::Audio { .. } | Self::ResourceLink(_) => None, } } } /// A resource, identified by URI and carrying its content. /// -/// The first four fields are MCP's; the rest are JP's, and an MCP-sourced -/// resource leaves them empty. +/// Embedded resource content and its protocol metadata remain separate from +/// optional presentation information. #[derive(Debug, Clone, PartialEq)] pub struct Resource { /// The URI identifying this resource. @@ -194,9 +231,14 @@ pub struct Resource { /// Content already formatted for the model. /// - /// When set, it is what the model sees; `content` remains the resource's - /// actual bytes. + /// Available to rendering consumers; raw-content projections leave it out. pub formatted: Option, + + /// Opaque metadata on the enclosing content block. + pub metadata: Option>, + + /// Opaque metadata on the embedded resource itself. + pub content_metadata: Option>, } impl Resource { @@ -212,6 +254,8 @@ impl Resource { title: None, description: None, formatted: None, + metadata: None, + content_metadata: None, } } } @@ -224,26 +268,103 @@ pub enum ResourceContent { /// Bytes, such as an image or a PDF. Blob(Vec), + + /// Base64 data received from MCP, retained without rewriting its encoding. + EncodedBlob(String), +} + +/// An image block, retaining its encoded bytes, media type, and metadata. +#[derive(Debug, Clone, PartialEq)] +pub struct ImageContent { + /// Base64-encoded data as supplied by the tool. + pub data: String, + /// Media type of the encoded data. + pub mime_type: String, + /// Audience, priority, and modification time. + pub annotations: Option, + /// Opaque protocol metadata. + pub metadata: Option>, +} + +/// An MCP resource reference without embedded content. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ResourceLink { + /// URI identifying the resource. + pub uri: String, + /// Machine-readable resource name. + pub name: String, + /// Display title. + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + /// Human-readable description. + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + /// Resource media type. + #[serde(skip_serializing_if = "Option::is_none")] + pub mime_type: Option, + /// Declared resource size in bytes. + #[serde(skip_serializing_if = "Option::is_none")] + pub size: Option, + /// Resource icons and their presentation hints. + #[serde(skip_serializing_if = "Option::is_none")] + pub icons: Option>, + /// Audience, priority, and modification time. + #[serde(skip_serializing_if = "Option::is_none")] + pub annotations: Option, + /// Opaque protocol metadata. + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub metadata: Option>, +} + +/// An icon supplied with a resource reference. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ResourceIcon { + /// URI of the icon, including data URIs. + pub src: String, + /// Icon media type. + #[serde(skip_serializing_if = "Option::is_none")] + pub mime_type: Option, + /// Declared image dimensions, using MCP's size notation. + #[serde(skip_serializing_if = "Option::is_none")] + pub sizes: Option>, + /// Background theme for which the icon is intended. + #[serde(skip_serializing_if = "Option::is_none")] + pub theme: Option, +} + +/// Background theme of a resource icon. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum IconTheme { + Light, + Dark, } /// MCP annotations on a block or resource. /// /// Carried so a result that arrives with them can be handed back out intact. /// Nothing in JP reads them. -#[derive(Debug, Clone, Default, PartialEq)] +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct Annotations { /// Who the content is meant for. - pub audience: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub audience: Option>, /// How important the content is, from `0.0` to `1.0`. + #[serde(skip_serializing_if = "Option::is_none")] pub priority: Option, /// When the content last changed, as an ISO 8601 timestamp. + #[serde(skip_serializing_if = "Option::is_none")] pub last_modified: Option, } /// A party in the conversation, as MCP names them. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] pub enum Role { User, Assistant, @@ -314,6 +435,15 @@ impl AnswerType { } } +impl From> for ToolResult { + fn from(result: Result) -> Self { + match result { + Ok(text) => Self::text(text), + Err(text) => Self::error(text), + } + } +} + impl From for ToolResult { fn from(outcome: Outcome) -> Self { match outcome { @@ -323,15 +453,30 @@ impl From for ToolResult { trace, transient, } => Self { - content: vec![ContentBlock::text(message)], - is_error: true, - error: Some(ErrorDetails { transient, trace }), - }, - Outcome::NeedsInput { question } => Self { - content: vec![ContentBlock::Question(question.into())], - is_error: false, - error: None, + content: vec![ContentBlock::text(if trace.is_empty() { + message + } else { + format!("{message}\n\nTrace:\n{}", trace.join("\n")) + })], + status: ToolStatus::Error(ErrorDetails { transient, trace }), + structured_content: None, + metadata: None, }, + Outcome::NeedsInput { mut question } => { + let mut content: Vec<_> = question + .pre_amble + .take() + .into_iter() + .map(ContentBlock::text) + .collect(); + content.push(ContentBlock::Question(question.into())); + Self { + content, + status: ToolStatus::Success, + structured_content: None, + metadata: None, + } + } } } } diff --git a/crates/jp_tool/src/content_tests.rs b/crates/jp_tool/src/content_tests.rs index cf4a7e56c..adb7e53b2 100644 --- a/crates/jp_tool/src/content_tests.rs +++ b/crates/jp_tool/src/content_tests.rs @@ -20,8 +20,9 @@ fn a_successful_outcome_becomes_one_text_block() { assert_eq!(result, ToolResult { content: vec![ContentBlock::text("done")], - is_error: false, - error: None, + status: ToolStatus::Success, + structured_content: None, + metadata: None, }); } @@ -34,9 +35,12 @@ fn a_failed_outcome_keeps_its_trace_and_transience() { }); assert_eq!(result, ToolResult { - content: vec![ContentBlock::text("File not found: foo.rs")], - is_error: true, - error: Some(ErrorDetails { + content: vec![ContentBlock::text( + "File not found: foo.rs\n\nTrace:\nio error: No such file or directory" + )], + structured_content: None, + metadata: None, + status: ToolStatus::Error(ErrorDetails { transient: true, trace: vec!["io error: No such file or directory".to_owned()], }), @@ -51,14 +55,23 @@ fn a_needs_input_outcome_is_not_an_error() { question: question("target", AnswerType::Text), }); - assert!(!result.is_error); - assert_eq!(result.error, None); + assert!(!result.is_error()); + assert_eq!(result.error_details(), None); assert_eq!( result.input_request().map(|r| r.id.as_str()), Some("target") ); } +#[test] +fn outcome_conversion_preserves_question_context() { + let result = ToolResult::from(Outcome::NeedsInput { + question: question("target", AnswerType::Text), + }); + assert_eq!(result.to_text(), "A preamble the request does not carry."); + assert_eq!(result.content.len(), 2); +} + #[test] fn a_select_question_becomes_an_enum_schema() { let request = InputRequest::from(question("branch", AnswerType::Select { @@ -113,8 +126,9 @@ fn flattening_joins_blocks_in_order_with_a_blank_line() { ContentBlock::Resource(Resource::text("file:///a.rs", "second")), ContentBlock::text("third"), ], - is_error: false, - error: None, + status: ToolStatus::Success, + structured_content: None, + metadata: None, }; assert_eq!(result.to_text(), "first\n\nsecond\n\nthird"); @@ -129,8 +143,9 @@ fn flattening_names_a_binary_resource_by_its_uri() { content: ResourceContent::Blob(vec![0x89, 0x50, 0x4e, 0x47]), ..Resource::text("file:///shot.png", "") })], - is_error: false, - error: None, + status: ToolStatus::Success, + structured_content: None, + metadata: None, }; assert_eq!(result.to_text(), "file:///shot.png"); @@ -145,15 +160,16 @@ fn flattening_omits_a_question_but_keeps_its_context() { ContentBlock::text("Two hunks remain."), ContentBlock::Question(InputRequest::from(question("stage", AnswerType::Boolean))), ], - is_error: false, - error: None, + status: ToolStatus::Success, + structured_content: None, + metadata: None, }; assert_eq!(result.to_text(), "Two hunks remain."); } #[test] -fn flattening_an_error_appends_its_trace() { +fn outcome_error_trace_is_rendered_once() { let result = ToolResult::from(Outcome::Error { message: "failed".to_owned(), trace: vec!["inner".to_owned(), "innermost".to_owned()], diff --git a/crates/jp_tool/src/definition.rs b/crates/jp_tool/src/definition.rs index d15391e41..d720e3a74 100644 --- a/crates/jp_tool/src/definition.rs +++ b/crates/jp_tool/src/definition.rs @@ -3,9 +3,8 @@ //! A [`ToolDefinition`] is the resolved description of one tool, whatever its //! source: a local command, a built-in implementation, or a tool a configured //! MCP server declares. -//! Building one reads configuration, so that belongs with the configuration -//! types; this module holds the resolved shape and the argument handling that -//! reads its schema. +//! Definition resolution lives in `jp_mcp::server`; this module holds the +//! resolved shape and the argument handling that reads its schema. use indexmap::IndexMap; use serde_json::{Map, Value}; @@ -15,12 +14,16 @@ use crate::{Error, schema::Node}; /// Documentation for a single tool parameter. #[derive(Debug, Clone)] pub struct ParameterDocs { + /// Short description included in the provider's parameter schema. pub summary: Option, + /// Expanded documentation returned by tool discovery. pub description: Option, + /// Usage examples supplied by the tool configuration. pub examples: Option, } impl ParameterDocs { + /// Whether expanded documentation is absent, irrespective of the summary. #[must_use] pub fn is_empty(&self) -> bool { self.description.is_none() && self.examples.is_none() @@ -30,13 +33,18 @@ impl ParameterDocs { /// Documentation for a single tool. #[derive(Debug, Clone, Default)] pub struct ToolDocs { + /// Short description included in the provider's tool schema. pub summary: Option, + /// Expanded tool documentation. pub description: Option, + /// Usage examples supplied by the tool configuration. pub examples: Option, + /// Parameter documentation in declaration order. pub parameters: IndexMap, } impl ToolDocs { + /// Whether expanded tool and parameter documentation is absent. #[must_use] pub fn is_empty(&self) -> bool { self.description.is_none() @@ -56,7 +64,9 @@ impl ToolDocs { /// The definition of a tool. #[derive(Debug, Clone)] pub struct ToolDefinition { + /// Advertised name, which may differ from the upstream implementation name. pub name: String, + /// Descriptions used by providers and tool discovery. pub docs: ToolDocs, /// JSON Schema for the tool's arguments, as its source declared it, with diff --git a/crates/jp_tool/src/lib.rs b/crates/jp_tool/src/lib.rs index c2cf8c15e..0efcf1617 100644 --- a/crates/jp_tool/src/lib.rs +++ b/crates/jp_tool/src/lib.rs @@ -214,16 +214,26 @@ pub struct Question { } impl Question { - /// Create a new text question. - /// Fails if `id` is empty or contains a `.`. - pub fn text(id: impl Into, text: impl Into) -> Result { - Ok(Self { - id: QuestionId::try_from(id.into())?, + /// Construct a question with an already validated identifier. + #[must_use] + pub fn new(id: QuestionId, text: impl Into, answer_type: AnswerType) -> Self { + Self { + id, text: text.into(), + answer_type, pre_amble: None, - answer_type: AnswerType::Text, default: None, - }) + } + } + + /// Create a new text question. + /// Fails if `id` is empty or contains a `.`. + pub fn text(id: impl Into, text: impl Into) -> Result { + Ok(Self::new( + QuestionId::try_from(id.into())?, + text, + AnswerType::Text, + )) } /// Create a new boolean question. diff --git a/docs/rfd/109-in-process-jp-mcp-server.md b/docs/rfd/109-in-process-jp-mcp-server.md index 5bad04772..2a7c7435c 100644 --- a/docs/rfd/109-in-process-jp-mcp-server.md +++ b/docs/rfd/109-in-process-jp-mcp-server.md @@ -1,10 +1,9 @@ # RFD 109: In-Process JP MCP Server -- **Status**: Accepted +- **Status**: Implemented - **Category**: Design - **Authors**: Jean Mertz - **Date**: 2026-09-12 -- **Required by**: [RFD 110] ## Summary diff --git a/docs/rfd/110-anthropic-subscription-queries-via-acp.md b/docs/rfd/110-anthropic-subscription-queries-via-acp.md index 7aeb1960f..c8aee33fc 100644 --- a/docs/rfd/110-anthropic-subscription-queries-via-acp.md +++ b/docs/rfd/110-anthropic-subscription-queries-via-acp.md @@ -5,7 +5,6 @@ - **Authors**: Jean Mertz - **Date**: 2026-09-11 - **Extends**: [RFD 090] -- **Requires**: [RFD 109] ## Summary From ba6cb32a2c6dbbacdfdd5b9573a2ae4107ff6a05 Mon Sep 17 00:00:00 2001 From: Jean Mertz Date: Mon, 14 Sep 2026 19:49:25 +0200 Subject: [PATCH 08/29] fmt Signed-off-by: Jean Mertz --- docs/rfd/109-in-process-jp-mcp-server.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/rfd/109-in-process-jp-mcp-server.md b/docs/rfd/109-in-process-jp-mcp-server.md index 2a7c7435c..934d986ea 100644 --- a/docs/rfd/109-in-process-jp-mcp-server.md +++ b/docs/rfd/109-in-process-jp-mcp-server.md @@ -470,5 +470,4 @@ shared contracts and execution behavior specified here. [RFD 058]: 058-typed-content-blocks-for-tool-responses.md [RFD 065]: 065-typed-resource-model-for-attachments.md [RFD 108]: 108-transitional-jp-protocol-bridge-for-mcp-tools.md -[RFD 110]: 110-anthropic-subscription-queries-via-acp.md [Streamable HTTP]: https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http From 71ec16f98232cf7e648ef5e19bea2a2f81505512 Mon Sep 17 00:00:00 2001 From: Jean Mertz Date: Mon, 14 Sep 2026 21:04:53 +0200 Subject: [PATCH 09/29] review feedback Signed-off-by: Jean Mertz --- Cargo.lock | 2 - crates/jp_attachment_mcp_resources/src/lib.rs | 30 +- .../src/lib_tests.rs | 27 +- crates/jp_cli/src/cmd.rs | 2 +- crates/jp_cli/src/cmd/conversation/print.rs | 13 - crates/jp_cli/src/cmd/query.rs | 3 +- crates/jp_cli/src/cmd/query/tool.rs | 3 +- .../jp_cli/src/cmd/query/tool/coordinator.rs | 342 ++++---- .../src/cmd/query/tool/coordinator_tests.rs | 190 ++--- crates/jp_cli/src/cmd/query/tool/executor.rs | 799 +++++------------ .../src/cmd/query/tool/executor_error.rs | 82 ++ .../src/cmd/query/tool/executor_mock.rs | 188 ++++ .../src/cmd/query/tool/executor_tests.rs | 272 ------ .../jp_cli/src/cmd/query/tool/mcp_executor.rs | 802 ++++++++++++++++++ .../src/cmd/query/tool/mcp_executor_tests.rs | 487 +++++++++++ crates/jp_cli/src/cmd/query/tool/pending.rs | 3 +- .../src/cmd/query/tool/pending_tests.rs | 2 +- crates/jp_cli/src/cmd/query/tool/prompter.rs | 2 +- crates/jp_cli/src/cmd/query/turn_loop.rs | 32 +- .../jp_cli/src/cmd/query/turn_loop_tests.rs | 77 +- crates/jp_cli/src/cmd/query_tests.rs | 14 +- crates/jp_cli/src/error.rs | 3 +- crates/jp_cli/src/render/tool.rs | 186 +--- crates/jp_cli/src/render/tool_tests.rs | 123 +-- crates/jp_cli/src/render/turn.rs | 22 +- crates/jp_llm/Cargo.toml | 2 - crates/jp_llm/src/lib.rs | 1 - crates/jp_llm/src/tool.rs | 399 --------- crates/jp_llm/src/tool_error.rs | 74 -- crates/jp_mcp/Cargo.toml | 3 + crates/jp_mcp/src/client_protocol_tests.rs | 155 +++- crates/jp_mcp/src/server.rs | 9 +- crates/jp_mcp/src/server/conformance_tests.rs | 30 +- crates/jp_mcp/src/server/http.rs | 14 +- crates/jp_mcp/src/server/http_tests.rs | 2 +- crates/jp_mcp/src/server/result.rs | 24 - crates/jp_mcp/src/server/result_tests.rs | 11 +- crates/jp_mcp/src/server/service.rs | 129 +-- crates/jp_mcp/src/server/service_tests.rs | 166 +++- crates/jp_mcp/src/server/upstream.rs | 72 +- crates/jp_mcp/src/server_tests.rs | 5 +- crates/jp_tool/src/definition_tests.rs | 105 +-- crates/jp_tool/src/error.rs | 12 - docs/architecture/ubiquitous-language.md | 44 + docs/rfd/014-attachment-handler-guide.md | 2 +- justfile | 6 + 46 files changed, 2687 insertions(+), 2284 deletions(-) create mode 100644 crates/jp_cli/src/cmd/query/tool/executor_error.rs create mode 100644 crates/jp_cli/src/cmd/query/tool/executor_mock.rs delete mode 100644 crates/jp_cli/src/cmd/query/tool/executor_tests.rs create mode 100644 crates/jp_cli/src/cmd/query/tool/mcp_executor.rs create mode 100644 crates/jp_cli/src/cmd/query/tool/mcp_executor_tests.rs delete mode 100644 crates/jp_llm/src/tool.rs delete mode 100644 crates/jp_llm/src/tool_error.rs diff --git a/Cargo.lock b/Cargo.lock index 8392e402e..132de2456 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2577,7 +2577,6 @@ dependencies = [ "jp_config", "jp_conversation", "jp_credentials", - "jp_mcp", "jp_openrouter", "jp_storage", "jp_test", @@ -2596,7 +2595,6 @@ dependencies = [ "test-log", "thiserror 2.0.20", "tokio", - "tokio-util", "tracing", "url", "uuid", diff --git a/crates/jp_attachment_mcp_resources/src/lib.rs b/crates/jp_attachment_mcp_resources/src/lib.rs index 4067ad7d4..2a98f7ab4 100644 --- a/crates/jp_attachment_mcp_resources/src/lib.rs +++ b/crates/jp_attachment_mcp_resources/src/lib.rs @@ -30,20 +30,30 @@ pub struct McpResources(BTreeSet); /// Returned when an `mcp` attachment is asked for its contents. /// -/// Names the attachment so a conversation carrying several of them says which -/// one to remove. +/// Names every attachment the conversation carries, so one query tells the user +/// the whole set to remove rather than one per attempt. #[derive(Debug)] pub struct UnsupportedResolution { - uri: Url, + uris: Vec, } impl fmt::Display for UnsupportedResolution { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let list = self + .uris + .iter() + .map(|uri| format!("`{uri}`")) + .collect::>() + .join(", "); + let removals = self + .uris + .iter() + .map(|uri| format!("jp attachment rm '{uri}'")) + .collect::>() + .join(" && "); write!( f, - "MCP resource attachments are no longer resolved: `{}`. Remove it with `jp attachment \ - rm {}`.", - self.uri, self.uri + "MCP resource attachments are no longer resolved: {list}. Remove them with: {removals}" ) } } @@ -78,10 +88,12 @@ impl Handler for McpResources { } async fn get(&self, _: &Utf8Path) -> Result, Box> { - match self.0.iter().next() { - Some(uri) => Err(Box::new(UnsupportedResolution { uri: uri.clone() })), - None => Ok(vec![]), + if self.0.is_empty() { + return Ok(vec![]); } + Err(Box::new(UnsupportedResolution { + uris: self.0.iter().cloned().collect(), + })) } } diff --git a/crates/jp_attachment_mcp_resources/src/lib_tests.rs b/crates/jp_attachment_mcp_resources/src/lib_tests.rs index 7f56f42d7..85e9c30a4 100644 --- a/crates/jp_attachment_mcp_resources/src/lib_tests.rs +++ b/crates/jp_attachment_mcp_resources/src/lib_tests.rs @@ -30,8 +30,31 @@ async fn resolving_a_stored_attachment_names_it_and_how_to_remove_it() { assert_eq!( error.to_string(), "MCP resource attachments are no longer resolved: \ - `mcp+github-mcp-server+repo://owner/name`. Remove it with `jp attachment rm \ - mcp+github-mcp-server+repo://owner/name`." + `mcp+github-mcp-server+repo://owner/name`. Remove them with: jp attachment rm \ + 'mcp+github-mcp-server+repo://owner/name'" + ); +} + +/// A conversation carrying several of them names all of them at once, so the +/// user does not learn about the next one by querying again. +#[tokio::test] +async fn resolving_names_every_stored_attachment() { + let second = Url::parse("mcp+other-server+file:///notes.md").unwrap(); + let mut handler = McpResources::default(); + handler.add(&uri(), Utf8Path::new("/")).await.unwrap(); + handler.add(&second, Utf8Path::new("/")).await.unwrap(); + + let error = handler + .get(Utf8Path::new("/")) + .await + .expect_err("mcp resource attachments no longer resolve"); + + assert_eq!( + error.to_string(), + "MCP resource attachments are no longer resolved: \ + `mcp+github-mcp-server+repo://owner/name`, `mcp+other-server+file:///notes.md`. Remove \ + them with: jp attachment rm 'mcp+github-mcp-server+repo://owner/name' && jp attachment \ + rm 'mcp+other-server+file:///notes.md'" ); } diff --git a/crates/jp_cli/src/cmd.rs b/crates/jp_cli/src/cmd.rs index 3296dd3be..2121af6e0 100644 --- a/crates/jp_cli/src/cmd.rs +++ b/crates/jp_cli/src/cmd.rs @@ -8,7 +8,7 @@ pub(crate) mod label; mod lock; pub(crate) mod plugin; mod provider; -mod query; +pub(crate) mod query; pub(crate) mod target; pub(crate) mod time; pub(crate) mod turn_selection; diff --git a/crates/jp_cli/src/cmd/conversation/print.rs b/crates/jp_cli/src/cmd/conversation/print.rs index eabd4f702..86c23e1eb 100644 --- a/crates/jp_cli/src/cmd/conversation/print.rs +++ b/crates/jp_cli/src/cmd/conversation/print.rs @@ -5,7 +5,6 @@ use jp_config::{ style::{reasoning::ReasoningDisplayConfig, typewriter::DelayDuration}, }; use jp_conversation::stream::TurnOrigin; -use jp_mcp::server::InvocationContext; use jp_workspace::ConversationHandle; use crate::{ @@ -153,11 +152,6 @@ impl Print { let raw_count = events.turn_count(); let cfg = ctx.config(); - let root = ctx - .storage_path() - .unwrap_or(ctx.workspace.root()) - .to_path_buf(); - let source = if current_config { ConfigSource::Fixed } else { @@ -176,20 +170,13 @@ impl Print { let assistant_name = cfg.assistant.name.clone(); let model_id = Some(cfg.assistant.model.id.resolved().to_string()); - let invocation = InvocationContext { - workspace_id: ctx.workspace.id().to_string(), - conversation_id: handle.id().to_string(), - }; - let mut renderer = TurnRenderer::new( ctx.printer.clone(), render_style, tools_config, assistant_name, model_id, - root, source, - invocation, style_overlay, ); renderer.set_user_only(user_only); diff --git a/crates/jp_cli/src/cmd/query.rs b/crates/jp_cli/src/cmd/query.rs index 6605fb924..6603ed135 100644 --- a/crates/jp_cli/src/cmd/query.rs +++ b/crates/jp_cli/src/cmd/query.rs @@ -1175,7 +1175,7 @@ impl Query { tools, &cfg.conversation.tools, approvals, - invocation.clone(), + invocation, mcp_client, root.clone(), ) @@ -1201,7 +1201,6 @@ impl Query { prompt_backend, tool_coordinator, chat_request, - invocation, pending_trim, turn_interrupt, ) diff --git a/crates/jp_cli/src/cmd/query/tool.rs b/crates/jp_cli/src/cmd/query/tool.rs index 83f63d1f9..1a35f56d2 100644 --- a/crates/jp_cli/src/cmd/query/tool.rs +++ b/crates/jp_cli/src/cmd/query/tool.rs @@ -7,11 +7,12 @@ pub(crate) mod builtins; pub(crate) mod coordinator; pub(crate) mod executor; pub(crate) mod inquiry; +pub(crate) mod mcp_executor; pub(crate) mod pending; pub(crate) mod prompter; pub(crate) use coordinator::{ToolCallDecision, ToolCallState, ToolCoordinator}; -pub(crate) use executor::TerminalExecutorSource; +pub(crate) use mcp_executor::TerminalExecutorSource; pub(crate) use pending::{PendingEntry, PendingTools, build_execution_plan}; pub(crate) use prompter::ToolPrompter; diff --git a/crates/jp_cli/src/cmd/query/tool/coordinator.rs b/crates/jp_cli/src/cmd/query/tool/coordinator.rs index 2110449e9..8506a528c 100644 --- a/crates/jp_cli/src/cmd/query/tool/coordinator.rs +++ b/crates/jp_cli/src/cmd/query/tool/coordinator.rs @@ -87,8 +87,7 @@ use indexmap::IndexMap; use inquire::error::InquireError; use jp_config::{ conversation::tool::{ - FormatMode, QuestionTarget, ResultMode, RunMode, ToolSource, ToolsConfig, - style::ParametersStyle, + QuestionTarget, ResultMode, RunMode, ToolsConfig, style::ParametersStyle, }, interrupt::ToolInterruptConfig, }; @@ -101,7 +100,6 @@ use jp_conversation::{ }; use jp_editor::EditorBackend; use jp_inquire::{ReplyEditMode, prompt::PromptBackend}; -use jp_llm::tool::{Executor, ExecutorError, ExecutorResult, ExecutorSource, PermissionInfo}; use jp_mcp::{Client, server::StderrSink}; use jp_printer::Printer; use jp_tool::{AnswerType, Question}; @@ -113,6 +111,7 @@ use tracing::{debug, warn}; use super::{ ToolRenderer, + executor::{Executor, ExecutorError, ExecutorResult, ExecutorSource, PermissionInfo, Review}, inquiry::{self, InquiryBackend, InquiryError}, prompter::{PermissionResult, ToolPrompter}, }; @@ -170,18 +169,18 @@ enum ExecutionEvent { ResultModeProcessed { index: usize, tool_id: String, - response: ToolCallResponse, + review: Review, }, } #[derive(Debug)] pub struct ExecutionResult { - /// Tool responses paired with the plan index supplied by the caller in - /// `executors`. + /// What the Host settled on per tool, paired with the plan index supplied + /// by the caller in `executors`. /// Indices may be sparse when the caller's plan also contains pre-resolved /// tools that bypass execution; merging those back into the original stream /// order is the caller's job. - pub responses: Vec<(usize, ToolCallResponse)>, + pub reviews: Vec<(usize, Review)>, /// How the execution phase ended, and what the caller should do next. pub outcome: ExecutionOutcome, @@ -288,6 +287,17 @@ enum PendingPrompt { }, } +/// What rendering a tool call before its approval prompt produced. +#[derive(Debug)] +enum PreRender { + /// Rendered; the content shown, when the style produced any. + Ready(Option), + + /// Held back until the call is admitted, because its formatter is a + /// user-configured command the execution service has not run yet. + Deferred, +} + /// Result of [`ToolCoordinator::decide_permission`] for a single tool. pub enum PermissionDecision { /// Tool can run immediately (unattended, persisted approval, non-TTY). @@ -436,32 +446,6 @@ impl ToolCoordinator { .unwrap_or_default() } - /// Return the name the tool is invoked under. - /// - /// A tool's key in `conversation.tools` is the name the assistant calls. - /// Its `source` may name a differently named implementation - /// (`local.fs_list_files`, `mcp..`), and that is the name the - /// tool is actually invoked with. - /// Falls back to `tool_name` when the source names nothing. - pub fn invoked_name(&self, tool_name: &str) -> String { - self.tools_config - .get(tool_name) - .and_then(|config| match config.source() { - ToolSource::Builtin { tool } - | ToolSource::Local { tool } - | ToolSource::Mcp { tool, .. } => tool.clone(), - }) - .unwrap_or_else(|| tool_name.to_owned()) - } - - /// Return the format mode for a tool, falling back to `Ask` if the tool is - /// unknown (untrusted-by-default). - pub fn format_mode(&self, tool_name: &str) -> FormatMode { - self.tools_config - .get(tool_name) - .map_or(FormatMode::Ask, |c| c.format()) - } - /// Pre-render a tool call ahead of its approval prompt. /// /// Built-in parameter styles ([`ParametersStyle::Json`], @@ -470,47 +454,31 @@ impl ToolCoordinator { /// user needs to see the rendered call to make an informed approval /// decision. /// - /// [`ParametersStyle::Custom`] shells out to a user-configured command and - /// is gated by [`FormatMode`]: it only pre-renders when the tool opts in - /// via `format = "unattended"`; otherwise rendering is deferred until after - /// approval. + /// [`ParametersStyle::Custom`] renders whatever the execution service + /// produced. + /// A formatter configured with `format = "ask"` has not run yet at this + /// point, which is [`PreRender::Deferred`]. /// - /// Returns: - /// - /// - `Ok(Some(content))` if pre-render fired successfully — caller should - /// skip the post-approval render and use this content. - /// - `Ok(None)` if pre-render was suppressed (Custom style with `format = - /// "ask"`) — caller should follow the existing post-approval render - /// path. - /// - `Err(error_message)` if a custom formatter command failed — caller - /// should treat this as a tool failure and skip prompting. - pub(crate) async fn pre_render_for_prompt( + /// Returns `Err` if a formatter failed — the caller should treat that as a + /// tool failure and skip prompting. + fn pre_render_for_prompt( &self, - tool_name: &str, - arguments: &Map, + executor: &dyn Executor, tool_renderer: &ToolRenderer, - ) -> Result>, String> { - // `FormatMode::Ask` exists to defer side-effecting *custom* - // formatters until after approval — running a user-configured - // shell command before the user okays the tool would be - // surprising. Built-in styles are pure and have no side effects, - // so they always render before the prompt. - let should_pre_render = match self.parameter_style(tool_name) { - ParametersStyle::Custom(_) => { - matches!(self.format_mode(tool_name), FormatMode::Unattended) - } - ParametersStyle::Json | ParametersStyle::FunctionCall | ParametersStyle::Off => true, - }; - - if !should_pre_render { - return Ok(None); + ) -> Result { + let name = executor.tool_name(); + if matches!(self.parameter_style(name), ParametersStyle::Custom(_)) + && executor.formatted_arguments().is_none() + { + // Running a user-configured shell command before the user okays + // the tool would be surprising, so `format = "ask"` holds the + // formatter back until admission. Built-in styles are pure and + // have no side effects, so they always render before the prompt. + return Ok(PreRender::Deferred); } - match self - .render_approved_tool(tool_name, arguments, tool_renderer) - .await - { - RenderOutcome::Rendered { content } => Ok(Some(content)), + match self.render_executor(executor, tool_renderer) { + RenderOutcome::Rendered { content } => Ok(PreRender::Ready(content)), RenderOutcome::Suppressed { error } => Err(error), } } @@ -558,13 +526,14 @@ impl ToolCoordinator { // it. prompter.set_background(tool_renderer.current_region()); + // Asking the service to format arguments for a call the user already + // said no to would run a formatter command for output nobody sees. let remembered_denial = interactive - && executor.permission_info().is_some_and(|info| { - turn_state - .remembered_permission_decisions - .get(&PermissionCacheKey::new(&info.tool_name)) - == Some(&false) - }); + && executor.needs_permission() + && turn_state + .remembered_permission_decisions + .get(&PermissionCacheKey::new(executor.tool_name())) + == Some(&false); let render_arguments = !self.is_hidden(executor.tool_name()) && !remembered_denial; match executor.prepare(render_arguments).await { Ok(Some(response)) => { @@ -600,11 +569,9 @@ impl ToolCoordinator { // Built-in parameter styles always pre-render; Custom // formatters are gated on `format = "unattended"` // because they shell out to a user-controlled command. - let pre = match self - .pre_render_executor_for_prompt(executor.as_ref(), tool_renderer) - .await - { - Ok(maybe_content) => maybe_content, + let pre = match self.pre_render_for_prompt(executor.as_ref(), tool_renderer) { + Ok(PreRender::Ready(content)) => Some(content), + Ok(PreRender::Deferred) => None, Err(error) => { return ToolCallDecision::Failed(Self::render_failed_response( info.tool_id.clone(), @@ -649,7 +616,7 @@ impl ToolCoordinator { pre } else { let tool_name = executor.tool_name().to_owned(); - match self.render_executor(executor.as_ref(), tool_renderer).await { + match self.render_executor(executor.as_ref(), tool_renderer) { RenderOutcome::Rendered { content } => content, RenderOutcome::Suppressed { error } => { let id = executor.tool_id().to_owned(); @@ -668,62 +635,37 @@ impl ToolCoordinator { } } - async fn pre_render_executor_for_prompt( - &self, - executor: &dyn Executor, - renderer: &ToolRenderer, - ) -> Result>, String> { - if !executor.formats_arguments() - || !matches!( - self.parameter_style(executor.tool_name()), - ParametersStyle::Custom(_) - ) - { - return self - .pre_render_for_prompt(executor.tool_name(), executor.arguments(), renderer) - .await; - } - if executor.formatted_arguments().is_none() { - return Ok(None); - } - match self.render_executor(executor, renderer).await { - RenderOutcome::Rendered { content } => Ok(Some(content)), - RenderOutcome::Suppressed { error } => Err(error), - } - } - - async fn render_executor( - &self, - executor: &dyn Executor, - renderer: &ToolRenderer, - ) -> RenderOutcome { + /// Render one tool call's arguments for display. + /// + /// A `Custom` parameter style shows what the execution service's formatter + /// produced. + /// The formatter is a user-configured command, so it runs once, there, + /// under the call's access policy and cancellation token — never a second + /// time here. + fn render_executor(&self, executor: &dyn Executor, renderer: &ToolRenderer) -> RenderOutcome { let name = executor.tool_name(); if self.is_hidden(name) { return RenderOutcome::Rendered { content: None }; } - if executor.formats_arguments() - && matches!(self.parameter_style(name), ParametersStyle::Custom(_)) - { - return renderer.render_custom_result( - name, - executor - .formatted_arguments() - .cloned() - .unwrap_or_else(|| Ok(String::new())) - .map_err(|error| error.to_string()), - ); - } - self.render_approved_tool(name, executor.arguments(), renderer) - .await + let ParametersStyle::Custom(_) = self.parameter_style(name) else { + return self.render_approved_tool(name, executor.arguments(), renderer); + }; + // No formatter output means the service was never asked for it, so the + // call header is all there is to show. + let formatted = executor + .formatted_arguments() + .cloned() + .unwrap_or_else(|| Ok(String::new())); + renderer.render_custom_result(name, formatted.map_err(|error| error.to_string())) } /// Acknowledge the execution service after the conversation owner flushes. - pub async fn acknowledge_responses( - &self, - responses: Vec, - ) -> Result<(), ExecutorError> { - for response in responses { - self.executor_source.acknowledge(response).await?; + /// + /// Until this runs, each call is still parked on its final barrier and its + /// MCP response has not been returned to the caller. + pub async fn acknowledge_reviews(&self, reviews: Vec) -> Result<(), ExecutorError> { + for review in reviews { + self.executor_source.acknowledge(review).await?; } Ok(()) } @@ -848,20 +790,18 @@ impl ToolCoordinator { /// Renders the tool call header and arguments after permission approval. /// - /// For non-Custom styles: prints the header with inline-formatted - /// arguments. - /// For Custom style: runs the custom formatter command, then prints header + /// Prints the header with inline-formatted arguments. + /// A hidden tool renders nothing and still returns `Rendered`, because it + /// also still executes. + /// + /// A `Custom` parameter style is rendered by [`render_executor`] from the + /// execution service's formatter output, not here. /// - /// - custom output atomically. - /// If the custom formatter fails, nothing is printed and - /// [`RenderOutcome::Suppressed`] is returned — the caller should abort - /// execution and return an error response to the LLM. - /// For hidden tools: renders nothing but returns `Rendered` (hidden tools - /// still execute). - pub(crate) async fn render_approved_tool( + /// [`render_executor`]: Self::render_executor + pub(crate) fn render_approved_tool( &self, tool_name: &str, - arguments: &serde_json::Map, + arguments: &Map, tool_renderer: &ToolRenderer, ) -> RenderOutcome { if self.is_hidden(tool_name) { @@ -869,9 +809,7 @@ impl ToolCoordinator { } let style = self.parameter_style(tool_name); - tool_renderer - .render_approved(tool_name, &self.invoked_name(tool_name), arguments, &style) - .await + tool_renderer.render_approved(tool_name, arguments, &style) } /// Determines permission for a single tool without blocking on user input. @@ -1053,7 +991,7 @@ impl ToolCoordinator { ) -> ExecutionResult { if executors.is_empty() { return ExecutionResult { - responses: Vec::new(), + reviews: Vec::new(), outcome: ExecutionOutcome::Completed, }; } @@ -1084,7 +1022,7 @@ impl ToolCoordinator { let cancellation_token = self.cancellation_token.clone(); let (event_tx, mut event_rx) = mpsc::channel::(32); let mut executing_tools: HashMap = HashMap::new(); - let mut results: Vec> = vec![None; total_tools]; + let mut results: Vec> = vec![None; total_tools]; let mut pending_prompts: VecDeque = VecDeque::new(); let mut prompt_active = false; @@ -1253,7 +1191,7 @@ impl ToolCoordinator { Some(tool) => { self.set_tool_state(&tool.tool_id, ToolCallState::Completed); - results[index] = Some(ToolCallResponse { + results[index] = Some(Review::replaced(ToolCallResponse { id: tool.tool_id.clone(), result: Err(format!( "The tool '{}' asked a follow-up question (\"{}\") that \ @@ -1263,7 +1201,7 @@ impl ToolCoordinator { the turn.", tool.tool_name, question_text, error, )), - }); + })); } } } @@ -1289,14 +1227,14 @@ impl ToolCoordinator { ExecutionEvent::ResultModeProcessed { index, tool_id, - response, + review, } => { prompt_active = false; let tool_name = executing_tools .get(&index) .map(|t| t.tool_name.clone()) .unwrap_or_default(); - let is_error = response.result.is_err(); + let is_error = review.response.result.is_err(); let (inline_results, results_file_link) = self .tools_config .get(&tool_name) @@ -1313,11 +1251,15 @@ impl ToolCoordinator { .get(&tool_name) .is_some_and(|cfg| cfg.style().hidden); if !is_hidden { - tool_renderer.render_result(&response, &inline_results, &results_file_link); + tool_renderer.render_result( + &review.response, + &inline_results, + &results_file_link, + ); } self.set_tool_state(&tool_id, ToolCallState::Completed); - results[index] = Some(response); + results[index] = Some(review); self.process_next_prompt( &mut pending_prompts, &mut prompt_active, @@ -1408,23 +1350,25 @@ impl ToolCoordinator { tool_renderer.clear_progress(); - let mut responses: Vec<(usize, ToolCallResponse)> = plan_indices + let mut reviews: Vec<(usize, Review)> = plan_indices .into_iter() - .zip(results.into_iter().map(|r| { - r.unwrap_or_else(|| ToolCallResponse { - id: "unknown".to_string(), - result: Err("Tool did not complete".to_string()), + .zip(results.into_iter().map(|result| { + result.unwrap_or_else(|| { + Review::replaced(ToolCallResponse { + id: "unknown".to_owned(), + result: Err("Tool did not complete".to_owned()), + }) }) })) .collect(); if tools_cancelled { for &i in &cancelled_indices { - let Some((_, response)) = responses.get_mut(i) else { + let Some((_, review)) = reviews.get_mut(i) else { continue; }; - response.result = Ok(if let Some(msg) = &cancellation_message { + review.response.result = Ok(if let Some(msg) = &cancellation_message { format!("Tool run cancelled by user with a custom message:\n\n{msg}") } else { // No custom message: each cancelled tool answers with its @@ -1435,10 +1379,13 @@ impl ToolCoordinator { .unwrap_or_default(); self.cancellation_response(tool_name) }); + // The cancellation message stands in for whatever the tool + // would have produced. + review.edited = true; } } - ExecutionResult { responses, outcome } + ExecutionResult { reviews, outcome } } /// Builds an error response for a tool whose argument rendering failed. @@ -1607,7 +1554,7 @@ impl ToolCoordinator { result: ExecutorResult, tool: &mut ExecutingTool, index: usize, - tracked_response: &mut Option, + tracked_review: &mut Option, pending_prompts: &mut VecDeque, prompt_active: &mut bool, prompter: Arc, @@ -1649,14 +1596,14 @@ impl ToolCoordinator { ); } self.set_tool_state(&tool.tool_id, ToolCallState::Completed); - *tracked_response = Some(response); + *tracked_review = Some(Review::unchanged(response)); } + // The execution service applies `result = "skip"` itself, + // so this response is already its skip message rather than + // the tool's output, and recording it replaces nothing. ResultMode::Skip => { self.set_tool_state(&tool.tool_id, ToolCallState::Completed); - *tracked_response = Some(ToolCallResponse { - id: response.id, - result: Ok("Result delivery skipped by configuration.".to_string()), - }); + *tracked_review = Some(Review::unchanged(response)); } result_mode @ (ResultMode::Ask | ResultMode::Edit) => { // Both Ask and Edit prompt whenever a user is there to @@ -1701,7 +1648,7 @@ impl ToolCoordinator { ); } self.set_tool_state(&tool.tool_id, ToolCallState::Completed); - *tracked_response = Some(response); + *tracked_review = Some(Review::unchanged(response)); } } } @@ -1842,10 +1789,10 @@ impl ToolCoordinator { }; Self::record_inquiry_cancelled(conv, &inquiry_id, reason); self.set_tool_state(&tool_id, ToolCallState::Completed); - *tracked_response = Some(ToolCallResponse { + *tracked_review = Some(Review::replaced(ToolCallResponse { id: tool_id.clone(), result: Err(message), - }); + })); } else { // The `InquiryRequest` is already recorded above; spawn the // async inquiry on a cloned snapshot. @@ -1933,7 +1880,7 @@ impl ToolCoordinator { inquiry_id: &InquiryId, reason: CancellationReason, executing_tools: &mut HashMap, - results: &mut [Option], + results: &mut [Option], pending_prompts: &mut VecDeque, prompt_active: &mut bool, prompter: Arc, @@ -1945,17 +1892,17 @@ impl ToolCoordinator { // A user cancellation (Esc / Ctrl-C / EOF at the prompt) completes the // tool benignly; a prompt failure is a tool-level error. let result = match reason { - CancellationReason::User => Ok("Tool input cancelled by user.".to_string()), - _ => Err("Tool input prompt failed.".to_string()), + CancellationReason::User => Ok("Tool input cancelled by user.".to_owned()), + _ => Err("Tool input prompt failed.".to_owned()), }; Self::record_inquiry_cancelled(conv, inquiry_id, reason); if let Some(tool) = executing_tools.get(&index) { self.set_tool_state(&tool.tool_id, ToolCallState::Completed); - results[index] = Some(ToolCallResponse { + results[index] = Some(Review::replaced(ToolCallResponse { id: tool.tool_id.clone(), result, - }); + })); } self.process_next_prompt( pending_prompts, @@ -2014,44 +1961,51 @@ impl ToolCoordinator { event_tx: mpsc::Sender, ) { tokio::task::spawn_blocking(move || { - let final_response = match result_mode { + // Whether the content changed is decided here, where both the + // offered response and the user's answer are in hand. Downstream + // it becomes `Review::edited`, which is what lets the execution + // service hand an unedited result back to the caller intact + // instead of re-deriving it from the recorded text. + let review = match result_mode { ResultMode::Ask => match prompter.prompt_result_confirmation(&tool_name) { - Ok(true) => response, - Ok(false) => ToolCallResponse { + Ok(true) => Review::unchanged(response), + Ok(false) => Review::replaced(ToolCallResponse { id: response.id, - result: Ok("Result delivery skipped by user.".to_string()), - }, - Err(e) if e.to_string().contains("edit_requested") => { + result: Ok("Result delivery skipped by user.".to_owned()), + }), + Err(error) if error.to_string().contains("edit_requested") => { Self::handle_edit_result(&prompter, response) } - Err(_) => ToolCallResponse { + Err(_) => Review::replaced(ToolCallResponse { id: response.id, - result: Ok("Result delivery cancelled.".to_string()), - }, + result: Ok("Result delivery cancelled.".to_owned()), + }), }, ResultMode::Edit => Self::handle_edit_result(&prompter, response), - _ => response, + _ => Review::unchanged(response), }; drop(event_tx.blocking_send(ExecutionEvent::ResultModeProcessed { index, tool_id, - response: final_response, + review, })); }); } - fn handle_edit_result(prompter: &ToolPrompter, response: ToolCallResponse) -> ToolCallResponse { - let result_str = response.result.as_ref().map_or("", |s| s.as_str()); - match prompter.edit_result(result_str) { - Ok(Some(edited)) => ToolCallResponse { + fn handle_edit_result(prompter: &ToolPrompter, response: ToolCallResponse) -> Review { + let original = response.result.as_deref().unwrap_or_default(); + match prompter.edit_result(original) { + Ok(Some(edited)) => Review::replaced(ToolCallResponse { id: response.id, result: Ok(edited), - }, - Ok(None) => response, - Err(_) => ToolCallResponse { + }), + // The editor closed without a change, so the tool's own result + // stands. + Ok(None) => Review::unchanged(response), + Err(_) => Review::replaced(ToolCallResponse { id: response.id, - result: Ok("Result edit cancelled.".to_string()), - }, + result: Ok("Result edit cancelled.".to_owned()), + }), } } diff --git a/crates/jp_cli/src/cmd/query/tool/coordinator_tests.rs b/crates/jp_cli/src/cmd/query/tool/coordinator_tests.rs index bf6066acd..d542ff0d3 100644 --- a/crates/jp_cli/src/cmd/query/tool/coordinator_tests.rs +++ b/crates/jp_cli/src/cmd/query/tool/coordinator_tests.rs @@ -4,7 +4,6 @@ use camino_tempfile::Utf8TempDir; use jp_config::AppConfig; use jp_config::conversation::tool::{ToolConfig, ToolSource, style::PartialDisplayStyleConfig}; use jp_inquire::{ReplyOutcome, prompt::MockPromptBackend}; -use jp_llm::tool::{MockExecutor, TestExecutorSource}; #[cfg(unix)] use jp_mcp::{ Client, @@ -18,14 +17,23 @@ use schematic::Config as _; use serde_json::json; use super::*; -use crate::render::tool::ToolRenderer; #[cfg(unix)] -use crate::{access::approvals::ApprovalStore, cmd::query::tool::executor::TerminalExecutorSource}; +use crate::{ + access::approvals::ApprovalStore, cmd::query::tool::mcp_executor::TerminalExecutorSource, +}; +use crate::{ + cmd::query::tool::executor::mock::{MockExecutor, TestExecutorSource}, + render::tool::ToolRenderer, +}; fn empty_executor_source() -> Box { Box::new(TestExecutorSource::new()) } +fn strip_ansi(text: &str) -> String { + String::from_utf8(strip_ansi_escapes::strip(text)).expect("valid utf-8 after stripping ANSI") +} + #[test] fn test_is_prompting_default_false() { let coordinator = ToolCoordinator::new( @@ -286,18 +294,13 @@ fn test_static_answer_with_configured_answer() { ); } -#[tokio::test] -async fn test_pre_render_for_prompt_function_call_fires_before_approval() { - // Regression test for the bug where `fs_delete_file`-style tools - // (built-in parameter style + `run = "ask"`) showed the permission - // prompt without first rendering the arguments. `FormatMode::Ask` - // exists to defer side-effecting custom formatters; it should not - // suppress rendering for the pure built-in styles. +/// Build a coordinator around a single tool with the given parameter style. +fn coordinator_with_style(name: &str, parameters: ParametersStyle) -> ToolCoordinator { let tool_config = ToolConfig::from_partial( jp_config::conversation::tool::PartialToolConfig { source: Some(ToolSource::Builtin { tool: None }), style: Some(PartialDisplayStyleConfig { - parameters: Some(ParametersStyle::FunctionCall), + parameters: Some(parameters), ..Default::default() }), ..Default::default() @@ -307,106 +310,87 @@ async fn test_pre_render_for_prompt_function_call_fires_before_approval() { .expect("valid tool config"); let mut tools_config = jp_config::AppConfig::new_test().conversation.tools; - tools_config.insert("fs_delete_file".to_string(), tool_config); - - let coordinator = ToolCoordinator::new(tools_config, empty_executor_source()); + tools_config.insert(name.to_owned(), tool_config); + ToolCoordinator::new(tools_config, empty_executor_source()) +} - // Sanity-check the precondition: with no explicit `format` and the - // default `run = "ask"`, the format mode derives to `Ask`. The bug - // was that this gated rendering even for non-Custom styles. - assert_eq!(coordinator.format_mode("fs_delete_file"), FormatMode::Ask); +#[test] +fn test_pre_render_for_prompt_function_call_fires_before_approval() { + // Regression test for the bug where `fs_delete_file`-style tools + // (built-in parameter style + `run = "ask"`) showed the permission prompt + // without first rendering the arguments. Deferral exists to hold back a + // side-effecting custom formatter, and must not suppress rendering for the + // pure built-in styles. + let coordinator = coordinator_with_style("fs_delete_file", ParametersStyle::FunctionCall); let (printer, _stdout, stderr) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let style_config = jp_config::AppConfig::new_test().style; - let root = Utf8TempDir::new().expect("temp dir"); let tool_renderer = ToolRenderer::new( ErrChannel::new(printer.clone()), - style_config, - root.path().to_owned(), - jp_mcp::server::InvocationContext::default(), + jp_config::AppConfig::new_test().style, ); let mut args = Map::new(); args.insert("path".into(), Value::String("src/foo.rs".into())); + let executor = MockExecutor::completed("call-1", "fs_delete_file", "done") + .with_arguments(args) + .with_permission_info(PermissionInfo { + tool_id: "call-1".into(), + tool_name: "fs_delete_file".into(), + tool_source: ToolSource::Builtin { tool: None }, + run_mode: RunMode::Ask, + arguments: Value::Object(Map::new()), + }); - let result = coordinator - .pre_render_for_prompt("fs_delete_file", &args, &tool_renderer) - .await; + let result = coordinator.pre_render_for_prompt(&executor, &tool_renderer); - // Non-Custom styles should always pre-render. `content` is `None` - // because only Custom formatters produce persistable rendered content. + // Built-in styles print their arguments inline, so they render before the + // prompt and produce no content for the caller to persist. assert!( - matches!(result, Ok(Some(None))), + matches!(result, Ok(PreRender::Ready(None))), "pre-render should fire for FunctionCall style, got: {result:?}" ); printer.flush(); - let output = stderr.lock(); - assert!( - output.contains("fs_delete_file"), - "stderr should contain tool name; got: {output:?}" - ); - assert!( - output.contains("src/foo.rs"), - "stderr should contain the rendered argument; got: {output:?}" + assert_eq!( + strip_ansi(&stderr.lock()), + "Calling tool fs_delete_file(path: \"src/foo.rs\")\n" ); } -#[tokio::test] -async fn test_pre_render_for_prompt_custom_ask_defers_rendering() { - // Counterpart to the test above: Custom formatters with the default - // `FormatMode::Ask` should still defer rendering until after approval, - // because the formatter is a user-controlled shell command. +#[test] +fn test_pre_render_for_prompt_custom_defers_until_the_service_formats() { + // Counterpart to the test above: a Custom formatter is a user-controlled + // command run by the execution service, so until the service reports its + // output there is nothing to show and rendering defers. use jp_config::conversation::tool::CommandConfigOrString; - let tool_config = ToolConfig::from_partial( - jp_config::conversation::tool::PartialToolConfig { - source: Some(ToolSource::Builtin { tool: None }), - style: Some(PartialDisplayStyleConfig { - parameters: Some(ParametersStyle::Custom(CommandConfigOrString::String( - "echo SHOULD-NOT-RUN".into(), - ))), - ..Default::default() - }), - ..Default::default() - }, - vec![], - ) - .expect("valid tool config"); - - let mut tools_config = jp_config::AppConfig::new_test().conversation.tools; - tools_config.insert("custom_tool".to_string(), tool_config); - - let coordinator = ToolCoordinator::new(tools_config, empty_executor_source()); - assert_eq!(coordinator.format_mode("custom_tool"), FormatMode::Ask); + let coordinator = coordinator_with_style( + "custom_tool", + ParametersStyle::Custom(CommandConfigOrString::String("echo SHOULD-NOT-RUN".into())), + ); let (printer, _stdout, stderr) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let style_config = jp_config::AppConfig::new_test().style; - let root = Utf8TempDir::new().expect("temp dir"); let tool_renderer = ToolRenderer::new( ErrChannel::new(printer.clone()), - style_config, - root.path().to_owned(), - jp_mcp::server::InvocationContext::default(), + jp_config::AppConfig::new_test().style, ); - let result = coordinator - .pre_render_for_prompt("custom_tool", &Map::new(), &tool_renderer) - .await; + // A mock executor never formats arguments, standing in for a service that + // has not run the formatter yet. + let executor = MockExecutor::completed("call-1", "custom_tool", "done"); + let result = coordinator.pre_render_for_prompt(&executor, &tool_renderer); assert!( - matches!(result, Ok(None)), - "Custom + format=ask should defer rendering, got: {result:?}" + matches!(result, Ok(PreRender::Deferred)), + "an unformatted Custom style should defer rendering, got: {result:?}" ); printer.flush(); - let output = stderr.lock(); - assert!( - !output.contains("SHOULD-NOT-RUN"), - "custom formatter must not have run; got: {output:?}" - ); + // Nothing at all is printed: not the formatter's output, and not a header + // with nothing under it. + assert_eq!(strip_ansi(&stderr.lock()), ""); } /// Minimal `Executor` whose `set_arguments` actually mutates state. @@ -483,13 +467,7 @@ async fn test_resolve_tool_call_decision_invalidates_prerender_on_edit() { let (printer, _stdout, stderr) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); let style_config = jp_config::AppConfig::new_test().style; - let root = Utf8TempDir::new().expect("temp dir"); - let tool_renderer = ToolRenderer::new( - ErrChannel::new(printer.clone()), - style_config, - root.path().to_owned(), - jp_mcp::server::InvocationContext::default(), - ); + let tool_renderer = ToolRenderer::new(ErrChannel::new(printer.clone()), style_config); let mut pre_edit_args = Map::new(); pre_edit_args.insert("path".into(), Value::String("src/foo.rs".into())); @@ -802,12 +780,11 @@ fn test_pending_prompt_mixed_types_interleaved() { assert!(matches!(queue[2], PendingPrompt::Question { .. })); } -#[tokio::test] -async fn custom_formatter_receives_the_invoked_tool_name() { +#[test] +fn a_custom_style_shows_the_key_the_assistant_called() { // A `source` that names an implementation (`local.fs_list_files` under the - // key `ls`) is the name the tool is executed with, so the custom parameter - // formatter has to be handed that name too. Handing it the key asks the - // formatter about a tool that does not exist. + // key `ls`) changes the name the tool runs under, but the header the user + // reads stays the name the assistant called. use jp_config::conversation::tool::CommandConfigOrString; let tool_config = ToolConfig::from_partial( @@ -830,23 +807,16 @@ async fn custom_formatter_receives_the_invoked_tool_name() { let mut tools_config = jp_config::AppConfig::new_test().conversation.tools; tools_config.insert("ls".to_owned(), tool_config); - let coordinator = ToolCoordinator::new(tools_config, empty_executor_source()); - let (printer, _stdout, stderr) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - // The formatter is spawned with this path as its working directory, so it - // has to exist on every platform the tests run on. - let root = Utf8TempDir::new().expect("temp dir"); let tool_renderer = ToolRenderer::new( ErrChannel::new(printer.clone()), jp_config::AppConfig::new_test().style, - root.path().to_owned(), - jp_mcp::server::InvocationContext::default(), ); - let outcome = coordinator - .render_approved_tool("ls", &Map::new(), &tool_renderer) - .await; + // The execution service ran the formatter and reported what it printed; + // nothing here shells out to produce this. + let outcome = tool_renderer.render_custom_result("ls", Ok("fs_list_files".into())); match outcome { RenderOutcome::Rendered { content } => { @@ -855,11 +825,11 @@ async fn custom_formatter_receives_the_invoked_tool_name() { RenderOutcome::Suppressed { error } => panic!("custom formatter failed: {error}"), } - // The header the user reads stays the name the assistant called. printer.flush(); - let output = String::from_utf8(strip_ansi_escapes::strip(stderr.lock().as_str())) - .expect("valid utf-8 after stripping ANSI"); - assert_eq!(output, "Calling tool ls\n\nfs_list_files\n"); + assert_eq!( + strip_ansi(&stderr.lock()), + "Calling tool ls\n\nfs_list_files\n" + ); } #[tokio::test] @@ -906,12 +876,7 @@ async fn remembered_denial_does_not_run_http_argument_formatter() { Arc::new(MockPromptBackend::new()), ReplyEditMode::default(), ); - let renderer = ToolRenderer::new( - ErrChannel::new(printer), - config.style, - root.path().to_owned(), - InvocationContext::default(), - ); + let renderer = ToolRenderer::new(ErrChannel::new(printer), config.style); let mut state = TurnState::default(); state .remembered_permission_decisions @@ -922,9 +887,12 @@ async fn remembered_denial_does_not_run_http_argument_formatter() { let ToolCallDecision::Skipped(response) = decision else { panic!("expected remembered denial") }; - assert!(!root.path().join("formatted").exists()); + assert!( + !root.path().join("formatted").exists(), + "a call the user already denied must not run its formatter" + ); coordinator - .acknowledge_responses(vec![response]) + .acknowledge_reviews(vec![Review::unchanged(response)]) .await .unwrap(); owner.shutdown().await.unwrap(); diff --git a/crates/jp_cli/src/cmd/query/tool/executor.rs b/crates/jp_cli/src/cmd/query/tool/executor.rs index c67d51986..a55d0ba76 100644 --- a/crates/jp_cli/src/cmd/query/tool/executor.rs +++ b/crates/jp_cli/src/cmd/query/tool/executor.rs @@ -1,628 +1,273 @@ -//! MCP Host adapter for tool calls through JP's loopback HTTP endpoint. +//! The seam a turn loop runs one tool call through. //! -//! The coordinator resolves interactions; this adapter holds their single-use -//! replies across preparation, execution, and final conversation recording. - -use std::{ - collections::HashMap, - sync::{Arc, Mutex as SyncMutex, MutexGuard, PoisonError}, -}; +//! [`Executor`] is the MCP Host's view of one logical tool call: preparation +//! and approval precede execution release, and an input request returns control +//! to the Host so it can route the inquiry, review the result, and record both. +//! [`ExecutorSource`] builds one per tool call, so a test can supply +//! [`MockExecutor`] where production supplies [`super::mcp_executor`]. +//! +//! Execution itself lives in `jp_mcp::server`; nothing here runs a tool. use async_trait::async_trait; -use camino::{Utf8Path, Utf8PathBuf}; +use camino::Utf8Path; use futures::future::BoxFuture; use indexmap::IndexMap; -use jp_config::conversation::tool::{RunMode, ToolConfigWithDefaults, ToolsConfig}; +use jp_config::conversation::tool::{RunMode, ToolConfigWithDefaults, ToolSource}; use jp_conversation::event::{InquirySource, ToolCallRequest, ToolCallResponse}; -use jp_llm::tool::{Executor, ExecutorError, ExecutorResult, ExecutorSource, PermissionInfo}; use jp_mcp::{ - CallToolResult, Client, - server::{ - InvocationContext, StderrSink, - builtin::BuiltinExecutors, - http::{Endpoint, EndpointError}, - result::{from_mcp, to_legacy}, - service::{ - AccessPolicyError, Admission, ConfiguredTool, FormatterError, HostReply, HostRequest, - InputAnswer, Interaction, InvocationId, ReleaseDecision, Service, - }, - }, -}; -use jp_tool::{ - AnswerType, ContentBlock, InputRequest, Question, QuestionId, ToolDefinition, ToolResult, -}; -use rand::random; -use rmcp::{ - Peer, ServiceError as McpCallError, - model::{CallToolRequestParams, Meta}, - service::{RoleClient, RunningService}, + Client, + server::{StderrSink, service::Formatted}, }; +use jp_tool::{Question, ToolResult}; use serde_json::{Map, Value}; -use tokio::{ - sync::{Mutex, broadcast::error::RecvError as ProgressError, mpsc, oneshot}, - task::JoinHandle, -}; use tokio_util::sync::CancellationToken; -use tracing::debug; -use crate::access::{approvals::ApprovalStore, compile::compile_tool_policy}; +#[path = "executor_error.rs"] +mod error; +pub(crate) use error::ExecutorError; -const CORRELATION_KEY: &str = "computer.jp/hostCall"; +/// The MCP Host's view of a logical tool call. +/// +/// Preparation and approval precede release. +/// Input and completed results return control to the Host for inquiry routing, +/// result review, and recording. +#[async_trait] +pub(crate) trait Executor: Send + Sync { + /// Prepare an invocation, or return a response resolved without execution. + async fn prepare( + &mut self, + _render_arguments: bool, + ) -> Result, ExecutorError> { + Ok(None) + } -type TextResult = Result; -type Reply = oneshot::Sender>; -type Calls = Arc>>>>; + /// Apply Host approval and wait until the invocation is ready for release. + async fn approve(&mut self) -> Result<(), ExecutorError> { + Ok(()) + } -struct Route { - request: ToolCallRequest, - invocation: Option, - sender: mpsc::Sender, -} + /// Custom argument rendering provided by the execution service. + /// + /// `None` when the execution service has not formatted this call's + /// arguments, either because nothing asked it to or because its formatter + /// waits for admission. + fn formatted_arguments(&self) -> Option<&Formatted> { + None + } -/// Private correlation value generated by the Host, not a provider tool-call -/// ID. -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -struct HostCallKey(String); + /// Returns the tool call ID. + fn tool_id(&self) -> &str; -type Routes = Arc>>; -type Sinks = Arc>>; + /// Returns the tool name. + fn tool_name(&self) -> &str; -fn locked(value: &SyncMutex) -> MutexGuard<'_, T> { - value.lock().unwrap_or_else(PoisonError::into_inner) -} + /// Returns the tool call arguments. + /// + /// This is separate from [`permission_info()`] because arguments are always + /// available, while permission info is only present for tools that require + /// a permission prompt. + /// + /// [`permission_info()`]: Self::permission_info + fn arguments(&self) -> &Map; -/// Creates MCP-backed executors; configuration and execution context are fixed -/// by the MCP Host when starting the endpoint. -pub struct TerminalExecutorSource { - peer: Peer, - service: Arc, - definitions: IndexMap, - calls: Calls, - routes: Routes, - sinks: Sinks, -} + /// Returns information needed for permission prompting. + /// + /// Returns `None` if the tool doesn't need a permission prompt (e.g., + /// `RunMode::Unattended` or `RunMode::Skip`). + fn permission_info(&self) -> Option; -/// Keeps the listener, MCP connection, and Host routing tasks alive for a turn. -pub struct ExecutionOwner { - endpoint: Option, - client: Option>, - router: JoinHandle<()>, - progress: JoinHandle<()>, -} - -impl ExecutionOwner { - /// Cancel pending work and wait for listener/connection cleanup. - pub async fn shutdown(mut self) -> Result<(), EndpointError> { - if let Some(endpoint) = self.endpoint.take() { - endpoint.shutdown().await?; - } - if let Some(client) = self.client.take() { - client.cancel().await?; - } - self.router.abort(); - self.progress.abort(); - Ok(()) + /// Whether this call needs a permission prompt before it runs. + /// + /// Agrees with [`permission_info()`] being `Some`, without copying the + /// arguments to find out. + /// + /// [`permission_info()`]: Self::permission_info + fn needs_permission(&self) -> bool { + self.permission_info().is_some() } -} -impl Drop for ExecutionOwner { - fn drop(&mut self) { - self.router.abort(); - self.progress.abort(); - } + /// Updates the arguments to use for execution. + /// + /// This is called after permission prompting if the user edited the + /// arguments (via `RunMode::Edit`). + /// The new arguments replace the original arguments from the tool call + /// request. + fn set_arguments(&mut self, args: Value); + + /// Advance the call to its next input request or result. + /// + /// An MCP-backed executor releases prepared work or answers the pending + /// inquiry on its existing MCP call. + /// The server re-executes a tool that returned `NeedsInput`; the executor + /// does not submit another MCP call. + /// The result remains subject to Host review and recording. + /// + /// The executor doesn't know how questions should be answered - it just + /// reports that input is needed. + /// The coordinator looks up the tool configuration to determine whether to + /// prompt the user or ask the LLM. + /// + /// # Arguments + /// + /// - `answers` - Accumulated answers from previous `NeedsInput` responses + /// - `mcp_client` - MCP client for remote tool execution + /// - `root` - Project root directory + /// - `cancellation_token` - Token to cancel execution + /// - `stderr` - Receives the tool's stderr lines as they arrive, for a + /// caller showing progress while it runs. + /// `None` when nothing is watching; the lines still reach tracing and the + /// accumulated buffer either way. + async fn execute( + &self, + answers: &IndexMap, + mcp_client: &Client, + root: &Utf8Path, + cancellation_token: CancellationToken, + stderr: Option, + ) -> ExecutorResult; } -impl TerminalExecutorSource { - /// Start the common MCP execution path and its private Host connection. - pub async fn start( - builtins: BuiltinExecutors, - definitions: &[ToolDefinition], - tools: &ToolsConfig, - approvals: Arc, - invocation: InvocationContext, - upstream: &Client, - root: Utf8PathBuf, - ) -> Result<(Self, ExecutionOwner), EndpointError> { - let configured = definitions - .iter() - .filter_map(|definition| { - let config = tools.get(&definition.name)?; - let access = - compile_tool_policy(config.access(), &root, &approvals).map_err(|error| { - AccessPolicyError { - tool: definition.name.clone(), - source: Arc::new(error), - } - }); - Some(ConfiguredTool { - definition: definition.clone(), - config, - access, - metadata: Map::new(), - }) - }) - .collect(); - let (service, mut host) = - Service::new(configured, upstream.clone(), builtins, root, invocation)?; - let mut stderr = service.subscribe_progress(); - let endpoint = Endpoint::start(service).await?; - let client = endpoint.connect().await?; - let routes = Routes::default(); - let router_routes = routes.clone(); - let router = tokio::spawn(async move { - while let Some(request) = host.recv().await { - let sender = { - let key = request - .call - .request - .correlation - .get(CORRELATION_KEY) - .and_then(Value::as_str) - .map(|value| HostCallKey(value.to_owned())); - let mut routes = locked(&router_routes); - key.and_then(|key| routes.get_mut(&key)).and_then(|route| { - // Correlation associates an existing Host call, not - // authority from caller-supplied execution metadata. - if route.request.name != request.call.request.name - || route.request.arguments != request.call.request.arguments - || route.invocation.is_some_and(|id| id != request.call.id) - { - return None; - } - if route.invocation.is_none() { - debug!(invocation = ?request.call.id, tool_call_id = %route.request.id, tool = %route.request.name, "Associated MCP invocation with Host tool call"); - } - route.invocation = Some(request.call.id); - Some(route.sender.clone()) - }) - }; - if let Some(sender) = sender { - drop(sender.send(request).await); - } - // An unassociated call loses its reply sender and fails closed. - } - }); - let sinks = Sinks::default(); - let progress_sinks = sinks.clone(); - let progress = tokio::spawn(async move { - loop { - match stderr.recv().await { - Ok(line) => { - let sink = locked(&progress_sinks).get(&line.id).cloned(); - if let Some(sink) = sink { - sink(&line.line); - } - } - Err(ProgressError::Lagged(_)) => {} - Err(ProgressError::Closed) => break, - } - } - }); - let source = Self { - peer: client.peer().clone(), - service: endpoint.service(), - definitions: definitions - .iter() - .map(|d| (d.name.clone(), d.clone())) - .collect(), - calls: Calls::default(), - routes, - sinks, - }; - Ok((source, ExecutionOwner { - endpoint: Some(endpoint), - client: Some(client), - router, - progress, - })) +/// Creates Host-facing tool calls and acknowledges their recorded responses. +pub(crate) trait ExecutorSource: Send + Sync { + /// Release a final delivery barrier after the response has been recorded. + /// + /// `review` carries the content the Host settled on, which the executor + /// compares against what it offered to decide whether the Host edited it. + fn acknowledge(&self, _review: Review) -> BoxFuture<'_, Result<(), ExecutorError>> { + Box::pin(async { Ok(()) }) } -} -impl ExecutorSource for TerminalExecutorSource { + /// Creates an executor for the given tool call request. + /// + /// Returns `None` if the tool cannot be resolved (e.g. missing from the + /// definitions). fn create( &self, request: ToolCallRequest, config: ToolConfigWithDefaults, - ) -> Option> { - self.definitions.get(&request.name)?; - let (sender, receiver) = mpsc::channel(8); - let key = HostCallKey(format!("{:032x}", random::())); - locked(&self.routes).insert(key.clone(), Route { - request: request.clone(), - invocation: None, - sender, - }); - let state = Arc::new(Mutex::new(PendingCall { - receiver, - task: None, - input: None, - prepare: None, - release: None, - review: None, - record: None, - id: None, - finished: false, - })); - locked(&self.calls).insert(request.id.clone(), state.clone()); - Some(Box::new(ToolExecutor { - request, - config, - key, - peer: self.peer.clone(), - service: self.service.clone(), - state, - formatted: None, - sinks: self.sinks.clone(), - })) - } - - fn acknowledge(&self, response: ToolCallResponse) -> BoxFuture<'_, Result<(), ExecutorError>> { - Box::pin(async move { - let call = locked(&self.calls).remove(&response.id); - let Some(call) = call else { - return Ok(()); - }; - let mut call = call.lock().await; - let result = call.acknowledge(response.result).await; - if let Some(id) = call.id { - locked(&self.sinks).remove(&id); - } - locked(&self.routes).retain(|_, route| route.request.id != response.id); - result - }) - } + ) -> Option>; } -struct PendingCall { - receiver: mpsc::Receiver, - task: Option>>, - input: Option<(QuestionId, Reply)>, - prepare: Option>, - release: Option>, - review: Option<(ToolResult, Reply)>, - record: Option>, - id: Option, - finished: bool, -} +/// What the Host settled on for one call, once the conversation has it. +/// +/// [`edited`] is what distinguishes a Host that rewrote the text from one that +/// passed it through: only the executor that offered the original knows which +/// happened, so the comparison is made where the original still exists rather +/// than by projecting both to text and comparing strings. +/// +/// [`edited`]: Self::edited +#[derive(Debug, Clone)] +pub(crate) struct Review { + /// The response the conversation recorded. + pub response: ToolCallResponse, -#[expect( - clippy::large_enum_variant, - reason = "Each Host interaction is consumed immediately without an additional allocation" -)] -enum Received { - Interaction(Interaction), - Finished(TextResult), + /// Whether the Host changed the content it was offered. + pub edited: bool, } -impl PendingCall { - async fn next(&mut self) -> Result { - let task = self.task.as_mut().ok_or(ExecutorError::NotStarted)?; - tokio::select! { - request = self.receiver.recv() => { - let request = request.ok_or(ExecutorError::HostDisconnected)?; - self.id = Some(request.call.id); - Ok(Received::Interaction(request.interaction)) - } - result = task => { - self.task = None; - self.finished = true; - let result = result?; - let result = result.map_err(|error| ExecutorError::Transport(Box::new(error)))?; - Ok(Received::Finished(to_legacy(&from_mcp(result).map_err(ExecutorError::MalformedResult)?))) - } +impl Review { + /// The Host recorded the content it was offered. + pub fn unchanged(response: ToolCallResponse) -> Self { + Self { + response, + edited: false, } } - async fn acknowledge(&mut self, result: TextResult) -> Result<(), ExecutorError> { - if self.finished { - return Ok(()); - } - if let Some(reply) = self.prepare.take() { - drop(reply.send(Ok(Admission::Complete { - result: result.clone().into(), - }))); - } else if let Some(reply) = self.release.take() { - drop(reply.send(Ok(ReleaseDecision::Complete { - result: result.clone().into(), - }))); - } else if let Some((_, reply)) = self.input.take() { - drop(reply.send(Ok(InputAnswer::Complete { - result: result.clone().into(), - }))); - } else if let Some((original, reply)) = self.review.take() { - let approved = if to_legacy(&original) == result { - original - } else { - result.clone().into() - }; - drop(reply.send(Ok(approved))); - } - if let Some(reply) = self.record.take() { - drop(reply.send(Ok(()))); - } - loop { - match self.next().await? { - Received::Interaction(Interaction::Review { - result: original, - reply, - .. - }) => { - let approved = if to_legacy(&original) == result { - original - } else { - result.clone().into() - }; - drop(reply.send(Ok(approved))); - } - Received::Interaction(Interaction::Record { - reply, - result: delivered, - .. - }) => { - if to_legacy(&delivered) != result { - return Err(ExecutorError::RecordingMismatch); - } - drop(reply.send(Ok(()))); - } - Received::Finished(delivered) => { - if delivered != result { - return Err(ExecutorError::DeliveryMismatch); - } - return Ok(()); - } - Received::Interaction(_) => { - return Err(ExecutorError::UnexpectedRecording); - } - } + /// The Host recorded content of its own in place of what it was offered. + pub fn replaced(response: ToolCallResponse) -> Self { + Self { + response, + edited: true, } } } -/// Represents one logical MCP call, including its pending Host interactions. -pub struct ToolExecutor { - request: ToolCallRequest, - config: ToolConfigWithDefaults, - key: HostCallKey, - peer: Peer, - service: Arc, - state: Arc>, - formatted: Option>, - sinks: Sinks, +/// Project a tool result into the conversation's text/error format. +/// +/// This is the compatibility projection: the conversation stores one string per +/// call plus a failure flag, so ordered content, resources, and structured data +/// are flattened by [`ToolResult::to_text`] and the failure flag becomes `Err`. +pub(crate) fn response(id: impl Into, result: &ToolResult) -> ToolCallResponse { + let text = result.to_text(); + ToolCallResponse { + id: id.into(), + result: if result.is_error() { + Err(text) + } else { + Ok(text) + }, + } } -#[async_trait] -impl Executor for ToolExecutor { - fn tool_id(&self) -> &str { - &self.request.id - } - fn tool_name(&self) -> &str { - &self.request.name - } - fn arguments(&self) -> &Map { - &self.request.arguments - } - fn formats_arguments(&self) -> bool { - true - } - fn formatted_arguments(&self) -> Option<&Result> { - self.formatted.as_ref() - } - fn permission_info(&self) -> Option { - let run_mode = self.config.run(); - if matches!(run_mode, RunMode::Unattended | RunMode::Skip) { - return None; - } - Some(PermissionInfo { - tool_id: self.request.id.clone(), - tool_name: self.request.name.clone(), - tool_source: self.config.source().clone(), - run_mode, - arguments: self.request.arguments.clone().into(), - }) - } - fn set_arguments(&mut self, args: Value) { - if let Value::Object(arguments) = args { - self.request.arguments = arguments; - } - } +/// Result of a tool execution attempt. +/// +/// Tools may need multiple rounds of execution if they require additional +/// input. +/// This enum allows the executor to return control to the coordinator, which +/// decides how to handle the `NeedsInput` case by looking up the question +/// configuration. +#[derive(Debug)] +#[expect( + clippy::large_enum_variant, + reason = "A turn holds one of these per in-flight call, not a collection of them" +)] +pub(crate) enum ExecutorResult { + /// Tool completed (success or error). + /// + /// The full result stays with the executor, which hands it back unchanged + /// if the Host records this response without editing it. + Completed(ToolCallResponse), - async fn prepare( - &mut self, - render_arguments: bool, - ) -> Result, ExecutorError> { - let mut state = self.state.lock().await; - if state.task.is_some() || state.finished { - return Err(ExecutorError::AlreadyPrepared); - } - let mut params = CallToolRequestParams::new(self.request.name.clone()); - params.arguments = Some(self.request.arguments.clone()); - params.meta = Some(Meta(Map::from_iter([( - CORRELATION_KEY.into(), - self.key.0.clone().into(), - )]))); - let peer = self.peer.clone(); - state.task = Some(tokio::spawn(async move { peer.call_tool(params).await })); - loop { - match state.next().await? { - Received::Interaction(Interaction::RenderArguments { reply }) => { - drop(reply.send(Ok(render_arguments))); - } - Received::Interaction(Interaction::Prepare { - arguments, - formatted_arguments, - reply, - .. - }) => { - self.request.arguments = arguments; - self.formatted = formatted_arguments; - state.prepare = Some(reply); - return Ok(None); - } - Received::Interaction(Interaction::Record { result, reply, .. }) => { - state.record = Some(reply); - return Ok(Some(ToolCallResponse { - id: self.request.id.clone(), - result: to_legacy(&result), - })); - } - Received::Finished(result) => { - return Ok(Some(ToolCallResponse { - id: self.request.id.clone(), - result, - })); - } - Received::Interaction(_) => { - return Err(ExecutorError::UnexpectedPreparation); - } - } - } - } + /// Tool needs additional input before it can continue. + /// + /// The executor doesn't know who should answer - it just reports that input + /// is needed. + /// The coordinator looks up the question configuration to determine the + /// target: + /// + /// - `User`: Prompt the user interactively, then restart the tool + /// - `Assistant`: Format a response asking the LLM to re-run with answers + NeedsInput { + /// Tool call ID. + tool_id: String, - async fn approve(&mut self) -> Result<(), ExecutorError> { - let mut state = self.state.lock().await; - let reply = state - .prepare - .take() - .ok_or(ExecutorError::NotAwaitingApproval)?; - reply - .send(Ok(Admission::Run { - arguments: self.request.arguments.clone(), - })) - .map_err(|_| ExecutorError::ApprovalExpired)?; - match state.next().await? { - Received::Interaction(Interaction::Release { - arguments, - formatted_arguments, - reply, - }) => { - self.request.arguments = arguments; - self.formatted = formatted_arguments; - state.release = Some(reply); - Ok(()) - } - Received::Finished(Err(message)) => Err(ExecutorError::Rejected { message }), - _ => Err(ExecutorError::MissingRelease), - } - } + /// Tool name (for persisting answers). + tool_name: String, - async fn execute( - &self, - answers: &IndexMap, - _: &Client, - _: &Utf8Path, - cancellation: CancellationToken, - stderr: Option, - ) -> ExecutorResult { - let mut state = self.state.lock().await; - let result = async { - if let (Some(id), Some(stderr)) = (state.id, stderr) { - locked(&self.sinks).insert(id, stderr); - } - if let Some(reply) = state.release.take() { - reply - .send(Ok(ReleaseDecision::Execute)) - .map_err(|_| ExecutorError::ReleaseExpired)?; - } - if let Some((id, reply)) = state.input.take() { - let answer = answers - .get(id.as_str()) - .ok_or(ExecutorError::MissingAnswer)? - .clone(); - reply - .send(Ok(InputAnswer::Answer(answer))) - .map_err(|_| ExecutorError::InquiryExpired)?; - } - match state.next().await? { - Received::Interaction(Interaction::Input { - request, - supporting, - answers, - reply, - }) => { - let question = question(request, &supporting)?; - state.input = Some((question.id.clone(), reply)); - Ok(ExecutorResult::NeedsInput { - tool_id: self.request.id.clone(), - tool_name: self.request.name.clone(), - source: InquirySource::tool(&self.request.name), - question, - accumulated_answers: answers, - }) - } - Received::Interaction(Interaction::Review { result, reply, .. }) => { - let text = to_legacy(&result); - state.review = Some((result, reply)); - Ok(ExecutorResult::Completed(ToolCallResponse { - id: self.request.id.clone(), - result: text, - })) - } - Received::Interaction(Interaction::Record { result, reply, .. }) => { - state.record = Some(reply); - Ok(ExecutorResult::Completed(ToolCallResponse { - id: self.request.id.clone(), - result: to_legacy(&result), - })) - } - Received::Finished(result) => Ok(ExecutorResult::Completed(ToolCallResponse { - id: self.request.id.clone(), - result, - })), - Received::Interaction(_) => Err(ExecutorError::UnexpectedExecution), - } - }; - let result: Result = tokio::select! { - biased; - () = cancellation.cancelled() => Err(ExecutorError::Cancelled), - result = result => result, - }; - if result.is_err() { - if let Some(id) = state.id { - self.service.cancel_call(id); - } - state.finished = true; - } - result.unwrap_or_else(|error| { - ExecutorResult::Completed(ToolCallResponse { - id: self.request.id.clone(), - result: Err(error.to_string()), - }) - }) - } + /// The question that needs to be answered. + question: Question, + + /// Resolved provenance for the persisted `InquiryRequest`. + source: InquirySource, + + /// Accumulated answers so far (for retry). + accumulated_answers: IndexMap, + }, } -fn question(request: InputRequest, supporting: &[ContentBlock]) -> Result { - let answer_type = if request.secret { - AnswerType::Secret - } else if request.schema.get("type").and_then(Value::as_str) == Some("boolean") { - AnswerType::Boolean - } else if let Some(options) = request.schema.get("enum").and_then(Value::as_array) { - AnswerType::Select { - options: options - .iter() - .map(|v| { - v.as_str() - .map(str::to_owned) - .ok_or(ExecutorError::NonStringChoice) - }) - .collect::>()?, - } - } else if request.schema.get("type").and_then(Value::as_str) == Some("string") { - AnswerType::Text - } else { - return Err(ExecutorError::UnsupportedInquirySchema); - }; - let preamble = supporting - .iter() - .filter_map(ContentBlock::as_text) - .collect::>() - .join("\n\n"); - let mut question = Question::new(request.id, request.label, answer_type); - question.pre_amble = (!preamble.is_empty()).then_some(preamble); - question.default = request.default; - Ok(question) +/// Information needed to prompt for tool execution permission. +/// +/// This struct contains all the data the `ToolPrompter` needs to show a +/// permission prompt to the user. +#[derive(Debug, Clone)] +pub(crate) struct PermissionInfo { + /// The tool call ID. + pub tool_id: String, + + /// The tool name. + pub tool_name: String, + + /// The tool source (builtin, local, MCP). + pub tool_source: ToolSource, + + /// The configured run mode. + pub run_mode: RunMode, + + /// The arguments to pass to the tool. + pub arguments: Value, } #[cfg(test)] -#[path = "executor_tests.rs"] -mod tests; +#[path = "executor_mock.rs"] +pub(crate) mod mock; diff --git a/crates/jp_cli/src/cmd/query/tool/executor_error.rs b/crates/jp_cli/src/cmd/query/tool/executor_error.rs new file mode 100644 index 000000000..8f49844b9 --- /dev/null +++ b/crates/jp_cli/src/cmd/query/tool/executor_error.rs @@ -0,0 +1,82 @@ +//! Failures while the MCP Host advances a logical tool call. + +use std::error::Error as StdError; + +use serde_json::Error as JsonError; +use tokio::task::JoinError; + +/// A tool-call adapter failed before completing its Host protocol. +#[derive(Debug, thiserror::Error)] +pub(crate) enum ExecutorError { + /// The service lost its Host interaction channel. + #[error("MCP Host interaction channel closed")] + HostDisconnected, + + /// Result metadata could not be decoded into the shared result contract. + #[error("Invalid tool result: {0}")] + MalformedResult(#[source] JsonError), + + /// The transport task terminated unexpectedly. + #[error(transparent)] + Task(#[from] JoinError), + + /// An MCP request failed. + #[error("{0}")] + Transport(#[source] Box), + + /// The call stopped before the Host's reply reached the service. + /// + /// `operation` names the barrier that lapsed: `approval`, `release`, or + /// `inquiry`. + #[error("MCP {operation} expired")] + ReplyExpired { + /// The barrier the Host was answering. + operation: &'static str, + }, + + /// No answer exists for the outstanding question. + #[error("Missing answer to pending MCP inquiry")] + MissingAnswer, + + /// The service rejected execution with a tool diagnostic. + #[error("{message}")] + Rejected { + /// The diagnostic to report in place of a result. + message: String, + }, + + /// The call was cancelled by the Host. + #[error("Tool execution cancelled.")] + Cancelled, + + /// The Host asked for something this call's phase cannot do. + #[error("MCP call cannot {operation} while {phase}")] + OutOfOrder { + /// What the Host asked for. + operation: &'static str, + /// The phase the call is in. + phase: &'static str, + }, + + /// The service asked for an interaction outside the expected sequence. + /// + /// Reaching this means the service and this adapter disagree about the + /// interaction protocol, not that a tool or the user did anything wrong. + #[error("Unexpected MCP interaction while {phase} a tool call")] + UnexpectedInteraction { + /// What the adapter was doing. + phase: &'static str, + }, + + /// The content the caller received differs from the content recorded. + #[error("MCP response differs from the recorded response")] + DeliveryMismatch, + + /// The legacy inquiry interface accepts only textual choices. + #[error("Non-string inquiry choice")] + NonStringChoice, + + /// The legacy inquiry interface cannot present this schema. + #[error("Unsupported tool inquiry schema")] + UnsupportedInquirySchema, +} diff --git a/crates/jp_cli/src/cmd/query/tool/executor_mock.rs b/crates/jp_cli/src/cmd/query/tool/executor_mock.rs new file mode 100644 index 000000000..34714443b --- /dev/null +++ b/crates/jp_cli/src/cmd/query/tool/executor_mock.rs @@ -0,0 +1,188 @@ +//! Executors that return a scripted result instead of running anything. + +use std::{collections::HashMap, sync::Mutex}; + +use async_trait::async_trait; +use camino::Utf8Path; +use indexmap::IndexMap; +use jp_config::conversation::tool::ToolConfigWithDefaults; +use jp_conversation::event::{ToolCallRequest, ToolCallResponse}; +use jp_mcp::{Client, server::StderrSink}; +use jp_tool::{ToolDefinition, ToolDocs}; +use serde_json::{Map, Value, json}; +use tokio_util::sync::CancellationToken; + +use super::{Executor, ExecutorResult, ExecutorSource, PermissionInfo}; + +/// A mock executor for testing that returns pre-configured results. +/// +/// This executor doesn't execute any real commands - it simply returns whatever +/// result is configured, making it ideal for testing tool coordination flows +/// without side effects. +/// +/// # Example +/// +/// ```ignore +/// let executor = MockExecutor::completed("call_1", "my_tool", "success output"); +/// let result = executor.execute(&answers, &client, &root, token, None).await; +/// ``` +pub(crate) struct MockExecutor { + tool_id: String, + tool_name: String, + arguments: Map, + permission_info: Option, + result: Mutex>, +} + +impl MockExecutor { + /// Creates a mock executor that returns a successful completion. + pub(crate) fn completed(tool_id: &str, tool_name: &str, output: &str) -> Self { + Self::new(tool_id, tool_name, Ok(output.to_owned())) + } + + /// Creates a mock executor that returns an error. + pub(crate) fn error(tool_id: &str, tool_name: &str, error: &str) -> Self { + Self::new(tool_id, tool_name, Err(error.to_owned())) + } + + fn new(tool_id: &str, tool_name: &str, result: Result) -> Self { + Self { + tool_id: tool_id.to_owned(), + tool_name: tool_name.to_owned(), + arguments: Map::new(), + permission_info: None, + result: Mutex::new(Some(ExecutorResult::Completed(ToolCallResponse { + id: tool_id.to_owned(), + result, + }))), + } + } + + /// Sets the arguments for this executor. + pub(crate) fn with_arguments(mut self, args: Map) -> Self { + self.arguments = args; + self + } + + /// Sets the permission info for this executor. + /// + /// If set, the executor will require permission prompting based on the + /// configured `RunMode`. + pub(crate) fn with_permission_info(mut self, info: PermissionInfo) -> Self { + self.permission_info = Some(info); + self + } +} + +#[async_trait] +impl Executor for MockExecutor { + fn tool_id(&self) -> &str { + &self.tool_id + } + + fn tool_name(&self) -> &str { + &self.tool_name + } + + fn arguments(&self) -> &Map { + &self.arguments + } + + fn permission_info(&self) -> Option { + self.permission_info.clone() + } + + fn set_arguments(&mut self, _args: Value) { + // Arguments don't affect the pre-configured result. + } + + async fn execute( + &self, + _answers: &IndexMap, + _mcp_client: &Client, + _root: &Utf8Path, + _cancellation_token: CancellationToken, + _stderr: Option, + ) -> ExecutorResult { + self.result + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() + .unwrap_or_else(|| { + ExecutorResult::Completed(ToolCallResponse { + id: self.tool_id.clone(), + result: Err("MockExecutor: result already consumed".to_owned()), + }) + }) + } +} + +/// An executor source for testing that returns pre-registered mock executors. +/// +/// This allows tests to inject mock executors for specific tool names without +/// executing any real shell commands. +/// +/// # Example +/// +/// ```ignore +/// let source = TestExecutorSource::new() +/// .with_executor("my_tool", |req| { +/// Box::new(MockExecutor::completed(&req.id, &req.name, "mock output")) +/// }); +/// +/// let coordinator = ToolCoordinator::new(tools_config, Box::new(source)); +/// ``` +#[derive(Default)] +pub(crate) struct TestExecutorSource { + #[expect( + clippy::type_complexity, + reason = "A boxed factory per tool name, named inline rather than aliased once" + )] + factories: HashMap Box + Send + Sync>>, +} + +impl TestExecutorSource { + /// Creates a new empty test executor source. + pub(crate) fn new() -> Self { + Self::default() + } + + /// Registers a factory function for a tool name. + /// + /// When `create()` is called for this tool name, the factory will be + /// invoked to create the executor. + pub(crate) fn with_executor(mut self, tool_name: &str, factory: F) -> Self + where + F: Fn(ToolCallRequest) -> Box + Send + Sync + 'static, + { + self.factories + .insert(tool_name.to_owned(), Box::new(factory)); + self + } + + /// Returns stub [`ToolDefinition`]s for all registered tool names. + /// + /// Useful for passing to `run_turn_loop` so the availability check accepts + /// the tools this source can handle. + pub(crate) fn tool_definitions(&self) -> Vec { + self.factories + .keys() + .map(|name| ToolDefinition { + name: name.clone(), + docs: ToolDocs::default(), + parameters: json!({ "type": "object", "properties": {} }), + }) + .collect() + } +} + +impl ExecutorSource for TestExecutorSource { + fn create( + &self, + request: ToolCallRequest, + _config: ToolConfigWithDefaults, + ) -> Option> { + let factory = self.factories.get(&request.name)?; + Some(factory(request)) + } +} diff --git a/crates/jp_cli/src/cmd/query/tool/executor_tests.rs b/crates/jp_cli/src/cmd/query/tool/executor_tests.rs deleted file mode 100644 index 95cdd75a3..000000000 --- a/crates/jp_cli/src/cmd/query/tool/executor_tests.rs +++ /dev/null @@ -1,272 +0,0 @@ -use std::sync::{ - Arc, - atomic::{AtomicUsize, Ordering}, -}; - -use async_trait::async_trait; -use jp_config::{ - AppConfig, Config as _, - conversation::tool::{PartialToolConfig, ToolConfig}, -}; -use jp_mcp::server::BuiltinTool; -use jp_tool::{Outcome, ToolDocs}; -use serde_json::json; -use tokio::time::{Duration, advance, pause, resume, timeout}; - -use super::*; - -struct InquiringTool(Arc); -#[async_trait] -impl BuiltinTool for InquiringTool { - async fn execute(&self, arguments: &Value, answers: &IndexMap) -> Outcome { - self.0.fetch_add(1, Ordering::SeqCst); - if let Some(answer) = answers.get("confirm") { - return Outcome::Success { - content: json!({"arguments":arguments,"answer":answer}).to_string(), - }; - } - Question::boolean("confirm", "Continue?").unwrap().into() - } -} - -#[tokio::test] -async fn http_executor_keeps_one_call_through_input_and_recording() { - let mut cfg = AppConfig::new_test(); - let partial: PartialToolConfig = - serde_json::from_value(json!({"source":"builtin", "run":"ask", "result":"edit"})).unwrap(); - cfg.conversation.tools.insert( - "example".into(), - ToolConfig::from_partial(partial, vec![]).unwrap(), - ); - let count = Arc::new(AtomicUsize::new(0)); - let definitions = vec![ToolDefinition { - name: "example".into(), - docs: ToolDocs::default(), - parameters: json!({"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}), - }]; - let (source, owner) = TerminalExecutorSource::start( - BuiltinExecutors::new().register("example", InquiringTool(count.clone())), - &definitions, - &cfg.conversation.tools, - Arc::new(ApprovalStore::default()), - InvocationContext::default(), - &Client::default(), - "/tmp".into(), - ) - .await - .unwrap(); - let mut executor = source - .create( - ToolCallRequest { - id: "call-1".into(), - name: "example".into(), - arguments: json!({"name":"original"}).as_object().unwrap().clone(), - }, - cfg.conversation.tools.get("example").unwrap(), - ) - .unwrap(); - assert_eq!(executor.prepare(false).await.unwrap(), None); - assert_eq!(count.load(Ordering::SeqCst), 0); - executor.set_arguments(json!({"name":"edited"})); - executor.approve().await.unwrap(); - assert_eq!(count.load(Ordering::SeqCst), 0); - let first = executor - .execute( - &IndexMap::new(), - &Client::default(), - "/tmp".into(), - CancellationToken::new(), - None, - ) - .await; - let ExecutorResult::NeedsInput { question, .. } = first else { - panic!("expected question") - }; - assert_eq!(question, Question::boolean("confirm", "Continue?").unwrap()); - assert_eq!(count.load(Ordering::SeqCst), 1); - let second = executor - .execute( - &IndexMap::from_iter([("confirm".into(), json!(true))]), - &Client::default(), - "/tmp".into(), - CancellationToken::new(), - None, - ) - .await; - let ExecutorResult::Completed(response) = second else { - panic!("expected result review") - }; - assert_eq!( - response.result, - Ok(r#"{"arguments":{"name":"edited"},"answer":true}"#.into()) - ); - assert_eq!(count.load(Ordering::SeqCst), 2); - timeout( - Duration::from_secs(2), - source.acknowledge(ToolCallResponse { - id: "call-1".into(), - result: Ok("reviewed".into()), - }), - ) - .await - .unwrap() - .unwrap(); - owner.shutdown().await.unwrap(); -} - -async fn fixture( - result_mode: &str, -) -> ( - TerminalExecutorSource, - ExecutionOwner, - Box, - Arc, -) { - let mut cfg = AppConfig::new_test(); - let partial: PartialToolConfig = - serde_json::from_value(json!({"source":"builtin", "run":"ask", "result":result_mode})) - .unwrap(); - cfg.conversation.tools.insert( - "example".into(), - ToolConfig::from_partial(partial, vec![]).unwrap(), - ); - let count = Arc::new(AtomicUsize::new(0)); - let definitions = vec![ToolDefinition { - name: "example".into(), - docs: ToolDocs::default(), - parameters: json!({"type":"object","properties":{}}), - }]; - let (source, owner) = TerminalExecutorSource::start( - BuiltinExecutors::new().register("example", InquiringTool(count.clone())), - &definitions, - &cfg.conversation.tools, - Arc::new(ApprovalStore::default()), - InvocationContext::default(), - &Client::default(), - "/tmp".into(), - ) - .await - .unwrap(); - let executor = source - .create( - ToolCallRequest { - id: "call-1".into(), - name: "example".into(), - arguments: Map::new(), - }, - cfg.conversation.tools.get("example").unwrap(), - ) - .unwrap(); - (source, owner, executor, count) -} - -#[tokio::test] -async fn denied_http_call_never_reaches_execution() { - let (source, owner, mut executor, count) = fixture("unattended").await; - executor.prepare(false).await.unwrap(); - source - .acknowledge(ToolCallResponse { - id: "call-1".into(), - result: Ok("not approved".into()), - }) - .await - .unwrap(); - assert_eq!(count.load(Ordering::SeqCst), 0); - owner.shutdown().await.unwrap(); -} - -#[tokio::test] -async fn failure_after_approval_resolves_without_release_or_delivery_override() { - let (source, owner, mut executor, count) = fixture("skip").await; - executor.prepare(false).await.unwrap(); - executor.approve().await.unwrap(); - source - .acknowledge(ToolCallResponse { - id: "call-1".into(), - result: Err("formatter failed".into()), - }) - .await - .unwrap(); - assert_eq!(count.load(Ordering::SeqCst), 0); - owner.shutdown().await.unwrap(); -} - -#[tokio::test] -async fn declined_inquiry_finishes_without_another_attempt_or_delivery_override() { - let (source, owner, mut executor, count) = fixture("skip").await; - executor.prepare(false).await.unwrap(); - executor.approve().await.unwrap(); - let result = executor - .execute( - &IndexMap::new(), - &Client::default(), - "/tmp".into(), - CancellationToken::new(), - None, - ) - .await; - assert!(matches!(result, ExecutorResult::NeedsInput { .. })); - source - .acknowledge(ToolCallResponse { - id: "call-1".into(), - result: Ok("question declined".into()), - }) - .await - .unwrap(); - assert_eq!(count.load(Ordering::SeqCst), 1); - owner.shutdown().await.unwrap(); -} - -#[tokio::test] -async fn cancellation_before_release_does_not_execute() { - let (source, owner, mut executor, count) = fixture("unattended").await; - executor.prepare(false).await.unwrap(); - executor.approve().await.unwrap(); - let token = CancellationToken::new(); - token.cancel(); - let result = executor - .execute( - &IndexMap::new(), - &Client::default(), - "/tmp".into(), - token, - None, - ) - .await; - let ExecutorResult::Completed(response) = result else { - panic!("expected cancelled response") - }; - assert_eq!(response.result, Err("Tool execution cancelled.".into())); - source.acknowledge(response).await.unwrap(); - assert_eq!(count.load(Ordering::SeqCst), 0); - owner.shutdown().await.unwrap(); -} - -#[tokio::test] -async fn host_approval_can_wait_without_rpc_timeout() { - let (source, owner, mut executor, count) = fixture("unattended").await; - executor.prepare(false).await.unwrap(); - pause(); - advance(Duration::from_secs(121)).await; - resume(); - executor.approve().await.unwrap(); - let result = executor - .execute( - &IndexMap::new(), - &Client::default(), - "/tmp".into(), - CancellationToken::new(), - None, - ) - .await; - assert!(matches!(result, ExecutorResult::NeedsInput { .. })); - source - .acknowledge(ToolCallResponse { - id: "call-1".into(), - result: Ok("declined after waiting".into()), - }) - .await - .unwrap(); - assert_eq!(count.load(Ordering::SeqCst), 1); - owner.shutdown().await.unwrap(); -} diff --git a/crates/jp_cli/src/cmd/query/tool/mcp_executor.rs b/crates/jp_cli/src/cmd/query/tool/mcp_executor.rs new file mode 100644 index 000000000..da5008185 --- /dev/null +++ b/crates/jp_cli/src/cmd/query/tool/mcp_executor.rs @@ -0,0 +1,802 @@ +//! MCP Host adapter for tool calls through JP's loopback HTTP endpoint. +//! +//! [`TerminalExecutorSource`] owns the endpoint, the Host's MCP connection, and +//! the task that routes the service's private interactions back to the tool +//! call that submitted them. +//! Each [`ToolExecutor`] drives one logical call: it holds the single-use Host +//! reply the service is waiting on, and hands the coordinator a [`Delivery`] +//! whenever the call produces something the conversation should record. + +use std::{ + collections::HashMap, + mem, + sync::{Arc, Mutex as SyncMutex, MutexGuard, PoisonError}, +}; + +use async_trait::async_trait; +use camino::{Utf8Path, Utf8PathBuf}; +use futures::future::BoxFuture; +use indexmap::IndexMap; +use jp_config::conversation::tool::{RunMode, ToolConfigWithDefaults, ToolsConfig}; +use jp_conversation::event::{InquirySource, ToolCallRequest, ToolCallResponse}; +use jp_mcp::{ + Client, + server::{ + InvocationContext, StderrSink, + builtin::BuiltinExecutors, + http::{Endpoint, EndpointError}, + result::from_mcp, + service::{ + AccessPolicyError, Admission, ConfiguredTool, Formatted, HostReply, HostRequest, + InputAnswer, Interaction, InvocationId, Progress, ReleaseDecision, Service, + }, + }, +}; +use jp_tool::{ + AnswerType, ContentBlock, InputRequest, Question, QuestionId, ToolDefinition, ToolResult, +}; +use rand::random; +use rmcp::{ + Peer, ServiceError as McpCallError, + model::{CallToolRequestParams, Meta}, + service::{RoleClient, RunningService}, +}; +use serde_json::{Map, Value}; +use tokio::{ + sync::{Mutex, broadcast, broadcast::error::RecvError as ProgressError, mpsc, oneshot}, + task::JoinHandle, +}; +use tokio_util::sync::CancellationToken; +use tracing::debug; + +use super::executor::{ + Executor, ExecutorError, ExecutorResult, ExecutorSource, PermissionInfo, Review, response, +}; +use crate::access::{approvals::ApprovalStore, compile::compile_tool_policy}; + +/// Where a call's correlation key travels on its MCP request. +/// +/// The value is generated by the Host and never derived from anything a caller +/// supplies, so a third-party MCP client cannot name someone else's call. +const CORRELATION_KEY: &str = "computer.jp/hostCall"; + +type Reply = oneshot::Sender>; + +fn locked(value: &SyncMutex) -> MutexGuard<'_, T> { + // No caller code runs under these locks, so recovering a poisoned one lets + // an unrelated panic elsewhere finish this turn's calls rather than hang + // them. + value.lock().unwrap_or_else(PoisonError::into_inner) +} + +/// Private correlation value generated by the Host, not a provider tool-call +/// ID. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct HostCallKey(String); + +impl HostCallKey { + fn generate() -> Self { + Self(format!("{:032x}", random::())) + } +} + +/// Everything the Host keeps for one logical tool call. +struct CallSlot { + /// The correlation key the service echoes back on every interaction. + key: HostCallKey, + + /// The call as the assistant requested it. + /// Argument edits do not change it. + request: ToolCallRequest, + + /// Where the router forwards this call's interactions. + sender: mpsc::Sender, + + /// The service's own name for this call, learned from its first + /// interaction. + invocation: SyncMutex>, + + /// Where to show this tool's stderr while something is watching. + stderr: SyncMutex>, + + /// The call's protocol phase and the Host reply it is parked on. + state: Mutex, +} + +/// The calls a turn has in flight, under the two names they are looked up by. +/// +/// Both maps hold the same slots: the router resolves an interaction by the +/// correlation key it carries, and acknowledgement resolves a recorded response +/// by the tool call id the conversation stores. +#[derive(Default)] +struct Registry { + by_key: HashMap>, + by_id: HashMap>, +} + +impl Registry { + fn insert(&mut self, slot: &Arc) { + self.by_key.insert(slot.key.clone(), slot.clone()); + self.by_id.insert(slot.request.id.clone(), slot.clone()); + } + + /// Claim a call for acknowledgement, leaving its route in place. + /// + /// The service still has barriers to raise before the call finishes, and a + /// request it cannot route fails closed, so the route outlives the claim. + fn claim(&mut self, id: &str) -> Option> { + self.by_id.remove(id) + } + + /// Drop a finished call's route. + fn forget(&mut self, slot: &CallSlot) { + self.by_key.remove(&slot.key); + } +} + +/// Creates MCP-backed executors; configuration and execution context are fixed +/// by the MCP Host when starting the endpoint. +pub(crate) struct TerminalExecutorSource { + peer: Peer, + service: Arc, + definitions: IndexMap, + calls: Arc>, +} + +/// Keeps the listener, MCP connection, and Host routing task alive for a turn. +pub(crate) struct ExecutionOwner { + endpoint: Option, + client: Option>, + router: JoinHandle<()>, +} + +impl ExecutionOwner { + /// Cancel pending work and wait for listener/connection cleanup. + pub(crate) async fn shutdown(mut self) -> Result<(), EndpointError> { + if let Some(endpoint) = self.endpoint.take() { + endpoint.shutdown().await?; + } + if let Some(client) = self.client.take() { + client.cancel().await?; + } + self.router.abort(); + Ok(()) + } +} + +impl Drop for ExecutionOwner { + fn drop(&mut self) { + self.router.abort(); + } +} + +impl TerminalExecutorSource { + /// Start the common MCP execution path and its private Host connection. + pub(crate) async fn start( + builtins: BuiltinExecutors, + definitions: &[ToolDefinition], + tools: &ToolsConfig, + approvals: Arc, + invocation: InvocationContext, + upstream: &Client, + root: Utf8PathBuf, + ) -> Result<(Self, ExecutionOwner), EndpointError> { + let configured = definitions + .iter() + .filter_map(|definition| { + let config = tools.get(&definition.name)?; + let access = + compile_tool_policy(config.access(), &root, &approvals).map_err(|error| { + AccessPolicyError { + tool: definition.name.clone(), + source: Arc::new(error), + } + }); + Some(ConfiguredTool { + definition: definition.clone(), + config, + access, + metadata: Map::new(), + }) + }) + .collect(); + let (service, host) = + Service::new(configured, upstream.clone(), builtins, root, invocation)?; + let progress = service.subscribe_progress(); + let endpoint = Endpoint::start(service).await?; + let client = endpoint.connect().await?; + let calls = Arc::new(SyncMutex::new(Registry::default())); + let router = tokio::spawn(route(host, progress, calls.clone())); + let source = Self { + peer: client.peer().clone(), + service: endpoint.service(), + definitions: definitions + .iter() + .map(|definition| (definition.name.clone(), definition.clone())) + .collect(), + calls, + }; + Ok((source, ExecutionOwner { + endpoint: Some(endpoint), + client: Some(client), + router, + })) + } +} + +/// Deliver the service's interactions and stderr to the calls they belong to. +/// +/// Both streams are drained by one task so a display that stops reading cannot +/// delay a required interaction: progress is dropped, interactions are not. +async fn route( + mut host: mpsc::Receiver, + mut progress: broadcast::Receiver, + calls: Arc>, +) { + // The service names a call by an `InvocationId` the Host only learns from + // that call's first interaction, so this index is built here rather than at + // registration. Being task-local it needs no lock, and it is bounded by the + // turn's tool calls: the task is aborted when the turn's owner shuts down. + let mut by_invocation: HashMap> = HashMap::new(); + let mut watching_progress = true; + loop { + tokio::select! { + request = host.recv() => { + let Some(request) = request else { break }; + let Some(slot) = resolve(&calls, &request) else { + // An unassociated call loses its reply sender and fails + // closed. + continue; + }; + by_invocation.insert(request.call.id, slot.clone()); + drop(slot.sender.send(request).await); + } + line = progress.recv(), if watching_progress => match line { + Ok(line) => { + let sink = by_invocation + .get(&line.id) + .and_then(|slot| locked(&slot.stderr).clone()); + if let Some(sink) = sink { + sink(&line.line); + } + } + // A display that fell behind loses stderr lines. It never + // delays the interactions in the other branch, which is the + // point of keeping progress on its own channel. + Err(ProgressError::Lagged(_)) => {} + Err(ProgressError::Closed) => watching_progress = false, + }, + } + } +} + +/// Find the call an interaction belongs to, if it names one. +/// +/// The correlation key alone identifies the call. +/// The name and arguments are checked too so a key that somehow leaked cannot +/// be pointed at a different call than the one it was issued for; neither field +/// grants any authority of its own. +fn resolve(calls: &SyncMutex, request: &HostRequest) -> Option> { + let key = request + .call + .request + .correlation + .get(CORRELATION_KEY) + .and_then(Value::as_str) + .map(|value| HostCallKey(value.to_owned()))?; + let slot = locked(calls).by_key.get(&key).cloned()?; + if slot.request.name != request.call.request.name + || slot.request.arguments != request.call.request.arguments + { + return None; + } + let mut invocation = locked(&slot.invocation); + match *invocation { + Some(id) if id != request.call.id => return None, + Some(_) => {} + None => { + debug!( + invocation = ?request.call.id, + tool_call_id = %slot.request.id, + tool = %slot.request.name, + "Associated MCP invocation with Host tool call" + ); + *invocation = Some(request.call.id); + } + } + drop(invocation); + Some(slot) +} + +impl ExecutorSource for TerminalExecutorSource { + fn create( + &self, + request: ToolCallRequest, + config: ToolConfigWithDefaults, + ) -> Option> { + self.definitions.get(&request.name)?; + let (sender, receiver) = mpsc::channel(8); + let slot = Arc::new(CallSlot { + key: HostCallKey::generate(), + request, + sender, + invocation: SyncMutex::new(None), + stderr: SyncMutex::new(None), + state: Mutex::new(PendingCall { + receiver, + task: None, + phase: Phase::Idle, + }), + }); + locked(&self.calls).insert(&slot); + Some(Box::new(ToolExecutor { + arguments: slot.request.arguments.clone(), + config, + peer: self.peer.clone(), + service: self.service.clone(), + slot, + formatted: None, + })) + } + + fn acknowledge(&self, review: Review) -> BoxFuture<'_, Result<(), ExecutorError>> { + Box::pin(async move { + // Claiming makes a second acknowledgement a no-op. Forgetting the + // call afterwards is what bounds the registry; a turn that ends + // without acknowledging every call drops the whole source. + let Some(slot) = locked(&self.calls).claim(&review.response.id) else { + return Ok(()); + }; + let result = slot.acknowledge(&review).await; + locked(&self.calls).forget(&slot); + result + }) + } +} + +/// The Host reply a call is currently parked on. +/// +/// Exactly one is outstanding at a time: the service asks for the next thing +/// only once the previous reply reaches it. +enum Phase { + /// Created, but the MCP call has not been submitted. + Idle, + + /// Submitted; the Host owes an admission decision. + Admission(Reply), + + /// Admitted; the Host owes execution release. + Release(Reply), + + /// A tool asked for input; the Host owes an answer. + Input { + id: QuestionId, + reply: Reply, + }, + + /// A result is waiting for the Host to approve or replace its content. + /// + /// `offered` is what the service produced. + /// A Host that does not edit gets this value back, so resources, + /// annotations, structured content, and error details survive a review that + /// changed nothing. + Review { + offered: ToolResult, + reply: Reply, + }, + + /// Content is waiting for the Host to confirm it recorded it. + Record(Reply<()>), + + /// The MCP call returned; nothing is outstanding. + Finished, +} + +impl Phase { + /// How this phase reads in a protocol diagnostic. + fn name(&self) -> &'static str { + match self { + Self::Idle => "not yet submitted", + Self::Admission(_) => "awaiting admission", + Self::Release(_) => "awaiting release", + Self::Input { .. } => "awaiting input", + Self::Review { .. } => "awaiting result review", + Self::Record(_) => "awaiting recording", + Self::Finished => "finished", + } + } +} + +struct PendingCall { + receiver: mpsc::Receiver, + task: Option>>, + phase: Phase, +} + +enum Received { + /// Boxed because a call holds one of these only while dispatching it, and + /// the largest variant is several times the size of the rest. + Interaction(Box), + + /// The MCP call returned its final result. + Finished(ToolResult), +} + +impl PendingCall { + /// Wait for the next Host interaction, or for the MCP call to return. + async fn next(&mut self) -> Result { + let task = self.task.as_mut().ok_or(ExecutorError::HostDisconnected)?; + tokio::select! { + request = self.receiver.recv() => { + let request = request.ok_or(ExecutorError::HostDisconnected)?; + Ok(Received::Interaction(Box::new(request.interaction))) + } + result = task => { + self.task = None; + self.phase = Phase::Finished; + let result = result?.map_err(|error| ExecutorError::Transport(Box::new(error)))?; + Ok(Received::Finished( + from_mcp(result).map_err(ExecutorError::MalformedResult)?, + )) + } + } + } +} + +/// The result the service should deliver, given what the Host recorded. +/// +/// An unedited review returns the result the service offered, so resources, +/// annotations, structured content, and error details survive a review that +/// changed nothing. +/// Anything else is rebuilt from the recorded text, which is all the Host's +/// replacement content amounts to. +fn approved(offered: Option, review: &Review) -> ToolResult { + match offered { + Some(offered) if !review.edited => offered, + _ => ToolResult::from(review.response.result.clone()), + } +} + +impl CallSlot { + /// Release whichever barrier the call is parked on with the Host's final + /// content, then drain the call to its MCP response. + async fn acknowledge(&self, review: &Review) -> Result<(), ExecutorError> { + let mut state = self.state.lock().await; + match mem::replace(&mut state.phase, Phase::Finished) { + // Nothing is outstanding: the call already delivered its result, or + // never started because preparation failed. + Phase::Idle | Phase::Finished => return Ok(()), + Phase::Admission(reply) => drop(reply.send(Ok(Admission::Complete { + result: approved(None, review), + }))), + Phase::Release(reply) => drop(reply.send(Ok(ReleaseDecision::Complete { + result: approved(None, review), + }))), + Phase::Input { reply, .. } => drop(reply.send(Ok(InputAnswer::Complete { + result: approved(None, review), + }))), + Phase::Review { offered, reply } => { + drop(reply.send(Ok(approved(Some(offered), review)))); + } + Phase::Record(reply) => drop(reply.send(Ok(()))), + } + self.drain(&mut state, review).await + } + + /// Answer the service's remaining barriers and check what it delivered. + async fn drain(&self, state: &mut PendingCall, review: &Review) -> Result<(), ExecutorError> { + loop { + match state.next().await? { + Received::Interaction(interaction) => match *interaction { + // The Host settled this call at an earlier barrier, so the + // content it recorded is also what it approves here. + Interaction::Review { result, reply, .. } => { + drop(reply.send(Ok(approved(Some(result), review)))); + } + Interaction::Record { reply, .. } => drop(reply.send(Ok(()))), + _ => { + return Err(ExecutorError::UnexpectedInteraction { phase: "recording" }); + } + }, + Received::Finished(delivered) => { + let delivered = response(&review.response.id, &delivered); + return if delivered.result == review.response.result { + Ok(()) + } else { + Err(ExecutorError::DeliveryMismatch) + }; + } + } + } + } +} + +/// Represents one logical MCP call, including its pending Host interactions. +pub(crate) struct ToolExecutor { + /// The arguments to execute with, which Host editing may replace. + arguments: Map, + config: ToolConfigWithDefaults, + peer: Peer, + service: Arc, + slot: Arc, + formatted: Option, +} + +impl ToolExecutor { + /// Cancel the service-side invocation, if the service has named it yet. + fn cancel_invocation(&self) { + if let Some(id) = *locked(&self.slot.invocation) { + self.service.cancel_call(id); + } + } +} + +#[async_trait] +impl Executor for ToolExecutor { + fn tool_id(&self) -> &str { + &self.slot.request.id + } + + fn tool_name(&self) -> &str { + &self.slot.request.name + } + + fn arguments(&self) -> &Map { + &self.arguments + } + + fn formatted_arguments(&self) -> Option<&Formatted> { + self.formatted.as_ref() + } + + fn needs_permission(&self) -> bool { + !matches!(self.config.run(), RunMode::Unattended | RunMode::Skip) + } + + fn permission_info(&self) -> Option { + let run_mode = self.config.run(); + if matches!(run_mode, RunMode::Unattended | RunMode::Skip) { + return None; + } + Some(PermissionInfo { + tool_id: self.slot.request.id.clone(), + tool_name: self.slot.request.name.clone(), + tool_source: self.config.source().clone(), + run_mode, + arguments: self.arguments.clone().into(), + }) + } + + fn set_arguments(&mut self, args: Value) { + if let Value::Object(arguments) = args { + self.arguments = arguments; + } + } + + async fn prepare( + &mut self, + render_arguments: bool, + ) -> Result, ExecutorError> { + let mut state = self.slot.state.lock().await; + if !matches!(state.phase, Phase::Idle) { + return Err(ExecutorError::OutOfOrder { + operation: "be submitted", + phase: state.phase.name(), + }); + } + let mut params = CallToolRequestParams::new(self.slot.request.name.clone()); + params.arguments = Some(self.arguments.clone()); + params.meta = Some(Meta(Map::from_iter([( + CORRELATION_KEY.into(), + self.slot.key.0.clone().into(), + )]))); + let peer = self.peer.clone(); + state.task = Some(tokio::spawn(async move { peer.call_tool(params).await })); + loop { + match state.next().await? { + Received::Interaction(interaction) => match *interaction { + Interaction::RenderArguments { reply } => { + drop(reply.send(Ok(render_arguments))); + } + Interaction::Prepare { + arguments, + formatted_arguments, + reply, + .. + } => { + self.arguments = arguments; + self.formatted = formatted_arguments; + state.phase = Phase::Admission(reply); + return Ok(None); + } + Interaction::Record { result, reply, .. } => { + let response = response(&self.slot.request.id, &result); + state.phase = Phase::Record(reply); + return Ok(Some(response)); + } + _ => { + return Err(ExecutorError::UnexpectedInteraction { phase: "preparing" }); + } + }, + Received::Finished(result) => { + return Ok(Some(response(&self.slot.request.id, &result))); + } + } + } + } + + async fn approve(&mut self) -> Result<(), ExecutorError> { + let mut state = self.slot.state.lock().await; + let Phase::Admission(reply) = mem::replace(&mut state.phase, Phase::Finished) else { + state.phase = Phase::Finished; + return Err(ExecutorError::OutOfOrder { + operation: "be approved", + phase: "not awaiting admission", + }); + }; + reply + .send(Ok(Admission::Run { + arguments: self.arguments.clone(), + })) + .map_err(|_| ExecutorError::ReplyExpired { + operation: "approval", + })?; + match state.next().await? { + Received::Interaction(interaction) => match *interaction { + Interaction::Release { + arguments, + formatted_arguments, + reply, + } => { + self.arguments = arguments; + self.formatted = formatted_arguments; + state.phase = Phase::Release(reply); + Ok(()) + } + _ => Err(ExecutorError::UnexpectedInteraction { phase: "approving" }), + }, + // Validating the approved arguments can fail the call outright, + // which arrives as the MCP response rather than another barrier. + Received::Finished(result) if result.is_error() => Err(ExecutorError::Rejected { + message: result.to_text(), + }), + Received::Finished(_) => { + Err(ExecutorError::UnexpectedInteraction { phase: "approving" }) + } + } + } + + async fn execute( + &self, + answers: &IndexMap, + _: &Client, + _: &Utf8Path, + cancellation: CancellationToken, + stderr: Option, + ) -> ExecutorResult { + let mut state = self.slot.state.lock().await; + *locked(&self.slot.stderr) = stderr; + let attempt = async { + match mem::replace(&mut state.phase, Phase::Finished) { + Phase::Release(reply) => { + reply.send(Ok(ReleaseDecision::Execute)).map_err(|_| { + ExecutorError::ReplyExpired { + operation: "release", + } + })?; + } + Phase::Input { id, reply } => { + let answer = answers + .get(id.as_str()) + .ok_or(ExecutorError::MissingAnswer)? + .clone(); + reply.send(Ok(InputAnswer::Answer(answer))).map_err(|_| { + ExecutorError::ReplyExpired { + operation: "inquiry", + } + })?; + } + phase => { + let name = phase.name(); + state.phase = phase; + return Err(ExecutorError::OutOfOrder { + operation: "execute", + phase: name, + }); + } + } + let id = &self.slot.request.id; + match state.next().await? { + Received::Interaction(interaction) => match *interaction { + Interaction::Input { + request, + supporting, + answers, + reply, + } => { + let question = question(request, &supporting)?; + state.phase = Phase::Input { + id: question.id.clone(), + reply, + }; + Ok(ExecutorResult::NeedsInput { + tool_id: id.clone(), + tool_name: self.slot.request.name.clone(), + source: InquirySource::tool(&self.slot.request.name), + question, + accumulated_answers: answers, + }) + } + Interaction::Review { result, reply, .. } => { + let offered = response(id, &result); + state.phase = Phase::Review { + offered: result, + reply, + }; + Ok(ExecutorResult::Completed(offered)) + } + Interaction::Record { result, reply, .. } => { + let response = response(id, &result); + state.phase = Phase::Record(reply); + Ok(ExecutorResult::Completed(response)) + } + _ => Err(ExecutorError::UnexpectedInteraction { phase: "executing" }), + }, + Received::Finished(result) => Ok(ExecutorResult::Completed(response(id, &result))), + } + }; + let result = tokio::select! { + biased; + () = cancellation.cancelled() => Err(ExecutorError::Cancelled), + result = attempt => result, + }; + result.unwrap_or_else(|error| { + // The call cannot continue, so stop the service-side work rather + // than leaving it parked on a reply that will never arrive. + self.cancel_invocation(); + state.phase = Phase::Finished; + ExecutorResult::Completed(ToolCallResponse { + id: self.slot.request.id.clone(), + result: Err(error.to_string()), + }) + }) + } +} + +/// Render a shared input request as the question the terminal prompts with. +fn question(request: InputRequest, supporting: &[ContentBlock]) -> Result { + let answer_type = if request.secret { + AnswerType::Secret + } else if request.schema.get("type").and_then(Value::as_str) == Some("boolean") { + AnswerType::Boolean + } else if let Some(options) = request.schema.get("enum").and_then(Value::as_array) { + AnswerType::Select { + options: options + .iter() + .map(|value| { + value + .as_str() + .map(str::to_owned) + .ok_or(ExecutorError::NonStringChoice) + }) + .collect::>()?, + } + } else if request.schema.get("type").and_then(Value::as_str) == Some("string") { + AnswerType::Text + } else { + return Err(ExecutorError::UnsupportedInquirySchema); + }; + let preamble = supporting + .iter() + .filter_map(ContentBlock::as_text) + .collect::>() + .join("\n\n"); + let mut question = Question::new(request.id, request.label, answer_type); + question.pre_amble = (!preamble.is_empty()).then_some(preamble); + question.default = request.default; + Ok(question) +} + +#[cfg(test)] +#[path = "mcp_executor_tests.rs"] +mod tests; diff --git a/crates/jp_cli/src/cmd/query/tool/mcp_executor_tests.rs b/crates/jp_cli/src/cmd/query/tool/mcp_executor_tests.rs new file mode 100644 index 000000000..79ef07c13 --- /dev/null +++ b/crates/jp_cli/src/cmd/query/tool/mcp_executor_tests.rs @@ -0,0 +1,487 @@ +use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, +}; + +use async_trait::async_trait; +use jp_config::{ + AppConfig, Config as _, + conversation::tool::{PartialToolConfig, ToolConfig}, +}; +use jp_conversation::event::ToolCallResponse; +use jp_mcp::server::{BuiltinTool, result::to_mcp}; +use jp_tool::{ + ContentBlock, Outcome, ToolDocs, ToolResult, + content::{ErrorDetails, Resource, ToolStatus}, +}; +use rmcp::model::CallToolRequestParams; +use serde_json::json; +use tokio::time::{Duration, timeout}; + +use super::*; + +/// A tool that asks one question, then echoes the arguments and the answer. +/// +/// The counter is how a test tells "the tool never ran" from "the tool ran and +/// its output went nowhere": every assertion about a denied or cancelled call +/// pairs the outcome with a count. +struct InquiringTool(Arc); + +#[async_trait] +impl BuiltinTool for InquiringTool { + async fn execute(&self, arguments: &Value, answers: &IndexMap) -> Outcome { + self.0.fetch_add(1, Ordering::SeqCst); + if let Some(answer) = answers.get("confirm") { + return Outcome::Success { + content: json!({"arguments": arguments, "answer": answer}).to_string(), + }; + } + Question::boolean("confirm", "Continue?").unwrap().into() + } +} + +struct Fixture { + source: TerminalExecutorSource, + owner: ExecutionOwner, + config: ToolConfigWithDefaults, + count: Arc, +} + +impl Fixture { + /// Start a service exposing one `example` tool with the given config. + async fn start(config: Value, tool: InquiringTool) -> Self { + let partial: PartialToolConfig = serde_json::from_value(config).unwrap(); + let mut cfg = AppConfig::new_test(); + cfg.conversation.tools.insert( + "example".into(), + ToolConfig::from_partial(partial, vec![]).unwrap(), + ); + let count = Arc::new(AtomicUsize::new(0)); + let definitions = vec![ToolDefinition { + name: "example".into(), + docs: ToolDocs::default(), + parameters: json!({ + "type": "object", + "properties": {"name": {"type": "string"}}, + }), + }]; + let (source, owner) = TerminalExecutorSource::start( + BuiltinExecutors::new().register("example", tool), + &definitions, + &cfg.conversation.tools, + Arc::new(ApprovalStore::default()), + InvocationContext::default(), + &Client::default(), + "/tmp".into(), + ) + .await + .unwrap(); + Self { + source, + owner, + config: cfg.conversation.tools.get("example").unwrap(), + count, + } + } + + /// Start a fixture whose tool asks a question before completing. + async fn inquiring(result_mode: &str) -> Self { + let count = Arc::new(AtomicUsize::new(0)); + let tool = InquiringTool(count.clone()); + let mut fixture = Self::start( + json!({"source": "builtin", "run": "ask", "result": result_mode}), + tool, + ) + .await; + fixture.count = count; + fixture + } + + fn executor(&self, arguments: &Value) -> Box { + self.source + .create( + ToolCallRequest { + id: "call-1".into(), + name: "example".into(), + arguments: arguments.as_object().cloned().unwrap_or_default(), + }, + self.config.clone(), + ) + .unwrap() + } + + fn attempts(&self) -> usize { + self.count.load(Ordering::SeqCst) + } + + /// Acknowledge a call, failing rather than hanging if the service never + /// releases its barrier. + async fn acknowledge(&self, review: Review) -> Result<(), ExecutorError> { + timeout(Duration::from_secs(5), self.source.acknowledge(review)) + .await + .expect("acknowledgement timed out") + } + + async fn shutdown(self) { + self.owner.shutdown().await.unwrap(); + } +} + +fn recorded(result: Result<&str, &str>) -> Review { + Review::replaced(ToolCallResponse { + id: "call-1".into(), + result: result.map(str::to_owned).map_err(str::to_owned), + }) +} + +#[tokio::test] +async fn one_call_spans_input_and_recording() { + let fixture = Fixture::inquiring("edit").await; + let mut executor = fixture.executor(&json!({"name": "original"})); + + assert!(executor.prepare(false).await.unwrap().is_none()); + assert_eq!(fixture.attempts(), 0); + + executor.set_arguments(json!({"name": "edited"})); + executor.approve().await.unwrap(); + assert_eq!(fixture.attempts(), 0, "approval alone must not execute"); + + let first = executor + .execute( + &IndexMap::new(), + &Client::default(), + "/tmp".into(), + CancellationToken::new(), + None, + ) + .await; + let ExecutorResult::NeedsInput { question, .. } = first else { + panic!("expected the tool's question, got {first:?}") + }; + assert_eq!(question, Question::boolean("confirm", "Continue?").unwrap()); + assert_eq!(fixture.attempts(), 1); + + let second = executor + .execute( + &IndexMap::from_iter([("confirm".into(), json!(true))]), + &Client::default(), + "/tmp".into(), + CancellationToken::new(), + None, + ) + .await; + let ExecutorResult::Completed(response) = second else { + panic!("expected a completed call, got {second:?}") + }; + // The answer reached a second execution of the same logical call, and the + // arguments it ran with are the edited ones. + assert_eq!( + response.result, + Ok(r#"{"arguments":{"name":"edited"},"answer":true}"#.into()) + ); + assert_eq!(fixture.attempts(), 2); + + fixture.acknowledge(recorded(Ok("reviewed"))).await.unwrap(); + assert_eq!(fixture.attempts(), 2, "acknowledgement must not re-execute"); + fixture.shutdown().await; +} + +/// A result carrying everything the conversation's text projection drops. +fn rich_result() -> ToolResult { + ToolResult { + content: vec![ + ContentBlock::text("plain text"), + ContentBlock::Resource(Resource::text("file:///a", "embedded")), + ], + status: ToolStatus::Error(ErrorDetails { + transient: true, + trace: vec!["upstream".into()], + }), + structured_content: Some(json!({"answer": 42})), + metadata: None, + } +} + +#[test] +fn an_unedited_review_delivers_the_result_the_service_offered() { + let offered = rich_result(); + let recorded = response("call-1", &offered); + // The conversation keeps only the text, and it is an error, so a result + // rebuilt from it would be a single text block with no resource, no + // structured content, and default error details. + assert_eq!( + recorded.result, + Err("plain text\n\nembedded".into()), + "the text projection is what the conversation records" + ); + + let delivered = approved(Some(offered.clone()), &Review::unchanged(recorded)); + + assert_eq!(delivered, offered); +} + +#[test] +fn an_edited_review_delivers_the_content_the_host_recorded() { + let offered = rich_result(); + let edited = Review::replaced(ToolCallResponse { + id: "call-1".into(), + result: Ok("the user rewrote this".into()), + }); + + let delivered = approved(Some(offered), &edited); + + // Editing replaces the content outright: the caller must not receive the + // resource, structured content, or error status of a result the Host chose + // not to deliver. + assert_eq!(delivered, ToolResult::text("the user rewrote this")); +} + +#[test] +fn a_barrier_with_no_result_behind_it_delivers_the_recorded_content() { + // A call the Host denied before execution has no result of its own, so + // there is nothing to preserve and the recorded text is all there is. + let denied = Review::unchanged(ToolCallResponse { + id: "call-1".into(), + result: Err("not approved".into()), + }); + + assert_eq!(approved(None, &denied), ToolResult::error("not approved")); +} + +#[tokio::test] +async fn an_unedited_review_reaches_the_service_through_a_real_call() { + // The unit tests above pin the decision; this pins that a review actually + // reaches it, rather than the call resolving at some earlier barrier. + let fixture = Fixture::inquiring("ask").await; + let mut executor = fixture.executor(&json!({})); + assert!(executor.prepare(false).await.unwrap().is_none()); + executor.approve().await.unwrap(); + + let first = executor + .execute( + &IndexMap::new(), + &Client::default(), + "/tmp".into(), + CancellationToken::new(), + None, + ) + .await; + assert!(matches!(first, ExecutorResult::NeedsInput { .. })); + + let second = executor + .execute( + &IndexMap::from_iter([("confirm".into(), json!(true))]), + &Client::default(), + "/tmp".into(), + CancellationToken::new(), + None, + ) + .await; + let ExecutorResult::Completed(response) = second else { + panic!("expected a reviewable result, got {second:?}") + }; + + // Recording the offered content unchanged completes the call: the service + // accepts its own result back and returns it to the caller. + fixture + .acknowledge(Review::unchanged(response)) + .await + .unwrap(); + assert_eq!(fixture.attempts(), 2); + fixture.shutdown().await; +} + +#[tokio::test] +async fn a_denied_call_completes_without_executing() { + let fixture = Fixture::inquiring("unattended").await; + let mut executor = fixture.executor(&json!({})); + assert!(executor.prepare(false).await.unwrap().is_none()); + + fixture + .acknowledge(recorded(Ok("not approved"))) + .await + .unwrap(); + + assert_eq!(fixture.attempts(), 0, "a denied call must not run the tool"); + // Acknowledging again is a no-op rather than an error: the call is gone. + fixture + .acknowledge(recorded(Ok("not approved"))) + .await + .unwrap(); + fixture.shutdown().await; +} + +#[tokio::test] +async fn a_failure_after_approval_resolves_the_call() { + let fixture = Fixture::inquiring("skip").await; + let mut executor = fixture.executor(&json!({})); + executor.prepare(false).await.unwrap(); + executor.approve().await.unwrap(); + + // The Host abandons the call at the release barrier rather than executing. + fixture + .acknowledge(recorded(Err("formatter failed"))) + .await + .unwrap(); + + assert_eq!(fixture.attempts(), 0); + fixture.shutdown().await; +} + +#[tokio::test] +async fn a_declined_inquiry_finishes_without_another_attempt() { + let fixture = Fixture::inquiring("skip").await; + let mut executor = fixture.executor(&json!({})); + executor.prepare(false).await.unwrap(); + executor.approve().await.unwrap(); + + let result = executor + .execute( + &IndexMap::new(), + &Client::default(), + "/tmp".into(), + CancellationToken::new(), + None, + ) + .await; + assert!(matches!(result, ExecutorResult::NeedsInput { .. })); + assert_eq!(fixture.attempts(), 1); + + fixture + .acknowledge(recorded(Ok("question declined"))) + .await + .unwrap(); + + assert_eq!( + fixture.attempts(), + 1, + "declining the question must not run the tool again" + ); + fixture.shutdown().await; +} + +#[tokio::test] +async fn cancellation_before_release_does_not_execute() { + let fixture = Fixture::inquiring("unattended").await; + let mut executor = fixture.executor(&json!({})); + executor.prepare(false).await.unwrap(); + executor.approve().await.unwrap(); + + let token = CancellationToken::new(); + token.cancel(); + let result = executor + .execute( + &IndexMap::new(), + &Client::default(), + "/tmp".into(), + token, + None, + ) + .await; + + let ExecutorResult::Completed(response) = result else { + panic!("expected a cancelled response, got {result:?}") + }; + assert_eq!(response.result, Err("Tool execution cancelled.".into())); + assert_eq!(fixture.attempts(), 0); + + fixture + .acknowledge(Review::unchanged(response)) + .await + .unwrap(); + fixture.shutdown().await; +} + +#[tokio::test] +async fn preparing_a_call_twice_is_refused() { + let fixture = Fixture::inquiring("unattended").await; + let mut executor = fixture.executor(&json!({})); + executor.prepare(false).await.unwrap(); + + let error = executor.prepare(false).await.unwrap_err(); + assert_eq!( + error.to_string(), + "MCP call cannot be submitted while awaiting admission" + ); + assert_eq!(fixture.attempts(), 0); + fixture.shutdown().await; +} + +#[tokio::test] +async fn approval_is_refused_before_the_call_is_submitted() { + let fixture = Fixture::inquiring("unattended").await; + let mut executor = fixture.executor(&json!({})); + + let error = executor.approve().await.unwrap_err(); + assert_eq!( + error.to_string(), + "MCP call cannot be approved while not awaiting admission" + ); + assert_eq!(fixture.attempts(), 0); + fixture.shutdown().await; +} + +#[tokio::test] +async fn caller_metadata_cannot_claim_another_call() { + // The correlation key is the Host's own, so an MCP call that arrives + // without it (or with the wrong one) never reaches a Host route and fails + // closed rather than borrowing another call's approval. + let fixture = Fixture::inquiring("unattended").await; + let _executor = fixture.executor(&json!({})); + + let mut params = CallToolRequestParams::new("example"); + params.arguments = Some(Map::new()); + params.meta = Some(Meta( + json!({"computer.jp/hostCall": "0".repeat(32)}) + .as_object() + .unwrap() + .clone(), + )); + let peer = fixture.source.peer.clone(); + let call = tokio::spawn(async move { peer.call_tool(params).await }); + + // No Host route accepts the forged key, so the service's interaction is + // dropped and the call ends without an admission decision. + let outcome = timeout(Duration::from_secs(5), call) + .await + .expect("forged call must not hang") + .unwrap(); + assert!( + outcome.is_err(), + "a forged correlation key must not execute" + ); + assert_eq!(fixture.attempts(), 0); + fixture.shutdown().await; +} + +#[test] +fn a_rich_result_projects_to_text_and_survives_the_mcp_round_trip() { + // Pins the compatibility projection the conversation stores against the + // result the caller receives: the first drops everything but text, the + // second keeps all of it. + let result = ToolResult { + content: vec![ + ContentBlock::text("plain text"), + ContentBlock::Resource(Resource::text("file:///a", "embedded")), + ], + status: ToolStatus::Success, + structured_content: Some(json!({"answer": 42})), + metadata: None, + }; + + assert_eq!( + response("call-1", &result).result, + Ok("plain text\n\nembedded".into()) + ); + assert_eq!( + serde_json::to_value(to_mcp(result).unwrap()).unwrap(), + json!({ + "content": [ + {"type": "text", "text": "plain text"}, + {"type": "resource", "resource": {"uri": "file:///a", "text": "embedded"}}, + ], + "structuredContent": {"answer": 42}, + "isError": false, + }) + ); +} diff --git a/crates/jp_cli/src/cmd/query/tool/pending.rs b/crates/jp_cli/src/cmd/query/tool/pending.rs index d4534b575..9b30a0028 100644 --- a/crates/jp_cli/src/cmd/query/tool/pending.rs +++ b/crates/jp_cli/src/cmd/query/tool/pending.rs @@ -24,7 +24,8 @@ use jp_conversation::{ ConversationStream, event::{ToolCallRequest, ToolCallResponse}, }; -use jp_llm::tool::Executor; + +use super::executor::Executor; /// The work product for a single tool call, as decided during the streaming /// phase. diff --git a/crates/jp_cli/src/cmd/query/tool/pending_tests.rs b/crates/jp_cli/src/cmd/query/tool/pending_tests.rs index 16641f1cc..586023f76 100644 --- a/crates/jp_cli/src/cmd/query/tool/pending_tests.rs +++ b/crates/jp_cli/src/cmd/query/tool/pending_tests.rs @@ -2,10 +2,10 @@ use jp_conversation::{ ConversationStream, event::{ChatRequest, ChatResponse, ToolCallRequest, ToolCallResponse}, }; -use jp_llm::tool::MockExecutor; use serde_json::Map; use super::*; +use crate::cmd::query::tool::executor::mock::MockExecutor; fn req(id: &str, name: &str) -> ToolCallRequest { ToolCallRequest { diff --git a/crates/jp_cli/src/cmd/query/tool/prompter.rs b/crates/jp_cli/src/cmd/query/tool/prompter.rs index ab83b61e6..a29ab1727 100644 --- a/crates/jp_cli/src/cmd/query/tool/prompter.rs +++ b/crates/jp_cli/src/cmd/query/tool/prompter.rs @@ -21,12 +21,12 @@ use jp_config::conversation::tool::{RunMode, ToolSource}; use jp_conversation::event::SelectOption; use jp_editor::{EditOutcome, EditorBackend}; use jp_inquire::{InlineOption, ReplyEditMode, ReplyOutcome, prompt::PromptBackend}; -use jp_llm::tool::PermissionInfo; use jp_printer::{Printer, PromptWriter}; use jp_term::{background::DefaultBackground, shade::ShadedWriter}; use jp_tool::AnswerType; use serde_json::Value; +use super::executor::PermissionInfo; use crate::{Error, editor::report_editor_failure}; /// Result of a permission prompt. diff --git a/crates/jp_cli/src/cmd/query/turn_loop.rs b/crates/jp_cli/src/cmd/query/turn_loop.rs index 3442c5937..32aec7082 100644 --- a/crates/jp_cli/src/cmd/query/turn_loop.rs +++ b/crates/jp_cli/src/cmd/query/turn_loop.rs @@ -36,10 +36,8 @@ use jp_llm::{ model::ModelDetails, provider::get_provider, query::{ChatQuery, Truncation}, - tool::Executor, with_idle_timeout, with_output_limit, }; -use jp_mcp::server::InvocationContext; use jp_printer::{ErrChannel, Printer, RegionStyle, StatusRegion}; use jp_tool::ToolDefinition; use jp_workspace::{ConversationLock, ConversationMut}; @@ -59,6 +57,7 @@ use super::{ tool::{ PendingEntry, PendingTools, ToolCallDecision, ToolCallState, ToolCoordinator, ToolPrompter, ToolRenderer, build_execution_plan, + executor::{Executor, Review}, inquiry::{InquiryBackend, InquiryConfig, LlmInquiryBackend}, }, turn::{Action, CommittedEvent, TurnCoordinator, TurnPhase, TurnState}, @@ -188,7 +187,6 @@ pub(super) async fn run_turn_loop( prompt_backend: Arc, mut tool_coordinator: ToolCoordinator, chat_request: ChatRequest, - invocation: InvocationContext, pending_trim: PendingStreamTrim, mut turn_interrupt: TurnInterrupt, ) -> Result<(), Error> { @@ -224,8 +222,6 @@ pub(super) async fn run_turn_loop( Printer::sink().into() }), cfg.style.clone(), - root.to_path_buf(), - invocation, ); // Share the owed-separator flag so visible assistant content rendered by // the coordinator can cancel a blank line owed by a preceding tool result. @@ -1169,18 +1165,30 @@ async fn commit_tool_responses( // permission phase into the corresponding ToolCallRequest events. flush_rendered_arguments(tool, conv); - // Both `result.responses` and `pre_resolved` are already keyed by the + // Both `result.reviews` and `pre_resolved` are already keyed by the // plan index assigned in `build_execution_plan`. Sorting by that // index restores stream order for the persisted responses. - let mut indexed: Vec<(usize, ToolCallResponse)> = result.responses; - indexed.extend(pre_resolved); - indexed.sort_by_key(|(idx, _)| *idx); - let responses: Vec<_> = indexed.into_iter().map(|(_, r)| r).collect(); + // + // A pre-resolved tool never reached an executor, so nothing offered it a + // result to edit. + let mut indexed: Vec<(usize, Review)> = result.reviews; + indexed.extend( + pre_resolved + .into_iter() + .map(|(index, response)| (index, Review::unchanged(response))), + ); + indexed.sort_by_key(|(index, _)| *index); + let reviews: Vec<_> = indexed.into_iter().map(|(_, review)| review).collect(); - let recorded = responses.clone(); + let responses = reviews + .iter() + .map(|review| review.response.clone()) + .collect(); let action = conv.update_events(|stream| turn.handle_tool_responses(stream, responses)); conv.flush()?; - tool.acknowledge_responses(recorded) + // Only now does each call's MCP response reach its caller: the service + // holds every result until the conversation has it on disk. + tool.acknowledge_reviews(reviews) .await .map_err(Error::McpRecording)?; diff --git a/crates/jp_cli/src/cmd/query/turn_loop_tests.rs b/crates/jp_cli/src/cmd/query/turn_loop_tests.rs index 58dc4db80..9b817f89c 100644 --- a/crates/jp_cli/src/cmd/query/turn_loop_tests.rs +++ b/crates/jp_cli/src/cmd/query/turn_loop_tests.rs @@ -51,9 +51,6 @@ use jp_llm::{ model::ModelDetails, provider::mock::MockProvider, query::ChatQuery, - tool::{ - Executor, ExecutorResult, ExecutorSource, MockExecutor, PermissionInfo, TestExecutorSource, - }, }; use jp_mcp::{ Client, @@ -75,7 +72,14 @@ use crate::{ access::approvals::ApprovalStore, cmd::query::{ stream::retry::MAX_CONSECUTIVE_REBUILDS, - tool::{ToolCoordinator, executor::TerminalExecutorSource}, + tool::{ + ToolCoordinator, + executor::{ + Executor, ExecutorResult, ExecutorSource, PermissionInfo, + mock::{MockExecutor, TestExecutorSource}, + }, + mcp_executor::TerminalExecutorSource, + }, }, signals::testing::{detached_router, test_router}, }; @@ -392,7 +396,6 @@ async fn test_interrupt_stop_during_streaming_persists_content() { Arc::new(backend), ToolCoordinator::new(config.conversation.tools.clone(), empty_executor_source()), chat_request.clone(), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -492,7 +495,6 @@ async fn a_completed_block_is_persisted_before_the_turn_ends() { Arc::new(backend), ToolCoordinator::new(config.conversation.tools.clone(), empty_executor_source()), ChatRequest::from("What is 2+2?"), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -576,7 +578,6 @@ async fn a_refusal_takes_back_content_it_had_persisted() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), empty_executor_source()), ChatRequest::from("something declined"), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -663,7 +664,6 @@ async fn test_streaming_interrupt_menu_cancel_escalates() { Arc::new(backend), ToolCoordinator::new(config.conversation.tools.clone(), empty_executor_source()), chat_request.clone(), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -747,7 +747,6 @@ async fn test_normal_completion_persists_content() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), empty_executor_source()), chat_request.clone(), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -833,7 +832,6 @@ async fn premature_stream_end_without_finished_returns_error() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), empty_executor_source()), ChatRequest::from("hi"), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ), @@ -897,7 +895,6 @@ async fn premature_stream_end_exhausts_retry_budget() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), empty_executor_source()), ChatRequest::from("hi"), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ), @@ -977,7 +974,6 @@ async fn output_ceiling_ends_turn_without_re_requesting() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), empty_executor_source()), ChatRequest::from("hi"), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ), @@ -1082,7 +1078,6 @@ async fn orphan_tool_call_is_sanitized_before_provider_request() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), empty_executor_source()), ChatRequest::from("new query"), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -1167,7 +1162,6 @@ async fn test_tool_call_cycle_completes_with_followup() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), empty_executor_source()), chat_request.clone(), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -1465,7 +1459,6 @@ async fn test_tool_interrupt_menu_cancel_escalates() { Arc::new(backend), ToolCoordinator::new(config.conversation.tools.clone(), Box::new(executor_source)), chat_request.clone(), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -1615,7 +1608,6 @@ async fn test_tool_stop_on_interrupt_commits_responses_without_follow_up() { ToolCoordinator::new(config.conversation.tools.clone(), Box::new(executor_source)) .with_interrupt(config.interrupt.tool_call.clone()), chat_request.clone(), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -1759,7 +1751,6 @@ async fn test_interrupt_during_tool_prompt_completes_turn_early() { Arc::new(backend), ToolCoordinator::new(config.conversation.tools.clone(), Box::new(executor_source)), chat_request.clone(), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -1871,7 +1862,6 @@ async fn test_multiple_tool_calls_in_sequence() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), empty_executor_source()), chat_request.clone(), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -1962,7 +1952,6 @@ async fn test_empty_tool_response_continues_cycle() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), empty_executor_source()), chat_request.clone(), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -2108,7 +2097,6 @@ async fn test_tool_restart_on_interrupt() { Arc::new(backend), ToolCoordinator::new(config.conversation.tools.clone(), Box::new(executor_source)), chat_request.clone(), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -2230,7 +2218,6 @@ async fn test_merged_stream_exits_after_tool_response() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), empty_executor_source()), chat_request.clone(), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -2358,7 +2345,6 @@ async fn test_tool_call_with_run_mode_ask_approves() { Arc::new(backend), ToolCoordinator::new(config.conversation.tools.clone(), Box::new(executor_source)), chat_request.clone(), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -2501,7 +2487,6 @@ async fn test_tool_call_with_run_mode_ask_skips() { Arc::new(backend), ToolCoordinator::new(config.conversation.tools.clone(), Box::new(executor_source)), chat_request.clone(), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -2655,7 +2640,6 @@ async fn test_permission_prompt_follows_interactive_not_is_tty() { Arc::new(backend), ToolCoordinator::new(config.conversation.tools.clone(), Box::new(executor_source)), chat_request.clone(), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -2779,7 +2763,6 @@ async fn test_tool_call_with_run_mode_unattended() { Arc::new(backend), ToolCoordinator::new(config.conversation.tools.clone(), Box::new(executor_source)), chat_request.clone(), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -2927,7 +2910,6 @@ async fn test_tool_call_with_run_mode_skip() { Arc::new(backend), ToolCoordinator::new(config.conversation.tools.clone(), Box::new(executor_source)), chat_request.clone(), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -3131,7 +3113,6 @@ async fn test_multiple_tools_with_different_run_modes() { Arc::new(backend), ToolCoordinator::new(config.conversation.tools.clone(), Box::new(executor_source)), chat_request.clone(), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -3280,7 +3261,6 @@ async fn test_tool_call_returns_error() { Arc::new(backend), ToolCoordinator::new(config.conversation.tools.clone(), Box::new(executor_source)), chat_request.clone(), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -3516,7 +3496,6 @@ async fn test_waiting_indicator_shows_during_delay() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), empty_executor_source()), chat_request.clone(), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -3617,7 +3596,6 @@ async fn test_waiting_indicator_survives_keep_alive_and_shows_status() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), empty_executor_source()), chat_request.clone(), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -3732,7 +3710,6 @@ async fn test_waiting_indicator_cleared_before_retry_notice() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), empty_executor_source()), chat_request.clone(), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -3820,7 +3797,6 @@ async fn test_waiting_indicator_not_shown_when_disabled() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), empty_executor_source()), chat_request.clone(), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -3900,7 +3876,6 @@ async fn test_waiting_indicator_not_shown_for_non_tty() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), empty_executor_source()), chat_request.clone(), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -3981,7 +3956,6 @@ async fn test_waiting_indicator_follows_stderr_not_stdout() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), empty_executor_source()), chat_request.clone(), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -4167,7 +4141,6 @@ async fn test_multi_part_tool_call_shows_preparing_spinner() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), empty_executor_source()), chat_request.clone(), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -4255,7 +4228,6 @@ async fn test_turn_start_event_is_emitted() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), empty_executor_source()), chat_request.clone(), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -4319,7 +4291,6 @@ async fn test_turn_start_index_increments_across_turns() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), empty_executor_source()), chat_request.clone(), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -4355,7 +4326,6 @@ async fn test_turn_start_index_increments_across_turns() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), empty_executor_source()), chat_request.clone(), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -4451,7 +4421,6 @@ async fn test_markdown_flushed_before_tool_header() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), empty_executor_source()), chat_request.clone(), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -4638,7 +4607,6 @@ async fn test_parallel_tool_calls_rendered_atomically() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), Box::new(executor_source)), chat_request.clone(), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -4798,7 +4766,6 @@ async fn test_single_tool_call_rendered_with_args() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), Box::new(executor_source)), chat_request.clone(), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -5045,7 +5012,6 @@ async fn a_running_tools_stderr_reaches_the_progress_window() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), Box::new(executor_source)), ChatRequest::from("Build it"), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -5143,7 +5109,6 @@ async fn parallel_tools_label_their_window_rows() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), Box::new(executor_source)), ChatRequest::from("Run both"), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -5266,7 +5231,6 @@ async fn a_tool_result_survives_a_live_window() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), Box::new(executor_source)), ChatRequest::from("Run both"), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -5382,7 +5346,6 @@ async fn a_sink_survives_the_re_spawn_an_answer_triggers() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), Box::new(executor_source)), ChatRequest::from("Ask then work"), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -5517,7 +5480,6 @@ async fn a_tool_can_opt_out_of_the_progress_window() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), Box::new(executor_source)), ChatRequest::from("Run both"), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -5742,7 +5704,6 @@ async fn a_tool_prompt_hides_the_window_and_restores_it() { Arc::clone(&prompts) as Arc, ToolCoordinator::new(config.conversation.tools.clone(), Box::new(executor_source)), ChatRequest::from("Ask me"), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -5925,7 +5886,7 @@ impl Executor for InquiryMockExecutor { }; } } - ExecutorResult::Completed(jp_conversation::event::ToolCallResponse { + ExecutorResult::Completed(ToolCallResponse { id: self.tool_id.clone(), result: Ok(self.output.clone()), }) @@ -6267,7 +6228,6 @@ async fn test_tool_with_single_inquiry() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), Box::new(executor_source)), chat_request, - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -6396,7 +6356,6 @@ async fn test_secret_question_without_tty_fails_tool() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), Box::new(executor_source)), chat_request, - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -6506,7 +6465,6 @@ async fn test_secret_question_with_assistant_target_fails_tool() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), Box::new(executor_source)), chat_request, - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -6609,7 +6567,6 @@ async fn test_secret_prompter_answer_is_redacted() { Arc::new(MockPromptBackend::new().with_password_responses(["s3cret"])), ToolCoordinator::new(config.conversation.tools.clone(), Box::new(executor_source)), chat_request, - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -6716,7 +6673,6 @@ async fn test_secret_static_answer_is_redacted() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), Box::new(executor_source)), chat_request, - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -6824,7 +6780,6 @@ async fn test_static_answer_records_answered_inquiry() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), Box::new(executor_source)), chat_request, - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -6940,7 +6895,6 @@ async fn test_remembered_answer_cache_hit_records_new_inquiry_pair() { prompt_backend, ToolCoordinator::new(config.conversation.tools.clone(), Box::new(executor_source)), chat_request, - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -7063,7 +7017,6 @@ async fn test_tool_with_multiple_inquiries() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), Box::new(executor_source)), chat_request, - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -7216,7 +7169,6 @@ async fn test_parallel_tools_one_with_inquiry() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), Box::new(executor_source)), chat_request, - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -7355,7 +7307,6 @@ async fn test_parallel_tools_both_with_inquiries() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), Box::new(executor_source)), chat_request, - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -7506,7 +7457,6 @@ async fn test_retry_counter_resets_on_successful_event() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), empty_executor_source()), chat_request, - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -7646,7 +7596,6 @@ async fn test_unavailable_tool_before_approved_does_not_panic() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), Box::new(executor_source)), chat_request, - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -7757,7 +7706,6 @@ async fn test_inquiry_failure_marks_tool_as_error() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), Box::new(executor_source)), chat_request, - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -7952,7 +7900,6 @@ async fn test_live_header_uses_configured_model_id_not_provider_returned() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), empty_executor_source()), chat_request, - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -8068,7 +8015,6 @@ async fn reasoning_before_a_tool_call_shades_the_tool_chrome() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), Box::new(executor_source)), ChatRequest::from("use the tool"), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -8193,7 +8139,6 @@ async fn a_tool_that_does_not_join_reasoning_renders_unshaded_live() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), Box::new(executor_source)), ChatRequest::from("use the tool"), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -8325,7 +8270,6 @@ async fn test_rebuild_cap_stops_a_provider_that_keeps_requesting_rebuilds() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), empty_executor_source()), ChatRequest::from("repair this"), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -8417,7 +8361,6 @@ async fn test_refused_rebuild_clears_the_retry_line() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), empty_executor_source()), ChatRequest::from("answer this"), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -8502,7 +8445,6 @@ async fn test_refused_rebuild_persists_streamed_content() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), empty_executor_source()), ChatRequest::from("answer this"), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) @@ -8606,7 +8548,6 @@ async fn http_tool_cycle_persists_inquiry_and_response_before_followup() { Arc::new(MockPromptBackend::new()), ToolCoordinator::new(config.conversation.tools.clone(), Box::new(source)), ChatRequest::from("Run the tool."), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) diff --git a/crates/jp_cli/src/cmd/query_tests.rs b/crates/jp_cli/src/cmd/query_tests.rs index 33f78a899..12d6cb518 100644 --- a/crates/jp_cli/src/cmd/query_tests.rs +++ b/crates/jp_cli/src/cmd/query_tests.rs @@ -21,12 +21,8 @@ use jp_conversation::{ event::{ChatRequest, ChatResponse}, }; use jp_inquire::prompt::MockPromptBackend; -use jp_llm::{ - Provider, - provider::mock::MockProvider, - tool::{ExecutorSource, TestExecutorSource}, -}; -use jp_mcp::{Startup, StderrLine, server::InvocationContext}; +use jp_llm::{Provider, provider::mock::MockProvider}; +use jp_mcp::{Startup, StderrLine}; use jp_printer::{OutputFormat, Printer, SharedBuffer, TerminalCapability}; use jp_storage::{ backend::{ConversationFilter, FsStorageBackend, LoadBackend}, @@ -41,7 +37,10 @@ use relative_path::RelativePathBuf; use serde_json::Value; use tokio::{runtime::Runtime, sync::broadcast}; -use super::*; +use super::{ + tool::executor::{ExecutorSource, mock::TestExecutorSource}, + *, +}; use crate::{ Cli, Globals, KeyValueOrPath, cmd::target::{ConversationTarget, PickerFilter}, @@ -405,7 +404,6 @@ async fn run_mock_turn( Arc::new(MockPromptBackend::new()), tool::ToolCoordinator::new(cfg.conversation.tools.clone(), empty_executor_source()), ChatRequest::from(prompt), - InvocationContext::default(), PendingStreamTrim::default(), router.turn_interrupt(lock.id()), ) diff --git a/crates/jp_cli/src/error.rs b/crates/jp_cli/src/error.rs index c83048eae..26e29e588 100644 --- a/crates/jp_cli/src/error.rs +++ b/crates/jp_cli/src/error.rs @@ -2,11 +2,10 @@ use std::io; use camino::Utf8PathBuf; use jp_conversation::ConversationId; -use jp_llm::tool::ExecutorError; use jp_mcp::server::http::EndpointError; use url::Url; -use crate::cmd; +use crate::{cmd, cmd::query::tool::executor::ExecutorError}; pub(crate) type Result = std::result::Result; diff --git a/crates/jp_cli/src/render/tool.rs b/crates/jp_cli/src/render/tool.rs index 8eec5481d..fad815079 100644 --- a/crates/jp_cli/src/render/tool.rs +++ b/crates/jp_cli/src/render/tool.rs @@ -11,22 +11,16 @@ use std::{ time::Duration, }; -use camino::{Utf8Path, Utf8PathBuf}; use crossterm::style::Stylize as _; use jp_config::{ - conversation::tool::{ - CommandConfig, - style::{InlineResults, LinkStyle, ParametersStyle, TruncateLines}, - }, + conversation::tool::style::{InlineResults, LinkStyle, ParametersStyle, TruncateLines}, style::{StyleConfig, stderr_rows::StderrRows}, }; use jp_conversation::event::ToolCallResponse; -use jp_mcp::server::{CommandResult, InvocationContext, run_tool_command}; use jp_md::format::Formatter; use jp_printer::{ErrChannel, LineSink, OutputLines, RegionStyle, StatusRegion}; use jp_term::{background::DefaultBackground, osc::hyperlink, shade::ShadedWriter}; use serde_json::{Map, Value}; -use tokio_util::sync::CancellationToken; use tracing::warn; /// Map the `stderr_rows` config key onto the printer's window budget. @@ -87,11 +81,6 @@ pub enum RenderOutcome { pub struct ToolRenderer { channel: ErrChannel, config: StyleConfig, - root: Utf8PathBuf, - - /// Workspace and conversation identity, forwarded to custom argument - /// formatter commands. - invocation: InvocationContext, /// Markdown formatter used for syntax highlighting code blocks in tool /// results. @@ -142,12 +131,7 @@ pub struct ToolRenderer { } impl ToolRenderer { - pub fn new( - channel: ErrChannel, - config: StyleConfig, - root: Utf8PathBuf, - invocation: InvocationContext, - ) -> Self { + pub fn new(channel: ErrChannel, config: StyleConfig) -> Self { let formatter = Formatter::new().theme(if channel.pretty_printing_enabled() { config.markdown.theme.as_deref() } else { @@ -157,8 +141,6 @@ impl ToolRenderer { Self { channel, config, - root, - invocation, formatter, pending: Vec::new(), preparing: StatusRegion::inert(), @@ -269,62 +251,34 @@ impl ToolRenderer { }); } - /// Renders a tool call with all styles, printing header and arguments - /// atomically. - /// - /// For non-Custom styles: prints the header with inline-formatted arguments - /// in a single write. + /// Renders an approved tool call, printing header and arguments atomically. /// - /// For Custom style: runs the custom formatter command first, then prints - /// the header followed by the formatted output. - /// If the custom formatter fails, nothing is printed and - /// [`RenderOutcome::Suppressed`] is returned. + /// Prints the header with inline-formatted arguments in a single write, and + /// returns `Rendered { content: None }`: the built-in styles print their + /// arguments inline rather than producing content a caller persists. /// - /// On success, returns `Rendered { content }` where `content` is the - /// custom-formatted output (if any) so the caller can persist it for - /// replay. + /// A `Custom` style is rendered by [`render_custom_result`] instead, from + /// output the execution service produced. /// - /// `name` is what the assistant called and what the header shows. - /// `invoked_name` is what the tool's implementation is called, which a - /// `source` override can make different, and is what a custom formatter - /// receives. - pub async fn render_approved( + /// [`render_custom_result`]: Self::render_custom_result + pub fn render_approved( &self, name: &str, - invoked_name: &str, arguments: &Map, style: &ParametersStyle, ) -> RenderOutcome { - if let ParametersStyle::Custom(cmd_config) = style { - let cmd = cmd_config.clone().command(); - self.render_custom_tool_call(name, invoked_name, arguments, cmd) - .await - } else { - self.render_tool_call(name, arguments, style); - RenderOutcome::Rendered { content: None } - } - } - - /// Renders a Custom-style tool call: header + custom formatted output. - /// - /// Runs the custom formatter command first. - /// If it succeeds, prints the "Calling tool X" header followed by the - /// formatted output. - /// If it fails, nothing is printed — the tool call is suppressed from the - /// display. - async fn render_custom_tool_call( - &self, - name: &str, - invoked_name: &str, - arguments: &Map, - cmd: CommandConfig, - ) -> RenderOutcome { - let result = - format_args_custom(invoked_name, arguments, cmd, &self.root, &self.invocation).await; - self.render_custom_result(name, result) + self.render_tool_call(name, arguments, style); + RenderOutcome::Rendered { content: None } } /// Render custom arguments already formatted by the execution service. + /// + /// Prints the "Calling tool X" header followed by the formatted output. + /// A formatter that failed prints nothing and returns + /// [`RenderOutcome::Suppressed`], so a broken formatter does not show a + /// half-rendered call. + /// + /// The returned content is what the caller persists for replay. pub(crate) fn render_custom_result( &self, name: &str, @@ -818,108 +772,6 @@ fn format_args_json(arguments: Map) -> String { format!(" with arguments:\n\n```json\n{pretty}\n```") } -/// Runs a custom arguments formatter command and returns the content. -/// -/// `tool_name` is the name the tool is invoked under, which is the name its own -/// implementation answers to rather than the key the assistant called. -async fn format_args_custom( - tool_name: &str, - arguments: &Map, - cmd: CommandConfig, - root: &Utf8Path, - invocation: &InvocationContext, -) -> Result { - let ctx = serde_json::json!({ - "tool": { - "name": tool_name, - "arguments": arguments, - }, - "context": { - "action": jp_tool::Action::FormatArguments, - "root": root, - "workspace_id": &invocation.workspace_id, - "conversation_id": &invocation.conversation_id, - }, - }); - - let result = run_tool_command(cmd.clone(), ctx, root, CancellationToken::new(), None) - .await - .map_err(|e| { - warn!( - command = %cmd, - error = %e, - "Custom parameters formatter failed" - ); - format!("Custom parameters formatter '{cmd}' failed: {e}") - })?; - - match result { - CommandResult::Success(content) => Ok(content.trim().to_owned()), - CommandResult::TransientError { message, trace } => { - let detail = CommandResult::format_error(&message, &trace); - warn!( - command = %cmd, - error = %detail, - "Custom parameters formatter returned error" - ); - Err(detail) - } - CommandResult::FatalError { raw, .. } => { - warn!( - command = %cmd, - "Custom parameters formatter returned fatal error" - ); - Err(raw) - } - CommandResult::NeedsInput(_) => { - warn!( - command = %cmd, - "Custom parameters formatter returned NeedsInput" - ); - Err(format!( - "Custom parameters formatter '{cmd}' returned unexpected NeedsInput" - )) - } - CommandResult::Cancelled => Ok(String::new()), - CommandResult::InvalidInquiry { question_id } => { - warn!( - command = %cmd, - question_id = %question_id, - "Custom parameters formatter returned an invalid inquiry" - ); - Err(format!( - "Custom parameters formatter '{cmd}' produced an invalid inquiry (question id \ - '{question_id}')" - )) - } - CommandResult::MalformedInquiry { detail } => { - warn!( - command = %cmd, - %detail, - "Custom parameters formatter returned a malformed inquiry" - ); - Err(format!( - "Custom parameters formatter '{cmd}' produced a malformed inquiry: {detail}" - )) - } - CommandResult::RawOutput { - stdout, - success: true, - .. - } => Ok(stdout.trim().to_owned()), - CommandResult::RawOutput { stderr, .. } => { - warn!( - command = %cmd, - error = %stderr, - "Custom parameters formatter failed" - ); - Err(format!( - "Custom parameters formatter '{cmd}' failed: {stderr}" - )) - } - } -} - #[cfg(test)] #[path = "tool_tests.rs"] mod tests; diff --git a/crates/jp_cli/src/render/tool_tests.rs b/crates/jp_cli/src/render/tool_tests.rs index a080c4135..4a75bcede 100644 --- a/crates/jp_cli/src/render/tool_tests.rs +++ b/crates/jp_cli/src/render/tool_tests.rs @@ -1,6 +1,5 @@ use std::sync::Arc; -use camino_tempfile::Utf8TempDir; use jp_config::{ AppConfig, conversation::tool::{CommandConfigOrString, style::ParametersStyle}, @@ -88,12 +87,7 @@ fn create_renderer() -> (ToolRenderer, SharedBuffer, SharedBuffer) { let (printer, out, err) = Printer::memory(OutputFormat::TextPretty); let mut config = AppConfig::new_test().style; config.tool_call.show = true; - let renderer = ToolRenderer::new( - ErrChannel::new(Arc::new(printer)), - config, - "/tmp".into(), - jp_mcp::server::InvocationContext::default(), - ); + let renderer = ToolRenderer::new(ErrChannel::new(Arc::new(printer)), config); (renderer, err, out) } @@ -113,12 +107,7 @@ fn create_renderer_with_show(show: bool) -> (ToolRenderer, SharedBuffer) { config.tool_call.progress.stderr_rows = StderrRows::Fixed(RowCount { rows: 2 }); let printer = printer.with_terminal(TerminalCapability::interactive(Some(80)).with_rows(Some(24))); - let renderer = ToolRenderer::new( - ErrChannel::new(Arc::new(printer)), - config, - "/tmp".into(), - jp_mcp::server::InvocationContext::default(), - ); + let renderer = ToolRenderer::new(ErrChannel::new(Arc::new(printer)), config); (renderer, err) } @@ -183,25 +172,13 @@ fn test_render_tool_call_custom_does_not_run_command() { insta::assert_snapshot!(output); } -#[tokio::test] -async fn test_render_custom_arguments_after_approval() { - let root = Utf8TempDir::new().unwrap(); +#[test] +fn test_render_custom_result_after_approval() { let (printer, _out, err) = Printer::memory(OutputFormat::TextPretty); let config = AppConfig::new_test().style; - let renderer = ToolRenderer::new( - ErrChannel::new(Arc::new(printer)), - config, - root.path().to_owned(), - jp_mcp::server::InvocationContext::default(), - ); + let renderer = ToolRenderer::new(ErrChannel::new(Arc::new(printer)), config); - let mut args = Map::new(); - args.insert("host".into(), Value::String("myhost".into())); - let style = ParametersStyle::Custom(CommandConfigOrString::String("echo custom-output".into())); - - let outcome = renderer - .render_approved("ssh_run", "ssh_run", &args, &style) - .await; + let outcome = renderer.render_custom_result("ssh_run", Ok("custom-output".into())); assert!(matches!(outcome, RenderOutcome::Rendered { content: Some(_) @@ -216,6 +193,24 @@ async fn test_render_custom_arguments_after_approval() { assert_eq!(output, "Calling tool ssh_run\n\ncustom-output\n"); } +#[test] +fn test_render_custom_result_suppresses_a_failed_formatter() { + let (printer, _out, err) = Printer::memory(OutputFormat::TextPretty); + let renderer = ToolRenderer::new( + ErrChannel::new(Arc::new(printer)), + AppConfig::new_test().style, + ); + + let outcome = renderer.render_custom_result("ssh_run", Err("formatter exploded".into())); + + assert!( + matches!(outcome, RenderOutcome::Suppressed { ref error } if error == "formatter exploded") + ); + renderer.channel.flush(); + // A broken formatter must not leave a header with nothing under it. + assert_eq!(strip_ansi(&err.lock()), ""); +} + #[test] fn test_consecutive_plain_headers_are_grouped() { // Plain (non-custom) headers carry no owed separator, so a batch of tool @@ -405,12 +400,7 @@ fn progress_window_is_off_without_print_stderr() { let (printer, _out, _err) = Printer::memory(OutputFormat::TextPretty); let mut config = AppConfig::new_test().style; config.tool_call.progress.stderr_rows = StderrRows::Off; - let renderer = ToolRenderer::new( - ErrChannel::new(Arc::new(printer)), - config, - "/tmp".into(), - jp_mcp::server::InvocationContext::default(), - ); + let renderer = ToolRenderer::new(ErrChannel::new(Arc::new(printer)), config); assert!(renderer.progress_source("cargo_test").is_none()); } @@ -526,12 +516,7 @@ fn test_completing_one_pending_tool_does_not_collide_with_header() { // Disable the animated suffix so `register` doesn't spawn a timer task // (this is a sync test with no tokio runtime). config.tool_call.preparing.show = false; - let mut renderer = ToolRenderer::new( - ErrChannel::new(Arc::new(printer)), - config, - "/tmp".into(), - jp_mcp::server::InvocationContext::default(), - ); + let mut renderer = ToolRenderer::new(ErrChannel::new(Arc::new(printer)), config); renderer.register("id1", "fs_read_file"); renderer.register("id2", "fs_read_file"); @@ -602,12 +587,7 @@ fn test_preparing_row_carries_the_elapsed_time() { #[test] fn test_show_false_suppresses_preparing_output() { let config = AppConfig::new_test().style; - let mut renderer = ToolRenderer::new( - ErrChannel::new(Arc::new(Printer::sink())), - config, - "/tmp".into(), - jp_mcp::server::InvocationContext::default(), - ); + let mut renderer = ToolRenderer::new(ErrChannel::new(Arc::new(Printer::sink())), config); renderer.register("id1", "tool_a"); renderer.complete("id1"); @@ -618,12 +598,7 @@ fn test_show_false_suppresses_preparing_output() { #[test] fn test_tool_call_show_false_suppresses_output() { let config = AppConfig::new_test().style; - let renderer = ToolRenderer::new( - ErrChannel::new(Arc::new(Printer::sink())), - config, - "/tmp".into(), - jp_mcp::server::InvocationContext::default(), - ); + let renderer = ToolRenderer::new(ErrChannel::new(Arc::new(Printer::sink())), config); let mut args = Map::new(); args.insert("key".into(), Value::String("value".into())); @@ -667,48 +642,6 @@ fn test_format_args_custom_returns_empty() { assert_eq!(result, ""); } -#[tokio::test] -async fn test_format_custom_content_returns_raw_content() { - let root = Utf8TempDir::new().unwrap(); - let mut args = Map::new(); - args.insert("key".into(), Value::String("value".into())); - let cmd = CommandConfigOrString::String("echo hello-world".into()).command(); - let result = format_args_custom( - "my_tool", - &args, - cmd, - root.path(), - &jp_mcp::server::InvocationContext::default(), - ) - .await - .unwrap(); - assert_eq!(result, "hello-world"); -} - -/// Regression: the `format_arguments` path must surface the invocation's -/// workspace and conversation IDs to a custom formatter command via -/// `context.workspace_id` and `context.conversation_id`. -/// A non-empty `InvocationContext` pins the wiring — the other tests pass the -/// empty default, which would still pass if the fields were dropped or wired to -/// empty strings. -#[tokio::test] -async fn test_format_args_custom_exposes_invocation_ids() { - let root = Utf8TempDir::new().unwrap(); - let args = Map::new(); - let cmd = CommandConfigOrString::String( - "echo {{context.workspace_id}}/{{context.conversation_id}}".into(), - ) - .command(); - let invocation = jp_mcp::server::InvocationContext { - workspace_id: "ws-abc".into(), - conversation_id: "conv-xyz".into(), - }; - let result = format_args_custom("my_tool", &args, cmd, root.path(), &invocation) - .await - .unwrap(); - assert_eq!(result, "ws-abc/conv-xyz"); -} - #[test] fn test_format_args_hides_empty_object_value() { let mut args = Map::new(); diff --git a/crates/jp_cli/src/render/turn.rs b/crates/jp_cli/src/render/turn.rs index 3a9d4d898..92f3cf7f8 100644 --- a/crates/jp_cli/src/render/turn.rs +++ b/crates/jp_cli/src/render/turn.rs @@ -8,7 +8,6 @@ use std::{collections::HashMap, sync::Arc}; -use camino::Utf8PathBuf; use chrono::Utc; use jp_config::{ PartialAppConfig, @@ -23,7 +22,6 @@ use jp_conversation::{ EventKind, stream::{TurnOrigin, turn_iter::Turn}, }; -use jp_mcp::server::InvocationContext; use jp_printer::{ErrChannel, Printer}; use tracing::warn; @@ -78,9 +76,7 @@ impl StyleOverlay { pub struct TurnRenderer { // Stable params for rebuilding sub-renderers. printer: Arc, - root: Utf8PathBuf, source: ConfigSource, - invocation: InvocationContext, view: TurnView, tool: ToolRenderer, @@ -113,9 +109,7 @@ impl TurnRenderer { mut tools_config: ToolsConfig, assistant_name: Option, model_id: Option, - root: Utf8PathBuf, source: ConfigSource, - invocation: InvocationContext, style_overlay: Option, ) -> Self { if let Some(overlay) = &style_overlay { @@ -130,18 +124,11 @@ impl TurnRenderer { RenderFlow::Replay, ); let tool_chrome_shown = style.tool_call.show; - let tool = ToolRenderer::new( - ErrChannel::new(printer.clone()), - style, - root.clone(), - invocation.clone(), - ); + let tool = ToolRenderer::new(ErrChannel::new(printer.clone()), style); view.set_tool_separator(tool.separator_flag()); Self { printer, - root, source, - invocation, view, tool, tools_config, @@ -290,12 +277,7 @@ impl TurnRenderer { assistant_name, model_id, ); - self.tool = ToolRenderer::new( - ErrChannel::new(self.printer.clone()), - style, - self.root.clone(), - self.invocation.clone(), - ); + self.tool = ToolRenderer::new(ErrChannel::new(self.printer.clone()), style); self.view.set_tool_separator(self.tool.separator_flag()); self.tools_config = tools_config; } diff --git a/crates/jp_llm/Cargo.toml b/crates/jp_llm/Cargo.toml index d14065252..34958de21 100644 --- a/crates/jp_llm/Cargo.toml +++ b/crates/jp_llm/Cargo.toml @@ -17,7 +17,6 @@ jp_attachment = { workspace = true } jp_config = { workspace = true } jp_conversation = { workspace = true } jp_credentials = { workspace = true } -jp_mcp = { workspace = true, features = ["server"] } jp_openrouter = { workspace = true } jp_tool = { workspace = true } @@ -42,7 +41,6 @@ serde_json = { workspace = true, features = ["preserve_order"] } sha2 = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true } -tokio-util = { workspace = true } tracing = { workspace = true } url = { workspace = true } uuid = { workspace = true, features = ["v5"] } diff --git a/crates/jp_llm/src/lib.rs b/crates/jp_llm/src/lib.rs index a629d9df4..514f31f18 100644 --- a/crates/jp_llm/src/lib.rs +++ b/crates/jp_llm/src/lib.rs @@ -8,7 +8,6 @@ pub mod query; pub mod retry; mod stream; pub mod title; -pub mod tool; pub mod window; #[cfg(test)] diff --git a/crates/jp_llm/src/tool.rs b/crates/jp_llm/src/tool.rs deleted file mode 100644 index 3b11a162a..000000000 --- a/crates/jp_llm/src/tool.rs +++ /dev/null @@ -1,399 +0,0 @@ -//! The seam a turn loop runs one tool call through. -//! -//! [`Executor`] is the Host-facing view of a tool call. -//! Preparation and approval precede execution release. -//! An input request returns control to the Host; supplying an answer advances -//! the same logical call. -//! [`ExecutorSource`] builds one per tool call, so a test can supply -//! [`MockExecutor`] where production supplies a real one. -//! -//! The execution itself lives in [`jp_mcp::server`]. - -use std::sync::Mutex; - -use async_trait::async_trait; -use camino::Utf8Path; -use futures::future::BoxFuture; -use indexmap::IndexMap; -use jp_config::conversation::tool::{RunMode, ToolConfigWithDefaults, ToolSource}; -use jp_conversation::event::{InquirySource, ToolCallRequest, ToolCallResponse}; -use jp_mcp::{ - Client, - server::{StderrSink, service::FormatterError}, -}; -use jp_tool::{Question, ToolDefinition}; -use serde_json::{Map, Value}; -use tokio_util::sync::CancellationToken; - -#[path = "tool_error.rs"] -mod error; -pub use error::ExecutorError; - -/// The MCP Host's view of a logical tool call. -/// -/// Preparation and approval precede release. -/// Input and completed results return control to the Host for inquiry routing, -/// result review, and recording. -#[async_trait] -pub trait Executor: Send + Sync { - /// Prepare an invocation, or return a response resolved without execution. - async fn prepare( - &mut self, - _render_arguments: bool, - ) -> Result, ExecutorError> { - Ok(None) - } - - /// Apply Host approval and wait until the invocation is ready for release. - async fn approve(&mut self) -> Result<(), ExecutorError> { - Ok(()) - } - - /// Custom argument rendering provided by the execution service. - fn formatted_arguments(&self) -> Option<&Result> { - None - } - - /// Whether custom formatting is owned by the execution service. - fn formats_arguments(&self) -> bool { - false - } - - /// Returns the tool call ID. - fn tool_id(&self) -> &str; - - /// Returns the tool name. - fn tool_name(&self) -> &str; - - /// Returns the tool call arguments. - /// - /// This is separate from [`permission_info()`] because arguments are always - /// available, while permission info is only present for tools that require - /// a permission prompt. - /// - /// [`permission_info()`]: Self::permission_info - fn arguments(&self) -> &Map; - - /// Returns information needed for permission prompting. - /// - /// Returns `None` if the tool doesn't need a permission prompt (e.g., - /// `RunMode::Unattended` or `RunMode::Skip`). - fn permission_info(&self) -> Option; - - /// Updates the arguments to use for execution. - /// - /// This is called after permission prompting if the user edited the - /// arguments (via `RunMode::Edit`). - /// The new arguments replace the original arguments from the tool call - /// request. - fn set_arguments(&mut self, args: Value); - - /// Advance the call to its next input request or result. - /// - /// An MCP-backed executor releases prepared work or answers the pending - /// inquiry on its existing MCP call. - /// The server re-executes a tool that returned `NeedsInput`; the executor - /// does not submit another MCP call. - /// The result remains subject to Host review and recording. - /// - /// The executor doesn't know how questions should be answered - it just - /// reports that input is needed. - /// The coordinator looks up the tool configuration to determine whether to - /// prompt the user or ask the LLM. - /// - /// # Arguments - /// - /// - `answers` - Accumulated answers from previous `NeedsInput` responses - /// - `mcp_client` - MCP client for remote tool execution - /// - `root` - Project root directory - /// - `cancellation_token` - Token to cancel execution - /// - `stderr` - Receives the tool's stderr lines as they arrive, for a - /// caller showing progress while it runs. - /// `None` when nothing is watching; the lines still reach tracing and the - /// accumulated buffer either way. - async fn execute( - &self, - answers: &IndexMap, - mcp_client: &Client, - root: &Utf8Path, - cancellation_token: CancellationToken, - stderr: Option, - ) -> ExecutorResult; -} - -/// Creates Host-facing tool calls and acknowledges their recorded responses. -pub trait ExecutorSource: Send + Sync { - /// Release a final delivery barrier after the response has been recorded. - fn acknowledge(&self, _response: ToolCallResponse) -> BoxFuture<'_, Result<(), ExecutorError>> { - Box::pin(async { Ok(()) }) - } - - /// Creates an executor for the given tool call request. - /// - /// Returns `None` if the tool cannot be resolved (e.g. missing from the - /// definitions). - fn create( - &self, - request: ToolCallRequest, - config: ToolConfigWithDefaults, - ) -> Option>; -} - -/// Result of a tool execution attempt. -/// -/// Tools may need multiple rounds of execution if they require additional -/// input. -/// This enum allows the executor to return control to the coordinator, which -/// decides how to handle the `NeedsInput` case by looking up the question -/// configuration. -#[derive(Debug)] -#[allow(clippy::large_enum_variant)] // NeedsInput variant is larger but rarely used -pub enum ExecutorResult { - /// Tool completed (success or error). - Completed(ToolCallResponse), - - /// Tool needs additional input before it can continue. - /// - /// The executor doesn't know who should answer - it just reports that input - /// is needed. - /// The coordinator looks up the question configuration to determine the - /// target: - /// - /// - `User`: Prompt the user interactively, then restart the tool - /// - `Assistant`: Format a response asking the LLM to re-run with answers - NeedsInput { - /// Tool call ID. - tool_id: String, - - /// Tool name (for persisting answers). - tool_name: String, - - /// The question that needs to be answered. - question: Question, - - /// Resolved provenance for the persisted `InquiryRequest`. - source: InquirySource, - - /// Accumulated answers so far (for retry). - accumulated_answers: IndexMap, - }, -} - -/// A mock executor for testing that returns pre-configured results. -/// -/// This executor doesn't execute any real commands - it simply returns whatever -/// result is configured, making it ideal for testing tool coordination flows -/// without side effects. -/// -/// # Example -/// -/// ```ignore -/// let executor = MockExecutor::completed("call_1", "my_tool", "success output"); -/// let result = executor.execute(&answers, &client, &root, token).await; -/// assert!(result.is_completed()); -/// ``` -pub struct MockExecutor { - tool_id: String, - tool_name: String, - arguments: Map, - permission_info: Option, - result: Mutex>, -} - -impl MockExecutor { - /// Creates a mock executor that returns a successful completion. - #[must_use] - pub fn completed(tool_id: &str, tool_name: &str, output: &str) -> Self { - Self { - tool_id: tool_id.to_string(), - tool_name: tool_name.to_string(), - arguments: Map::new(), - permission_info: None, - result: Mutex::new(Some(ExecutorResult::Completed(ToolCallResponse { - id: tool_id.to_string(), - result: Ok(output.to_string()), - }))), - } - } - - /// Creates a mock executor that returns an error. - #[must_use] - pub fn error(tool_id: &str, tool_name: &str, error: &str) -> Self { - Self { - tool_id: tool_id.to_string(), - tool_name: tool_name.to_string(), - arguments: Map::new(), - permission_info: None, - result: Mutex::new(Some(ExecutorResult::Completed(ToolCallResponse { - id: tool_id.to_string(), - result: Err(error.to_string()), - }))), - } - } - - /// Sets the arguments for this executor. - #[must_use] - pub fn with_arguments(mut self, args: Map) -> Self { - self.arguments = args; - self - } - - /// Sets the permission info for this executor. - /// - /// If set, the executor will require permission prompting based on the - /// configured `RunMode`. - #[must_use] - pub fn with_permission_info(mut self, info: PermissionInfo) -> Self { - self.permission_info = Some(info); - self - } - - /// Sets a custom result for this executor. - #[must_use] - pub fn with_result(mut self, result: ExecutorResult) -> Self { - self.result = Mutex::new(Some(result)); - self - } -} - -#[async_trait] -impl Executor for MockExecutor { - fn tool_id(&self) -> &str { - &self.tool_id - } - - fn tool_name(&self) -> &str { - &self.tool_name - } - - fn arguments(&self) -> &Map { - &self.arguments - } - - fn permission_info(&self) -> Option { - self.permission_info.clone() - } - - fn set_arguments(&mut self, _args: Value) { - // No-op for mock executor - arguments don't affect the pre-configured - // result - } - - async fn execute( - &self, - _answers: &IndexMap, - _mcp_client: &Client, - _root: &Utf8Path, - _cancellation_token: CancellationToken, - _stderr: Option, - ) -> ExecutorResult { - self.result.lock().unwrap().take().unwrap_or_else(|| { - ExecutorResult::Completed(ToolCallResponse { - id: self.tool_id.clone(), - result: Err("MockExecutor: result already consumed".to_string()), - }) - }) - } -} - -/// An executor source for testing that returns pre-registered mock executors. -/// -/// This allows tests to inject mock executors for specific tool names without -/// executing any real shell commands. -/// -/// # Example -/// -/// ```ignore -/// let source = TestExecutorSource::new() -/// .with_executor("my_tool", |req| { -/// Box::new(MockExecutor::completed(&req.id, &req.name, "mock output")) -/// }); -/// -/// let coordinator = ToolCoordinator::new(tools_config, Arc::new(source)); -/// ``` -pub struct TestExecutorSource { - #[allow(clippy::type_complexity)] - factories: std::collections::HashMap< - String, - Box Box + Send + Sync>, - >, -} - -impl TestExecutorSource { - /// Creates a new empty test executor source. - #[must_use] - pub fn new() -> Self { - Self { - factories: std::collections::HashMap::new(), - } - } - - /// Registers a factory function for a tool name. - /// - /// When `create()` is called for this tool name, the factory will be - /// invoked to create the executor. - #[must_use] - pub fn with_executor(mut self, tool_name: &str, factory: F) -> Self - where - F: Fn(ToolCallRequest) -> Box + Send + Sync + 'static, - { - self.factories - .insert(tool_name.to_string(), Box::new(factory)); - self - } - - /// Returns stub [`ToolDefinition`]s for all registered tool names. - /// - /// Useful for passing to `run_turn_loop` so the availability check accepts - /// the tools this source can handle. - #[must_use] - pub fn tool_definitions(&self) -> Vec { - self.factories - .keys() - .map(|name| ToolDefinition { - name: name.clone(), - docs: jp_tool::ToolDocs::default(), - parameters: serde_json::json!({ "type": "object", "properties": {} }), - }) - .collect() - } -} - -impl Default for TestExecutorSource { - fn default() -> Self { - Self::new() - } -} - -impl ExecutorSource for TestExecutorSource { - fn create( - &self, - request: ToolCallRequest, - _config: ToolConfigWithDefaults, - ) -> Option> { - let factory = self.factories.get(&request.name)?; - Some(factory(request)) - } -} - -/// Information needed to prompt for tool execution permission. -/// -/// This struct contains all the data the `ToolPrompter` needs to show a -/// permission prompt to the user. -#[derive(Debug, Clone)] -pub struct PermissionInfo { - /// The tool call ID. - pub tool_id: String, - - /// The tool name. - pub tool_name: String, - - /// The tool source (builtin, local, MCP). - pub tool_source: ToolSource, - - /// The configured run mode. - pub run_mode: RunMode, - - /// The arguments to pass to the tool. - pub arguments: Value, -} diff --git a/crates/jp_llm/src/tool_error.rs b/crates/jp_llm/src/tool_error.rs deleted file mode 100644 index 3bff55cd9..000000000 --- a/crates/jp_llm/src/tool_error.rs +++ /dev/null @@ -1,74 +0,0 @@ -//! Failures while the MCP Host advances a logical tool call. - -use std::error::Error as StdError; - -use serde_json::Error as JsonError; -use tokio::task::JoinError; - -/// A tool-call adapter failed before completing its Host protocol. -#[derive(Debug, thiserror::Error)] -pub enum ExecutorError { - /// Execution was requested before submitting a call. - #[error("MCP call has not started")] - NotStarted, - /// A call was submitted more than once. - #[error("MCP call was prepared twice")] - AlreadyPrepared, - /// The service lost its Host interaction channel. - #[error("MCP Host interaction channel closed")] - HostDisconnected, - /// Result metadata could not be decoded into the shared result contract. - #[error("Invalid tool result: {0}")] - MalformedResult(#[source] JsonError), - /// The transport task terminated unexpectedly. - #[error(transparent)] - Task(#[from] JoinError), - /// An MCP request failed. - #[error("{0}")] - Transport(#[source] Box), - /// Approval is not the next operation for this call. - #[error("MCP call is not awaiting approval")] - NotAwaitingApproval, - /// A prepared call stopped before the Host replied. - #[error("MCP approval expired")] - ApprovalExpired, - /// The call stopped before execution was released. - #[error("MCP release expired")] - ReleaseExpired, - /// The call stopped before an input answer was supplied. - #[error("MCP inquiry expired")] - InquiryExpired, - /// No answer exists for the outstanding question. - #[error("Missing answer to pending MCP inquiry")] - MissingAnswer, - /// The service rejected execution with a tool diagnostic. - #[error("{message}")] - Rejected { message: String }, - /// The call was cancelled by the Host. - #[error("Tool execution cancelled.")] - Cancelled, - /// The service produced an interaction outside the recording protocol. - #[error("Unexpected MCP interaction during recording")] - UnexpectedRecording, - /// The service produced an interaction outside the preparation protocol. - #[error("Unexpected MCP preparation interaction")] - UnexpectedPreparation, - /// Approval did not reach a release barrier. - #[error("MCP call did not reach the release barrier")] - MissingRelease, - /// The service produced an interaction outside the execution protocol. - #[error("Unexpected MCP execution interaction")] - UnexpectedExecution, - /// The result approved by the service differs from recorded content. - #[error("MCP result differs from the recorded response")] - RecordingMismatch, - /// The delivered response differs from recorded content. - #[error("MCP response differs from the recorded response")] - DeliveryMismatch, - /// The legacy inquiry interface accepts only textual choices. - #[error("Non-string inquiry choice")] - NonStringChoice, - /// The legacy inquiry interface cannot present this schema. - #[error("Unsupported tool inquiry schema")] - UnsupportedInquirySchema, -} diff --git a/crates/jp_mcp/Cargo.toml b/crates/jp_mcp/Cargo.toml index 15673c177..500368896 100644 --- a/crates/jp_mcp/Cargo.toml +++ b/crates/jp_mcp/Cargo.toml @@ -71,6 +71,9 @@ which = { workspace = true } camino-tempfile = { workspace = true } assert_matches = { workspace = true } jp_test = { workspace = true } +# The conformance client speaks JSON-RPC and SSE over plain HTTP by hand, on +# purpose: it must not share a transport with the endpoint it is checking. +reqwest = { workspace = true, features = ["json"] } test-log = { workspace = true } tokio = { workspace = true, features = ["test-util"] } diff --git a/crates/jp_mcp/src/client_protocol_tests.rs b/crates/jp_mcp/src/client_protocol_tests.rs index 79c6e63f9..48de205a6 100644 --- a/crates/jp_mcp/src/client_protocol_tests.rs +++ b/crates/jp_mcp/src/client_protocol_tests.rs @@ -204,51 +204,158 @@ impl ServerHandler for NativeUpstream { } #[tokio::test] +#[expect( + clippy::too_many_lines, + reason = "One upstream call read end to end, from its wire result to the caller's" +)] async fn native_upstream_result_survives_host_projection_and_http_delivery() { timeout(Duration::from_secs(10), async { let count = Arc::new(AtomicUsize::new(0)); // Use the stdio codec without starting an extra fixture executable. let (client_transport, server_transport) = duplex(8192); let handler = NativeUpstream(count.clone()); - let server = tokio::spawn(async move {handler.serve(server_transport).await.unwrap()}); + let server = tokio::spawn(async move { handler.serve(server_transport).await.unwrap() }); let running = ().serve(client_transport).await.unwrap(); let server = server.await.unwrap(); - let upstream = Client::new(IndexMap::from_iter([("upstream".into(), McpProviderConfig::Stdio(StdioConfig { - command:"unused-fixture".into(), arguments:vec![], variables:vec![], checksum:None, optional:false, startup_timeout_secs:60, - }))])); - upstream.services.write().await.insert(McpServerId::new("upstream"), running); + + let upstream = Client::new(IndexMap::from_iter([( + "upstream".into(), + McpProviderConfig::Stdio(StdioConfig { + command: "unused-fixture".into(), + arguments: vec![], + variables: vec![], + checksum: None, + optional: false, + startup_timeout_secs: 60, + }), + )])); + upstream + .services + .write() + .await + .insert(McpServerId::new("upstream"), running); + let mut cfg = AppConfig::new_test(); - let partial: PartialToolConfig = serde_json::from_value(json!({"source":"mcp.upstream.native","run":"unattended","result":"ask"})).unwrap(); - cfg.conversation.tools.insert("alias".into(), ToolConfig::from_partial(partial, vec![]).unwrap()); - let definitions = tool_definitions(cfg.conversation.tools.iter(), &upstream, None).await.unwrap(); - let configured = definitions.into_iter().map(|definition| ConfiguredTool {config:cfg.conversation.tools.get(&definition.name).unwrap(), definition, access:Ok(None), metadata:Map::new()}).collect(); - let (service, mut host) = Service::new(configured, upstream, BuiltinExecutors::new(), "/work".into(), InvocationContext::default()).unwrap(); + let partial: PartialToolConfig = serde_json::from_value(json!({ + "source": "mcp.upstream.native", + "run": "unattended", + "result": "ask", + })) + .unwrap(); + cfg.conversation.tools.insert( + "alias".into(), + ToolConfig::from_partial(partial, vec![]).unwrap(), + ); + let definitions = tool_definitions(cfg.conversation.tools.iter(), &upstream, None) + .await + .unwrap(); + let configured = definitions + .into_iter() + .map(|definition| ConfiguredTool { + config: cfg.conversation.tools.get(&definition.name).unwrap(), + definition, + access: Ok(None), + metadata: Map::new(), + }) + .collect(); + let (service, mut host) = Service::new( + configured, + upstream, + BuiltinExecutors::new(), + "/work".into(), + InvocationContext::default(), + ) + .unwrap(); + let endpoint = Endpoint::start(service).await.unwrap(); let client = endpoint.connect().await.unwrap(); let peer = client.peer().clone(); - let result = tokio::spawn(async move {peer.call_tool(CallToolRequestParams::new("alias")).await.unwrap()}); - let Interaction::Prepare {arguments,reply,..} = host.recv().await.unwrap().interaction else {panic!("expected preparation")}; - reply.send(Ok(Admission::Run {arguments})).unwrap(); - let Interaction::Release {reply,..} = host.recv().await.unwrap().interaction else {panic!("expected release")}; + let result = tokio::spawn(async move { + peer.call_tool(CallToolRequestParams::new("alias")) + .await + .unwrap() + }); + + let Interaction::Prepare { + arguments, reply, .. + } = host.recv().await.unwrap().interaction + else { + panic!("expected preparation") + }; + reply.send(Ok(Admission::Run { arguments })).unwrap(); + + let Interaction::Release { reply, .. } = host.recv().await.unwrap().interaction else { + panic!("expected release") + }; reply.send(Ok(ReleaseDecision::Execute)).unwrap(); - let Interaction::Review {result: reviewed, reply, ..} = host.recv().await.unwrap().interaction else {panic!("expected review")}; - assert!(matches!(&reviewed.content[1], ContentBlock::Image(image) if image.data == "AA==" && image.mime_type == "image/png")); - assert_eq!(reviewed.structured_content, Some(json!({"answer":42}))); - assert_eq!(reviewed.metadata, Some(json!({"fixture/source":"upstream"}).as_object().unwrap().clone())); + + // Everything the upstream server sent reaches the Host intact: the + // image block, the structured data, and the result metadata. + let Interaction::Review { + result: reviewed, + reply, + .. + } = host.recv().await.unwrap().interaction + else { + panic!("expected review") + }; + assert!(matches!( + &reviewed.content[1], + ContentBlock::Image(image) + if image.data == "AA==" && image.mime_type == "image/png" + )); + assert_eq!(reviewed.structured_content, Some(json!({"answer": 42}))); + assert_eq!( + reviewed.metadata, + Some( + json!({"fixture/source": "upstream"}) + .as_object() + .unwrap() + .clone() + ) + ); reply.send(Ok(reviewed.clone())).unwrap(); - let Interaction::Record {result:projected,raw_result,reply,..} = host.recv().await.unwrap().interaction else {panic!("expected recording")}; + + let Interaction::Record { + result: projected, + raw_result, + reply, + .. + } = host.recv().await.unwrap().interaction + else { + panic!("expected recording") + }; assert_eq!(projected, reviewed); assert_eq!(raw_result, Some(reviewed)); + // The conversation stores only the text, which is what makes the + // assertion below worth making. assert_eq!(projected.to_text(), "alpha\n\nresource"); assert!(!reply.is_closed()); reply.send(Ok(())).unwrap(); - assert_eq!(serde_json::to_value(result.await.unwrap()).unwrap(), json!({ - "content":[{"type":"text","text":"alpha"},{"type":"image","data":"AA==","mimeType":"image/png"},{"type":"resource","resource":{"uri":"fixture:///resource","text":"resource","mimeType":"text/plain"}}], - "isError":false,"structuredContent":{"answer":42},"_meta":{"fixture/source":"upstream"} - })); + + assert_eq!( + serde_json::to_value(result.await.unwrap()).unwrap(), + json!({ + "content": [ + {"type": "text", "text": "alpha"}, + {"type": "image", "data": "AA==", "mimeType": "image/png"}, + {"type": "resource", "resource": { + "uri": "fixture:///resource", + "text": "resource", + "mimeType": "text/plain", + }}, + ], + "isError": false, + "structuredContent": {"answer": 42}, + "_meta": {"fixture/source": "upstream"}, + }) + ); assert_eq!(count.load(Ordering::SeqCst), 1); + client.cancel().await.unwrap(); endpoint.shutdown().await.unwrap(); server.cancel().await.unwrap(); - }).await.unwrap(); + }) + .await + .unwrap(); } diff --git a/crates/jp_mcp/src/server.rs b/crates/jp_mcp/src/server.rs index e44296ca8..4e9a5a429 100644 --- a/crates/jp_mcp/src/server.rs +++ b/crates/jp_mcp/src/server.rs @@ -16,7 +16,7 @@ pub mod json_schema; pub mod result; pub mod service; mod upstream; -use std::{convert::identity, ffi::OsStr, fmt, process::Stdio, sync::Arc}; +use std::{ffi::OsStr, fmt, process::Stdio, sync::Arc}; pub use builtin::BuiltinTool; use camino::Utf8Path; @@ -33,7 +33,7 @@ use jp_tool::{ schema::{Node, merge_description}, }; use minijinja::{Environment, ErrorKind as MinijinjaErrorKind, value::ValueKind}; -use result::{from_mcp, to_legacy}; +use result::from_mcp; use serde_json::{Error as JsonError, Map, Value, json}; use tokio::{ io::{AsyncBufReadExt, AsyncReadExt, BufReader}, @@ -920,8 +920,9 @@ async fn execute_mcp( let text = if transient { json!({"message":message, "trace":trace}).to_string() } else { - to_legacy(&from_mcp(response.clone()).map_err(ToolError::MalformedOutput)?) - .unwrap_or_else(identity) + from_mcp(response.clone()) + .map_err(ToolError::MalformedOutput)? + .to_text() }; let mut result = from_mcp(replace_envelope(response, &text, true)) .map_err(ToolError::MalformedOutput)?; diff --git a/crates/jp_mcp/src/server/conformance_tests.rs b/crates/jp_mcp/src/server/conformance_tests.rs index 81a2d3776..8f542bf65 100644 --- a/crates/jp_mcp/src/server/conformance_tests.rs +++ b/crates/jp_mcp/src/server/conformance_tests.rs @@ -276,6 +276,19 @@ async fn next(host: &mut HostReceiver) -> HostRequest { .expect("Host channel closed") } +/// A fixture whose `probe` tool requires one integer argument. +async fn integer_argument_fixture(count: &Arc) -> Fixture { + fixture( + json!({ + "source": "builtin", + "run": "ask", + "parameters": {"value": {"type": "integer", "required": true}}, + }), + BuiltinExecutors::new().register("probe", Ordinal(count.clone())), + ) + .await +} + #[tokio::test] async fn external_discovery_preserves_host_metadata_without_executing() { let (mut fixture, count) = counting_fixture().await; @@ -422,7 +435,18 @@ async fn external_inquiry_reexecutes_with_host_answers_and_records_edited_output .is_err() ); assert!(!reply.is_closed()); - fs::write(fixture.root.path().join("record.json"), serde_json::to_vec(&json!({"requested":pending.call.request.arguments,"executed":arguments,"result":result.to_text()})).unwrap()).unwrap(); + // Stand in for the Host writing its conversation: the call must not be + // delivered until this has happened. + let record = json!({ + "requested": pending.call.request.arguments, + "executed": arguments, + "result": result.to_text(), + }); + fs::write( + fixture.root.path().join("record.json"), + serde_json::to_vec(&record).unwrap(), + ) + .unwrap(); reply.send(Ok(())).unwrap(); assert_eq!( returned.await.unwrap(), @@ -680,7 +704,7 @@ async fn host_loss_closes_an_outstanding_approval_without_execution() { #[tokio::test] async fn malformed_arguments_are_rejected_before_approval() { let count = Arc::new(AtomicUsize::new(0)); - let mut fixture = fixture(json!({"source":"builtin", "run":"ask", "parameters":{"value":{"type":"integer","required":true}}}), BuiltinExecutors::new().register("probe", Ordinal(count.clone()))).await; + let mut fixture = integer_argument_fixture(&count).await; let external = ExternalClient::connect(fixture.endpoint.url()).await; let response = external .request( @@ -878,7 +902,7 @@ async fn external_denial_never_executes_the_tool() { #[tokio::test] async fn edited_arguments_are_checked_before_execution_release() { let count = Arc::new(AtomicUsize::new(0)); - let mut fixture = fixture(json!({"source":"builtin", "run":"ask", "parameters":{"value":{"type":"integer","required":true}}}), BuiltinExecutors::new().register("probe", Ordinal(count.clone()))).await; + let mut fixture = integer_argument_fixture(&count).await; let external = ExternalClient::connect(fixture.endpoint.url()).await; let response = external .request( diff --git a/crates/jp_mcp/src/server/http.rs b/crates/jp_mcp/src/server/http.rs index 83af202fd..0f28c8613 100644 --- a/crates/jp_mcp/src/server/http.rs +++ b/crates/jp_mcp/src/server/http.rs @@ -49,7 +49,7 @@ pub enum EndpointError { /// The HTTP task failed. #[error(transparent)] Task(#[from] JoinError), - /// Execution service shutdown failed. + /// The tool catalog the Host supplied cannot be served. #[error(transparent)] Service(#[from] ServiceError), /// The MCP handshake failed. @@ -76,6 +76,12 @@ impl Endpoint { let service = Arc::new(service); let factory = service.clone(); let cancellation = CancellationToken::new(); + // Only this listener's own address is an acceptable Host or Origin, so + // a page in a browser cannot reach the endpoint by resolving some other + // name to loopback. + // + // Assigned field by field because rmcp marks the config + // `#[non_exhaustive]`, which rules out struct-update syntax downstream. let mut config = StreamableHttpServerConfig::default(); config.allowed_hosts = vec![address.to_string()]; config.allowed_origins = vec![origin]; @@ -112,6 +118,8 @@ impl Endpoint { /// Establish the MCP Host's ordinary HTTP connection to this endpoint. pub async fn connect(&self) -> Result, EndpointError> { + // A loopback connection must not be routed through an environment + // proxy or followed to another host. let client = LoopbackClient::new().map_err(|error| EndpointError::Connect(Box::new(error)))?; let config = StreamableHttpClientTransportConfig::with_uri(self.url.clone()) @@ -134,7 +142,7 @@ impl Endpoint { /// Stop tool work, close upstream services, and join the HTTP listener. pub async fn shutdown(mut self) -> Result<(), EndpointError> { - self.service.shutdown().await?; + self.service.shutdown().await; self.cancellation.cancel(); if let Some(task) = self.task.take() { task.await??; @@ -157,6 +165,8 @@ struct Handler { impl ServerHandler for Handler { fn get_info(&self) -> ServerInfo { + // Assigned field by field because rmcp marks `ServerInfo` + // `#[non_exhaustive]`, which rules out struct-update syntax downstream. let mut info = ServerInfo::default(); info.server_info.name = "jp".into(); info.server_info.version = env!("CARGO_PKG_VERSION").into(); diff --git a/crates/jp_mcp/src/server/http_tests.rs b/crates/jp_mcp/src/server/http_tests.rs index 61ba27df9..10e4b8b06 100644 --- a/crates/jp_mcp/src/server/http_tests.rs +++ b/crates/jp_mcp/src/server/http_tests.rs @@ -258,5 +258,5 @@ async fn dropping_endpoint_stops_admission_even_if_host_is_still_connected() { Err(ServiceError::Stopped) )); assert_eq!(count.load(Ordering::SeqCst), 0); - service.shutdown().await.unwrap(); + service.shutdown().await; } diff --git a/crates/jp_mcp/src/server/result.rs b/crates/jp_mcp/src/server/result.rs index eeb094711..33d970f08 100644 --- a/crates/jp_mcp/src/server/result.rs +++ b/crates/jp_mcp/src/server/result.rs @@ -223,30 +223,6 @@ fn to_content(block: ContentBlock) -> Result { Ok(content) } -/// Existing conversation-format projection, applied by the MCP Host only. -/// Image, audio, and links contribute no text; embedded blobs retain their -/// base64 form. -pub fn to_legacy(result: &ToolResult) -> Result { - let text = result - .content - .iter() - .filter_map(|block| match block { - ContentBlock::Text { text, .. } => Some(text.clone()), - ContentBlock::Resource(resource) => Some(match &resource.content { - ResourceContent::Text(text) | ResourceContent::EncodedBlob(text) => text.clone(), - ResourceContent::Blob(bytes) => STANDARD.encode(bytes), - }), - _ => None, - }) - .collect::>() - .join("\n\n"); - if result.is_error() { - Err(text) - } else { - Ok(text) - } -} - #[cfg(test)] #[path = "result_tests.rs"] mod tests; diff --git a/crates/jp_mcp/src/server/result_tests.rs b/crates/jp_mcp/src/server/result_tests.rs index d88176882..b4f23ac0b 100644 --- a/crates/jp_mcp/src/server/result_tests.rs +++ b/crates/jp_mcp/src/server/result_tests.rs @@ -22,10 +22,10 @@ fn native_content_round_trips_through_shared_result() { let result = from_mcp(native).unwrap(); assert_eq!(result.content.len(), 6); assert!(matches!(result.content[1], ContentBlock::Image(_))); - assert_eq!( - to_legacy(&result), - Ok("first\n\nembedded\n\nYmxvYg==".into()) - ); + // Image, audio, and links contribute no text, and a blob resource + // contributes its URI rather than its bytes. + assert_eq!(result.to_text(), "first\n\nembedded\n\nfile:///b"); + assert!(!result.is_error()); assert_eq!(serde_json::to_value(to_mcp(result).unwrap()).unwrap(), wire); } @@ -47,7 +47,8 @@ fn error_details_survive_mcp_encoding() { ); let decoded = from_mcp(native).unwrap(); assert_eq!(decoded.status, result.status); - assert_eq!(to_legacy(&decoded), Err("busy\n\nTrace:\nupstream".into())); + assert!(decoded.is_error()); + assert_eq!(decoded.to_text(), "busy\n\nTrace:\nupstream"); } #[test] diff --git a/crates/jp_mcp/src/server/service.rs b/crates/jp_mcp/src/server/service.rs index e0881c200..13ba08454 100644 --- a/crates/jp_mcp/src/server/service.rs +++ b/crates/jp_mcp/src/server/service.rs @@ -15,7 +15,8 @@ use std::{ use camino::Utf8PathBuf; use indexmap::IndexMap; use jp_config::conversation::tool::{ - FormatMode, ResultMode, RunMode, ToolConfigWithDefaults, ToolSource, style::ParametersStyle, + CommandConfig, FormatMode, ResultMode, RunMode, ToolConfigWithDefaults, ToolSource, + style::ParametersStyle, }; use jp_tool::{ AccessPolicy, Action, ContentBlock, Error as ToolError, InputRequest, QuestionId, @@ -113,6 +114,12 @@ pub struct AccessPolicyError { pub source: Arc, } +/// What a tool's argument formatter produced, or why it produced nothing. +/// +/// A formatter that fails leaves the call runnable: the Host decides whether to +/// show the diagnostic or suppress the call from its display. +pub type Formatted = Result; + /// An argument formatter failed without producing presentation text. #[derive(Debug, Clone, thiserror::Error)] pub enum FormatterError { @@ -186,7 +193,7 @@ pub enum Interaction { arguments: Map, /// Custom formatter output, if formatting was permitted before /// approval. - formatted_arguments: Option>, + formatted_arguments: Option, /// One reply for this preparation operation. reply: oneshot::Sender>, }, @@ -195,7 +202,7 @@ pub enum Interaction { /// Validated arguments that will actually execute. arguments: Map, /// Custom representation of the approved arguments, if requested. - formatted_arguments: Option>, + formatted_arguments: Option, /// Permission to execute, or a final response without execution. reply: oneshot::Sender>, }, @@ -534,9 +541,11 @@ impl Service { /// Stop admission, cancel outstanding calls, wait for their cleanup, and /// close owned upstream services. /// Safe to call more than once. - pub async fn shutdown(&self) -> Result<(), ServiceError> { + pub async fn shutdown(&self) { self.stop(); loop { + // Enabling the notification before checking is what makes this + // race-free: a call finishing in between is still observed. let idle = self.inner.idle.notified(); tokio::pin!(idle); idle.as_mut().enable(); @@ -546,7 +555,6 @@ impl Service { idle.await; } self.inner.upstream.shutdown().await; - Ok(()) } } @@ -618,21 +626,35 @@ async fn run_call( ) -> Result { let mut arguments = call.request.arguments.clone(); validate_arguments(&tool, &mut arguments)?; - let wants_format = if tool.config.run() != RunMode::Skip - && !tool.config.style().hidden - && matches!(tool.config.style().parameters, ParametersStyle::Custom(_)) - { - ask(inner, call, |reply| Interaction::RenderArguments { reply }).await? - } else { - false + // A skipped or hidden call shows nothing, so its formatter is a command + // that would run for output nobody reads. + let formatter = match &tool.config.style().parameters { + ParametersStyle::Custom(command) + if tool.config.run() != RunMode::Skip && !tool.config.style().hidden => + { + Some(command.clone().command()) + } + _ => None, }; - let mut formatted_arguments = if wants_format && tool.config.format() == FormatMode::Unattended - { - Some(format_arguments(inner, &tool, &arguments, cancellation).await?) - } else { - None + let formatter = match formatter { + Some(command) + if ask(inner, call, |reply| Interaction::RenderArguments { reply }).await? => + { + Some(command) + } + _ => None, + }; + // `format = "ask"` holds a user-configured command back until the Host has + // admitted the call. + let mut formatted_arguments = match &formatter { + Some(command) if tool.config.format() == FormatMode::Unattended => { + Some(format_arguments(inner, &tool, command, &arguments, cancellation).await?) + } + _ => None, }; let original_arguments = arguments.clone(); + // `run = "skip"` is the service's own decision, so it needs no Host + // admission, but it resolves the call the same way a Host denial does. let admission = if tool.config.run() == RunMode::Skip { Admission::Skip { reason: "Tool execution skipped by configuration.".into(), @@ -646,32 +668,24 @@ async fn run_call( }) .await? }; - let admission = match admission { - Admission::Skip { reason } => Admission::Complete { - result: ToolResult::text(reason), - }, - other => other, - }; arguments = match admission { Admission::Run { arguments } => arguments, + Admission::Skip { reason } => { + return record_without_executing(inner, call, arguments, ToolResult::text(reason)) + .await; + } Admission::Complete { result } => { - ask(inner, call, |reply| Interaction::Record { - arguments, - raw_result: None, - result: result.clone(), - reply, - }) - .await?; - return Ok(CallOutput { - result, - delivery_decided: true, - }); + return record_without_executing(inner, call, arguments, result).await; } - Admission::Skip { .. } => unreachable!("skip was normalized above"), }; validate_arguments(&tool, &mut arguments)?; - if wants_format && (formatted_arguments.is_none() || arguments != original_arguments) { - formatted_arguments = Some(format_arguments(inner, &tool, &arguments, cancellation).await?); + // Arguments the Host edited make any earlier formatting stale, so the + // presentation is rebuilt from what will actually execute. + if let Some(command) = &formatter + && (formatted_arguments.is_none() || arguments != original_arguments) + { + formatted_arguments = + Some(format_arguments(inner, &tool, command, &arguments, cancellation).await?); } let release = ask(inner, call, |reply| Interaction::Release { arguments: arguments.clone(), @@ -695,6 +709,27 @@ async fn run_call( deliver_result(inner, call, &tool, arguments, output, executed).await } +/// Record a call the Host resolved before it could execute. +async fn record_without_executing( + inner: &Inner, + call: &CallInfo, + arguments: Map, + result: ToolResult, +) -> Result { + ask(inner, call, |reply| Interaction::Record { + arguments, + // Nothing ran, so there is no unedited result behind the one delivered. + raw_result: None, + result: result.clone(), + reply, + }) + .await?; + Ok(CallOutput { + result, + delivery_decided: true, + }) +} + async fn deliver_result( inner: &Inner, call: &CallInfo, @@ -818,15 +853,18 @@ async fn execute_with_answers( } } +/// Run a tool's configured argument formatter and return what it printed. +/// +/// A formatter that fails is presentation that failed, not a failed call, so it +/// comes back as [`FormatterError`] for the Host to show or suppress. +/// Only cancellation and a policy that never compiled end the call itself. async fn format_arguments( inner: &Inner, tool: &ConfiguredTool, + command: &CommandConfig, arguments: &Map, cancellation: &CancellationToken, -) -> Result, ServiceError> { - let ParametersStyle::Custom(command) = &tool.config.style().parameters else { - return Ok(Ok(String::new())); - }; +) -> Result { let name = match tool.config.source() { ToolSource::Local { tool: name } | ToolSource::Builtin { tool: name } @@ -843,7 +881,7 @@ async fn format_arguments( &inner.invocation, ); let result = match run_tool_command( - command.clone().command(), + command.clone(), context, &inner.root, cancellation.clone(), @@ -863,12 +901,7 @@ async fn format_arguments( })), other => { let result = other.into_tool_result(name); - let message = result - .content - .iter() - .filter_map(ContentBlock::as_text) - .collect::>() - .join("\n\n"); + let message = result.to_text(); if result.is_error() { Ok(Err(FormatterError::Reported { message })) } else { diff --git a/crates/jp_mcp/src/server/service_tests.rs b/crates/jp_mcp/src/server/service_tests.rs index 5e716997c..1aa925621 100644 --- a/crates/jp_mcp/src/server/service_tests.rs +++ b/crates/jp_mcp/src/server/service_tests.rs @@ -9,7 +9,9 @@ use std::{ }, }; +use assert_matches::assert_matches; use async_trait::async_trait; +use camino::Utf8Path; #[cfg(unix)] use camino_tempfile::{Utf8TempDir, tempdir}; use jp_config::{ @@ -44,12 +46,19 @@ impl BuiltinTool for CountingTool { } } -fn fixture(run: &str, result: &str) -> (Service, HostReceiver, Arc) { - let count = Arc::new(AtomicUsize::new(0)); - let partial: PartialToolConfig = - serde_json::from_value(json!({"source":"builtin", "run":run, "result":result})).unwrap(); - let mut config = AppConfig::new_test(); - config.conversation.tools.insert( +/// Build a service around one builtin tool named `count`. +/// +/// `config` is the tool's configuration as a user would write it, so a test +/// says what it needs rather than patching a service after construction. +fn service( + config: Value, + root: &Utf8Path, + builtins: BuiltinExecutors, + invocation: InvocationContext, +) -> (Service, HostReceiver) { + let partial: PartialToolConfig = serde_json::from_value(config).unwrap(); + let mut app = AppConfig::new_test(); + app.conversation.tools.insert( "count".into(), ToolConfig::from_partial(partial, vec![]).unwrap(), ); @@ -57,20 +66,38 @@ fn fixture(run: &str, result: &str) -> (Service, HostReceiver, Arc) definition: ToolDefinition { name: "count".into(), docs: ToolDocs::default(), - parameters: json!({"type":"object", "properties":{"path":{"type":"string"}}, "required":["path"]}), + parameters: json!({ + "type": "object", + "properties": {"path": {"type": "string"}}, + "required": ["path"], + }), }, - config: config.conversation.tools.get("count").unwrap(), + config: app.conversation.tools.get("count").unwrap(), access: Ok(None), metadata: Map::new(), }; - let (service, host) = Service::new( + Service::new( vec![tool], Client::default(), - BuiltinExecutors::new().register("count", CountingTool(count.clone())), + builtins, + root.to_owned(), + invocation, + ) + .unwrap() +} + +/// A service whose `count` tool asks one question and then echoes its input. +/// +/// The counter records how many execution attempts actually ran, which is what +/// separates "the call was denied" from "the call silently went nowhere". +fn fixture(run: &str, result: &str) -> (Service, HostReceiver, Arc) { + let count = Arc::new(AtomicUsize::new(0)); + let (service, host) = service( + json!({"source": "builtin", "run": run, "result": result}), "/tmp".into(), + BuiltinExecutors::new().register("count", CountingTool(count.clone())), InvocationContext::default(), - ) - .unwrap(); + ); (service, host, count) } @@ -167,7 +194,7 @@ async fn preparation_release_input_and_delivery_use_distinct_acknowledgements() call.finish().await.unwrap(), ToolResult::text("edited result") ); - service.shutdown().await.unwrap(); + service.shutdown().await; } #[tokio::test] @@ -239,7 +266,6 @@ async fn shutdown_cancels_pending_release_and_rejects_late_reply() { }; timeout(Duration::from_secs(2), service.shutdown()) .await - .unwrap() .unwrap(); assert!(reply.send(Ok(ReleaseDecision::Execute)).is_err()); assert!(matches!(call.finish().await, Err(ServiceError::Cancelled))); @@ -281,8 +307,10 @@ async fn failed_recording_prevents_result_delivery() { "disk full", ))))) .unwrap(); - assert!( - matches!(call.finish().await, Err(ServiceError::Host(HostError::Recording(source))) if source.to_string() == "disk full") + assert_matches!( + call.finish().await, + Err(ServiceError::Host(HostError::Recording(source))) + if source.to_string() == "disk full" ); assert_eq!(count.load(Ordering::SeqCst), 2); } @@ -489,8 +517,12 @@ async fn wrong_argument_type_fails_before_host_approval() { let mut input = request(); input.arguments.insert("path".into(), json!(42)); let call = service.start_call(input).unwrap(); - assert!( - matches!(timeout(Duration::from_secs(2), call.finish()).await.unwrap(), Err(ServiceError::InvalidArgument { path }) if path == "path") + let finished = timeout(Duration::from_secs(2), call.finish()) + .await + .unwrap(); + assert_matches!( + finished, + Err(ServiceError::InvalidArgument { path }) if path == "path" ); assert_eq!(count.load(Ordering::SeqCst), 0); } @@ -517,14 +549,17 @@ impl BuiltinTool for BlockedTool { #[tokio::test] async fn cancellation_drops_an_in_flight_builtin_attempt() { - let (mut service, mut host, _) = fixture("ask", "unattended"); let entered = Arc::new(Notify::new()); let dropped = Arc::new(AtomicUsize::new(0)); - Arc::get_mut(&mut service.inner).unwrap().builtins = + let (service, mut host) = service( + json!({"source": "builtin", "run": "ask", "result": "unattended"}), + "/tmp".into(), BuiltinExecutors::new().register("count", BlockedTool { entered: entered.clone(), dropped: dropped.clone(), - }); + }), + InvocationContext::default(), + ); let call = service.start_call(request()).unwrap(); release(&mut host).await; timeout(Duration::from_secs(2), entered.notified()) @@ -551,25 +586,39 @@ async fn dropping_result_receiver_does_not_cancel_or_reexecute() { }; reply.send(Ok(())).unwrap(); assert_eq!(count.load(Ordering::SeqCst), 2); - service.shutdown().await.unwrap(); + service.shutdown().await; } +/// A service whose `count` tool formats its arguments with a shell command. +/// +/// The formatter touches `formatter-ran` in the working root, so a test can +/// tell "the formatter did not run" from "it ran and produced nothing", and +/// echoes the action and the invocation identity the service supplied it. #[cfg(unix)] fn formatter_fixture(mode: &str) -> (Service, HostReceiver, Utf8TempDir) { - let (mut service, host, _) = fixture("ask", "unattended"); let root = tempdir().unwrap(); - let partial: PartialToolConfig = serde_json::from_value(json!({ - "source":"builtin", "run":"ask", "format":mode, - "style":{"parameters":{"program":"sh", "args":["-c", "printf 'formatted' > formatter-ran; printf '%s' '{{context.action}}:{{tool.arguments.path}}'"], "shell":false}} - })).unwrap(); - let mut cfg = AppConfig::new_test(); - cfg.conversation.tools.insert( - "count".into(), - ToolConfig::from_partial(partial, vec![]).unwrap(), + let (service, host) = service( + json!({ + "source": "builtin", + "run": "ask", + "format": mode, + "style": {"parameters": { + "program": "sh", + "args": [ + "-c", + "printf 'formatted' > formatter-ran; printf '%s' \ + '{{context.action}}:{{tool.arguments.path}}:{{context.workspace_id}}/{{context.conversation_id}}'", + ], + "shell": false, + }}, + }), + root.path(), + BuiltinExecutors::new().register("count", CountingTool(Arc::new(AtomicUsize::new(0)))), + InvocationContext { + workspace_id: "ws-abc".into(), + conversation_id: "conv-xyz".into(), + }, ); - let inner = Arc::get_mut(&mut service.inner).unwrap(); - inner.root = root.path().to_owned(); - inner.tools.get_mut("count").unwrap().config = cfg.conversation.tools.get("count").unwrap(); (service, host, root) } @@ -605,9 +654,11 @@ async fn formatter_asks_for_visibility_and_waits_for_approval() { else { panic!("expected release") }; + // The formatter runs under the action, arguments, and invocation identity + // the service supplies, not values a caller could set. assert_eq!( formatted_arguments.map(|result| result.map_err(|error| error.to_string())), - Some(Ok("format_arguments:original".into())) + Some(Ok("format_arguments:original:ws-abc/conv-xyz".into())) ); assert_eq!( fs::read_to_string(root.path().join("formatter-ran")).unwrap(), @@ -636,13 +687,56 @@ async fn unattended_formatter_is_available_before_approval() { }; assert_eq!( formatted_arguments.map(|result| result.map_err(|error| error.to_string())), - Some(Ok("format_arguments:original".into())) + Some(Ok("format_arguments:original:ws-abc/conv-xyz".into())) ); assert!(root.path().join("formatter-ran").exists()); call.cancel(); assert!(matches!(call.finish().await, Err(ServiceError::Cancelled))); } +#[tokio::test] +#[cfg(unix)] +async fn a_formatter_is_told_the_name_the_tool_runs_under() { + // A `source` naming an implementation (`builtin.counter` under the key + // `count`) is the name the tool executes as, so the formatter is asked + // about that name rather than the key the assistant called. Handing it the + // key asks about a tool that does not exist. + let root = tempdir().unwrap(); + let (service, mut host) = service( + json!({ + "source": "builtin.counter", + "run": "ask", + "format": "unattended", + "style": {"parameters": { + "program": "sh", + "args": ["-c", "printf '%s' '{{tool.name}}'"], + "shell": false, + }}, + }), + root.path(), + BuiltinExecutors::new().register("counter", CountingTool(Arc::new(AtomicUsize::new(0)))), + InvocationContext::default(), + ); + let call = service.start_call(request()).unwrap(); + let Interaction::RenderArguments { reply } = next(&mut host).await.interaction else { + panic!("expected visibility request") + }; + reply.send(Ok(true)).unwrap(); + let Interaction::Prepare { + formatted_arguments, + .. + } = next(&mut host).await.interaction + else { + panic!("expected preparation") + }; + assert_eq!( + formatted_arguments.map(|result| result.map_err(|error| error.to_string())), + Some(Ok("counter".into())) + ); + call.cancel(); + assert!(matches!(call.finish().await, Err(ServiceError::Cancelled))); +} + #[tokio::test] #[cfg(unix)] async fn hidden_presentation_never_executes_formatter() { diff --git a/crates/jp_mcp/src/server/upstream.rs b/crates/jp_mcp/src/server/upstream.rs index c63a9354a..0be61f6f9 100644 --- a/crates/jp_mcp/src/server/upstream.rs +++ b/crates/jp_mcp/src/server/upstream.rs @@ -15,43 +15,61 @@ pub(super) enum UpstreamResult { } /// Recognize one complete legacy envelope without flattening native content. +/// +/// Only a result that is exactly one text block is a candidate, and only if +/// that text parses whole. +/// Anything else is a native MCP result and is returned untouched, mixed +/// content included. pub(super) fn decode_result(result: CallToolResult) -> Result { - if let [content] = result.content.as_slice() - && let RawContent::Text(text) = &content.raw - { - match serde_json::from_str::(&text.text) { - Ok(Outcome::Success { .. }) if result.is_error == Some(true) => { - warn!("MCP error flag conflicts with an Outcome::Success envelope"); - } - Ok(outcome) => { - return Ok(UpstreamResult::Outcome { - outcome, - response: result, - }); - } - Err(error) => { - let value = serde_json::from_str::(&text.text).ok(); - if matches!( - value - .as_ref() - .and_then(|v| v.get("type")) - .and_then(Value::as_str), - Some("needs_input") - ) { - return Err(error); - } - } + let [content] = result.content.as_slice() else { + return Ok(UpstreamResult::Native(result)); + }; + let RawContent::Text(text) = &content.raw else { + return Ok(UpstreamResult::Native(result)); + }; + + match serde_json::from_str::(&text.text) { + // The server said the call failed and its payload says it succeeded. + // Per RFD 108 the flag wins, so the envelope is left unrecognized and + // the failure carries through as the native result it already is. + Ok(Outcome::Success { .. }) if result.is_error == Some(true) => { + warn!("MCP error flag conflicts with an Outcome::Success envelope"); + Ok(UpstreamResult::Native(result)) } + Ok(outcome) => Ok(UpstreamResult::Outcome { + outcome, + response: result, + }), + // A payload shaped like an inquiry that will not parse is a protocol + // mismatch, not prose: handing the raw JSON to the model would hide it. + Err(error) if is_needs_input(&text.text) => Err(error), + Err(_) => Ok(UpstreamResult::Native(result)), } - Ok(UpstreamResult::Native(result)) } -/// Replace an unwrapped envelope while retaining its native result metadata. +/// Whether the text is a JSON object announcing itself as an inquiry. +fn is_needs_input(text: &str) -> bool { + serde_json::from_str::(text) + .ok() + .as_ref() + .and_then(|value| value.get("type")) + .and_then(Value::as_str) + == Some("needs_input") +} + +/// Replace a recognized envelope's text while retaining its native metadata. +/// +/// `response` is the result [`decode_result`] recognized, so its single text +/// block is the envelope being unwrapped. pub(super) fn replace_envelope( mut response: CallToolResult, text: &str, is_error: bool, ) -> CallToolResult { + debug_assert!( + matches!(response.content.as_slice(), [content] if matches!(content.raw, RawContent::Text(_))), + "only a recognized single-text envelope can be replaced" + ); if let Some(content) = response.content.first_mut() && let RawContent::Text(content) = &mut content.raw { diff --git a/crates/jp_mcp/src/server_tests.rs b/crates/jp_mcp/src/server_tests.rs index 142e19f30..af7c106c8 100644 --- a/crates/jp_mcp/src/server_tests.rs +++ b/crates/jp_mcp/src/server_tests.rs @@ -31,9 +31,10 @@ fn command_error_keeps_details_and_its_conversation_projection() { trace: vec!["upstream".into()], }) ); + assert!(result.is_error()); assert_eq!( - to_legacy(&result), - Err(r#"{"message":"busy","trace":["upstream"]}"#.into()) + result.to_text(), + r#"{"message":"busy","trace":["upstream"]}"# ); } diff --git a/crates/jp_tool/src/definition_tests.rs b/crates/jp_tool/src/definition_tests.rs index 55adc0947..9ab33ea41 100644 --- a/crates/jp_tool/src/definition_tests.rs +++ b/crates/jp_tool/src/definition_tests.rs @@ -35,6 +35,21 @@ fn definition(parameters: Value) -> ToolDefinition { } } +/// Assert that validation reported exactly these missing and unknown arguments, +/// in this order. +#[track_caller] +fn assert_arguments_error(result: Result<(), Error>, missing: &[String], unknown: &[String]) { + let Err(Error::Arguments { + missing: got_missing, + unknown: got_unknown, + }) = result + else { + panic!("expected an argument error, got {result:?}") + }; + assert_eq!(got_missing, missing, "missing arguments"); + assert_eq!(got_unknown, unknown, "unknown arguments"); +} + #[test] fn coerces_json_strings_to_declared_parameter_types() { let parameters = schema([ @@ -126,14 +141,16 @@ fn test_validate_tool_arguments() { struct TestCase { arguments: Map, parameters: Value, - want: Result<(), Error>, + /// The arguments reported missing and unknown, or `None` when the call + /// is expected to validate. + want: Option<(Vec, Vec)>, } let cases = vec![ ("empty", TestCase { arguments: Map::new(), parameters: schema([]), - want: Ok(()), + want: None, }), ("correct", TestCase { arguments: Map::from_iter([("foo".to_owned(), json!("bar"))]), @@ -141,37 +158,31 @@ fn test_validate_tool_arguments() { ("foo", param("string"), true), ("bar", param("string"), false), ]), - want: Ok(()), + want: None, }), ("missing", TestCase { arguments: Map::new(), parameters: schema([("foo", param("string"), true)]), - want: Err(Error::Arguments { - missing: vec!["foo".to_owned()], - unknown: vec![], - }), + want: Some((vec!["foo".to_owned()], vec![])), }), ("unknown", TestCase { arguments: Map::from_iter([("foo".to_owned(), json!("bar"))]), parameters: schema([("bar", param("string"), false)]), - want: Err(Error::Arguments { - missing: vec![], - unknown: vec!["foo".to_owned()], - }), + want: Some((vec![], vec!["foo".to_owned()])), }), ("both", TestCase { arguments: Map::from_iter([("foo".to_owned(), json!("bar"))]), parameters: schema([("bar", param("string"), true)]), - want: Err(Error::Arguments { - missing: vec!["bar".to_owned()], - unknown: vec!["foo".to_owned()], - }), + want: Some((vec!["bar".to_owned()], vec!["foo".to_owned()])), }), ]; for (name, test_case) in cases { let result = validate_tool_arguments(&test_case.arguments, &test_case.parameters); - assert_eq!(result, test_case.want, "failed case: {name}"); + match test_case.want { + None => result.unwrap_or_else(|error| panic!("case {name} should validate: {error}")), + Some((missing, unknown)) => assert_arguments_error(result, &missing, &unknown), + } } } @@ -203,10 +214,7 @@ fn test_validate_nested_array_item_properties() { "path": "src/lib.rs", "patterns": [{"old": "foo", "new": "bar"}] }); - assert_eq!( - validate_tool_arguments(args.as_object().unwrap(), ¶meters), - Ok(()) - ); + validate_tool_arguments(args.as_object().unwrap(), ¶meters).expect("arguments validate"); // Valid: multiple items. let args = json!({ @@ -216,22 +224,17 @@ fn test_validate_nested_array_item_properties() { {"old": "c", "new": "d"} ] }); - assert_eq!( - validate_tool_arguments(args.as_object().unwrap(), ¶meters), - Ok(()) - ); + validate_tool_arguments(args.as_object().unwrap(), ¶meters).expect("arguments validate"); // Invalid: unknown inner field. let args = json!({ "path": "src/lib.rs", "patterns": [{"old": "foo", "new": "bar", "extra": true}] }); - assert_eq!( + assert_arguments_error( validate_tool_arguments(args.as_object().unwrap(), ¶meters), - Err(Error::Arguments { - missing: vec![], - unknown: vec!["extra".to_owned()], - }) + &[], + &["extra".to_owned()], ); // Invalid: missing required inner field. @@ -239,12 +242,10 @@ fn test_validate_nested_array_item_properties() { "path": "src/lib.rs", "patterns": [{"old": "foo"}] }); - assert_eq!( + assert_arguments_error( validate_tool_arguments(args.as_object().unwrap(), ¶meters), - Err(Error::Arguments { - missing: vec!["new".to_owned()], - unknown: vec![], - }) + &["new".to_owned()], + &[], ); // Invalid: wrong inner field names (the LLM hallucinated names). @@ -269,20 +270,14 @@ fn test_validate_nested_array_item_properties() { "path": "src/lib.rs", "patterns": ["not an object"] }); - assert_eq!( - validate_tool_arguments(args.as_object().unwrap(), ¶meters), - Ok(()) - ); + validate_tool_arguments(args.as_object().unwrap(), ¶meters).expect("arguments validate"); // Valid: parameter is not an array (type mismatch, but not our job to check types). let args = json!({ "path": "src/lib.rs", "patterns": "not an array" }); - assert_eq!( - validate_tool_arguments(args.as_object().unwrap(), ¶meters), - Ok(()) - ); + validate_tool_arguments(args.as_object().unwrap(), ¶meters).expect("arguments validate"); } #[test] @@ -305,36 +300,26 @@ fn test_validate_nested_object_properties() { // Valid. let args = json!({ "name": "test", "config": { "verbose": true, "output": "out.txt" } }); - assert_eq!( - validate_tool_arguments(args.as_object().unwrap(), ¶meters), - Ok(()) - ); + validate_tool_arguments(args.as_object().unwrap(), ¶meters).expect("arguments validate"); // Valid: optional object param omitted entirely. let args = json!({ "name": "test" }); - assert_eq!( - validate_tool_arguments(args.as_object().unwrap(), ¶meters), - Ok(()) - ); + validate_tool_arguments(args.as_object().unwrap(), ¶meters).expect("arguments validate"); // Invalid: unknown field inside the object. let args = json!({ "name": "test", "config": { "output": "o", "bogus": 1 } }); - assert_eq!( + assert_arguments_error( validate_tool_arguments(args.as_object().unwrap(), ¶meters), - Err(Error::Arguments { - missing: vec![], - unknown: vec!["bogus".to_owned()], - }) + &[], + &["bogus".to_owned()], ); // Invalid: missing required field inside the object. let args = json!({ "name": "test", "config": { "verbose": true } }); - assert_eq!( + assert_arguments_error( validate_tool_arguments(args.as_object().unwrap(), ¶meters), - Err(Error::Arguments { - missing: vec!["output".to_owned()], - unknown: vec![], - }) + &["output".to_owned()], + &[], ); } diff --git a/crates/jp_tool/src/error.rs b/crates/jp_tool/src/error.rs index c5f64fdcf..e941769f6 100644 --- a/crates/jp_tool/src/error.rs +++ b/crates/jp_tool/src/error.rs @@ -57,15 +57,3 @@ pub enum Error { unknown: Vec, }, } - -#[cfg(test)] -impl PartialEq for Error { - fn eq(&self, other: &Self) -> bool { - if std::mem::discriminant(self) != std::mem::discriminant(other) { - return false; - } - - // Good enough for testing purposes - format!("{self:?}") == format!("{other:?}") - } -} diff --git a/docs/architecture/ubiquitous-language.md b/docs/architecture/ubiquitous-language.md index 316179033..384c27ac6 100644 --- a/docs/architecture/ubiquitous-language.md +++ b/docs/architecture/ubiquitous-language.md @@ -32,7 +32,10 @@ In disagreements between code and docs, the code is authoritative. - [Event Overlay](#event-overlay) - [InlineReply](#inlinereply) - [Inquiry](#inquiry) + - [Invocation](#invocation) + - [JP MCP Server](#jp-mcp-server) - [Match](#match) + - [MCP Host](#mcp-host) - [Persona](#persona) - [Pinned Conversation](#pinned-conversation) - [Provider](#provider) @@ -219,6 +222,33 @@ Carried as `InquiryRequest` and `InquiryResponse` events within a conversation. Used for mid-turn clarification that should not appear in the main chat stream or be sent to the LLM provider as context. +### Invocation + +One execution of one tool call inside the [JP MCP Server](#jp-mcp-server), from +the moment the call is admitted to the moment its result is recorded. +It carries an identity the server assigns itself, so two callers asking for the +same tool with the same arguments at the same time remain distinguishable. +Implemented as `InvocationId` in `jp_mcp::server::service`. + +**Not the same as** a [Tool Call](#tool-call), which is the pair of conversation +events an invocation produces, nor a transport request ID, which belongs to +whichever protocol carried the call. + +A tool that asks for input ends its execution attempt and runs again with the +answer; both attempts belong to the same invocation. + +### JP MCP Server + +The in-process service that executes tool calls: it resolves the tool, validates +arguments, runs the local command, built-in, or upstream MCP tool, and produces +the result. +It owns no conversation and makes no policy decision about who answers a +question; it asks the [MCP Host](#mcp-host) for each decision it needs. +Implemented as `Service` in `jp_mcp::server::service`. + +**Not the same as** a third-party MCP server, which is an external process +configured under `providers.mcp` and reached *through* this one. + ### Match A [Search Hit](#search-hit) whose line actually contains the pattern, as opposed @@ -231,6 +261,20 @@ heading, the `--output count` value, and the `--max-matches` cap. **Not the same as.** A Search Hit, which also covers context lines. +### MCP Host + +The side of a tool call that owns everything the [JP MCP Server](#jp-mcp-server) +deliberately does not: admission, argument and result editing, inquiry routing, +and writing the conversation. +The server asks; the Host decides and records. +In JP the Host is the CLI process, reached through the private channel a +`HostReceiver` carries. + +**Not the same as** an MCP client, which is any caller that invokes tools over +the protocol. +The Host is one such caller, and a third-party client is another; only the Host +answers the server's decisions. + ### Pinned Conversation A conversation the user has marked as important, so it stays prominent and is diff --git a/docs/rfd/014-attachment-handler-guide.md b/docs/rfd/014-attachment-handler-guide.md index 1f27759b3..281261e76 100644 --- a/docs/rfd/014-attachment-handler-guide.md +++ b/docs/rfd/014-attachment-handler-guide.md @@ -126,7 +126,7 @@ pub trait Handler: Debug + DynClone + DynHash + Send + Sync { - **`list()`** — returns all stored attachment URLs. Used by `jp attachment ls`. Should produce canonical (hierarchical) URLs for consistency. -- **`get(cwd, mcp)`** — fetches and returns the actual attachment content. +- **`get(cwd)`** — fetches and returns the actual attachment content. This is where the handler does its real work: reading files, running commands, making HTTP requests, etc. diff --git a/justfile b/justfile index b208021fa..b09d38c44 100644 --- a/justfile +++ b/justfile @@ -3511,6 +3511,12 @@ lint-ci: (_rustup_component "clippy") _install_ci_matchers cargo clippy --locked --workspace --all-targets --all-features --no-deps --profile=lint -- --deny warnings + # `--all-features` and the workspace build both turn `jp_mcp/server` on, + # because `jp_cli` asks for it. A crate that also ships without the tool + # server has to be compiled that way somewhere, or the `client`-only + # spelling rots unnoticed. + cargo clippy --locked --package jp_mcp --all-targets --no-default-features --features client --no-deps --profile=lint -- --deny warnings + # Check code formatting on CI. [group('ci')] fmt-ci: (_rustup_component "rustfmt") _install_ci_matchers From 7f9d90d901da5405e20ea4d59c4bd18ce2703c67 Mon Sep 17 00:00:00 2001 From: Jean Mertz Date: Mon, 14 Sep 2026 21:10:23 +0200 Subject: [PATCH 10/29] cargo vet Signed-off-by: Jean Mertz --- .config/supply-chain/audits.toml | 20 ++++++++++++++++++++ .config/supply-chain/imports.lock | 31 +++++++++++++++++++++++++++++++ Cargo.lock | 2 -- crates/jp_mcp/Cargo.toml | 4 +--- 4 files changed, 52 insertions(+), 5 deletions(-) diff --git a/.config/supply-chain/audits.toml b/.config/supply-chain/audits.toml index 017fb5655..5eb1c1b5d 100644 --- a/.config/supply-chain/audits.toml +++ b/.config/supply-chain/audits.toml @@ -26,6 +26,11 @@ who = "Jean Mertz " criteria = "safe-to-deploy" version = "0.7.3" +[[audits.chacha20]] +who = "Jean Mertz " +criteria = "safe-to-deploy" +delta = "0.10.0 -> 0.10.2" + [[audits.comfy-table]] who = "Jean Mertz " criteria = "safe-to-deploy" @@ -46,6 +51,11 @@ who = "Jean Mertz " criteria = "safe-to-deploy" delta = "0.2.14 -> 0.3.5" +[[audits.cpufeatures]] +who = "Jean Mertz " +criteria = "safe-to-deploy" +delta = "0.3.0 -> 0.3.1" + [[audits.datetime_literal]] who = "Jean Mertz " criteria = "safe-to-deploy" @@ -216,6 +226,16 @@ who = "Jean Mertz " criteria = "safe-to-deploy" delta = "0.9.4 -> 0.9.5" +[[audits.rand]] +who = "Jean Mertz " +criteria = "safe-to-deploy" +delta = "0.10.1 -> 0.10.2" + +[[audits.rand_core]] +who = "Jean Mertz " +criteria = "safe-to-deploy" +delta = "0.10.0 -> 0.10.1" + [[audits.rand_xorshift]] who = "Jean Mertz " criteria = "safe-to-deploy" diff --git a/.config/supply-chain/imports.lock b/.config/supply-chain/imports.lock index 4b7665dd9..363762c50 100644 --- a/.config/supply-chain/imports.lock +++ b/.config/supply-chain/imports.lock @@ -1623,6 +1623,12 @@ criteria = "safe-to-deploy" delta = "0.9.2 -> 0.9.4" notes = "Minor bugfix release" +[[audits.bytecode-alliance.audits.rand]] +who = "Alex Crichton " +criteria = "safe-to-deploy" +delta = "0.10.0 -> 0.10.1" +notes = "Minor logging-based updated fixing a recent advisory for the crate." + [[audits.bytecode-alliance.audits.rustc-demangle]] who = "Alex Crichton " criteria = "safe-to-deploy" @@ -2767,6 +2773,16 @@ who = "J.C. Jones " criteria = "safe-to-deploy" delta = "1.0.1 -> 1.0.3" +[[audits.isrg.audits.chacha20]] +who = "David Cook " +criteria = "safe-to-deploy" +version = "0.10.0" + +[[audits.isrg.audits.cpufeatures]] +who = "David Cook " +criteria = "safe-to-deploy" +delta = "0.2.17 -> 0.3.0" + [[audits.isrg.audits.getrandom]] who = "David Cook " criteria = "safe-to-deploy" @@ -2836,6 +2852,11 @@ who = "Tim Geoghegan " criteria = "safe-to-deploy" delta = "0.9.1 -> 0.9.2" +[[audits.isrg.audits.rand]] +who = "David Cook " +criteria = "safe-to-deploy" +delta = "0.9.2 -> 0.10.0" + [[audits.isrg.audits.rand_chacha]] who = "David Cook " criteria = "safe-to-deploy" @@ -2846,6 +2867,16 @@ who = "David Cook " criteria = "safe-to-deploy" delta = "0.6.4 -> 0.9.3" +[[audits.isrg.audits.rand_core]] +who = "J.C. Jones " +criteria = "safe-to-deploy" +delta = "0.9.3 -> 0.9.5" + +[[audits.isrg.audits.rand_core]] +who = "David Cook " +criteria = "safe-to-deploy" +delta = "0.9.5 -> 0.10.0" + [[audits.isrg.audits.rayon]] who = "Brandon Pitman " criteria = "safe-to-deploy" diff --git a/Cargo.lock b/Cargo.lock index 132de2456..d4f180af0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2617,7 +2617,6 @@ dependencies = [ "futures", "indexmap", "jp_config", - "jp_test", "jp_tool", "minijinja", "reqwest", @@ -2627,7 +2626,6 @@ dependencies = [ "sha1", "sha2", "sse-stream", - "test-log", "thiserror 2.0.20", "tokio", "tokio-util", diff --git a/crates/jp_mcp/Cargo.toml b/crates/jp_mcp/Cargo.toml index 500368896..5de6ca9d2 100644 --- a/crates/jp_mcp/Cargo.toml +++ b/crates/jp_mcp/Cargo.toml @@ -68,13 +68,11 @@ tracing = { workspace = true } which = { workspace = true } [dev-dependencies] -camino-tempfile = { workspace = true } assert_matches = { workspace = true } -jp_test = { workspace = true } +camino-tempfile = { workspace = true } # The conformance client speaks JSON-RPC and SSE over plain HTTP by hand, on # purpose: it must not share a transport with the endpoint it is checking. reqwest = { workspace = true, features = ["json"] } -test-log = { workspace = true } tokio = { workspace = true, features = ["test-util"] } [lints] From b7bc16c01545a9eae2d0ff58e486b3ee3f1f4f2e Mon Sep 17 00:00:00 2001 From: Jean Mertz Date: Mon, 14 Sep 2026 22:11:41 +0200 Subject: [PATCH 11/29] review feedback Signed-off-by: Jean Mertz --- crates/jp_cli/src/cmd.rs | 3 - crates/jp_cli/src/cmd/query.rs | 2 - .../jp_cli/src/cmd/query/tool/coordinator.rs | 570 +++++++++--------- .../src/cmd/query/tool/coordinator_tests.rs | 2 - crates/jp_cli/src/cmd/query/tool/executor.rs | 17 +- .../src/cmd/query/tool/executor_error.rs | 19 +- .../src/cmd/query/tool/executor_mock.rs | 5 +- .../jp_cli/src/cmd/query/tool/mcp_executor.rs | 71 +-- .../src/cmd/query/tool/mcp_executor_tests.rs | 61 +- crates/jp_cli/src/cmd/query/turn_loop.rs | 16 +- .../jp_cli/src/cmd/query/turn_loop_tests.rs | 207 +------ crates/jp_cli/src/cmd/query_tests.rs | 15 +- crates/jp_cli/src/error.rs | 5 +- crates/jp_mcp/src/client_protocol_tests.rs | 13 +- crates/jp_mcp/src/server.rs | 23 +- crates/jp_mcp/src/server/conformance_tests.rs | 84 ++- crates/jp_mcp/src/server/http_tests.rs | 10 +- crates/jp_mcp/src/server/result.rs | 23 +- crates/jp_mcp/src/server/service.rs | 48 +- crates/jp_mcp/src/server/service_tests.rs | 31 +- crates/jp_mcp/src/server/upstream.rs | 13 +- crates/jp_tool/src/content.rs | 32 +- crates/jp_tool/src/content_tests.rs | 72 ++- crates/jp_tool/src/lib.rs | 36 ++ ...s-that-spawn-a-process-only-run-on-unix.md | 63 ++ 25 files changed, 706 insertions(+), 735 deletions(-) create mode 100644 docs/ticket/0jwc9gr-tool-execution-tests-that-spawn-a-process-only-run-on-unix.md diff --git a/crates/jp_cli/src/cmd.rs b/crates/jp_cli/src/cmd.rs index 2121af6e0..1f2da7fb2 100644 --- a/crates/jp_cli/src/cmd.rs +++ b/crates/jp_cli/src/cmd.rs @@ -449,9 +449,6 @@ impl From for Error { Conversation(error) => return error.into(), Mcp(error) => return error.into(), McpEndpoint(error) => [("message", error.to_string())].into(), - McpRecording(error) => { - [("message", format!("MCP Host recording failed: {error}"))].into() - } Llm(error) => return error.into(), Io(error) => return error.into(), Url(error) => return error.into(), diff --git a/crates/jp_cli/src/cmd/query.rs b/crates/jp_cli/src/cmd/query.rs index 6603ed135..d6790f954 100644 --- a/crates/jp_cli/src/cmd/query.rs +++ b/crates/jp_cli/src/cmd/query.rs @@ -1190,8 +1190,6 @@ impl Query { &model, cfg, signals, - mcp_client, - &root, interactive, attachments, lock, diff --git a/crates/jp_cli/src/cmd/query/tool/coordinator.rs b/crates/jp_cli/src/cmd/query/tool/coordinator.rs index 8506a528c..cb6720dbf 100644 --- a/crates/jp_cli/src/cmd/query/tool/coordinator.rs +++ b/crates/jp_cli/src/cmd/query/tool/coordinator.rs @@ -82,7 +82,6 @@ use std::{ sync::Arc, }; -use camino::{Utf8Path, Utf8PathBuf}; use indexmap::IndexMap; use inquire::error::InquireError; use jp_config::{ @@ -95,12 +94,12 @@ use jp_conversation::{ ConversationStream, event::{ CancellationReason, InquiryAnswerType, InquiryId, InquiryQuestion, InquiryRequest, - InquiryResponse, SelectOption, ToolCallRequest, ToolCallResponse, + InquiryResponse, InquirySource, SelectOption, ToolCallRequest, ToolCallResponse, }, }; use jp_editor::EditorBackend; use jp_inquire::{ReplyEditMode, prompt::PromptBackend}; -use jp_mcp::{Client, server::StderrSink}; +use jp_mcp::server::StderrSink; use jp_printer::Printer; use jp_tool::{AnswerType, Question}; use jp_workspace::ConversationMut; @@ -287,6 +286,18 @@ enum PendingPrompt { }, } +/// A question one tool asked, and what routing it needs to know. +/// +/// The four travel together because answering needs all of them: the call to +/// resume, the name its configuration is keyed on, the question itself, and the +/// provenance the recorded `InquiryRequest` carries. +struct ToolQuestion { + tool_id: String, + tool_name: String, + question: Question, + source: InquirySource, +} + /// What rendering a tool call before its approval prompt produced. #[derive(Debug)] enum PreRender { @@ -650,24 +661,30 @@ impl ToolCoordinator { let ParametersStyle::Custom(_) = self.parameter_style(name) else { return self.render_approved_tool(name, executor.arguments(), renderer); }; - // No formatter output means the service was never asked for it, so the - // call header is all there is to show. - let formatted = executor - .formatted_arguments() - .cloned() - .unwrap_or_else(|| Ok(String::new())); - renderer.render_custom_result(name, formatted.map_err(|error| error.to_string())) + let Some(formatted) = executor.formatted_arguments() else { + // The service formats a call's arguments before releasing it, + // unless the call is hidden or configured not to run. Both of + // those are already handled, so no output here means there is no + // call to announce: a bare header would say otherwise. + return RenderOutcome::Rendered { content: None }; + }; + renderer.render_custom_result(name, formatted.clone().map_err(|error| error.to_string())) } /// Acknowledge the execution service after the conversation owner flushes. /// /// Until this runs, each call is still parked on its final barrier and its /// MCP response has not been returned to the caller. + /// Every call is acknowledged even when one fails, so one call's + /// disagreement does not strand the rest; the first failure is returned. pub async fn acknowledge_reviews(&self, reviews: Vec) -> Result<(), ExecutorError> { + let mut failure = None; for review in reviews { - self.executor_source.acknowledge(review).await?; + if let Err(error) = self.executor_source.acknowledge(review).await { + failure.get_or_insert(error); + } } - Ok(()) + failure.map_or(Ok(()), Err) } pub fn question_target(&self, tool_name: &str, question_id: &str) -> Option { @@ -984,8 +1001,6 @@ impl ToolCoordinator { edit_mode: ReplyEditMode, inquiry_backend: Arc, conv: &ConversationMut, - mcp_client: &Client, - root: &Utf8Path, tool_renderer: &mut ToolRenderer, interactive: bool, ) -> ExecutionResult { @@ -1066,8 +1081,6 @@ impl ToolCoordinator { index, executor, accumulated_answers, - mcp_client.clone(), - root.to_path_buf(), cancellation_token.child_token(), event_tx.clone(), stderr, @@ -1113,8 +1126,6 @@ impl ToolCoordinator { prompter.clone(), &inquiry_backend, conv, - mcp_client, - root, &cancellation_token, event_tx.clone(), turn_state, @@ -1141,8 +1152,6 @@ impl ToolCoordinator { &mut pending_prompts, &mut prompt_active, prompter.clone(), - mcp_client, - root, &cancellation_token, event_tx.clone(), conv, @@ -1168,8 +1177,6 @@ impl ToolCoordinator { index, tool.executor.clone(), tool.accumulated_answers.clone(), - mcp_client.clone(), - root.to_path_buf(), cancellation_token.child_token(), event_tx.clone(), tool.stderr.clone(), @@ -1232,32 +1239,9 @@ impl ToolCoordinator { prompt_active = false; let tool_name = executing_tools .get(&index) - .map(|t| t.tool_name.clone()) - .unwrap_or_default(); - let is_error = review.response.result.is_err(); - let (inline_results, results_file_link) = self - .tools_config - .get(&tool_name) - .map(|c| { - ( - c.style().inline_results(is_error).clone(), - c.style().results_file_link(is_error).clone(), - ) - }) + .map(|tool| tool.tool_name.clone()) .unwrap_or_default(); - - let is_hidden = self - .tools_config - .get(&tool_name) - .is_some_and(|cfg| cfg.style().hidden); - if !is_hidden { - tool_renderer.render_result( - &review.response, - &inline_results, - &results_file_link, - ); - } - + self.render_result(&tool_name, &review.response, tool_renderer); self.set_tool_state(&tool_id, ToolCallState::Completed); results[index] = Some(review); self.process_next_prompt( @@ -1409,16 +1393,12 @@ impl ToolCoordinator { index: usize, executor: Arc, answers: IndexMap, - client: Client, - root: Utf8PathBuf, token: CancellationToken, tx: mpsc::Sender, stderr: Option, ) { tokio::spawn(async move { - let result = executor - .execute(&answers, &client, &root, token, stderr) - .await; + let result = executor.execute(&answers, token, stderr).await; let _err = tx.send(ExecutionEvent::ToolResult { index, result }).await; }); } @@ -1547,8 +1527,41 @@ impl ToolCoordinator { }); } + /// Show a finished call's result, unless the tool renders no chrome. + fn render_result(&self, tool_name: &str, response: &ToolCallResponse, renderer: &ToolRenderer) { + if self.is_hidden(tool_name) { + return; + } + let style = self.tools_config.get(tool_name); + let is_error = response.result.is_err(); + let (inline_results, results_file_link) = style + .map(|config| { + ( + config.style().inline_results(is_error).clone(), + config.style().results_file_link(is_error).clone(), + ) + }) + .unwrap_or_default(); + renderer.render_result(response, &inline_results, &results_file_link); + } + + /// Show a call's result and record it as the content the Host settled on. + /// + /// This is the path for a result nobody was asked about: either the tool is + /// configured to deliver unattended, or there is no user to ask. + fn finish_tool_call( + &mut self, + tool: &ExecutingTool, + response: ToolCallResponse, + tracked_review: &mut Option, + tool_renderer: &ToolRenderer, + ) { + self.render_result(&tool.tool_name, &response, tool_renderer); + self.set_tool_state(&tool.tool_id, ToolCallState::Completed); + *tracked_review = Some(Review::unchanged(response)); + } + #[allow(clippy::too_many_arguments)] - #[allow(clippy::too_many_lines)] fn handle_tool_result( &mut self, result: ExecutorResult, @@ -1560,8 +1573,6 @@ impl ToolCoordinator { prompter: Arc, inquiry_backend: &Arc, conv: &ConversationMut, - mcp_client: &Client, - root: &Utf8Path, cancellation_token: &CancellationToken, event_tx: mpsc::Sender, turn_state: &mut TurnState, @@ -1570,89 +1581,70 @@ impl ToolCoordinator { ) { match result { ExecutorResult::Completed(response) => { - let is_error = response.result.is_err(); - let (inline_results, results_file_link) = self - .tools_config - .get(&tool.tool_name) - .map(|c| { - ( - c.style().inline_results(is_error).clone(), - c.style().results_file_link(is_error).clone(), - ) - }) - .unwrap_or_default(); - match self.result_mode(&tool.tool_name) { ResultMode::Unattended => { - let is_hidden = self - .tools_config - .get(&tool.tool_name) - .is_some_and(|cfg| cfg.style().hidden); - if !is_hidden { - tool_renderer.render_result( - &response, - &inline_results, - &results_file_link, - ); - } - self.set_tool_state(&tool.tool_id, ToolCallState::Completed); - *tracked_review = Some(Review::unchanged(response)); + self.finish_tool_call(tool, response, tracked_review, tool_renderer); } // The execution service applies `result = "skip"` itself, // so this response is already its skip message rather than - // the tool's output, and recording it replaces nothing. + // the tool's output. Rendering it would announce a result + // the configuration asked not to deliver. ResultMode::Skip => { self.set_tool_state(&tool.tool_id, ToolCallState::Completed); *tracked_review = Some(Review::unchanged(response)); } - result_mode @ (ResultMode::Ask | ResultMode::Edit) => { - // Both Ask and Edit prompt whenever a user is there to - // answer: the Edit flow uses the inline widget, which - // does not need a configured editor. - let can_prompt = interactive; - if can_prompt { - if *prompt_active { - pending_prompts.push_back(PendingPrompt::ResultMode { - index, - tool_id: tool.tool_id.clone(), - tool_name: tool.tool_name.clone(), - response, - result_mode, - }); - } else { - *prompt_active = true; - self.set_tool_state( - &tool.tool_id, - ToolCallState::AwaitingResultEdit, - ); - Self::spawn_result_mode_prompt( - index, - tool.tool_id.clone(), - tool.tool_name.clone(), - response, - result_mode, - prompter, - event_tx, - ); - } + // Nobody is there to answer, so the configured prompt is + // skipped and the result stands as the tool produced it. + ResultMode::Ask | ResultMode::Edit if !interactive => { + self.finish_tool_call(tool, response, tracked_review, tool_renderer); + } + // Both Ask and Edit prompt whenever a user is there to + // answer: the Edit flow uses the inline widget, which does + // not need a configured editor. + result_mode => { + if *prompt_active { + pending_prompts.push_back(PendingPrompt::ResultMode { + index, + tool_id: tool.tool_id.clone(), + tool_name: tool.tool_name.clone(), + response, + result_mode, + }); } else { - let is_hidden = self - .tools_config - .get(&tool.tool_name) - .is_some_and(|cfg| cfg.style().hidden); - if !is_hidden { - tool_renderer.render_result( - &response, - &inline_results, - &results_file_link, - ); - } - self.set_tool_state(&tool.tool_id, ToolCallState::Completed); - *tracked_review = Some(Review::unchanged(response)); + *prompt_active = true; + self.set_tool_state(&tool.tool_id, ToolCallState::AwaitingResultEdit); + Self::spawn_result_mode_prompt( + index, + tool.tool_id.clone(), + tool.tool_name.clone(), + response, + result_mode, + prompter, + event_tx, + ); } } } } + ExecutorResult::Failed(error) => { + // Nothing ran, and the reason is JP's rather than the tool's. + // The user gets the detail; the model gets only the fact that + // the call did not happen, so it can decide to retry. + warn!( + %error, + tool = %tool.tool_name, + "Tool call could not be completed." + ); + self.set_tool_state(&tool.tool_id, ToolCallState::Completed); + *tracked_review = Some(Review::replaced(ToolCallResponse { + id: tool.tool_id.clone(), + result: Err(format!( + "Tool '{}' was not executed: JP could not complete the call. You may \ + retry it.", + tool.tool_name + )), + })); + } ExecutorResult::NeedsInput { tool_id, tool_name, @@ -1660,156 +1652,194 @@ impl ToolCoordinator { source, accumulated_answers, } => { - tool.accumulated_answers = accumulated_answers.clone(); - - // Allocate the inquiry ID (incrementing the per-turn attempt - // counter) and record the `InquiryRequest` before any routing - // decision, so every question round-trip lands on the stream - // regardless of how it is answered. - let attempt = turn_state.next_inquiry_attempt(&tool_id, question.id.as_str()); - let inquiry_id = InquiryId::new(inquiry::tool_call_inquiry_id( - &tool_id, - question.id.as_str(), - attempt, - )); - let inquiry_question = tool_question_to_inquiry_question(&question); - conv.update_events(|events| { - events - .current_turn_mut() - .add_inquiry_request(InquiryRequest::new( - inquiry_id.clone(), - source, - inquiry_question, - )) - .build() - .expect("Invalid ConversationStream state"); - }); + tool.accumulated_answers = accumulated_answers; + self.route_tool_question( + ToolQuestion { + tool_id, + tool_name, + question, + source, + }, + tool, + index, + tracked_review, + pending_prompts, + prompt_active, + &prompter, + inquiry_backend, + conv, + cancellation_token, + event_tx, + turn_state, + interactive, + ); + } + } + } - let is_secret = question.answer_type == AnswerType::Secret; + /// Decide who answers a tool's question, and set that in motion. + /// + /// The `InquiryRequest` is recorded before any routing decision, so every + /// question round-trip lands on the stream however it is answered. + /// A question answered from the turn cache or from configuration resumes + /// the tool here; anything else hands off to a prompt or to the assistant + /// and resumes on a later event. + #[allow(clippy::too_many_arguments)] + #[allow(clippy::too_many_lines)] + fn route_tool_question( + &mut self, + question: ToolQuestion, + tool: &mut ExecutingTool, + index: usize, + tracked_review: &mut Option, + pending_prompts: &mut VecDeque, + prompt_active: &mut bool, + prompter: &Arc, + inquiry_backend: &Arc, + conv: &ConversationMut, + cancellation_token: &CancellationToken, + event_tx: mpsc::Sender, + turn_state: &mut TurnState, + interactive: bool, + ) { + let ToolQuestion { + tool_id, + tool_name, + question, + source, + } = question; + // Allocate the inquiry ID, incrementing the per-turn attempt counter. + let attempt = turn_state.next_inquiry_attempt(&tool_id, question.id.as_str()); + let inquiry_id = InquiryId::new(inquiry::tool_call_inquiry_id( + &tool_id, + question.id.as_str(), + attempt, + )); + let inquiry_question = tool_question_to_inquiry_question(&question); + conv.update_events(|events| { + events + .current_turn_mut() + .add_inquiry_request(InquiryRequest::new( + inquiry_id.clone(), + source, + inquiry_question, + )) + .build() + .expect("Invalid ConversationStream state"); + }); - // Secrets never enter or read the turn-answer cache. - if !is_secret { - let answer_key = ToolAnswerCacheKey::new(&tool_name, question.id.as_str()); - let persisted_answer = - turn_state.remembered_tool_answers.get(&answer_key).cloned(); - if let Some(answer) = persisted_answer { - Self::record_inquiry_answer(conv, &inquiry_id, &answer); - tool.accumulated_answers - .insert(question.id.to_string(), answer); - Self::spawn_tool_execution( - index, - tool.executor.clone(), - tool.accumulated_answers.clone(), - mcp_client.clone(), - root.to_path_buf(), - cancellation_token.clone(), - event_tx, - tool.stderr.clone(), - ); - return; - } - } + let is_secret = question.answer_type == AnswerType::Secret; + + // Secrets never enter or read the turn-answer cache. + if !is_secret { + let answer_key = ToolAnswerCacheKey::new(&tool_name, question.id.as_str()); + let persisted_answer = turn_state.remembered_tool_answers.get(&answer_key).cloned(); + if let Some(answer) = persisted_answer { + Self::record_inquiry_answer(conv, &inquiry_id, &answer); + tool.accumulated_answers + .insert(question.id.to_string(), answer); + Self::spawn_tool_execution( + index, + tool.executor.clone(), + tool.accumulated_answers.clone(), + cancellation_token.clone(), + event_tx, + tool.stderr.clone(), + ); + return; + } + } - if let Some(answer) = self.static_answer(&tool_name, question.id.as_str()) { - // The tool still receives the configured value in-memory; - // only the persisted record is redacted for secrets. - if is_secret { - Self::record_inquiry_redacted(conv, &inquiry_id); - } else { - Self::record_inquiry_answer(conv, &inquiry_id, &answer); - } - tool.accumulated_answers - .insert(question.id.to_string(), answer); - Self::spawn_tool_execution( - index, - tool.executor.clone(), - tool.accumulated_answers.clone(), - mcp_client.clone(), - root.to_path_buf(), - cancellation_token.clone(), - event_tx, - tool.stderr.clone(), - ); - return; - } + if let Some(answer) = self.static_answer(&tool_name, question.id.as_str()) { + // The tool still receives the configured value in-memory; + // only the persisted record is redacted for secrets. + if is_secret { + Self::record_inquiry_redacted(conv, &inquiry_id); + } else { + Self::record_inquiry_answer(conv, &inquiry_id, &answer); + } + tool.accumulated_answers + .insert(question.id.to_string(), answer); + Self::spawn_tool_execution( + index, + tool.executor.clone(), + tool.accumulated_answers.clone(), + cancellation_token.clone(), + event_tx, + tool.stderr.clone(), + ); + return; + } - let target = self - .question_target(&tool_name, question.id.as_str()) - .unwrap_or(QuestionTarget::User); - - tracing::info!( - tool_name = %tool_name, - tool_id = %tool_id, - question_id = %question.id, - question_text = %question.text, - question_type = ?question.answer_type, - target = ?target, - interactive = interactive, - "Tool question received, routing to target", - ); + let target = self + .question_target(&tool_name, question.id.as_str()) + .unwrap_or(QuestionTarget::User); + + tracing::info!( + tool_name = %tool_name, + tool_id = %tool_id, + question_id = %question.id, + question_text = %question.text, + question_type = ?question.answer_type, + target = ?target, + interactive = interactive, + "Tool question received, routing to target", + ); - if interactive && target.is_user() { - if *prompt_active { - pending_prompts.push_back(PendingPrompt::Question { - index, - question, - inquiry_id, - }); - } else { - *prompt_active = true; - self.set_tool_state(&tool_id, ToolCallState::AwaitingInput); - Self::spawn_user_prompt( - index, - question, - inquiry_id, - prompter.clone(), - event_tx, - ); - } - } else if is_secret { - // A secret requires a human at an interactive prompt; it - // must never route to the inquiry backend. Fail the tool - // and close the recorded inquiry with the guard's reason. - let (reason, message) = if target.is_user() { - ( - CancellationReason::NoPromptBackend, - format!( - "The tool '{tool_name}' asked for a secret value, which requires \ - an interactive prompt, but no interactive terminal is available." - ), - ) - } else { - ( - CancellationReason::AssistantRoutingDenied, - format!( - "The tool '{tool_name}' asked for a secret value, which must be \ - entered by a human and cannot be routed to the assistant." - ), - ) - }; - Self::record_inquiry_cancelled(conv, &inquiry_id, reason); - self.set_tool_state(&tool_id, ToolCallState::Completed); - *tracked_review = Some(Review::replaced(ToolCallResponse { - id: tool_id.clone(), - result: Err(message), - })); - } else { - // The `InquiryRequest` is already recorded above; spawn the - // async inquiry on a cloned snapshot. - Self::spawn_inquiry( - index, - inquiry_id, - tool_id.clone(), - tool_name, - question, - Arc::clone(inquiry_backend), - conv.events().clone(), - cancellation_token.child_token(), - event_tx.clone(), - ); - self.set_tool_state(&tool_id, ToolCallState::AwaitingInput); - } + if interactive && target.is_user() { + if *prompt_active { + pending_prompts.push_back(PendingPrompt::Question { + index, + question, + inquiry_id, + }); + } else { + *prompt_active = true; + self.set_tool_state(&tool_id, ToolCallState::AwaitingInput); + Self::spawn_user_prompt(index, question, inquiry_id, prompter.clone(), event_tx); } + } else if is_secret { + // A secret requires a human at an interactive prompt; it must never + // route to the inquiry backend. Fail the tool and close the + // recorded inquiry with the guard's reason. + let (reason, message) = if target.is_user() { + ( + CancellationReason::NoPromptBackend, + format!( + "The tool '{tool_name}' asked for a secret value, which requires an \ + interactive prompt, but no interactive terminal is available." + ), + ) + } else { + ( + CancellationReason::AssistantRoutingDenied, + format!( + "The tool '{tool_name}' asked for a secret value, which must be entered \ + by a human and cannot be routed to the assistant." + ), + ) + }; + Self::record_inquiry_cancelled(conv, &inquiry_id, reason); + self.set_tool_state(&tool_id, ToolCallState::Completed); + *tracked_review = Some(Review::replaced(ToolCallResponse { + id: tool_id.clone(), + result: Err(message), + })); + } else { + // The `InquiryRequest` is already recorded above; spawn the + // async inquiry on a cloned snapshot. + Self::spawn_inquiry( + index, + inquiry_id, + tool_id.clone(), + tool_name, + question, + Arc::clone(inquiry_backend), + conv.events().clone(), + cancellation_token.child_token(), + event_tx.clone(), + ); + self.set_tool_state(&tool_id, ToolCallState::AwaitingInput); } } @@ -1826,8 +1856,6 @@ impl ToolCoordinator { pending_prompts: &mut VecDeque, prompt_active: &mut bool, prompter: Arc, - mcp_client: &Client, - root: &Utf8Path, cancellation_token: &CancellationToken, event_tx: mpsc::Sender, conv: &ConversationMut, @@ -1857,8 +1885,6 @@ impl ToolCoordinator { index, tool.executor.clone(), tool.accumulated_answers.clone(), - mcp_client.clone(), - root.to_path_buf(), cancellation_token.clone(), event_tx.clone(), tool.stderr.clone(), diff --git a/crates/jp_cli/src/cmd/query/tool/coordinator_tests.rs b/crates/jp_cli/src/cmd/query/tool/coordinator_tests.rs index d542ff0d3..8249ca3b1 100644 --- a/crates/jp_cli/src/cmd/query/tool/coordinator_tests.rs +++ b/crates/jp_cli/src/cmd/query/tool/coordinator_tests.rs @@ -427,8 +427,6 @@ impl Executor for EditableExecutor { async fn execute( &self, _answers: &IndexMap, - _mcp_client: &jp_mcp::Client, - _root: &camino::Utf8Path, _cancellation_token: tokio_util::sync::CancellationToken, _stderr: Option, ) -> ExecutorResult { diff --git a/crates/jp_cli/src/cmd/query/tool/executor.rs b/crates/jp_cli/src/cmd/query/tool/executor.rs index a55d0ba76..a23142457 100644 --- a/crates/jp_cli/src/cmd/query/tool/executor.rs +++ b/crates/jp_cli/src/cmd/query/tool/executor.rs @@ -9,15 +9,11 @@ //! Execution itself lives in `jp_mcp::server`; nothing here runs a tool. use async_trait::async_trait; -use camino::Utf8Path; use futures::future::BoxFuture; use indexmap::IndexMap; use jp_config::conversation::tool::{RunMode, ToolConfigWithDefaults, ToolSource}; use jp_conversation::event::{InquirySource, ToolCallRequest, ToolCallResponse}; -use jp_mcp::{ - Client, - server::{StderrSink, service::Formatted}, -}; +use jp_mcp::server::{StderrSink, service::Formatted}; use jp_tool::{Question, ToolResult}; use serde_json::{Map, Value}; use tokio_util::sync::CancellationToken; @@ -110,8 +106,6 @@ pub(crate) trait Executor: Send + Sync { /// # Arguments /// /// - `answers` - Accumulated answers from previous `NeedsInput` responses - /// - `mcp_client` - MCP client for remote tool execution - /// - `root` - Project root directory /// - `cancellation_token` - Token to cancel execution /// - `stderr` - Receives the tool's stderr lines as they arrive, for a /// caller showing progress while it runs. @@ -120,8 +114,6 @@ pub(crate) trait Executor: Send + Sync { async fn execute( &self, answers: &IndexMap, - mcp_client: &Client, - root: &Utf8Path, cancellation_token: CancellationToken, stderr: Option, ) -> ExecutorResult; @@ -219,6 +211,13 @@ pub(crate) enum ExecutorResult { /// if the Host records this response without editing it. Completed(ToolCallResponse), + /// The call could not be advanced, and nothing ran. + /// + /// Distinct from a tool that ran and reported failure: the reason is JP's + /// own machinery, not the tool's, so it is not content for the model to + /// reason about. + Failed(ExecutorError), + /// Tool needs additional input before it can continue. /// /// The executor doesn't know who should answer - it just reports that input diff --git a/crates/jp_cli/src/cmd/query/tool/executor_error.rs b/crates/jp_cli/src/cmd/query/tool/executor_error.rs index 8f49844b9..5760b15df 100644 --- a/crates/jp_cli/src/cmd/query/tool/executor_error.rs +++ b/crates/jp_cli/src/cmd/query/tool/executor_error.rs @@ -71,12 +71,17 @@ pub(crate) enum ExecutorError { /// The content the caller received differs from the content recorded. #[error("MCP response differs from the recorded response")] DeliveryMismatch, +} - /// The legacy inquiry interface accepts only textual choices. - #[error("Non-string inquiry choice")] - NonStringChoice, - - /// The legacy inquiry interface cannot present this schema. - #[error("Unsupported tool inquiry schema")] - UnsupportedInquirySchema, +impl ExecutorError { + /// Whether this belongs in the conversation as the call's outcome. + /// + /// A user stopping a tool is something that happened to the call, and the + /// model needs to know it. + /// Everything else here is the Host and the execution service failing to + /// agree, which is JP's problem to report to the user rather than the + /// model's to reason about. + pub(crate) fn is_call_outcome(&self) -> bool { + matches!(self, Self::Cancelled) + } } diff --git a/crates/jp_cli/src/cmd/query/tool/executor_mock.rs b/crates/jp_cli/src/cmd/query/tool/executor_mock.rs index 34714443b..358e41cfa 100644 --- a/crates/jp_cli/src/cmd/query/tool/executor_mock.rs +++ b/crates/jp_cli/src/cmd/query/tool/executor_mock.rs @@ -3,11 +3,10 @@ use std::{collections::HashMap, sync::Mutex}; use async_trait::async_trait; -use camino::Utf8Path; use indexmap::IndexMap; use jp_config::conversation::tool::ToolConfigWithDefaults; use jp_conversation::event::{ToolCallRequest, ToolCallResponse}; -use jp_mcp::{Client, server::StderrSink}; +use jp_mcp::server::StderrSink; use jp_tool::{ToolDefinition, ToolDocs}; use serde_json::{Map, Value, json}; use tokio_util::sync::CancellationToken; @@ -99,8 +98,6 @@ impl Executor for MockExecutor { async fn execute( &self, _answers: &IndexMap, - _mcp_client: &Client, - _root: &Utf8Path, _cancellation_token: CancellationToken, _stderr: Option, ) -> ExecutorResult { diff --git a/crates/jp_cli/src/cmd/query/tool/mcp_executor.rs b/crates/jp_cli/src/cmd/query/tool/mcp_executor.rs index da5008185..0574c2d7b 100644 --- a/crates/jp_cli/src/cmd/query/tool/mcp_executor.rs +++ b/crates/jp_cli/src/cmd/query/tool/mcp_executor.rs @@ -14,7 +14,7 @@ use std::{ }; use async_trait::async_trait; -use camino::{Utf8Path, Utf8PathBuf}; +use camino::Utf8PathBuf; use futures::future::BoxFuture; use indexmap::IndexMap; use jp_config::conversation::tool::{RunMode, ToolConfigWithDefaults, ToolsConfig}; @@ -32,9 +32,7 @@ use jp_mcp::{ }, }, }; -use jp_tool::{ - AnswerType, ContentBlock, InputRequest, Question, QuestionId, ToolDefinition, ToolResult, -}; +use jp_tool::{ContentBlock, InputRequest, Question, QuestionId, ToolDefinition, ToolResult}; use rand::random; use rmcp::{ Peer, ServiceError as McpCallError, @@ -100,6 +98,18 @@ struct CallSlot { stderr: SyncMutex>, /// The call's protocol phase and the Host reply it is parked on. + /// + /// Held for the whole of every operation that advances the call, including + /// across the await on the service, so two operations on one call are + /// serialised rather than interleaved. + /// Ordering is not left to that serialisation: [`Phase`] is what makes an + /// operation arriving out of turn an [`ExecutorError::OutOfOrder`] instead + /// of a silently wrong reply. + /// + /// The cost is that acknowledging a call while it is still executing waits + /// for the execution attempt to finish. + /// The coordinator never does that, because it acknowledges only after the + /// conversation has the response. state: Mutex, } @@ -609,8 +619,8 @@ impl Executor for ToolExecutor { state.phase = Phase::Admission(reply); return Ok(None); } - Interaction::Record { result, reply, .. } => { - let response = response(&self.slot.request.id, &result); + Interaction::Record { recording, reply } => { + let response = response(&self.slot.request.id, &recording.result); state.phase = Phase::Record(reply); return Ok(Some(response)); } @@ -669,8 +679,6 @@ impl Executor for ToolExecutor { async fn execute( &self, answers: &IndexMap, - _: &Client, - _: &Utf8Path, cancellation: CancellationToken, stderr: Option, ) -> ExecutorResult { @@ -714,7 +722,7 @@ impl Executor for ToolExecutor { answers, reply, } => { - let question = question(request, &supporting)?; + let question = question(request, &supporting); state.phase = Phase::Input { id: question.id.clone(), reply, @@ -735,8 +743,8 @@ impl Executor for ToolExecutor { }; Ok(ExecutorResult::Completed(offered)) } - Interaction::Record { result, reply, .. } => { - let response = response(id, &result); + Interaction::Record { recording, reply } => { + let response = response(id, &recording.result); state.phase = Phase::Record(reply); Ok(ExecutorResult::Completed(response)) } @@ -755,46 +763,31 @@ impl Executor for ToolExecutor { // than leaving it parked on a reply that will never arrive. self.cancel_invocation(); state.phase = Phase::Finished; - ExecutorResult::Completed(ToolCallResponse { - id: self.slot.request.id.clone(), - result: Err(error.to_string()), - }) + if error.is_call_outcome() { + return ExecutorResult::Completed(ToolCallResponse { + id: self.slot.request.id.clone(), + result: Err(error.to_string()), + }); + } + ExecutorResult::Failed(error) }) } } /// Render a shared input request as the question the terminal prompts with. -fn question(request: InputRequest, supporting: &[ContentBlock]) -> Result { - let answer_type = if request.secret { - AnswerType::Secret - } else if request.schema.get("type").and_then(Value::as_str) == Some("boolean") { - AnswerType::Boolean - } else if let Some(options) = request.schema.get("enum").and_then(Value::as_array) { - AnswerType::Select { - options: options - .iter() - .map(|value| { - value - .as_str() - .map(str::to_owned) - .ok_or(ExecutorError::NonStringChoice) - }) - .collect::>()?, - } - } else if request.schema.get("type").and_then(Value::as_str) == Some("string") { - AnswerType::Text - } else { - return Err(ExecutorError::UnsupportedInquirySchema); - }; +/// +/// The supporting blocks are the content the tool emitted before its request; +/// the terminal shows them above the prompt. +fn question(request: InputRequest, supporting: &[ContentBlock]) -> Question { let preamble = supporting .iter() .filter_map(ContentBlock::as_text) .collect::>() .join("\n\n"); - let mut question = Question::new(request.id, request.label, answer_type); + let mut question = Question::new(request.id, request.label, request.answer_type); question.pre_amble = (!preamble.is_empty()).then_some(preamble); question.default = request.default; - Ok(question) + question } #[cfg(test)] diff --git a/crates/jp_cli/src/cmd/query/tool/mcp_executor_tests.rs b/crates/jp_cli/src/cmd/query/tool/mcp_executor_tests.rs index 79ef07c13..491019cdc 100644 --- a/crates/jp_cli/src/cmd/query/tool/mcp_executor_tests.rs +++ b/crates/jp_cli/src/cmd/query/tool/mcp_executor_tests.rs @@ -147,13 +147,7 @@ async fn one_call_spans_input_and_recording() { assert_eq!(fixture.attempts(), 0, "approval alone must not execute"); let first = executor - .execute( - &IndexMap::new(), - &Client::default(), - "/tmp".into(), - CancellationToken::new(), - None, - ) + .execute(&IndexMap::new(), CancellationToken::new(), None) .await; let ExecutorResult::NeedsInput { question, .. } = first else { panic!("expected the tool's question, got {first:?}") @@ -164,8 +158,6 @@ async fn one_call_spans_input_and_recording() { let second = executor .execute( &IndexMap::from_iter([("confirm".into(), json!(true))]), - &Client::default(), - "/tmp".into(), CancellationToken::new(), None, ) @@ -258,21 +250,13 @@ async fn an_unedited_review_reaches_the_service_through_a_real_call() { executor.approve().await.unwrap(); let first = executor - .execute( - &IndexMap::new(), - &Client::default(), - "/tmp".into(), - CancellationToken::new(), - None, - ) + .execute(&IndexMap::new(), CancellationToken::new(), None) .await; assert!(matches!(first, ExecutorResult::NeedsInput { .. })); let second = executor .execute( &IndexMap::from_iter([("confirm".into(), json!(true))]), - &Client::default(), - "/tmp".into(), CancellationToken::new(), None, ) @@ -336,13 +320,7 @@ async fn a_declined_inquiry_finishes_without_another_attempt() { executor.approve().await.unwrap(); let result = executor - .execute( - &IndexMap::new(), - &Client::default(), - "/tmp".into(), - CancellationToken::new(), - None, - ) + .execute(&IndexMap::new(), CancellationToken::new(), None) .await; assert!(matches!(result, ExecutorResult::NeedsInput { .. })); assert_eq!(fixture.attempts(), 1); @@ -369,15 +347,7 @@ async fn cancellation_before_release_does_not_execute() { let token = CancellationToken::new(); token.cancel(); - let result = executor - .execute( - &IndexMap::new(), - &Client::default(), - "/tmp".into(), - token, - None, - ) - .await; + let result = executor.execute(&IndexMap::new(), token, None).await; let ExecutorResult::Completed(response) = result else { panic!("expected a cancelled response, got {result:?}") @@ -392,6 +362,29 @@ async fn cancellation_before_release_does_not_execute() { fixture.shutdown().await; } +#[tokio::test] +async fn a_protocol_failure_is_reported_as_a_failure_not_as_tool_output() { + // Executing before the call is released puts the adapter and the service + // out of step. That is JP's problem, so it must not arrive as a tool + // result the model reads as "the tool said this". + let fixture = Fixture::inquiring("unattended").await; + let executor = fixture.executor(&json!({})); + + let result = executor + .execute(&IndexMap::new(), CancellationToken::new(), None) + .await; + + let ExecutorResult::Failed(error) = result else { + panic!("a protocol failure must not be a tool result, got {result:?}") + }; + assert_eq!( + error.to_string(), + "MCP call cannot execute while not yet submitted" + ); + assert_eq!(fixture.attempts(), 0); + fixture.shutdown().await; +} + #[tokio::test] async fn preparing_a_call_twice_is_refused() { let fixture = Fixture::inquiring("unattended").await; diff --git a/crates/jp_cli/src/cmd/query/turn_loop.rs b/crates/jp_cli/src/cmd/query/turn_loop.rs index 32aec7082..1b8a00a5f 100644 --- a/crates/jp_cli/src/cmd/query/turn_loop.rs +++ b/crates/jp_cli/src/cmd/query/turn_loop.rs @@ -10,7 +10,6 @@ use std::{ time::Duration, }; -use camino::Utf8Path; use futures::{ Stream, StreamExt as _, future, stream::{self, SelectAll}, @@ -176,8 +175,6 @@ pub(super) async fn run_turn_loop( model: &ModelDetails, cfg: &AppConfig, signals: &SignalRouter, - mcp_client: &jp_mcp::Client, - root: &Utf8Path, interactive: bool, attachments: &[Attachment], lock: &ConversationLock, @@ -869,8 +866,6 @@ pub(super) async fn run_turn_loop( reply_edit_mode(cfg.editor.inline.edit_mode), Arc::clone(&inquiry_backend), &conv, - mcp_client, - root, &mut tool_renderer, interactive, ) @@ -1188,9 +1183,14 @@ async fn commit_tool_responses( conv.flush()?; // Only now does each call's MCP response reach its caller: the service // holds every result until the conversation has it on disk. - tool.acknowledge_reviews(reviews) - .await - .map_err(Error::McpRecording)?; + // + // The conversation is already written at this point, so a failure here is + // the Host and the execution service disagreeing about a call that, from + // the user's side, succeeded. Ending the turn over it would discard work + // that is on disk and about to be answered. + if let Err(error) = tool.acknowledge_reviews(reviews).await { + warn!(%error, "Could not acknowledge a recorded tool response."); + } Ok(matches!(action, Action::SendFollowUp)) } diff --git a/crates/jp_cli/src/cmd/query/turn_loop_tests.rs b/crates/jp_cli/src/cmd/query/turn_loop_tests.rs index 9b817f89c..75dc969d7 100644 --- a/crates/jp_cli/src/cmd/query/turn_loop_tests.rs +++ b/crates/jp_cli/src/cmd/query/turn_loop_tests.rs @@ -359,7 +359,6 @@ async fn test_interrupt_stop_during_streaming_persists_content() { let (printer, _out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let (router, signals) = test_router(); let router = Arc::new(router); @@ -385,8 +384,6 @@ async fn test_interrupt_stop_during_streaming_persists_content() { &model, &config, &router, - &mcp_client, - root, false, // interactive &[], // attachments &lock, @@ -459,7 +456,6 @@ async fn a_completed_block_is_persisted_before_the_turn_ends() { let (printer, _out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let (router, signals) = test_router(); let router = Arc::new(router); @@ -484,8 +480,6 @@ async fn a_completed_block_is_persisted_before_the_turn_ends() { &model, &config, &router, - &mcp_client, - root, false, // is_tty &[], // attachments &lock, @@ -558,7 +552,6 @@ async fn a_refusal_takes_back_content_it_had_persisted() { let (printer, _out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let (router, _signals) = test_router(); let router = Arc::new(router); @@ -567,8 +560,6 @@ async fn a_refusal_takes_back_content_it_had_persisted() { &model, &config, &router, - &mcp_client, - root, false, &[], &lock, @@ -626,7 +617,6 @@ async fn test_streaming_interrupt_menu_cancel_escalates() { let (printer, _out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let (router, signals) = test_router(); let router = Arc::new(router); @@ -653,8 +643,6 @@ async fn test_streaming_interrupt_menu_cancel_escalates() { &model, &config, &router, - &mcp_client, - root, false, // interactive &[], // attachments &lock, @@ -728,7 +716,6 @@ async fn test_normal_completion_persists_content() { let (printer, out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); run_turn_loop( @@ -736,8 +723,6 @@ async fn test_normal_completion_persists_content() { &model, &config, &router, - &mcp_client, - root, false, // interactive &[], &lock, @@ -810,7 +795,6 @@ async fn premature_stream_end_without_finished_returns_error() { let (printer, _out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); // Without the backstop the loop pends forever, so cap the whole run. @@ -821,8 +805,6 @@ async fn premature_stream_end_without_finished_returns_error() { &model, &config, &router, - &mcp_client, - root, false, // interactive &[], &lock, @@ -874,7 +856,6 @@ async fn premature_stream_end_exhausts_retry_budget() { let (printer, _out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); let result = timeout( @@ -884,8 +865,6 @@ async fn premature_stream_end_exhausts_retry_budget() { &model, &config, &router, - &mcp_client, - root, false, // interactive &[], &lock, @@ -953,7 +932,6 @@ async fn output_ceiling_ends_turn_without_re_requesting() { let (printer, _out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); let result = timeout( @@ -963,8 +941,6 @@ async fn output_ceiling_ends_turn_without_re_requesting() { &model, &config, &router, - &mcp_client, - root, false, // interactive &[], &lock, @@ -1059,7 +1035,6 @@ async fn orphan_tool_call_is_sanitized_before_provider_request() { let (printer, _out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); run_turn_loop( @@ -1067,8 +1042,6 @@ async fn orphan_tool_call_is_sanitized_before_provider_request() { &model, &config, &router, - &mcp_client, - root, false, // interactive &[], &lock, @@ -1143,7 +1116,6 @@ async fn test_tool_call_cycle_completes_with_followup() { let (printer, out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); let result = run_turn_loop( @@ -1151,8 +1123,6 @@ async fn test_tool_call_cycle_completes_with_followup() { &model, &config, &router, - &mcp_client, - root, false, // interactive &[], &lock, @@ -1254,8 +1224,6 @@ impl Executor for SleepingExecutor { async fn execute( &self, _answers: &IndexMap, - _mcp_client: &jp_mcp::Client, - _root: &Utf8Path, cancellation_token: CancellationToken, _stderr: Option, ) -> ExecutorResult { @@ -1414,7 +1382,6 @@ async fn test_tool_interrupt_menu_cancel_escalates() { let (printer, _out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let (router, signals) = test_router(); let router = Arc::new(router); @@ -1448,8 +1415,6 @@ async fn test_tool_interrupt_menu_cancel_escalates() { &model, &config, &router, - &mcp_client, - root, false, // interactive &[], &lock, @@ -1562,7 +1527,6 @@ async fn test_tool_stop_on_interrupt_commits_responses_without_follow_up() { let (printer, _out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let (router, signals) = test_router(); let router = Arc::new(router); @@ -1596,8 +1560,6 @@ async fn test_tool_stop_on_interrupt_commits_responses_without_follow_up() { &model, &config, &router, - &mcp_client, - root, false, // interactive &[], &lock, @@ -1705,7 +1667,6 @@ async fn test_interrupt_during_tool_prompt_completes_turn_early() { let (printer, _out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let (router, signals) = test_router(); let router = Arc::new(router); @@ -1740,8 +1701,6 @@ async fn test_interrupt_during_tool_prompt_completes_turn_early() { &model, &config, &router, - &mcp_client, - root, true, // interactive: user-targeted question prompts need a user &[], &lock, @@ -1843,7 +1802,6 @@ async fn test_multiple_tool_calls_in_sequence() { let (printer, out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); let result = run_turn_loop( @@ -1851,8 +1809,6 @@ async fn test_multiple_tool_calls_in_sequence() { &model, &config, &router, - &mcp_client, - root, false, // interactive &[], &lock, @@ -1933,7 +1889,6 @@ async fn test_empty_tool_response_continues_cycle() { let (printer, out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); let result = run_turn_loop( @@ -1941,8 +1896,6 @@ async fn test_empty_tool_response_continues_cycle() { &model, &config, &router, - &mcp_client, - root, false, // interactive &[], &lock, @@ -2042,7 +1995,6 @@ async fn test_tool_restart_on_interrupt() { let (printer, out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let (router, signals) = test_router(); let router = Arc::new(router); @@ -2086,8 +2038,6 @@ async fn test_tool_restart_on_interrupt() { &model, &config, &router, - &mcp_client, - root, false, // interactive &[], &lock, @@ -2197,7 +2147,6 @@ async fn test_merged_stream_exits_after_tool_response() { let (printer, out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); // No signals sent - the turn loop should complete naturally after @@ -2207,8 +2156,6 @@ async fn test_merged_stream_exits_after_tool_response() { &model, &config, &router, - &mcp_client, - root, false, // interactive &[], &lock, @@ -2307,7 +2254,6 @@ async fn test_tool_call_with_run_mode_ask_approves() { let (printer, out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); // Mock: user presses 'y' to approve @@ -2334,8 +2280,6 @@ async fn test_tool_call_with_run_mode_ask_approves() { &model, &config, &router, - &mcp_client, - root, true, // interactive = true to enable prompts &[], &lock, @@ -2450,7 +2394,6 @@ async fn test_tool_call_with_run_mode_ask_skips() { let (printer, out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); // Mock: user presses 'n' to skip @@ -2476,8 +2419,6 @@ async fn test_tool_call_with_run_mode_ask_skips() { &model, &config, &router, - &mcp_client, - root, true, // interactive &[], &lock, @@ -2604,7 +2545,6 @@ async fn test_permission_prompt_follows_interactive_not_is_tty() { let (printer, _out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); let backend = MockPromptBackend::new().with_inline_responses(['n']); @@ -2629,8 +2569,6 @@ async fn test_permission_prompt_follows_interactive_not_is_tty() { &model, &config, &router, - &mcp_client, - root, true, // interactive: the user is still at the terminal &[], &lock, @@ -2730,7 +2668,6 @@ async fn test_tool_call_with_run_mode_unattended() { let (printer, out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); // No prompt responses needed - tool runs without asking @@ -2752,8 +2689,6 @@ async fn test_tool_call_with_run_mode_unattended() { &model, &config, &router, - &mcp_client, - root, true, // interactive doesn't matter for Unattended &[], &lock, @@ -2868,7 +2803,6 @@ async fn test_tool_call_with_run_mode_skip() { let (printer, out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); // No prompt responses needed - tool is skipped automatically @@ -2899,8 +2833,6 @@ async fn test_tool_call_with_run_mode_skip() { &model, &config, &router, - &mcp_client, - root, true, // interactive &[], &lock, @@ -3068,7 +3000,6 @@ async fn test_multiple_tools_with_different_run_modes() { let (printer, out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); // User presses 'y' to approve the Ask tool @@ -3102,8 +3033,6 @@ async fn test_multiple_tools_with_different_run_modes() { &model, &config, &router, - &mcp_client, - root, true, // interactive &[], &lock, @@ -3230,7 +3159,6 @@ async fn test_tool_call_returns_error() { let (printer, out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); let backend = MockPromptBackend::new(); @@ -3250,8 +3178,6 @@ async fn test_tool_call_returns_error() { &model, &config, &router, - &mcp_client, - root, true, // interactive &[], &lock, @@ -3477,7 +3403,6 @@ async fn test_waiting_indicator_shows_during_delay() { let (printer, out, err) = Printer::memory(OutputFormat::TextPretty); // The status region only renders against a terminal it has to itself. let printer = Arc::new(printer.with_terminal(TerminalCapability::interactive(Some(80)))); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); run_turn_loop( @@ -3485,8 +3410,6 @@ async fn test_waiting_indicator_shows_during_delay() { &model, &config, &router, - &mcp_client, - root, false, // interactive &[], &lock, @@ -3577,7 +3500,6 @@ async fn test_waiting_indicator_survives_keep_alive_and_shows_status() { let (printer, out, err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer.with_terminal(TerminalCapability::interactive(Some(80)))); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); run_turn_loop( @@ -3585,8 +3507,6 @@ async fn test_waiting_indicator_survives_keep_alive_and_shows_status() { &model, &config, &router, - &mcp_client, - root, true, // interactive &[], &lock, @@ -3691,7 +3611,6 @@ async fn test_waiting_indicator_cleared_before_retry_notice() { let (printer, out, err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer.with_terminal(TerminalCapability::interactive(Some(80)))); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); run_turn_loop( @@ -3699,8 +3618,6 @@ async fn test_waiting_indicator_cleared_before_retry_notice() { &model, &config, &router, - &mcp_client, - root, true, // interactive &[], &lock, @@ -3778,7 +3695,6 @@ async fn test_waiting_indicator_not_shown_when_disabled() { // A terminal is available; `show = false` is what turns the indicator // off, so the region must stay inert on its own. let printer = Arc::new(printer.with_terminal(TerminalCapability::interactive(Some(80)))); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); run_turn_loop( @@ -3786,8 +3702,6 @@ async fn test_waiting_indicator_not_shown_when_disabled() { &model, &config, &router, - &mcp_client, - root, true, // interactive &[], &lock, @@ -3857,7 +3771,6 @@ async fn test_waiting_indicator_not_shown_for_non_tty() { // The default capability models a piped stderr. let (printer, _out, err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); run_turn_loop( @@ -3865,8 +3778,6 @@ async fn test_waiting_indicator_not_shown_for_non_tty() { &model, &config, &router, - &mcp_client, - root, true, // interactive &[], &lock, @@ -3937,7 +3848,6 @@ async fn test_waiting_indicator_follows_stderr_not_stdout() { // though stdout is a terminal. let (printer, _out, err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); run_turn_loop( @@ -3945,8 +3855,6 @@ async fn test_waiting_indicator_follows_stderr_not_stdout() { &model, &config, &router, - &mcp_client, - root, true, // interactive &[], &lock, @@ -4122,7 +4030,6 @@ async fn test_multi_part_tool_call_shows_preparing_spinner() { let (printer, out, err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer.with_terminal(terminal)); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); let result = run_turn_loop( @@ -4130,8 +4037,6 @@ async fn test_multi_part_tool_call_shows_preparing_spinner() { &model, &config, &router, - &mcp_client, - root, true, // interactive &[], &lock, @@ -4209,7 +4114,6 @@ async fn test_turn_start_event_is_emitted() { let (printer, _out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); run_turn_loop( @@ -4217,8 +4121,6 @@ async fn test_turn_start_event_is_emitted() { &model, &config, &router, - &mcp_client, - root, false, // interactive &[], &lock, @@ -4260,8 +4162,6 @@ async fn test_turn_start_index_increments_across_turns() { .create_and_lock_conversation(Conversation::default(), config.clone().into(), None) .unwrap(); - let mcp_client = jp_mcp::Client::default(); - // First turn. let chat_request = ChatRequest::from("First question"); @@ -4280,8 +4180,6 @@ async fn test_turn_start_index_increments_across_turns() { &model, &config, &router, - &mcp_client, - root, false, // interactive &[], &lock, @@ -4315,8 +4213,6 @@ async fn test_turn_start_index_increments_across_turns() { &model, &config, &router, - &mcp_client, - root, false, // interactive &[], &lock, @@ -4402,7 +4298,6 @@ async fn test_markdown_flushed_before_tool_header() { let (printer, out, err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer.with_terminal(terminal)); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); run_turn_loop( @@ -4410,8 +4305,6 @@ async fn test_markdown_flushed_before_tool_header() { &model, &config, &router, - &mcp_client, - root, true, // interactive &[], &lock, @@ -4573,7 +4466,6 @@ async fn test_parallel_tool_calls_rendered_atomically() { let (printer, _out, err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); let executor_source = TestExecutorSource::new() @@ -4596,8 +4488,6 @@ async fn test_parallel_tool_calls_rendered_atomically() { &model, &config, &router, - &mcp_client, - root, false, // interactive &[], &lock, @@ -4664,8 +4554,8 @@ async fn test_parallel_tool_calls_rendered_atomically() { /// Verifies that a single tool call uses "Calling tool" (singular), and that /// its header+arguments are rendered atomically. -#[tokio::test] #[expect(clippy::too_many_lines)] +#[tokio::test] async fn test_single_tool_call_rendered_with_args() { let test_result = Box::pin(timeout(Duration::from_secs(5), async { let tmp = tempdir().unwrap(); @@ -4739,7 +4629,6 @@ async fn test_single_tool_call_rendered_with_args() { let (printer, _out, err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); let executor_source = TestExecutorSource::new().with_executor("fs_read_file", |req| { @@ -4755,8 +4644,6 @@ async fn test_single_tool_call_rendered_with_args() { &model, &config, &router, - &mcp_client, - root, false, // interactive &[], &lock, @@ -4859,8 +4746,6 @@ impl Executor for TalkingExecutor { async fn execute( &self, _answers: &IndexMap, - _mcp_client: &jp_mcp::Client, - _root: &Utf8Path, _cancellation_token: CancellationToken, stderr: Option, ) -> ExecutorResult { @@ -4979,7 +4864,6 @@ async fn a_running_tools_stderr_reaches_the_progress_window() { let printer = Arc::new( printer.with_terminal(TerminalCapability::interactive(Some(80)).with_rows(Some(24))), ); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); let got_sink = Arc::new(AtomicBool::new(false)); @@ -5001,8 +4885,6 @@ async fn a_running_tools_stderr_reaches_the_progress_window() { &model, &config, &router, - &mcp_client, - root, false, // interactive &[], &lock, @@ -5064,7 +4946,6 @@ async fn parallel_tools_label_their_window_rows() { let printer = Arc::new( printer.with_terminal(TerminalCapability::interactive(Some(80)).with_rows(Some(24))), ); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); let seen = Arc::new(AtomicBool::new(false)); @@ -5098,8 +4979,6 @@ async fn parallel_tools_label_their_window_rows() { &model, &config, &router, - &mcp_client, - root, false, // interactive &[], &lock, @@ -5149,7 +5028,6 @@ async fn parallel_tools_label_their_window_rows() { /// region frame lands inside the link's line — not about the text being /// present at all. #[tokio::test(flavor = "multi_thread")] -#[expect(clippy::too_many_lines)] async fn a_tool_result_survives_a_live_window() { let test_result = Box::pin(timeout(Duration::from_secs(5), async { let tmp = tempdir().unwrap(); @@ -5190,7 +5068,6 @@ async fn a_tool_result_survives_a_live_window() { let printer = Arc::new( printer.with_terminal(TerminalCapability::interactive(Some(80)).with_rows(Some(24))), ); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); let seen = Arc::new(AtomicBool::new(false)); @@ -5220,8 +5097,6 @@ async fn a_tool_result_survives_a_live_window() { &model, &config, &router, - &mcp_client, - root, false, // interactive &[], &lock, @@ -5322,7 +5197,6 @@ async fn a_sink_survives_the_re_spawn_an_answer_triggers() { let printer = Arc::new( printer.with_terminal(TerminalCapability::interactive(Some(80)).with_rows(Some(24))), ); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); let executor_source = TestExecutorSource::new().with_executor("asking_tool", |req| { @@ -5335,8 +5209,6 @@ async fn a_sink_survives_the_re_spawn_an_answer_triggers() { &model, &config, &router, - &mcp_client, - root, false, // interactive &[], &lock, @@ -5374,8 +5246,8 @@ async fn a_sink_survives_the_re_spawn_an_answer_triggers() { /// Rows are screen space, so the window's size is global. /// Membership is not: `conversation.tools..style.print_stderr` keeps one /// noisy tool out without shrinking the window for everything else. -#[tokio::test(flavor = "multi_thread")] #[expect(clippy::too_many_lines)] +#[tokio::test(flavor = "multi_thread")] async fn a_tool_can_opt_out_of_the_progress_window() { let test_result = Box::pin(timeout(Duration::from_secs(5), async { let tmp = tempdir().unwrap(); @@ -5434,7 +5306,6 @@ async fn a_tool_can_opt_out_of_the_progress_window() { let printer = Arc::new( printer.with_terminal(TerminalCapability::interactive(Some(80)).with_rows(Some(24))), ); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); let loud_got_sink = Arc::new(AtomicBool::new(false)); @@ -5469,8 +5340,6 @@ async fn a_tool_can_opt_out_of_the_progress_window() { &model, &config, &router, - &mcp_client, - root, false, // interactive &[], &lock, @@ -5675,7 +5544,6 @@ async fn a_tool_prompt_hides_the_window_and_restores_it() { let printer = Arc::new( printer.with_terminal(TerminalCapability::interactive(Some(80)).with_rows(Some(24))), ); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); let prompts = Arc::new(ObservingPromptBackend::new( @@ -5693,8 +5561,6 @@ async fn a_tool_prompt_hides_the_window_and_restores_it() { &model, &config, &router, - &mcp_client, - root, true, // interactive: a user-targeted question needs a user &[], &lock, @@ -5796,8 +5662,6 @@ impl Executor for AskingTalkingExecutor { async fn execute( &self, answers: &IndexMap, - _mcp_client: &jp_mcp::Client, - _root: &Utf8Path, _cancellation_token: CancellationToken, stderr: Option, ) -> ExecutorResult { @@ -5870,8 +5734,6 @@ impl Executor for InquiryMockExecutor { async fn execute( &self, answers: &IndexMap, - _mcp_client: &jp_mcp::Client, - _root: &camino::Utf8Path, _cancellation_token: tokio_util::sync::CancellationToken, _stderr: Option, ) -> ExecutorResult { @@ -6155,8 +6017,8 @@ async fn inquiry_ceiling_honors_the_per_question_override() { /// Tool has one boolean question with `QuestionTarget::Assistant`. /// Flow: LLM tool call → `NeedsInput` → inquiry → answer → tool completes. -#[tokio::test] #[expect(clippy::too_many_lines)] +#[tokio::test] async fn test_tool_with_single_inquiry() { let test_result = Box::pin(timeout(Duration::from_secs(5), async { let tmp = tempdir().unwrap(); @@ -6199,7 +6061,6 @@ async fn test_tool_with_single_inquiry() { let (printer, out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); let executor_source = TestExecutorSource::new().with_executor("inquiry_tool", |req| { @@ -6217,8 +6078,6 @@ async fn test_tool_with_single_inquiry() { &model, &config, &router, - &mcp_client, - root, false, // interactive &[], &lock, @@ -6327,7 +6186,6 @@ async fn test_secret_question_without_tty_fails_tool() { let (printer, _out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); let executor_source = TestExecutorSource::new().with_executor("secret_tool", |req| { @@ -6345,8 +6203,6 @@ async fn test_secret_question_without_tty_fails_tool() { &model, &config, &router, - &mcp_client, - root, false, // interactive &[], &lock, @@ -6436,7 +6292,6 @@ async fn test_secret_question_with_assistant_target_fails_tool() { let (printer, _out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); let executor_source = TestExecutorSource::new().with_executor("secret_tool", |req| { @@ -6454,8 +6309,6 @@ async fn test_secret_question_with_assistant_target_fails_tool() { &model, &config, &router, - &mcp_client, - root, false, // interactive &[], &lock, @@ -6538,7 +6391,6 @@ async fn test_secret_prompter_answer_is_redacted() { let (printer, _out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); let executor_source = TestExecutorSource::new().with_executor("secret_tool", |req| { @@ -6556,8 +6408,6 @@ async fn test_secret_prompter_answer_is_redacted() { &model, &config, &router, - &mcp_client, - root, true, // interactive &[], &lock, @@ -6644,7 +6494,6 @@ async fn test_secret_static_answer_is_redacted() { let (printer, _out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); let executor_source = TestExecutorSource::new().with_executor("secret_tool", |req| { @@ -6662,8 +6511,6 @@ async fn test_secret_static_answer_is_redacted() { &model, &config, &router, - &mcp_client, - root, false, // interactive &[], &lock, @@ -6751,7 +6598,6 @@ async fn test_static_answer_records_answered_inquiry() { let (printer, _out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); let executor_source = TestExecutorSource::new().with_executor("static_tool", |req| { @@ -6769,8 +6615,6 @@ async fn test_static_answer_records_answered_inquiry() { &model, &config, &router, - &mcp_client, - root, false, // interactive &[], &lock, @@ -6861,7 +6705,6 @@ async fn test_remembered_answer_cache_hit_records_new_inquiry_pair() { let (printer, _out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); let executor_source = TestExecutorSource::new().with_executor("cached_tool", |req| { @@ -6884,8 +6727,6 @@ async fn test_remembered_answer_cache_hit_records_new_inquiry_pair() { &model, &config, &router, - &mcp_client, - root, true, // interactive &[], &lock, @@ -6985,7 +6826,6 @@ async fn test_tool_with_multiple_inquiries() { let (printer, out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); let executor_source = TestExecutorSource::new().with_executor("multi_q_tool", |req| { @@ -7006,8 +6846,6 @@ async fn test_tool_with_multiple_inquiries() { &model, &config, &router, - &mcp_client, - root, false, // interactive &[], &lock, @@ -7064,8 +6902,8 @@ async fn test_tool_with_multiple_inquiries() { /// Two parallel tools: one requires an inquiry, the other completes normally. /// The inquiry should not block the normal tool from completing. -#[tokio::test] #[expect(clippy::too_many_lines)] +#[tokio::test] async fn test_parallel_tools_one_with_inquiry() { let test_result = Box::pin(timeout(Duration::from_secs(5), async { let tmp = tempdir().unwrap(); @@ -7136,7 +6974,6 @@ async fn test_parallel_tools_one_with_inquiry() { let (printer, out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); let executor_source = TestExecutorSource::new() @@ -7158,8 +6995,6 @@ async fn test_parallel_tools_one_with_inquiry() { &model, &config, &router, - &mcp_client, - root, false, // interactive &[], &lock, @@ -7211,8 +7046,8 @@ async fn test_parallel_tools_one_with_inquiry() { /// Two parallel tools both requiring inquiries. /// Uses responses without `inquiry_id` since the concurrent inquiry call order /// is non-deterministic. -#[tokio::test] #[expect(clippy::too_many_lines)] +#[tokio::test] async fn test_parallel_tools_both_with_inquiries() { let test_result = Box::pin(timeout(Duration::from_secs(5), async { let tmp = tempdir().unwrap(); @@ -7269,7 +7104,6 @@ async fn test_parallel_tools_both_with_inquiries() { let (printer, out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); let executor_source = TestExecutorSource::new() @@ -7296,8 +7130,6 @@ async fn test_parallel_tools_both_with_inquiries() { &model, &config, &router, - &mcp_client, - root, false, // interactive &[], &lock, @@ -7438,7 +7270,6 @@ async fn test_retry_counter_resets_on_successful_event() { let (printer, _out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); let result = run_turn_loop( @@ -7446,8 +7277,6 @@ async fn test_retry_counter_resets_on_successful_event() { &model, &config, &router, - &mcp_client, - root, false, // interactive &[], &lock, @@ -7569,7 +7398,6 @@ async fn test_unavailable_tool_before_approved_does_not_panic() { let (printer, _out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); // Only `ok_tool` is registered with the executor source; the @@ -7585,8 +7413,6 @@ async fn test_unavailable_tool_before_approved_does_not_panic() { &model, &config, &router, - &mcp_client, - root, false, // interactive &[], &lock, @@ -7677,7 +7503,6 @@ async fn test_inquiry_failure_marks_tool_as_error() { let (printer, _out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); let executor_source = TestExecutorSource::new().with_executor("inquiry_tool", |req| { @@ -7695,8 +7520,6 @@ async fn test_inquiry_failure_marks_tool_as_error() { &model, &config, &router, - &mcp_client, - root, false, // interactive &[], &lock, @@ -7881,7 +7704,6 @@ async fn test_live_header_uses_configured_model_id_not_provider_returned() { // The live role header is chrome, so it lands on the error stream. let (printer, _out, err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); run_turn_loop( @@ -7889,8 +7711,6 @@ async fn test_live_header_uses_configured_model_id_not_provider_returned() { &model, &config, &router, - &mcp_client, - root, false, // interactive &[], &lock, @@ -7991,7 +7811,6 @@ async fn reasoning_before_a_tool_call_shades_the_tool_chrome() { let (printer, _out, err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); let executor_source = TestExecutorSource::new().with_executor("mock_tool", |req| { @@ -8004,8 +7823,6 @@ async fn reasoning_before_a_tool_call_shades_the_tool_chrome() { &model, &config, &router, - &mcp_client, - root, false, // interactive &[], &lock, @@ -8115,7 +7932,6 @@ async fn a_tool_that_does_not_join_reasoning_renders_unshaded_live() { let (printer, _out, err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); let executor_source = TestExecutorSource::new().with_executor("mock_tool", |req| { @@ -8128,8 +7944,8 @@ async fn a_tool_that_does_not_join_reasoning_renders_unshaded_live() { &model, &config, &router, - &mcp_client, root, + InvocationContext::default(), false, // interactive &[], &lock, @@ -8250,7 +8066,6 @@ async fn test_rebuild_cap_stops_a_provider_that_keeps_requesting_rebuilds() { let (printer, _out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let (router, _signals) = test_router(); let router = Arc::new(router); @@ -8259,8 +8074,6 @@ async fn test_rebuild_cap_stops_a_provider_that_keeps_requesting_rebuilds() { &model, &config, &router, - &mcp_client, - root, false, // interactive &[], &lock, @@ -8342,7 +8155,6 @@ async fn test_refused_rebuild_clears_the_retry_line() { // The notice only takes a status region on a terminal; elsewhere it is // a persistent line with nothing to retire. let printer = Arc::new(printer.with_terminal(TerminalCapability::interactive(Some(80)))); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); let result = run_turn_loop( @@ -8350,8 +8162,6 @@ async fn test_refused_rebuild_clears_the_retry_line() { &model, &config, &router, - &mcp_client, - root, true, // interactive &[], &lock, @@ -8425,7 +8235,6 @@ async fn test_refused_rebuild_persists_streamed_content() { let (printer, _out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let (router, _signals) = test_router(); let router = Arc::new(router); @@ -8434,8 +8243,6 @@ async fn test_refused_rebuild_persists_streamed_content() { &model, &config, &router, - &mcp_client, - root, false, // interactive &[], &lock, @@ -8537,8 +8344,6 @@ async fn http_tool_cycle_persists_inquiry_and_response_before_followup() { &model, &config, &router, - &client, - root, false, &[], &lock, diff --git a/crates/jp_cli/src/cmd/query_tests.rs b/crates/jp_cli/src/cmd/query_tests.rs index 12d6cb518..692fac833 100644 --- a/crates/jp_cli/src/cmd/query_tests.rs +++ b/crates/jp_cli/src/cmd/query_tests.rs @@ -372,7 +372,6 @@ async fn an_interrupt_during_mcp_startup_stops_the_turn_before_it_runs() { } async fn run_mock_turn( - root: &camino::Utf8Path, cfg: &AppConfig, lock: &jp_workspace::ConversationLock, prompt: &str, @@ -385,7 +384,6 @@ async fn run_mock_turn( .unwrap(); let (printer, _out, _err) = Printer::memory(OutputFormat::TextPretty); let printer = Arc::new(printer); - let mcp_client = jp_mcp::Client::default(); let router = detached_router(); turn_loop::run_turn_loop( @@ -393,8 +391,6 @@ async fn run_mock_turn( &model, cfg, &router, - &mcp_client, - root, false, // interactive &[], lock, @@ -1885,14 +1881,7 @@ async fn query_sequence_new_cfg_profile_then_model_override_persists_for_plain_q .create_and_lock_conversation(Conversation::default(), Arc::new(cfg1.clone()), None) .unwrap(); let conversation_id = lock1.id(); - run_mock_turn( - root, - &cfg1, - &lock1, - "is this thing on?", - "Yes, loud and clear.", - ) - .await; + run_mock_turn(&cfg1, &lock1, "is this thing on?", "Yes, loud and clear.").await; drop(lock1); let handle2 = workspace.acquire_conversation(&conversation_id).unwrap(); @@ -1909,7 +1898,7 @@ async fn query_sequence_new_cfg_profile_then_model_override_persists_for_plain_q lock2 .as_mut() .update_events(|events| events.add_config_delta(delta)); - run_mock_turn(root, &cfg2, &lock2, "are you there?", "Yes.").await; + run_mock_turn(&cfg2, &lock2, "are you there?", "Yes.").await; drop(lock2); let handle3 = workspace.acquire_conversation(&conversation_id).unwrap(); diff --git a/crates/jp_cli/src/error.rs b/crates/jp_cli/src/error.rs index 26e29e588..de406b803 100644 --- a/crates/jp_cli/src/error.rs +++ b/crates/jp_cli/src/error.rs @@ -5,7 +5,7 @@ use jp_conversation::ConversationId; use jp_mcp::server::http::EndpointError; use url::Url; -use crate::{cmd, cmd::query::tool::executor::ExecutorError}; +use crate::cmd; pub(crate) type Result = std::result::Result; @@ -69,9 +69,6 @@ pub(crate) enum Error { #[error(transparent)] McpEndpoint(#[from] EndpointError), - #[error("MCP Host recording failed: {0}")] - McpRecording(#[source] ExecutorError), - #[error("LLM error")] Llm(#[from] jp_llm::Error), diff --git a/crates/jp_mcp/src/client_protocol_tests.rs b/crates/jp_mcp/src/client_protocol_tests.rs index 48de205a6..d5133efec 100644 --- a/crates/jp_mcp/src/client_protocol_tests.rs +++ b/crates/jp_mcp/src/client_protocol_tests.rs @@ -316,20 +316,15 @@ async fn native_upstream_result_survives_host_projection_and_http_delivery() { ); reply.send(Ok(reviewed.clone())).unwrap(); - let Interaction::Record { - result: projected, - raw_result, - reply, - .. - } = host.recv().await.unwrap().interaction + let Interaction::Record { recording, reply } = host.recv().await.unwrap().interaction else { panic!("expected recording") }; - assert_eq!(projected, reviewed); - assert_eq!(raw_result, Some(reviewed)); + assert_eq!(recording.result, reviewed); + assert_eq!(recording.raw_result, Some(reviewed)); // The conversation stores only the text, which is what makes the // assertion below worth making. - assert_eq!(projected.to_text(), "alpha\n\nresource"); + assert_eq!(recording.result.to_text(), "alpha\n\nresource"); assert!(!reply.is_closed()); reply.send(Ok(())).unwrap(); diff --git a/crates/jp_mcp/src/server.rs b/crates/jp_mcp/src/server.rs index 4e9a5a429..be1ad6b15 100644 --- a/crates/jp_mcp/src/server.rs +++ b/crates/jp_mcp/src/server.rs @@ -614,14 +614,7 @@ fn parse_command_output(stdout: &[u8], stderr: &[u8], success: bool) -> CommandR // model to invent an explanation. Output that is not an `Outcome` at // all stays `RawOutput`. Err(error) => { - let value = serde_json::from_str::(&stdout_str).ok(); - let is_needs_input = value - .as_ref() - .and_then(|v| v.get("type")) - .and_then(Value::as_str) - == Some("needs_input"); - - if !is_needs_input { + if !Outcome::claims_needs_input(&stdout_str) { return CommandResult::RawOutput { stdout: stdout_str.into_owned(), stderr: String::from_utf8_lossy(stderr).into_owned(), @@ -629,19 +622,13 @@ fn parse_command_output(stdout: &[u8], stderr: &[u8], success: bool) -> CommandR }; } - let question_id = value - .as_ref() - .and_then(|v| v.get("question")) - .and_then(|q| q.get("id")) - .and_then(Value::as_str); - - match question_id { + match Outcome::claimed_question_id(&stdout_str) { // The id itself is the problem: empty, or containing the `.` // reserved as the inquiry-id separator (`QuestionId` rejects // both). - Some(id) if id.is_empty() || id.contains('.') => CommandResult::InvalidInquiry { - question_id: id.to_owned(), - }, + Some(id) if id.is_empty() || id.contains('.') => { + CommandResult::InvalidInquiry { question_id: id } + } // Some other field failed to parse (wrong shape, missing // field, protocol skew). _ => CommandResult::MalformedInquiry { detail: error }, diff --git a/crates/jp_mcp/src/server/conformance_tests.rs b/crates/jp_mcp/src/server/conformance_tests.rs index 8f542bf65..7f11af50b 100644 --- a/crates/jp_mcp/src/server/conformance_tests.rs +++ b/crates/jp_mcp/src/server/conformance_tests.rs @@ -151,12 +151,14 @@ impl SseReader { } } - // Parse complete LF-framed events, including priming events with no data. - // Decoding after finding the delimiter handles split UTF-8 code points. + /// Read one complete event, including a priming event carrying no data. + /// + /// Decoding after finding the delimiter rather than before handles a UTF-8 + /// code point split across two chunks. async fn frame(&mut self) -> (Option, Option) { loop { - if let Some(offset) = self.buffer.windows(2).position(|bytes| bytes == b"\n\n") { - let bytes = self.buffer.drain(..offset + 2).collect::>(); + if let Some((start, len)) = Self::delimiter(&self.buffer) { + let bytes = self.buffer.drain(..start + len).collect::>(); let frame = String::from_utf8(bytes).unwrap(); let id = frame .lines() @@ -183,6 +185,24 @@ impl SseReader { } } + /// Where the first event delimiter starts, and how long it is. + /// + /// The SSE grammar ends an event on a blank line, whose line break may be + /// LF, CRLF, or a bare CR. + /// A reader that only knows `\n\n` would hang on a conforming server rather + /// than report what it received. + fn delimiter(buffer: &[u8]) -> Option<(usize, usize)> { + let crlf = buffer.windows(4).position(|bytes| bytes == b"\r\n\r\n"); + let lf = buffer.windows(2).position(|bytes| bytes == b"\n\n"); + let cr = buffer.windows(2).position(|bytes| bytes == b"\r\r"); + // The earliest match wins, and a CRLF pair starting at the same offset + // as a bare LF would have matched one byte later. + [(crlf, 4), (lf, 2), (cr, 2)] + .into_iter() + .filter_map(|(start, len)| Some((start?, len))) + .min_by_key(|(start, len)| (*start, std::cmp::Reverse(*len))) + } + async fn reply(mut self, id: u64) -> Value { loop { if let (_, Some(message)) = self.frame().await { @@ -194,6 +214,20 @@ impl SseReader { } } +/// The fixture client has to frame events the way a conforming server may send +/// them, or a future transport change looks like a hang rather than a failure. +#[test] +fn the_sse_reader_frames_every_line_break_the_grammar_allows() { + assert_eq!(SseReader::delimiter(b"data: x\n\nrest"), Some((7, 2))); + assert_eq!(SseReader::delimiter(b"data: x\r\n\r\nrest"), Some((7, 4))); + assert_eq!(SseReader::delimiter(b"data: x\r\rrest"), Some((7, 2))); + assert_eq!(SseReader::delimiter(b"data: x\n"), None); + + // A CRLF pair must be consumed whole: taking the inner `\n\n` would leave + // a stray `\r` at the head of the next event. + assert_eq!(SseReader::delimiter(b"a\r\n\r\nb\n\nc"), Some((1, 4))); +} + struct Fixture { endpoint: Endpoint, host: HostReceiver, @@ -381,7 +415,7 @@ async fn external_inquiry_reexecutes_with_host_answers_and_records_edited_output }; assert_eq!(request.id.as_str(), "confirm"); assert_eq!( - request.schema, + request.schema(), json!({"type":"boolean"}).as_object().unwrap().clone() ); assert!(answers.is_empty()); @@ -413,21 +447,15 @@ async fn external_inquiry_reexecutes_with_host_answers_and_records_edited_output pending.call.request.arguments, json!({"value":"requested"}).as_object().unwrap().clone() ); - let Interaction::Record { - arguments, - raw_result, - result, - reply, - } = pending.interaction - else { + let Interaction::Record { recording, reply } = pending.interaction else { panic!("expected record barrier") }; assert_eq!( - arguments, + recording.arguments, json!({"value":"edited"}).as_object().unwrap().clone() ); - assert_eq!(raw_result, Some(ToolResult::text(raw))); - assert_eq!(result, ToolResult::text("approved output")); + assert_eq!(recording.raw_result, Some(ToolResult::text(raw))); + assert_eq!(recording.result, ToolResult::text("approved output")); let mut returned = tokio::spawn(response.reply(11)); assert!( timeout(Duration::from_millis(40), &mut returned) @@ -439,8 +467,8 @@ async fn external_inquiry_reexecutes_with_host_answers_and_records_edited_output // delivered until this has happened. let record = json!({ "requested": pending.call.request.arguments, - "executed": arguments, - "result": result.to_text(), + "executed": recording.arguments, + "result": recording.result.to_text(), }); fs::write( fixture.root.path().join("record.json"), @@ -812,10 +840,10 @@ async fn cancellation_is_scoped_to_the_requesting_client_session() { .unwrap(); let record = next(&mut fixture.host).await; assert_eq!(record.call.id, second_input.call.id); - let Interaction::Record { result, reply, .. } = record.interaction else { + let Interaction::Record { recording, reply } = record.interaction else { panic!("expected only the second result") }; - assert_eq!(result, ToolResult::text("answered")); + assert_eq!(recording.result, ToolResult::text("answered")); reply.send(Ok(())).unwrap(); assert_eq!( second_response.reply(31).await, @@ -838,10 +866,10 @@ impl BuiltinTool for LargeResult { #[tokio::test] async fn large_result_reaches_external_client_byte_for_byte() { - // A repetitive fixed payload avoids a large checked-in fixture. Comparing - // the entire value catches truncation, duplication, and newline changes. + // 240 KB, well past any single chunk the transport reads. A repetitive + // fixed payload avoids a large checked-in fixture, and comparing the entire + // value catches truncation, duplication, and newline changes. let payload = "line\n".repeat(48_000); - assert_eq!(payload.len(), 240_000); let mut fixture = fixture( json!({"source":"builtin", "run":"ask", "result":"unattended"}), BuiltinExecutors::new().register("probe", LargeResult(payload.clone())), @@ -852,11 +880,10 @@ async fn large_result_reaches_external_client_byte_for_byte() { .request(33, "tools/call", json!({"name":"probe","arguments":{}})) .await; release(&mut fixture.host).await; - let Interaction::Record { result, reply, .. } = next(&mut fixture.host).await.interaction - else { + let Interaction::Record { recording, reply } = next(&mut fixture.host).await.interaction else { panic!("expected record") }; - assert_eq!(result, ToolResult::text(payload.clone())); + assert_eq!(recording.result, ToolResult::text(payload.clone())); reply.send(Ok(())).unwrap(); let result = response.reply(33).await; assert_eq!( @@ -882,13 +909,10 @@ async fn external_denial_never_executes_the_tool() { reason: "denied by Host".into(), })) .unwrap(); - let Interaction::Record { - raw_result, reply, .. - } = next(&mut fixture.host).await.interaction - else { + let Interaction::Record { recording, reply } = next(&mut fixture.host).await.interaction else { panic!("expected recording without release") }; - assert_eq!(raw_result, None); + assert_eq!(recording.raw_result, None); reply.send(Ok(())).unwrap(); assert_eq!( response.reply(35).await, diff --git a/crates/jp_mcp/src/server/http_tests.rs b/crates/jp_mcp/src/server/http_tests.rs index 10e4b8b06..0cab1ef74 100644 --- a/crates/jp_mcp/src/server/http_tests.rs +++ b/crates/jp_mcp/src/server/http_tests.rs @@ -105,10 +105,10 @@ async fn http_call_waits_for_host_release_and_records_edited_result() { }; assert_eq!(result, ToolResult::text("raw")); reply.send(Ok(ToolResult::text("edited"))).unwrap(); - let Interaction::Record { result, reply, .. } = host.recv().await.unwrap().interaction else { + let Interaction::Record { recording, reply } = host.recv().await.unwrap().interaction else { panic!("expected record") }; - assert_eq!(result, ToolResult::text("edited")); + assert_eq!(recording.result, ToolResult::text("edited")); assert!(!task.is_finished()); reply.send(Ok(())).unwrap(); let result = timeout(Duration::from_secs(2), task) @@ -168,9 +168,7 @@ async fn http_denial_is_recorded_without_execution() { reason: "not approved".into(), })) .unwrap(); - let Interaction::Record { - reply, raw_result, .. - } = timeout(Duration::from_secs(2), host.recv()) + let Interaction::Record { recording, reply } = timeout(Duration::from_secs(2), host.recv()) .await .unwrap() .unwrap() @@ -178,7 +176,7 @@ async fn http_denial_is_recorded_without_execution() { else { panic!("expected recording") }; - assert_eq!(raw_result, None); + assert_eq!(recording.raw_result, None); assert_eq!(count.load(Ordering::SeqCst), 0); reply.send(Ok(())).unwrap(); let result = timeout(Duration::from_secs(2), task) diff --git a/crates/jp_mcp/src/server/result.rs b/crates/jp_mcp/src/server/result.rs index 33d970f08..20e4e8a73 100644 --- a/crates/jp_mcp/src/server/result.rs +++ b/crates/jp_mcp/src/server/result.rs @@ -29,7 +29,16 @@ pub enum ResultError { UnansweredQuestion, } -fn convert(value: T) -> Result { +/// Re-read a value as the type the MCP wire gives it. +/// +/// The wire representation is the only thing both sides are defined against. +/// For rmcp's model types that is not merely convenient but required: +/// `Annotations`, `Icon`, and `IconTheme` are `#[non_exhaustive]`, so nothing +/// outside rmcp can name their fields to build one field by field. +/// +/// An error here means two definitions of the same wire shape have drifted +/// apart, not that a tool sent something malformed. +fn via_wire(value: T) -> Result { serde_json::from_value(serde_json::to_value(value)?) } @@ -60,7 +69,7 @@ pub fn from_mcp(result: CallToolResult) -> Result { } fn from_content(content: Content) -> Result { - let annotations: Option = content.annotations.map(convert).transpose()?; + let annotations: Option = content.annotations.map(via_wire).transpose()?; Ok(match content.raw { RawContent::Text(text) => ContentBlock::Text { text: text.text, @@ -108,7 +117,7 @@ fn from_content(content: Content) -> Result { }) } RawContent::ResourceLink(link) => { - let mut link: ResourceLink = convert(link)?; + let mut link: ResourceLink = via_wire(link)?; link.annotations = annotations; ContentBlock::ResourceLink(link) } @@ -137,7 +146,7 @@ pub fn to_mcp(result: ToolResult) -> Result { Some(value) => serde_json::from_value(value)?, None => Map::new(), }; - let encoded: Map = convert(error)?; + let encoded: Map = via_wire(error)?; details.extend(encoded); metadata.insert(ERROR_METADATA.into(), Value::Object(details)); } @@ -214,12 +223,14 @@ fn to_content(block: ContentBlock) -> Result { (content, resource.annotations) } ContentBlock::ResourceLink(mut link) => { + // Annotations live on the enclosing content block, not on the link + // itself, so they are moved out before the link crosses over. let annotations = link.annotations.take(); - (Content::resource_link(convert(link)?), annotations) + (Content::resource_link(via_wire(link)?), annotations) } ContentBlock::Question(_) => return Err(ResultError::UnansweredQuestion), }; - content.annotations = annotations.map(convert).transpose()?; + content.annotations = annotations.map(via_wire).transpose()?; Ok(content) } diff --git a/crates/jp_mcp/src/server/service.rs b/crates/jp_mcp/src/server/service.rs index 13ba08454..8ed0bce46 100644 --- a/crates/jp_mcp/src/server/service.rs +++ b/crates/jp_mcp/src/server/service.rs @@ -229,18 +229,31 @@ pub enum Interaction { }, /// Acknowledge final recording before returning the result to the caller. Record { - /// Post-edit execution arguments, separate from `CallInfo::request`. - arguments: Map, - /// Original completed result; absent for skipped calls. - raw_result: Option, - /// Content approved for delivery. - result: ToolResult, + /// What the Host is being asked to record. + /// + /// Boxed because it is the largest thing the private channel carries, + /// and every other interaction in flight would otherwise be sized for + /// it. + recording: Box, /// Acknowledges the Host's configured persistence policy, not an /// unconditional disk write. reply: oneshot::Sender>, }, } +/// One call as the Host should record it. +#[derive(Debug)] +pub struct Recording { + /// Post-edit execution arguments, separate from `CallInfo::request`. + pub arguments: Map, + + /// Original completed result; absent for skipped calls. + pub raw_result: Option, + + /// Content approved for delivery. + pub result: ToolResult, +} + /// Bounded, best-effort progress. /// It is independent of required Host requests. #[derive(Clone, Debug)] @@ -717,10 +730,13 @@ async fn record_without_executing( result: ToolResult, ) -> Result { ask(inner, call, |reply| Interaction::Record { - arguments, - // Nothing ran, so there is no unedited result behind the one delivered. - raw_result: None, - result: result.clone(), + recording: Box::new(Recording { + arguments, + // Nothing ran, so there is no unedited result behind the delivered + // one. + raw_result: None, + result: result.clone(), + }), reply, }) .await?; @@ -759,9 +775,13 @@ async fn deliver_result( } }; ask(inner, call, |reply| Interaction::Record { - arguments, - raw_result: (executed && !delivery_decided).then_some(raw_result), - result: result.clone(), + recording: Box::new(Recording { + arguments, + // A call the Host resolved at an earlier barrier never produced a + // result of its own, so there is nothing unedited behind it. + raw_result: (executed && !delivery_decided).then_some(raw_result), + result: result.clone(), + }), reply, }) .await?; @@ -844,7 +864,7 @@ async fn execute_with_answers( }); } }; - if !Node::root(&Value::Object(request.schema)).permits(&answer) { + if !Node::root(&Value::Object(request.schema())).permits(&answer) { return Err(ServiceError::InvalidAnswer(request.id.clone())); } answers.insert(request.id.to_string(), answer); diff --git a/crates/jp_mcp/src/server/service_tests.rs b/crates/jp_mcp/src/server/service_tests.rs index 1aa925621..456c531d4 100644 --- a/crates/jp_mcp/src/server/service_tests.rs +++ b/crates/jp_mcp/src/server/service_tests.rs @@ -88,6 +88,9 @@ fn service( /// A service whose `count` tool asks one question and then echoes its input. /// +/// `run` and `result` are that tool's `run` and `result` settings, spelled as a +/// user writes them: `unattended`, `ask`, `edit`, or `skip`. +/// /// The counter records how many execution attempts actually ran, which is what /// separates "the call was denied" from "the call silently went nowhere". fn fixture(run: &str, result: &str) -> (Service, HostReceiver, Arc) { @@ -184,10 +187,10 @@ async fn preparation_release_input_and_delivery_use_distinct_acknowledgements() ); assert_eq!(count.load(Ordering::SeqCst), 2); reply.send(Ok(ToolResult::text("edited result"))).unwrap(); - let Interaction::Record { result, reply, .. } = next(&mut host).await.interaction else { + let Interaction::Record { recording, reply } = next(&mut host).await.interaction else { panic!("expected recording") }; - assert_eq!(result, ToolResult::text("edited result")); + assert_eq!(recording.result, ToolResult::text("edited result")); assert!(!call.is_finished()); reply.send(Ok(())).unwrap(); assert_eq!( @@ -209,10 +212,10 @@ async fn denied_call_never_executes() { reason: "denied".into(), })) .unwrap(); - let Interaction::Record { reply, result, .. } = next(&mut host).await.interaction else { + let Interaction::Record { recording, reply } = next(&mut host).await.interaction else { panic!("expected recording") }; - assert_eq!(result, ToolResult::text("denied")); + assert_eq!(recording.result, ToolResult::text("denied")); reply.send(Ok(())).unwrap(); assert_eq!(call.finish().await.unwrap(), ToolResult::text("denied")); assert_eq!(count.load(Ordering::SeqCst), 0); @@ -402,13 +405,10 @@ async fn identical_calls_have_independent_answers_and_out_of_order_delivery() { async fn configured_skip_never_requests_execution_release() { let (service, mut host, count) = fixture("skip", "unattended"); let call = service.start_call(request()).unwrap(); - let Interaction::Record { - reply, raw_result, .. - } = next(&mut host).await.interaction - else { + let Interaction::Record { recording, reply } = next(&mut host).await.interaction else { panic!("skip must go directly to recording") }; - assert_eq!(raw_result, None); + assert_eq!(recording.raw_result, None); reply.send(Ok(())).unwrap(); assert_eq!( call.finish().await.unwrap(), @@ -426,23 +426,18 @@ async fn skipped_delivery_records_original_without_delivering_it() { panic!("expected input") }; reply.send(Ok(json!(true).into())).unwrap(); - let Interaction::Record { - reply, - raw_result, - result, - .. - } = next(&mut host).await.interaction - else { + let Interaction::Record { recording, reply } = next(&mut host).await.interaction else { panic!("expected recording, no review") }; + // The tool's own output is recorded even though the user never sees it. assert_eq!( - raw_result, + recording.raw_result, Some(ToolResult::text( r#"{"arguments":{"path":"original"},"answer":true}"# )) ); assert_eq!( - result, + recording.result, ToolResult::text("Result delivery skipped by configuration.") ); reply.send(Ok(())).unwrap(); diff --git a/crates/jp_mcp/src/server/upstream.rs b/crates/jp_mcp/src/server/upstream.rs index 0be61f6f9..82ceeec47 100644 --- a/crates/jp_mcp/src/server/upstream.rs +++ b/crates/jp_mcp/src/server/upstream.rs @@ -1,7 +1,6 @@ //! JP-aware result decoding for upstream MCP tools. use jp_tool::Outcome; -use serde_json::Value; use tracing::warn; use crate::{CallToolResult, RawContent}; @@ -42,21 +41,11 @@ pub(super) fn decode_result(result: CallToolResult) -> Result Err(error), + Err(error) if Outcome::claims_needs_input(&text.text) => Err(error), Err(_) => Ok(UpstreamResult::Native(result)), } } -/// Whether the text is a JSON object announcing itself as an inquiry. -fn is_needs_input(text: &str) -> bool { - serde_json::from_str::(text) - .ok() - .as_ref() - .and_then(|value| value.get("type")) - .and_then(Value::as_str) - == Some("needs_input") -} - /// Replace a recognized envelope's text while retaining its native metadata. /// /// `response` is the result [`decode_result`] recognized, so its single text diff --git a/crates/jp_tool/src/content.rs b/crates/jp_tool/src/content.rs index 15184aeb1..c11449d9f 100644 --- a/crates/jp_tool/src/content.rs +++ b/crates/jp_tool/src/content.rs @@ -384,17 +384,34 @@ pub struct InputRequest { /// Supporting material belongs in the content blocks preceding this one. pub label: String, - /// JSON Schema the answer must satisfy. - pub schema: Map, + /// What kind of answer the tool expects. + /// + /// Both the [`schema`] an answer is validated against and the widget a host + /// prompts with come from this, so a request that crosses a service + /// boundary and comes back describes the same input it started as. + /// + /// [`schema`]: Self::schema + pub answer_type: AnswerType, /// The answer used when none is given. pub default: Option, +} + +impl InputRequest { + /// The JSON Schema an answer must satisfy. + #[must_use] + pub fn schema(&self) -> Map { + self.answer_type.to_schema() + } /// Whether the answer must not be written to disk. /// /// A secret answer is not echoed while it is typed, and the recorded /// inquiry response holds a redaction marker rather than the answer. - pub secret: bool, + #[must_use] + pub fn is_secret(&self) -> bool { + matches!(self.answer_type, AnswerType::Secret) + } } impl From for InputRequest { @@ -410,8 +427,7 @@ impl From for InputRequest { Self { id, label: text, - secret: matches!(answer_type, AnswerType::Secret), - schema: answer_type.to_schema(), + answer_type, default, } } @@ -420,9 +436,9 @@ impl From for InputRequest { impl AnswerType { /// The JSON Schema an answer of this type must satisfy. /// - /// A secret answer is a string like any other; that it must not be - /// persisted is carried by [`InputRequest::secret`], not by the schema, so - /// the rule cannot be lost by rewriting the schema. + /// A secret answer is a string like any other: that it must not be + /// persisted is a property of the answer type, not of the schema, so the + /// rule cannot be lost by rewriting the schema. #[must_use] pub fn to_schema(&self) -> Map { let schema = match self { diff --git a/crates/jp_tool/src/content_tests.rs b/crates/jp_tool/src/content_tests.rs index adb7e53b2..4add603a5 100644 --- a/crates/jp_tool/src/content_tests.rs +++ b/crates/jp_tool/src/content_tests.rs @@ -73,49 +73,89 @@ fn outcome_conversion_preserves_question_context() { } #[test] -fn a_select_question_becomes_an_enum_schema() { - let request = InputRequest::from(question("branch", AnswerType::Select { +fn a_question_keeps_its_answer_type_and_derives_an_enum_schema() { + let answer_type = AnswerType::Select { options: vec!["main".to_owned(), "develop".to_owned()], - })); + }; + let request = InputRequest::from(question("branch", answer_type.clone())); assert_eq!(request, InputRequest { id: "branch".parse().unwrap(), label: "Which branch?".to_owned(), - schema: json!({ "type": "string", "enum": ["main", "develop"] }) - .as_object() - .cloned() - .unwrap(), + answer_type, default: Some(json!("main")), - secret: false, }); + assert_eq!( + request.schema(), + json!({ "type": "string", "enum": ["main", "develop"] }) + .as_object() + .cloned() + .unwrap() + ); } #[test] -fn a_boolean_question_becomes_a_boolean_schema() { +fn a_boolean_question_derives_a_boolean_schema() { let request = InputRequest::from(question("proceed", AnswerType::Boolean)); + assert_eq!(request.answer_type, AnswerType::Boolean); assert_eq!( - request.schema, + request.schema(), json!({ "type": "boolean" }).as_object().cloned().unwrap() ); } -/// Secrecy is a typed field, not a schema keyword: a consumer that rewrites the -/// schema for a provider cannot drop the rule that the answer stays off disk. +/// Secrecy rides on the answer type, not on a schema keyword: a consumer that +/// rewrites the schema for a provider cannot drop the rule that the answer +/// stays off disk. #[test] -fn a_secret_question_is_a_plain_string_schema_and_a_set_flag() { +fn a_secret_question_derives_a_plain_string_schema_and_stays_secret() { let request = InputRequest::from(question("token", AnswerType::Secret)); - assert!(request.secret); + assert!(request.is_secret()); assert_eq!( - request.schema, + request.schema(), json!({ "type": "string" }).as_object().cloned().unwrap() ); } +/// A text question derives the same schema as a secret one, which is exactly +/// why the schema cannot be what tells them apart. #[test] fn an_ordinary_text_question_is_not_secret() { - assert!(!InputRequest::from(question("name", AnswerType::Text)).secret); + let request = InputRequest::from(question("name", AnswerType::Text)); + + assert!(!request.is_secret()); + assert_eq!( + request.schema(), + InputRequest::from(question("token", AnswerType::Secret)).schema() + ); +} + +/// Every answer type comes back as itself after a request crosses the service +/// boundary, including the two that share a schema and the one whose options a +/// schema-only representation would have to re-read. +#[test] +fn every_answer_type_survives_the_request_round_trip() { + let types = [ + AnswerType::Text, + AnswerType::Secret, + AnswerType::Boolean, + AnswerType::Select { + options: vec!["main".to_owned(), "develop".to_owned()], + }, + ]; + + for answer_type in types { + let original = question("q", answer_type.clone()); + let request = InputRequest::from(original.clone()); + + let mut restored = Question::new(request.id, request.label, request.answer_type); + restored.default = request.default; + restored.pre_amble = original.pre_amble.clone(); + + assert_eq!(restored, original, "round trip lost {answer_type:?}"); + } } #[test] diff --git a/crates/jp_tool/src/lib.rs b/crates/jp_tool/src/lib.rs index 0efcf1617..9dd7de612 100644 --- a/crates/jp_tool/src/lib.rs +++ b/crates/jp_tool/src/lib.rs @@ -91,6 +91,42 @@ impl Outcome { pub fn unwrap_content(self) -> String { self.into_content().unwrap() } + + /// Whether `text` claims to be a `needs_input` outcome, however badly. + /// + /// Answers the question a decoder asks after [`Outcome`] itself failed to + /// parse: did the tool mean to ask something? + /// A payload that says it did and then will not parse is a protocol + /// mismatch the caller must report, where output that was never an + /// `Outcome` is just text. + #[must_use] + pub fn claims_needs_input(text: &str) -> bool { + Self::claimed_shape(text).is_some_and(|kind| kind == "needs_input") + } + + /// The variant tag `text` carries, if it is a JSON object carrying one. + fn claimed_shape(text: &str) -> Option { + serde_json::from_str::(text) + .ok()? + .get("type")? + .as_str() + .map(str::to_owned) + } + + /// The question id a `needs_input` payload carries, if it carries one. + /// + /// Read straight from the JSON rather than from a parsed [`Question`], + /// because the reason a caller wants it is that parsing failed: an id that + /// [`QuestionId`] rejects is exactly what it is looking for. + #[must_use] + pub fn claimed_question_id(text: &str) -> Option { + serde_json::from_str::(text) + .ok()? + .get("question")? + .get("id")? + .as_str() + .map(str::to_owned) + } } /// A validated tool-question identifier. diff --git a/docs/ticket/0jwc9gr-tool-execution-tests-that-spawn-a-process-only-run-on-unix.md b/docs/ticket/0jwc9gr-tool-execution-tests-that-spawn-a-process-only-run-on-unix.md new file mode 100644 index 000000000..74ab8440d --- /dev/null +++ b/docs/ticket/0jwc9gr-tool-execution-tests-that-spawn-a-process-only-run-on-unix.md @@ -0,0 +1,63 @@ +# Tool-execution tests that spawn a process only run on Unix + +- **Status**: Todo +- **Kind**: Chore +- **Authors**: jp +- **Date**: 2026-09-14 +- **Implements**: 109 +- **Label**: domain=mcp +- **Label**: package=jp_cli +- **Label**: package=jp_mcp +- **Label**: type=task + +Seven tests covering the JP MCP Server's process-spawning paths are gated +`#[cfg(unix)]`, so on Windows the local-command path, the inquiry re-execution +proof, and every custom-formatter behaviour are untested. + +## The tests + +- `jp_mcp::server::service_tests` + - `local_inquiry_exits_and_runs_a_new_process_with_the_answer` + - `formatter_asks_for_visibility_and_waits_for_approval` + - `unattended_formatter_is_available_before_approval` + - `a_formatter_is_told_the_name_the_tool_runs_under` + - `hidden_presentation_never_executes_formatter` +- `jp_mcp::server::conformance_tests` + - `external_inquiry_reexecutes_with_host_answers_and_records_edited_output` +- `jp_cli::cmd::query::tool::coordinator_tests` + - `remembered_denial_does_not_run_http_argument_formatter` + +## Why they are gated + +Each configures a tool whose command is `{"program": "sh", "args": ["-c", +"