diff --git a/cli/src/bin/code/main.rs b/cli/src/bin/code/main.rs index 8a9c38f3..d0a5b470 100644 --- a/cli/src/bin/code/main.rs +++ b/cli/src/bin/code/main.rs @@ -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, @@ -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 diff --git a/cli/src/commands.rs b/cli/src/commands.rs index eeb8fc53..d502a8eb 100644 --- a/cli/src/commands.rs +++ b/cli/src/commands.rs @@ -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; diff --git a/cli/src/commands/args.rs b/cli/src/commands/args.rs index ef6daa07..3bb0ae07 100644 --- a/cli/src/commands/args.rs +++ b/cli/src/commands/args.rs @@ -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, + + /// Workspace root. Defaults to the current working directory. The + /// server refuses operations on paths outside this root. + #[clap(long)] + pub workspace: Option, +} + +#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)] +pub enum McpTransport { + Stdio, + Sse, } #[derive(Args, Debug, Clone)] diff --git a/cli/src/commands/mcp.rs b/cli/src/commands/mcp.rs new file mode 100644 index 00000000..683299a7 --- /dev/null +++ b/cli/src/commands/mcp.rs @@ -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 { + 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 { + 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()) + } + } +} diff --git a/docs/zeus-mcp-server.md b/docs/zeus-mcp-server.md new file mode 100644 index 00000000..16e82e19 --- /dev/null +++ b/docs/zeus-mcp-server.md @@ -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.