Revert tool-handler dispatch to plain guard; VM bug fixed in sigil 0.16.0
The call/cc-escape workaround in handle-tools-call (added in 75dbf3c / v0.16.2) dodged a VM bug where an exception propagating through map's CPS frames corrupted call-with-prompt's return context. That escape, run from inside a with-exception-handler, jumped past the dispatch loop's continuation on the buggy VM: the error response was delivered but the loop never resumed, so the next request got "Connection closed" and the server was dead until restart.
That VM bug is fixed in sigil v0.16.0 (the guard-raise-escape / abort-propagation fix, 3dd14f94). sigil-mcp requires sigil ^0.16, so every supported build runs a fixed VM and the workaround is dead weight that was itself the cause of the wedge. Revert handle-tools-call to a plain guard (matching every other handler) and restore unified guarded dispatch in mcp-server-handle-message.
Add a regression group "tool-handler error isolation" that drives the full mcp-server-run loop with a raising tools/call followed by a second request, asserting both get well-formed responses and the loop survives -- in both a plain and a with-async/go context.
Bump to 0.16.3.
package.sgl | 2 +-
sigil.lock | 20 ++++++++++----------
src/sigil/mcp/server.sgl | 136 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------------------------------------------------------
test/test-server.sgl | 88 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
4 files changed, 159 insertions(+), 87 deletions(-)package.sglmodified
(package name: "sigil-mcp" version: "0.16.2" version: "0.16.3" sigil: "^0.16" description: "Library for building MCP servers in Sigil" url: "https://codeberg.org/sigil/sigil-mcp"sigil.lockmodified
;; Auto-generated by sigil deps install. Do not edit.(lock (package name: "sigil-stdlib" url: "codeberg:sigil/sigil-lang" ref: "^0.14" sha: "dba24e3fe531bc150bcf93444e764f87fb321850" url: "codeberg:sigil/sigil" ref: "^0.16" sha: "136c07b8cf1547ddd2d96a450df45a17e5ae6665" package-selector: "sigil-stdlib" version: "0.14.1") version: "0.16.2") (package name: "sigil-json" url: "codeberg:sigil/sigil-json" ref: "^0.14.0" sha: "a6b76e84bdcccfbed79b048ed8bc9850d08efae7" version: "0.14.0") ref: "^0.16" sha: "55c9e74712b5f79b45d41c65cfe0aef2c8f7e155" version: "0.16.0") (package name: "sigil-log" url: "codeberg:sigil/sigil-log" ref: "^0.14.0" sha: "0e8106b6e064e9ddb0ff3997e3559ad04f8dfdf1" version: "0.14.0") ref: "^0.16" sha: "56e4fc5a52dc370c037ef14502bb8d6d2876dc53" version: "0.16.0"))src/sigil/mcp/server.sglmodified
(if (null? (cdr missing)) "" "s") (comma-join missing)))) (else ;; Sigil VM bug: when an exception propagates through map's CPS ;; continuation frames, call-with-prompt's return context becomes ;; corrupted (return_ip/stack_mark invalid). Using guard (which is ;; call-with-prompt) causes car:expected-pair on handler return. ;; Using call/cc escape from within the exception handler bypasses ;; the corrupted return path, but Sigil's VM does not properly ;; resume the continuation after escape-from-within-weh — the ;; escape jumps past the caller, so the returned value is never ;; propagated. Workaround: write the error response directly from ;; inside the exception handler (before escaping), so the response ;; is written even if the continuation is not resumed normally. (call-with-current-continuation (lambda (escape) (with-exception-handler (lambda (e) (let ((err-msg (guard (fe (else "unknown error")) (format-exception e)))) (guard (le (else #f)) (log-error "tool/call failed" tool: name error: err-msg)) ;; Write error response directly — escape will not resume ;; the caller, so we cannot return this response the normal ;; way. (guard (we (else #f)) (mcp-write-response! (make-error-response id error-internal (format "Tool error: ~a" err-msg)))) (escape #f))) (lambda () (let ((result ((tool-def-handler tool) args))) (guard (le (else #f)) (log-debug "tool/call completed" tool: name)) (make-response id `((content . (((type . "text") (text . ,(if (string? result) result (json-encode result))))))))))))))))))) ;; A raising handler is caught and converted to a JSON-RPC ;; error response. This uses a plain `guard` like every other ;; handler. (Earlier releases needed a call/cc-escape workaround ;; here to dodge a VM bug where an exception propagating through ;; map's CPS frames corrupted call-with-prompt's return context; ;; that escape wedged the dispatch loop. The VM bug was fixed in ;; sigil v0.16.0, and this package requires ^0.16, so the plain ;; guard is correct. See test "tool-handler error isolation".) (guard (e (else (let ((err-msg (guard (fe (else "unknown error")) (format-exception e)))) (guard (le (else #f)) (log-error "tool/call failed" tool: name error: err-msg)) (make-error-response id error-internal (format "Tool error: ~a" err-msg))))) (let ((result ((tool-def-handler tool) args))) (guard (le (else #f)) (log-debug "tool/call completed" tool: name)) (make-response id `((content . (((type . "text") (text . ,(if (string? result) result (json-encode result))))))))))))))))))) (define (handle-resources-list server params id) (let ((method (jsonrpc-request-method msg)) (params (or (jsonrpc-request-params msg) '())) (id (jsonrpc-request-id msg))) ;; tools/call is dispatched without the outer guard because ;; handle-tools-call uses call/cc escape internally to avoid ;; a Sigil VM bug (corrupted OP_PROMPT return context when ;; exceptions propagate through map's CPS frames). The outer ;; guard below wraps all other method handlers. (if (equal? method "tools/call") (handle-tools-call server params id) (guard (e (else (let ((err-msg (guard (fe (else "unknown error")) (format-exception e)))) (guard (le (else #f)) (log-error "request dispatch failed" id: id error: err-msg)) (make-error-response id error-internal (format "Internal error: ~a" err-msg))))) (log-debug "request" method: method id: id) (cond ((equal? method "initialize") (handle-initialize server params id)) ((equal? method "tools/list") (handle-tools-list server params id)) ((equal? method "resources/list") (handle-resources-list server params id)) ((equal? method "resources/read") (handle-resources-read server params id)) ((equal? method "resources/templates/list") (make-response id `((resourceTemplates . ,#())))) ((equal? method "prompts/list") (handle-prompts-list server params id)) ((equal? method "prompts/get") (handle-prompts-get server params id)) ((equal? method "ping") (log-debug "pong" id: id) (make-response id '())) (else (log-warn "Unknown method" method: method) (make-error-response id error-method-not-found (format "Unknown method: ~a" method)))))))) ;; Guard the entire request dispatch so any error during tool ;; lookup, schema validation, or handler execution still returns a ;; JSON-RPC error response. Without this, an uncaught error escapes ;; the read loop and a live client hangs forever waiting for a reply. (guard (e (else (let ((err-msg (guard (fe (else "unknown error")) (format-exception e)))) (guard (le (else #f)) (log-error "request dispatch failed" id: id error: err-msg)) (make-error-response id error-internal (format "Internal error: ~a" err-msg))))) (log-debug "request" method: method id: id) (cond ((equal? method "initialize") (handle-initialize server params id)) ((equal? method "tools/list") (handle-tools-list server params id)) ((equal? method "tools/call") (handle-tools-call server params id)) ((equal? method "resources/list") (handle-resources-list server params id)) ((equal? method "resources/read") (handle-resources-read server params id)) ((equal? method "resources/templates/list") (make-response id `((resourceTemplates . ,#())))) ((equal? method "prompts/list") (handle-prompts-list server params id)) ((equal? method "prompts/get") (handle-prompts-get server params id)) ((equal? method "ping") (log-debug "pong" id: id) (make-response id '())) (else (log-warn "Unknown method" method: method) (make-error-response id error-method-not-found (format "Unknown method: ~a" method))))))) ((jsonrpc-notification? msg) ;; Handle notifications (no response expected) (let ((method (jsonrpc-notification-method msg))) line)) (mcp-write-response! (make-error-response #f error-parse "Parse error")))))) (loop)))))))))) No newline at end of file (loop)))))))) No newline at end of filetest/test-server.sglmodified
(sigil mcp protocol) (sigil json) (sigil string) (sigil io)) (sigil io) (sigil log) (sigil async));; ============================================================;; Server creation (assert-true (jsonrpc-response? resp)) (assert-equal "ok" (assoc-ref 'text (car content)))))));; ============================================================;; tool-handler error isolation — dispatch loop must survive a raise;; ============================================================;;;; Regression for the "MCP dispatch continuation wedge": a tool handler;; that raises must return a clean JSON-RPC error AND leave the server;; loop alive to serve the NEXT request. The earlier workaround used a;; call/cc escape from inside a with-exception-handler to dodge a Sigil;; VM bug (exception propagation through map's CPS frames corrupting;; call-with-prompt's return context). On the buggy VM that escape jumped;; past the dispatch loop's continuation, so the loop never resumed and;; the next request got "Connection closed". The VM bug was fixed in;; sigil v0.16.0 (the guard-raise-escape / abort-propagation fix), so;; handle-tools-call now uses a plain `guard` like every other handler.;;;; These tests drive the full mcp-server-run loop: a raising tools/call;; followed by a second request, asserting BOTH get well-formed responses.;; The async variant runs inside with-async/go with logging enabled,;; reproducing the production context (scheduler prompt + async logging;; path live on the stack) where the wedge originally manifested.(define (drive-loop server input-string) ;; Run mcp-server-run over a string input, returning the non-empty ;; response lines written to stdout. (let ((input (open-input-string input-string)) (output (open-output-string))) (parameterize ((current-input-port input) (current-output-port output)) (mcp-server-run server load-env?: #f)) (filter (lambda (l) (not (string-empty? l))) (string-split (get-output-string output) "\n"))))(define (raise+follow-up-input) (string-append (json-encode (dict jsonrpc: "2.0" id: 1 method: "tools/call" params: (dict name: "boom" arguments: (dict)))) "\n" (json-encode (dict jsonrpc: "2.0" id: 2 method: "ping" params: (dict))) "\n"))(test-group "tool-handler error isolation" (test "raising tool/call returns error AND loop serves next request" (let ((s (mcp-server name: "test" version: "1.0"))) (mcp-server-register-tool! s "boom" "Always fails" '() (lambda (args) (error "intentional boom"))) (let ((lines (drive-loop s (raise+follow-up-input)))) ;; Both requests answered: loop survived the raise. (assert-equal 2 (length lines)) (let ((resp1 (json-decode (car lines))) (resp2 (json-decode (cadr lines)))) ;; First: well-formed JSON-RPC error for the raising call. (assert-equal 1 (dict-ref resp1 id:)) (assert-true (dict-ref resp1 error: #f)) (assert-equal error-internal (dict-ref (dict-ref resp1 error:) code:)) ;; Second: the follow-up ping succeeded (no "Connection closed"). (assert-equal 2 (dict-ref resp2 id:)) (assert-false (dict-ref resp2 error: #f)))))) (test "loop survives a raising tool/call inside with-async/go" ;; Faithful to production: mcp-server-run runs in a goroutine and ;; logging routes through the async await-port-writable path. Logs ;; go to a sink port so they don't pollute test output. (let ((s (mcp-server name: "test" version: "1.0")) (log-sink (open-output-string)) (input (open-input-string (raise+follow-up-input))) (output (open-output-string))) (mcp-server-register-tool! s "boom" "Always fails" '() (lambda (args) (error "intentional boom"))) (log-configure! level: 'debug target: log-sink) (parameterize ((current-input-port input) (current-output-port output)) (with-async (go (mcp-server-run s load-env?: #f)))) (log-configure! level: 'warn target: 'console) (let ((lines (filter (lambda (l) (not (string-empty? l))) (string-split (get-output-string output) "\n")))) (assert-equal 2 (length lines)) (let ((resp1 (json-decode (car lines))) (resp2 (json-decode (cadr lines)))) (assert-true (dict-ref resp1 error: #f)) (assert-equal 2 (dict-ref resp2 id:)) (assert-false (dict-ref resp2 error: #f)))))));; ============================================================;; resources/read;; ============================================================