forked from 1b-agent/opencadstudio-upstream
feat(mcp): cancellable waits with GUI dismiss and max wait
run() reads stdin on a feeder thread into a channel (CancelPump); wait loops harvest lines in 50ms quanta instead of sleeping blind. A notifications/cancelled naming the in-flight MCP id dismisses the GUI prompt with a second cancel exchange, drops the late result, and sends nothing back. Cancels arriving between polls resolve via the inflight map; re-polls for dismissed ops answer cancelled without touching the GUI. Interactive picks (user_select/getpoint) get a 10-minute single-call ceiling ending in isError timeout text; other ops keep the 60s clamp. Ping-during-block stays unanswered while one exchange occupies the loop (bounded residual).
This commit is contained in:
1 parent
5794276213
commit
b8caa5ccac
1 file changed
+391
-30
+391
-30
@@ -14,6 +14,7 @@ use std::{
|
|||||||
net::{SocketAddr, TcpStream},
|
net::{SocketAddr, TcpStream},
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
process::{Child, Command, Stdio},
|
process::{Child, Command, Stdio},
|
||||||
|
sync::mpsc::{Receiver, TryRecvError},
|
||||||
thread,
|
thread,
|
||||||
time::{Duration, Instant, SystemTime},
|
time::{Duration, Instant, SystemTime},
|
||||||
};
|
};
|
||||||
@@ -238,6 +239,98 @@ struct GuiClient {
|
|||||||
state: Value,
|
state: Value,
|
||||||
client_id: String,
|
client_id: String,
|
||||||
batches: VecDeque<BatchExecution>,
|
batches: VecDeque<BatchExecution>,
|
||||||
|
/// MCP request id (as string) -> GUI request id for ops still pending
|
||||||
|
/// server-side. Lets an idle-arriving `notifications/cancelled` find
|
||||||
|
/// the GUI op to dismiss. Entries leave on terminal responses and
|
||||||
|
/// dismissals; abandoned entries mirror GUI sessions that outlive
|
||||||
|
/// interest (pre-existing behavior, two small strings each).
|
||||||
|
inflight: HashMap<String, String>,
|
||||||
|
/// GUI request ids dismissed by cancel/timeout: late completions and
|
||||||
|
/// re-polls answer `cancelled` without touching the GUI. Capped.
|
||||||
|
dismissed: VecDeque<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reader-thread inbox for stdin lines. The reader never writes: the main
|
||||||
|
/// loop (and, during waits, the wait loop) is the only consumer, so stdout
|
||||||
|
/// stays single-writer. Non-cancel lines met during a wait are stowed in
|
||||||
|
/// `backlog` and handled in order once the wait ends.
|
||||||
|
struct CancelPump {
|
||||||
|
rx: Receiver<Result<String, String>>,
|
||||||
|
backlog: VecDeque<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PumpEvent {
|
||||||
|
Cancelled,
|
||||||
|
Quiet,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CancelPump {
|
||||||
|
fn next(&mut self) -> Option<String> {
|
||||||
|
if let Some(line) = self.backlog.pop_front() {
|
||||||
|
return Some(line);
|
||||||
|
}
|
||||||
|
match self.rx.recv() {
|
||||||
|
Ok(Ok(line)) => Some(line),
|
||||||
|
Ok(Err(error)) => {
|
||||||
|
eprintln!("MCP input error: {error}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
Err(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drain newly arrived lines for up to `quantum`: consume a cancel naming
|
||||||
|
/// `mkey`, stow everything else in order. Never blocks past the quantum.
|
||||||
|
fn wait_line(&mut self, quantum: Duration, mkey: Option<&str>) -> PumpEvent {
|
||||||
|
let deadline = Instant::now() + quantum;
|
||||||
|
loop {
|
||||||
|
match self.rx.try_recv() {
|
||||||
|
Ok(Ok(line)) => {
|
||||||
|
if mkey.is_some_and(|key| is_cancel_for(&line, key)) {
|
||||||
|
return PumpEvent::Cancelled;
|
||||||
|
}
|
||||||
|
self.backlog.push_back(line);
|
||||||
|
}
|
||||||
|
// Reader gone or input broken: end the wait quietly; the
|
||||||
|
// main loop observes the closed channel right after.
|
||||||
|
Ok(Err(_)) | Err(TryRecvError::Disconnected) => return PumpEvent::Quiet,
|
||||||
|
Err(TryRecvError::Empty) => {
|
||||||
|
if Instant::now() >= deadline {
|
||||||
|
return PumpEvent::Quiet;
|
||||||
|
}
|
||||||
|
thread::sleep(Duration::from_millis(5));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// MCP request-id key for cancel matching. String and number ids stay
|
||||||
|
/// distinct (`7` vs `"7"`): both sides stringify the same JSON value.
|
||||||
|
fn mcp_key(id: &Value) -> String {
|
||||||
|
id.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_cancel_for(line: &str, mkey: &str) -> bool {
|
||||||
|
let Ok(message) = serde_json::from_str::<Value>(line) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if message.get("method").and_then(Value::as_str) != Some("notifications/cancelled") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
message
|
||||||
|
.get("params")
|
||||||
|
.and_then(|params| params.get("requestId"))
|
||||||
|
.is_some_and(|id| id.to_string() == mkey)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wait_deadline(op: &str, wait_seconds: f64) -> Duration {
|
||||||
|
let capped = if matches!(op, "user_select" | "getpoint") {
|
||||||
|
wait_seconds.clamp(0.0, INTERACTIVE_MAX_WAIT.as_secs_f64())
|
||||||
|
} else {
|
||||||
|
wait_seconds.clamp(0.0, 60.0)
|
||||||
|
};
|
||||||
|
Duration::from_secs_f64(capped)
|
||||||
}
|
}
|
||||||
|
|
||||||
struct BatchExecution {
|
struct BatchExecution {
|
||||||
@@ -546,8 +639,15 @@ fn private_descriptor(_: &Path) -> bool {
|
|||||||
enum CallError {
|
enum CallError {
|
||||||
Tool(String),
|
Tool(String),
|
||||||
Infra(String),
|
Infra(String),
|
||||||
|
/// Client cancelled the in-flight request: send nothing back.
|
||||||
|
Cancelled,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Interactive picks may legitimately take a human minutes; everything else
|
||||||
|
/// keeps the 60 s clamp. This bounds a single call; agents re-poll with
|
||||||
|
/// small waits by design.
|
||||||
|
const INTERACTIVE_MAX_WAIT: Duration = Duration::from_secs(600);
|
||||||
|
|
||||||
impl From<String> for CallError {
|
impl From<String> for CallError {
|
||||||
fn from(message: String) -> Self {
|
fn from(message: String) -> Self {
|
||||||
CallError::Tool(message)
|
CallError::Tool(message)
|
||||||
@@ -573,6 +673,7 @@ impl CallError {
|
|||||||
fn message(&self) -> &str {
|
fn message(&self) -> &str {
|
||||||
match self {
|
match self {
|
||||||
CallError::Tool(message) | CallError::Infra(message) => message,
|
CallError::Tool(message) | CallError::Infra(message) => message,
|
||||||
|
CallError::Cancelled => "cancelled by client",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -861,10 +962,41 @@ impl GuiClient {
|
|||||||
state,
|
state,
|
||||||
client_id: random_id().map_err(CallError::Infra)?,
|
client_id: random_id().map_err(CallError::Infra)?,
|
||||||
batches: VecDeque::new(),
|
batches: VecDeque::new(),
|
||||||
|
inflight: HashMap::new(),
|
||||||
|
dismissed: VecDeque::new(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn request(&mut self, request: Value, wait_seconds: f64) -> Result<Value, CallError> {
|
/// Best-effort dismiss of a GUI-pending interactive op: the GUI resolves
|
||||||
|
/// a pending `user_select`/`getpoint` as cancelled on `op == "cancel"`
|
||||||
|
/// (see `app::control`), and late completions are dropped bridge-side
|
||||||
|
/// via `dismissed`. Failures are ignored: dismissal races a GUI that may
|
||||||
|
/// already be gone, and the outcome (Cancelled/timeout) stands either way.
|
||||||
|
fn dismiss(&mut self, gui_id: Option<&str>) {
|
||||||
|
let Some(gui_id) = gui_id else { return };
|
||||||
|
let cancel = json!({
|
||||||
|
"op": "cancel",
|
||||||
|
"request_id": random_id().unwrap_or_else(|_| format!("cancel-{}", iso8601_now())),
|
||||||
|
"client_id": self.client_id.clone(),
|
||||||
|
"document_id": self.state["document_id"].clone(),
|
||||||
|
"revision": self.state["revision"].clone(),
|
||||||
|
});
|
||||||
|
let _ = exchange(&self.descriptor, cancel, Duration::from_secs(5));
|
||||||
|
if !self.dismissed.iter().any(|id| id == gui_id) {
|
||||||
|
if self.dismissed.len() >= 128 {
|
||||||
|
self.dismissed.pop_front();
|
||||||
|
}
|
||||||
|
self.dismissed.push_back(gui_id.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request(
|
||||||
|
&mut self,
|
||||||
|
request: Value,
|
||||||
|
wait_seconds: f64,
|
||||||
|
pump: &mut CancelPump,
|
||||||
|
mcp_id: Option<&Value>,
|
||||||
|
) -> Result<Value, CallError> {
|
||||||
let mut object = request
|
let mut object = request
|
||||||
.as_object()
|
.as_object()
|
||||||
.cloned()
|
.cloned()
|
||||||
@@ -896,26 +1028,78 @@ impl GuiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let request_id = object.get("request_id").cloned();
|
let request_id = object.get("request_id").cloned();
|
||||||
|
// Re-polls for dismissed interactions answer `cancelled` without
|
||||||
|
// touching the GUI: the prompt is already gone.
|
||||||
|
if op == "operation" {
|
||||||
|
if let Some(polled) = request_id.as_ref().and_then(Value::as_str) {
|
||||||
|
if self.dismissed.iter().any(|id| id == polled) {
|
||||||
|
return Ok(json!({"ok":false,"status":"cancelled","request_id":polled}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
let mut response = exchange(
|
let mut response = exchange(
|
||||||
&self.descriptor,
|
&self.descriptor,
|
||||||
Value::Object(object),
|
Value::Object(object),
|
||||||
Duration::from_secs(15),
|
Duration::from_secs(15),
|
||||||
)?;
|
)?;
|
||||||
let wait = wait_seconds.clamp(0.0, 60.0);
|
let gui_id = request_id.as_ref().and_then(Value::as_str).map(str::to_string);
|
||||||
let deadline = Instant::now() + Duration::from_secs_f64(wait);
|
let mkey = mcp_id.map(mcp_key);
|
||||||
|
// Track the GUI op while it stays pending so an idle-arriving
|
||||||
|
// cancel can find and dismiss it. Terminal outcomes remove the
|
||||||
|
// entry; deadline exits keep it (the agent re-polls and re-tracks).
|
||||||
|
if let (Some(key), Some(gui)) = (mkey.as_ref(), gui_id.as_ref()) {
|
||||||
|
self.inflight.insert(key.clone(), gui.clone());
|
||||||
|
}
|
||||||
|
let interactive = matches!(op.as_str(), "user_select" | "getpoint");
|
||||||
|
let deadline = Instant::now() + wait_deadline(&op, wait_seconds);
|
||||||
while matches!(response["status"].as_str(), Some("accepted" | "running"))
|
while matches!(response["status"].as_str(), Some("accepted" | "running"))
|
||||||
&& Instant::now() < deadline
|
&& Instant::now() < deadline
|
||||||
{
|
{
|
||||||
let Some(request_id) = request_id.clone() else {
|
let Some(request_id) = request_id.clone() else {
|
||||||
break;
|
break;
|
||||||
};
|
};
|
||||||
thread::sleep(Duration::from_millis(50));
|
match pump.wait_line(Duration::from_millis(50), mkey.as_deref()) {
|
||||||
|
PumpEvent::Cancelled => {
|
||||||
|
self.dismiss(gui_id.as_deref());
|
||||||
|
if let Some(key) = mkey.as_ref() {
|
||||||
|
self.inflight.remove(key);
|
||||||
|
}
|
||||||
|
return Err(CallError::Cancelled);
|
||||||
|
}
|
||||||
|
PumpEvent::Quiet => {}
|
||||||
|
}
|
||||||
response = exchange(
|
response = exchange(
|
||||||
&self.descriptor,
|
&self.descriptor,
|
||||||
json!({"op":"operation","request_id":request_id}),
|
json!({"op":"operation","request_id":request_id}),
|
||||||
Duration::from_secs(15),
|
Duration::from_secs(15),
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
|
let terminal = !matches!(response["status"].as_str(), Some("accepted" | "running"));
|
||||||
|
if terminal {
|
||||||
|
if let Some(key) = mkey.as_ref() {
|
||||||
|
self.inflight.remove(key);
|
||||||
|
}
|
||||||
|
if interactive && response["status"].as_str() == Some("cancelled") {
|
||||||
|
if let Some(gui) = gui_id.as_ref() {
|
||||||
|
if !self.dismissed.iter().any(|id| id == gui) {
|
||||||
|
if self.dismissed.len() >= 128 {
|
||||||
|
self.dismissed.pop_front();
|
||||||
|
}
|
||||||
|
self.dismissed.push_back(gui.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if interactive && Instant::now() >= deadline {
|
||||||
|
// Single-call ceiling for human-paced picks: dismiss the prompt
|
||||||
|
// and say so plainly instead of blocking forever.
|
||||||
|
self.dismiss(gui_id.as_deref());
|
||||||
|
if let Some(key) = mkey.as_ref() {
|
||||||
|
self.inflight.remove(key);
|
||||||
|
}
|
||||||
|
return Err(CallError::Tool(
|
||||||
|
"timed out waiting for user (10 min); the prompt was dismissed".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
if response.get("state").is_some() {
|
if response.get("state").is_some() {
|
||||||
self.state = response["state"].clone();
|
self.state = response["state"].clone();
|
||||||
} else if matches!(op.as_str(), "hello" | "state") && response["ok"].as_bool() == Some(true)
|
} else if matches!(op.as_str(), "hello" | "state") && response["ok"].as_bool() == Some(true)
|
||||||
@@ -925,7 +1109,13 @@ impl GuiClient {
|
|||||||
Ok(response)
|
Ok(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn execute_batch(&mut self, request: Value, wait_seconds: f64) -> Result<Value, CallError> {
|
fn execute_batch(
|
||||||
|
&mut self,
|
||||||
|
request: Value,
|
||||||
|
wait_seconds: f64,
|
||||||
|
pump: &mut CancelPump,
|
||||||
|
mcp_id: Option<&Value>,
|
||||||
|
) -> Result<Value, CallError> {
|
||||||
let id = required_string(&request, "request_id")?.to_owned();
|
let id = required_string(&request, "request_id")?.to_owned();
|
||||||
let mut batch = if let Some(position) = self.batches.iter().position(|batch| batch.id == id)
|
let mut batch = if let Some(position) = self.batches.iter().position(|batch| batch.id == id)
|
||||||
{
|
{
|
||||||
@@ -991,6 +1181,14 @@ impl GuiClient {
|
|||||||
}
|
}
|
||||||
attempted = true;
|
attempted = true;
|
||||||
|
|
||||||
|
// Cancel between steps: dismiss a pending interactive step so
|
||||||
|
// no late answer fires, then abandon the batch (cancel = stop).
|
||||||
|
if let PumpEvent::Cancelled =
|
||||||
|
pump.wait_line(Duration::ZERO, mcp_id.map(mcp_key).as_deref())
|
||||||
|
{
|
||||||
|
self.dismiss(batch.active.as_deref());
|
||||||
|
return Err(CallError::Cancelled);
|
||||||
|
}
|
||||||
let step_id = batch
|
let step_id = batch
|
||||||
.active
|
.active
|
||||||
.clone()
|
.clone()
|
||||||
@@ -1001,6 +1199,8 @@ impl GuiClient {
|
|||||||
deadline
|
deadline
|
||||||
.saturating_duration_since(Instant::now())
|
.saturating_duration_since(Instant::now())
|
||||||
.as_secs_f64(),
|
.as_secs_f64(),
|
||||||
|
pump,
|
||||||
|
mcp_id,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
let mut step = batch.steps[batch.next]
|
let mut step = batch.steps[batch.next]
|
||||||
@@ -1022,10 +1222,15 @@ impl GuiClient {
|
|||||||
deadline
|
deadline
|
||||||
.saturating_duration_since(Instant::now())
|
.saturating_duration_since(Instant::now())
|
||||||
.as_secs_f64(),
|
.as_secs_f64(),
|
||||||
|
pump,
|
||||||
|
mcp_id,
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
let response = match response {
|
let response = match response {
|
||||||
Ok(response) => response,
|
Ok(response) => response,
|
||||||
|
// Cancelled abandons the batch outright: resuming would
|
||||||
|
// re-poll a dismissed step.
|
||||||
|
Err(CallError::Cancelled) => return Err(CallError::Cancelled),
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
batch.active = Some(step_id);
|
batch.active = Some(step_id);
|
||||||
self.batches.push_back(batch);
|
self.batches.push_back(batch);
|
||||||
@@ -1224,6 +1429,7 @@ fn shape_execute_response(
|
|||||||
mut response: Value,
|
mut response: Value,
|
||||||
detail: &str,
|
detail: &str,
|
||||||
gui: &mut GuiClient,
|
gui: &mut GuiClient,
|
||||||
|
pump: &mut CancelPump,
|
||||||
) -> Result<Value, String> {
|
) -> Result<Value, String> {
|
||||||
if detail == "changed_entities" {
|
if detail == "changed_entities" {
|
||||||
let handles = response_handles(&response);
|
let handles = response_handles(&response);
|
||||||
@@ -1231,6 +1437,8 @@ fn shape_execute_response(
|
|||||||
let entities = gui.request(
|
let entities = gui.request(
|
||||||
json!({"op":"query","handles":handles,"detail":"geometry","limit":MAX_BATCH_STEPS * 100}),
|
json!({"op":"query","handles":handles,"detail":"geometry","limit":MAX_BATCH_STEPS * 100}),
|
||||||
30.0,
|
30.0,
|
||||||
|
pump,
|
||||||
|
None,
|
||||||
)?;
|
)?;
|
||||||
if let Some(object) = response.as_object_mut() {
|
if let Some(object) = response.as_object_mut() {
|
||||||
object.insert("changed_entities".into(), entities["entities"].clone());
|
object.insert("changed_entities".into(), entities["entities"].clone());
|
||||||
@@ -1251,6 +1459,8 @@ fn read_resource(
|
|||||||
uri: &str,
|
uri: &str,
|
||||||
clients: &mut HashMap<String, GuiClient>,
|
clients: &mut HashMap<String, GuiClient>,
|
||||||
resources: &mut ResourceStore,
|
resources: &mut ResourceStore,
|
||||||
|
pump: &mut CancelPump,
|
||||||
|
mcp_id: Option<&Value>,
|
||||||
) -> Result<Value, String> {
|
) -> Result<Value, String> {
|
||||||
if !uri.starts_with("cad://") {
|
if !uri.starts_with("cad://") {
|
||||||
return Err(format!("Unsupported resource URI scheme: {uri}"));
|
return Err(format!("Unsupported resource URI scheme: {uri}"));
|
||||||
@@ -1416,7 +1626,8 @@ fn read_resource(
|
|||||||
"bounds": tile_bounds,
|
"bounds": tile_bounds,
|
||||||
"max_dimension": 512,
|
"max_dimension": 512,
|
||||||
});
|
});
|
||||||
let result = client(clients, session_id)?.request(req, 20.0)?;
|
let result =
|
||||||
|
client(clients, session_id)?.request(req, 20.0, pump, mcp_id)?;
|
||||||
if result["ok"].as_bool() != Some(true)
|
if result["ok"].as_bool() != Some(true)
|
||||||
|| result["status"].as_str() != Some("completed")
|
|| result["status"].as_str() != Some("completed")
|
||||||
{
|
{
|
||||||
@@ -1484,7 +1695,7 @@ fn read_resource(
|
|||||||
"max_dimension": 1600,
|
"max_dimension": 1600,
|
||||||
});
|
});
|
||||||
let result = client(clients, session_id)?
|
let result = client(clients, session_id)?
|
||||||
.request(req, 15.0)?;
|
.request(req, 15.0, pump, mcp_id)?;
|
||||||
if result["ok"].as_bool() != Some(true)
|
if result["ok"].as_bool() != Some(true)
|
||||||
|| result["status"].as_str() != Some("completed")
|
|| result["status"].as_str() != Some("completed")
|
||||||
{
|
{
|
||||||
@@ -1536,6 +1747,8 @@ fn call_tool(
|
|||||||
arguments: &Value,
|
arguments: &Value,
|
||||||
clients: &mut HashMap<String, GuiClient>,
|
clients: &mut HashMap<String, GuiClient>,
|
||||||
resources: &mut ResourceStore,
|
resources: &mut ResourceStore,
|
||||||
|
pump: &mut CancelPump,
|
||||||
|
mcp_id: Option<&Value>,
|
||||||
) -> Result<Value, CallError> {
|
) -> Result<Value, CallError> {
|
||||||
match name {
|
match name {
|
||||||
"ocs_sessions" => {
|
"ocs_sessions" => {
|
||||||
@@ -1561,7 +1774,8 @@ fn call_tool(
|
|||||||
.cloned()
|
.cloned()
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
request.insert("op".into(), Value::String(op.into()));
|
request.insert("op".into(), Value::String(op.into()));
|
||||||
let response = client(clients, session_id)?.request(Value::Object(request), 30.0)?;
|
let response =
|
||||||
|
client(clients, session_id)?.request(Value::Object(request), 30.0, pump, mcp_id)?;
|
||||||
if matches!(op, "hello" | "capabilities") {
|
if matches!(op, "hello" | "capabilities") {
|
||||||
Ok(with_bridge_identity(response))
|
Ok(with_bridge_identity(response))
|
||||||
} else {
|
} else {
|
||||||
@@ -1588,9 +1802,9 @@ fn call_tool(
|
|||||||
let detail = arguments["response_detail"].as_str().unwrap_or("compact");
|
let detail = arguments["response_detail"].as_str().unwrap_or("compact");
|
||||||
let gui = client(clients, session_id)?;
|
let gui = client(clients, session_id)?;
|
||||||
let mut response = if op == "batch" {
|
let mut response = if op == "batch" {
|
||||||
gui.execute_batch(request, wait)?
|
gui.execute_batch(request, wait, pump, mcp_id)?
|
||||||
} else {
|
} else {
|
||||||
gui.request(request, wait)?
|
gui.request(request, wait, pump, mcp_id)?
|
||||||
};
|
};
|
||||||
if !val_res.warnings.is_empty() {
|
if !val_res.warnings.is_empty() {
|
||||||
if let Some(object) = response.as_object_mut() {
|
if let Some(object) = response.as_object_mut() {
|
||||||
@@ -1600,7 +1814,7 @@ fn call_tool(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
shape_execute_response(response, detail, gui).map_err(CallError::from)
|
shape_execute_response(response, detail, gui, pump).map_err(CallError::from)
|
||||||
}
|
}
|
||||||
"ocs_capture" => {
|
"ocs_capture" => {
|
||||||
let session_id = required_string(arguments, "ocs_session_id")?;
|
let session_id = required_string(arguments, "ocs_session_id")?;
|
||||||
@@ -1710,7 +1924,7 @@ fn call_tool(
|
|||||||
req["annotate"] = json!(annotate);
|
req["annotate"] = json!(annotate);
|
||||||
}
|
}
|
||||||
let result = client(clients, session_id)?
|
let result = client(clients, session_id)?
|
||||||
.request(req, 30.0)?;
|
.request(req, 30.0, pump, mcp_id)?;
|
||||||
if result["ok"].as_bool() != Some(true)
|
if result["ok"].as_bool() != Some(true)
|
||||||
|| result["status"].as_str() != Some("completed")
|
|| result["status"].as_str() != Some("completed")
|
||||||
{
|
{
|
||||||
@@ -2137,6 +2351,7 @@ fn poll_task(
|
|||||||
task: &mut McpTask,
|
task: &mut McpTask,
|
||||||
clients: &mut HashMap<String, GuiClient>,
|
clients: &mut HashMap<String, GuiClient>,
|
||||||
resources: &mut ResourceStore,
|
resources: &mut ResourceStore,
|
||||||
|
pump: &mut CancelPump,
|
||||||
) -> Value {
|
) -> Value {
|
||||||
if task.result.is_some() {
|
if task.result.is_some() {
|
||||||
return task_value(task, "completed");
|
return task_value(task, "completed");
|
||||||
@@ -2147,7 +2362,9 @@ fn poll_task(
|
|||||||
task.last_updated_at = iso8601_now();
|
task.last_updated_at = iso8601_now();
|
||||||
let mut arguments = task.arguments.clone();
|
let mut arguments = task.arguments.clone();
|
||||||
arguments["wait_seconds"] = Value::from(0);
|
arguments["wait_seconds"] = Value::from(0);
|
||||||
match call_tool(&task.name, &arguments, clients, resources) {
|
// Task re-issues are server-driven with no client wait: no MCP id to
|
||||||
|
// match cancels against, and a cancel here only means "stop polling".
|
||||||
|
match call_tool(&task.name, &arguments, clients, resources, pump, None) {
|
||||||
Ok(value) if matches!(value["status"].as_str(), Some("accepted" | "running")) => {
|
Ok(value) if matches!(value["status"].as_str(), Some("accepted" | "running")) => {
|
||||||
task_value(task, "working")
|
task_value(task, "working")
|
||||||
}
|
}
|
||||||
@@ -2155,6 +2372,9 @@ fn poll_task(
|
|||||||
task.result = Some(tool_result(value));
|
task.result = Some(tool_result(value));
|
||||||
task_value(task, "completed")
|
task_value(task, "completed")
|
||||||
}
|
}
|
||||||
|
// Don't cache a cancel as failure: the client moved on, and the
|
||||||
|
// next poll simply re-issues.
|
||||||
|
Err(CallError::Cancelled) => task_value(task, "working"),
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
task.error = Some(json!({"code":-32000,"message":error.message()}));
|
task.error = Some(json!({"code":-32000,"message":error.message()}));
|
||||||
task_value(task, "failed")
|
task_value(task, "failed")
|
||||||
@@ -2208,6 +2428,7 @@ fn handle_message(
|
|||||||
clients: &mut HashMap<String, GuiClient>,
|
clients: &mut HashMap<String, GuiClient>,
|
||||||
tasks: &mut TaskStore,
|
tasks: &mut TaskStore,
|
||||||
resources: &mut ResourceStore,
|
resources: &mut ResourceStore,
|
||||||
|
pump: &mut CancelPump,
|
||||||
) -> Option<Value> {
|
) -> Option<Value> {
|
||||||
// Non-object input (batches included: the spec defines no batch
|
// Non-object input (batches included: the spec defines no batch
|
||||||
// semantics for us) is Invalid Request, never silence: a sender must
|
// semantics for us) is Invalid Request, never silence: a sender must
|
||||||
@@ -2304,7 +2525,7 @@ fn handle_message(
|
|||||||
let Some(uri) = params["uri"].as_str() else {
|
let Some(uri) = params["uri"].as_str() else {
|
||||||
return Some(rpc_error(id, -32602, "Missing resource uri"));
|
return Some(rpc_error(id, -32602, "Missing resource uri"));
|
||||||
};
|
};
|
||||||
match read_resource(uri, clients, resources) {
|
match read_resource(uri, clients, resources, pump, Some(&id)) {
|
||||||
Ok(contents) => response(id, protocol_result(contents, modern, true)),
|
Ok(contents) => response(id, protocol_result(contents, modern, true)),
|
||||||
// -32002 was THE not-found code in 2025-11-25 and earlier;
|
// -32002 was THE not-found code in 2025-11-25 and earlier;
|
||||||
// 2026-07-28 says MUST NOT emit it, so modern gets -32602.
|
// 2026-07-28 says MUST NOT emit it, so modern gets -32602.
|
||||||
@@ -2334,7 +2555,7 @@ fn handle_message(
|
|||||||
.get("arguments")
|
.get("arguments")
|
||||||
.cloned()
|
.cloned()
|
||||||
.unwrap_or_else(|| json!({}));
|
.unwrap_or_else(|| json!({}));
|
||||||
let called = call_tool(name, &arguments, clients, resources);
|
let called = call_tool(name, &arguments, clients, resources, pump, Some(&id));
|
||||||
if modern && supports_tasks(¶ms) {
|
if modern && supports_tasks(¶ms) {
|
||||||
if let Ok(value) = &called {
|
if let Ok(value) = &called {
|
||||||
if matches!(value["status"].as_str(), Some("accepted" | "running")) {
|
if matches!(value["status"].as_str(), Some("accepted" | "running")) {
|
||||||
@@ -2371,11 +2592,13 @@ fn handle_message(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Tool-domain failures stay isError results (model-actionable);
|
// Tool-domain failures stay isError results (model-actionable);
|
||||||
// infrastructure failures become -32603 (nothing to fix in-band).
|
// infrastructure failures become -32603 (nothing to fix in-band);
|
||||||
|
// a cancelled request gets no response at all (spec SHOULD).
|
||||||
let result = match called {
|
let result = match called {
|
||||||
Ok(value) => tool_result(value),
|
Ok(value) => tool_result(value),
|
||||||
Err(CallError::Tool(message)) => error_result(message),
|
Err(CallError::Tool(message)) => error_result(message),
|
||||||
Err(CallError::Infra(message)) => return Some(rpc_error(id, -32603, message)),
|
Err(CallError::Infra(message)) => return Some(rpc_error(id, -32603, message)),
|
||||||
|
Err(CallError::Cancelled) => return None,
|
||||||
};
|
};
|
||||||
response(id, protocol_result(result, modern, false))
|
response(id, protocol_result(result, modern, false))
|
||||||
}
|
}
|
||||||
@@ -2386,7 +2609,7 @@ fn handle_message(
|
|||||||
let Some(task) = tasks.get_mut(task_id) else {
|
let Some(task) = tasks.get_mut(task_id) else {
|
||||||
return Some(rpc_error(id, -32602, "Unknown or expired taskId"));
|
return Some(rpc_error(id, -32602, "Unknown or expired taskId"));
|
||||||
};
|
};
|
||||||
response(id, protocol_result(poll_task(task, clients, resources), true, false))
|
response(id, protocol_result(poll_task(task, clients, resources, pump), true, false))
|
||||||
}
|
}
|
||||||
"tasks/update" if modern && supports_tasks(¶ms) => {
|
"tasks/update" if modern && supports_tasks(¶ms) => {
|
||||||
let Some(task_id) = params["taskId"].as_str() else {
|
let Some(task_id) = params["taskId"].as_str() else {
|
||||||
@@ -2454,33 +2677,83 @@ pub fn sync_agent_tool_schemas() -> bool {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A cancel arriving while nothing waits targets an op that is pending
|
||||||
|
/// server-side between polls: find its GUI request via the inflight map and
|
||||||
|
/// dismiss it now, so the next poll answers `cancelled`. Unknown ids are
|
||||||
|
/// ignored (spec: fire-and-forget, races expected).
|
||||||
|
fn idle_cancel(clients: &mut HashMap<String, GuiClient>, params: &Value) {
|
||||||
|
let Some(key) = params
|
||||||
|
.get("requestId")
|
||||||
|
.map(|id| id.to_string())
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for gui in clients.values_mut() {
|
||||||
|
if let Some(gui_id) = gui.inflight.remove(&key) {
|
||||||
|
gui.dismiss(Some(&gui_id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Run the MCP stdio loop until the client closes stdin.
|
/// Run the MCP stdio loop until the client closes stdin.
|
||||||
|
///
|
||||||
|
/// A reader thread feeds lines through a channel so wait loops can observe
|
||||||
|
/// `notifications/cancelled` mid-wait (see [`CancelPump`]). The reader never
|
||||||
|
/// writes: this loop is the only stdout writer.
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
let _ = sync_agent_tool_schemas();
|
let _ = sync_agent_tool_schemas();
|
||||||
let exe_stamp = exe_fingerprint();
|
let exe_stamp = exe_fingerprint();
|
||||||
let stdin = io::stdin();
|
|
||||||
let stdout = io::stdout();
|
let stdout = io::stdout();
|
||||||
let mut output = stdout.lock();
|
let mut output = stdout.lock();
|
||||||
let mut clients = HashMap::new();
|
let mut clients = HashMap::new();
|
||||||
let mut tasks = TaskStore::default();
|
let mut tasks = TaskStore::default();
|
||||||
let mut resources = ResourceStore::default();
|
let mut resources = ResourceStore::default();
|
||||||
for line in crate::io::line_read::lines_capped(
|
let (tx, rx) = std::sync::mpsc::channel::<Result<String, String>>();
|
||||||
stdin.lock(),
|
let stdin = io::stdin();
|
||||||
crate::io::line_read::MAX_LINE_BYTES,
|
thread::spawn(move || {
|
||||||
) {
|
for line in crate::io::line_read::lines_capped(
|
||||||
|
stdin.lock(),
|
||||||
|
crate::io::line_read::MAX_LINE_BYTES,
|
||||||
|
) {
|
||||||
|
if tx.send(line.map_err(|error| error.to_string())).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let mut pump = CancelPump {
|
||||||
|
rx,
|
||||||
|
backlog: VecDeque::new(),
|
||||||
|
};
|
||||||
|
loop {
|
||||||
|
let Some(line) = pump.next() else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
// Serve the in-flight request with the old schema first (it was made
|
// Serve the in-flight request with the old schema first (it was made
|
||||||
// against it), then exit so the client respawns a fresh bridge. This
|
// against it), then exit so the client respawns a fresh bridge. This
|
||||||
// ordering means a rebuild never fails a call that is already running.
|
// ordering means a rebuild never fails a call that is already running.
|
||||||
let superseded = exe_superseded(&exe_stamp);
|
let superseded = exe_superseded(&exe_stamp);
|
||||||
let response = match line {
|
let response = if line.trim().is_empty() {
|
||||||
Ok(line) if !line.trim().is_empty() => match serde_json::from_str::<Value>(&line) {
|
None
|
||||||
Ok(message) => handle_message(message, &mut clients, &mut tasks, &mut resources),
|
} else {
|
||||||
|
match serde_json::from_str::<Value>(&line) {
|
||||||
|
Ok(message) => {
|
||||||
|
if message.get("id").is_none()
|
||||||
|
&& message.get("method").and_then(Value::as_str)
|
||||||
|
== Some("notifications/cancelled")
|
||||||
|
{
|
||||||
|
idle_cancel(&mut clients, &message["params"]);
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
handle_message(
|
||||||
|
message,
|
||||||
|
&mut clients,
|
||||||
|
&mut tasks,
|
||||||
|
&mut resources,
|
||||||
|
&mut pump,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Err(error) => Some(rpc_error(Value::Null, -32700, error)),
|
Err(error) => Some(rpc_error(Value::Null, -32700, error)),
|
||||||
},
|
|
||||||
Ok(_) => None,
|
|
||||||
Err(error) => {
|
|
||||||
eprintln!("MCP input error: {error}");
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if let Some(response) = response {
|
if let Some(response) = response {
|
||||||
@@ -2502,6 +2775,16 @@ pub fn run() {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// A pump with no reader: waits behave exactly as before (quiet
|
||||||
|
/// immediately), so tests that never cancel need no other changes.
|
||||||
|
fn detached_pump() -> CancelPump {
|
||||||
|
let (_tx, rx) = std::sync::mpsc::channel();
|
||||||
|
CancelPump {
|
||||||
|
rx,
|
||||||
|
backlog: VecDeque::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn advertises_the_shared_tools() {
|
fn advertises_the_shared_tools() {
|
||||||
let tools = tool_definitions();
|
let tools = tool_definitions();
|
||||||
@@ -2622,6 +2905,7 @@ mod tests {
|
|||||||
&mut clients,
|
&mut clients,
|
||||||
&mut TaskStore::default(),
|
&mut TaskStore::default(),
|
||||||
&mut ResourceStore::default(),
|
&mut ResourceStore::default(),
|
||||||
|
&mut detached_pump(),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(initialized["result"]["protocolVersion"], "2025-11-25");
|
assert_eq!(initialized["result"]["protocolVersion"], "2025-11-25");
|
||||||
@@ -2641,6 +2925,7 @@ mod tests {
|
|||||||
&mut clients,
|
&mut clients,
|
||||||
&mut TaskStore::default(),
|
&mut TaskStore::default(),
|
||||||
&mut ResourceStore::default(),
|
&mut ResourceStore::default(),
|
||||||
|
&mut detached_pump(),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(listed["result"]["tools"].as_array().unwrap().len(), 4);
|
assert_eq!(listed["result"]["tools"].as_array().unwrap().len(), 4);
|
||||||
@@ -2660,6 +2945,7 @@ mod tests {
|
|||||||
&mut clients,
|
&mut clients,
|
||||||
&mut TaskStore::default(),
|
&mut TaskStore::default(),
|
||||||
&mut ResourceStore::default(),
|
&mut ResourceStore::default(),
|
||||||
|
&mut detached_pump(),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(discovered["result"]["resultType"], "complete");
|
assert_eq!(discovered["result"]["resultType"], "complete");
|
||||||
@@ -2681,6 +2967,7 @@ mod tests {
|
|||||||
&mut clients,
|
&mut clients,
|
||||||
&mut TaskStore::default(),
|
&mut TaskStore::default(),
|
||||||
&mut ResourceStore::default(),
|
&mut ResourceStore::default(),
|
||||||
|
&mut detached_pump(),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(listed["result"]["resultType"], "complete");
|
assert_eq!(listed["result"]["resultType"], "complete");
|
||||||
@@ -2699,6 +2986,7 @@ mod tests {
|
|||||||
&mut HashMap::new(),
|
&mut HashMap::new(),
|
||||||
&mut TaskStore::default(),
|
&mut TaskStore::default(),
|
||||||
&mut ResourceStore::default(),
|
&mut ResourceStore::default(),
|
||||||
|
&mut detached_pump(),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(listed["result"]["ttlMs"], CACHE_TTL_MS, "{method}");
|
assert_eq!(listed["result"]["ttlMs"], CACHE_TTL_MS, "{method}");
|
||||||
@@ -2718,6 +3006,7 @@ mod tests {
|
|||||||
&mut HashMap::new(),
|
&mut HashMap::new(),
|
||||||
&mut TaskStore::default(),
|
&mut TaskStore::default(),
|
||||||
&mut ResourceStore::default(),
|
&mut ResourceStore::default(),
|
||||||
|
&mut detached_pump(),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let info = &initialized["result"]["serverInfo"];
|
let info = &initialized["result"]["serverInfo"];
|
||||||
@@ -2736,6 +3025,7 @@ mod tests {
|
|||||||
&mut HashMap::new(),
|
&mut HashMap::new(),
|
||||||
&mut TaskStore::default(),
|
&mut TaskStore::default(),
|
||||||
&mut ResourceStore::default(),
|
&mut ResourceStore::default(),
|
||||||
|
&mut detached_pump(),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(fallback["result"]["protocolVersion"], PROTOCOL_VERSION);
|
assert_eq!(fallback["result"]["protocolVersion"], PROTOCOL_VERSION);
|
||||||
@@ -2751,6 +3041,7 @@ mod tests {
|
|||||||
&mut HashMap::new(),
|
&mut HashMap::new(),
|
||||||
&mut TaskStore::default(),
|
&mut TaskStore::default(),
|
||||||
&mut ResourceStore::default(),
|
&mut ResourceStore::default(),
|
||||||
|
&mut detached_pump(),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(modern["error"]["code"], -32602);
|
assert_eq!(modern["error"]["code"], -32602);
|
||||||
@@ -2759,6 +3050,7 @@ mod tests {
|
|||||||
&mut HashMap::new(),
|
&mut HashMap::new(),
|
||||||
&mut TaskStore::default(),
|
&mut TaskStore::default(),
|
||||||
&mut ResourceStore::default(),
|
&mut ResourceStore::default(),
|
||||||
|
&mut detached_pump(),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(legacy["error"]["code"], -32002);
|
assert_eq!(legacy["error"]["code"], -32002);
|
||||||
@@ -2774,6 +3066,7 @@ mod tests {
|
|||||||
&mut HashMap::new(),
|
&mut HashMap::new(),
|
||||||
&mut TaskStore::default(),
|
&mut TaskStore::default(),
|
||||||
&mut ResourceStore::default(),
|
&mut ResourceStore::default(),
|
||||||
|
&mut detached_pump(),
|
||||||
);
|
);
|
||||||
assert_eq!(rejected.unwrap()["error"]["code"], -32600);
|
assert_eq!(rejected.unwrap()["error"]["code"], -32600);
|
||||||
}
|
}
|
||||||
@@ -2782,6 +3075,7 @@ mod tests {
|
|||||||
&mut HashMap::new(),
|
&mut HashMap::new(),
|
||||||
&mut TaskStore::default(),
|
&mut TaskStore::default(),
|
||||||
&mut ResourceStore::default(),
|
&mut ResourceStore::default(),
|
||||||
|
&mut detached_pump(),
|
||||||
);
|
);
|
||||||
assert!(silent.is_none());
|
assert!(silent.is_none());
|
||||||
}
|
}
|
||||||
@@ -2795,6 +3089,7 @@ mod tests {
|
|||||||
&mut HashMap::new(),
|
&mut HashMap::new(),
|
||||||
&mut TaskStore::default(),
|
&mut TaskStore::default(),
|
||||||
&mut ResourceStore::default(),
|
&mut ResourceStore::default(),
|
||||||
|
&mut detached_pump(),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(unknown["error"]["code"], -32602);
|
assert_eq!(unknown["error"]["code"], -32602);
|
||||||
@@ -2815,11 +3110,60 @@ mod tests {
|
|||||||
&json!({"ocs_session_id":"00000000","op":"state"}),
|
&json!({"ocs_session_id":"00000000","op":"state"}),
|
||||||
&mut clients,
|
&mut clients,
|
||||||
&mut resources,
|
&mut resources,
|
||||||
|
&mut detached_pump(),
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(matches!(err, CallError::Infra(_)), "got {err:?}");
|
assert!(matches!(err, CallError::Infra(_)), "got {err:?}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cancelled_notifications_are_accepted_silently() {
|
||||||
|
// No response to notifications, even for cancel (nothing in flight
|
||||||
|
// in a unit test; live waits honor it — see wait_deadline below).
|
||||||
|
let silent = handle_message(
|
||||||
|
json!({"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":7}}),
|
||||||
|
&mut HashMap::new(),
|
||||||
|
&mut TaskStore::default(),
|
||||||
|
&mut ResourceStore::default(),
|
||||||
|
&mut detached_pump(),
|
||||||
|
);
|
||||||
|
assert!(silent.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cancel_matching_and_wait_deadlines() {
|
||||||
|
// Only a cancel naming our in-flight MCP id counts; anything else
|
||||||
|
// (other ids, pings, results) is ignored by the wait, not eaten.
|
||||||
|
assert!(is_cancel_for(
|
||||||
|
r#"{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":7}}"#,
|
||||||
|
"7"
|
||||||
|
));
|
||||||
|
assert!(!is_cancel_for(
|
||||||
|
r#"{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":8}}"#,
|
||||||
|
"7"
|
||||||
|
));
|
||||||
|
assert!(!is_cancel_for(
|
||||||
|
r#"{"jsonrpc":"2.0","id":7,"method":"ping"}"#,
|
||||||
|
"7"
|
||||||
|
));
|
||||||
|
assert!(!is_cancel_for("not json at all", "7"));
|
||||||
|
// String ids compare as strings: 7 != "7".
|
||||||
|
assert!(!is_cancel_for(
|
||||||
|
r#"{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":7}}"#,
|
||||||
|
"\"7\""
|
||||||
|
));
|
||||||
|
// Interactive picks may legitimately take minutes; everything else
|
||||||
|
// keeps the 60 s clamp.
|
||||||
|
assert_eq!(
|
||||||
|
wait_deadline("user_select", 3600.0),
|
||||||
|
Duration::from_secs(600)
|
||||||
|
);
|
||||||
|
assert_eq!(wait_deadline("getpoint", 5.0), Duration::from_secs(5));
|
||||||
|
assert_eq!(wait_deadline("run", 3600.0), Duration::from_secs(60));
|
||||||
|
assert_eq!(wait_deadline("run", 5.0), Duration::from_secs(5));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn malformed_requests_get_jsonrpc_errors() {
|
fn malformed_requests_get_jsonrpc_errors() {
|
||||||
let unknown = handle_message(
|
let unknown = handle_message(
|
||||||
@@ -2827,6 +3171,7 @@ mod tests {
|
|||||||
&mut HashMap::new(),
|
&mut HashMap::new(),
|
||||||
&mut TaskStore::default(),
|
&mut TaskStore::default(),
|
||||||
&mut ResourceStore::default(),
|
&mut ResourceStore::default(),
|
||||||
|
&mut detached_pump(),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(unknown["error"]["code"], -32601);
|
assert_eq!(unknown["error"]["code"], -32601);
|
||||||
@@ -2835,6 +3180,7 @@ mod tests {
|
|||||||
&mut HashMap::new(),
|
&mut HashMap::new(),
|
||||||
&mut TaskStore::default(),
|
&mut TaskStore::default(),
|
||||||
&mut ResourceStore::default(),
|
&mut ResourceStore::default(),
|
||||||
|
&mut detached_pump(),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(nameless["error"]["code"], -32602);
|
assert_eq!(nameless["error"]["code"], -32602);
|
||||||
@@ -2843,6 +3189,7 @@ mod tests {
|
|||||||
&mut HashMap::new(),
|
&mut HashMap::new(),
|
||||||
&mut TaskStore::default(),
|
&mut TaskStore::default(),
|
||||||
&mut ResourceStore::default(),
|
&mut ResourceStore::default(),
|
||||||
|
&mut detached_pump(),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(uriless["error"]["code"], -32602);
|
assert_eq!(uriless["error"]["code"], -32602);
|
||||||
@@ -2856,6 +3203,7 @@ mod tests {
|
|||||||
&mut clients,
|
&mut clients,
|
||||||
&mut TaskStore::default(),
|
&mut TaskStore::default(),
|
||||||
&mut ResourceStore::default(),
|
&mut ResourceStore::default(),
|
||||||
|
&mut detached_pump(),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(called["result"]["isError"], true);
|
assert_eq!(called["result"]["isError"], true);
|
||||||
@@ -2869,6 +3217,7 @@ mod tests {
|
|||||||
&mut clients,
|
&mut clients,
|
||||||
&mut TaskStore::default(),
|
&mut TaskStore::default(),
|
||||||
&mut ResourceStore::default(),
|
&mut ResourceStore::default(),
|
||||||
|
&mut detached_pump(),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(rejected["error"]["code"], -32022);
|
assert_eq!(rejected["error"]["code"], -32022);
|
||||||
@@ -2887,6 +3236,7 @@ mod tests {
|
|||||||
&mut clients,
|
&mut clients,
|
||||||
&mut TaskStore::default(),
|
&mut TaskStore::default(),
|
||||||
&mut ResourceStore::default(),
|
&mut ResourceStore::default(),
|
||||||
|
&mut detached_pump(),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(called["result"]["isError"], true);
|
assert_eq!(called["result"]["isError"], true);
|
||||||
@@ -2907,6 +3257,7 @@ mod tests {
|
|||||||
&mut clients,
|
&mut clients,
|
||||||
&mut TaskStore::default(),
|
&mut TaskStore::default(),
|
||||||
&mut ResourceStore::default(),
|
&mut ResourceStore::default(),
|
||||||
|
&mut detached_pump(),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(called["result"]["isError"], true);
|
assert_eq!(called["result"]["isError"], true);
|
||||||
@@ -2939,6 +3290,7 @@ mod tests {
|
|||||||
&mut clients,
|
&mut clients,
|
||||||
&mut tasks,
|
&mut tasks,
|
||||||
&mut resources,
|
&mut resources,
|
||||||
|
&mut detached_pump(),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let res_arr = listed["result"]["resources"].as_array().unwrap();
|
let res_arr = listed["result"]["resources"].as_array().unwrap();
|
||||||
@@ -2951,6 +3303,7 @@ mod tests {
|
|||||||
&mut clients,
|
&mut clients,
|
||||||
&mut tasks,
|
&mut tasks,
|
||||||
&mut resources,
|
&mut resources,
|
||||||
|
&mut detached_pump(),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let contents = read_png["result"]["contents"].as_array().unwrap();
|
let contents = read_png["result"]["contents"].as_array().unwrap();
|
||||||
@@ -2963,6 +3316,7 @@ mod tests {
|
|||||||
&mut clients,
|
&mut clients,
|
||||||
&mut tasks,
|
&mut tasks,
|
||||||
&mut resources,
|
&mut resources,
|
||||||
|
&mut detached_pump(),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(read_latest["result"]["contents"][0]["blob"], dummy_data);
|
assert_eq!(read_latest["result"]["contents"][0]["blob"], dummy_data);
|
||||||
@@ -2973,6 +3327,7 @@ mod tests {
|
|||||||
&mut clients,
|
&mut clients,
|
||||||
&mut tasks,
|
&mut tasks,
|
||||||
&mut resources,
|
&mut resources,
|
||||||
|
&mut detached_pump(),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let text = read_meta["result"]["contents"][0]["text"].as_str().unwrap();
|
let text = read_meta["result"]["contents"][0]["text"].as_str().unwrap();
|
||||||
@@ -2985,6 +3340,7 @@ mod tests {
|
|||||||
&mut clients,
|
&mut clients,
|
||||||
&mut tasks,
|
&mut tasks,
|
||||||
&mut resources,
|
&mut resources,
|
||||||
|
&mut detached_pump(),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(read_err["error"]["code"], -32002);
|
assert_eq!(read_err["error"]["code"], -32002);
|
||||||
@@ -3181,6 +3537,7 @@ mod tests {
|
|||||||
&mut clients,
|
&mut clients,
|
||||||
&mut tasks,
|
&mut tasks,
|
||||||
&mut resources,
|
&mut resources,
|
||||||
|
&mut detached_pump(),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let res_arr = listed["result"]["resources"].as_array().unwrap();
|
let res_arr = listed["result"]["resources"].as_array().unwrap();
|
||||||
@@ -3193,6 +3550,7 @@ mod tests {
|
|||||||
&mut clients,
|
&mut clients,
|
||||||
&mut tasks,
|
&mut tasks,
|
||||||
&mut resources,
|
&mut resources,
|
||||||
|
&mut detached_pump(),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let man_contents = read_man["result"]["contents"].as_array().unwrap();
|
let man_contents = read_man["result"]["contents"].as_array().unwrap();
|
||||||
@@ -3205,6 +3563,7 @@ mod tests {
|
|||||||
&mut clients,
|
&mut clients,
|
||||||
&mut tasks,
|
&mut tasks,
|
||||||
&mut resources,
|
&mut resources,
|
||||||
|
&mut detached_pump(),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let tile_contents = read_tile["result"]["contents"].as_array().unwrap();
|
let tile_contents = read_tile["result"]["contents"].as_array().unwrap();
|
||||||
@@ -3470,6 +3829,8 @@ mod tests {
|
|||||||
&req["arguments"],
|
&req["arguments"],
|
||||||
&mut clients,
|
&mut clients,
|
||||||
&mut resources,
|
&mut resources,
|
||||||
|
&mut detached_pump(),
|
||||||
|
None,
|
||||||
).expect("ocs_read op: tools must succeed");
|
).expect("ocs_read op: tools must succeed");
|
||||||
assert_eq!(result["ok"], true);
|
assert_eq!(result["ok"], true);
|
||||||
assert!(result["tools"].is_array());
|
assert!(result["tools"].is_array());
|
||||||
|
|||||||
Reference in new issue
Block a user