From 4f210c6f8bd638dc4ca6007d43addf26444c7392 Mon Sep 17 00:00:00 2001
From: zed-patches <zed-patches@localhost>
Date: Tue, 1 Sep 2026 11:55:36 -0300
Subject: [PATCH] feat(claude_code_ide): serve the Claude Code IDE protocol on
 a loopback WebSocket

Creates crates/claude_code_ide/ and wires it into crates/project and crates/zed,
so the `claude` CLI running in Zed's integrated terminal connects to the editor
the way it does to VS Code and JetBrains: `/ide` finds a server, selections and
diagnostics flow, and a proposed change opens as a real diff tab with Keep and
Reject rather than as a wall of text in the terminal.

Shape. One server per workspace window, bound to 127.0.0.1:0. Each writes
~/.claude/ide/<port>.lock (0600 in a 0700 directory, atomically, so the CLI never
reads a half-written file) carrying a per-window random token, and the terminal
gets CLAUDE_CODE_SSE_PORT and ENABLE_IDE_INTEGRATION so the CLI auto-connects.
The trust boundary is processes running as this user, which is what the official
editor integrations assume too.

`openDiff` blocks until the user decides and then returns the accepted contents;
it deliberately does not write the file, because the CLI performs that write and
doing both would race.

The protocol this speaks is defined by the CLI and documented nowhere, so two
clauses are worth naming here -- both were wrong in this patch's first version,
and both fail silently:

  - The lock file must carry `useWebSocket: true`. The CLI branches on that field
    alone; `transport: "ws"` is never read, and without the boolean the CLI
    builds http://host:port/sse and reaches nothing. Note serde's camelCase
    renders `use_websocket` as `useWebsocket`, with a lowercase `s`, which the
    CLI also ignores -- hence the explicit #[serde(rename)].
  - The handshake must echo `Sec-WebSocket-Protocol: mcp`. The CLI offers it on
    every connection and its WebSocket client closes with 1002, "Missing client
    protocol", when the 101 does not name it back.

Neither breaks the build, so neither is caught by anything that checks whether
this patch still applies. zed-patches/scripts/check-protocol.sh is what watches
them, and terminal-ide/CLAUDE.md records how to re-derive them from the shipped
CLI when it next moves.

Upstream PR #58300 was closed unmerged on 2026-06-30. Discussion #58338 is where
this work lives now, and where protocol breakage is reported first.
---

diff --git a/Cargo.lock b/Cargo.lock
index d4b1f45..9757ed1 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3085,6 +3085,31 @@ version = "0.7.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d"
 
+[[package]]
+name = "claude_code_ide"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "async-tungstenite",
+ "buffer_diff",
+ "collections",
+ "editor",
+ "futures 0.3.34",
+ "gpui",
+ "language",
+ "log",
+ "paths",
+ "project",
+ "serde",
+ "serde_json",
+ "smol",
+ "text",
+ "ui",
+ "util",
+ "uuid",
+ "workspace",
+]
+
 [[package]]
 name = "cli"
 version = "0.1.0"
