Commitb12296cbRecorded24 Apr 2026Repositorysigil-mcp

sigil-mcp: validate required args before dispatching to tool handler

Message

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.

Changed
 src/sigil/mcp/server.sgl | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
 test/test-server.sgl     | 72 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
 2 files changed, 130 insertions(+), 17 deletions(-)
Diff
src/sigil/mcp/server.sglmodified
@@ -213,6 +213,36 @@
213
(inputSchema . ,(tool-def-input-schema t))))
214
(mcp-server-tools server))))))
215
+216
;; Extract the "required" array from a JSON Schema object.
+217
;; Handles both dict and alist representations; returns '() when
+218
;; missing or malformed.
+219
(define (schema-required-fields schema)
+220
(let ((req (cond
+221
((not schema) #f)
+222
((dict? schema) (dict-ref schema required: #f))
+223
((list? schema) (assoc-ref 'required schema))
+224
(else #f))))
+225
(if (list? req) req '())))
+226
+227
;; Return required fields (as strings) missing from args. The schema
+228
;; uses JSON-Schema string names; args is a dict keyed by `name:`
+229
;; keywords, so we convert each required name to a keyword via
+230
;; string->keyword before checking presence. Do NOT use string->symbol
+231
;; here — symbols and keywords with the same printed form are distinct
+232
;; in Sigil (e.g., 'org: and (string->symbol "org:") are not eq?).
+233
(define (missing-required-args schema args)
+234
(filter (lambda (field-name)
+235
(not (dict-contains? args (string->keyword field-name))))
+236
(schema-required-fields schema)))
+237
+238
;; Join string list with ", " separators. Avoids depending on
+239
;; string-join, which isn't in (sigil string)'s exports.
+240
(define (comma-join strs)
+241
(cond
+242
((null? strs) "")
+243
((null? (cdr strs)) (car strs))
+244
(else (string-append (car strs) ", " (comma-join (cdr strs))))))
+245
246
(define (handle-tools-call server params id)
247
(let* ((name (dict-ref params name: #f))
248
(args (or (dict-ref params arguments: #f) (dict)))
@@ -226,22 +256,35 @@
256
(else
257
(guard (le (else #f))
258
(log-debug "tool/call" tool: name id: id))
229
(guard (e (else
230
(let ((err-msg (guard (fe (else "unknown error"))
231
(format-exception e))))
232
(guard (le (else #f))
233
(log-error "tool/call failed" tool: name
234
error: err-msg))
235
(make-error-response id error-internal
236
(format "Tool error: ~a" err-msg)))))
237
(let ((result ((tool-def-handler tool) args)))
238
(guard (le (else #f))
239
(log-debug "tool/call completed" tool: name))
240
(make-response id
241
`((content . (((type . "text")
242
(text . ,(if (string? result)
243
result
244
(json-encode result)))))))))))))))
+259
(let ((missing (missing-required-args
+260
(tool-def-input-schema tool) args)))
+261
(cond
+262
((not (null? missing))
+263
(guard (le (else #f))
+264
(log-warn "tool/call missing args"
+265
tool: name missing: missing))
+266
(make-error-response id error-invalid-params
+267
(format "Tool '~a' missing required argument~a: ~a"
+268
name
+269
(if (null? (cdr missing)) "" "s")
+270
(comma-join missing))))
+271
(else
+272
(guard (e (else
+273
(let ((err-msg (guard (fe (else "unknown error"))
+274
(format-exception e))))
+275
(guard (le (else #f))
+276
(log-error "tool/call failed" tool: name
+277
error: err-msg))
+278
(make-error-response id error-internal
+279
(format "Tool error: ~a" err-msg)))))
+280
(let ((result ((tool-def-handler tool) args)))
+281
(guard (le (else #f))
+282
(log-debug "tool/call completed" tool: name))
+283
(make-response id
+284
`((content . (((type . "text")
+285
(text . ,(if (string? result)
+286
result
+287
(json-encode result))))))))))))))))))
288
)
289
290
(define (handle-resources-list server params id)
test/test-server.sglmodified
@@ -144,7 +144,77 @@
144
params: (dict name: "fail")))
145
(resp (mcp-server-handle-message s msg)))
146
(assert-true (jsonrpc-error-response? resp))
147
(assert-equal error-internal (jsonrpc-error-response-code resp))))))
+147
(assert-equal error-internal (jsonrpc-error-response-code resp)))))
+148
+149
;; Required-args validation: handler must NOT be called when a required
+150
;; field from inputSchema.required is absent. Without this validation,
+151
;; handlers using `(dict-ref args X:)` (no default) raise "dict-ref: key
+152
;; not found", which escapes as an opaque multi-frame stack trace. The
+153
;; validation surfaces a structured error-invalid-params instead.
+154
(test "missing required arg returns invalid-params (not handler call)"
+155
(let ((s (mcp-server))
+156
(handler-called #f))
+157
(mcp-server-register-tool! s "needs-org" "Tool requiring org"
+158
'((type . "object")
+159
(properties . ((org . ((type . "string")))))
+160
(required . ("org")))
+161
(lambda (args)
+162
(set! handler-called #t)
+163
(dict-ref args org:)))
+164
(let* ((msg (jsonrpc-request id: 1 method: "tools/call"
+165
params: (dict name: "needs-org" arguments: (dict))))
+166
(resp (mcp-server-handle-message s msg)))
+167
(assert-true (jsonrpc-error-response? resp))
+168
(assert-equal error-invalid-params (jsonrpc-error-response-code resp))
+169
(assert-false handler-called))))
+170
+171
(test "multiple missing required args listed in error"
+172
(let ((s (mcp-server)))
+173
(mcp-server-register-tool! s "needs-two" "Tool requiring owner + repo"
+174
'((type . "object")
+175
(properties . ((owner . ((type . "string")))
+176
(repo . ((type . "string")))))
+177
(required . ("owner" "repo")))
+178
(lambda (args) "should not reach"))
+179
(let* ((msg (jsonrpc-request id: 1 method: "tools/call"
+180
params: (dict name: "needs-two" arguments: (dict))))
+181
(resp (mcp-server-handle-message s msg))
+182
(err-msg (jsonrpc-error-response-message resp)))
+183
(assert-true (jsonrpc-error-response? resp))
+184
(assert-equal error-invalid-params (jsonrpc-error-response-code resp))
+185
(assert-true (string-contains? err-msg "owner"))
+186
(assert-true (string-contains? err-msg "repo")))))
+187
+188
(test "required args all present dispatches normally"
+189
(let ((s (mcp-server)))
+190
(mcp-server-register-tool! s "needs-org" "Tool requiring org"
+191
'((type . "object")
+192
(properties . ((org . ((type . "string")))))
+193
(required . ("org")))
+194
(lambda (args)
+195
(string-append "org=" (dict-ref args org:))))
+196
(let* ((msg (jsonrpc-request id: 1 method: "tools/call"
+197
params: (dict name: "needs-org"
+198
arguments: (dict org: "sigil"))))
+199
(resp (mcp-server-handle-message s msg))
+200
(result (jsonrpc-response-result resp))
+201
(content (assoc-ref 'content result)))
+202
(assert-true (jsonrpc-response? resp))
+203
(assert-equal "org=sigil" (assoc-ref 'text (car content))))))
+204
+205
(test "schema with no required field skips validation"
+206
(let ((s (mcp-server)))
+207
(mcp-server-register-tool! s "anything-goes" "Tool with no required args"
+208
'((type . "object")
+209
(properties . ((opt . ((type . "string"))))))
+210
(lambda (args) "ok"))
+211
(let* ((msg (jsonrpc-request id: 1 method: "tools/call"
+212
params: (dict name: "anything-goes" arguments: (dict))))
+213
(resp (mcp-server-handle-message s msg))
+214
(result (jsonrpc-response-result resp))
+215
(content (assoc-ref 'content result)))
+216
(assert-true (jsonrpc-response? resp))
+217
(assert-equal "ok" (assoc-ref 'text (car content)))))))
218
219
;; ============================================================
220
;; resources/read