fix(http): read the HTTP verb as a symbol, not only a string
The wire parser stores the method as a symbol ('POST); only a hand-constructed request carries a string. Comparing against the string form alone made every real POST answer 405 Method Not Allowed.
The unit suite could not see this: it built its own requests with method: "POST", so the fixtures agreed with the bug. A live curl battery against a real socket found it immediately. The verb is now normalised from either form, the fixtures use the symbol the parser actually produces, and a test asserts both spellings work.
Also replaces the two exported struct field accessors with a single mcp-server-info, so the struct's shape stays private to the module.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HgY55w6F7XJf643tYHhwLn
src/sigil/mcp/http.sgl | 18 ++++++++++++++----
src/sigil/mcp/server.sgl | 13 +++++++++----
test/test-http.sgl | 28 +++++++++++++++++++++++-----
3 files changed, 46 insertions(+), 13 deletions(-)src/sigil/mcp/http.sglmodified
(define (safe-ref d key default) (if (dict? d) (dict-ref d key default) default)) ;; The wire parser stores the HTTP verb as a SYMBOL ('POST), while a ;; hand-constructed request may carry a string. Normalise to an uppercase ;; string so a comparison cannot silently fail against one of the two — ;; matching only the string form made every real POST a 405. (define (request-verb req) (let ((m (http-request-method req))) (cond ((symbol? m) (symbol->string m)) ((string? m) (string-upcase m)) (else "")))) (define (json-error-body id code message data) (error-response->json (if data (define (server-info-meta server) (list (cons (string->symbol meta-server-info-key) `((name . ,(mcp-server-name server)) (version . ,(mcp-server-version server)))))) (mcp-server-info server)))) ;; Stamp a successful result with the envelope every modern result owes: ;; `resultType`, `_meta.serverInfo`, and cache metadata for the methods (format-exception e)))) (error-response HTTP-INTERNAL-SERVER-ERROR #f error-internal "Internal error"))) (let ((method (http-request-method req)) (let ((method (request-verb req)) (origin (http-request-header req "Origin"))) (cond ;; Origin is checked before anything else, including the path: (host "127.0.0.1") (path "/mcp"))) (log-info "MCP HTTP server ready" name: (mcp-server-name server) server: (mcp-server-info server) host: host port: port path: path)src/sigil/mcp/server.sglmodified
(sigil log)) (export mcp-server mcp-server? mcp-server-name mcp-server-version mcp-server-info mcp-server-initialized mcp-server-register-tool! mcp-server-register-resource! (define (params-ref params key default) (if (dict? params) (dict-ref params key default) default)) (define (server-info server) ;;; The server's identity, as `initialize` and `_meta.serverInfo` both ;;; want it. ;;; ;;; A function rather than two exported field accessors: transports need ;;; the pair together, and this keeps the struct's shape private so a ;;; field can be renamed without breaking an importer. (define (mcp-server-info server) `((name . ,(mcp-server-name server)) (version . ,(mcp-server-version server)))) (append `((protocolVersion . ,negotiated) (capabilities . ,caps) (serverInfo . ,(server-info server))) (serverInfo . ,(mcp-server-info server))) (if (mcp-server-instructions server) `((instructions . ,(mcp-server-instructions server))) '())))))test/test-http.sglmodified
'())))))))))(define (post headers body) (http-request method: "POST" path: "/mcp" headers: headers body: 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) (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" (let ((resp (respond (http-request method: 'POST path: "/mcp" headers: (dict 'origin: "http://evil.com") body: "")))) (assert-equal 403 (http-response-status resp)))))(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" (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" (let ((resp (respond (http-request method: 'DELETE path: "/mcp" headers: (dict) body: "")))) (assert-equal 405 (http-response-status resp)))) (test "the verb is read as a symbol AND as a string" ;; The wire parser produces 'POST; a hand-built request may carry "POST". ;; Accepting only one of the two is invisible to a test suite that builds ;; its own requests the same wrong way — every real POST answered 405 ;; while the suite stayed green. (let ((sym (respond (http-request method: 'POST path: "/mcp" headers: (dict) body: "{bad"))) (str (respond (http-request method: "POST" path: "/mcp" headers: (dict) body: "{bad")))) (assert-equal 400 (http-response-status sym)) (assert-equal 400 (http-response-status str))) (let ((sym (respond (http-request method: 'GET path: "/mcp" headers: (dict) body: ""))) (str (respond (http-request method: "GET" path: "/mcp" headers: (dict) body: "")))) (assert-equal 405 (http-response-status sym)) (assert-equal 405 (http-response-status str)))) (test "unknown path is 404" (let ((resp (respond (http-request method: "POST" path: "/nope" (let ((resp (respond (http-request method: 'POST path: "/nope" headers: (dict) body: "")))) (assert-equal 404 (http-response-status resp)))))