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
//! Expanding an MCP server prompt into the turn it starts.
//!
//! A prompt is picked from the command palette like a skill, but unlike a skill
//! it cannot be expanded while completing the token: `prompts/get` is a
//! round-trip to the server. So the palette only completes `/mcp:<server>:<name>`
//! and the expansion happens here, on submit, where a turn has not started yet
//! and there is somewhere to report a failure.
use super::{App, InteractiveRuntime, TurnPrompt};
/// The prefix that marks a slash command as an MCP server prompt.
const MCP_PROMPT_PREFIX: &str = "mcp:";
impl App {
/// Expand `/mcp:<server>:<prompt>` into the turn it should start.
///
/// Returns `Ok(None)` when the command is not an MCP prompt, so the caller
/// can fall through to its other dynamic-command handlers. A prompt that
/// exists but cannot be fetched reports the reason and yields `Ok(None)`
/// with the composer already cleared, because starting a turn on a failed
/// expansion would send the model a command it cannot act on.
pub(super) async fn expand_mcp_prompt(
&mut self,
command: &str,
trailing: &str,
display: &str,
agent: &InteractiveRuntime,
) -> anyhow::Result<Option<TurnPrompt>> {
let Some((server, name)) = parse_command(command) else {
return Ok(None);
};
let catalog = agent.mcp_catalog().clone();
let Some(prompt) = catalog
.prompts()
.into_iter()
.find(|prompt| prompt.server == server && prompt.name == name)
else {
return Ok(None);
};
// Prompt text is bounded by the same cap as tool output, so one very
// large prompt cannot swamp the turn it starts.
let max_output_bytes = self
.info
.services
.config_repository
.load()?
.max_output_bytes;
let arguments = prompt.parse_arguments(trailing);
let missing = prompt.missing_arguments(&arguments);
if !missing.is_empty() {
self.set_status(format!(
"{} needs {}",
prompt.command_name(),
missing.join(", ")
));
return Ok(None);
}
match catalog
.get_prompt(&server, &name, arguments, max_output_bytes)
.await
{
Ok(expansion) => {
let model = prompt_turn_text(
expansion.description.as_deref(),
&expansion.text,
max_output_bytes,
);
Ok(Some(TurnPrompt::command(model, display.to_string())))
}
Err(error) => {
self.set_status(format!("{} failed: {error}", prompt.command_name()));
Ok(None)
}
}
}
}
/// Split `mcp:<server>:<prompt>` into its parts. Server identities cannot
/// contain `:`, so the first separator after the prefix is unambiguous.
pub(super) fn parse_command(command: &str) -> Option<(String, String)> {
let rest = command
.get(..MCP_PROMPT_PREFIX.len())
.filter(|prefix| prefix.eq_ignore_ascii_case(MCP_PROMPT_PREFIX))
.and_then(|_| command.get(MCP_PROMPT_PREFIX.len()..))?;
let (server, prompt) = rest.split_once(':')?;
if server.is_empty() || prompt.is_empty() {
return None;
}
Some((server.to_string(), prompt.to_string()))
}
/// Build the model-facing turn text from a prompt expansion.
///
/// The server's description leads when present so the model knows what the
/// following text is for. Description and body share one `max_output_bytes`
/// budget; the message body alone is already capped upstream.
pub(super) fn prompt_turn_text(
description: Option<&str>,
body: &str,
max_output_bytes: usize,
) -> String {
match description {
Some(description) if !description.trim().is_empty() => {
rho_tools::tool::truncate(format!("{description}\n\n{body}"), max_output_bytes)
}
_ => body.to_string(),
}
}
#[cfg(test)]
#[path = "mcp_prompt_tests.rs"]
mod tests;