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)