feat(http): add stateless dual-era Streamable HTTP transport
Adds mcp-server-run-http beside the existing stdio loop. The transport targets protocol revision 2026-07-28 -- per-request _meta, no sessions, no Mcp-Session-Id -- while still answering a legacy initialize handshake so clients that have not migrated keep working.
Dispatch was already factored out of the read loop, so both transports share mcp-server-handle-message and cannot drift apart.
- Origin is validated against the whole host, not a substring, and an absent Origin is allowed (non-browser clients send none).
MCP-Protocol-Version,Mcp-MethodandMcp-Namemust agree with the body. The headers exist so a proxy can route without parsing JSON, which only holds while the two agree.- An unimplemented METHOD answers 404; an unknown TOOL answers 200 with an error. Both reach the transport as -32601, so the method name is checked against an explicit list before dispatch rather than inferred from the response.
- Results carry
resultTypeand_meta.serverInfo; listings also carryttlMs/cacheScope. Tool output is deliberately not cacheable.
Two fixes fall out of this and apply to stdio as well:
initializenow answers with the version the client asked for when it is supported, instead of a hardcoded one. A client pinned to an older revision was being handed a version string it did not know.server/discoveris implemented, and reports supported versions, capabilities and identity.
The sigil: floor moves from the grandfathered caret ^0.19 to a bare "0.21", matching the estate convention that the field is a floor rather than a range.
sigil-http reaches native code through sigil-tls, so sigil test now requires an explicit test harness; sigil-test and sigil-test-runner are added as dev-dependencies.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HgY55w6F7XJf643tYHhwLn
package.sgl | 14 +++++-
sigil.lock | 44 +++++++++++++++++--
src/sigil/mcp/http.sgl | 391 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
src/sigil/mcp/protocol.sgl | 65 ++++++++++++++++++++++++++++
src/sigil/mcp/server.sgl | 40 +++++++++++++++--
test/test-http.sgl | 364 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 909 insertions(+), 9 deletions(-)package.sglmodified
(package name: "sigil-mcp" version: "0.17.5" sigil: "^0.19" sigil: "0.21" description: "Library for building MCP clients and servers in Sigil" url: "https://codeberg.org/sigil/sigil-mcp" license: "BSD-3-Clause" dependencies: (list (from-git url: "codeberg:sigil/sigil-json" version: "^0.16") (from-git url: "codeberg:sigil/sigil-log" version: "^0.16") (from-git url: "codeberg:sigil/sigil-nrepl" version: "^0.17.1"))) (from-git url: "codeberg:sigil/sigil-http" version: "^0.18.5") (from-git url: "codeberg:sigil/sigil-nrepl" version: "^0.17.1")) ;; sigil-http reaches native code through sigil-tls, and `sigil test` ;; requires an explicit test harness for any package whose graph contains ;; native code. These are test-only and do not reach consumers. dev-dependencies: (list (from-git url: "codeberg:sigil/sigil" package: "sigil-test" version: ">=0.21") (from-git url: "codeberg:sigil/sigil" package: "sigil-test-runner" version: ">=0.21")))sigil.lockmodified
(lock (package name: "sigil-stdlib" url: "codeberg:sigil/sigil" ref: "^0.19" sha: "b99b8f2dd82e7ff7465c3d49a1f81250f2d00007" ref: ">=0.21, <1.0" sha: "8643e9abf19038c5f4961d41748427dbd623cb15" package-selector: "sigil-stdlib" version: "0.19.0") version: "0.21.0") (package name: "sigil-test" url: "codeberg:sigil/sigil" ref: ">=0.21" sha: "8643e9abf19038c5f4961d41748427dbd623cb15" package-selector: "sigil-test" version: "0.21.0") (package name: "sigil-test-runner" url: "codeberg:sigil/sigil" ref: ">=0.21" sha: "8643e9abf19038c5f4961d41748427dbd623cb15" package-selector: "sigil-test-runner" version: "0.21.0") (package name: "sigil-json" url: "codeberg:sigil/sigil-json" ref: "^0.16" ref: "^0.16" sha: "56e4fc5a52dc370c037ef14502bb8d6d2876dc53" version: "0.16.0") (package name: "sigil-http" url: "codeberg:sigil/sigil-http" ref: "^0.18.5" sha: "0b4617ac000d6f3ed9f3d0906e6b746ad995ea03" version: "0.18.5") (package name: "sigil-nrepl" url: "codeberg:sigil/sigil-nrepl" ref: "^0.17.1" sha: "3d741f14b046ed7a3e39017a3a6d7229be4537a8" version: "0.17.1") (package name: "sigil-crypto" url: "codeberg:sigil/sigil-crypto" ref: "^0.16.0" sha: "debba4462b7ff4de143db9e0792b8606fdc455bc" version: "0.16.5") (package name: "sigil-sqlite" url: "codeberg:sigil/sigil-sqlite" ref: "^0.16.0" sha: "569ef1063dda8f5989968644af31fc09c64b4bfe" version: "0.16.3") (package name: "sigil-tls" url: "codeberg:sigil/sigil-tls" ref: "^0.16.5" sha: "4cde885f8bbb15b1457b0293a5b9fa7d292cfc46" version: "0.16.6") (package name: "sigil-repl" url: "codeberg:sigil/sigil" ref: "^0.19" sha: "b99b8f2dd82e7ff7465c3d49a1f81250f2d00007" package-selector: "sigil-repl" version: "0.19.0") (package name: "sigil-lib" url: "codeberg:sigil/sigil" ref: "^0.19" sha: "8643e9abf19038c5f4961d41748427dbd623cb15" package-selector: "sigil-lib" version: "0.21.0"))src/sigil/mcp/http.sgladded
;;; (sigil mcp http) - Streamable HTTP transport for MCP servers.;;;;;; Serves an MCP server over a single POST endpoint, stateless and;;; dual-era. It targets protocol revision 2026-07-28 — per-request `_meta`,;;; no sessions, no `Mcp-Session-Id` — while still answering the legacy;;; `initialize` handshake for clients that have not migrated.;;;;;; Stateless refers to MCP sessions only. The hosting application stays as;;; stateful as it likes; what this transport promises is that no request;;; depends on a previous one having arrived on the same connection.;;;;;; ```scheme;;; (import (sigil mcp) (sigil mcp http));;;;;; (define server (mcp-server name: "example" version: "1.0.0"));;; (mcp-server-register-tool! server "ping" "Reply with pong";;; '((type . "object") (properties . ()));;; (lambda (args) "pong"));;; (mcp-server-run-http server port: 8765);;; ```(define-library (sigil mcp http) (import (sigil mcp protocol) (sigil mcp server) (sigil http request) (sigil http response) (sigil http server) (sigil json) (sigil dict) (sigil string) (sigil error) (sigil log)) (export mcp-server-run-http mcp-http-handler mcp-http-origin-allowed? mcp-http-cacheable-method?) (begin ;; ============================================================ ;; Cacheability ;; ============================================================ ;; Results of these methods must carry `ttlMs` and `cacheScope`. ;; Everything a tool returns is excluded: tool output is a computation, ;; not a listing, and caching it would change behaviour. (define cacheable-methods (list "tools/list" "prompts/list" "resources/list" "resources/read" "resources/templates/list" "server/discover")) ;; Conservative default: one minute, and never cacheable by a shared ;; intermediary. A server whose tool list is genuinely public can widen ;; this, but guessing "public" on behalf of an application that exposes ;; per-user tools would leak one client's surface to another. (define default-cache-ttl-ms 60000) (define default-cache-scope "private") ;;; Does a result for `method` have to carry cache metadata? (define (mcp-http-cacheable-method? method) (and (string? method) (if (member method cacheable-methods) #t #f))) ;; ============================================================ ;; Origin Validation ;; ============================================================ ;; Hosts a browser page may legitimately be served from when it is ;; talking to a loopback MCP server. Anything else is treated as a ;; DNS-rebinding attempt. (define loopback-hosts (list "127.0.0.1" "localhost" "[::1]" "::1")) ;; Reduce an Origin header to its host, dropping scheme and port. ;; "http://127.0.0.1:8765" -> "127.0.0.1" (define (origin-host origin) (let* ((after-scheme (let ((parts (string-split origin "://"))) (if (> (length parts) 1) (cadr parts) (car parts)))) ;; An IPv6 literal keeps its brackets, so only split on a ;; colon that follows them. (host (if (string-starts-with? after-scheme "[") (let ((parts (string-split after-scheme "]"))) (if (> (length parts) 1) (string-append (car parts) "]") after-scheme)) (car (string-split after-scheme ":"))))) (string-downcase host))) ;;; Is this `Origin` header value acceptable? ;;; ;;; An absent Origin (`#f`) is allowed: non-browser clients do not send ;;; one, and the spec only requires rejecting an Origin that is present ;;; and invalid. A present Origin must name a loopback host. (define (mcp-http-origin-allowed? origin) (cond ((not origin) #t) ((not (string? origin)) #f) ((equal? origin "null") #f) (else (if (member (origin-host origin) loopback-hosts) #t #f)))) ;; ============================================================ ;; Response Helpers ;; ============================================================ ;; `dict-ref` raises on a non-dict, and both `params` and a missing ;; `_meta` degrade to '() or #f, so every optional lookup goes here. (define (safe-ref d key default) (if (dict? d) (dict-ref d key default) default)) (define (json-error-body id code message data) (error-response->json (if data (make-error-response id code message data) (make-error-response id code message)))) ;; An error carried by an HTTP status other than 200. The body is a ;; JSON-RPC error so a client that reads it learns which rule it broke; ;; the status is what a client that does not read bodies acts on. (define (error-response status id code message . data) (http-response status: status headers: #{ content-type: "application/json" } body: (json-error-body id code message (if (null? data) #f (car data))))) (define (server-info-meta server) (list (cons (string->symbol meta-server-info-key) `((name . ,(mcp-server-name server)) (version . ,(mcp-server-version server)))))) ;; Stamp a successful result with the envelope every modern result owes: ;; `resultType`, `_meta.serverInfo`, and cache metadata for the methods ;; that require it. Error responses pass through untouched — the spec ;; puts none of this on an error. (define (augment-result server resp method) (if (jsonrpc-response? resp) (let* ((base (let ((r (jsonrpc-response-result resp))) (if (list? r) r '()))) (cached (if (mcp-http-cacheable-method? method) (append `((ttlMs . ,default-cache-ttl-ms) (cacheScope . ,default-cache-scope)) base) base))) (jsonrpc-response id: (jsonrpc-response-id resp) result: (append `((resultType . "complete") (_meta . ,(server-info-meta server))) cached))) resp)) ;; ============================================================ ;; Request Validation ;; ============================================================ ;; Which era is this request in? A body naming `initialize` is legacy by ;; definition — that method does not exist in the modern revision. Every ;; other request is modern and must carry the `_meta` envelope. (define (legacy-initialize? method) (equal? method "initialize")) ;; Validate the header/body agreement the modern era requires. Returns #f ;; when the request is well-formed, or an http-response describing the ;; violation. ;; ;; The headers duplicate values already in the body so that proxies and ;; gateways can route and authorise without parsing JSON. That only holds ;; if the two actually agree, which is why a mismatch is an error rather ;; than a preference for one source. (define (validate-headers req method id name-required) (let ((hdr-method (http-request-header req "Mcp-Method")) (hdr-name (http-request-header req "Mcp-Name"))) (cond ((not hdr-method) (error-response HTTP-BAD-REQUEST id error-header-mismatch "Missing required header: Mcp-Method")) ((not (equal? hdr-method method)) (error-response HTTP-BAD-REQUEST id error-header-mismatch (format "Mcp-Method header ~s does not match request method ~s" hdr-method method))) ((and name-required (not hdr-name)) (error-response HTTP-BAD-REQUEST id error-header-mismatch (format "Missing required header: Mcp-Name (required for ~a)" method))) ((and name-required (not (equal? hdr-name name-required))) (error-response HTTP-BAD-REQUEST id error-header-mismatch (format "Mcp-Name header ~s does not match request target ~s" hdr-name name-required))) (else #f)))) ;; The name a request's `Mcp-Name` header must carry, or #f when the ;; method does not use one. (define (request-target-name method params) (cond ((equal? method "tools/call") (safe-ref params name: #f)) ((equal? method "prompts/get") (safe-ref params name: #f)) ((equal? method "resources/read") (safe-ref params uri: #f)) (else #f))) ;; Validate the modern `_meta` envelope and the protocol version, in both ;; the body and the header. Returns #f when well-formed, else a response. (define (validate-modern-envelope req params id) (let* ((meta (safe-ref params '_meta: #f)) (body-version (safe-ref meta (string->keyword meta-protocol-version-key) #f)) (caps (safe-ref meta (string->keyword meta-client-capabilities-key) #f)) (hdr-version (http-request-header req "MCP-Protocol-Version"))) (cond ((not body-version) (error-response HTTP-BAD-REQUEST id error-invalid-params (format "Missing required _meta field: ~a" meta-protocol-version-key))) ((not caps) (error-response HTTP-BAD-REQUEST id error-invalid-params (format "Missing required _meta field: ~a" meta-client-capabilities-key))) ((not (member body-version mcp-supported-versions)) (error-response HTTP-BAD-REQUEST id error-unsupported-version "Unsupported protocol version" `((supported . ,mcp-supported-versions) (requested . ,body-version)))) ((not hdr-version) (error-response HTTP-BAD-REQUEST id error-header-mismatch "Missing required header: MCP-Protocol-Version")) ((not (equal? hdr-version body-version)) (error-response HTTP-BAD-REQUEST id error-header-mismatch (format "MCP-Protocol-Version header ~s does not match _meta ~s" hdr-version body-version))) (else #f)))) ;; ============================================================ ;; Dispatch ;; ============================================================ ;; Methods this server does not implement answer 404, not 200-with-error: ;; the spec makes "no such method" an HTTP-level fact so a client can tell ;; it apart from a method that ran and failed. (define (method-not-found-response id method) (error-response HTTP-NOT-FOUND id error-method-not-found (format "Unknown method: ~a" method))) ;; The methods this server implements. An unknown METHOD is an HTTP-level ;; 404; an unknown TOOL is a 200 carrying an error from a method that ran. ;; Both arrive from the core dispatcher as -32601, so they cannot be told ;; apart by inspecting the response — the method name has to be checked ;; against this list BEFORE dispatching. (define implemented-methods (list "initialize" "server/discover" "tools/list" "tools/call" "resources/list" "resources/read" "resources/templates/list" "prompts/list" "prompts/get" ;; Removed in 2026-07-28, retained for legacy clients. "ping")) (define (implemented-method? method) (and (string? method) (if (member method implemented-methods) #t #f))) (define (dispatch-message server msg method) (let ((resp (mcp-server-handle-message server msg))) (cond ;; Notifications produce no response at all. ((not resp) (http-response status: HTTP-ACCEPTED headers: #{ content-type: "application/json" } body: "")) ((jsonrpc-error-response? resp) (http-response status: HTTP-OK headers: #{ content-type: "application/json" } body: (error-response->json resp))) (else (http-response status: HTTP-OK headers: #{ content-type: "application/json" } body: (response->json (augment-result server resp method))))))) ;; The method name of a request, or #f for anything else. Used to tell a ;; dispatcher-level "unknown method" apart from an unknown TOOL, which is ;; also reported as -32601 but is a 200 with an error, not a 404. (define (jsonrpc-request-method-safe msg) (if (jsonrpc-request? msg) (jsonrpc-request-method msg) #f)) ;; ============================================================ ;; The Handler ;; ============================================================ (define (handle-post server req) (let* ((body (http-request-body req)) (msg (guard (e (else #f)) (parse-message-string (if (string? body) body ""))))) (if (not msg) (error-response HTTP-BAD-REQUEST #f error-parse "Parse error") (let* ((method (jsonrpc-request-method-safe msg)) (id (if (jsonrpc-request? msg) (jsonrpc-request-id msg) #f)) (params (if (jsonrpc-request? msg) (jsonrpc-request-params msg) #f))) (cond ;; Legacy era: an `initialize` request opts this exchange out ;; of the modern envelope entirely. Header and `_meta` rules ;; do not apply, because the client predates them. ((legacy-initialize? method) (let ((resp (mcp-server-handle-message server msg))) (http-response status: HTTP-OK headers: #{ content-type: "application/json" } body: (if (jsonrpc-error-response? resp) (error-response->json resp) (response->json resp))))) ;; A legacy notification (`notifications/initialized`) carries ;; no envelope either, and wants no response. ((jsonrpc-notification? msg) (dispatch-message server msg method)) ;; Checked before the envelope: a client asking for a method ;; we do not have gets the same 404 whether or not its headers ;; were in order, and learns the endpoint exists. ((not (implemented-method? method)) (method-not-found-response id method)) (else (or (validate-modern-envelope req params id) (validate-headers req method id (request-target-name method params)) (dispatch-message server msg method)))))))) ;;; Build the HTTP handler for an MCP server. ;;; ;;; Returns a procedure suitable for `http-serve`. Exposed separately from ;;; `mcp-server-run-http` so it can be tested against constructed requests ;;; without opening a socket. (define (mcp-http-handler server (keys: (path "/mcp"))) (lambda (req) (guard (e (else (guard (le (else #f)) (log-error "MCP HTTP handler failed" error: (guard (fe (else "unknown error")) (format-exception e)))) (error-response HTTP-INTERNAL-SERVER-ERROR #f error-internal "Internal error"))) (let ((method (http-request-method req)) (origin (http-request-header req "Origin"))) (cond ;; Origin is checked before anything else, including the path: ;; a rebinding attempt should not learn which paths exist. ((not (mcp-http-origin-allowed? origin)) (error-response HTTP-FORBIDDEN #f error-invalid-request "Origin not permitted")) ((not (equal? (http-request-path req) path)) (error-response HTTP-NOT-FOUND #f error-method-not-found "Not found")) ;; There is no GET stream in this revision: resumability and ;; the server-opened SSE channel are both gone. ((or (equal? method "GET") (equal? method "DELETE")) (error-response HTTP-METHOD-NOT-ALLOWED #f error-invalid-request "Method not allowed; POST a JSON-RPC message to this endpoint")) ((not (equal? method "POST")) (error-response HTTP-METHOD-NOT-ALLOWED #f error-invalid-request "Method not allowed")) (else (handle-post server req))))))) ;;; Run an MCP server over Streamable HTTP. ;;; ;;; Binds loopback by default. The transport is stateless: it never issues ;;; or honours a session id, so any number of clients may attach and each ;;; may restart without disturbing the others or the hosting application. ;;; ;;; Blocks until the server stops, exactly as `mcp-server-run` does. (define (mcp-server-run-http server (keys: (port 8765) (host "127.0.0.1") (path "/mcp"))) (log-info "MCP HTTP server ready" name: (mcp-server-name server) host: host port: port path: path) (http-serve (mcp-http-handler server path: path) port: port host: host))))src/sigil/mcp/protocol.sglmodified
error-method-not-found error-invalid-params error-internal error-header-mismatch error-missing-capability error-unsupported-version ;; Protocol revisions mcp-current-version mcp-legacy-versions mcp-supported-versions mcp-negotiate-legacy-version ;; Reserved `_meta` keys meta-protocol-version-key meta-client-capabilities-key meta-client-info-key meta-server-info-key ;; Helpers make-error-response (define error-invalid-params -32602) (define error-internal -32603) ;; MCP spec-defined codes. The -32020..-32099 band is reserved for the ;; specification's own codes; -32000..-32019 stays implementation-defined, ;; so nothing new may be allocated there. (define error-header-mismatch -32020) (define error-missing-capability -32021) (define error-unsupported-version -32022) ;; ============================================================ ;; Protocol Revisions ;; ============================================================ ;;; The current (stateless) protocol revision. Requests in this era carry ;;; their version in `_meta` on every message and never call `initialize`. (define mcp-current-version "2026-07-28") ;;; Revisions reachable only through the legacy `initialize` handshake, ;;; newest first. The head is the fallback offered to a legacy client ;;; whose requested version we do not recognise. (define mcp-legacy-versions (list "2025-11-25" "2025-06-18" "2025-03-26" "2024-11-05")) ;;; Everything this implementation can speak, newest first. (define mcp-supported-versions (cons mcp-current-version mcp-legacy-versions)) ;;; Choose the version to answer a legacy `initialize` with. ;;; ;;; Echoes the client's request when we support it, otherwise offers our ;;; newest legacy revision. NEVER answers with `mcp-current-version`: that ;;; revision has no `initialize` handshake at all, so a legacy client ;;; handed it would be looking at a version it cannot speak. A client that ;;; asked for a version we do not know is expected to disconnect if it ;;; cannot accept the counter-offer. (define (mcp-negotiate-legacy-version requested) (if (and (string? requested) (member requested mcp-legacy-versions)) requested (car mcp-legacy-versions))) ;; ============================================================ ;; Reserved `_meta` Keys ;; ============================================================ ;; The `io.modelcontextprotocol/` prefix is reserved by the spec for the ;; protocol's own fields; these are the four this implementation reads or ;; writes. (define meta-protocol-version-key "io.modelcontextprotocol/protocolVersion") (define meta-client-capabilities-key "io.modelcontextprotocol/clientCapabilities") (define meta-client-info-key "io.modelcontextprotocol/clientInfo") (define meta-server-info-key "io.modelcontextprotocol/serverInfo") ;; ============================================================ ;; Message Parsing ;; ============================================================src/sigil/mcp/server.sglmodified
(sigil log)) (export mcp-server mcp-server? mcp-server-name mcp-server-version mcp-server-initialized mcp-server-register-tool! mcp-server-register-resource! (else (cons (car rest) (keep (cdr rest))))))) defaults)) ;; `dict-ref` raises on a non-dict, and `params` degrades to '() for a ;; request that carried none, so every optional lookup goes through this. (define (params-ref params key default) (if (dict? params) (dict-ref params key default) default)) (define (server-info server) `((name . ,(mcp-server-name server)) (version . ,(mcp-server-version server)))) (define (handle-initialize server params id) (set-mcp-server-initialized! server #t) ;; Answer with the version the client asked for when we support it. ;; Replying with a fixed version regardless of the request breaks ;; clients pinned to an older revision: they receive a version string ;; they do not know and disconnect. (let ((caps (merge-capabilities (default-capabilities server) (mcp-server-capabilities server))) (info `((name . ,(mcp-server-name server)) (version . ,(mcp-server-version server))))) (negotiated (mcp-negotiate-legacy-version (params-ref params protocolVersion: #f)))) (make-response id (append `((protocolVersion . "2025-11-25") `((protocolVersion . ,negotiated) (capabilities . ,caps) (serverInfo . ,info)) (serverInfo . ,(server-info server))) (if (mcp-server-instructions server) `((instructions . ,(mcp-server-instructions server))) '()))))) ;;; Answer `server/discover`: the versions, capabilities and identity a ;;; client reads before sending anything else. Required of every server ;;; from revision 2026-07-28, and harmless on stdio, so it is answered ;;; here rather than in a single transport. (define (handle-server-discover server params id) (let ((caps (merge-capabilities (default-capabilities server) (mcp-server-capabilities server)))) (make-response id (append `((supportedVersions . ,mcp-supported-versions) (capabilities . ,caps)) (if (mcp-server-instructions server) `((instructions . ,(mcp-server-instructions server))) '()))))) (cond ((equal? method "initialize") (handle-initialize server params id)) ((equal? method "server/discover") (handle-server-discover server params id)) ((equal? method "tools/list") (handle-tools-list server params id)) ((equal? method "tools/call")test/test-http.sgladded
(import (sigil test) (sigil mcp http) (sigil mcp server) (sigil mcp protocol) (sigil http request) (sigil http response) (sigil json) (sigil dict) (sigil string));; ============================================================;; Fixtures;; ============================================================(define current-version "2026-07-28")(define (make-server) (let ((s (mcp-server name: "test-http" version: "9.9.9"))) (mcp-server-register-tool! s "echo" "Echo the input" '((type . "object") (properties . ((text . ((type . "string"))))) (required . ("text"))) (lambda (args) (dict-ref args text: ""))) (mcp-server-register-resource! s "test://doc" "Doc" "A doc" "text/plain" (lambda (uri) "contents")) s))(define (handler) (mcp-http-handler (make-server)));; A well-formed modern request body: `_meta` carrying the protocol version;; and the client's capabilities, both of which are required on every request.(define (modern-body id method params) (json-encode `((jsonrpc . "2.0") (id . ,id) (method . ,method) (params . ,(append params `((_meta . ,(list (cons (string->symbol meta-protocol-version-key) current-version) (cons (string->symbol meta-client-capabilities-key) '())))))))))(define (post headers body) (http-request method: "POST" path: "/mcp" headers: headers body: body));; The header set a compliant modern client sends alongside `modern-body`.(define (modern-headers method . name) (if (null? name) (dict 'mcp-protocol-version: current-version 'mcp-method: method) (dict 'mcp-protocol-version: current-version 'mcp-method: method 'mcp-name: (car name))))(define (respond req) ((handler) req))(define (body-json resp) (json-decode (http-response-body resp)))(define (error-code resp) (let ((err (dict-ref (body-json resp) 'error: #f))) (and err (dict-ref err 'code: #f))))(define (result-of resp) (dict-ref (body-json resp) 'result: #f));; ============================================================;; Origin validation;; ============================================================(test-group "origin validation" (test "absent Origin is allowed" ;; Non-browser clients send no Origin at all; only a PRESENT and invalid ;; one is a rebinding signal. (assert-true (mcp-http-origin-allowed? #f))) (test "loopback origins are allowed" (assert-true (mcp-http-origin-allowed? "http://127.0.0.1:8765")) (assert-true (mcp-http-origin-allowed? "http://localhost:3000")) (assert-true (mcp-http-origin-allowed? "https://127.0.0.1")) (assert-true (mcp-http-origin-allowed? "http://[::1]:9000"))) (test "foreign origins are rejected" (assert-false (mcp-http-origin-allowed? "http://evil.com")) (assert-false (mcp-http-origin-allowed? "https://example.org:443")) (assert-false (mcp-http-origin-allowed? "null"))) (test "a host merely CONTAINING localhost is rejected" ;; The check must compare the whole host, not look for a substring: ;; localhost.evil.com resolves wherever the attacker points it. (assert-false (mcp-http-origin-allowed? "http://localhost.evil.com")) (assert-false (mcp-http-origin-allowed? "http://127.0.0.1.evil.com"))) (test "invalid Origin yields 403 before anything else is considered" (let ((resp (respond (http-request method: "POST" path: "/mcp" headers: (dict 'origin: "http://evil.com") body: "")))) (assert-equal 403 (http-response-status resp)))));; ============================================================;; HTTP method and path;; ============================================================(test-group "http method and path" (test "GET is 405 — this revision has no server-opened stream" (let ((resp (respond (http-request method: "GET" path: "/mcp" headers: (dict) body: "")))) (assert-equal 405 (http-response-status resp)))) (test "DELETE is 405 — there is no session to delete" (let ((resp (respond (http-request method: "DELETE" path: "/mcp" headers: (dict) body: "")))) (assert-equal 405 (http-response-status resp)))) (test "unknown path is 404" (let ((resp (respond (http-request method: "POST" path: "/nope" headers: (dict) body: "")))) (assert-equal 404 (http-response-status resp)))));; ============================================================;; Body parsing;; ============================================================(test-group "body parsing" (test "unparseable body is 400 with -32700" (let ((resp (respond (post (dict) "{not json")))) (assert-equal 400 (http-response-status resp)) (assert-equal -32700 (error-code resp)))));; ============================================================;; Modern envelope validation;; ============================================================(test-group "modern envelope" (test "missing _meta protocolVersion is -32602" (let* ((body (json-encode `((jsonrpc . "2.0") (id . 1) (method . "tools/list") (params . ((_meta . ,(list (cons (string->symbol meta-client-capabilities-key) '())))))))) (resp (respond (post (modern-headers "tools/list") body)))) (assert-equal 400 (http-response-status resp)) (assert-equal -32602 (error-code resp)))) (test "missing _meta clientCapabilities is -32602" (let* ((body (json-encode `((jsonrpc . "2.0") (id . 1) (method . "tools/list") (params . ((_meta . ,(list (cons (string->symbol meta-protocol-version-key) current-version)))))))) (resp (respond (post (modern-headers "tools/list") body)))) (assert-equal 400 (http-response-status resp)) (assert-equal -32602 (error-code resp)))) (test "unsupported version is -32022 and lists what we support" (let* ((body (json-encode `((jsonrpc . "2.0") (id . 1) (method . "tools/list") (params . ((_meta . ,(list (cons (string->symbol meta-protocol-version-key) "1900-01-01") (cons (string->symbol meta-client-capabilities-key) '())))))))) (resp (respond (post (dict 'mcp-protocol-version: "1900-01-01" 'mcp-method: "tools/list") body))) (err (dict-ref (body-json resp) 'error: #f)) (data (dict-ref err 'data: #f))) (assert-equal 400 (http-response-status resp)) (assert-equal -32022 (error-code resp)) (assert-equal "1900-01-01" (dict-ref data 'requested: #f)) ;; The client cannot fall forward on its own; the supported list is ;; how it learns what to retry with. (assert-true (> (array-length (dict-ref data 'supported: #[])) 0)))));; ============================================================;; Header agreement;; ============================================================(test-group "header agreement" (test "missing MCP-Protocol-Version header is -32020" (let ((resp (respond (post (dict 'mcp-method: "tools/list") (modern-body 1 "tools/list" '()))))) (assert-equal 400 (http-response-status resp)) (assert-equal -32020 (error-code resp)))) (test "header version disagreeing with _meta is -32020" ;; The headers exist so a proxy can route without parsing JSON. That is ;; only sound while the two agree, so disagreement is an error rather ;; than a preference for one source. (let ((resp (respond (post (dict 'mcp-protocol-version: "2025-11-25" 'mcp-method: "tools/list") (modern-body 1 "tools/list" '()))))) (assert-equal 400 (http-response-status resp)) (assert-equal -32020 (error-code resp)))) (test "missing Mcp-Method header is -32020" (let ((resp (respond (post (dict 'mcp-protocol-version: current-version) (modern-body 1 "tools/list" '()))))) (assert-equal 400 (http-response-status resp)) (assert-equal -32020 (error-code resp)))) (test "Mcp-Method disagreeing with the body method is -32020" (let ((resp (respond (post (modern-headers "prompts/list") (modern-body 1 "tools/list" '()))))) (assert-equal 400 (http-response-status resp)) (assert-equal -32020 (error-code resp)))) (test "tools/call without Mcp-Name is -32020" (let ((resp (respond (post (modern-headers "tools/call") (modern-body 1 "tools/call" '((name . "echo") (arguments . ((text . "hi"))))))))) (assert-equal 400 (http-response-status resp)) (assert-equal -32020 (error-code resp)))) (test "tools/call with a mismatched Mcp-Name is -32020" (let ((resp (respond (post (modern-headers "tools/call" "other") (modern-body 1 "tools/call" '((name . "echo") (arguments . ((text . "hi"))))))))) (assert-equal 400 (http-response-status resp)) (assert-equal -32020 (error-code resp)))) (test "resources/read takes its Mcp-Name from the uri" (let ((resp (respond (post (modern-headers "resources/read" "test://doc") (modern-body 1 "resources/read" '((uri . "test://doc"))))))) (assert-equal 200 (http-response-status resp)))));; ============================================================;; Successful dispatch and the result envelope;; ============================================================(test-group "result envelope" (test "tools/list succeeds and carries the full envelope" (let* ((resp (respond (post (modern-headers "tools/list") (modern-body 1 "tools/list" '())))) (result (result-of resp))) (assert-equal 200 (http-response-status resp)) (assert-equal "complete" (dict-ref result 'resultType: #f)) (let* ((meta (dict-ref result '_meta: #f)) (info (dict-ref meta (string->keyword meta-server-info-key) #f))) (assert-equal "test-http" (dict-ref info 'name: #f)) (assert-equal "9.9.9" (dict-ref info 'version: #f))) ;; Cacheable listings must say for how long and to whom. (assert-equal 60000 (dict-ref result 'ttlMs: #f)) (assert-equal "private" (dict-ref result 'cacheScope: #f)))) (test "tools/call succeeds and is NOT marked cacheable" ;; Tool output is a computation, not a listing. Marking it cacheable ;; would let an intermediary serve a stale answer for a fresh call. (let* ((resp (respond (post (modern-headers "tools/call" "echo") (modern-body 1 "tools/call" '((name . "echo") (arguments . ((text . "hi")))))))) (result (result-of resp))) (assert-equal 200 (http-response-status resp)) (assert-equal "complete" (dict-ref result 'resultType: #f)) (assert-false (dict-ref result 'ttlMs: #f)) (assert-false (dict-ref result 'cacheScope: #f)))) (test "cacheable-method? agrees with what the envelope stamps" (assert-true (mcp-http-cacheable-method? "tools/list")) (assert-true (mcp-http-cacheable-method? "resources/read")) (assert-true (mcp-http-cacheable-method? "server/discover")) (assert-false (mcp-http-cacheable-method? "tools/call")) (assert-false (mcp-http-cacheable-method? "initialize"))));; ============================================================;; Method resolution;; ============================================================(test-group "method resolution" (test "an unimplemented METHOD is 404" (let ((resp (respond (post (modern-headers "nope/nope") (modern-body 1 "nope/nope" '()))))) (assert-equal 404 (http-response-status resp)) (assert-equal -32601 (error-code resp)))) (test "an unknown TOOL is 200 with an error, not 404" ;; The distinction is load-bearing: "this server has no such method" is ;; an HTTP-level fact a client caches, while "this server has no such ;; tool" is an answer from a method that ran. (let ((resp (respond (post (modern-headers "tools/call" "ghost") (modern-body 1 "tools/call" '((name . "ghost") (arguments . ()))))))) (assert-equal 200 (http-response-status resp)) (assert-equal -32601 (error-code resp)))));; ============================================================;; server/discover;; ============================================================(test-group "server/discover" (test "discover reports supported versions and identity" (let* ((resp (respond (post (modern-headers "server/discover") (modern-body 1 "server/discover" '())))) (result (result-of resp)) (versions (dict-ref result 'supportedVersions: #[]))) (assert-equal 200 (http-response-status resp)) (assert-true (> (array-length versions) 1)) ;; The modern revision must be offered first. (assert-equal current-version (array-ref versions 0)) (assert-true (dict-ref result 'capabilities: #f)))));; ============================================================;; Legacy era;; ============================================================(test-group "legacy initialize" (test "initialize echoes a supported legacy version back" ;; A client pinned to 2025-06-18 must hear 2025-06-18. Answering with a ;; different version is what makes an older client hang up. (let* ((body (json-encode `((jsonrpc . "2.0") (id . 1) (method . "initialize") (params . ((protocolVersion . "2025-06-18")))))) (resp (respond (post (dict) body))) (result (result-of resp))) (assert-equal 200 (http-response-status resp)) (assert-equal "2025-06-18" (dict-ref result 'protocolVersion: #f)))) (test "initialize needs no modern headers or _meta at all" ;; The whole point of dual-era: a legacy client cannot be expected to ;; send an envelope defined after it shipped. (let* ((body (json-encode `((jsonrpc . "2.0") (id . 1) (method . "initialize") (params . ((protocolVersion . "2025-11-25")))))) (resp (respond (post (dict) body)))) (assert-equal 200 (http-response-status resp)))) (test "an unknown requested version gets our newest LEGACY version" ;; Never the modern one: 2026-07-28 has no initialize handshake, so a ;; client that just sent one could not speak it. (let* ((body (json-encode `((jsonrpc . "2.0") (id . 1) (method . "initialize") (params . ((protocolVersion . "1999-01-01")))))) (resp (respond (post (dict) body))) (answered (dict-ref (result-of resp) 'protocolVersion: #f))) (assert-equal "2025-11-25" answered) (assert-false (equal? current-version answered)))) (test "negotiation never answers with the modern version" (assert-false (equal? mcp-current-version (mcp-negotiate-legacy-version "2026-07-28"))) (assert-false (equal? mcp-current-version (mcp-negotiate-legacy-version #f))) (assert-equal "2025-06-18" (mcp-negotiate-legacy-version "2025-06-18"))));; ============================================================;; Notifications;; ============================================================(test-group "notifications" (test "a notification is accepted with 202 and an empty body" (let* ((body (json-encode `((jsonrpc . "2.0") (method . "notifications/initialized")))) (resp (respond (post (dict) body)))) (assert-equal 202 (http-response-status resp)) (assert-equal "" (http-response-body resp)))))(run-tests)