@@ -22807,6 +22832,7 @@ dependencies = [
  "channel",
  "chrono",
  "clap",
+ "claude_code_ide",
  "cli",
  "client",
  "clock",
diff --git a/Cargo.toml b/Cargo.toml
index 44edcd8..6941fcb 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -26,6 +26,7 @@ members = [
     "crates/call",
     "crates/call_hierarchy",
     "crates/channel",
+    "crates/claude_code_ide",
     "crates/cli",
     "crates/client",
     "crates/clock",
@@ -299,6 +300,7 @@ buffer_diff = { path = "crates/buffer_diff" }
 call = { path = "crates/call" }
 call_hierarchy = { path = "crates/call_hierarchy" }
 channel = { path = "crates/channel" }
+claude_code_ide = { path = "crates/claude_code_ide" }
 cli = { path = "crates/cli" }
 client = { path = "crates/client" }
 clock = { path = "crates/clock" }
diff --git a/crates/claude_code_ide/Cargo.toml b/crates/claude_code_ide/Cargo.toml
new file mode 100644
index 0000000..54fb658
--- /dev/null
+++ b/crates/claude_code_ide/Cargo.toml
@@ -0,0 +1,35 @@
+[package]
+name = "claude_code_ide"
+version = "0.1.0"
+edition.workspace = true
+publish = false
+license = "GPL-3.0-or-later"
+description = "IDE-side of the Claude Code editor integration protocol (WebSocket MCP server) so the Claude Code CLI connects to Zed like it does to VS Code and JetBrains"
+
+[lints]
+workspace = true
+
+[lib]
+path = "src/claude_code_ide.rs"
+doctest = false
+
+[dependencies]
+anyhow.workspace = true
+async-tungstenite.workspace = true
+buffer_diff.workspace = true
+collections.workspace = true
+editor.workspace = true
+futures.workspace = true
+gpui.workspace = true
+language.workspace = true
+log.workspace = true
+paths.workspace = true
+project.workspace = true
+serde.workspace = true
+serde_json.workspace = true
+smol.workspace = true
+text.workspace = true
+ui.workspace = true
+util.workspace = true
+uuid.workspace = true
+workspace.workspace = true
diff --git a/crates/claude_code_ide/README.md b/crates/claude_code_ide/README.md
new file mode 100644
index 0000000..eb59c5d
--- /dev/null
+++ b/crates/claude_code_ide/README.md
@@ -0,0 +1,66 @@
+# claude_code_ide
+
+Native [Claude Code](https://docs.claude.com/en/docs/claude-code) IDE integration
+for Zed.
+
+This crate lets the `claude` CLI connect to Zed the same way it connects to
+VS Code, JetBrains and Neovim — over Claude Code's native WebSocket + MCP
+"IDE integration" protocol. It is **not** ACP: Zed already exposes Claude Code
+as an ACP agent in the agent panel; this is the complementary direction, where
+`claude` running in Zed's integrated terminal drives the editor.
+
+## What you get
+
+- **Auto-connect** in Zed's integrated terminal — no `/ide` needed.
+- `@`-mentions of your current selection (`getCurrentSelection`).
+- Model-visible **diagnostics** (`getDiagnostics`).
+- Open editors / open file / save / dirty checks
+  (`getOpenEditors`, `openFile`, `saveDocument`, `checkDocumentDirty`).
+- **Blocking accept/reject diffs** (`openDiff`): Claude's proposed change opens
+  as a side-by-side diff tab, the view centers on the first change, and the CLI
+  blocks until you click **Keep** (green) or **Reject** (red).
+
+## How it works
+
+1. Each workspace starts a per-window server: it binds `127.0.0.1:0`, then
+   writes a lockfile to `~/.claude/ide/<port>.lock` (honoring
+   `CLAUDE_CONFIG_DIR`) with `0600` perms inside a `0700` dir. The lockfile
+   advertises `{pid, workspaceFolders, ideName: "Zed", transport: "ws",
+   authToken}` so the CLI can discover the IDE.
+2. The transport is a WebSocket authenticated by the
+   `x-claude-code-ide-authorization` header (which must equal `authToken`),
+   speaking JSON-RPC 2.0 / MCP (`initialize`, `tools/list`, `tools/call`),
+   protocol version `2024-11-05`.
+3. Auto-connect: the workspace publishes the server port onto `Project`, and
+   `crates/project/src/terminals.rs` injects `CLAUDE_CODE_SSE_PORT` and
+   `ENABLE_IDE_INTEGRATION=true` into the integrated terminal, so `claude`
+   connects automatically. An external terminal can still attach via `/ide`,
+   discovering Zed from the lockfile.
+
+Entry point: `claude_code_ide::init(cx)`, called from `crates/zed/src/main.rs`.
+
+## Tools
+
+| Tool | Purpose |
+| --- | --- |
+| `getCurrentSelection` / `getLatestSelection` | Active editor selection (for `@`-mentions). |
+| `getWorkspaceFolders` | Visible worktree roots. |
+| `getOpenEditors` | Open buffers with uri/label/language/dirty state. |
+| `getDiagnostics` | Language diagnostics, all buffers or one uri. |
+| `openFile` | Open a path, optionally selecting a line range. |
+| `saveDocument` / `checkDocumentDirty` | Save a buffer / query its dirty state. |
+| `openDiff` | Blocking side-by-side diff with Keep/Reject. |
+
+## Try it
+
+```bash
+cargo run --release -p zed   # Linux: run ./script/linux once for build deps
+```
+
+Open Zed's integrated terminal and run `claude` — it connects automatically.
+Edit a file through Claude and a Keep/Reject diff opens in the editor.
+
+## Limitations
+
+- Keep/Reject hotkeys are intentionally omitted; use the notification buttons.
+
diff --git a/crates/claude_code_ide/src/claude_code_ide.rs b/crates/claude_code_ide/src/claude_code_ide.rs
new file mode 100644
index 0000000..01d8350
--- /dev/null
+++ b/crates/claude_code_ide/src/claude_code_ide.rs
@@ -0,0 +1,222 @@
+//! IDE-side implementation of the Claude Code editor integration protocol.
+//!
+//! This lets the Claude Code CLI connect to Zed the same way it connects to the
+//! official VS Code and JetBrains extensions: Zed runs a localhost WebSocket
+//! server speaking a WebSocket variant of MCP, advertises it through a lock file
+//! under `~/.claude/ide/`, and points the CLI at it via the `CLAUDE_CODE_SSE_PORT`
+//! and `ENABLE_IDE_INTEGRATION` environment variables in its integrated terminal.
+//!
+//! [`init`] wires one [`ClaudeCodeIdeServer`] per [`Workspace`]: it binds a
+//! loopback port, writes the lock file, and serves connections until the window
+//! closes, at which point the lock file is removed.
+
+mod lockfile;
+mod open_diff;
+mod server;
+mod tools;
+
+use std::{cell::Cell, cell::RefCell, path::PathBuf, rc::Rc};
+
+use anyhow::{Context as _, Result};
+use collections::HashMap;
+use gpui::{
+    AnyWindowHandle, App, AppContext as _, AsyncApp, Context, Entity, EntityId, Subscription, Task,
+    WeakEntity,
+};
+use project::Project;
+use util::ResultExt as _;
+use workspace::Workspace;
+
+pub use lockfile::{IDE_NAME, generate_auth_token};
+pub use server::{Dispatcher, ProtocolError, ToolDescriptor, bind, serve_connection};
+pub use tools::WorkspaceDispatcher;
+
+/// Registers a Claude Code IDE server for every workspace window.
+///
+/// Call once during app startup. Each created [`Workspace`] gets its own server
+/// entity, kept alive in `servers` for the window's lifetime and dropped (which
+/// removes its lock file) when the workspace is released.
+pub fn init(cx: &mut App) {
+    let servers: Rc<RefCell<HashMap<EntityId, Entity<ClaudeCodeIdeServer>>>> = Rc::default();
+    cx.observe_new({
+        let servers = servers.clone();
+        move |workspace: &mut Workspace, window, cx: &mut Context<Workspace>| {
+            let workspace_id = cx.entity_id();
+            let workspace_handle = cx.entity().downgrade();
+            let project = workspace.project().clone();
+            let window_handle = window.map(|window| window.window_handle());
+            let server = cx
+                .new(|cx| ClaudeCodeIdeServer::new(workspace_handle, project, window_handle, cx));
+            servers.borrow_mut().insert(workspace_id, server);
+
+            cx.on_release({
+                let servers = servers.clone();
+                move |_workspace, _cx| {
+                    servers.borrow_mut().remove(&workspace_id);
+                }
+            })
+            .detach();
+        }
+    })
+    .detach();
+
+    // Lock files are removed when a window closes (see `Drop`), but a hard quit
+    // skips destructors, so clean them up explicitly on app exit too.
+    // The last use of `servers`, so it moves in rather than being cloned.
+    cx.on_app_quit(move |cx| {
+        for server in servers.borrow().values() {
+            server.update(cx, |server, _| server.remove_lockfile());
+        }
+        async move {}
+    })
+    .detach();
+}
+
+/// One running WebSocket server, bound to a single workspace window.
+struct ClaudeCodeIdeServer {
+    /// Set once the listener is bound; read by `Drop` to remove the lock file.
+    /// Shared with the accept-loop task, which is the writer.
+    port: Rc<Cell<Option<u16>>>,
+    /// Set alongside `port`, and kept so the lock file can be rewritten when the
+    /// workspace's folders change without minting a new token.
+    auth_token: Rc<RefCell<Option<String>>>,
+    /// Read back when rewriting the lock file, to list the current folders.
+    workspace: WeakEntity<Workspace>,
+    /// The bind + accept loop. Dropping it (when the workspace closes) cancels
+    /// the loop, stopping the server.
+    _server_task: Task<()>,
+    /// Keeps us subscribed to the project's worktree changes.
+    _worktrees_subscription: Subscription,
+}
+
+impl ClaudeCodeIdeServer {
+    fn new(
+        workspace: WeakEntity<Workspace>,
+        project: Entity<Project>,
+        window: Option<AnyWindowHandle>,
+        cx: &mut Context<Self>,
+    ) -> Self {
+        let port = Rc::new(Cell::new(None));
+        let auth_token = Rc::new(RefCell::new(None));
+        let server_task = cx.spawn({
+            let port = port.clone();
+            let auth_token = auth_token.clone();
+            let workspace = workspace.clone();
+            async move |_this, cx| {
+                if let Err(error) = Self::run(workspace, window, port, auth_token, cx).await {
+                    log::error!("Claude Code IDE server stopped: {error:#}");
+                }
+            }
+        });
+
+        // A workspace's folders are not loaded yet when it is first observed, so
+        // the lock file `run` writes can start out with an empty folder list.
+        // The CLI matches its working directory against that list to pick an
+        // IDE, so rewrite the file whenever the set of folders changes.
+        let worktrees_subscription =
+            cx.subscribe(&project, |this, _project, event: &project::Event, cx| {
+                if matches!(
+                    event,
+                    project::Event::WorktreeAdded(_)
+                        | project::Event::WorktreeRemoved(_)
+                        | project::Event::WorktreeOrderChanged
+                ) {
+                    this.rewrite_lockfile(cx);
+                }
+            });
+
+        Self {
+            port,
+            auth_token,
+            workspace,
+            _server_task: server_task,
+            _worktrees_subscription: worktrees_subscription,
+        }
+    }
+
+    /// Rewrites this server's lock file with the workspace's current folders.
+    ///
+    /// A no-op until the listener is bound, since `run` writes the file itself
+    /// once it has a port; the write is atomic, so the CLI never reads a
+    /// half-updated file.
+    fn rewrite_lockfile(&self, cx: &mut App) {
+        let (Some(port), Some(auth_token)) = (self.port.get(), self.auth_token.borrow().clone())
+        else {
+            return;
+        };
+        let Some(workspace) = self.workspace.upgrade() else {
+            return;
+        };
+        let folders = workspace_folders_of(workspace.read(cx), cx);
+        lockfile::create(port, &auth_token, &folders).log_err();
+    }
+
+    async fn run(
+        workspace: WeakEntity<Workspace>,
+        window: Option<AnyWindowHandle>,
+        port_cell: Rc<Cell<Option<u16>>>,
+        auth_token_cell: Rc<RefCell<Option<String>>>,
+        cx: &mut AsyncApp,
+    ) -> Result<()> {
+        let (listener, port) = bind().await?;
+        let auth_token = generate_auth_token();
+
+        let workspace_folders = workspace
+            .update(cx, |workspace, cx| workspace_folders_of(workspace, cx))
+            .context("reading workspace folders")?;
+
+        lockfile::create(port, &auth_token, &workspace_folders)?;
+        port_cell.set(Some(port));
+        *auth_token_cell.borrow_mut() = Some(auth_token.clone());
+
+        // Publish the port to the project so newly opened terminals advertise it
+        // to the Claude CLI via `CLAUDE_CODE_SSE_PORT`.
+        workspace
+            .update(cx, |workspace, cx| {
+                workspace
+                    .project()
+                    .update(cx, |project, _| project.set_claude_code_ide_port(Some(port)));
+            })
+            .context("publishing IDE port to project")?;
+
+        log::info!("Claude Code IDE server listening on 127.0.0.1:{port}");
+
+        // Each accepted connection is served on the foreground executor so its
+        // tool handlers can touch workspace entities; the async I/O still yields,
+        // so it never blocks the UI.
+        while let Ok((stream, _addr)) = listener.accept().await {
+            let dispatcher = WorkspaceDispatcher::new(workspace.clone(), window, cx.clone());
+            let auth_token = auth_token.clone();
+            cx.spawn(async move |_cx| {
+                serve_connection(stream, auth_token, dispatcher).await.log_err();
+            })
+            .detach();
+        }
+
+        Ok(())
+    }
+
+    /// Removes this server's lock file, if one has been written. Idempotent.
+    fn remove_lockfile(&self) {
+        if let Some(port) = self.port.get() {
+            lockfile::remove(port).log_err();
+        }
+    }
+}
+
+impl Drop for ClaudeCodeIdeServer {
+    fn drop(&mut self) {
+        self.remove_lockfile();
+    }
+}
+
+/// The absolute paths of a workspace's visible folders, as the lock file and the
+/// `getWorkspaceFolders` tool report them.
+fn workspace_folders_of(workspace: &Workspace, cx: &App) -> Vec<PathBuf> {
+    workspace
+        .project()
+        .read(cx)
+        .visible_worktrees(cx)
+        .map(|worktree| worktree.read(cx).abs_path().to_path_buf())
+        .collect()
+}
diff --git a/crates/claude_code_ide/src/lockfile.rs b/crates/claude_code_ide/src/lockfile.rs
new file mode 100644
index 0000000..78b1095
--- /dev/null
+++ b/crates/claude_code_ide/src/lockfile.rs
@@ -0,0 +1,166 @@
+//! Discovery lock files for the Claude Code IDE integration.
+//!
+//! The Claude Code CLI finds a running IDE WebSocket server by scanning
+//! `~/.claude/ide/*.lock`. Each file describes one server: which workspace it
+//! serves, the PID that owns it, and the auth token the CLI must present in the
+//! `x-claude-code-ide-authorization` header when it connects. We mirror the
+//! format the official VS Code and JetBrains extensions write.
+
+use anyhow::{Context as _, Result};
+use serde::Serialize;
+use std::{fs, path::PathBuf};
+
+/// The `ideName` reported to the CLI; shown in its `/ide` status output.
+pub const IDE_NAME: &str = "Zed";
+
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct LockFileContents {
+    pid: u32,
+    workspace_folders: Vec<String>,
+    ide_name: &'static str,
+    /// The field the CLI actually branches on when it builds our URL:
+    /// `true` gives `ws://host:port`, absent or `false` gives
+    /// `http://host:port/sse`. We serve only WebSocket, so omitting this
+    /// sends the CLI to an SSE endpoint that nothing answers.
+    ///
+    /// Renamed explicitly: `rename_all = "camelCase"` would emit
+    /// `useWebsocket`, with a lowercase `s`, which the CLI does not read.
+    #[serde(rename = "useWebSocket")]
+    use_websocket: bool,
+    /// Descriptive only. The CLI's lock-file parser does not read it; it is
+    /// kept because the official extensions write it and an older CLI may.
+    transport: &'static str,
+    auth_token: String,
+}
+
+impl LockFileContents {
+    fn new(auth_token: &str, workspace_folders: &[PathBuf]) -> Self {
+        Self {
+            pid: std::process::id(),
+            workspace_folders: workspace_folders
+                .iter()
+                .map(|path| path.to_string_lossy().into_owned())
+                .collect(),
+            ide_name: IDE_NAME,
+            use_websocket: true,
+            transport: "ws",
+            auth_token: auth_token.to_owned(),
+        }
+    }
+}
+
+/// `$CLAUDE_CONFIG_DIR/ide` when that variable is set and non-empty, otherwise
+/// `~/.claude/ide` — the same resolution the CLI itself performs.
+pub fn lock_dir() -> PathBuf {
+    match std::env::var_os("CLAUDE_CONFIG_DIR") {
+        Some(dir) if !dir.is_empty() => PathBuf::from(dir).join("ide"),
+        _ => paths::home_dir().join(".claude").join("ide"),
+    }
+}
+
+pub fn lock_path(port: u16) -> PathBuf {
+    lock_dir().join(format!("{port}.lock"))
+}
+
+/// A fresh v4 UUID, matching the auth-token format the official extensions use.
+pub fn generate_auth_token() -> String {
+    uuid::Uuid::new_v4().to_string()
+}
+
+/// Writes the lock file for `port`, returning its path.
+///
+/// The write is atomic (temp file + rename) so the CLI never observes a
+/// partially written file while scanning the directory.
+pub fn create(port: u16, auth_token: &str, workspace_folders: &[PathBuf]) -> Result<PathBuf> {
+    let dir = lock_dir();
+    fs::create_dir_all(&dir).with_context(|| format!("creating lock dir {dir:?}"))?;
+    // The token is a secret, so restrict the directory to the current user
+    // (0700), matching what the official extensions do.
+    set_owner_only(&dir, 0o700)?;
+
+    let contents = LockFileContents::new(auth_token, workspace_folders);
+    let json = serde_json::to_string(&contents).context("serializing lock file")?;
+
+    let path = dir.join(format!("{port}.lock"));
+    let temp = dir.join(format!("{port}.lock.tmp"));
+    fs::write(&temp, json).with_context(|| format!("writing {temp:?}"))?;
+    // Restrict to 0600 before the rename publishes the file, so the token is
+    // never briefly world-readable.
+    set_owner_only(&temp, 0o600)?;
+    fs::rename(&temp, &path).with_context(|| format!("renaming {temp:?} to {path:?}"))?;
+    Ok(path)
+}
+
+/// Sets owner-only Unix permissions (e.g. `0o700`, `0o600`). A no-op on
+/// platforms without Unix permission bits.
+#[cfg(unix)]
+fn set_owner_only(path: &std::path::Path, mode: u32) -> Result<()> {
+    use std::os::unix::fs::PermissionsExt as _;
+    fs::set_permissions(path, fs::Permissions::from_mode(mode))
+        .with_context(|| format!("setting mode {mode:o} on {path:?}"))
+}
+
+#[cfg(not(unix))]
+fn set_owner_only(_path: &std::path::Path, _mode: u32) -> Result<()> {
+    Ok(())
+}
+
+/// Removes the lock file for `port`. Succeeds if it is already gone.
+pub fn remove(port: u16) -> Result<()> {
+    let path = lock_path(port);
+    match fs::remove_file(&path) {
+        Ok(()) => Ok(()),
+        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
+        Err(error) => Err(error).with_context(|| format!("removing {path:?}")),
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn auth_token_is_a_uuid() {
+        let token = generate_auth_token();
+        assert_eq!(token.len(), 36);
+        assert!(uuid::Uuid::parse_str(&token).is_ok());
+    }
+
+    /// Asserts against what `create` publishes, not against a struct the test
+    /// builds itself: the earlier version of this test hard-coded its own field
+    /// values, so it stayed green while the file we actually wrote was missing
+    /// the one field the CLI branches on.
+    #[test]
+    fn lock_file_matches_expected_wire_format() {
+        let contents = LockFileContents::new("the-token", &[PathBuf::from("/home/user/project")]);
+        let value: serde_json::Value =
+            serde_json::from_str(&serde_json::to_string(&contents).unwrap()).unwrap();
+
+        assert_eq!(value["pid"], std::process::id());
+        assert_eq!(value["workspaceFolders"][0], "/home/user/project");
+        assert_eq!(value["ideName"], "Zed");
+        assert_eq!(value["authToken"], "the-token");
+        assert_eq!(value["transport"], "ws");
+    }
+
+    /// Without `useWebSocket: true` the CLI builds `http://host:port/sse` and
+    /// never reaches the WebSocket server this crate runs, so `/ide` reports no
+    /// IDE at all. Pinned separately because it is the whole integration.
+    #[test]
+    fn lock_file_selects_the_websocket_transport() {
+        let contents = LockFileContents::new("the-token", &[]);
+        let value: serde_json::Value =
+            serde_json::from_str(&serde_json::to_string(&contents).unwrap()).unwrap();
+
+        assert_eq!(value["useWebSocket"], true);
+    }
+
+    #[test]
+    fn lock_dir_honors_claude_config_dir() {
+        // Documents the resolution rule; we avoid mutating process env here to
+        // keep the test free of global state.
+        let default_dir = paths::home_dir().join(".claude").join("ide");
+        assert!(default_dir.ends_with(".claude/ide"));
+    }
+}
diff --git a/crates/claude_code_ide/src/open_diff.rs b/crates/claude_code_ide/src/open_diff.rs
new file mode 100644
index 0000000..14a3c79
--- /dev/null
+++ b/crates/claude_code_ide/src/open_diff.rs
@@ -0,0 +1,219 @@
+//! The blocking `openDiff` tool.
+//!
+//! Shows Claude's proposed change as a side-by-side diff tab in Zed plus a
+//! Keep/Reject notification, then blocks until the user decides. On Keep we
+//! return `FILE_SAVED` together with the final buffer contents and let the
+//! Claude CLI perform the actual write (matching the official IDE protocol);
+//! on Reject we return `DIFF_REJECTED` and discard the change. The IDE
+//! deliberately does *not* write the file itself, which would race the CLI's
+//! own save.
+
+use crate::server::{ProtocolError, error_codes};
+use buffer_diff::BufferDiff;
+use editor::{DiffViewStyle, MultiBuffer, SelectionEffects, SplittableEditor, scroll::Autoscroll};
+use futures::channel::oneshot;
+use gpui::{AnyWindowHandle, AppContext as _, AsyncApp, DismissEvent, WeakEntity};
+use language::Buffer;
+use serde_json::{Value, json};
+use ui::{Color, IconName};
+use std::{cell::RefCell, rc::Rc};
+use workspace::{
+    SaveIntent, SplitDirection, Workspace,
+    notifications::{NotificationId, simple_message_notification::MessageNotification},
+};
+
+/// Distinguishes our notification from others in the notification registry.
+struct ClaudeDiffNotification;
+
+pub async fn open_diff(
+    workspace: WeakEntity<Workspace>,
+    window: Option<AnyWindowHandle>,
+    arguments: Value,
+    cx: &mut AsyncApp,
+) -> Result<Value, ProtocolError> {
+    let string_arg = |key: &str| arguments.get(key).and_then(Value::as_str).map(str::to_owned);
+
+    let old_file_path = string_arg("old_file_path")
+        .ok_or_else(|| ProtocolError::new(error_codes::INVALID_REQUEST, "missing old_file_path"))?;
+    let new_file_contents = string_arg("new_file_contents").ok_or_else(|| {
+        ProtocolError::new(error_codes::INVALID_REQUEST, "missing new_file_contents")
+    })?;
+    let tab_name = string_arg("tab_name").unwrap_or_else(|| "Proposed changes".to_owned());
+
+    let window =
+        window.ok_or_else(|| ProtocolError::internal("no window available to show a diff"))?;
+
+    // The current on-disk contents are the diff base (the "old" side).
+    let old_contents = {
+        let path = old_file_path.clone();
+        smol::unblock(move || std::fs::read_to_string(&path).unwrap_or_default()).await
+    };
+
+    let (decision_tx, decision_rx) = oneshot::channel::<bool>();
+    let decision_tx = Rc::new(RefCell::new(Some(decision_tx)));
+
+    let message = format!("Claude proposes changes to {old_file_path}");
+
+    // Build the proposed buffer and start computing the diff against the old
+    // contents (the base). `set_base_text` is asynchronous, so we await it
+    // before showing the editor to avoid flashing a "whole file added" diff.
+    let (buffer, diff, base_ready) = window
+        .update(cx, |_root, _window, cx| {
+            let buffer = cx.new(|cx| Buffer::local(new_file_contents.clone(), cx));
+            let snapshot = buffer.read(cx).text_snapshot();
+            let diff = cx.new(|cx| BufferDiff::new(&snapshot, None, None, cx));
+            let base_ready = diff.update(cx, |diff, cx| {
+                diff.set_base_text(Some(old_contents.into()), snapshot, cx)
+            });
+            (buffer, diff, base_ready)
+        })
+        .map_err(|error| ProtocolError::internal(error.to_string()))?;
+
+    base_ready.await;
+
+    let editor_id = window
+        .update(cx, |_root, window, cx| {
+            let multibuffer = cx.new(|cx| {
+                let mut multibuffer = MultiBuffer::singleton(buffer.clone(), cx);
+                multibuffer.add_diff(diff, cx);
+                multibuffer
+            });
+
+            let Some(workspace_entity) = workspace.upgrade() else {
+                return None;
+            };
+            let project = workspace_entity.read(cx).project().clone();
+
+            // `DiffViewStyle::Split` renders side-by-side (old on the left, new on
+            // the right) like the JetBrains diff viewer; `SplittableEditor` is
+            // itself a workspace item, so it can be opened directly as a tab.
+            let diff_editor = cx.new(|cx| {
+                SplittableEditor::new(
+                    DiffViewStyle::Split,
+                    multibuffer,
+                    project,
+                    workspace_entity.clone(),
+                    window,
+                    cx,
+                )
+            });
+            let editor_id = diff_editor.entity_id();
+
+            workspace_entity.update(cx, |workspace, cx| {
+                // The active pane holds Claude's integrated terminal: the CLI has
+                // focus there when it sends `openDiff`. Show the diff in any other
+                // pane so it doesn't cover the terminal; if the terminal is the
+                // only pane, split a new one off to its left.
+                let active_pane_id = workspace.active_pane().entity_id();
+                let other_pane = workspace
+                    .panes()
+                    .iter()
+                    .find(|pane| pane.entity_id() != active_pane_id)
+                    .cloned();
+                if let Some(target_pane) = other_pane {
+                    workspace.add_item(
+                        target_pane,
+                        Box::new(diff_editor.clone()),
+                        None,
+                        true,
+                        true,
+                        window,
+                        cx,
+                    );
+                } else {
+                    workspace.split_item(
+                        SplitDirection::Left,
+                        Box::new(diff_editor.clone()),
+                        window,
+                        cx,
+                    );
+                }
+
+                let tx_keep = decision_tx.clone();
+                let tx_reject = decision_tx.clone();
+                workspace.show_notification(
+                    NotificationId::composite::<ClaudeDiffNotification>(tab_name),
+                    cx,
+                    move |cx| {
+                        cx.new(|cx| {
+                            MessageNotification::new(message, cx)
+                                .primary_message("Keep")
+                                .primary_icon(IconName::Check)
+                                .primary_icon_color(Color::Success)
+                                .primary_on_click(move |_window, cx| {
+                                    if let Some(tx) = tx_keep.borrow_mut().take() {
+                                        let _ = tx.send(true);
+                                    }
+                                    cx.emit(DismissEvent);
+                                })
+                                .secondary_message("Reject")
+                                .secondary_icon(IconName::Close)
+                                .secondary_icon_color(Color::Error)
+                                .secondary_on_click(move |_window, cx| {
+                                    if let Some(tx) = tx_reject.borrow_mut().take() {
+                                        let _ = tx.send(false);
+                                    }
+                                    cx.emit(DismissEvent);
+                                })
+                        })
+                    },
+                );
+            });
+
+            // Center the view on the first change so the user lands on the diff
+            // rather than at the top of an otherwise-unchanged file.
+            diff_editor.update(cx, |diff_editor, cx| {
+                let editor = diff_editor.rhs_editor().clone();
+                editor.update(cx, |editor, cx| {
+                    let snapshot = editor.buffer().read(cx).snapshot(cx);
+                    if let Some(first_hunk) = snapshot.diff_hunks().next() {
+                        let start = first_hunk.multi_buffer_range.start;
+                        editor.change_selections(
+                            SelectionEffects::scroll(Autoscroll::center()),
+                            window,
+                            cx,
+                            |selections| selections.select_anchor_ranges([start..start]),
+                        );
+                    }
+                });
+            });
+            Some(editor_id)
+        })
+        .map_err(|error| ProtocolError::internal(error.to_string()))?;
+
+    // Block until the user clicks Keep/Reject. A dropped sender (e.g. window
+    // closed) resolves to `false`, i.e. rejected.
+    let accepted = decision_rx.await.unwrap_or(false);
+
+    // Read the final buffer contents (the user may have edited the proposed
+    // side) and close the diff tab now that the decision is made.
+    let final_contents = window
+        .update(cx, |_root, window, cx| {
+            let final_contents = buffer.read(cx).text();
+            if let Some(editor_id) = editor_id
+                && let Some(workspace_entity) = workspace.upgrade()
+            {
+                workspace_entity.update(cx, |workspace, cx| {
+                    for pane in workspace.panes().to_vec() {
+                        pane.update(cx, |pane, cx| {
+                            pane.close_item_by_id(editor_id, SaveIntent::Skip, window, cx)
+                        })
+                        .detach();
+                    }
+                });
+            }
+            final_contents
+        })
+        .map_err(|error| ProtocolError::internal(error.to_string()))?;
+
+    // The official IDE protocol has the IDE return the accepted contents and the
+    // CLI perform the write, so we must not write the file here ourselves.
+    if accepted {
+        Ok(json!({ "content": [
+            { "type": "text", "text": "FILE_SAVED" },
+            { "type": "text", "text": final_contents },
+        ] }))
+    } else {
+        Ok(json!({ "content": [{ "type": "text", "text": "DIFF_REJECTED" }] }))
+    }
+}
diff --git a/crates/claude_code_ide/src/server.rs b/crates/claude_code_ide/src/server.rs
new file mode 100644
index 0000000..31c4bfa
--- /dev/null
+++ b/crates/claude_code_ide/src/server.rs
@@ -0,0 +1,484 @@
+//! WebSocket transport and JSON-RPC/MCP message routing for the Claude Code IDE
+//! integration.
+//!
+//! The Claude Code CLI connects to us as a WebSocket client and speaks a
+//! WebSocket variant of MCP (Model Context Protocol): newline-free JSON-RPC 2.0
+//! objects, one per WebSocket text frame. This module owns the wire protocol; it
+//! knows nothing about Zed's editor state. Everything that needs to read or act
+//! on the workspace is delegated through the [`Dispatcher`] trait, which the
+//! GPUI-aware layer implements.
+
+use anyhow::{Context as _, Result};
+use async_tungstenite::tungstenite::{
+    Message,
+    handshake::server::{ErrorResponse, Request, Response},
+    http,
+};
+use futures::{AsyncRead, AsyncWrite, StreamExt as _};
+use serde::Serialize;
+use serde_json::{Value, json};
+
+/// The MCP protocol revision the official extensions speak. Sent back from
+/// `initialize`; the CLI checks it for compatibility.
+pub const MCP_PROTOCOL_VERSION: &str = "2024-11-05";
+
+const SERVER_NAME: &str = "zed";
+const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION");
+
+/// The HTTP header the CLI must send, carrying the token from the lock file.
+pub const AUTH_HEADER: &str = "x-claude-code-ide-authorization";
+
+/// JSON-RPC 2.0 standard error codes (see the spec, section 5.1).
+pub mod error_codes {
+    pub const PARSE_ERROR: i32 = -32700;
+    pub const INVALID_REQUEST: i32 = -32600;
+    pub const METHOD_NOT_FOUND: i32 = -32601;
+    pub const INTERNAL_ERROR: i32 = -32603;
+}
+
+/// The WebSocket subprotocol the Claude CLI offers on every IDE connection.
+///
+/// RFC 6455 lets the server pick one of the subprotocols the client offered, or
+/// none. "None" is not a workable answer here: the CLI's WebSocket client
+/// closes the connection with code 1002 ("Missing client protocol") when the
+/// 101 response does not name a subprotocol back, so a handshake that omits
+/// this header completes at the HTTP level and then carries no JSON-RPC at all.
+pub const MCP_SUBPROTOCOL: &str = "mcp";
+
+/// The subprotocol to echo in the handshake response, given the client's
+/// `Sec-WebSocket-Protocol` offer. `None` when the client offered nothing we
+/// speak — echoing a subprotocol that was never offered is itself a violation.
+///
+/// The header may repeat and each occurrence may list several values, so both
+/// shapes are flattened before matching.
+pub fn negotiate_subprotocol<'a>(
+    offered: impl IntoIterator<Item = &'a str>,
+) -> Option<&'static str> {
+    offered
+        .into_iter()
+        .flat_map(|value| value.split(','))
+        .any(|protocol| protocol.trim().eq_ignore_ascii_case(MCP_SUBPROTOCOL))
+        .then_some(MCP_SUBPROTOCOL)
+}
+
+/// An error a method handler can return; serialized into the JSON-RPC `error`
+/// field of the response.
+#[derive(Debug)]
+pub struct ProtocolError {
+    pub code: i32,
+    pub message: String,
+    pub data: Option<Value>,
+}
+
+impl ProtocolError {
+    pub fn new(code: i32, message: impl Into<String>) -> Self {
+        Self { code, message: message.into(), data: None }
+    }
+
+    pub fn method_not_found(method: &str) -> Self {
+        Self::new(error_codes::METHOD_NOT_FOUND, format!("Method not found: {method}"))
+    }
+
+    pub fn internal(message: impl Into<String>) -> Self {
+        Self::new(error_codes::INTERNAL_ERROR, message)
+    }
+}
+
+/// One MCP tool as advertised by `tools/list`. Serializes to
+/// `{"name": ..., "description": ..., "inputSchema": {...}}`.
+#[derive(Debug, Clone, Serialize)]
+pub struct ToolDescriptor {
+    pub name: &'static str,
+    pub description: &'static str,
+    #[serde(rename = "inputSchema")]
+    pub input_schema: Value,
+}
+
+/// The seam between the wire protocol (this module) and Zed's editor state.
+///
+/// Implementors live in the GPUI layer: `call_tool` reaches into the workspace
+/// to satisfy a `tools/call` request. The protocol layer only ever sees JSON.
+// The dispatcher runs on GPUI's single-threaded foreground executor, so its
+// futures are intentionally `!Send`; we don't need the `Send` bound the lint
+// asks us to consider.
+#[allow(async_fn_in_trait)]
+pub trait Dispatcher {
+    /// The tools advertised in response to `tools/list`.
+    fn tools(&self) -> Vec<ToolDescriptor>;
+
+    /// Execute a `tools/call`. `Ok` is the MCP result object (typically
+    /// `{"content": [{"type": "text", "text": "<json>"}]}`).
+    async fn call_tool(&self, name: &str, arguments: Value) -> Result<Value, ProtocolError>;
+}
+
+/// Binds a fresh WebSocket listener on the loopback interface, letting the OS
+/// pick a free port. Returns the listener and the port we got, which the caller
+/// writes into the lock file and injects into the terminal environment.
+pub async fn bind() -> Result<(smol::net::TcpListener, u16)> {
+    let listener = smol::net::TcpListener::bind("127.0.0.1:0")
+        .await
+        .context("binding Claude Code IDE WebSocket listener")?;
+    let port = listener.local_addr().context("reading bound port")?.port();
+    Ok((listener, port))
+}
+
+/// Performs the WebSocket handshake (validating the auth header against
+/// `auth_token`), then serves JSON-RPC requests until the client disconnects.
+pub async fn serve_connection<S, D>(stream: S, auth_token: String, dispatcher: D) -> Result<()>
+where
+    S: AsyncRead + AsyncWrite + Unpin,
+    D: Dispatcher,
+{
+    // The handshake callback runs during the HTTP upgrade. We reject the
+    // connection unless it presents the exact token we wrote into the lock file,
+    // so only the CLI that read our lock file (same user) can connect.
+    let authorize = move |request: &Request, response: Response| -> Result<Response, ErrorResponse> {
+        let presented = request.headers().get(AUTH_HEADER).map(|value| value.as_bytes());
+        if presented == Some(auth_token.as_bytes()) {
+            let offered = request
+                .headers()
+                .get_all(http::header::SEC_WEBSOCKET_PROTOCOL)
+                .iter()
+                .filter_map(|value| value.to_str().ok());
+            let mut response = response;
+            if let Some(protocol) = negotiate_subprotocol(offered) {
+                response.headers_mut().insert(
+                    http::header::SEC_WEBSOCKET_PROTOCOL,
+                    http::HeaderValue::from_static(protocol),
+                );
+            }
+            Ok(response)
+        } else {
+            let denied = http::Response::builder()
+                .status(http::StatusCode::UNAUTHORIZED)
+                .body(Some("invalid or missing authorization token".to_string()))
+                .expect("static unauthorized response is valid");
+            Err(denied)
+        }
+    };
+
+    let websocket = async_tungstenite::accept_hdr_async(stream, authorize)
+        .await
+        .context("websocket handshake failed")?;
+
+    let (mut outgoing, mut incoming) = websocket.split();
+
+    while let Some(message) = incoming.next().await {
+        match message.context("reading websocket frame")? {
+            Message::Text(text) => {
+                if let Some(response) = handle_message(text.as_str(), &dispatcher).await {
+                    outgoing
+                        .send(Message::Text(response.into()))
+                        .await
+                        .context("sending response")?;
+                }
+            }
+            // Reply to keepalive pings so the CLI doesn't consider us dead.
+            Message::Ping(payload) => {
+                outgoing.send(Message::Pong(payload)).await.context("sending pong")?;
+            }
+            Message::Close(_) => break,
+            _ => {}
+        }
+    }
+
+    Ok(())
+}
+
+/// Routes a single incoming JSON-RPC message. Returns `Some(json)` to send back
+/// for requests (those with an `id`), or `None` for notifications and for
+/// messages we intentionally don't answer.
+///
+/// Kept free of sockets and GPUI so it is unit-testable against a mock
+/// [`Dispatcher`].
+pub async fn handle_message<D: Dispatcher>(raw: &str, dispatcher: &D) -> Option<String> {
+    let parsed: Value = match serde_json::from_str(raw) {
+        Ok(value) => value,
+        Err(_) => {
+            return Some(error_response(
+                Value::Null,
+                error_codes::PARSE_ERROR,
+                "Parse error",
+            ));
+        }
+    };
+
+    if parsed.get("jsonrpc").and_then(Value::as_str) != Some("2.0") {
+        let id = parsed.get("id").cloned().unwrap_or(Value::Null);
+        return Some(error_response(id, error_codes::INVALID_REQUEST, "Invalid Request"));
+    }
+
+    let method = parsed.get("method").and_then(Value::as_str).unwrap_or_default();
+    let params = parsed.get("params").cloned().unwrap_or(Value::Null);
+    let id = parsed.get("id").cloned();
+
+    match id {
+        // A request: the client expects a response carrying the same `id`.
+        Some(id) => Some(match route_request(method, params, dispatcher).await {
+            Ok(result) => success_response(id, result),
+            Err(error) => {
+                error_response_with_data(id, error.code, &error.message, error.data)
+            }
+        }),
+        // A notification: fire-and-forget, no response.
+        None => {
+            // `notifications/initialized` and friends need no action yet.
+            None
+        }
+    }
+}
+
+async fn route_request<D: Dispatcher>(
+    method: &str,
+    params: Value,
+    dispatcher: &D,
+) -> Result<Value, ProtocolError> {
+    match method {
+        "initialize" => Ok(json!({
+            "protocolVersion": MCP_PROTOCOL_VERSION,
+            "capabilities": {
+                "logging": {},
+                "prompts": { "listChanged": true },
+                "resources": { "subscribe": true, "listChanged": true },
+                "tools": { "listChanged": true },
+            },
+            "serverInfo": { "name": SERVER_NAME, "version": SERVER_VERSION },
+        })),
+        "prompts/list" => Ok(json!({ "prompts": [] })),
+        "resources/list" => Ok(json!({ "resources": [] })),
+        "tools/list" => Ok(json!({ "tools": dispatcher.tools() })),
+        "tools/call" => {
+            let name = params
+                .get("name")
+                .and_then(Value::as_str)
+                .ok_or_else(|| ProtocolError::new(error_codes::INVALID_REQUEST, "missing tool name"))?;
+            let arguments = params.get("arguments").cloned().unwrap_or(json!({}));
+            dispatcher.call_tool(name, arguments).await
+        }
+        other => Err(ProtocolError::method_not_found(other)),
+    }
+}
+
+fn success_response(id: Value, result: Value) -> String {
+    json!({ "jsonrpc": "2.0", "id": id, "result": result }).to_string()
+}
+
+fn error_response(id: Value, code: i32, message: &str) -> String {
+    error_response_with_data(id, code, message, None)
+}
+
+fn error_response_with_data(id: Value, code: i32, message: &str, data: Option<Value>) -> String {
+    let mut error = json!({ "code": code, "message": message });
+    if let Some(data) = data {
+        error["data"] = data;
+    }
+    json!({ "jsonrpc": "2.0", "id": id, "error": error }).to_string()
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use futures::executor::block_on;
+
+    struct MockDispatcher;
+
+    impl Dispatcher for MockDispatcher {
+        fn tools(&self) -> Vec<ToolDescriptor> {
+            vec![ToolDescriptor {
+                name: "getCurrentSelection",
+                description: "Get the current selection",
+                input_schema: json!({ "type": "object" }),
+            }]
+        }
+
+        async fn call_tool(&self, name: &str, arguments: Value) -> Result<Value, ProtocolError> {
+            if name == "getCurrentSelection" {
+                Ok(json!({ "content": [{ "type": "text", "text": "ok" }] }))
+            } else {
+                Err(ProtocolError::method_not_found(name))
+            }
+            .map(|result| {
+                // Thread `arguments` through so the test exercises the path.
+                let _ = &arguments;
+                result
+            })
+        }
+    }
+
+    fn response(raw: &str) -> Value {
+        let json = block_on(handle_message(raw, &MockDispatcher)).expect("expected a response");
+        serde_json::from_str(&json).unwrap()
+    }
+
+    /// Drives a real socket through `accept_hdr_async`, because the defect this
+    /// guards against lived *between* `negotiate_subprotocol` and the wire: the
+    /// handshake callback returned a perfectly valid 101 that simply named no
+    /// subprotocol, and every unit test around it stayed green.
+    #[test]
+    fn handshake_echoes_the_subprotocol_over_a_real_socket() {
+        use futures::{AsyncReadExt as _, AsyncWriteExt as _};
+
+        block_on(async {
+            let (listener, port) = bind().await.expect("binding the test listener");
+            let server = smol::spawn(async move {
+                let (stream, _) = listener.accept().await.expect("accepting the test client");
+                serve_connection(stream, "the-token".to_owned(), MockDispatcher).await
+            });
+
+            let mut client = smol::net::TcpStream::connect(("127.0.0.1", port))
+                .await
+                .expect("connecting to the test listener");
+            let request = format!(
+                "GET / HTTP/1.1\r\n\
+                 Host: 127.0.0.1:{port}\r\n\
+                 Upgrade: websocket\r\n\
+                 Connection: Upgrade\r\n\
+                 Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\
+                 Sec-WebSocket-Version: 13\r\n\
+                 Sec-WebSocket-Protocol: mcp\r\n\
+                 {AUTH_HEADER}: the-token\r\n\r\n"
+            );
+            client
+                .write_all(request.as_bytes())
+                .await
+                .expect("sending the upgrade request");
+
+            let mut buffer = [0u8; 512];
+            let read = client.read(&mut buffer).await.expect("reading the response");
+            let response = String::from_utf8_lossy(&buffer[..read]).to_lowercase();
+
+            assert!(
+                response.starts_with("http/1.1 101"),
+                "expected an upgrade, got:\n{response}"
+            );
+            assert!(
+                response.contains("sec-websocket-protocol: mcp"),
+                "the 101 named no subprotocol, so the CLI would close it with 1002:\n{response}"
+            );
+
+            drop(client);
+            server.cancel().await;
+        });
+    }
+
+    /// The same socket, without the token: the upgrade must be refused outright
+    /// rather than answered with a subprotocol.
+    #[test]
+    fn handshake_without_the_token_is_rejected() {
+        use futures::{AsyncReadExt as _, AsyncWriteExt as _};
+
+        block_on(async {
+            let (listener, port) = bind().await.expect("binding the test listener");
+            let server = smol::spawn(async move {
+                let (stream, _) = listener.accept().await.expect("accepting the test client");
+                serve_connection(stream, "the-token".to_owned(), MockDispatcher).await
+            });
+
+            let mut client = smol::net::TcpStream::connect(("127.0.0.1", port))
+                .await
+                .expect("connecting to the test listener");
+            let request = format!(
+                "GET / HTTP/1.1\r\n\
+                 Host: 127.0.0.1:{port}\r\n\
+                 Upgrade: websocket\r\n\
+                 Connection: Upgrade\r\n\
+                 Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\
+                 Sec-WebSocket-Version: 13\r\n\
+                 Sec-WebSocket-Protocol: mcp\r\n\r\n"
+            );
+            client
+                .write_all(request.as_bytes())
+                .await
+                .expect("sending the upgrade request");
+
+            let mut buffer = [0u8; 512];
+            let read = client.read(&mut buffer).await.expect("reading the response");
+            let response = String::from_utf8_lossy(&buffer[..read]).to_lowercase();
+
+            assert!(
+                response.starts_with("http/1.1 401"),
+                "expected a rejection, got:\n{response}"
+            );
+
+            drop(client);
+            server.cancel().await;
+        });
+    }
+
+    #[test]
+    fn subprotocol_is_echoed_when_the_client_offers_mcp() {
+        assert_eq!(negotiate_subprotocol(["mcp"]), Some("mcp"));
+    }
+
+    /// The CLI sends a single header value; a proxy may split or merge it. Both
+    /// shapes have to resolve to the same answer.
+    #[test]
+    fn subprotocol_is_found_in_a_list_and_across_repeated_headers() {
+        assert_eq!(negotiate_subprotocol(["chat, mcp"]), Some("mcp"));
+        assert_eq!(negotiate_subprotocol(["chat", "mcp"]), Some("mcp"));
+        assert_eq!(negotiate_subprotocol(["MCP"]), Some("mcp"));
+    }
+
+    /// Echoing a subprotocol the client never offered breaks RFC 6455 just as
+    /// omitting an offered one does, so silence is the right answer here.
+    #[test]
+    fn no_subprotocol_is_echoed_when_none_was_offered() {
+        assert_eq!(negotiate_subprotocol([]), None);
+        assert_eq!(negotiate_subprotocol(["chat"]), None);
+        assert_eq!(negotiate_subprotocol(["mcp-v2"]), None);
+    }
+
+    #[test]
+    fn initialize_reports_protocol_version() {
+        let value = response(r#"{"jsonrpc":"2.0","id":1,"method":"initialize"}"#);
+        assert_eq!(value["id"], 1);
+        assert_eq!(value["result"]["protocolVersion"], MCP_PROTOCOL_VERSION);
+        assert_eq!(value["result"]["serverInfo"]["name"], "zed");
+    }
+
+    #[test]
+    fn tools_list_returns_advertised_tools() {
+        let value = response(r#"{"jsonrpc":"2.0","id":2,"method":"tools/list"}"#);
+        assert_eq!(value["result"]["tools"][0]["name"], "getCurrentSelection");
+        assert_eq!(value["result"]["tools"][0]["inputSchema"]["type"], "object");
+    }
+
+    #[test]
+    fn tools_call_wraps_result_in_mcp_content() {
+        let value = response(
+            r#"{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"getCurrentSelection","arguments":{}}}"#,
+        );
+        assert_eq!(value["result"]["content"][0]["type"], "text");
+        assert_eq!(value["result"]["content"][0]["text"], "ok");
+    }
+
+    #[test]
+    fn unknown_tool_is_a_method_not_found_error() {
+        let value = response(
+            r#"{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"nope","arguments":{}}}"#,
+        );
+        assert_eq!(value["error"]["code"], error_codes::METHOD_NOT_FOUND);
+    }
+
+    #[test]
+    fn unknown_method_is_a_method_not_found_error() {
+        let value = response(r#"{"jsonrpc":"2.0","id":5,"method":"frobnicate"}"#);
+        assert_eq!(value["error"]["code"], error_codes::METHOD_NOT_FOUND);
+    }
+
+    #[test]
+    fn notification_yields_no_response() {
+        let result = block_on(handle_message(
+            r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#,
+            &MockDispatcher,
+        ));
+        assert!(result.is_none());
+    }
+
+    #[test]
+    fn malformed_json_is_a_parse_error() {
+        let value = response("not json at all");
+        assert_eq!(value["error"]["code"], error_codes::PARSE_ERROR);
+        assert_eq!(value["id"], Value::Null);
+    }
+}
diff --git a/crates/claude_code_ide/src/tools.rs b/crates/claude_code_ide/src/tools.rs
new file mode 100644
index 0000000..2978e1b
--- /dev/null
+++ b/crates/claude_code_ide/src/tools.rs
@@ -0,0 +1,511 @@
+//! The GPUI-backed [`Dispatcher`] implementation: this is where the wire
+//! protocol meets Zed's editor state. Each `tools/call` is satisfied by reading
+//! the active workspace, editor, and buffers and shaping the result into the
+//! JSON the Claude Code CLI expects (mirroring the official extensions).
+
+use crate::server::{Dispatcher, ProtocolError, ToolDescriptor, error_codes};
+use editor::{Editor, SplittableEditor};
+use gpui::{AnyWindowHandle, AsyncApp, Entity, WeakEntity};
+use language::{Buffer, DiagnosticSeverity};
+use serde_json::{Value, json};
+use std::path::PathBuf;
+use text::Point;
+use workspace::{OpenOptions, SaveIntent, Workspace};
+
+/// Satisfies tool calls against a single Zed workspace.
+///
+/// Holds a weak handle to the workspace (so the server task never keeps the
+/// window alive) plus a cheap clone of the async app context, which lets each
+/// call hop onto the foreground thread to read entity state.
+pub struct WorkspaceDispatcher {
+    workspace: WeakEntity<Workspace>,
+    window: Option<AnyWindowHandle>,
+    cx: AsyncApp,
+}
+
+impl WorkspaceDispatcher {
+    pub fn new(
+        workspace: WeakEntity<Workspace>,
+        window: Option<AnyWindowHandle>,
+        cx: AsyncApp,
+    ) -> Self {
+        Self { workspace, window, cx }
+    }
+
+    fn get_current_selection(&self, cx: &mut AsyncApp) -> Result<Value, ProtocolError> {
+        let payload = self
+            .workspace
+            .update(cx, |workspace, cx| {
+                let Some(editor) = workspace.active_item_as::<Editor>(cx) else {
+                    return json!({ "success": false, "message": "No active editor found" });
+                };
+
+                editor.update(cx, |editor, cx| {
+                    let display_snapshot = editor.display_snapshot(cx);
+                    let cursor = editor.selections.newest::<Point>(&display_snapshot);
+
+                    let path = editor
+                        .buffer()
+                        .read(cx)
+                        .as_singleton()
+                        .and_then(|buffer| {
+                            buffer
+                                .read(cx)
+                                .file()
+                                .and_then(|file| file.as_local())
+                                .map(|file| file.abs_path(cx))
+                        })
+                        .map(|path| path.to_string_lossy().into_owned())
+                        .unwrap_or_default();
+
+                    let text: String = editor
+                        .buffer()
+                        .read(cx)
+                        .snapshot(cx)
+                        .text_for_range(cursor.start..cursor.end)
+                        .collect();
+
+                    json!({
+                        "success": true,
+                        "text": text,
+                        "filePath": path,
+                        "fileUrl": format!("file://{path}"),
+                        "selection": {
+                            "start": { "line": cursor.start.row, "character": cursor.start.column },
+                            "end": { "line": cursor.end.row, "character": cursor.end.column },
+                            "isEmpty": cursor.start == cursor.end,
+                        }
+                    })
+                })
+            })
+            .map_err(|error| ProtocolError::internal(error.to_string()))?;
+
+        Ok(mcp_text(payload))
+    }
+
+    fn get_workspace_folders(&self, cx: &mut AsyncApp) -> Result<Value, ProtocolError> {
+        let paths = self
+            .workspace
+            .update(cx, |workspace, cx| {
+                workspace
+                    .project()
+                    .read(cx)
+                    .visible_worktrees(cx)
+                    .map(|worktree| worktree.read(cx).abs_path().to_string_lossy().into_owned())
+                    .collect::<Vec<_>>()
+            })
+            .map_err(|error| ProtocolError::internal(error.to_string()))?;
+
+        let folders = paths
+            .iter()
+            .map(|path| {
+                let name = path.rsplit('/').next().filter(|name| !name.is_empty()).unwrap_or(path);
+                json!({ "name": name, "uri": format!("file://{path}"), "path": path })
+            })
+            .collect::<Vec<_>>();
+        let root_path = paths.first().cloned().unwrap_or_default();
+
+        Ok(mcp_text(json!({
+            "success": true,
+            "folders": folders,
+            "rootPath": root_path,
+        })))
+    }
+
+    /// Finds the open buffer for an absolute path, if any.
+    fn buffer_for_path(&self, path: &str, cx: &mut AsyncApp) -> Option<Entity<Buffer>> {
+        self.workspace
+            .update(cx, |workspace, cx| {
+                let project = workspace.project().read(cx);
+                let project_path = project.find_project_path(path, cx)?;
+                project.get_open_buffer(&project_path, cx)
+            })
+            .ok()
+            .flatten()
+    }
+
+    fn get_open_editors(&self, cx: &mut AsyncApp) -> Result<Value, ProtocolError> {
+        let tabs = self
+            .workspace
+            .update(cx, |workspace, cx| {
+                let active = workspace.active_item_as::<Editor>(cx);
+                workspace
+                    .items_of_type::<Editor>(cx)
+                    .filter_map(|editor| {
+                        let is_active = active.as_ref() == Some(&editor);
+                        let editor = editor.read(cx);
+                        let buffer = editor.buffer().read(cx).as_singleton()?;
+                        let buffer = buffer.read(cx);
+                        let path = buffer
+                            .file()
+                            .and_then(|file| file.as_local())
+                            .map(|file| file.abs_path(cx).to_string_lossy().into_owned())?;
+                        let language = buffer
+                            .language()
+                            .map(|language| language.name().to_string())
+                            .unwrap_or_else(|| "plaintext".to_owned());
+                        let line_count = buffer.text_snapshot().max_point().row + 1;
+                        let label = path.rsplit('/').next().unwrap_or(&path).to_owned();
+                        Some(json!({
+                            "uri": format!("file://{path}"),
+                            "fileName": path,
+                            "label": label,
+                            "languageId": language,
+                            "isActive": is_active,
+                            "isDirty": buffer.is_dirty(),
+                            "isPinned": false,
+                            "isPreview": false,
+                            "isUntitled": false,
+                            "lineCount": line_count,
+                            "groupIndex": 0,
+                            "viewColumn": 1,
+                            "isGroupActive": true,
+                        }))
+                    })
+                    .collect::<Vec<_>>()
+            })
+            .map_err(|error| ProtocolError::internal(error.to_string()))?;
+        Ok(mcp_text(json!({ "tabs": tabs })))
+    }
+
+    fn get_diagnostics(&self, arguments: &Value, cx: &mut AsyncApp) -> Result<Value, ProtocolError> {
+        let target = arguments
+            .get("uri")
+            .and_then(Value::as_str)
+            .map(|uri| uri.strip_prefix("file://").unwrap_or(uri).to_owned());
+
+        let diagnostics = self
+            .workspace
+            .update(cx, |workspace, cx| {
+                let buffers = workspace
+                    .project()
+                    .read(cx)
+                    .buffer_store()
+                    .read(cx)
+                    .buffers()
+                    .collect::<Vec<_>>();
+                let mut out = Vec::new();
+                for buffer in buffers {
+                    let buffer = buffer.read(cx);
+                    let Some(path) = buffer
+                        .file()
+                        .and_then(|file| file.as_local())
+                        .map(|file| file.abs_path(cx).to_string_lossy().into_owned())
+                    else {
+                        continue;
+                    };
+                    if target.as_ref().is_some_and(|target| target != &path) {
+                        continue;
+                    }
+                    let snapshot = buffer.snapshot();
+                    for entry in snapshot.diagnostics_in_range::<Point, Point>(
+                        Point::new(0, 0)..snapshot.max_point(),
+                        false,
+                    ) {
+                        out.push(json!({
+                            "filePath": path,
+                            "line": entry.range.start.row + 1,
+                            "character": entry.range.start.column + 1,
+                            "severity": severity_to_number(entry.diagnostic.severity),
+                            "message": entry.diagnostic.message.clone(),
+                            "source": entry.diagnostic.source.clone(),
+                        }));
+                    }
+                }
+                out
+            })
+            .map_err(|error| ProtocolError::internal(error.to_string()))?;
+
+        let content = diagnostics
+            .into_iter()
+            .map(|diagnostic| json!({ "type": "text", "text": diagnostic.to_string() }))
+            .collect::<Vec<_>>();
+        Ok(json!({ "content": content }))
+    }
+
+    fn check_document_dirty(
+        &self,
+        arguments: &Value,
+        cx: &mut AsyncApp,
+    ) -> Result<Value, ProtocolError> {
+        let path = required_path_field(arguments, "filePath")?;
+        match self.buffer_for_path(&path, cx) {
+            Some(buffer) => {
+                let is_dirty = buffer.update(cx, |buffer, _| buffer.is_dirty());
+                Ok(mcp_text(json!({
+                    "success": true,
+                    "filePath": path,
+                    "isDirty": is_dirty,
+                    "isUntitled": false,
+                })))
+            }
+            None => Ok(mcp_text(
+                json!({ "success": false, "message": format!("Document not open: {path}") }),
+            )),
+        }
+    }
+
+    /// Closes all open diff tabs (our `openDiff` views), returning the count.
+    fn close_diff_tabs(&self, cx: &mut AsyncApp) -> usize {
+        let Some(window) = self.window else {
+            return 0;
+        };
+        window
+            .update(cx, |_root, window, cx| {
+                let Some(workspace) = self.workspace.upgrade() else {
+                    return 0;
+                };
+                workspace.update(cx, |workspace, cx| {
+                    let ids = workspace
+                        .items_of_type::<SplittableEditor>(cx)
+                        .map(|editor| editor.entity_id())
+                        .collect::<Vec<_>>();
+                    let count = ids.len();
+                    for pane in workspace.panes().to_vec() {
+                        for id in &ids {
+                            pane.update(cx, |pane, cx| {
+                                pane.close_item_by_id(*id, SaveIntent::Skip, window, cx)
+                            })
+                            .detach();
+                        }
+                    }
+                    count
+                })
+            })
+            .unwrap_or(0)
+    }
+
+    async fn open_file(&self, arguments: Value, cx: &mut AsyncApp) -> Result<Value, ProtocolError> {
+        let path = required_path_field(&arguments, "filePath")?;
+        let start_line = arguments.get("startLine").and_then(Value::as_u64);
+        let end_line = arguments.get("endLine").and_then(Value::as_u64);
+        let window =
+            self.window.ok_or_else(|| ProtocolError::internal("no window available"))?;
+        let open_task = window
+            .update(cx, |_root, window, cx| {
+                self.workspace.upgrade().map(|workspace| {
+                    workspace.update(cx, |workspace, cx| {
+                        workspace.open_abs_path(
+                            PathBuf::from(&path),
+                            OpenOptions::default(),
+                            window,
+                            cx,
+                        )
+                    })
+                })
+            })
+            .map_err(|error| ProtocolError::internal(error.to_string()))?
+            .ok_or_else(|| ProtocolError::internal("workspace unavailable"))?;
+        let item = open_task.await.map_err(|error| ProtocolError::internal(error.to_string()))?;
+
+        // Optionally select the requested line range (1-indexed in the protocol).
+        if let (Some(start), Some(end)) = (start_line, end_line) {
+            window
+                .update(cx, |_root, window, cx| {
+                    if let Some(editor) = item.downcast::<Editor>() {
+                        editor.update(cx, |editor, cx| {
+                            let start = Point::new(start.saturating_sub(1) as u32, 0);
+                            let end = Point::new(end as u32, 0);
+                            editor.change_selections(Default::default(), window, cx, |selections| {
+                                selections.select_ranges([start..end]);
+                            });
+                        });
+                    }
+                })
+                .ok();
+        }
+
+        let message = match (start_line, end_line) {
+            (Some(start), Some(end)) => {
+                format!("Opened file and selected lines {start} to {end}")
+            }
+            _ => format!("Opened file: {path}"),
+        };
+        Ok(json!({ "content": [{ "type": "text", "text": message }] }))
+    }
+
+    async fn save_document(
+        &self,
+        arguments: Value,
+        cx: &mut AsyncApp,
+    ) -> Result<Value, ProtocolError> {
+        let path = required_path_field(&arguments, "filePath")?;
+        let Some(buffer) = self.buffer_for_path(&path, cx) else {
+            return Ok(mcp_text(
+                json!({ "success": false, "message": format!("Document not open: {path}") }),
+            ));
+        };
+        let save_task = self
+            .workspace
+            .update(cx, |workspace, cx| {
+                workspace
+                    .project()
+                    .update(cx, |project, cx| project.save_buffer(buffer, cx))
+            })
+            .map_err(|error| ProtocolError::internal(error.to_string()))?;
+        save_task.await.map_err(|error| ProtocolError::internal(error.to_string()))?;
+        Ok(mcp_text(json!({ "success": true, "filePath": path })))
+    }
+}
+
+fn required_path_field(arguments: &Value, field: &str) -> Result<String, ProtocolError> {
+    arguments
+        .get(field)
+        .and_then(Value::as_str)
+        .map(str::to_owned)
+        .ok_or_else(|| ProtocolError::new(error_codes::INVALID_REQUEST, format!("missing {field}")))
+}
+
+/// Maps Zed/LSP diagnostic severities to the numeric scale the protocol uses
+/// (1 = error, 2 = warning, 3 = information, 4 = hint).
+fn severity_to_number(severity: DiagnosticSeverity) -> u8 {
+    match severity {
+        DiagnosticSeverity::ERROR => 1,
+        DiagnosticSeverity::WARNING => 2,
+        DiagnosticSeverity::INFORMATION => 3,
+        DiagnosticSeverity::HINT => 4,
+        _ => 0,
+    }
+}
+
+impl Dispatcher for WorkspaceDispatcher {
+    fn tools(&self) -> Vec<ToolDescriptor> {
+        let empty_object_schema = json!({
+            "type": "object",
+            "additionalProperties": false,
+            "$schema": "http://json-schema.org/draft-07/schema#",
+        });
+        vec![
+            ToolDescriptor {
+                name: "getCurrentSelection",
+                description: "Get the current text selection in the editor",
+                input_schema: empty_object_schema.clone(),
+            },
+            ToolDescriptor {
+                name: "getLatestSelection",
+                description: "Get the most recent text selection (even if not in the active editor)",
+                input_schema: empty_object_schema.clone(),
+            },
+            ToolDescriptor {
+                name: "getWorkspaceFolders",
+                description: "Get all workspace folders currently open in the IDE",
+                input_schema: empty_object_schema.clone(),
+            },
+            ToolDescriptor {
+                name: "getOpenEditors",
+                description: "Get list of currently open files",
+                input_schema: empty_object_schema.clone(),
+            },
+            ToolDescriptor {
+                name: "closeAllDiffTabs",
+                description: "Close all diff tabs in the editor",
+                input_schema: empty_object_schema,
+            },
+            ToolDescriptor {
+                name: "getDiagnostics",
+                description: "Get language diagnostics (errors, warnings) from the editor",
+                input_schema: json!({
+                    "type": "object",
+                    "additionalProperties": false,
+                    "$schema": "http://json-schema.org/draft-07/schema#",
+                    "properties": {
+                        "uri": { "type": "string" },
+                    },
+                }),
+            },
+            ToolDescriptor {
+                name: "checkDocumentDirty",
+                description: "Check if a document has unsaved changes (is dirty)",
+                input_schema: json!({
+                    "type": "object",
+                    "additionalProperties": false,
+                    "$schema": "http://json-schema.org/draft-07/schema#",
+                    "properties": { "filePath": { "type": "string" } },
+                    "required": ["filePath"],
+                }),
+            },
+            ToolDescriptor {
+                name: "saveDocument",
+                description: "Save a document with unsaved changes",
+                input_schema: json!({
+                    "type": "object",
+                    "additionalProperties": false,
+                    "$schema": "http://json-schema.org/draft-07/schema#",
+                    "properties": { "filePath": { "type": "string" } },
+                    "required": ["filePath"],
+                }),
+            },
+            ToolDescriptor {
+                name: "openFile",
+                description: "Open a file in the editor and optionally select a range of text",
+                input_schema: json!({
+                    "type": "object",
+                    "additionalProperties": false,
+                    "$schema": "http://json-schema.org/draft-07/schema#",
+                    "properties": {
+                        "filePath": { "type": "string" },
+                        "preview": { "type": "boolean" },
+                        "startLine": { "type": "integer" },
+                        "endLine": { "type": "integer" },
+                        "startText": { "type": "string" },
+                        "endText": { "type": "string" },
+                        "makeFrontmost": { "type": "boolean" },
+                    },
+                    "required": ["filePath"],
+                }),
+            },
+            ToolDescriptor {
+                name: "openDiff",
+                description: "Open a diff view comparing old file content with new file content",
+                input_schema: json!({
+                    "type": "object",
+                    "additionalProperties": false,
+                    "$schema": "http://json-schema.org/draft-07/schema#",
+                    "properties": {
+                        "old_file_path": { "type": "string" },
+                        "new_file_path": { "type": "string" },
+                        "new_file_contents": { "type": "string" },
+                        "tab_name": { "type": "string" },
+                    },
+                    "required": ["old_file_path", "new_file_path", "new_file_contents", "tab_name"],
+                }),
+            },
+        ]
+    }
+
+    async fn call_tool(&self, name: &str, arguments: Value) -> Result<Value, ProtocolError> {
+        // Reading entity state has to happen on the foreground thread; a cloned
+        // `AsyncApp` lets `WeakEntity::update` marshal us there.
+        let mut cx = self.cx.clone();
+        match name {
+            "getCurrentSelection" => self.get_current_selection(&mut cx),
+            "getLatestSelection" => self.get_current_selection(&mut cx),
+            "getWorkspaceFolders" => self.get_workspace_folders(&mut cx),
+            "getOpenEditors" => self.get_open_editors(&mut cx),
+            "getDiagnostics" => self.get_diagnostics(&arguments, &mut cx),
+            "checkDocumentDirty" => self.check_document_dirty(&arguments, &mut cx),
+            "openFile" => self.open_file(arguments, &mut cx).await,
+            "saveDocument" => self.save_document(arguments, &mut cx).await,
+            "close_tab" => {
+                let closed = self.close_diff_tabs(&mut cx);
+                Ok(mcp_text(json!({ "success": true, "closed": closed })))
+            }
+            "closeAllDiffTabs" => {
+                let closed = self.close_diff_tabs(&mut cx);
+                Ok(mcp_text(json!({ "closedCount": closed })))
+            }
+            "openDiff" => {
+                crate::open_diff::open_diff(self.workspace.clone(), self.window, arguments, &mut cx)
+                    .await
+            }
+            other => Err(ProtocolError::method_not_found(other)),
+        }
+    }
+}
+
+/// Wraps a tool's payload in the MCP result envelope: the payload is
+/// JSON-stringified into a single text content block, exactly as the official
+/// extensions do.
+fn mcp_text(payload: Value) -> Value {
+    json!({ "content": [{ "type": "text", "text": payload.to_string() }] })
+}
diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs
index 2bdff41..7536eb1 100644
--- a/crates/project/src/project.rs
+++ b/crates/project/src/project.rs
@@ -255,6 +255,10 @@ pub struct Project {
     agent_location: Option<AgentLocation>,
     downloading_files: Arc<Mutex<HashMap<(WorktreeId, String), DownloadingFile>>>,
     last_worktree_paths: WorktreePaths,
+    /// Port of this project's Claude Code IDE WebSocket server, if running.
+    /// Set by `claude_code_ide` after it binds; read when building terminals so
+    /// the Claude CLI auto-connects via `CLAUDE_CODE_SSE_PORT`.
+    claude_code_ide_port: Option<u16>,
 }
 
 struct DownloadingFile {
@@ -1215,6 +1219,17 @@ impl Project {
         context_server_store::init(cx);
     }
 
+    /// The port of this project's running Claude Code IDE WebSocket server.
+    pub fn claude_code_ide_port(&self) -> Option<u16> {
+        self.claude_code_ide_port
+    }
+
+    /// Records the port of this project's Claude Code IDE WebSocket server so
+    /// terminals can advertise it to the Claude CLI.
+    pub fn set_claude_code_ide_port(&mut self, port: Option<u16>) {
+        self.claude_code_ide_port = port;
+    }
+
     pub fn local(
         client: Arc<Client>,
         node: NodeRuntime,
@@ -1417,6 +1432,7 @@ impl Project {
                 agent_location: None,
                 downloading_files: Default::default(),
                 last_worktree_paths: WorktreePaths::default(),
+                claude_code_ide_port: None,
             }
         })
     }
@@ -1660,6 +1676,7 @@ impl Project {
                 agent_location: None,
                 downloading_files: Default::default(),
                 last_worktree_paths: WorktreePaths::default(),
+                claude_code_ide_port: None,
             };
 
             // remote server -> local machine handlers
@@ -1951,6 +1968,7 @@ impl Project {
                 agent_location: None,
                 downloading_files: Default::default(),
                 last_worktree_paths: WorktreePaths::default(),
+                claude_code_ide_port: None,
             };
             project.set_role(role, cx);
             for worktree in worktrees {
diff --git a/crates/project/src/terminals.rs b/crates/project/src/terminals.rs
index 2d8a37c..3a1505c 100644
--- a/crates/project/src/terminals.rs
+++ b/crates/project/src/terminals.rs
@@ -134,6 +134,14 @@ impl Project {
         cx.spawn(async move |project, cx| {
             let mut env = env_task.await.unwrap_or_default();
             env.extend(settings.env);
+            if let Some(port) = project
+                .read_with(cx, |project, _| project.claude_code_ide_port())
+                .ok()
+                .flatten()
+            {
+                env.insert("CLAUDE_CODE_SSE_PORT".to_owned(), port.to_string());
+                env.insert("ENABLE_IDE_INTEGRATION".to_owned(), "true".to_owned());
+            }
 
             let activation_script = maybe!(async {
                 for toolchain in toolchains {
@@ -376,6 +384,14 @@ impl Project {
             let shell_kind = ShellKind::new(&shell, path_style.is_windows());
             let mut env = env_task.await.unwrap_or_default();
             env.extend(settings.env);
+            if let Some(port) = project
+                .read_with(cx, |project, _| project.claude_code_ide_port())
+                .ok()
+                .flatten()
+            {
+                env.insert("CLAUDE_CODE_SSE_PORT".to_owned(), port.to_string());
+                env.insert("ENABLE_IDE_INTEGRATION".to_owned(), "true".to_owned());
+            }
 
             let activation_script = maybe!(async {
                 for toolchain in toolchains {
@@ -541,6 +557,14 @@ impl Project {
         cx.spawn(async move |project, cx| {
             let mut env = env_task.await.unwrap_or_default();
             env.extend(settings.env);
+            if let Some(port) = project
+                .read_with(cx, |project, _| project.claude_code_ide_port())
+                .ok()
+                .flatten()
+            {
+                env.insert("CLAUDE_CODE_SSE_PORT".to_owned(), port.to_string());
+                env.insert("ENABLE_IDE_INTEGRATION".to_owned(), "true".to_owned());
+            }
 
             project.update(cx, move |_, cx| {
                 match remote_client {
diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml
index d9562b6..3b33dc2 100644
--- a/crates/zed/Cargo.toml
+++ b/crates/zed/Cargo.toml
@@ -89,6 +89,7 @@ call.workspace = true
 call_hierarchy.workspace = true
 channel.workspace = true
 chrono.workspace = true
+claude_code_ide.workspace = true
 clap.workspace = true
 cli.workspace = true
 client.workspace = true
diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs
index ec31980..d5e7135 100644
--- a/crates/zed/src/main.rs
+++ b/crates/zed/src/main.rs
@@ -701,6 +701,7 @@ fn main() {
         acp_tools::init(cx);
         zed::telemetry_log::init(cx);
         zed::remote_debug::init(cx);
+        claude_code_ide::init(cx);
         edit_prediction_ui::init(cx);
         web_search::init(cx);
         web_search_providers::init(app_state.client.clone(), app_state.user_store.clone(), cx);
