Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion crates/biorouter-server/src/routes/tool_bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -123,14 +123,20 @@ async fn call_tool(
Some(_) => return rpc_error(id, -32602, "tools/call arguments must be an object"),
};

// The child's own id for this call. Not forwarded to the tool — `meta` stays
// `None` — but it is what lets the transcript pair the full result the grant
// keeps with the frame on which the child reports the call.
let child_call_id = bridge::child_call_id(params.get("_meta"));
let call = CallToolRequestParams {
name: name.to_string().into(),
arguments,
meta: None,
task: None,
};

match grant.call(call).await {
// The child is answered with the model's view of the result: the blocks a
// model is sent, unannotated (`bridge::child_view`, QA-E F4).
match grant.call_for_child(call, child_call_id).await {
Ok(result) => rpc_ok(
id,
serde_json::to_value(result).unwrap_or_else(|e| {
Expand Down
163 changes: 154 additions & 9 deletions crates/biorouter-server/tests/tool_bridge_routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,142 @@ fn no_hooks() -> Arc<biorouter::hooks::HooksManager> {
))
}

/// A dispatcher that answers every call with one fixed result.
///
/// Stands in for an extension when a test needs to know that a call really ran
/// on Biorouter's side (a random marker only this returns) or needs a tool's
/// exact result shape.
struct FixedResultDispatch {
result: rmcp::model::CallToolResult,
}

#[async_trait::async_trait]
impl bridge::BridgeToolDispatch for FixedResultDispatch {
async fn dispatch(
&self,
_session_id: &str,
_call: rmcp::model::CallToolRequestParams,
_capability: CallCapability,
_cancel: tokio_util::sync::CancellationToken,
) -> Result<rmcp::model::CallToolResult, String> {
Ok(self.result.clone())
}
}

/// A grant over [`advertised_tool`] whose calls are approved (Auto mode, a real
/// permission inspector) and answered with `result`.
fn fixed_result_grant(result: rmcp::model::CallToolResult) -> bridge::BridgeGrant {
use biorouter::config::permission::PermissionManager;
use biorouter::managed::ManagedPolicy;
use biorouter::permission::permission_inspector::PermissionInspector;
use biorouter::permission::tool_risk::ToolRiskRegistry;

let risks = Arc::new(ToolRiskRegistry::new());
let mut inspections = ToolInspectionManager::new();
inspections.add_inspector(Box::new(PermissionInspector::new(
Arc::clone(&risks),
PermissionManager::instance(),
Arc::new(ManagedPolicy::empty()),
Arc::new(tokio::sync::Mutex::new(None)),
)));
bridge::BridgeGrant::new(
Session::default(),
BioRouterMode::Auto,
Arc::new(FixedResultDispatch { result }),
Arc::new(inspections),
CallCapability::public_enforced(),
vec![advertised_tool()],
Conversation::new_unvalidated(vec![]),
None,
no_hooks(),
None,
risks,
)
}

/// A grant whose one tool answers with `marker=<marker>` and nothing else.
fn marker_grant(marker: &str) -> bridge::BridgeGrant {
fixed_result_grant(rmcp::model::CallToolResult::success(vec![
rmcp::model::Content::text(format!("marker={marker}")),
]))
}

/// QA-E F4 at the wire, through the real router: a child's `tools/call` is
/// answered with the model's view of the result — the assistant's block, no
/// annotations — and the full result is kept under the child's own call id, for
/// each CLI's `_meta` spelling (measured: claude 2.1.266, codex-cli 0.153.4).
///
/// The shape is `developer__shell`'s. Handing the child both blocks made it read
/// every result twice, and the user block's `priority: 0.0` made codex-cli fail
/// the call outright with "Unexpected response type".
#[tokio::test]
#[serial_test::serial]
async fn the_child_is_answered_with_the_models_view_and_the_full_result_is_kept() {
use axum::body::Body;
use axum::http::Request;
use tower::ServiceExt;

bridge::publish_base_url("http://127.0.0.1:65535");
let shell = rmcp::model::CallToolResult::success(vec![
rmcp::model::Content::text("Thu Sep 11").with_audience(vec![rmcp::model::Role::Assistant]),
rmcp::model::Content::text("Thu Sep 11")
.with_audience(vec![rmcp::model::Role::User])
.with_priority(0.0),
]);
let lease = bridge::issue(fixed_result_grant(shell.clone())).expect("issued");
let nonce = lease.url().rsplit('/').next().expect("a nonce").to_string();

for (meta, child_call_id) in [
(
json!({ "claudecode/toolUseId": "toolu_wire", "progressToken": 2 }),
"toolu_wire",
),
(
json!({ "callId": "exec-wire", "threadId": "t", "progressToken": 1 }),
"exec-wire",
),
] {
let request = json!({
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": {
"name": "spokeagent__query_knowledge_graph",
"arguments": { "cypher": "MATCH (n) RETURN n LIMIT 1" },
"_meta": meta,
}
});
let response = biorouter_server::routes::tool_bridge::routes()
.oneshot(
Request::builder()
.method("POST")
.uri(format!("/tool_bridge/{nonce}"))
.header("content-type", "application/json")
.body(Body::from(request.to_string()))
.expect("a request"),
)
.await
.expect("the route answers");
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("a body");
let body: serde_json::Value = serde_json::from_slice(&bytes).expect("JSON");

assert_eq!(
body["result"]["content"],
json!([{ "type": "text", "text": "Thu Sep 11" }]),
"the child gets the model's block, unannotated: {body}"
);
assert!(
!body.to_string().contains("priority"),
"codex-cli cannot parse a `priority` annotation: {body}"
);
assert_eq!(
bridge::take_recorded_result(lease.url(), child_call_id),
Some(shell.clone()),
"the full result is kept for the transcript under {child_call_id}"
);
}
}

/// The whole lifecycle in one test, because the assertions are sequential: a grant
/// is reachable, serves its own tool set, and stops existing when its lease drops.
#[tokio::test]
Expand Down Expand Up @@ -341,8 +477,17 @@ async fn the_real_codex_provider_reaches_biorouters_tools_over_the_bridge() {
use biorouter::providers::base::Provider;
use biorouter::providers::codex::CodexProvider;

// ⚠ QA-E F1: this used to issue a grant over an EMPTY extension manager and
// accept any answer that NAMED the tool, on the argument that a refusal "can
// only have come from Biorouter's side of the bridge". It could also come from
// Codex's own side — "MCP tool call requires approval, but approval policy is
// never" names the tool too — so on codex-cli 0.148+ this passed while every
// bridged call was refused inside the CLI. The tool now answers with a random
// marker that exists only in Biorouter's dispatcher, so only a call that
// really crossed the bridge and ran can put it in the answer.
let marker = format!("CODEXBRIDGE{:016x}", rand::random::<u64>());
serve_real_bridge().await;
let lease = bridge::issue(grant().await).expect("the base URL is published");
let lease = bridge::issue(marker_grant(&marker)).expect("the base URL is published");

// Drive the PROVIDER, not the CLI directly. `codex exec` cannot answer an
// approval request, so an MCP tool call there fails with "user cancelled MCP
Expand All @@ -356,7 +501,7 @@ async fn the_real_codex_provider_reaches_biorouters_tools_over_the_bridge() {

let messages = vec![Message::user().with_text(
"Call the spokeagent__query_knowledge_graph tool with cypher='MATCH (n) RETURN n LIMIT 1'. \
Then report, in one line, the exact text the tool returned.",
Then reply with ONLY the marker value the tool returned, and nothing else.",
)];

let outcome = bridge::ACTIVE_BRIDGE_URL
Expand All @@ -373,14 +518,14 @@ async fn the_real_codex_provider_reaches_biorouters_tools_over_the_bridge() {
match outcome {
Ok((message, usage)) => {
let text = message.as_concat_text();
// The grant's ExtensionManager holds no real extension, so the call is
// refused by the gate stack rather than executed — and that refusal is
// the proof: it can only have come from Biorouter's side of the bridge.
// A child that never reached the bridge would report a missing tool
// instead.
assert!(
text.contains("spokeagent__query_knowledge_graph"),
"the model should have reached Biorouter's tool; it said: {text}"
!text.contains("approval policy"),
"Codex refused the call itself instead of asking Biorouter: {text}"
);
assert!(
text.contains(&marker),
"the bridged tool never ran: {marker} exists only in Biorouter's \
dispatcher, and the answer was: {text}"
);
assert_eq!(
usage.provider.as_deref(),
Expand Down
Loading
Loading