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