1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
//! Answering `elicitation/create`: a server asking the user a question.
//!
//! Rho shows the request as an ordinary questionnaire on the tool card that
//! provoked it, which is why the answer is routed through the in-flight
//! `tools/call` (see [`super::inflight`]) rather than through any session-wide
//! channel: a questionnaire only reaches a person while a turn is running.
//!
//! Every path that cannot put the question in front of a person **declines**.
//! Declining is a first-class MCP answer and lets the server carry on without
//! the information; inventing an answer would not.
use rho_sdk::HostInputRequest;
use rmcp::{
model::{ElicitRequestParams, ElicitResult, ElicitationAction},
ErrorData as McpError,
};
use super::{elicitation_form::ElicitationForm, inflight::McpInFlightCalls};
/// Whether this run can put a question in front of a person.
///
/// Rho declares the `elicitation` capability only when this is
/// [`Self::Available`], because a run with no questionnaire loop (`rho mcp`
/// inventory, an automation run started without a host input responder) would
/// have to decline every request it invited.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum McpElicitationSupport {
Available,
Unavailable,
}
impl McpElicitationSupport {
pub(crate) fn is_available(self) -> bool {
match self {
Self::Available => true,
Self::Unavailable => false,
}
}
}
/// Serves one session's elicitation requests.
#[derive(Clone, Debug)]
pub(crate) struct McpElicitationService {
identity: String,
calls: McpInFlightCalls,
support: McpElicitationSupport,
}
impl McpElicitationService {
pub(crate) fn new(
identity: impl Into<String>,
calls: McpInFlightCalls,
support: McpElicitationSupport,
) -> Self {
Self {
identity: identity.into(),
calls,
support,
}
}
/// Whether the `elicitation` capability may be declared for this session.
pub(crate) fn is_available(&self) -> bool {
self.support.is_available()
}
/// Answer one `elicitation/create`.
///
/// Returns `Ok` for every outcome the protocol has an action for, and only
/// fails the request when the server asked for something Rho does not model.
pub(crate) async fn elicit(
&self,
request: ElicitRequestParams,
) -> Result<ElicitResult, McpError> {
// A server that asks without seeing the capability declared is answered
// rather than obeyed. Sending the question on would reach a run with no
// questionnaire loop, where an unanswerable request fails the whole run.
if !self.support.is_available() {
return Ok(self.decline("this Rho run cannot show a server's question to anyone"));
}
let (message, schema) = match request {
ElicitRequestParams::FormElicitationParams {
message,
requested_schema,
..
} => (message, requested_schema),
// URL mode asks Rho to send the user to a web page. Rho has no
// browser affordance it can drive safely from a background session,
// and accepting without opening anything would tell the server the
// user had answered.
ElicitRequestParams::UrlElicitationParams { .. } => {
return Ok(self.decline("Rho does not support URL elicitation"))
}
// Non-exhaustive upstream: an unknown mode is not something Rho can
// show, so it declines instead of guessing.
_ => return Ok(self.decline("Rho does not support this elicitation mode")),
};
let caller = match self.calls.sole_caller() {
Ok(caller) => caller,
Err(error) => return Ok(self.decline(error.reason())),
};
let form = match ElicitationForm::from_schema(&schema) {
Ok(form) => form,
Err(error) => return Ok(self.decline(error.reason())),
};
let title = elicitation_title(&self.identity, &message);
let host_request = match HostInputRequest::questionnaire(title, form.questions().to_vec()) {
Ok(request) => request,
Err(error) => return Ok(self.decline(error.to_string())),
};
match caller.ask(host_request).await {
Ok(response) => match form.content(&response) {
Ok(content) => {
Ok(ElicitResult::new(ElicitationAction::Accept).with_content(content))
}
// The user answered, but the answer cannot be typed the way the
// schema declares. Sending it anyway would break the server's
// contract, so the request is declined instead.
Err(error) => Ok(self.decline(error.reason())),
},
// Dismissing the form cancels the turn, so cancelling the whole
// operation is exactly what happened.
Err(rho_sdk::Error::Cancelled) => Ok(ElicitResult::new(ElicitationAction::Cancel)),
Err(error) => Ok(self.decline(error.to_string())),
}
}
/// A decline plus the reason, at debug level only: elicitation messages are
/// server-authored prose that can carry whatever the server put in them.
fn decline(&self, reason: impl AsRef<str>) -> ElicitResult {
tracing::debug!(
server = %self.identity,
reason = reason.as_ref(),
"declined an MCP elicitation request"
);
ElicitResult::new(ElicitationAction::Decline)
}
}
/// Name the asking server in the form title, so a question that interrupts a
/// turn is never mistaken for one of Rho's own.
fn elicitation_title(identity: &str, message: &str) -> String {
let message = message.trim();
if message.is_empty() {
return format!("MCP server `{identity}` needs input");
}
format!("MCP server `{identity}`: {message}")
}
#[cfg(test)]
#[path = "elicitation_tests.rs"]
mod tests;