collective

Browse / github.com/storytold/vectorcraft / patch

Add optional loopback HTTP MCP transport

Add optional loopback HTTP MCP transport ## What and why Expose the existing MCP server through an optional loopback-only HTTP transport. Require a private 64-hex-character bearer token, constant-time token comparison, actual loopback peers, matching Host/Origin, and bounded JSON requests. Add `--http` and `--http-token-file` CLI options and tests using a real HTTP listener. This part contains no OAuth server, desktop settings, or editor-launch integration. The transport is for local use; it must not be published or tunneled. Two existing SVG test assertions also use platform-aware paths so the Windows workspace checks pass; prod

Status
open
Score
0.00
Adopters
0 other operators
Author
RyudoXK (imported from pull request #3300, open)
Base
bc1ae0825264c5ca4b27bd718d6e04f2121b8bb3
Size
+921 −4 in 12 files
Review
pending
Shared
2026-10-28
Id
e451e161ada17c4c2309e1a8

How it went

No other operator has reported using it yet.

Diff

raw · Cargo.lock apps/vectorcraft-cli/Cargo.toml apps/vectorcraft-cli/src/main.rs apps/vectorcraft-cli/tests/http_options_wording.rs crates/mcp/Cargo.toml crates/mcp/src/backend.rs crates/mcp/src/http.rs crates/mcp/src/http/tests.rs crates/mcp/src/lib.rs crates/svg/tests/import_details.rs crates/svg/tests/interactivity.rs docs/local-http-prototype.md

diff --git a/Cargo.lock b/Cargo.lock--- a/Cargo.lock+++ b/Cargo.lock@@ -4031,6 +4031,12 @@ dependencies = [  "write-fonts", ] +[[package]]+name = "subtle"+version = "2.6.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"+ [[package]] name = "svgtypes" version = "0.15.3"@@ -4762,6 +4768,8 @@ dependencies = [  "proptest",  "serde_json",  "socket2",+ "subtle",+ "tempfile",  "vectorcraft-doc",  "vectorcraft-engine",  "vectorcraft-format",diff --git a/apps/vectorcraft-cli/Cargo.toml b/apps/vectorcraft-cli/Cargo.toml--- a/apps/vectorcraft-cli/Cargo.toml+++ b/apps/vectorcraft-cli/Cargo.toml@@ -13,6 +13,9 @@ path = "src/main.rs" [lints] workspace = true +[features]+http = ["vectorcraft-mcp/http"]+ [dependencies] vectorcraft-color = { workspace = true } vectorcraft-doc = { workspace = true }diff --git a/apps/vectorcraft-cli/src/main.rs b/apps/vectorcraft-cli/src/main.rs--- a/apps/vectorcraft-cli/src/main.rs+++ b/apps/vectorcraft-cli/src/main.rs@@ -125,9 +125,16 @@ USAGE: /// The usage text plus the formats `document.open` reads and `document.export` writes. fn usage() -> String {     use vectorcraft_engine::cmd::fileio::{OPEN_EXTS, export_extensions};-    format!("{USAGE}\nReadable formats: .{}\nWritable formats: .{}\n", OPEN_EXTS.join(", ."), export_extensions().join(", ."))+    let http = if cfg!(all(feature = "http", not(target_arch = "wasm32"))) {+        "\nExperimental local HTTP:\n  vectorcraft-cli mcp --http 127.0.0.1:33981 --http-token-file PATH --connect 127.0.0.1:33982\n  Requires a private 64-hex-character bearer token file. No public binding or headless fallback.\n"+    } else {+        ""+    };+    format!("{USAGE}\nReadable formats: .{}\nWritable formats: .{}\n{http}", OPEN_EXTS.join(", ."), export_extensions().join(", .")) } +/// Run the requested CLI operation and report failures without opening the desktop editor.+/// Remote connection setup and serving require their optional build features. fn main() -> ExitCode {     // Before any font lookup: the fonts font services load (#579).     #[cfg(all(windows, not(target_vendor = "win7")))]@@ -173,9 +180,13 @@ fn with_stores(mut h: Headless) -> Headless {     h } +/// Serve MCP over stdio or the explicitly configured loopback HTTP endpoint.+/// Discovery can run while the editor is closed; desktop operations use the selected backend. fn mcp(args: &[String]) -> Result<(), String> {     let mut connect: Option<String> = None;     let mut headless = false;+    #[cfg(all(feature = "http", not(target_arch = "wasm32")))]+    let (mut http_address, mut http_token_file): (Option<String>, Option<String>) = (None, None);     let (mut read_root, mut write_root): (Option<String>, Option<String>) = (None, None);     let mut it = args.iter();     while let Some(a) = it.next() {@@ -187,6 +198,18 @@ fn mcp(args: &[String]) -> Result<(), String> {         match flag {             "--connect" => connect = Some(it.next().cloned().ok_or("--connect needs an address")?),             "--headless" => headless = true,+            #[cfg(all(feature = "http", not(target_arch = "wasm32")))]+            "--http" => {+                if http_address.replace(it.next().cloned().ok_or("--http needs a loopback address")?).is_some() {+                    return Err("--http is given twice".into());+                }+            }+            #[cfg(all(feature = "http", not(target_arch = "wasm32")))]+            "--http-token-file" => {+                if http_token_file.replace(it.next().cloned().ok_or("--http-token-file needs a path")?).is_some() {+                    return Err("--http-token-file is given twice".into());+                }+            }             "--automation-read-root" | "--automation-write-root" => {                 let dir = inline.or_else(|| it.next().cloned()).ok_or_else(|| format!("{flag} needs a folder"))?;                 let slot = if flag == "--automation-read-root" { &mut read_root } else { &mut write_root };@@ -201,6 +224,23 @@ fn mcp(args: &[String]) -> Result<(), String> {         return Err("use either --connect or --headless".into());     }     let roots = AutomationRoots::new(read_root.as_deref().map(Path::new), write_root.as_deref().map(Path::new))?;+    #[cfg(all(feature = "http", not(target_arch = "wasm32")))]+    if http_address.is_some() || http_token_file.is_some() {+        let address = http_address.ok_or("--http-token-file requires --http")?;+        let token_file = http_token_file.ok_or("--http requires --http-token-file")?;+        if headless || roots.is_some() {+            return Err("the HTTP prototype requires a live desktop backend; set automation roots on the desktop app".into());+        }+        let desktop = connect.as_deref().unwrap_or(DEFAULT_ADDR);+        let desktop_address: std::net::SocketAddr = desktop.parse().map_err(|_| "HTTP --connect must be a loopback IP:port")?;+        if !desktop_address.ip().is_loopback() {+            return Err("HTTP --connect must target a loopback desktop address".into());+        }+        let options = vectorcraft_mcp::http::Options::from_file(&address, Path::new(&token_file))?;+        let endpoint = vectorcraft_mcp::http::Endpoint::bind(options)?;+        eprintln!("vectorcraft-cli: experimental local HTTP at http://{}/mcp (bearer token required)", endpoint.address());+        return endpoint.serve(Box::new(Remote::deferred(desktop)));+    }     if roots.is_some() && connect.is_some() {         return Err("--automation-read-root and --automation-write-root confine the headless server; a running app enforces its own roots:                     start it with `vectorcraft --control <port> --automation-read-root <dir> --automation-write-root <dir>`"             .into());diff --git a/apps/vectorcraft-cli/tests/http_options_wording.rs b/apps/vectorcraft-cli/tests/http_options_wording.rsnew file mode 100644--- /dev/null+++ b/apps/vectorcraft-cli/tests/http_options_wording.rs@@ -0,0 +1,31 @@+//! Regression: preserve the v0.14 user-facing local HTTP option labels and help text.+//! These strings are part of our private VectorCraft's CLI compatibility contract.+#![cfg(feature = "http")]+#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]+use std::process::Command;++const BIN: &str = env!("CARGO_BIN_EXE_vectorcraft-cli");++#[test]+fn http_help_keeps_original_option_wording() {+    let output = Command::new(BIN).arg("--help").output().unwrap();+    assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));+    let help = String::from_utf8(output.stdout).unwrap();+    const ORIGINAL: &str = "Experimental local HTTP:\n  vectorcraft-cli mcp --http 127.0.0.1:33981 --http-token-file PATH --connect 127.0.0.1:33982\n  Requires a private 64-hex-character bearer token file. No public binding or headless fallback.";+    assert!(help.contains(ORIGINAL), "original HTTP CLI help was changed:\n{help}");+}++#[test]+fn http_option_errors_keep_original_diagnostics() {+    for (args, message) in [+        (vec!["mcp", "--http"], "--http needs a loopback address"),+        (vec!["mcp", "--http-token-file"], "--http-token-file needs a path"),+        (vec!["mcp", "--http", "127.0.0.1:33981"], "--http requires --http-token-file"),+        (vec!["mcp", "--http-token-file", "unused"], "--http-token-file requires --http"),+    ] {+        let result = Command::new(BIN).args(&args).output().unwrap();+        assert!(!result.status.success(), "{args:?} unexpectedly succeeded");+        let err = String::from_utf8_lossy(&result.stderr);+        assert!(err.contains(message), "{args:?} changed expected diagnostic ({message}): {err}");+    }+}diff --git a/crates/mcp/Cargo.toml b/crates/mcp/Cargo.toml--- a/crates/mcp/Cargo.toml+++ b/crates/mcp/Cargo.toml@@ -8,6 +8,10 @@ description = "VectorCraft MCP server: JSON-RPC 2.0 over stdio, driving a runnin [lints] workspace = true +[features]+# Local transport prototype; excluded from ordinary builds and from wasm.+http = ["dep:subtle"]+ [dependencies] vectorcraft-engine = { workspace = true } vectorcraft-render = { workspace = true }@@ -21,7 +25,11 @@ log = { workspace = true }  image = { workspace = true } +[target.'cfg(not(target_arch = "wasm32"))'.dependencies]+subtle = { version = "2.6", optional = true }+ [dev-dependencies]+tempfile = "3" vectorcraft-testkit = { workspace = true } proptest = { workspace = true } socket2 = { workspace = true }diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs--- a/crates/mcp/src/backend.rs+++ b/crates/mcp/src/backend.rs@@ -31,9 +31,16 @@ pub struct Remote { const MAX_REPLY_BYTES: usize = 256 * 1024 * 1024;  impl Remote {+    /// Prepare a desktop backend without opening a connection. Metadata-only discovery works+    /// while the desktop is closed; the first real call connects and fails explicitly if absent.+    /// As with `connect`, this never falls back to a headless session.+    pub fn deferred(addr: &str) -> Self {+        Self { addr: addr.to_string(), conn: None, next_id: 1 }+    }+     /// Connect to `addr` (e.g. `127.0.0.1:7979`), failing fast when nothing is listening.     pub fn connect(addr: &str) -> std::io::Result<Self> {-        let mut r = Self { addr: addr.to_string(), conn: None, next_id: 1 };+        let mut r = Self::deferred(addr);         r.reconnect()?;         Ok(r)     }diff --git a/crates/mcp/src/http.rs b/crates/mcp/src/http.rsnew file mode 100644--- /dev/null+++ b/crates/mcp/src/http.rs@@ -0,0 +1,423 @@+//! Experimental, local-only Streamable HTTP transport.+//!+//! This is deliberately not a public OAuth service. It binds only to loopback and requires a+//! 256-bit bearer token loaded from a private file. The existing `Server` handles MCP messages;+//! this module only validates HTTP and frames its replies. No headless fallback is introduced.++use std::io::{Read, Write};+use std::net::{SocketAddr, TcpListener, TcpStream};+use std::path::Path;+use std::time::{Duration, Instant};++use serde_json::{Value, json};+use subtle::ConstantTimeEq;++use crate::{Backend, Server};++const MAX_BODY: usize = 12 * 1024 * 1024;+const MAX_HEADERS: usize = 16 * 1024;+const REQUEST_DEADLINE: Duration = Duration::from_secs(10);+const WRITE_TIMEOUT: Duration = Duration::from_secs(5);+const VERSIONS: &[&str] = &["2025-03-26", "2025-06-18"];++/// Configuration for the local prototype. A public address is refused, even with a token.+pub struct Options {+    address: SocketAddr,+    token: String,+}++impl Options {+    /// Load the local bearer token and validate a loopback-only listener address.+    /// Reject oversized files and tokens other than 64 hexadecimal characters.+    pub fn from_file(address: &str, path: &Path) -> Result<Self, String> {+        let address: SocketAddr = address.parse().map_err(|e| format!("invalid HTTP address: {e}"))?;+        if !address.ip().is_loopback() {+            return Err("experimental HTTP must bind to a loopback address".into());+        }+        let file = std::fs::File::open(path).map_err(|e| format!("cannot open HTTP token file: {e}"))?;+        let metadata = file.metadata().map_err(|e| format!("cannot inspect HTTP token file: {e}"))?;+        if !metadata.is_file() {+            return Err("HTTP token path must be a regular file".into());+        }+        #[cfg(unix)]+        {+            use std::os::unix::fs::PermissionsExt;+            if metadata.permissions().mode() & 0o077 != 0 {+                return Err("HTTP token file must not be accessible by group or other users".into());+            }+        }+        let mut bytes = Vec::new();+        file.take(257).read_to_end(&mut bytes).map_err(|e| format!("cannot read HTTP token file: {e}"))?;+        if bytes.len() > 256 {+            return Err("HTTP token file is too large".into());+        }+        let text = std::str::from_utf8(&bytes).map_err(|_| "HTTP token must be UTF-8")?;+        let token = text.trim();+        if token.len() != 64 || !token.bytes().all(|b| b.is_ascii_hexdigit()) {+            return Err("HTTP token must contain exactly 64 random hexadecimal characters".into());+        }+        Ok(Self { address, token: token.into() })+    }+}++/// Bound HTTP endpoint. One request per connection, with a total input deadline.+pub struct Endpoint {+    transport: TcpListener,+    authority: String,+    token: String,+}++/// Limited HTTP/1.1 request; parsed headers are validated before body consumption.+struct HttpRequest {+    peer: SocketAddr,+    method: String,+    path: String,+    headers: Vec<(String, String)>,+    body: Vec<u8>,+}++impl HttpRequest {+    fn header(&self, name: &str) -> Result<Option<String>, Reply> {+        let mut matches = self.headers.iter().filter(|(key, _)| key.eq_ignore_ascii_case(name));+        let first = matches.next().map(|(_, value)| value.clone());+        if matches.next().is_some() {+            return Err(Reply::error(400, format!("duplicate {name} header")));+        }+        Ok(first)+    }+}++/// A read always honors the *original* deadline; slow trickles cannot extend it.+fn bounded_read(socket: &mut TcpStream, dest: &mut [u8], deadline: Instant) -> Result<usize, Reply> {+    let remaining = deadline.saturating_duration_since(Instant::now());+    if remaining.is_zero() {+        return Err(Reply::error(408, "HTTP request deadline exceeded"));+    }+    socket.set_read_timeout(Some(remaining)).map_err(|_| Reply::error(400, "cannot read request"))?;+    match socket.read(dest) {+        Ok(n) => Ok(n),+        Err(e) if matches!(e.kind(), std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock) => {+            Err(Reply::error(408, "HTTP request deadline exceeded"))+        }+        Err(_) => Err(Reply::error(400, "cannot read request")),+    }+}++/// Read one HTTP request line and a bounded header section, without consuming its body.+/// Rejecting authentication or Host/Origin now never tries to drain a stalled upload.+fn read_head(socket: &mut TcpStream, peer: SocketAddr, deadline: Instant) -> Result<HttpRequest, Reply> {+    let mut buffer = Vec::new();+    let boundary = loop {+        if let Some(i) = buffer.windows(4).position(|w| w == b"\r\n\r\n") {+            break i + 4;+        }+        if buffer.len() >= MAX_HEADERS {+            return Err(Reply::error(431, "HTTP headers too large"));+        }+        let mut chunk = [0u8; 4096];+        let capacity = (MAX_HEADERS - buffer.len()).min(chunk.len());+        let count = bounded_read(socket, &mut chunk[..capacity], deadline)?;+        if count == 0 {+            return Err(Reply::error(400, "incomplete HTTP request headers"));+        }+        buffer.extend_from_slice(&chunk[..count]);+    };+    let header_text = std::str::from_utf8(&buffer[..boundary]).map_err(|_| Reply::error(400, "invalid HTTP headers"))?;+    let mut lines = header_text.split("\r\n");+    let start = lines.next().ok_or_else(|| Reply::error(400, "invalid HTTP request line"))?;+    let mut tokens = start.split(' ');+    let (Some(method), Some(path), Some(version), None) = (tokens.next(), tokens.next(), tokens.next(), tokens.next()) else {+        return Err(Reply::error(400, "invalid HTTP request line"));+    };+    if version != "HTTP/1.1" || !method.bytes().all(|b| b.is_ascii_uppercase()) || !path.starts_with('/') {+        return Err(Reply::error(400, "invalid HTTP request line"));+    }+    let mut headers = Vec::new();+    for line in lines {+        if line.is_empty() {+            break;+        }+        if headers.len() >= 64 {+            return Err(Reply::error(431, "too many HTTP headers"));+        }+        let Some((key, value)) = line.split_once(':') else {+            return Err(Reply::error(400, "invalid HTTP header"));+        };+        if key.is_empty() || !key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-') || value.bytes().any(|b| b < 32 && b != b'\t' || b == 127) {+            return Err(Reply::error(400, "invalid HTTP header"));+        }+        headers.push((key.to_owned(), value.trim_matches([' ', '\t']).to_owned()));+    }+    Ok(HttpRequest {+        peer,+        method: method.to_owned(),+        path: path.to_owned(),+        headers,+        // Some of the body may already have arrived in the header read.+        body: buffer[boundary..].to_vec(),+    })+}++/// Receive an exactly declared bounded request body. Unsupported transfer framing is+/// rejected rather than interpreted ambiguously (request smuggling); connections close.+fn read_body(socket: &mut TcpStream, request: &mut HttpRequest, deadline: Instant) -> Result<(), Reply> {+    if request.header("Transfer-Encoding")?.is_some() {+        return Err(Reply::error(400, "Transfer-Encoding is not supported by this local endpoint"));+    }+    if request.header("Expect")?.is_some() {+        return Err(Reply::error(417, "Expect headers are not supported by this local endpoint"));+    }+    let declared = match request.header("Content-Length")? {+        Some(n) => n.parse::<usize>().map_err(|_| Reply::error(400, "invalid Content-Length"))?,+        None if request.method == "POST" => return Err(Reply::error(411, "Content-Length required")),+        None => 0,+    };+    if declared > MAX_BODY {+        return Err(Reply::error(413, "request body too large"));+    }+    request.body.truncate(declared);+    while request.body.len() < declared {+        let mut chunk = [0u8; 16384];+        let count = bounded_read(socket, &mut chunk[..(declared - request.body.len()).min(16384)], deadline)?;+        if count == 0 {+            return Err(Reply::error(400, "incomplete HTTP request body"));+        }+        request.body.extend_from_slice(&chunk[..count]);+    }+    Ok(())+}++impl Endpoint {+    /// Bind the validated local HTTP endpoint and retain its actual listening address.+    pub fn bind(options: Options) -> Result<Self, String> {+        let transport = TcpListener::bind(options.address).map_err(|e| format!("HTTP bind failed: {e}"))?;+        let address = transport.local_addr().map_err(|e| format!("HTTP listener address unavailable: {e}"))?;+        Ok(Self { transport, authority: address.to_string(), token: options.token })+    }++    /// Return the bound authority, including the assigned port when a test requested port zero.+    pub fn address(&self) -> &str {+        &self.authority+    }++    /// Dispatch requests serially. A stalled or malformed peer cannot retain this+    /// thread beyond the fixed per-request deadline.+    pub fn serve(self, backend: Box<dyn Backend>) -> Result<(), String> {+        let mut server = Server::new(backend);+        loop {+            let (socket, peer) = self.transport.accept().map_err(|e| format!("HTTP accept failed: {e}"))?;+            self.handle_socket(socket, peer, &mut server);+        }+    }++    fn handle_socket(&self, mut socket: TcpStream, peer: SocketAddr, server: &mut Server) {+        let deadline = Instant::now() + REQUEST_DEADLINE;+        let _ = socket.set_write_timeout(Some(WRITE_TIMEOUT));+        let reply = match read_head(&mut socket, peer, deadline) {+            Ok(mut request) => match self.validate(&request) {+                Err(reply) => reply,+                Ok(()) => match read_body(&mut socket, &mut request, deadline) {+                    Ok(()) => self.dispatch(&request, server),+                    Err(reply) => reply,+                },+            },+            Err(reply) => reply,+        };+        // All connections are close-after-response; rejected requests are *never*+        // synchronously drained. No edit is retried if the response is lost.+        if let Err(e) = socket.write_all(&reply.wire()) {+            log::debug!("HTTP client did not receive its response: {e}; request was not replayed");+        }+        // Close the sending half only: Windows may reset unread rejected bodies.+        let _ = socket.shutdown(std::net::Shutdown::Write);+    }++    /// Check the real loopback peer, Host, Origin, bearer token, route and protocol revision.+    /// Reject duplicate security headers before application dispatch.+    fn validate(&self, request: &HttpRequest) -> Result<(), Reply> {+        if !request.peer.ip().is_loopback() {+            return Err(Reply::error(403, "loopback clients only"));+        }+        let host = header(request, "Host")?;+        if host.as_deref() != Some(self.authority.as_str()) {+            return Err(Reply::error(403, "Host not allowed"));+        }+        if let Some(origin) = header(request, "Origin")?+            && origin != format!("http://{}", self.authority)+        {+            return Err(Reply::error(403, "Origin not allowed"));+        }+        let authorization = header(request, "Authorization")?.unwrap_or_default();+        let supplied = authorization.strip_prefix("Bearer ").unwrap_or("");+        if !bool::from(supplied.as_bytes().ct_eq(self.token.as_bytes())) {+            return Err(Reply::error(401, "bearer token required"));+        }+        if request.path != "/mcp" && request.path != "/health" {+            return Err(Reply::error(404, "not found"));+        }+        if let Some(version) = header(request, "MCP-Protocol-Version")?+            && !VERSIONS.contains(&version.as_str())+        {+            return Err(Reply::error(400, "unsupported MCP-Protocol-Version"));+        }+        Ok(())+    }++    /// Validate bounded HTTP/JSON-RPC input and dispatch one authenticated MCP message.+    /// Notifications receive an empty response without running edits; protocol state is per request.+    fn dispatch(&self, request: &HttpRequest, server: &mut Server) -> Reply {+        if request.path == "/health" {+            return if request.method == "GET" {+                Reply::json(+                    200,+                    json!({"service":"vectorcraft-native-http-prototype",+                    "version":env!("CARGO_PKG_VERSION"), "scope":"loopback-only"}),+                )+            } else {+                Reply::error(405, "use GET for health")+            };+        }+        if request.method != "POST" {+            return Reply::error(405, "this stateless endpoint accepts POST; standalone SSE is not offered");+        }+        let content_type = match header(request, "Content-Type") {+            Ok(Some(value)) => value,+            _ => return Reply::error(415, "Content-Type must be application/json"),+        };+        if content_type.split(';').next().map(str::trim) != Some("application/json") {+            return Reply::error(415, "Content-Type must be application/json");+        }+        let accept = match header(request, "Accept") {+            Ok(Some(value)) => value,+            _ => return Reply::error(406, "Accept must include application/json and text/event-stream"),+        };+        let accepted: Vec<&str> = accept.split(',').filter_map(|v| v.split(';').next().map(str::trim)).collect();+        if !accepted.contains(&"application/json") || !accepted.contains(&"text/event-stream") {+            return Reply::error(406, "Accept must include application/json and text/event-stream");+        }+        let bytes = &request.body;+        let mut message: Value = match serde_json::from_slice(&bytes) {+            Ok(value) => value,+            Err(_) => return Reply::rpc_error(400, -32700, "invalid JSON"),+        };+        if !message.is_object() || message.get("jsonrpc").and_then(Value::as_str) != Some("2.0") {+            return Reply::rpc_error(400, -32600, "expected a single JSON-RPC 2.0 message");+        }+        let Some(method) = message.get("method").and_then(Value::as_str).map(str::to_owned) else {+            // The transport permits client responses even though this prototype asks no questions.+            if message.get("id").is_some_and(|id| id.is_string() || id.is_number())+                && (message.get("result").is_some() ^ message.get("error").is_some())+            {+                return Reply::empty(202);+            }+            return Reply::rpc_error(400, -32600, "missing JSON-RPC method");+        };+        if message.get("params").is_some_and(|params| !params.is_object()) || message.get("id").is_some_and(|id| !id.is_string() && !id.is_number()) {+            return Reply::rpc_error(400, -32600, "invalid JSON-RPC id or params");+        }+        if message.get("id").is_none() {+            // No edit is a notification; this prototype offers no cancellation/progress stream.+            return Reply::empty(202);+        }+        let version = match header(request, "MCP-Protocol-Version") {+            Ok(Some(version)) => version,+            _ => "2025-03-26".into(),+        };+        if method == "initialize" {+            let asked = message.pointer("/params/protocolVersion").and_then(Value::as_str).unwrap_or(crate::PROTOCOL_VERSION);+            if !VERSIONS.contains(&asked) {+                message["params"]["protocolVersion"] = json!(crate::PROTOCOL_VERSION);+            }+        } else {+            // HTTP is stateless: one client's negotiated revision cannot leak into another's.+            let _ = server.handle(json!({"jsonrpc":"2.0","id":"http-version",+                "method":"initialize","params":{"protocolVersion":version}}));+        }+        if method == "logging/setLevel" {+            return Reply::json(+                200,+                json!({"jsonrpc":"2.0","id":message.get("id"),+                "error":{"code":-32601,"message":"HTTP prototype does not offer logging streams"}}),+            );+        }+        match server.handle(message) {+            Some(mut response) => {+                if method == "initialize"+                    && let Some(capabilities) = response.pointer_mut("/result/capabilities").and_then(Value::as_object_mut)+                {+                    capabilities.remove("logging");+                }+                Reply::json(200, response)+            }+            None => Reply::empty(202),+        }+    }+}++/// Read at most one header value, rejecting duplicates rather than choosing one.+fn header(request: &HttpRequest, name: &str) -> Result<Option<String>, Reply> {+    request.header(name)+}++struct Reply {+    status: u16,+    body: String,+}++impl Reply {+    /// Construct an HTTP reply with no response body, such as an accepted notification.+    fn empty(status: u16) -> Self {+        Self { status, body: String::new() }+    }+    /// Encode a JSON response using the requested HTTP status.+    fn json(status: u16, body: Value) -> Self {+        Self { status, body: body.to_string() }+    }+    /// Construct a transport error response rather than a successful tool result.+    fn error(status: u16, message: impl Into<String>) -> Self {+        Self::json(status, json!({"error":message.into()}))+    }+    /// Construct a JSON-RPC error with a null request ID for invalid incoming messages.+    fn rpc_error(status: u16, code: i64, message: &str) -> Self {+        Self::json(status, json!({"jsonrpc":"2.0","id":null,"error":{"code":code,"message":message}}))+    }+    /// Send a non-persistent HTTP/1.1 response with explicit framing. No unread+    /// request body is drained, even on authentication or protocol errors.+    fn wire(self) -> Vec<u8> {+        let reason = match self.status {+            200 => "OK",+            202 => "Accepted",+            400 => "Bad Request",+            401 => "Unauthorized",+            403 => "Forbidden",+            404 => "Not Found",+            405 => "Method Not Allowed",+            406 => "Not Acceptable",+            408 => "Request Timeout",+            411 => "Length Required",+            413 => "Content Too Large",+            415 => "Unsupported Media Type",+            417 => "Expectation Failed",+            431 => "Request Header Fields Too Large",+            _ => "Error",+        };+        let mut head = format!(+            "HTTP/1.1 {} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\nCache-Control: no-store\r\nX-Content-Type-Options: nosniff\r\n",+            self.status,+            self.body.len()+        );+        if self.status == 401 {+            head.push_str("WWW-Authenticate: Bearer realm=\"vectorcraft-local-prototype\"\r\n");+        }+        if self.status == 405 {+            head.push_str("Allow: POST\r\n");+        }+        head.push_str("\r\n");+        let mut bytes = head.into_bytes();+        bytes.extend_from_slice(self.body.as_bytes());+        bytes+    }+}++#[cfg(test)]+mod tests;diff --git a/crates/mcp/src/http/tests.rs b/crates/mcp/src/http/tests.rsnew file mode 100644--- /dev/null+++ b/crates/mcp/src/http/tests.rs@@ -0,0 +1,330 @@+use std::io::{Read, Write};+use std::net::{Shutdown, TcpStream};+use std::sync::Arc;+use std::sync::atomic::{AtomicUsize, Ordering};+use std::time::Duration;++use super::*;++const TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";++struct Fake(Arc<AtomicUsize>);+impl Backend for Fake {+    /// Count desktop dispatches and return a successful fake backend response.+    fn call(&mut self, _method: &str, _params: Value) -> Result<Value, String> {+        self.0.fetch_add(1, Ordering::SeqCst);+        Ok(json!({"ok":true}))+    }+    /// Advertise desktop support so transport tests exercise the native tool definitions.+    fn has_ui(&self) -> bool {+        true+    }+    /// Identify the fake desktop backend without contacting an editor.+    fn describe(&self) -> String {+        "test desktop".into()+    }+}++/// Start a loopback test endpoint that handles a fixed request count and records backend calls.+fn fixture(count: usize) -> (String, Arc<AtomicUsize>, std::thread::JoinHandle<()>) {+    let endpoint = Endpoint::bind(Options { address: "127.0.0.1:0".parse().unwrap(), token: TOKEN.into() }).unwrap();+    let address = endpoint.address().to_string();+    let calls = Arc::new(AtomicUsize::new(0));+    let backend = calls.clone();+    let worker = std::thread::spawn(move || {+        let mut mcp = Server::new(Box::new(Fake(backend)));+        for _ in 0..count {+            let (socket, peer) = endpoint.transport.accept().unwrap();+            endpoint.handle_socket(socket, peer, &mut mcp);+        }+    });+    (address, calls, worker)+}++/// Send one raw HTTP test request and return its status and wire response.+fn raw(address: &str, method: &str, path: &str, headers: &str, body: &str) -> (u16, String) {+    let mut socket = TcpStream::connect(address).unwrap();+    socket.set_read_timeout(Some(Duration::from_secs(5))).unwrap();+    write!(socket, "{method} {path} HTTP/1.1\r\nHost: {address}\r\nConnection: close\r\nContent-Length: {}\r\n{headers}\r\n{body}", body.len())+        .unwrap();+    socket.shutdown(Shutdown::Write).unwrap();+    let mut response = String::new();+    match socket.read_to_string(&mut response) {+        Ok(_) => {}+        Err(e) if e.kind() == std::io::ErrorKind::ConnectionReset && response.starts_with("HTTP/1.1 ") && response.contains("\r\n\r\n") => {}+        Err(e) => panic!("HTTP response was lost before completion: {e}: {response:?}"),+    }+    let status = response.split_whitespace().nth(1).unwrap().parse().unwrap();+    (status, response)+}++/// Build valid test authentication and content headers with optional extra fields.+fn headers(extra: &str) -> String {+    format!("Authorization: Bearer {TOKEN}\r\nContent-Type: application/json\r\nAccept: application/json, text/event-stream\r\n{extra}")+}++/// Send a JSON-RPC test call and decode either fixed-length or chunked response framing.+fn rpc(address: &str, method: &str, params: Value, extra: &str) -> (u16, Value) {+    let body = json!({"jsonrpc":"2.0","id":1,"method":method,"params":params}).to_string();+    let (status, response) = raw(address, "POST", "/mcp", &headers(extra), &body);+    let (head, body) = response.split_once("\r\n\r\n").unwrap();+    let bytes = if head.to_ascii_lowercase().contains("transfer-encoding: chunked") {+        // The native catalogue exceeds the HTTP library's chunk threshold. Test the real wire+        // framing instead of assuming every valid JSON response uses Content-Length.+        let mut remaining = body.as_bytes();+        let mut decoded = Vec::new();+        loop {+            let end = remaining.windows(2).position(|w| w == b"\r\n").unwrap();+            let length = usize::from_str_radix(std::str::from_utf8(&remaining[..end]).unwrap(), 16).unwrap();+            remaining = &remaining[end + 2..];+            if length == 0 {+                break;+            }+            assert!(remaining.len() >= length + 2);+            decoded.extend_from_slice(&remaining[..length]);+            remaining = &remaining[length + 2..];+        }+        decoded+    } else {+        body.as_bytes().to_vec()+    };+    (status, serde_json::from_slice(&bytes).unwrap())+}++/// Verify that every local endpoint requires authentication before backend dispatch.+#[test]+fn authentication_is_required_for_every_request() {+    let (address, calls, worker) = fixture(4);+    assert_eq!(raw(&address, "GET", "/health", "", "").0, 401);+    assert_eq!(raw(&address, "POST", "/mcp", "Authorization: Bearer wrong\r\n", "{}").0, 401);+    assert_eq!(rpc(&address, "tools/list", json!({}), "").0, 200);+    assert_eq!(raw(&address, "POST", "/mcp", "", "{}").0, 401);+    assert_eq!(calls.load(Ordering::SeqCst), 0);+    worker.join().unwrap();+}++/// Verify rejection of unexpected Host/Origin values and ambiguous authentication headers.+#[test]+fn host_origin_and_duplicate_auth_are_rejected() {+    let (address, calls, worker) = fixture(4);+    assert_eq!(rpc(&address, "tools/list", json!({}), "Origin: https://untrusted.example\r\n").0, 403);+    assert_eq!(rpc(&address, "tools/list", json!({}), "Host: untrusted.example\r\n").0, 400);+    assert_eq!(rpc(&address, "tools/list", json!({}), "Authorization: Bearer wrong\r\n").0, 400);+    assert_eq!(rpc(&address, "tools/list", json!({}), &format!("Origin: http://{address}\r\n")).0, 200);+    assert_eq!(calls.load(Ordering::SeqCst), 0);+    worker.join().unwrap();+}++/// Verify that tool discovery uses the native catalog without opening or contacting the editor.+#[test]+fn discovery_reuses_native_definitions_without_contacting_the_desktop() {+    let (address, calls, worker) = fixture(5);+    let (status, initialized) = rpc(&address, "initialize", json!({"protocolVersion":"2025-06-18"}), "");+    assert_eq!(status, 200);+    assert_eq!(initialized["result"]["serverInfo"]["name"], "vectorcraft");+    assert!(initialized["result"]["capabilities"].get("logging").is_none());+    let (_, tools) = rpc(&address, "tools/list", json!({}), "");+    assert_eq!(tools["result"]["tools"], json!(crate::tool_definitions()));+    for method in ["resources/list", "resources/templates/list", "prompts/list"] {+        let (status, response) = rpc(&address, method, json!({}), "");+        assert_eq!(status, 200);+        assert!(response.get("error").is_none(), "{response}");+    }+    assert_eq!(calls.load(Ordering::SeqCst), 0);+    worker.join().unwrap();+}++/// Verify malformed HTTP or JSON-RPC requests cannot reach the desktop backend.+#[test]+fn invalid_http_and_json_do_not_dispatch() {+    let (address, calls, worker) = fixture(8);+    assert_eq!(raw(&address, "GET", "/mcp", &headers(""), "").0, 405);+    assert_eq!(raw(&address, "DELETE", "/mcp", &headers(""), "").0, 405);+    assert_eq!(raw(&address, "POST", "/wrong", &headers(""), "{}").0, 404);+    assert_eq!(raw(&address, "POST", "/mcp", &headers(""), "{").0, 400);+    assert_eq!(raw(&address, "POST", "/mcp", &headers(""), "[]").0, 400);+    let no_accept = format!("Authorization: Bearer {TOKEN}\r\nContent-Type: application/json\r\n");+    assert_eq!(raw(&address, "POST", "/mcp", &no_accept, "{}").0, 406);+    let wrong_type = format!("Authorization: Bearer {TOKEN}\r\nContent-Type: text/plain\r\n");+    assert_eq!(raw(&address, "POST", "/mcp", &wrong_type, "{}").0, 415);+    assert_eq!(rpc(&address, "tools/list", json!({}), "MCP-Protocol-Version: 2099-01-01\r\n").0, 400);+    assert_eq!(calls.load(Ordering::SeqCst), 0);+    worker.join().unwrap();+}++/// Verify notifications receive empty accepted replies without dispatching artwork changes.+#[test]+fn notifications_have_empty_accepted_responses_and_never_edit() {+    let (address, calls, worker) = fixture(2);+    for method in ["notifications/initialized", "tools/call"] {+        let body = json!({"jsonrpc":"2.0","method":method,"params":{}}).to_string();+        let (status, response) = raw(&address, "POST", "/mcp", &headers(""), &body);+        assert_eq!(status, 202);+        assert!(response.split_once("\r\n\r\n").unwrap().1.is_empty());+    }+    assert_eq!(calls.load(Ordering::SeqCst), 0);+    worker.join().unwrap();+}++/// Verify that one HTTP client's selected protocol revision cannot affect another client.+#[test]+fn per_request_protocol_revisions_do_not_leak_between_clients() {+    let (address, _, worker) = fixture(3);+    let (_, init) = rpc(&address, "initialize", json!({"protocolVersion":"2026-07-28"}), "");+    assert_eq!(init["result"]["protocolVersion"], "2025-06-18");+    let (_, old) = rpc(&address, "tools/list", json!({}), "MCP-Protocol-Version: 2025-06-18\r\n");+    let (status, _) = rpc(&address, "tools/list", json!({}), "MCP-Protocol-Version: 2026-07-28\r\n");+    assert_eq!(old["result"]["tools"], json!(crate::tool_definitions()));+    assert_eq!(status, 400, "Do not claim a modern transport without discovery/metadata support");+    worker.join().unwrap();+}++/// Verify loopback-only binding and strict token-file validation before startup.+#[test]+fn public_bind_and_invalid_tokens_are_refused() {+    let missing = Path::new("does-not-exist-http-token");+    let err = Options::from_file("0.0.0.0:7979", missing).err().unwrap();+    assert!(err.contains("loopback"));+    assert!(Options::from_file("127.0.0.1:0", missing).is_err());+    let path = std::env::temp_dir().join(format!("vectorcraft-http-token-test-{}", std::process::id()));+    for text in ["too-short", &"x".repeat(64), &"0".repeat(300)] {+        std::fs::write(&path, text).unwrap();+        assert!(Options::from_file("127.0.0.1:0", &path).is_err());+    }+    std::fs::write(&path, format!("{TOKEN}\n")).unwrap();+    #[cfg(unix)]+    {+        use std::os::unix::fs::PermissionsExt;+        let mut permissions = std::fs::metadata(&path).unwrap().permissions();+        permissions.set_mode(0o600);+        std::fs::set_permissions(&path, permissions).unwrap();+    }+    assert!(Options::from_file("127.0.0.1:0", &path).is_ok());+    std::fs::remove_file(path).unwrap();+}++/// Verify discovery succeeds with a deferred backend while the editor is closed.+#[test]+fn deferred_remote_discovery_works_while_desktop_is_closed() {+    let endpoint = Endpoint::bind(Options { address: "127.0.0.1:0".parse().unwrap(), token: TOKEN.into() }).unwrap();+    let address = endpoint.address().to_string();+    let socket = std::net::TcpListener::bind("127.0.0.1:0").unwrap();+    let unused = socket.local_addr().unwrap().to_string();+    drop(socket);+    let worker = std::thread::spawn(move || {+        let mut server = Server::new(Box::new(crate::Remote::deferred(&unused)));+        for _ in 0..2 {+            let (socket, peer) = endpoint.transport.accept().unwrap();+            endpoint.handle_socket(socket, peer, &mut server);+        }+    });+    assert_eq!(rpc(&address, "tools/list", json!({}), "").0, 200);+    let (_, response) = rpc(&address, "tools/call", json!({"name":"ui_inspect","arguments":{}}), "");+    assert_eq!(response["result"]["isError"], true, "{response}");+    worker.join().unwrap();+}++/// Verify a lost HTTP reply cannot cause a native operation to run twice.+#[test]+fn native_tool_calls_are_dispatched_once_and_http_disconnect_does_not_replay() {+    let (address, calls, worker) = fixture(2);+    let body = json!({"jsonrpc":"2.0","id":1,"method":"tools/call",+        "params":{"name":"command_run","arguments":{"id":"file.new","params":{}}}})+    .to_string();+    let mut socket = TcpStream::connect(&address).unwrap();+    write!(socket, "POST /mcp HTTP/1.1\r\nHost: {address}\r\nConnection: close\r\nContent-Length: {}\r\n{}\r\n{body}", body.len(), headers(""))+        .unwrap();+    socket.flush().unwrap();+    for _ in 0..100 {+        if calls.load(Ordering::SeqCst) == 1 {+            break;+        }+        std::thread::sleep(Duration::from_millis(10));+    }+    assert_eq!(calls.load(Ordering::SeqCst), 1);+    drop(socket);+    let (_, response) = rpc(&address, "tools/list", json!({}), "");+    assert!(response["result"]["tools"].is_array());+    assert_eq!(calls.load(Ordering::SeqCst), 1);+    worker.join().unwrap();+}++/// Verify declared oversized bodies are rejected before backend dispatch.+#[test]+fn oversized_requests_are_rejected_before_reading_or_dispatch() {+    let (address, calls, worker) = fixture(1);+    let mut socket = TcpStream::connect(&address).unwrap();+    socket.set_read_timeout(Some(Duration::from_secs(5))).unwrap();+    write!(socket, "POST /mcp HTTP/1.1\r\nHost: {address}\r\nConnection: close\r\nContent-Length: {}\r\n{}\r\n", MAX_BODY + 1, headers("")).unwrap();+    socket.shutdown(Shutdown::Write).unwrap();+    let mut response = String::new();+    socket.read_to_string(&mut response).unwrap();+    assert_eq!(response.split_whitespace().nth(1), Some("413"));+    assert_eq!(calls.load(Ordering::SeqCst), 0);+    worker.join().unwrap();+}++/// Reject unauthenticated requests before attempting to drain unread bodies.+#[test]+fn unauthorized_incomplete_body_does_not_block_next_request() {+    let (address, calls, worker) = fixture(2);+    let mut stalled = TcpStream::connect(&address).unwrap();+    stalled.set_read_timeout(Some(Duration::from_secs(3))).unwrap();+    write!(stalled, "POST /mcp HTTP/1.1\r\nHost: {address}\r\nConnection: close\r\nAuthorization: Bearer wrong\r\nContent-Length: 900000\r\n\r\nx")+        .unwrap();+    stalled.flush().unwrap();+    let began = std::time::Instant::now();+    let mut wire = String::new();+    let _ = stalled.read_to_string(&mut wire);+    assert!(began.elapsed() < Duration::from_secs(3), "rejected bodies must not be drained");+    let (status, response) = rpc(&address, "tools/list", json!({}), "");+    assert_eq!(status, 200, "{response}");+    assert_eq!(calls.load(Ordering::SeqCst), 0);+    worker.join().unwrap();+}++/// A partial authorized upload must expire rather than pin the serial MCP server.+#[test]+fn incomplete_authenticated_body_has_deadline_and_releases_server() {+    let (address, calls, worker) = fixture(2);+    let mut stalled = TcpStream::connect(&address).unwrap();+    stalled.set_read_timeout(Some(REQUEST_DEADLINE + Duration::from_secs(4))).unwrap();+    write!(stalled, "POST /mcp HTTP/1.1\r\nHost: {address}\r\nConnection: close\r\nContent-Length: 5000\r\n{}\r\nx", headers("")).unwrap();+    stalled.flush().unwrap();+    let began = std::time::Instant::now();+    let mut response = String::new();+    let _ = stalled.read_to_string(&mut response);+    assert!(began.elapsed() < REQUEST_DEADLINE + Duration::from_secs(3));+    assert!(response.contains(" 408 ") || response.is_empty(), "unexpected timeout response: {response}");+    let (status, _) = rpc(&address, "tools/list", json!({}), "");+    assert_eq!(status, 200);+    assert_eq!(calls.load(Ordering::SeqCst), 0);+    worker.join().unwrap();+}++/// Ambiguous lengths or transfer encoding never reach the MCP server.+#[test]+fn duplicate_length_and_transfer_encoding_are_rejected() {+    let (address, calls, worker) = fixture(2);+    assert_eq!(raw(&address, "POST", "/mcp", &headers("Content-Length: 2\r\n"), "{}").0, 400);+    assert_eq!(raw(&address, "POST", "/mcp", &headers("Transfer-Encoding: chunked\r\n"), "{}").0, 400);+    assert_eq!(calls.load(Ordering::SeqCst), 0);+    worker.join().unwrap();+}++/// Incomplete request headers cannot hold the server open forever.+#[test]+fn incomplete_headers_are_bounded_by_deadline() {+    let (address, calls, worker) = fixture(2);+    let mut stalled = TcpStream::connect(&address).unwrap();+    stalled.set_read_timeout(Some(REQUEST_DEADLINE + Duration::from_secs(4))).unwrap();+    stalled.write_all(b"POST /mcp HTTP/1.1\r\nHost: ").unwrap();+    let began = std::time::Instant::now();+    let mut response = Vec::new();+    let _ = stalled.read_to_end(&mut response);+    assert!(began.elapsed() < REQUEST_DEADLINE + Duration::from_secs(3));+    let (status, _) = rpc(&address, "tools/list", json!({}), "");+    assert_eq!(status, 200);+    assert_eq!(calls.load(Ordering::SeqCst), 0);+    worker.join().unwrap();+}diff --git a/crates/mcp/src/lib.rs b/crates/mcp/src/lib.rs--- a/crates/mcp/src/lib.rs+++ b/crates/mcp/src/lib.rs@@ -16,6 +16,8 @@  mod backend; mod headless;+#[cfg(all(feature = "http", not(target_arch = "wasm32")))]+pub mod http; mod intake; pub mod logging; mod prompts;diff --git a/crates/svg/tests/import_details.rs b/crates/svg/tests/import_details.rs--- a/crates/svg/tests/import_details.rs+++ b/crates/svg/tests/import_details.rs@@ -90,6 +90,7 @@ fn a_linked_png_resolves_against_the_svg_folder_and_stays_linked() {     assert!((b.x0 - 10.0).abs() < 1e-6 && (b.y0 - 20.0).abs() < 1e-6 && (b.width() - 80.0).abs() < 1e-6, "{b:?}"); } +/// Verify a missing linked SVG image reports a warning and retains its placed bounds. #[test] fn a_missing_link_warns_and_keeps_a_placeholder_in_its_box() {     let dir = folder("missing");@@ -103,7 +104,8 @@ fn a_missing_link_warns_and_keeps_a_placeholder_in_its_box() {     assert!((b.x0 - 10.0).abs() < 1e-6 && (b.width() - 100.0).abs() < 1e-6 && (b.height() - 50.0).abs() < 1e-6, "{b:?}");     // Without a reader nothing is read: the same placeholder.     let (d, w) = import_with_report(&svg(r#"<image href="/abs/photo.png" width="10" height="10"/>"#)).unwrap();-    assert_eq!(images(&d)[0].1.link.as_ref().unwrap().path, "/abs/photo.png");+    // A stored filesystem path uses the platform's separators.+    assert_eq!(Path::new(&images(&d)[0].1.link.as_ref().unwrap().path), Path::new("/abs/photo.png"));     assert!(!w.is_empty()); } diff --git a/crates/svg/tests/interactivity.rs b/crates/svg/tests/interactivity.rs--- a/crates/svg/tests/interactivity.rs+++ b/crates/svg/tests/interactivity.rs@@ -153,7 +153,10 @@ fn import_never_reads_or_runs_scripts() {     let text = svg(r#"<script xlink:href="evil.js"/><script href="evil.js"/><script type="text/ecmascript">document.title = "x";</script>         <image xlink:href="photo.png" width="10" height="10"/>         <rect id="shape" width="20" height="20" onclick="fetch('https://example.com')" onfoo="x()" data-a="1"/>"#);-    let (doc, warnings) = import_with(&text, &ImportOptions { folder: Some("/tmp"), read: Some(&read) }).unwrap();+    // Resolving a local image needs an absolute folder on every platform, including a drive on Windows.+    let folder = std::env::temp_dir();+    let folder = folder.to_string_lossy();+    let (doc, warnings) = import_with(&text, &ImportOptions { folder: Some(&folder), read: Some(&read) }).unwrap();     let asked = asked.lock().unwrap().clone();     assert!(asked.iter().any(|p| p.contains("photo.png")) && !asked.iter().any(|p| p.contains("evil")), "read {asked:?}");     assert_eq!(doc.svg_scripts, ["evil.js"], "each link once, the inline script left out");diff --git a/docs/local-http-prototype.md b/docs/local-http-prototype.mdnew file mode 100644--- /dev/null+++ b/docs/local-http-prototype.md@@ -0,0 +1,60 @@+# Experimental loopback HTTP MCP transport+++This experimental Rust transport reuses `vectorcraft_mcp::Server`, the native tool catalogue and+`Remote`. It is a proof for architecture discussion, not a public remote-access release.+The desktop editor and its file/selection state are shared by all authorized callers.++Build with `cargo build -p vectorcraft-cli --features http`. Ordinary builds keep stdio unchanged;+the HTTP module and its dependencies are not compiled for WebAssembly.++Create a private file containing 32 cryptographically random bytes encoded as 64 hexadecimal+characters. Start a separate desktop profile with `--control 33982` and automation read/write roots+restricted to a disposable artwork folder. Then run:++```text+vectorcraft-cli mcp --http 127.0.0.1:33981 --http-token-file PATH --connect 127.0.0.1:33982+```++The endpoint is `http://127.0.0.1:33981/mcp`. Every request, including `/health`, requires+`Authorization: Bearer TOKEN`. Tokens are loaded from a file rather than placed on the command+line and are never printed by the server. Protect the token file with the current user's OS ACL.++The listener and desktop destination must both be loopback IP addresses. Host and optional Origin+must match the literal listener address. The implementation does not trust forwarded headers or+enable cross-origin browser access. POST requires JSON and Accept values for both JSON and SSE.+Requests are capped at 12 MiB and HTTP headers at 16 KiB. One HTTP/1.1 request is handled per+connection with an absolute 10-second header-and-body deadline and a bounded response-write wait.+Requests require a Content-Length for POST; chunked/other Transfer-Encoding and Expect requests+are explicitly rejected. Rejected requests are **not** drained before closing their connections.+This local prototype does not support keep-alive or request pipelining. Duplicate security or+framing headers, unsupported protocol versions and malformed/batched messages are refused before+reaching the editor. Replies use JSON, notifications+receive empty 202 responses, and standalone GET/SSE and DELETE receive 405 responses. No HTTP+session table is allocated. Each request's protocol-version header selects its native revision.++Tool definitions, prompts and resource lists work while the desktop is closed. A desktop operation+then returns an explicit unreachable error; it never edits a separate headless document. The next+explicit operation can reconnect after the user opens the desktop. Requests run serially and are+not automatically replayed when the client disconnects. A missing native reply can mean the edit+completed: inspect the document before explicitly retrying it.++The original loopback transport deliberately omits OAuth, an authorization/settings interface, background+service installation, remote app launching, SSE progress/logging, and HTTP cancellation. It does+not advertise logging support. Long-running backend edits still execute serially and can delay other requests. A read deadline is+not an admission control system: malicious local clients can still monopolize the listener by+repeating stalled requests. Additional admission/rate controls are required before any public use. A bearer token alone does+not implement the MCP OAuth authorization flow. **Do not publish or tunnel this prototype.**++Transport behavior follows the public MCP specification:+<https://modelcontextprotocol.io/specification/2025-06-18/basic/transports>.++Tests in `crates/mcp/src/http/tests.rs` exercise the real loopback HTTP listener, authorization on+every request, Host/Origin checks, metadata fidelity, protocol isolation, framing/limits, desktop+absence and dispatch-without-replay. Existing MCP tests continue to exercise native tool behavior.++## Validation scope++The transport is optional and has no OAuth server, app settings or process-launch integration.+The Windows workspace gate also needs two existing SVG test assertions to use platform-aware+filesystem paths; those test-only portability corrections accompany this first part.