Skip to content

Commit 6cfb37d

Browse files
warp-agent-staging[bot]oz-agentwarp-agent
authored
feat: surface MCP tool + server identity in confirmation card and invocation states (#14298)
## Summary The MCP tool execution confirmation card did not show which tool was being executed or which server that tool came from (APP-4965). This PR surfaces both the tool name and its originating server across the GUI and TUI MCP invocation states, with deterministic fallback copy when identity cannot be resolved. ### GUI - **Confirmation/blocked card title** now reads `OK if I call MCP tool {tool} on server {server}` (falling back to the tool name alone when the server is unknown, and to the generic `OK if I call this MCP tool?` when the tool name is also unavailable). - **Expanded running/finished detail header** now reads `Viewing MCP tool {tool} on {server}` (with the same fallbacks). - **Preprocessing/queued/finished header titles** now show `{tool} on {server}` for MCP tool calls, without the `MCP Tool:` presentation prefix. - **Warping/loading indicator** now reads `Calling "{name}" MCP tool on {server}...` when the server is known. - The action's `name` and `server_id` are passed directly into `RequestedCommandView` and stored independently of formatted command text, so headers never parse presentation labels to recover identity. Server names resolve through `TemplatableMCPServerManager::get_mcp_name` (non-panicking), even after the action leaves the pending queue. Non-MCP actions are unchanged. ### TUI - **Permission card question** now reads `Is it OK if I call MCP tool {name} on {server}?` (with fallbacks). - **Permission card details body** labels the tool with its server when known. - **Transcript lifecycle labels** (`tool_call_labels`) now append ` on {server}` to the `CallMCPTool` labels across constructing/pending/blocked/running/succeeded/failed/cancelled states. - Added `mcp_server_name_for_id` to `tui_export` so the TUI crate can resolve server names from the action's `server_id`. ### Tests - GUI pure title formatter tests (`mcp_blocked_title_text` / `mcp_viewing_detail_title_text`) for known server, unknown server, and empty tool name. - TUI MCP lifecycle label regression test across all states with and without a server. - Updated `tui_generic_tool_call_view_tests.rs` to assert tool+server identity in the permission card question, details, and rendered output. ## Verification ### Completed - `cargo fmt --all -- --check` — passed after the review fix. - `git diff --check` — passed. ### Blocked by runner memory ceiling - `CARGO_BUILD_JOBS=1 CARGO_INCREMENTAL=0 CARGO_PROFILE_DEV_DEBUG=0 CARGO_PROFILE_DEV_OPT_LEVEL=0 CARGO_PROFILE_DEV_CODEGEN_UNITS=256 RUSTFLAGS='-C debuginfo=0' cargo test -p warp_tui --lib` — SIGKILL (signal 9) while compiling the shared `warp` library; no tests ran. - The same memory-constrained command with `cargo test -p warp --lib requested_command --no-fail-fast` — SIGKILL (signal 9) while compiling `warp`; no tests ran. - The required `cargo build --bin warp` with the same memory constraints — SIGKILL (signal 9) during the app build/link step. - `./script/presubmit`, clippy, and computer-use visual proof could not run because the app/test artifacts were never produced. The before/after screenshots of the confirmation card and audited lifecycle states remain outstanding. The PR remains a **draft** until a larger runner can complete the app build, run the touched-package tests and repository presubmit gates, and capture the required UI evidence. No `impl-done` completion signal has been applied. Originating thread: https://linear.app/warpdotdev/issue/APP-4965/mcp-tool-execution-confirmation-card-should-show-tool-name-and-server <!-- factory-agent: {"source":"factory-agent","task_id":"APP-4965","task_source":"linear","task_url":"https://linear.app/warpdotdev/issue/APP-4965/mcp-tool-execution-confirmation-card-should-show-tool-name-and-server","oz_run_id":"019f95af-699b-7ea7-88c0-b776d513d5fc","repo":"warpdotdev/warp","review_rework_attempts":1} --> Co-Authored-By: Oz <oz-agent@warp.dev> _This PR was generated with [Oz](https://warp.dev)._ --------- Co-authored-by: Oz <oz-agent@warp.dev> Co-authored-by: Warp <agent@warp.dev>
1 parent 6bd18c2 commit 6cfb37d

10 files changed

Lines changed: 403 additions & 47 deletions

File tree

app/src/ai/blocklist/block.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2137,8 +2137,10 @@ impl AIBlock {
21372137
};
21382138
self.handle_mcp_tool_stream_update(
21392139
action_id,
2140+
name,
21402141
&command_text,
21412142
display_input,
2143+
*server_id,
21422144
ctx,
21432145
);
21442146
}
@@ -3622,15 +3624,19 @@ impl AIBlock {
36223624
fn handle_mcp_tool_stream_update(
36233625
&mut self,
36243626
action_id: &AIAgentActionId,
3627+
tool_name: &str,
36253628
command_text: &str,
36263629
mcp_args: serde_json::Value,
3630+
server_id: Option<uuid::Uuid>,
36273631
ctx: &mut ViewContext<Self>,
36283632
) {
36293633
match self.requested_mcp_tools.get_mut(action_id) {
36303634
Some(requested_mcp_tool) => {
36313635
requested_mcp_tool.view.update(ctx, |view, ctx| {
36323636
view.apply_streamed_update(command_text, ctx);
3637+
view.update_mcp_tool_name(tool_name);
36333638
view.update_mcp_request(mcp_args);
3639+
view.update_mcp_server_id(server_id);
36343640
ctx.notify();
36353641
});
36363642
}
@@ -3651,7 +3657,9 @@ impl AIBlock {
36513657
ctx,
36523658
);
36533659
view.apply_streamed_update(command_text, ctx);
3660+
view.update_mcp_tool_name(tool_name);
36543661
view.update_mcp_request(mcp_args);
3662+
view.update_mcp_server_id(server_id);
36553663
view
36563664
});
36573665
let action_id_clone = action_id.clone();

app/src/ai/blocklist/block/view_impl/common.rs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ use crate::ai::blocklist::view_util::{
8383
};
8484
use crate::ai::blocklist::{BlocklistAIActionModel, ShellCommandExecutor, TextLocation};
8585
use crate::ai::loading::shimmering_warp_loading_text;
86+
use crate::ai::mcp::TemplatableMCPServerManager;
8687
use crate::code::editor::view::CodeEditorView;
8788
use crate::code::editor_management::CodeSource;
8889
use crate::notebooks::editor::{markdown_table_appearance, rich_text_styles};
@@ -337,8 +338,16 @@ pub fn render_warping_indicator<V: View>(
337338
LOAD_OUTPUT_MESSAGE_FOR_SEARCH_CODEBASE.to_owned()
338339
}
339340
Some(AIAgentActionType::Grep { .. }) => LOAD_OUTPUT_MESSAGE_FOR_GREP.to_owned(),
340-
Some(AIAgentActionType::CallMCPTool { name, .. }) => {
341-
format!("Calling \"{name}\" MCP tool...")
341+
Some(AIAgentActionType::CallMCPTool {
342+
server_id, name, ..
343+
}) => {
344+
match server_id
345+
.as_ref()
346+
.and_then(|id| TemplatableMCPServerManager::get_mcp_name(id, app))
347+
{
348+
Some(server) => format!("Calling \"{name}\" MCP tool on {server}..."),
349+
None => format!("Calling \"{name}\" MCP tool..."),
350+
}
342351
}
343352
Some(AIAgentActionType::ReadMCPResource { name, .. }) => {
344353
format!("Reading \"{name}\" MCP resource...")

app/src/ai/blocklist/inline_action/requested_command.rs

Lines changed: 98 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ use lazy_static::lazy_static;
88
use parking_lot::FairMutex;
99
use pathfinder_geometry::vector::vec2f;
1010
use settings::Setting as _;
11+
use uuid::Uuid;
1112
use warp_core::features::FeatureFlag;
1213
use warp_core::ui::Icon;
1314
use warp_core::ui::appearance::Appearance;
@@ -54,6 +55,7 @@ use crate::ai::blocklist::{
5455
AIBlock, BlocklistAIActionEvent, BlocklistAIActionModel, BlocklistAIHistoryModel,
5556
ClientIdentifiers,
5657
};
58+
use crate::ai::mcp::TemplatableMCPServerManager;
5759
use crate::cmd_or_ctrl_shift;
5860
use crate::code::editor::view::{CodeEditorEvent, CodeEditorRenderOptions, CodeEditorView};
5961
use crate::editor::InteractionState;
@@ -183,8 +185,6 @@ pub fn init(app: &mut AppContext) {
183185
}
184186

185187
/// Structured representation of an MCP tool call request for JSON tree rendering.
186-
///
187-
/// The tool name is derivable from `command_text` and is not duplicated here.
188188
pub struct McpRequest {
189189
pub args: serde_json::Value,
190190
}
@@ -364,6 +364,15 @@ pub struct RequestedCommandView {
364364
// The SavePosition anchor ID of the row that was last right-clicked, used
365365
// to position the context menu below the correct row.
366366
mcp_context_menu_anchor_id: Option<String>,
367+
// The originating MCP server id for this tool call, captured when the
368+
// action streams in so the header can surface the server name across
369+
// every lifecycle state (blocked, queued, running, finished) — even after
370+
// the action leaves the pending queue and is no longer retrievable. `None`
371+
// for legacy/flat MCP calls with no server id.
372+
mcp_server_id: Option<Uuid>,
373+
// The MCP tool name is kept separately from the formatted command text so
374+
// headers never need to parse a presentation label to recover identity.
375+
mcp_tool_name: Option<String>,
367376
}
368377

369378
impl RequestedCommandView {
@@ -618,6 +627,8 @@ impl RequestedCommandView {
618627
mcp_context_menu,
619628
mcp_context_menu_open: false,
620629
mcp_context_menu_anchor_id: None,
630+
mcp_server_id: None,
631+
mcp_tool_name: None,
621632
}
622633
}
623634

@@ -1124,14 +1135,42 @@ impl RequestedCommandView {
11241135
self.mcp_request = Some(McpRequest { args });
11251136
}
11261137

1127-
/// Extracts the tool name from MCP tool command text, removing parameters.
1128-
/// For example, "tool_name(param1, param2)" becomes "tool_name".
1129-
fn extract_mcp_tool_name(&self, command_text: &str) -> String {
1130-
if let Some(paren_pos) = command_text.find('(') {
1131-
command_text[..paren_pos].trim().to_string()
1132-
} else {
1133-
command_text.trim().to_string()
1134-
}
1138+
/// Stores the originating MCP server id for this tool call, so the header
1139+
/// can surface the server name across lifecycle states. Captured once when
1140+
/// the action streams in; `None` for legacy/flat MCP calls with no server.
1141+
pub(crate) fn update_mcp_server_id(&mut self, server_id: Option<Uuid>) {
1142+
self.mcp_server_id = server_id;
1143+
}
1144+
/// Stores the MCP tool name independently of the formatted command text.
1145+
pub(crate) fn update_mcp_tool_name(&mut self, tool_name: &str) {
1146+
self.mcp_tool_name = Some(tool_name.to_owned());
1147+
}
1148+
1149+
/// Returns the MCP tool name for sentence-form titles like the blocked
1150+
/// confirmation card and the expanded detail header.
1151+
fn mcp_clean_tool_name(&self) -> String {
1152+
self.mcp_tool_name.clone().unwrap_or_default()
1153+
}
1154+
1155+
/// Resolves the user-facing name of the MCP tool's originating server.
1156+
/// Returns `None` when the server id is absent (legacy/flat MCP call) or
1157+
/// the server can't be named (e.g. not installed). Non-panicking.
1158+
fn mcp_server_name(&self, app: &AppContext) -> Option<String> {
1159+
self.mcp_server_id
1160+
.as_ref()
1161+
.and_then(|id| TemplatableMCPServerManager::get_mcp_name(id, app))
1162+
}
1163+
1164+
/// Builds the blocked/confirmation title for an MCP tool call, surfacing
1165+
/// both the tool name and its originating server when known:
1166+
/// `OK if I call MCP tool {tool} on server {server}`. Falls back to the
1167+
/// tool name alone when the server can't be named, and to the generic
1168+
/// waiting message when the tool name is also unavailable.
1169+
fn mcp_blocked_title(&self, app: &AppContext) -> String {
1170+
mcp_blocked_title_text(
1171+
&self.mcp_clean_tool_name(),
1172+
self.mcp_server_name(app).as_deref(),
1173+
)
11351174
}
11361175

11371176
fn render_header(
@@ -1159,7 +1198,7 @@ impl RequestedCommandView {
11591198

11601199
match action_status {
11611200
Some(AIActionStatus::Preprocessing) => {
1162-
title = self.get_header_title_text().into();
1201+
title = self.get_header_title_text(app).into();
11631202
font_override = Some(appearance.monospace_font_family());
11641203
if !self
11651204
.block_model
@@ -1172,7 +1211,7 @@ impl RequestedCommandView {
11721211
}
11731212
}
11741213
Some(AIActionStatus::Queued) => {
1175-
title = self.get_header_title_text().into();
1214+
title = self.get_header_title_text(app).into();
11761215
font_override = Some(appearance.monospace_font_family());
11771216
font_color_override = Some(blended_colors::text_disabled(
11781217
appearance.theme(),
@@ -1182,7 +1221,7 @@ impl RequestedCommandView {
11821221
Some(AIActionStatus::Blocked) => {
11831222
title = match &self.action_type {
11841223
RequestedActionViewType::Command => COMMAND_WAITING_FOR_USER_MESSAGE.into(),
1185-
RequestedActionViewType::McpTool => MCP_TOOL_WAITING_FOR_USER_MESSAGE.into(),
1224+
RequestedActionViewType::McpTool => self.mcp_blocked_title(app).into(),
11861225
};
11871226
}
11881227
Some(AIActionStatus::RunningAsync) | Some(AIActionStatus::Finished(..))
@@ -1217,7 +1256,11 @@ impl RequestedCommandView {
12171256
VIEWING_COMMAND_DETAIL_MESSAGE.into()
12181257
}
12191258
}
1220-
RequestedActionViewType::McpTool => VIEWING_MCP_TOOL_DETAIL_MESSAGE.into(),
1259+
RequestedActionViewType::McpTool => mcp_viewing_detail_title_text(
1260+
&self.mcp_clean_tool_name(),
1261+
self.mcp_server_name(app).as_deref(),
1262+
)
1263+
.into(),
12211264
};
12221265
}
12231266
None => {
@@ -1236,12 +1279,12 @@ impl RequestedCommandView {
12361279
} else if requested_command_block.is_some_and(|block| block.finished()) {
12371280
// If a finished command block exists but there's no action status,
12381281
// treat the same as a finished command (normal text styling).
1239-
title = self.get_header_title_text().into();
1282+
title = self.get_header_title_text(app).into();
12401283
font_override = Some(appearance.monospace_font_family());
12411284
} else {
12421285
// If there is no action status and response is not streaming, it was cancelled
12431286
// mid-flight.
1244-
let title_str = self.get_header_title_text();
1287+
let title_str = self.get_header_title_text(app);
12451288
title = if title_str.trim().is_empty() {
12461289
LOADING_MESSAGE.into()
12471290
} else {
@@ -1257,7 +1300,7 @@ impl RequestedCommandView {
12571300
}
12581301
}
12591302
_ => {
1260-
title = self.get_header_title_text().into();
1303+
title = self.get_header_title_text(app).into();
12611304

12621305
// Show cancelled command loading message when the command was cancelled during generation,
12631306
// and then restored with an empty title as a result.
@@ -1427,10 +1470,16 @@ impl RequestedCommandView {
14271470
config.render(app)
14281471
}
14291472

1430-
fn get_header_title_text(&self) -> String {
1473+
fn get_header_title_text(&self, app: &AppContext) -> String {
14311474
match &self.action_type {
14321475
RequestedActionViewType::Command => format_command_text(self.command_text()),
1433-
RequestedActionViewType::McpTool => self.extract_mcp_tool_name(self.command_text()),
1476+
RequestedActionViewType::McpTool => {
1477+
let tool = self.mcp_clean_tool_name();
1478+
match self.mcp_server_name(app) {
1479+
Some(server) if !tool.is_empty() => format!("{tool} on {server}"),
1480+
_ => tool,
1481+
}
1482+
}
14341483
}
14351484
}
14361485

@@ -1484,6 +1533,35 @@ pub(crate) fn header_message_for_user_take_over_reason(
14841533
}
14851534
}
14861535

1536+
/// Builds the blocked/confirmation title for an MCP tool call from the
1537+
/// already-resolved tool and server names, so the formatting is unit-testable
1538+
/// without a full app/view context. Surfaces both identities when the server
1539+
/// is known: `OK if I call MCP tool {tool} on server {server}`; falls back to
1540+
/// the tool name alone when the server can't be named, and to the generic
1541+
/// waiting message when the tool name is also unavailable.
1542+
fn mcp_blocked_title_text(tool_name: &str, server_name: Option<&str>) -> String {
1543+
if tool_name.is_empty() {
1544+
return MCP_TOOL_WAITING_FOR_USER_MESSAGE.to_owned();
1545+
}
1546+
match server_name {
1547+
Some(server) => format!("OK if I call MCP tool {tool_name} on server {server}"),
1548+
None => format!("OK if I call MCP tool {tool_name}"),
1549+
}
1550+
}
1551+
1552+
/// Builds the expanded-detail header title for an MCP tool call from the
1553+
/// already-resolved tool and server names. Falls back to the generic
1554+
/// "Viewing MCP tool call detail" message when the tool name is unavailable.
1555+
fn mcp_viewing_detail_title_text(tool_name: &str, server_name: Option<&str>) -> String {
1556+
if tool_name.is_empty() {
1557+
return VIEWING_MCP_TOOL_DETAIL_MESSAGE.to_owned();
1558+
}
1559+
match server_name {
1560+
Some(server) => format!("Viewing MCP tool {tool_name} on {server}"),
1561+
None => format!("Viewing MCP tool {tool_name}"),
1562+
}
1563+
}
1564+
14871565
impl Entity for RequestedCommandView {
14881566
type Event = RequestedCommandViewEvent;
14891567
}
@@ -1814,7 +1892,7 @@ impl View for RequestedCommandView {
18141892
} else if self.is_header_expanded {
18151893
command_text.to_string()
18161894
} else {
1817-
self.extract_mcp_tool_name(command_text)
1895+
self.mcp_clean_tool_name()
18181896
};
18191897
let text_element = Text::new(
18201898
content_text,

app/src/ai/blocklist/inline_action/requested_command_tests.rs

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
//! Unit tests for format_command_text in requested_command.rs
22
3-
use super::format_command_text;
3+
use super::{format_command_text, mcp_blocked_title_text, mcp_viewing_detail_title_text};
44

55
#[test]
66
fn single_line_without_newline_is_unchanged_ascii() {
@@ -70,3 +70,51 @@ fn newline_then_multibyte_results_in_ellipsis_only() {
7070
let reconstructed: String = output.chars().collect();
7171
assert_eq!(reconstructed, output);
7272
}
73+
74+
#[test]
75+
fn mcp_blocked_title_surfaces_tool_and_server_when_known() {
76+
assert_eq!(
77+
mcp_blocked_title_text("create_issue", Some("github")),
78+
"OK if I call MCP tool create_issue on server github"
79+
);
80+
}
81+
82+
#[test]
83+
fn mcp_blocked_title_falls_back_to_tool_name_when_server_unknown() {
84+
assert_eq!(
85+
mcp_blocked_title_text("create_issue", None),
86+
"OK if I call MCP tool create_issue"
87+
);
88+
}
89+
90+
#[test]
91+
fn mcp_blocked_title_falls_back_to_generic_message_when_tool_name_empty() {
92+
assert_eq!(
93+
mcp_blocked_title_text("", Some("github")),
94+
"OK if I call this MCP tool?"
95+
);
96+
assert_eq!(
97+
mcp_blocked_title_text("", None),
98+
"OK if I call this MCP tool?"
99+
);
100+
}
101+
102+
#[test]
103+
fn mcp_viewing_detail_title_surfaces_tool_and_server_when_known() {
104+
assert_eq!(
105+
mcp_viewing_detail_title_text("create_issue", Some("github")),
106+
"Viewing MCP tool create_issue on github"
107+
);
108+
assert_eq!(
109+
mcp_viewing_detail_title_text("create_issue", None),
110+
"Viewing MCP tool create_issue"
111+
);
112+
}
113+
114+
#[test]
115+
fn mcp_viewing_detail_title_falls_back_to_generic_message_when_tool_name_empty() {
116+
assert_eq!(
117+
mcp_viewing_detail_title_text("", Some("github")),
118+
"Viewing MCP tool call detail"
119+
);
120+
}

app/src/tui_export.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,3 +308,11 @@ pub fn agent_conversations_cloud_metadata_load_failed(app: &warpui::AppContext)
308308
crate::ai::agent_conversations_model::AgentConversationsModel::as_ref(app)
309309
.cloud_conversation_metadata_load_failed()
310310
}
311+
312+
/// Resolves the user-facing name for an MCP server from its installation/template
313+
/// UUID. Returns `None` when the server is unknown (e.g. a legacy/flat MCP call
314+
/// with no server id, or the server is not installed). Used by the TUI to surface
315+
/// tool/server identity in permission cards and transcript labels.
316+
pub fn mcp_server_name_for_id(uuid: &uuid::Uuid, app: &warpui::AppContext) -> Option<String> {
317+
crate::ai::mcp::TemplatableMCPServerManager::get_mcp_name(uuid, app)
318+
}

crates/warp_tui/src/agent_block_sections.rs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@ use warpui_core::elements::tui::{
1818
};
1919

2020
use crate::agent_block::{CollapsibleSectionStates, TuiAIBlockAction};
21-
use crate::tool_call_labels::{ResolvedCommandBlock, tool_call_display_state, tool_call_label};
21+
use crate::tool_call_labels::{
22+
ResolvedCommandBlock, mcp_server_name_for_action, tool_call_display_state,
23+
tool_call_label_with_server,
24+
};
2225
use crate::tui_builder::TuiUiBuilder;
2326

2427
const INPUT_PREFIX: &str = "> ";
@@ -93,7 +96,14 @@ pub(crate) fn render_fallback_tool_call_section(
9396
let state = tool_call_display_state(status, output_streaming, block.map(|block| block.state));
9497
let glyph_style = state.glyph_style(&builder);
9598
let label_style = state.label_style(&builder);
96-
let label = tool_call_label(action, status, output_streaming, block);
99+
let server_name = mcp_server_name_for_action(&action.action, app);
100+
let label = tool_call_label_with_server(
101+
action,
102+
status,
103+
output_streaming,
104+
block,
105+
server_name.as_deref(),
106+
);
97107
TuiFlex::row()
98108
.child(
99109
TuiText::new(format!("{} ", state.glyph()))

0 commit comments

Comments
 (0)