Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions cli/src/bin/code/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ use std::process::Command;
use clap::Parser;
use cli::{
commands::{
agent_host, agent_kill, agent_logs, agent_ps, agent_stop, args, serve_web, tunnels, update,
version, CommandContext,
agent_host, agent_kill, agent_logs, agent_ps, agent_stop, args, mcp, serve_web, tunnels,
update, version, CommandContext,
},
constants::get_default_user_agent,
desktop, log,
Expand Down Expand Up @@ -104,6 +104,8 @@ async fn main() -> Result<(), std::convert::Infallible> {
serve_web::serve_web(context!(), sw_args).await
}

Some(args::Commands::Mcp(mcp_args)) => mcp::mcp(context!(), mcp_args).await,

Some(args::Commands::Agent(agent_args)) => match agent_args.subcommand {
Some(args::AgentSubcommand::Ps(ps_args)) => {
agent_ps::agent_ps(context!(), ps_args).await
Expand Down
1 change: 1 addition & 0 deletions cli/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ pub mod agent_logs;
pub mod agent_ps;
pub mod agent_stop;
pub mod args;
pub mod mcp;
pub mod output;
pub mod serve_web;
pub mod tunnels;
Expand Down
42 changes: 42 additions & 0 deletions cli/src/commands/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,48 @@ pub enum Commands {
/// Manage agent host sessions.
#[clap(name = "agent")]
Agent(AgentArgs),

/// Expose the editor as a Model Context Protocol (MCP) server.
#[clap(name = "mcp")]
Mcp(McpArgs),
}

#[derive(Args, Debug, Clone)]
pub struct McpArgs {
/// Transport to use. `stdio` for line-oriented clients (Claude Code CLI
/// and similar), `sse` for HTTP clients.
#[clap(long, default_value = "stdio")]
pub transport: McpTransport,

/// Port to bind to when --transport=sse. 0 (default) picks an
/// ephemeral port; the chosen port is printed on stderr at startup.
#[clap(long, default_value_t = 0)]
pub port: u16,

/// Interface to bind on when --transport=sse. Defaults to `127.0.0.1`
/// (loopback). Only IP literals are accepted — pass `127.0.0.1` /
/// `::1` for loopback, not `localhost`. Binding to a non-loopback
/// interface (`0.0.0.0`, a LAN IP, etc.) also requires
/// `--allow-non-loopback` to confirm intent.
#[clap(long, default_value = "127.0.0.1")]
pub bind: std::net::IpAddr,

/// Acknowledge that binding the SSE transport to a non-loopback
/// interface is intentional. Required whenever `--bind` is set to
/// anything other than `127.0.0.1` / `::1`.
#[clap(long)]
pub allow_non_loopback: bool,
Comment thread
kanywst marked this conversation as resolved.

/// Workspace root. Defaults to the current working directory. The
/// server refuses operations on paths outside this root.
#[clap(long)]
pub workspace: Option<PathBuf>,
}
Comment thread
kanywst marked this conversation as resolved.

#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
pub enum McpTransport {
Stdio,
Sse,
}

#[derive(Args, Debug, Clone)]
Expand Down
68 changes: 68 additions & 0 deletions cli/src/commands/mcp.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

// Zeus — Model Context Protocol (MCP) server entry point.
// Scaffold only; real implementation lands in a follow-up PR. See
// docs/zeus-mcp-server.md for the tool surface and design.

use crate::commands::args::{McpArgs, McpTransport};
use crate::commands::CommandContext;
use crate::util::errors::{wrap, AnyError, SetupError};

pub async fn mcp(_ctx: CommandContext, args: McpArgs) -> Result<i32, AnyError> {
let raw_workspace = match args.workspace {
Some(p) => p,
None => std::env::current_dir().map_err(|e| wrap(e, "could not resolve workspace from cwd"))?,
};

// Canonicalize so the security check ("refuses operations on paths
// outside this root") can rely on byte-prefix comparison instead of
// having to re-resolve relative segments on every request.
let workspace = std::fs::canonicalize(&raw_workspace)
.map_err(|e| wrap(e, format!("could not canonicalize workspace path {}", raw_workspace.display())))?;

if !workspace.is_dir() {
return Err(SetupError(format!(
"workspace path is not a directory: {}",
workspace.display()
))
.into());
}

match args.transport {
Comment thread
kanywst marked this conversation as resolved.
McpTransport::Stdio => {
// Scaffold-only: surface this as a SetupError (non-zero exit)
// rather than Ok(0). A silent exit-0 would let scripts that
// pipe this command into a real MCP client appear to succeed.
Err(SetupError(format!(
"zeus mcp: stdio transport not yet implemented (workspace={})",
workspace.display()
))
.into())
}
McpTransport::Sse => {
// Refuse to bind to a non-loopback interface without explicit opt-in,
// to keep the default posture local-only. clap already parsed
// `--bind` into `IpAddr`, so no string-level validation needed here.
// See docs/zeus-mcp-server.md.
if !args.bind.is_loopback() && !args.allow_non_loopback {
return Err(SetupError(format!(
"refusing to bind SSE transport on non-loopback address {} \
without --allow-non-loopback (use 127.0.0.1 / ::1 for local-only)",
args.bind
))
.into());
}

Err(SetupError(format!(
"zeus mcp: sse transport not yet implemented (port={}, bind={}, workspace={})",
args.port,
args.bind,
workspace.display()
))
.into())
}
}
}
68 changes: 68 additions & 0 deletions docs/zeus-mcp-server.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# `zeus mcp` — Zeus as an MCP server

Zeus exposes its editor surface as an MCP server. Any MCP-aware client (Claude Code CLI, ChatGPT desktop, another editor, a script) can read buffers, apply edits, run commands, and start subagents through one protocol.

Implementation lives in two places:

- `cli/src/commands/mcp.rs` (this PR): the entry point — `code mcp` / `zeus mcp` subcommand
- `src/vs/workbench/contrib/mcpServer/` (later PR): the actual tool implementations, which talk to the workbench

## Subcommand surface

```text
zeus mcp [--transport stdio|sse] [--port N] [--bind IP] [--allow-non-loopback] [--workspace PATH]
```

- `--transport`: defaults to `stdio` (Anthropic / Claude Code CLI standard). `sse` for HTTP clients.
- `--port`: only meaningful with `--transport sse`. Defaults to a random ephemeral port; the chosen port is printed on stderr (stdout is reserved for protocol traffic on the stdio transport, and we keep stderr consistent across transports).
- `--bind`: only meaningful with `--transport sse`. Defaults to `127.0.0.1` (loopback). Accepts IP literals only — pass `127.0.0.1` / `::1` for loopback, not `localhost`. Any non-loopback value additionally requires `--allow-non-loopback`.
- `--allow-non-loopback`: acknowledges that binding the SSE transport to a non-loopback interface (`0.0.0.0`, a LAN IP, etc.) is intentional. Without this flag, non-loopback `--bind` values are refused.
- `--workspace`: workspace root path. Defaults to `$PWD`.

The Rust CLI launches a headless workbench process (or attaches to a running one if available) and proxies MCP traffic.

## Initial tool surface

```text
buffer_get(path) -> { content, language }
buffer_set(path, content) -> { ok }
edit_apply(path, range, text) -> { ok }
diagnostics_get(path?) -> { diagnostics[] }
selection_get() -> { path, range, text } | null
command_run(name, args?) -> { result }
visible_files() -> { paths[] }
search_workspace(query, opts?) -> { matches[] }
agent_start(skill, prompt) -> { agent_id }
agent_status(agent_id) -> { state, progress? }
agent_cancel(agent_id) -> { ok }
git_diff(staged?) -> { diff }
lsp_definitions(path, pos) -> { locations[] }
lsp_references(path, pos) -> { locations[] }
```

Detailed schemas land alongside the implementation PR.

## Authentication

MCP over stdio inherits the calling process's permissions; no extra auth.

MCP over SSE binds to `127.0.0.1` only by default and requires a bearer token printed on **stderr** on start (`Token: ...`). Stdout is reserved for protocol traffic on the stdio transport, and we keep stderr consistent across transports. Binding to non-loopback (`--bind 0.0.0.0`, a LAN IP, etc.) additionally requires `--allow-non-loopback` as a confirmation flag.

## Workspace isolation

A single Zeus install can host multiple `zeus mcp` instances, one per workspace. Each instance is scoped to its workspace root and refuses operations on paths outside it.

## Why headless and not just an extension?

A VS Code extension only runs inside the editor's UI process. We want this to work when no editor is open — for example, a CI job that wants to call `buffer_get` after applying a refactor PR. The CLI gives us that decoupling.

## Acceptance criteria for the implementation PR (not this scaffold PR)

- `code mcp` boots, prints transport info, and accepts an MCP `initialize` request
- `buffer_get` and `buffer_set` round-trip a small file in tests
- Refuses paths outside the workspace
- Plays nicely as a subprocess of Claude Code CLI

## Status

Scaffold only. The Rust command is registered and prints a stub message; real implementation is a follow-up.