Commit916c3842Recorded9 Jul 2026Repositorysigil-mcp

Expose nREPL introspection MCP tools

Changed
 package.sgl                    |   3 +-
 sigil.lock                     |  35 +++++++++++++++++++
 src/sigil/mcp/nrepl.sgl        | 311 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 test/fixtures/nrepl-server.sgl |  16 +++++++++
 test/test-nrepl-tools.sgl      | 101 ++++++++++++++++++++++++++++++++++++++++++++++++++++
 5 files changed, 465 insertions(+), 1 deletion(-)
Diff
package.sglmodified
@@ -16,4 +16,5 @@
16
17
dependencies: (list
18
(from-git url: "codeberg:sigil/sigil-json" version: "^0.16")
19
(from-git url: "codeberg:sigil/sigil-log" version: "^0.16")))
+19
(from-git url: "codeberg:sigil/sigil-log" version: "^0.16")
+20
(from-git url: "codeberg:sigil/sigil-nrepl" version: "^0.16")))
sigil.lockmodified
@@ -16,4 +16,39 @@
16
ref: "^0.16"
17
sha: "56e4fc5a52dc370c037ef14502bb8d6d2876dc53"
18
version: "0.16.0")
+19
(package name: "sigil-nrepl"
+20
url: "codeberg:sigil/sigil-nrepl"
+21
ref: "^0.16"
+22
sha: "fead992d99169523aa8726cc3eaa12623e547b2d"
+23
version: "0.16.1")
+24
(package name: "sigil-repl"
+25
url: "codeberg:sigil/sigil-repl"
+26
ref: "^0.16.0"
+27
sha: "cffebf24367e226aead63ef8e974329b59e10e69"
+28
version: "0.16.1")
+29
(package name: "sigil-socket"
+30
url: "codeberg:sigil/sigil-socket"
+31
ref: "^0.16.0"
+32
sha: "6b9e36eb4d4a944b463e5f6826bd711186ba6710"
+33
version: "0.16.0")
+34
(package name: "sigil-ansi"
+35
url: "codeberg:sigil/sigil-ansi"
+36
ref: "^0.16.0"
+37
sha: "1e14b6f9d034ca8ef9f404e2ded316d0780ad7cf"
+38
version: "0.16.0")
+39
(package name: "sigil-hooks"
+40
url: "codeberg:sigil/sigil-hooks"
+41
ref: "^0.16.0"
+42
sha: "0953f39460030b799c92604e66115225324b07d6"
+43
version: "0.16.1")
+44
(package name: "sigil-docs"
+45
url: "codeberg:sigil/sigil-docs"
+46
ref: "^0.16.0"
+47
sha: "105327cf559c31945fa3239b770460ca323ea8ed"
+48
version: "0.16.1")
+49
(package name: "sigil-args"
+50
url: "codeberg:sigil/sigil-args"
+51
ref: "^0.16.0"
+52
sha: "e8f9427990df5da509d08ea39f24a79f19f7e432"
+53
version: "0.16.0")
54
)
src/sigil/mcp/nrepl.sgladded
@@ -0,0 +1,311 @@
+1
;;; (sigil mcp nrepl) - MCP tools for nREPL interaction
+2
;;;
+3
;;; Provides MCP tools that proxy to a running Sigil app's nREPL server.
+4
+5
(define-library (sigil mcp nrepl)
+6
(import (sigil core)
+7
(sigil string)
+8
(sigil json)
+9
(sigil nrepl client))
+10
+11
(export register-nrepl-tools!)
+12
+13
(begin
+14
+15
;; ============================================================
+16
;; Tool Schemas
+17
;; ============================================================
+18
+19
(define nrepl-connect-schema
+20
'((type . "object")
+21
(properties . ((host . ((type . "string")
+22
(description . "Host to connect to (default: 127.0.0.1)")))
+23
(port . ((type . "integer")
+24
(description . "nREPL port to connect to")))))
+25
(required . ("port"))))
+26
+27
(define nrepl-eval-schema
+28
'((type . "object")
+29
(properties . ((code . ((type . "string")
+30
(description . "Sigil code to evaluate in the running app")))
+31
(module . ((type . "string")
+32
(description . "Module context (e.g. \"(sigil web demo views)\")")))))
+33
(required . ("code"))))
+34
+35
(define nrepl-complete-schema
+36
'((type . "object")
+37
(properties . ((prefix . ((type . "string")
+38
(description . "Symbol prefix to complete")))
+39
(module . ((type . "string")
+40
(description . "Optional module context")))))
+41
(required . ("prefix"))))
+42
+43
(define nrepl-symbol-schema
+44
'((type . "object")
+45
(properties . ((symbol . ((type . "string")
+46
(description . "Symbol name to inspect")))
+47
(module . ((type . "string")
+48
(description . "Optional module context")))))
+49
(required . ("symbol"))))
+50
+51
(define nrepl-macroexpand-schema
+52
'((type . "object")
+53
(properties . ((code . ((type . "string")
+54
(description . "Macro form to expand")))
+55
(module . ((type . "string")
+56
(description . "Optional module context")))))
+57
(required . ("code"))))
+58
+59
(define nrepl-status-schema
+60
'((type . "object")))
+61
+62
(define nrepl-disconnect-schema
+63
'((type . "object")))
+64
+65
;; ============================================================
+66
;; Helpers
+67
;; ============================================================
+68
+69
(define (connected-conn)
+70
(let ((conn (nrepl-lookup 'mcp)))
+71
(and conn (nrepl-connected? conn) conn)))
+72
+73
(define (not-connected-message tool-name)
+74
(format "Not connected to nREPL. Use sigil/nrepl-connect first before ~a." tool-name))
+75
+76
(define (response-ref resp key default)
+77
(let loop ((rest (if (and (pair? resp) (eq? (car resp) 'response))
+78
(cdr resp)
+79
'())))
+80
(cond
+81
((null? rest) default)
+82
((null? (cdr rest)) default)
+83
((eq? (car rest) key) (cadr rest))
+84
(else (loop (cddr rest))))))
+85
+86
(define (request-with-optional-module id op module . args)
+87
(append (list 'request ':id id ':op op)
+88
(if module (list ':module module) '())
+89
args))
+90
+91
(define (raw-nrepl-op conn id op module . args)
+92
(nrepl-send conn
+93
(apply request-with-optional-module id op module args)))
+94
+95
(define (format-response-error resp fallback)
+96
(format "Error: ~a"
+97
(or (response-ref resp ':message #f)
+98
(response-ref resp ':value #f)
+99
(response-ref resp ':code #f)
+100
fallback)))
+101
+102
(define (format-completions completions)
+103
(if (null? completions)
+104
"No completions"
+105
(string-join completions "\n")))
+106
+107
(define (format-doc resp)
+108
(if (eq? (response-ref resp ':status #f) 'error)
+109
(format-response-error resp "doc failed")
+110
(let ((doc (response-ref resp ':doc #f))
+111
(name (response-ref resp ':name #f))
+112
(kind (response-ref resp ':kind #f))
+113
(signature (response-ref resp ':signature #f))
+114
(summary (response-ref resp ':summary #f))
+115
(full (response-ref resp ':full #f)))
+116
(cond
+117
(doc "No documentation found")
+118
((or name kind signature summary full)
+119
(string-join
+120
(filter (lambda (s) (and s (not (string=? s ""))))
+121
(list (and name (format "~a" name))
+122
(and kind (format "kind: ~a" kind))
+123
signature
+124
summary
+125
full))
+126
"\n"))
+127
(else "No documentation found")))))
+128
+129
(define (format-describe resp)
+130
(if (eq? (response-ref resp ':status #f) 'error)
+131
(format-response-error resp "describe failed")
+132
(string-join
+133
(filter (lambda (s) (and s (not (string=? s ""))))
+134
(list (let ((name (response-ref resp ':name #f)))
+135
(and name (format "~a" name)))
+136
(let ((type (response-ref resp ':type #f)))
+137
(and type (format "type: ~a" type)))
+138
(let ((kind (response-ref resp ':kind #f)))
+139
(and kind (format "kind: ~a" kind)))
+140
(response-ref resp ':signature #f)
+141
(response-ref resp ':summary #f)
+142
(response-ref resp ':doc #f)))
+143
"\n")))
+144
+145
;; ============================================================
+146
;; Tool Handlers
+147
;; ============================================================
+148
+149
(define (tool-nrepl-connect args)
+150
(let ((host (or (dict-ref args host: #f) "127.0.0.1"))
+151
(port (dict-ref args port: #f))
+152
(existing (nrepl-lookup 'mcp)))
+153
(when (and existing (nrepl-connected? existing))
+154
(nrepl-disconnect existing)
+155
(nrepl-unregister! 'mcp))
+156
(let ((conn (nrepl-connect host port)))
+157
(if conn
+158
(begin
+159
(nrepl-register! 'mcp conn)
+160
(format "Connected to nREPL at ~a:~a" host port))
+161
(format "Failed to connect to nREPL at ~a:~a" host port)))))
+162
+163
(define (tool-nrepl-eval args)
+164
(let ((conn (connected-conn)))
+165
(if (not conn)
+166
"Not connected to nREPL. Use sigil/nrepl-connect first."
+167
(let ((code (dict-ref args code: ""))
+168
(module (dict-ref args module: #f)))
+169
(let ((resp (if module
+170
(nrepl-eval conn code module)
+171
(nrepl-eval conn code))))
+172
(if resp
+173
(let ((status (assoc-ref 'status resp))
+174
(value (assoc-ref 'value resp))
+175
(message (assoc-ref 'message resp))
+176
(mod (assoc-ref 'module resp)))
+177
(cond
+178
((eq? status 'ok)
+179
(if mod
+180
(format "~a\n[module: ~a]" (or value "ok") mod)
+181
(or value "ok")))
+182
((eq? status 'error)
+183
(format "Error: ~a" (or message value "unknown error")))
+184
(else
+185
(format "~a" (or value "ok")))))
+186
"Error: No response from nREPL (connection may be lost)"))))))
+187
+188
(define (tool-nrepl-complete args)
+189
(let ((conn (connected-conn)))
+190
(if (not conn)
+191
(not-connected-message "sigil/nrepl-complete")
+192
(let* ((prefix (dict-ref args prefix: ""))
+193
(module (dict-ref args module: #f))
+194
(resp (raw-nrepl-op conn "mcp-complete" 'complete module
+195
':prefix prefix)))
+196
(if (not resp)
+197
"Error: No response from nREPL"
+198
(if (eq? (response-ref resp ':status #f) 'error)
+199
(format-response-error resp "completion failed")
+200
(format-completions (response-ref resp ':completions '()))))))))
+201
+202
(define (tool-nrepl-doc args)
+203
(let ((conn (connected-conn)))
+204
(if (not conn)
+205
(not-connected-message "sigil/nrepl-doc")
+206
(let* ((symbol-name (dict-ref args symbol: ""))
+207
(module (dict-ref args module: #f))
+208
(resp (raw-nrepl-op conn "mcp-doc" 'doc module
+209
':symbol symbol-name)))
+210
(if resp (format-doc resp) "Error: No response from nREPL")))))
+211
+212
(define (tool-nrepl-describe args)
+213
(let ((conn (connected-conn)))
+214
(if (not conn)
+215
(not-connected-message "sigil/nrepl-describe")
+216
(let* ((symbol-name (dict-ref args symbol: ""))
+217
(module (dict-ref args module: #f))
+218
(resp (raw-nrepl-op conn "mcp-describe" 'describe module
+219
':symbol symbol-name)))
+220
(if resp (format-describe resp) "Error: No response from nREPL")))))
+221
+222
(define (tool-nrepl-macroexpand args)
+223
(let ((conn (connected-conn)))
+224
(if (not conn)
+225
(not-connected-message "sigil/nrepl-macroexpand")
+226
(let* ((code (dict-ref args code: ""))
+227
(module (dict-ref args module: #f))
+228
(resp (raw-nrepl-op conn "mcp-macroexpand" 'macroexpand module
+229
':code code)))
+230
(if (not resp)
+231
"Error: No response from nREPL"
+232
(if (eq? (response-ref resp ':status #f) 'error)
+233
(format-response-error resp "macroexpand failed")
+234
(or (response-ref resp ':expansion #f) "No expansion")))))))
+235
+236
(define (tool-nrepl-status args)
+237
(let ((conn (nrepl-lookup 'mcp)))
+238
(if (not conn)
+239
"Not connected"
+240
(if (not (nrepl-connected? conn))
+241
"Connection lost"
+242
(let ((resp (nrepl-eval conn "(values)")))
+243
(if resp
+244
(let ((mod (assoc-ref 'module resp)))
+245
(format "Connected\nModule: ~a" (or mod "unknown")))
+246
"Connected (no response to ping)"))))))
+247
+248
(define (tool-nrepl-disconnect args)
+249
(let ((conn (nrepl-lookup 'mcp)))
+250
(if (not conn)
+251
"Not connected"
+252
(begin
+253
(nrepl-disconnect conn)
+254
(nrepl-unregister! 'mcp)
+255
"Disconnected"))))
+256
+257
;; ============================================================
+258
;; Registration
+259
;; ============================================================
+260
+261
;;; Register nREPL tools with an MCP server.
+262
(define (register-nrepl-tools! server register-tool!)
+263
(register-tool! server
+264
"sigil/nrepl-connect"
+265
"Connect to a running Sigil app's nREPL server for live development"
+266
nrepl-connect-schema
+267
tool-nrepl-connect)
+268
+269
(register-tool! server
+270
"sigil/nrepl-eval"
+271
"Evaluate Sigil code in the running app (redefine handlers, views, etc.)"
+272
nrepl-eval-schema
+273
tool-nrepl-eval)
+274
+275
(register-tool! server
+276
"sigil/nrepl-status"
+277
"Check nREPL connection status and current module"
+278
nrepl-status-schema
+279
tool-nrepl-status)
+280
+281
(register-tool! server
+282
"sigil/nrepl-disconnect"
+283
"Disconnect from the nREPL server"
+284
nrepl-disconnect-schema
+285
tool-nrepl-disconnect)
+286
+287
(register-tool! server
+288
"sigil/nrepl-complete"
+289
"Return symbol completions from the connected nREPL session"
+290
nrepl-complete-schema
+291
tool-nrepl-complete)
+292
+293
(register-tool! server
+294
"sigil/nrepl-doc"
+295
"Return documentation for a symbol from the connected nREPL session"
+296
nrepl-symbol-schema
+297
tool-nrepl-doc)
+298
+299
(register-tool! server
+300
"sigil/nrepl-describe"
+301
"Return detailed symbol information from the connected nREPL session"
+302
nrepl-symbol-schema
+303
tool-nrepl-describe)
+304
+305
(register-tool! server
+306
"sigil/nrepl-macroexpand"
+307
"Macroexpand a Sigil form in the connected nREPL session"
+308
nrepl-macroexpand-schema
+309
tool-nrepl-macroexpand))
+310
+311
))
test/fixtures/nrepl-server.sgladded
@@ -0,0 +1,16 @@
+1
(import (sigil nrepl)
+2
(sigil fs)
+3
(sigil time))
+4
+5
(define port 57989)
+6
(define lock-file "/tmp/sigil-mcp-nrepl-57989.lock")
+7
+8
(when (file-exists? lock-file)
+9
(delete-file lock-file))
+10
+11
(let ((server (nrepl-start port)))
+12
(write-file-string lock-file (format "~a" port))
+13
(let loop ()
+14
(nrepl-process-pending server)
+15
(sleep 0.01)
+16
(loop)))
test/test-nrepl-tools.sgladded
@@ -0,0 +1,101 @@
+1
(clear-module-cache! '(sigil mcp nrepl))
+2
(clear-module-cache! '(sigil nrepl))
+3
(clear-module-cache! '(sigil nrepl client))
+4
+5
(import (sigil test)
+6
(sigil string)
+7
(sigil json)
+8
(sigil fs)
+9
(sigil process)
+10
(sigil time)
+11
(sigil mcp nrepl)
+12
(sigil mcp server))
+13
+14
(define *test-port* 57989)
+15
(define *lock-file* "/tmp/sigil-mcp-nrepl-57989.lock")
+16
+17
(define (tool-names)
+18
(let ((server (mcp-server name: "test" version: "0.1.0"))
+19
(tools '()))
+20
(register-nrepl-tools! server
+21
(lambda (srv name desc schema handler)
+22
(set! tools (cons name tools))))
+23
tools))
+24
+25
(define (register-tools server)
+26
(register-nrepl-tools! server mcp-server-register-tool!))
+27
+28
(define (call-tool server name args)
+29
(let* ((msg (jsonrpc-request id: 1 method: "tools/call"
+30
params: (dict name: name arguments: args)))
+31
(resp (mcp-server-handle-message server msg))
+32
(result (jsonrpc-response-result resp))
+33
(content (assoc-ref 'content result)))
+34
(assoc-ref 'text (car content))))
+35
+36
(define (wait-for-lock attempts)
+37
(cond
+38
((file-exists? *lock-file*) #t)
+39
((<= attempts 0) #f)
+40
(else
+41
(sleep 0.05)
+42
(wait-for-lock (- attempts 1)))))
+43
+44
(define (with-nrepl-process thunk)
+45
(when (file-exists? *lock-file*)
+46
(delete-file *lock-file*))
+47
(let ((proc (process-spawn "sigil" "-L" "build/dev/lib"
+48
"test/fixtures/nrepl-server.sgl")))
+49
(dynamic-wind
+50
(lambda ()
+51
(unless (wait-for-lock 100)
+52
(error "nREPL fixture did not write lock file")))
+53
thunk
+54
(lambda ()
+55
(when proc
+56
(process-kill! proc))
+57
(when (file-exists? *lock-file*)
+58
(delete-file *lock-file*))))))
+59
+60
(test-group "register-nrepl-tools!"
+61
(test "registers all nREPL tools"
+62
(let ((tools (tool-names)))
+63
(assert-true (member "sigil/nrepl-connect" tools))
+64
(assert-true (member "sigil/nrepl-eval" tools))
+65
(assert-true (member "sigil/nrepl-status" tools))
+66
(assert-true (member "sigil/nrepl-disconnect" tools))
+67
(assert-true (member "sigil/nrepl-complete" tools))
+68
(assert-true (member "sigil/nrepl-doc" tools))
+69
(assert-true (member "sigil/nrepl-describe" tools))
+70
(assert-true (member "sigil/nrepl-macroexpand" tools))
+71
(assert-equal 8 (length tools)))))
+72
+73
(test-group "nREPL MCP introspection tools"
+74
(test "complete, doc, describe, and macroexpand round-trip against live nREPL"
+75
(with-nrepl-process
+76
(lambda ()
+77
(let ((mcp (mcp-server name: "test" version: "0.1.0")))
+78
(register-tools mcp)
+79
(let ((connect-text (call-tool mcp "sigil/nrepl-connect"
+80
(dict port: *test-port*))))
+81
(assert-true (string-contains? connect-text "Connected")))
+82
(let ((eval-text (call-tool mcp "sigil/nrepl-eval"
+83
(dict code: "(define nrepl-mcp-alpha 42)"))))
+84
(assert-true eval-text))
+85
(let ((complete-text (call-tool mcp "sigil/nrepl-complete"
+86
(dict prefix: "nrepl-mcp-a"))))
+87
(assert-true (string-contains? complete-text "nrepl-mcp-alpha")))
+88
(let ((doc-text (call-tool mcp "sigil/nrepl-doc"
+89
(dict symbol: "car"))))
+90
(assert-true (or (string-contains? doc-text "car")
+91
(string-contains? doc-text "kind:"))))
+92
(let ((describe-text (call-tool mcp "sigil/nrepl-describe"
+93
(dict symbol: "car"))))
+94
(assert-true (string-contains? describe-text "car")))
+95
(let ((macro-text (call-tool mcp "sigil/nrepl-macroexpand"
+96
(dict code: "(when #t 1)"))))
+97
(assert-false (string-contains? macro-text "Error:"))
+98
(assert-false (string=? macro-text "No expansion")))
+99
(call-tool mcp "sigil/nrepl-disconnect" (dict)))))))
+100
+101
(run-tests)