sigil-mcp: validate required args before dispatching to tool handler
Previously a handler using (dict-ref args X:) without a default would crash with "dict-ref: key not found" when the caller omitted a required argument. The exception did get caught by the outer guard and turned into an error response, but the response body was the raw format- exception output — a 38-frame stack trace that's useless to the caller and hides the actual misuse (missing required arg).
Fix: validate args against tool.inputSchema.required BEFORE calling the handler. Surface a clean error-invalid-params response listing the missing field names. Handler is only invoked when all required args are present, so its dict-ref calls can safely omit defaults.
Uses string->keyword (not string->symbol) — in Sigil, 'org: and (string->symbol "org:") are not eq?, so we need the proper keyword constructor to match dict args keyed by 'org:.
4 regression tests added covering: missing required arg returns clean invalid-params (and handler is NOT called), multiple missing args both surface in the message, all-present dispatches normally, and schemas without required arrays bypass validation.
Benefits every sigil-mcp server downstream — fjo hit this first, others (sigil-mcp self-serve tools, bureau, tally, courier, folio, minder) will also stop producing mystery trace dumps when clients drop a field.
Root cause notes in folio ops-platform t-c5b2.
src/sigil/mcp/server.sgl | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
test/test-server.sgl | 72 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
2 files changed, 130 insertions(+), 17 deletions(-)src/sigil/mcp/server.sglmodified
(inputSchema . ,(tool-def-input-schema t)))) (mcp-server-tools server)))))) ;; Extract the "required" array from a JSON Schema object. ;; Handles both dict and alist representations; returns '() when ;; missing or malformed. (define (schema-required-fields schema) (let ((req (cond ((not schema) #f) ((dict? schema) (dict-ref schema required: #f)) ((list? schema) (assoc-ref 'required schema)) (else #f)))) (if (list? req) req '()))) ;; Return required fields (as strings) missing from args. The schema ;; uses JSON-Schema string names; args is a dict keyed by `name:` ;; keywords, so we convert each required name to a keyword via ;; string->keyword before checking presence. Do NOT use string->symbol ;; here — symbols and keywords with the same printed form are distinct ;; in Sigil (e.g., 'org: and (string->symbol "org:") are not eq?). (define (missing-required-args schema args) (filter (lambda (field-name) (not (dict-contains? args (string->keyword field-name)))) (schema-required-fields schema))) ;; Join string list with ", " separators. Avoids depending on ;; string-join, which isn't in (sigil string)'s exports. (define (comma-join strs) (cond ((null? strs) "") ((null? (cdr strs)) (car strs)) (else (string-append (car strs) ", " (comma-join (cdr strs)))))) (define (handle-tools-call server params id) (let* ((name (dict-ref params name: #f)) (args (or (dict-ref params arguments: #f) (dict))) (else (guard (le (else #f)) (log-debug "tool/call" tool: name id: id)) (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))))))))))))))) (let ((missing (missing-required-args (tool-def-input-schema tool) args))) (cond ((not (null? missing)) (guard (le (else #f)) (log-warn "tool/call missing args" tool: name missing: missing)) (make-error-response id error-invalid-params (format "Tool '~a' missing required argument~a: ~a" name (if (null? (cdr missing)) "" "s") (comma-join missing)))) (else (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)test/test-server.sglmodified
params: (dict name: "fail"))) (resp (mcp-server-handle-message s msg))) (assert-true (jsonrpc-error-response? resp)) (assert-equal error-internal (jsonrpc-error-response-code resp)))))) (assert-equal error-internal (jsonrpc-error-response-code resp))))) ;; Required-args validation: handler must NOT be called when a required ;; field from inputSchema.required is absent. Without this validation, ;; handlers using `(dict-ref args X:)` (no default) raise "dict-ref: key ;; not found", which escapes as an opaque multi-frame stack trace. The ;; validation surfaces a structured error-invalid-params instead. (test "missing required arg returns invalid-params (not handler call)" (let ((s (mcp-server)) (handler-called #f)) (mcp-server-register-tool! s "needs-org" "Tool requiring org" '((type . "object") (properties . ((org . ((type . "string"))))) (required . ("org"))) (lambda (args) (set! handler-called #t) (dict-ref args org:))) (let* ((msg (jsonrpc-request id: 1 method: "tools/call" params: (dict name: "needs-org" arguments: (dict)))) (resp (mcp-server-handle-message s msg))) (assert-true (jsonrpc-error-response? resp)) (assert-equal error-invalid-params (jsonrpc-error-response-code resp)) (assert-false handler-called)))) (test "multiple missing required args listed in error" (let ((s (mcp-server))) (mcp-server-register-tool! s "needs-two" "Tool requiring owner + repo" '((type . "object") (properties . ((owner . ((type . "string"))) (repo . ((type . "string"))))) (required . ("owner" "repo"))) (lambda (args) "should not reach")) (let* ((msg (jsonrpc-request id: 1 method: "tools/call" params: (dict name: "needs-two" arguments: (dict)))) (resp (mcp-server-handle-message s msg)) (err-msg (jsonrpc-error-response-message resp))) (assert-true (jsonrpc-error-response? resp)) (assert-equal error-invalid-params (jsonrpc-error-response-code resp)) (assert-true (string-contains? err-msg "owner")) (assert-true (string-contains? err-msg "repo"))))) (test "required args all present dispatches normally" (let ((s (mcp-server))) (mcp-server-register-tool! s "needs-org" "Tool requiring org" '((type . "object") (properties . ((org . ((type . "string"))))) (required . ("org"))) (lambda (args) (string-append "org=" (dict-ref args org:)))) (let* ((msg (jsonrpc-request id: 1 method: "tools/call" params: (dict name: "needs-org" arguments: (dict org: "sigil")))) (resp (mcp-server-handle-message s msg)) (result (jsonrpc-response-result resp)) (content (assoc-ref 'content result))) (assert-true (jsonrpc-response? resp)) (assert-equal "org=sigil" (assoc-ref 'text (car content)))))) (test "schema with no required field skips validation" (let ((s (mcp-server))) (mcp-server-register-tool! s "anything-goes" "Tool with no required args" '((type . "object") (properties . ((opt . ((type . "string")))))) (lambda (args) "ok")) (let* ((msg (jsonrpc-request id: 1 method: "tools/call" params: (dict name: "anything-goes" arguments: (dict)))) (resp (mcp-server-handle-message s msg)) (result (jsonrpc-response-result resp)) (content (assoc-ref 'content result))) (assert-true (jsonrpc-response? resp)) (assert-equal "ok" (assoc-ref 'text (car content)))))));; ============================================================;; resources/read