1use std::path::PathBuf;
2
3use crate::claude::tools::{ClaudeTool, EditToolParams, ReadToolParams};
4use acp_thread::AcpThread;
5use agent_client_protocol as acp;
6use anyhow::{Context, Result};
7use collections::HashMap;
8use context_server::listener::{McpServerTool, ToolResponse};
9use context_server::types::{
10 Implementation, InitializeParams, InitializeResponse, ProtocolVersion, ServerCapabilities,
11 ToolAnnotations, ToolResponseContent, ToolsCapabilities, requests,
12};
13use gpui::{App, AsyncApp, Task, WeakEntity};
14use schemars::JsonSchema;
15use serde::{Deserialize, Serialize};
16
17pub struct ClaudeZedMcpServer {
18 server: context_server::listener::McpServer,
19}
20
21pub const SERVER_NAME: &str = "zed";
22
23impl ClaudeZedMcpServer {
24 pub async fn new(
25 thread_rx: watch::Receiver<WeakEntity<AcpThread>>,
26 cx: &AsyncApp,
27 ) -> Result<Self> {
28 let mut mcp_server = context_server::listener::McpServer::new(cx).await?;
29 mcp_server.handle_request::<requests::Initialize>(Self::handle_initialize);
30
31 mcp_server.add_tool(PermissionTool {
32 thread_rx: thread_rx.clone(),
33 });
34 mcp_server.add_tool(ReadTool {
35 thread_rx: thread_rx.clone(),
36 });
37 mcp_server.add_tool(EditTool {
38 thread_rx: thread_rx.clone(),
39 });
40
41 Ok(Self { server: mcp_server })
42 }
43
44 pub fn server_config(&self) -> Result<McpServerConfig> {
45 #[cfg(not(test))]
46 let zed_path = std::env::current_exe()
47 .context("finding current executable path for use in mcp_server")?;
48
49 #[cfg(test)]
50 let zed_path = crate::e2e_tests::get_zed_path();
51
52 Ok(McpServerConfig {
53 command: zed_path,
54 args: vec![
55 "--nc".into(),
56 self.server.socket_path().display().to_string(),
57 ],
58 env: None,
59 })
60 }
61
62 fn handle_initialize(_: InitializeParams, cx: &App) -> Task<Result<InitializeResponse>> {
63 cx.foreground_executor().spawn(async move {
64 Ok(InitializeResponse {
65 protocol_version: ProtocolVersion("2025-06-18".into()),
66 capabilities: ServerCapabilities {
67 experimental: None,
68 logging: None,
69 completions: None,
70 prompts: None,
71 resources: None,
72 tools: Some(ToolsCapabilities {
73 list_changed: Some(false),
74 }),
75 },
76 server_info: Implementation {
77 name: SERVER_NAME.into(),
78 version: "0.1.0".into(),
79 },
80 meta: None,
81 })
82 })
83 }
84}
85
86#[derive(Serialize)]
87#[serde(rename_all = "camelCase")]
88pub struct McpConfig {
89 pub mcp_servers: HashMap<String, McpServerConfig>,
90}
91
92#[derive(Serialize, Clone)]
93#[serde(rename_all = "camelCase")]
94pub struct McpServerConfig {
95 pub command: PathBuf,
96 pub args: Vec<String>,
97 #[serde(skip_serializing_if = "Option::is_none")]
98 pub env: Option<HashMap<String, String>>,
99}
100
101// Tools
102
103#[derive(Clone)]
104pub struct PermissionTool {
105 thread_rx: watch::Receiver<WeakEntity<AcpThread>>,
106}
107
108#[derive(Deserialize, JsonSchema, Debug)]
109pub struct PermissionToolParams {
110 tool_name: String,
111 input: serde_json::Value,
112 tool_use_id: Option<String>,
113}
114
115#[derive(Serialize)]
116#[serde(rename_all = "camelCase")]
117pub struct PermissionToolResponse {
118 behavior: PermissionToolBehavior,
119 updated_input: serde_json::Value,
120}
121
122#[derive(Serialize)]
123#[serde(rename_all = "snake_case")]
124enum PermissionToolBehavior {
125 Allow,
126 Deny,
127}
128
129impl McpServerTool for PermissionTool {
130 type Input = PermissionToolParams;
131 type Output = ();
132
133 const NAME: &'static str = "Confirmation";
134
135 fn description(&self) -> &'static str {
136 "Request permission for tool calls"
137 }
138
139 async fn run(
140 &self,
141 input: Self::Input,
142 cx: &mut AsyncApp,
143 ) -> Result<ToolResponse<Self::Output>> {
144 let mut thread_rx = self.thread_rx.clone();
145 let Some(thread) = thread_rx.recv().await?.upgrade() else {
146 anyhow::bail!("Thread closed");
147 };
148
149 let claude_tool = ClaudeTool::infer(&input.tool_name, input.input.clone());
150 let tool_call_id = acp::ToolCallId(input.tool_use_id.context("Tool ID required")?.into());
151 let allow_option_id = acp::PermissionOptionId("allow".into());
152 let reject_option_id = acp::PermissionOptionId("reject".into());
153
154 let chosen_option = thread
155 .update(cx, |thread, cx| {
156 thread.request_tool_call_authorization(
157 claude_tool.as_acp(tool_call_id),
158 vec![
159 acp::PermissionOption {
160 id: allow_option_id.clone(),
161 name: "Allow".into(),
162 kind: acp::PermissionOptionKind::AllowOnce,
163 },
164 acp::PermissionOption {
165 id: reject_option_id.clone(),
166 name: "Reject".into(),
167 kind: acp::PermissionOptionKind::RejectOnce,
168 },
169 ],
170 cx,
171 )
172 })?
173 .await?;
174
175 let response = if chosen_option == allow_option_id {
176 PermissionToolResponse {
177 behavior: PermissionToolBehavior::Allow,
178 updated_input: input.input,
179 }
180 } else {
181 debug_assert_eq!(chosen_option, reject_option_id);
182 PermissionToolResponse {
183 behavior: PermissionToolBehavior::Deny,
184 updated_input: input.input,
185 }
186 };
187
188 Ok(ToolResponse {
189 content: vec![ToolResponseContent::Text {
190 text: serde_json::to_string(&response)?,
191 }],
192 structured_content: (),
193 })
194 }
195}
196
197#[derive(Clone)]
198pub struct ReadTool {
199 thread_rx: watch::Receiver<WeakEntity<AcpThread>>,
200}
201
202impl McpServerTool for ReadTool {
203 type Input = ReadToolParams;
204 type Output = ();
205
206 const NAME: &'static str = "Read";
207
208 fn description(&self) -> &'static str {
209 "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."
210 }
211
212 fn annotations(&self) -> ToolAnnotations {
213 ToolAnnotations {
214 title: Some("Read file".to_string()),
215 read_only_hint: Some(true),
216 destructive_hint: Some(false),
217 open_world_hint: Some(false),
218 idempotent_hint: None,
219 }
220 }
221
222 async fn run(
223 &self,
224 input: Self::Input,
225 cx: &mut AsyncApp,
226 ) -> Result<ToolResponse<Self::Output>> {
227 let mut thread_rx = self.thread_rx.clone();
228 let Some(thread) = thread_rx.recv().await?.upgrade() else {
229 anyhow::bail!("Thread closed");
230 };
231
232 let content = thread
233 .update(cx, |thread, cx| {
234 thread.read_text_file(input.abs_path, input.offset, input.limit, false, cx)
235 })?
236 .await?;
237
238 Ok(ToolResponse {
239 content: vec![ToolResponseContent::Text { text: content }],
240 structured_content: (),
241 })
242 }
243}
244
245#[derive(Clone)]
246pub struct EditTool {
247 thread_rx: watch::Receiver<WeakEntity<AcpThread>>,
248}
249
250impl McpServerTool for EditTool {
251 type Input = EditToolParams;
252 type Output = ();
253
254 const NAME: &'static str = "Edit";
255
256 fn description(&self) -> &'static str {
257 "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."
258 }
259
260 fn annotations(&self) -> ToolAnnotations {
261 ToolAnnotations {
262 title: Some("Edit file".to_string()),
263 read_only_hint: Some(false),
264 destructive_hint: Some(false),
265 open_world_hint: Some(false),
266 idempotent_hint: Some(false),
267 }
268 }
269
270 async fn run(
271 &self,
272 input: Self::Input,
273 cx: &mut AsyncApp,
274 ) -> Result<ToolResponse<Self::Output>> {
275 let mut thread_rx = self.thread_rx.clone();
276 let Some(thread) = thread_rx.recv().await?.upgrade() else {
277 anyhow::bail!("Thread closed");
278 };
279
280 let content = thread
281 .update(cx, |thread, cx| {
282 thread.read_text_file(input.abs_path.clone(), None, None, true, cx)
283 })?
284 .await?;
285
286 let new_content = content.replace(&input.old_text, &input.new_text);
287 if new_content == content {
288 return Err(anyhow::anyhow!("The old_text was not found in the content"));
289 }
290
291 thread
292 .update(cx, |thread, cx| {
293 thread.write_text_file(input.abs_path, new_content, cx)
294 })?
295 .await?;
296
297 Ok(ToolResponse {
298 content: vec![],
299 structured_content: (),
300 })
301 }
302}