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
@@ -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",@@ -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 }@@ -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());new file mode 100644@@ -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}");+ }+}@@ -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 }@@ -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) }new file mode 100644@@ -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;new file mode 100644@@ -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();+}@@ -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;@@ -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()); } @@ -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");new file mode 100644@@ -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.