Commit70747727Recorded3 Sep 2026Repositorysigil-mcp

fix(http): read the HTTP verb as a symbol, not only a string

Message

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

Changed
 src/sigil/mcp/http.sgl   | 18 ++++++++++++++----
 src/sigil/mcp/server.sgl | 13 +++++++++----
 test/test-http.sgl       | 28 +++++++++++++++++++++++-----
 3 files changed, 46 insertions(+), 13 deletions(-)
Diff
src/sigil/mcp/http.sglmodified
@@ -111,6 +111,17 @@
111
(define (safe-ref d key default)
112
(if (dict? d) (dict-ref d key default) default))
113
+114
;; The wire parser stores the HTTP verb as a SYMBOL ('POST), while a
+115
;; hand-constructed request may carry a string. Normalise to an uppercase
+116
;; string so a comparison cannot silently fail against one of the two —
+117
;; matching only the string form made every real POST a 405.
+118
(define (request-verb req)
+119
(let ((m (http-request-method req)))
+120
(cond
+121
((symbol? m) (symbol->string m))
+122
((string? m) (string-upcase m))
+123
(else ""))))
+124
125
(define (json-error-body id code message data)
126
(error-response->json
127
(if data
@@ -129,8 +140,7 @@
140
141
(define (server-info-meta server)
142
(list (cons (string->symbol meta-server-info-key)
−132
`((name . ,(mcp-server-name server))
−133
(version . ,(mcp-server-version server))))))
+143
(mcp-server-info server))))
144
145
;; Stamp a successful result with the envelope every modern result owes:
146
;; `resultType`, `_meta.serverInfo`, and cache metadata for the methods
@@ -350,7 +360,7 @@
360
(format-exception e))))
361
(error-response HTTP-INTERNAL-SERVER-ERROR #f
362
error-internal "Internal error")))
−353
(let ((method (http-request-method req))
+363
(let ((method (request-verb req))
364
(origin (http-request-header req "Origin")))
365
(cond
366
;; Origin is checked before anything else, including the path:
@@ -382,7 +392,7 @@
392
(host "127.0.0.1")
393
(path "/mcp")))
394
(log-info "MCP HTTP server ready"
−385
name: (mcp-server-name server)
+395
server: (mcp-server-info server)
396
host: host
397
port: port
398
path: path)
src/sigil/mcp/server.sglmodified
@@ -15,8 +15,7 @@
15
(sigil log))
16
(export mcp-server
17
mcp-server?
−18
mcp-server-name
−19
mcp-server-version
+18
mcp-server-info
19
mcp-server-initialized
20
mcp-server-register-tool!
21
mcp-server-register-resource!
@@ -197,7 +196,13 @@
196
(define (params-ref params key default)
197
(if (dict? params) (dict-ref params key default) default))
198
−200
(define (server-info server)
+199
;;; The server's identity, as `initialize` and `_meta.serverInfo` both
+200
;;; want it.
+201
;;;
+202
;;; A function rather than two exported field accessors: transports need
+203
;;; the pair together, and this keeps the struct's shape private so a
+204
;;; field can be renamed without breaking an importer.
+205
(define (mcp-server-info server)
206
`((name . ,(mcp-server-name server))
207
(version . ,(mcp-server-version server))))
208
@@ -215,7 +220,7 @@
220
(append
221
`((protocolVersion . ,negotiated)
222
(capabilities . ,caps)
−218
(serverInfo . ,(server-info server)))
+223
(serverInfo . ,(mcp-server-info server)))
224
(if (mcp-server-instructions server)
225
`((instructions . ,(mcp-server-instructions server)))
226
'())))))
test/test-http.sglmodified
@@ -43,7 +43,7 @@
43
'())))))))))
44
45
(define (post headers body)
−46
(http-request method: "POST" path: "/mcp" headers: headers body: body))
+46
(http-request method: 'POST path: "/mcp" headers: headers body: body))
47
48
;; The header set a compliant modern client sends alongside `modern-body`.
49
(define (modern-headers method . name)
@@ -92,7 +92,7 @@
92
(assert-false (mcp-http-origin-allowed? "http://127.0.0.1.evil.com")))
93
94
(test "invalid Origin yields 403 before anything else is considered"
−95
(let ((resp (respond (http-request method: "POST" path: "/mcp"
+95
(let ((resp (respond (http-request method: 'POST path: "/mcp"
96
headers: (dict 'origin: "http://evil.com")
97
body: ""))))
98
(assert-equal 403 (http-response-status resp)))))
@@ -103,17 +103,35 @@
103
104
(test-group "http method and path"
105
(test "GET is 405 — this revision has no server-opened stream"
−106
(let ((resp (respond (http-request method: "GET" path: "/mcp"
+106
(let ((resp (respond (http-request method: 'GET path: "/mcp"
107
headers: (dict) body: ""))))
108
(assert-equal 405 (http-response-status resp))))
109
110
(test "DELETE is 405 — there is no session to delete"
−111
(let ((resp (respond (http-request method: "DELETE" path: "/mcp"
+111
(let ((resp (respond (http-request method: 'DELETE path: "/mcp"
112
headers: (dict) body: ""))))
113
(assert-equal 405 (http-response-status resp))))
114
+115
(test "the verb is read as a symbol AND as a string"
+116
;; The wire parser produces 'POST; a hand-built request may carry "POST".
+117
;; Accepting only one of the two is invisible to a test suite that builds
+118
;; its own requests the same wrong way — every real POST answered 405
+119
;; while the suite stayed green.
+120
(let ((sym (respond (http-request method: 'POST path: "/mcp"
+121
headers: (dict) body: "{bad")))
+122
(str (respond (http-request method: "POST" path: "/mcp"
+123
headers: (dict) body: "{bad"))))
+124
(assert-equal 400 (http-response-status sym))
+125
(assert-equal 400 (http-response-status str)))
+126
(let ((sym (respond (http-request method: 'GET path: "/mcp"
+127
headers: (dict) body: "")))
+128
(str (respond (http-request method: "GET" path: "/mcp"
+129
headers: (dict) body: ""))))
+130
(assert-equal 405 (http-response-status sym))
+131
(assert-equal 405 (http-response-status str))))
+132
133
(test "unknown path is 404"
−116
(let ((resp (respond (http-request method: "POST" path: "/nope"
+134
(let ((resp (respond (http-request method: 'POST path: "/nope"
135
headers: (dict) body: ""))))
136
(assert-equal 404 (http-response-status resp)))))
137