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.rs
new 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.rs
new 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.rs
new 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.md
new 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.
