1use std::{cell::RefCell, path::PathBuf, rc::Rc};
2
3use acp_thread::AcpClientDelegate;
4use agentic_coding_protocol::{self as acp, Client, ReadTextFileParams, WriteTextFileParams};
5use anyhow::{Context, Result};
6use collections::HashMap;
7use context_server::types::{
8 CallToolParams, CallToolResponse, Implementation, InitializeParams, InitializeResponse,
9 ListToolsResponse, ProtocolVersion, ServerCapabilities, Tool, ToolAnnotations,
10 ToolResponseContent, ToolsCapabilities, requests,
11};
12use gpui::{App, AsyncApp, Task};
13use schemars::JsonSchema;
14use serde::{Deserialize, Serialize};
15use util::debug_panic;
16
17// todo! use shared tool inference?
18use crate::claude::tools::{
19 ClaudeTool, EditToolParams, EditToolResponse, ReadToolParams, ReadToolResponse,
20};
21
22pub struct ZedMcpServer {
23 server: context_server::listener::McpServer,
24}
25
26pub const SERVER_NAME: &str = "zed";
27pub const READ_TOOL: &str = "Read";
28pub const EDIT_TOOL: &str = "Edit";
29pub const PERMISSION_TOOL: &str = "Confirmation";
30
31#[derive(Deserialize, JsonSchema, Debug)]
32struct PermissionToolParams {
33 tool_name: String,
34 input: serde_json::Value,
35 tool_use_id: Option<String>,
36}
37
38#[derive(Serialize)]
39#[serde(rename_all = "camelCase")]
40struct PermissionToolResponse {
41 behavior: PermissionToolBehavior,
42 updated_input: serde_json::Value,
43}
44
45#[derive(Serialize)]
46#[serde(rename_all = "snake_case")]
47enum PermissionToolBehavior {
48 Allow,
49 Deny,
50}
51
52impl ZedMcpServer {
53 pub async fn new(
54 delegate: watch::Receiver<Option<AcpClientDelegate>>,
55 tool_id_map: Rc<RefCell<HashMap<String, acp::ToolCallId>>>,
56 cx: &AsyncApp,
57 ) -> Result<Self> {
58 let mut mcp_server = context_server::listener::McpServer::new(cx).await?;
59 mcp_server.handle_request::<requests::Initialize>(Self::handle_initialize);
60 mcp_server.handle_request::<requests::ListTools>(Self::handle_list_tools);
61 mcp_server.handle_request::<requests::CallTool>(move |request, cx| {
62 Self::handle_call_tool(request, delegate.clone(), tool_id_map.clone(), cx)
63 });
64
65 Ok(Self { server: mcp_server })
66 }
67
68 pub fn server_config(&self) -> Result<McpServerConfig> {
69 let zed_path = std::env::current_exe()
70 .context("finding current executable path for use in mcp_server")?;
71
72 Ok(McpServerConfig {
73 command: zed_path,
74 args: vec![
75 "--nc".into(),
76 self.server.socket_path().display().to_string(),
77 ],
78 env: None,
79 })
80 }
81
82 fn handle_initialize(_: InitializeParams, cx: &App) -> Task<Result<InitializeResponse>> {
83 cx.foreground_executor().spawn(async move {
84 Ok(InitializeResponse {
85 protocol_version: ProtocolVersion("2025-06-18".into()),
86 capabilities: ServerCapabilities {
87 experimental: None,
88 logging: None,
89 completions: None,
90 prompts: None,
91 resources: None,
92 tools: Some(ToolsCapabilities {
93 list_changed: Some(false),
94 }),
95 },
96 server_info: Implementation {
97 name: SERVER_NAME.into(),
98 version: "0.1.0".into(),
99 },
100 meta: None,
101 })
102 })
103 }
104
105 fn handle_list_tools(_: (), cx: &App) -> Task<Result<ListToolsResponse>> {
106 cx.foreground_executor().spawn(async move {
107 Ok(ListToolsResponse {
108 tools: vec![
109 Tool {
110 name: PERMISSION_TOOL.into(),
111 input_schema: schemars::schema_for!(PermissionToolParams).into(),
112 description: None,
113 annotations: None,
114 },
115 Tool {
116 name: READ_TOOL.into(),
117 input_schema: schemars::schema_for!(ReadToolParams).into(),
118 description: Some("Read the contents of a file. In sessions with mcp__zed__Read always use it instead of Read as it contains the most up-to-date contents.".to_string()),
119 annotations: Some(ToolAnnotations {
120 title: Some("Read file".to_string()),
121 read_only_hint: Some(true),
122 destructive_hint: Some(false),
123 open_world_hint: Some(false),
124 // if time passes the contents might change, but it's not going to do anything different
125 // true or false seem too strong, let's try a none.
126 idempotent_hint: None,
127 }),
128 },
129 Tool {
130 name: EDIT_TOOL.into(),
131 input_schema: schemars::schema_for!(EditToolParams).into(),
132 description: Some("Edits a file. In sessions with mcp__zed__Edit always use it instead of Edit as it will show the diff to the user better.".to_string()),
133 annotations: Some(ToolAnnotations {
134 title: Some("Edit file".to_string()),
135 read_only_hint: Some(false),
136 destructive_hint: Some(false),
137 open_world_hint: Some(false),
138 idempotent_hint: Some(false),
139 }),
140 },
141 ],
142 next_cursor: None,
143 meta: None,
144 })
145 })
146 }
147
148 fn handle_call_tool(
149 request: CallToolParams,
150 mut delegate_watch: watch::Receiver<Option<AcpClientDelegate>>,
151 tool_id_map: Rc<RefCell<HashMap<String, acp::ToolCallId>>>,
152 cx: &App,
153 ) -> Task<Result<CallToolResponse>> {
154 cx.spawn(async move |cx| {
155 let Some(delegate) = delegate_watch.recv().await? else {
156 debug_panic!("Sent None delegate");
157 anyhow::bail!("Server not available");
158 };
159
160 if request.name.as_str() == PERMISSION_TOOL {
161 let input =
162 serde_json::from_value(request.arguments.context("Arguments required")?)?;
163
164 let result =
165 Self::handle_permissions_tool_call(input, delegate, tool_id_map, cx).await?;
166 Ok(CallToolResponse {
167 content: vec![ToolResponseContent::Text {
168 text: serde_json::to_string(&result)?,
169 }],
170 is_error: None,
171 meta: None,
172 })
173 } else if request.name.as_str() == READ_TOOL {
174 let input =
175 serde_json::from_value(request.arguments.context("Arguments required")?)?;
176
177 let result = Self::handle_read_tool_call(input, delegate, cx).await?;
178 Ok(CallToolResponse {
179 content: vec![ToolResponseContent::Text {
180 text: serde_json::to_string(&result)?,
181 }],
182 is_error: None,
183 meta: None,
184 })
185 } else if request.name.as_str() == EDIT_TOOL {
186 let input =
187 serde_json::from_value(request.arguments.context("Arguments required")?)?;
188
189 let result = Self::handle_edit_tool_call(input, delegate, cx).await?;
190 Ok(CallToolResponse {
191 content: vec![ToolResponseContent::Text {
192 text: serde_json::to_string(&result)?,
193 }],
194 is_error: None,
195 meta: None,
196 })
197 } else {
198 anyhow::bail!("Unsupported tool");
199 }
200 })
201 }
202
203 fn handle_read_tool_call(
204 params: ReadToolParams,
205 delegate: AcpClientDelegate,
206 cx: &AsyncApp,
207 ) -> Task<Result<ReadToolResponse>> {
208 cx.foreground_executor().spawn(async move {
209 let response = delegate
210 .read_text_file(ReadTextFileParams {
211 path: params.abs_path,
212 line: params.offset,
213 limit: params.limit,
214 })
215 .await?;
216
217 Ok(ReadToolResponse {
218 content: response.content,
219 })
220 })
221 }
222
223 fn handle_edit_tool_call(
224 params: EditToolParams,
225 delegate: AcpClientDelegate,
226 cx: &AsyncApp,
227 ) -> Task<Result<EditToolResponse>> {
228 cx.foreground_executor().spawn(async move {
229 let response = delegate
230 .read_text_file_reusing_snapshot(ReadTextFileParams {
231 path: params.abs_path.clone(),
232 line: None,
233 limit: None,
234 })
235 .await?;
236
237 let new_content = response.content.replace(¶ms.old_text, ¶ms.new_text);
238 if new_content == response.content {
239 return Err(anyhow::anyhow!("The old_text was not found in the content"));
240 }
241
242 delegate
243 .write_text_file(WriteTextFileParams {
244 path: params.abs_path,
245 content: new_content,
246 })
247 .await?;
248
249 Ok(EditToolResponse)
250 })
251 }
252
253 fn handle_permissions_tool_call(
254 params: PermissionToolParams,
255 delegate: AcpClientDelegate,
256 tool_id_map: Rc<RefCell<HashMap<String, acp::ToolCallId>>>,
257 cx: &AsyncApp,
258 ) -> Task<Result<PermissionToolResponse>> {
259 cx.foreground_executor().spawn(async move {
260 let claude_tool = ClaudeTool::infer(¶ms.tool_name, params.input.clone());
261
262 let tool_call_id = match params.tool_use_id {
263 Some(tool_use_id) => tool_id_map
264 .borrow()
265 .get(&tool_use_id)
266 .cloned()
267 .context("Tool call ID not found")?,
268
269 None => delegate.push_tool_call(claude_tool.as_acp()).await?.id,
270 };
271
272 let outcome = delegate
273 .request_existing_tool_call_confirmation(
274 tool_call_id,
275 claude_tool.confirmation(None),
276 )
277 .await?;
278
279 match outcome {
280 acp::ToolCallConfirmationOutcome::Allow
281 | acp::ToolCallConfirmationOutcome::AlwaysAllow
282 | acp::ToolCallConfirmationOutcome::AlwaysAllowMcpServer
283 | acp::ToolCallConfirmationOutcome::AlwaysAllowTool => Ok(PermissionToolResponse {
284 behavior: PermissionToolBehavior::Allow,
285 updated_input: params.input,
286 }),
287 acp::ToolCallConfirmationOutcome::Reject
288 | acp::ToolCallConfirmationOutcome::Cancel => Ok(PermissionToolResponse {
289 behavior: PermissionToolBehavior::Deny,
290 updated_input: params.input,
291 }),
292 }
293 })
294 }
295}
296
297#[derive(Serialize)]
298#[serde(rename_all = "camelCase")]
299pub struct McpConfig {
300 pub mcp_servers: HashMap<String, McpServerConfig>,
301}
302
303#[derive(Serialize, Clone)]
304#[serde(rename_all = "camelCase")]
305pub struct McpServerConfig {
306 pub command: PathBuf,
307 pub args: Vec<String>,
308 #[serde(skip_serializing_if = "Option::is_none")]
309 pub env: Option<HashMap<String, String>>,
310}