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