1use std::{cell::RefCell, 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 .to_string_lossy()
72 .to_string();
73
74 Ok(McpServerConfig {
75 command: zed_path,
76 args: vec![
77 "--nc".into(),
78 self.server.socket_path().display().to_string(),
79 ],
80 env: None,
81 })
82 }
83
84 fn handle_initialize(_: InitializeParams, cx: &App) -> Task<Result<InitializeResponse>> {
85 cx.foreground_executor().spawn(async move {
86 Ok(InitializeResponse {
87 protocol_version: ProtocolVersion("2025-06-18".into()),
88 capabilities: ServerCapabilities {
89 experimental: None,
90 logging: None,
91 completions: None,
92 prompts: None,
93 resources: None,
94 tools: Some(ToolsCapabilities {
95 list_changed: Some(false),
96 }),
97 },
98 server_info: Implementation {
99 name: SERVER_NAME.into(),
100 version: "0.1.0".into(),
101 },
102 meta: None,
103 })
104 })
105 }
106
107 fn handle_list_tools(_: (), cx: &App) -> Task<Result<ListToolsResponse>> {
108 cx.foreground_executor().spawn(async move {
109 Ok(ListToolsResponse {
110 tools: vec![
111 Tool {
112 name: PERMISSION_TOOL.into(),
113 input_schema: schemars::schema_for!(PermissionToolParams).into(),
114 description: None,
115 annotations: None,
116 },
117 Tool {
118 name: READ_TOOL.into(),
119 input_schema: schemars::schema_for!(ReadToolParams).into(),
120 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()),
121 annotations: Some(ToolAnnotations {
122 title: Some("Read file".to_string()),
123 read_only_hint: Some(true),
124 destructive_hint: Some(false),
125 open_world_hint: Some(false),
126 // if time passes the contents might change, but it's not going to do anything different
127 // true or false seem too strong, let's try a none.
128 idempotent_hint: None,
129 }),
130 },
131 Tool {
132 name: EDIT_TOOL.into(),
133 input_schema: schemars::schema_for!(EditToolParams).into(),
134 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()),
135 annotations: Some(ToolAnnotations {
136 title: Some("Edit file".to_string()),
137 read_only_hint: Some(false),
138 destructive_hint: Some(false),
139 open_world_hint: Some(false),
140 idempotent_hint: Some(false),
141 }),
142 },
143 ],
144 next_cursor: None,
145 meta: None,
146 })
147 })
148 }
149
150 fn handle_call_tool(
151 request: CallToolParams,
152 mut delegate_watch: watch::Receiver<Option<AcpClientDelegate>>,
153 tool_id_map: Rc<RefCell<HashMap<String, acp::ToolCallId>>>,
154 cx: &App,
155 ) -> Task<Result<CallToolResponse>> {
156 cx.spawn(async move |cx| {
157 let Some(delegate) = delegate_watch.recv().await? else {
158 debug_panic!("Sent None delegate");
159 anyhow::bail!("Server not available");
160 };
161
162 if request.name.as_str() == PERMISSION_TOOL {
163 let input =
164 serde_json::from_value(request.arguments.context("Arguments required")?)?;
165
166 let result =
167 Self::handle_permissions_tool_call(input, delegate, tool_id_map, cx).await?;
168 Ok(CallToolResponse {
169 content: vec![ToolResponseContent::Text {
170 text: serde_json::to_string(&result)?,
171 }],
172 is_error: None,
173 meta: None,
174 })
175 } else if request.name.as_str() == READ_TOOL {
176 let input =
177 serde_json::from_value(request.arguments.context("Arguments required")?)?;
178
179 let result = Self::handle_read_tool_call(input, delegate, cx).await?;
180 Ok(CallToolResponse {
181 content: vec![ToolResponseContent::Text {
182 text: serde_json::to_string(&result)?,
183 }],
184 is_error: None,
185 meta: None,
186 })
187 } else if request.name.as_str() == EDIT_TOOL {
188 let input =
189 serde_json::from_value(request.arguments.context("Arguments required")?)?;
190
191 let result = Self::handle_edit_tool_call(input, delegate, cx).await?;
192 Ok(CallToolResponse {
193 content: vec![ToolResponseContent::Text {
194 text: serde_json::to_string(&result)?,
195 }],
196 is_error: None,
197 meta: None,
198 })
199 } else {
200 anyhow::bail!("Unsupported tool");
201 }
202 })
203 }
204
205 fn handle_read_tool_call(
206 params: ReadToolParams,
207 delegate: AcpClientDelegate,
208 cx: &AsyncApp,
209 ) -> Task<Result<ReadToolResponse>> {
210 cx.foreground_executor().spawn(async move {
211 let response = delegate
212 .read_text_file(ReadTextFileParams {
213 path: params.abs_path,
214 line: params.offset,
215 limit: params.limit,
216 })
217 .await?;
218
219 Ok(ReadToolResponse {
220 content: response.content,
221 })
222 })
223 }
224
225 fn handle_edit_tool_call(
226 params: EditToolParams,
227 delegate: AcpClientDelegate,
228 cx: &AsyncApp,
229 ) -> Task<Result<EditToolResponse>> {
230 cx.foreground_executor().spawn(async move {
231 let response = delegate
232 .read_text_file_reusing_snapshot(ReadTextFileParams {
233 path: params.abs_path.clone(),
234 line: None,
235 limit: None,
236 })
237 .await?;
238
239 let new_content = response.content.replace(¶ms.old_text, ¶ms.new_text);
240 if new_content == response.content {
241 return Err(anyhow::anyhow!("The old_text was not found in the content"));
242 }
243
244 delegate
245 .write_text_file(WriteTextFileParams {
246 path: params.abs_path,
247 content: new_content,
248 })
249 .await?;
250
251 Ok(EditToolResponse)
252 })
253 }
254
255 fn handle_permissions_tool_call(
256 params: PermissionToolParams,
257 delegate: AcpClientDelegate,
258 tool_id_map: Rc<RefCell<HashMap<String, acp::ToolCallId>>>,
259 cx: &AsyncApp,
260 ) -> Task<Result<PermissionToolResponse>> {
261 cx.foreground_executor().spawn(async move {
262 let claude_tool = ClaudeTool::infer(¶ms.tool_name, params.input.clone());
263
264 let tool_call_id = match params.tool_use_id {
265 Some(tool_use_id) => tool_id_map
266 .borrow()
267 .get(&tool_use_id)
268 .cloned()
269 .context("Tool call ID not found")?,
270
271 None => delegate.push_tool_call(claude_tool.as_acp()).await?.id,
272 };
273
274 let outcome = delegate
275 .request_existing_tool_call_confirmation(
276 tool_call_id,
277 claude_tool.confirmation(None),
278 )
279 .await?;
280
281 match outcome {
282 acp::ToolCallConfirmationOutcome::Allow
283 | acp::ToolCallConfirmationOutcome::AlwaysAllow
284 | acp::ToolCallConfirmationOutcome::AlwaysAllowMcpServer
285 | acp::ToolCallConfirmationOutcome::AlwaysAllowTool => Ok(PermissionToolResponse {
286 behavior: PermissionToolBehavior::Allow,
287 updated_input: params.input,
288 }),
289 acp::ToolCallConfirmationOutcome::Reject
290 | acp::ToolCallConfirmationOutcome::Cancel => Ok(PermissionToolResponse {
291 behavior: PermissionToolBehavior::Deny,
292 updated_input: params.input,
293 }),
294 }
295 })
296 }
297}
298
299#[derive(Serialize)]
300#[serde(rename_all = "camelCase")]
301pub struct McpConfig {
302 pub mcp_servers: HashMap<String, McpServerConfig>,
303}
304
305#[derive(Serialize)]
306#[serde(rename_all = "camelCase")]
307pub struct McpServerConfig {
308 command: String,
309 args: Vec<String>,
310 #[serde(skip_serializing_if = "Option::is_none")]
311 env: Option<HashMap<String, String>>,
312}