lampson 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/mcp.syn CHANGED
@@ -1,403 +1,403 @@
1
- -- lib/mcp.syn — cliente MCP (Model Context Protocol) por stdio: servers globales y por proyecto
2
- --
3
- -- Config (mismo formato que Claude Code / Cursor, para copiar y pegar):
4
- -- .lampson/mcp.json GLOBAL: lampson se instala una vez (~/lampson), así que esto vale para
5
- -- TODOS los proyectos que abras — "el mcp de GitHub en todos los repos"
6
- -- workspace/.lampson/mcp.json por proyecto (no committeado); un server con el mismo nombre pisa al global
7
- -- {"mcpServers": {"github": {"command": "npx", "args": ["-y", "@modelcontextprotocol/server-github"],
8
- -- "env": {"GITHUB_TOKEN": "…"}, "disabled": false}}}
9
- --
10
- -- Arquitectura (la misma que los procesos gestionados, lib/tools/proc.syn):
11
- -- * un agente supervisor `McpSup` por server: proc_spawn (pipes, una línea JSON por mensaje), handshake
12
- -- initialize → notifications/initialized → tools/list, y después un loop `select` entre el proceso y
13
- -- el bus. Vive mientras vive lampson; bajo `serve` sobrevive a los requests. Al cerrar, muere el árbol.
14
- -- * estado en el blackboard "mcp:<server>" = {status, tools: [{name, description, inputSchema,
15
- -- readonly}], error}; lo leen chat/web/registry con observe.
16
- -- * llamada: el tool publica "mcp.req.<server>" {id, method, params} y espera "mcp.res.<server>.<id>";
17
- -- el supervisor serializa las requests por server (un MCP stdio es secuencial de todos modos).
18
- -- * cada tool MCP entra al catálogo del modelo como `mcp_<server>_<tool>` con su inputSchema tal cual;
19
- -- loop.execute las despacha acá (no son tasks Synsema: los args son libres). permission.syn: ask por
20
- -- defecto (una tool de terceros con efectos fuera del workspace), yolo permite, strict deniega; las
21
- -- de solo lectura (annotations.readOnlyHint) están también en plan/review/explore.
22
- -- Sin HTTP/SSE por ahora (stdio cubre casi todos los servers publicados); es el siguiente paso natural.
23
-
24
- use "./tools/common.syn" as c
25
-
26
- export let GLOBAL_CONFIG be ".lampson/mcp.json"
27
- export let PROJECT_CONFIG be "workspace/.lampson/mcp.json"
28
- let CALL_TIMEOUT be 120
29
-
30
- task valid_name(name)
31
- when name == nothing or name == ""
32
- give false
33
- give matches(name, "[a-zA-Z0-9_-]{1,32}")
34
-
35
- task read_config(path, scope)
36
- let out be []
37
- try
38
- let doc be json_decode(read_file(path))
39
- when contains(doc, "mcpServers")
40
- each name in keys(doc["mcpServers"])
41
- let s be doc["mcpServers"][name]
42
- let disabled be false
43
- when contains(s, "disabled")
44
- set disabled to s["disabled"] == true
45
- when valid_name(name) and contains(s, "command") and not disabled
46
- set out to append(out, {"name": name, "command": text(s["command"]), "args": when contains(s, "args") then s["args"] otherwise [], "env": when contains(s, "env") then s["env"] otherwise {}, "cwd": when contains(s, "cwd") then text(s["cwd"]) otherwise "workspace", "scope": scope})
47
- recover err
48
- give out
49
- give out
50
-
51
- -- servers configurados: proyecto pisa a global por nombre. LAMPSON_MCP_CONFIG = un archivo extra (tests).
52
- export task servers()
53
- require file(".lampson")
54
- require file(".lampson/*")
55
- require file("workspace")
56
- require file("workspace/*")
57
- require env("LAMPSON_*")
58
- let by_name be {}
59
- each s in read_config(GLOBAL_CONFIG, "global")
60
- set by_name[s["name"]] to s
61
- each s in read_config(PROJECT_CONFIG, "project")
62
- set by_name[s["name"]] to s
63
- when env("LAMPSON_MCP_CONFIG", "") != ""
64
- each s in read_config(env("LAMPSON_MCP_CONFIG", ""), "extra")
65
- set by_name[s["name"]] to s
66
- let out be []
67
- each n in sort_by(keys(by_name), (x) => x)
68
- set out to append(out, by_name[n])
69
- give out
70
-
71
- -- ---------- supervisor: un agente por server ----------
72
- -- Parámetros del spawn: name, command, args_json, env_json, cwd (un agente no ve el módulo: todo va por spawn,
73
- -- y las constantes PROTOCOL/timeouts van como literales acá adentro).
74
- agent McpSup
75
- require exec
76
- require time
77
- require env("LAMPSON_*")
78
- require env("OS")
79
- require file("workspace")
80
- require file("workspace/*")
81
- let key be "mcp:" + name
82
- let args be json_decode(args_json)
83
- let envs be json_decode(env_json)
84
- share {"status": "starting", "tools": [], "error": nothing, "started": now()} as key
85
- let p be nothing
86
- try
87
- set p to proc_spawn(command, args, {"cwd": cwd, "env": envs, "stderr": "separate", "line_mode": true, "on_full": "drop_oldest"})
88
- recover err
89
- -- Windows: `npx`/`npm` son .cmd — proc_spawn no pasa por el shell, así que se reintenta con la extensión
90
- when env("OS", "") == "Windows_NT" and contains(text(err), "not found") and not contains(lower(command), ".")
91
- try
92
- set p to proc_spawn(command + ".cmd", args, {"cwd": cwd, "env": envs, "stderr": "separate", "line_mode": true, "on_full": "drop_oldest"})
93
- recover err2
94
- share {"status": "error", "tools": [], "error": "cannot start: " + text(err2), "started": now()} as key
95
- otherwise
96
- share {"status": "error", "tools": [], "error": "cannot start: " + text(err), "started": now()} as key
97
- when p != nothing
98
- let seq be 0
99
- let pending be nothing
100
- let sub be bus_subscribe(["mcp.req." + name, "mcp.stop." + name, "mcp.stop_all"])
101
- -- request síncrona (handshake y tools/list): manda y espera la respuesta con ese id, saltando
102
- -- notificaciones y basura; devuelve {result} | {error} | nothing (timeout / proceso muerto)
103
- task rpc(method, params, timeout)
104
- set seq to seq + 1
105
- let id be seq
106
- proc_send(p, json_encode({"jsonrpc": "2.0", "id": id, "method": method, "params": params}) + "\n")
107
- let deadline be now() + timeout
108
- let answer be nothing
109
- while answer == nothing and now() < deadline
110
- let ev be proc_recv(p, deadline - now())
111
- when ev == nothing
112
- set answer to {"error": "timeout waiting for " + method}
113
- otherwise when ev["type"] == "exit"
114
- set answer to {"error": "server exited with code " + text(ev["data"]["exit_code"])}
115
- otherwise when ev["type"] == "stdout"
116
- try
117
- let m be json_decode(ev["data"])
118
- when contains(m, "id") and m["id"] == id
119
- set answer to when contains(m, "error") then {"error": text(m["error"]["message"])} otherwise {"result": m["result"]}
120
- recover e2
121
- set answer to answer
122
- give answer
123
- task notify(method, params)
124
- proc_send(p, json_encode({"jsonrpc": "2.0", "method": method, "params": params}) + "\n")
125
- let init be rpc("initialize", {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "lampson", "version": "0.1"}}, 30)
126
- when init == nothing or contains(init, "error")
127
- share {"status": "error", "tools": [], "error": "initialize failed: " + (when init == nothing then "no answer" otherwise init["error"]), "started": now()} as key
128
- proc_close(p)
129
- otherwise
130
- notify("notifications/initialized", {})
131
- let listed be rpc("tools/list", {}, 30)
132
- let tools be []
133
- when listed != nothing and contains(listed, "result") and contains(listed["result"], "tools")
134
- each t in listed["result"]["tools"]
135
- let ro be false
136
- when contains(t, "annotations") and contains(t["annotations"], "readOnlyHint")
137
- set ro to t["annotations"]["readOnlyHint"] == true
138
- set tools to append(tools, {"name": t["name"], "description": when contains(t, "description") then t["description"] otherwise "", "inputSchema": when contains(t, "inputSchema") then t["inputSchema"] otherwise {"type": "object", "properties": {}}, "readonly": ro})
139
- share {"status": "ready", "tools": tools, "error": nothing, "started": now()} as key
140
- bus_publish("mcp." + name, {"name": name, "status": "ready", "tools": length(tools)})
141
- -- loop de servicio: requests por bus, salida/muerte del proceso
142
- let open be true
143
- while open
144
- let ev be select({"p": p, "bus": sub}, 60)
145
- when ev == nothing
146
- set open to proc_status(p) == "running"
147
- otherwise when ev["name"] == "bus"
148
- when ev["topic"] == "mcp.req." + name
149
- let r be ev["data"]
150
- let ans be rpc(r["method"], r["params"], 120)
151
- bus_publish("mcp.res." + name + "." + text(r["id"]), when ans == nothing then {"error": "no answer"} otherwise ans)
152
- otherwise
153
- set open to false
154
- otherwise when ev["type"] == "exit"
155
- share {"status": "exited", "tools": [], "error": "server exited with code " + text(ev["data"]["exit_code"]), "started": now()} as key
156
- bus_publish("mcp." + name, {"name": name, "status": "exited"})
157
- set open to false
158
- otherwise when ev["type"] == "stderr"
159
- bus_publish("mcp." + name, {"name": name, "status": "ready", "line": ev["data"]})
160
- proc_close(p)
161
- when proc_status(p) != "running"
162
- observe key as st
163
- when st["status"] == "ready"
164
- share {"status": "stopped", "tools": [], "error": nothing, "started": now()} as key
165
- bus_unsubscribe(sub)
166
-
167
- export task state(name)
168
- observe "mcp:" + name as st
169
- give st
170
-
171
- -- arrancar todos los servers configurados (chat/web al iniciar); espera hasta `wait_s` a que estén listos
172
- export task start_all(wait_s)
173
- require exec
174
- require time
175
- require env("LAMPSON_*")
176
- require env("OS")
177
- require file(".lampson")
178
- require file(".lampson/*")
179
- require file("workspace")
180
- require file("workspace/*")
181
- let list be servers()
182
- each s in list
183
- let st be state(s["name"])
184
- when st == nothing or (st["status"] != "ready" and st["status"] != "starting")
185
- spawn McpSup with name = s["name"], command = s["command"], args_json = json_encode(s["args"]), env_json = json_encode(s["env"]), cwd = s["cwd"]
186
- let waited be 0
187
- while waited < wait_s * 10 and length(where(list, (s) => state(s["name"]) == nothing or state(s["name"])["status"] == "starting")) > 0
188
- sleep(0.1)
189
- set waited to waited + 1
190
- give list
191
-
192
- export task stop_all()
193
- bus_publish("mcp.stop_all", {})
194
-
195
- -- ---------- conectar / desconectar servers (usuario: web o /mcp add · modelo: tool `mcp`, con aprobación) ----------
196
-
197
- -- "npx -y @modelcontextprotocol/server-github" → {command, args[]}; respeta comillas simples y dobles
198
- -- (rutas de Windows con espacios). No hay expansión de nada: los tokens van tal cual al spawn.
199
- export task parse_command(line)
200
- let toks be []
201
- let cur be ""
202
- let quote be ""
203
- let i be 0
204
- let n be length(text(line))
205
- while i < n
206
- let ch be slice(text(line), i, i + 1)
207
- when quote != ""
208
- when ch == quote
209
- set quote to ""
210
- otherwise
211
- set cur to cur + ch
212
- otherwise when ch == "\"" or ch == "'"
213
- set quote to ch
214
- otherwise when ch == " " or ch == "\t"
215
- when cur != ""
216
- set toks to append(toks, cur)
217
- set cur to ""
218
- otherwise
219
- set cur to cur + ch
220
- set i to i + 1
221
- when cur != ""
222
- set toks to append(toks, cur)
223
- when length(toks) == 0
224
- give {"command": "", "args": []}
225
- give {"command": toks[0], "args": slice(toks, 1, length(toks))}
226
-
227
- task config_path(scope)
228
- give when scope == "project" then PROJECT_CONFIG otherwise GLOBAL_CONFIG
229
-
230
- -- alta (o reemplazo) en el mcp.json del scope + arranque del supervisor; devuelve una línea de estado.
231
- -- El archivo queda en el formato estándar {"mcpServers": {...}} — editable a mano y compatible copy-paste.
232
- export task add_server(name, command_line, env_map, scope)
233
- require exec
234
- require time
235
- require env("LAMPSON_*")
236
- require env("OS")
237
- require file(".lampson")
238
- require file(".lampson/*")
239
- require file("workspace")
240
- require file("workspace/*")
241
- when not valid_name(name)
242
- raise("invalid server name '" + text(name) + "' (letters, digits, - or _, max 32)")
243
- let parsed be parse_command(command_line)
244
- when parsed["command"] == ""
245
- raise("empty command")
246
- let sc be when scope == "project" then "project" otherwise "global"
247
- let path be config_path(sc)
248
- let doc be {"mcpServers": {}}
249
- try
250
- set doc to json_decode(read_file(path))
251
- recover err
252
- set doc to {"mcpServers": {}}
253
- when not contains(doc, "mcpServers")
254
- set doc["mcpServers"] to {}
255
- let entry be {"command": parsed["command"], "args": parsed["args"]}
256
- when env_map != nothing
257
- when length(keys(env_map)) > 0
258
- set entry["env"] to env_map
259
- let servers_map be doc["mcpServers"]
260
- set servers_map[name] to entry
261
- set doc["mcpServers"] to servers_map
262
- write_file(path, json_encode(doc))
263
- -- reconexión: si ya había un supervisor con este nombre, apagarlo antes de arrancar el nuevo
264
- let st be state(name)
265
- when st != nothing
266
- when st["status"] == "ready" or st["status"] == "starting"
267
- bus_publish("mcp.stop." + name, {})
268
- let w be 0
269
- while w < 20 and state(name)["status"] == "ready"
270
- sleep(0.1)
271
- set w to w + 1
272
- start_all(10)
273
- let after be state(name)
274
- when after == nothing
275
- give "added '" + name + "' (" + sc + ") to " + path + " but it did not start — check the command"
276
- when after["status"] == "ready"
277
- give "connected '" + name + "' (" + sc + "): " + text(length(after["tools"])) + " tools — available as mcp_" + name + "_<tool> from the next turn"
278
- give "added '" + name + "' (" + sc + ") but status is " + after["status"] + (when after["error"] != nothing then ": " + text(after["error"]) otherwise "")
279
-
280
- -- baja: lo saca del archivo donde esté (de ambos si aparece dos veces) y apaga su supervisor;
281
- -- las tools mcp_<name>_* desaparecen del catálogo en el próximo turno
282
- export task remove_server(name)
283
- require time
284
- require env("LAMPSON_*")
285
- require file(".lampson")
286
- require file(".lampson/*")
287
- require file("workspace")
288
- require file("workspace/*")
289
- when not valid_name(name)
290
- raise("invalid server name")
291
- let removed be []
292
- each pth in [PROJECT_CONFIG, GLOBAL_CONFIG]
293
- try
294
- let doc be json_decode(read_file(pth))
295
- when contains(doc, "mcpServers")
296
- when contains(doc["mcpServers"], name)
297
- let kept be {}
298
- each k in keys(doc["mcpServers"])
299
- when k != name
300
- set kept[k] to doc["mcpServers"][k]
301
- set doc["mcpServers"] to kept
302
- write_file(pth, json_encode(doc))
303
- set removed to append(removed, pth)
304
- recover err
305
- set removed to removed
306
- bus_publish("mcp.stop." + name, {})
307
- when length(removed) == 0
308
- give "no server named '" + name + "' in " + PROJECT_CONFIG + " or " + GLOBAL_CONFIG
309
- give "removed '" + name + "' (" + join(removed, ", ") + ") and stopped its supervisor"
310
-
311
- -- [{name, scope, status, error, tools: n}]
312
- export task summary()
313
- require file(".lampson")
314
- require file(".lampson/*")
315
- require file("workspace")
316
- require file("workspace/*")
317
- let out be []
318
- each s in servers()
319
- let st be state(s["name"])
320
- set out to append(out, {"name": s["name"], "scope": s["scope"], "command": s["command"] + " " + join(apply((a) => text(a), s["args"]), " "), "status": when st == nothing then "off" otherwise st["status"], "error": when st == nothing then nothing otherwise st["error"], "tools": when st == nothing then [] otherwise apply((t) => t["name"], st["tools"])})
321
- give out
322
-
323
- -- ---------- tools para el modelo ----------
324
-
325
- export task tool_name(server, tool)
326
- give "mcp_" + server + "_" + tool
327
-
328
- -- catálogo (specs OpenAI-style) de las tools MCP listas; readonly_only = true para perfiles de solo lectura
329
- export task catalog(readonly_only)
330
- require file(".lampson")
331
- require file(".lampson/*")
332
- require file("workspace")
333
- require file("workspace/*")
334
- let out be []
335
- each s in servers()
336
- let st be state(s["name"])
337
- when st != nothing and st["status"] == "ready"
338
- each t in st["tools"]
339
- when t["readonly"] or not readonly_only
340
- set out to append(out, {"name": tool_name(s["name"], t["name"]), "description": "[MCP " + s["name"] + (when t["readonly"] then ", read-only" otherwise "") + "] " + t["description"], "parameters": t["inputSchema"]})
341
- give out
342
-
343
- export task names(readonly_only)
344
- require file(".lampson")
345
- require file(".lampson/*")
346
- require file("workspace")
347
- require file("workspace/*")
348
- give apply((s) => s["name"], catalog(readonly_only))
349
-
350
- task split_name(full)
351
- -- mcp_<server>_<tool>: el server no tiene "_" garantizado, así que probamos cada server configurado
352
- let rest be slice(full, 4, length(full))
353
- each s in servers()
354
- let pre be s["name"] + "_"
355
- when length(rest) > length(pre) and slice(rest, 0, length(pre)) == pre
356
- give {"server": s["name"], "tool": slice(rest, length(pre), length(rest))}
357
- give nothing
358
-
359
- -- llamada: request por bus al supervisor, respuesta por bus; el contenido vuelve como texto para el modelo
360
- export task call(full_name, args)
361
- require time
362
- require file(".lampson")
363
- require file(".lampson/*")
364
- require file("workspace")
365
- require file("workspace/*")
366
- let parts be split_name(full_name)
367
- when parts == nothing
368
- raise("unknown MCP tool " + full_name)
369
- let st be state(parts["server"])
370
- when st == nothing or st["status"] != "ready"
371
- raise("MCP server '" + parts["server"] + "' is not ready (" + (when st == nothing then "off" otherwise st["status"]) + ")")
372
- let id be text(floor(now() * 1000000))
373
- let sub be bus_subscribe("mcp.res." + parts["server"] + "." + id)
374
- bus_publish("mcp.req." + parts["server"], {"id": id, "method": "tools/call", "params": {"name": parts["tool"], "arguments": when args == nothing then {} otherwise args}})
375
- let ev be bus_recv(sub, CALL_TIMEOUT + 5)
376
- bus_unsubscribe(sub)
377
- when ev == nothing
378
- raise("MCP call timed out (" + full_name + ")")
379
- let ans be ev["data"]
380
- when contains(ans, "error")
381
- raise("MCP error: " + text(ans["error"]))
382
- give render(ans["result"])
383
-
384
- -- resultado MCP → texto: bloques text concatenados; otros tipos se describen; isError → ERROR: …
385
- task render(result)
386
- let parts be []
387
- when contains(result, "content")
388
- each b in result["content"]
389
- let ty be when contains(b, "type") then b["type"] otherwise "?"
390
- when ty == "text"
391
- set parts to append(parts, text(b["text"]))
392
- otherwise when ty == "image"
393
- set parts to append(parts, "[image " + (when contains(b, "mimeType") then b["mimeType"] otherwise "") + ", " + text(length(text(b["data"]))) + " base64 chars]")
394
- otherwise when ty == "resource"
395
- set parts to append(parts, "[resource] " + json_encode(b["resource"]))
396
- otherwise
397
- set parts to append(parts, json_encode(b))
398
- when length(parts) == 0 and contains(result, "structuredContent")
399
- set parts to append(parts, json_encode(result["structuredContent"]))
400
- let out be join(parts, "\n")
401
- when contains(result, "isError") and result["isError"] == true
402
- give "ERROR: " + out
403
- give c.truncate(out, c.MAX_OUTPUT)
1
+ -- lib/mcp.syn — cliente MCP (Model Context Protocol) por stdio: servers globales y por proyecto
2
+ --
3
+ -- Config (mismo formato que Claude Code / Cursor, para copiar y pegar):
4
+ -- .lampson/mcp.json GLOBAL: lampson se instala una vez (~/lampson), así que esto vale para
5
+ -- TODOS los proyectos que abras — "el mcp de GitHub en todos los repos"
6
+ -- workspace/.lampson/mcp.json por proyecto (no committeado); un server con el mismo nombre pisa al global
7
+ -- {"mcpServers": {"github": {"command": "npx", "args": ["-y", "@modelcontextprotocol/server-github"],
8
+ -- "env": {"GITHUB_TOKEN": "…"}, "disabled": false}}}
9
+ --
10
+ -- Arquitectura (la misma que los procesos gestionados, lib/tools/proc.syn):
11
+ -- * un agente supervisor `McpSup` por server: proc_spawn (pipes, una línea JSON por mensaje), handshake
12
+ -- initialize → notifications/initialized → tools/list, y después un loop `select` entre el proceso y
13
+ -- el bus. Vive mientras vive lampson; bajo `serve` sobrevive a los requests. Al cerrar, muere el árbol.
14
+ -- * estado en el blackboard "mcp:<server>" = {status, tools: [{name, description, inputSchema,
15
+ -- readonly}], error}; lo leen chat/web/registry con observe.
16
+ -- * llamada: el tool publica "mcp.req.<server>" {id, method, params} y espera "mcp.res.<server>.<id>";
17
+ -- el supervisor serializa las requests por server (un MCP stdio es secuencial de todos modos).
18
+ -- * cada tool MCP entra al catálogo del modelo como `mcp_<server>_<tool>` con su inputSchema tal cual;
19
+ -- loop.execute las despacha acá (no son tasks Synsema: los args son libres). permission.syn: ask por
20
+ -- defecto (una tool de terceros con efectos fuera del workspace), yolo permite, strict deniega; las
21
+ -- de solo lectura (annotations.readOnlyHint) están también en plan/review/explore.
22
+ -- Sin HTTP/SSE por ahora (stdio cubre casi todos los servers publicados); es el siguiente paso natural.
23
+
24
+ use "./tools/common.syn" as c
25
+
26
+ export let GLOBAL_CONFIG be ".lampson/mcp.json"
27
+ export let PROJECT_CONFIG be "workspace/.lampson/mcp.json"
28
+ let CALL_TIMEOUT be 120
29
+
30
+ task valid_name(name)
31
+ when name == nothing or name == ""
32
+ give false
33
+ give matches(name, "[a-zA-Z0-9_-]{1,32}")
34
+
35
+ task read_config(path, scope)
36
+ let out be []
37
+ try
38
+ let doc be json_decode(read_file(path))
39
+ when contains(doc, "mcpServers")
40
+ each name in keys(doc["mcpServers"])
41
+ let s be doc["mcpServers"][name]
42
+ let disabled be false
43
+ when contains(s, "disabled")
44
+ set disabled to s["disabled"] == true
45
+ when valid_name(name) and contains(s, "command") and not disabled
46
+ set out to append(out, {"name": name, "command": text(s["command"]), "args": when contains(s, "args") then s["args"] otherwise [], "env": when contains(s, "env") then s["env"] otherwise {}, "cwd": when contains(s, "cwd") then text(s["cwd"]) otherwise "workspace", "scope": scope})
47
+ recover err
48
+ give out
49
+ give out
50
+
51
+ -- servers configurados: proyecto pisa a global por nombre. LAMPSON_MCP_CONFIG = un archivo extra (tests).
52
+ export task servers()
53
+ require file(".lampson")
54
+ require file(".lampson/*")
55
+ require file("workspace")
56
+ require file("workspace/*")
57
+ require env("LAMPSON_*")
58
+ let by_name be {}
59
+ each s in read_config(GLOBAL_CONFIG, "global")
60
+ set by_name[s["name"]] to s
61
+ each s in read_config(PROJECT_CONFIG, "project")
62
+ set by_name[s["name"]] to s
63
+ when env("LAMPSON_MCP_CONFIG", "") != ""
64
+ each s in read_config(env("LAMPSON_MCP_CONFIG", ""), "extra")
65
+ set by_name[s["name"]] to s
66
+ let out be []
67
+ each n in sort_by(keys(by_name), (x) => x)
68
+ set out to append(out, by_name[n])
69
+ give out
70
+
71
+ -- ---------- supervisor: un agente por server ----------
72
+ -- Parámetros del spawn: name, command, args_json, env_json, cwd (un agente no ve el módulo: todo va por spawn,
73
+ -- y las constantes PROTOCOL/timeouts van como literales acá adentro).
74
+ agent McpSup
75
+ require exec
76
+ require time
77
+ require env("LAMPSON_*")
78
+ require env("OS")
79
+ require file("workspace")
80
+ require file("workspace/*")
81
+ let key be "mcp:" + name
82
+ let args be json_decode(args_json)
83
+ let envs be json_decode(env_json)
84
+ share {"status": "starting", "tools": [], "error": nothing, "started": now()} as key
85
+ let p be nothing
86
+ try
87
+ set p to proc_spawn(command, args, {"cwd": cwd, "env": envs, "stderr": "separate", "line_mode": true, "on_full": "drop_oldest"})
88
+ recover err
89
+ -- Windows: `npx`/`npm` son .cmd — proc_spawn no pasa por el shell, así que se reintenta con la extensión
90
+ when env("OS", "") == "Windows_NT" and contains(text(err), "not found") and not contains(lower(command), ".")
91
+ try
92
+ set p to proc_spawn(command + ".cmd", args, {"cwd": cwd, "env": envs, "stderr": "separate", "line_mode": true, "on_full": "drop_oldest"})
93
+ recover err2
94
+ share {"status": "error", "tools": [], "error": "cannot start: " + text(err2), "started": now()} as key
95
+ otherwise
96
+ share {"status": "error", "tools": [], "error": "cannot start: " + text(err), "started": now()} as key
97
+ when p != nothing
98
+ let seq be 0
99
+ let pending be nothing
100
+ let sub be bus_subscribe(["mcp.req." + name, "mcp.stop." + name, "mcp.stop_all"])
101
+ -- request síncrona (handshake y tools/list): manda y espera la respuesta con ese id, saltando
102
+ -- notificaciones y basura; devuelve {result} | {error} | nothing (timeout / proceso muerto)
103
+ task rpc(method, params, timeout)
104
+ set seq to seq + 1
105
+ let id be seq
106
+ proc_send(p, json_encode({"jsonrpc": "2.0", "id": id, "method": method, "params": params}) + "\n")
107
+ let deadline be now() + timeout
108
+ let answer be nothing
109
+ while answer == nothing and now() < deadline
110
+ let ev be proc_recv(p, deadline - now())
111
+ when ev == nothing
112
+ set answer to {"error": "timeout waiting for " + method}
113
+ otherwise when ev["type"] == "exit"
114
+ set answer to {"error": "server exited with code " + text(ev["data"]["exit_code"])}
115
+ otherwise when ev["type"] == "stdout"
116
+ try
117
+ let m be json_decode(ev["data"])
118
+ when contains(m, "id") and m["id"] == id
119
+ set answer to when contains(m, "error") then {"error": text(m["error"]["message"])} otherwise {"result": m["result"]}
120
+ recover e2
121
+ set answer to answer
122
+ give answer
123
+ task notify(method, params)
124
+ proc_send(p, json_encode({"jsonrpc": "2.0", "method": method, "params": params}) + "\n")
125
+ let init be rpc("initialize", {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "lampson", "version": "0.1"}}, 30)
126
+ when init == nothing or contains(init, "error")
127
+ share {"status": "error", "tools": [], "error": "initialize failed: " + (when init == nothing then "no answer" otherwise init["error"]), "started": now()} as key
128
+ proc_close(p)
129
+ otherwise
130
+ notify("notifications/initialized", {})
131
+ let listed be rpc("tools/list", {}, 30)
132
+ let tools be []
133
+ when listed != nothing and contains(listed, "result") and contains(listed["result"], "tools")
134
+ each t in listed["result"]["tools"]
135
+ let ro be false
136
+ when contains(t, "annotations") and contains(t["annotations"], "readOnlyHint")
137
+ set ro to t["annotations"]["readOnlyHint"] == true
138
+ set tools to append(tools, {"name": t["name"], "description": when contains(t, "description") then t["description"] otherwise "", "inputSchema": when contains(t, "inputSchema") then t["inputSchema"] otherwise {"type": "object", "properties": {}}, "readonly": ro})
139
+ share {"status": "ready", "tools": tools, "error": nothing, "started": now()} as key
140
+ bus_publish("mcp." + name, {"name": name, "status": "ready", "tools": length(tools)})
141
+ -- loop de servicio: requests por bus, salida/muerte del proceso
142
+ let open be true
143
+ while open
144
+ let ev be select({"p": p, "bus": sub}, 60)
145
+ when ev == nothing
146
+ set open to proc_status(p) == "running"
147
+ otherwise when ev["name"] == "bus"
148
+ when ev["topic"] == "mcp.req." + name
149
+ let r be ev["data"]
150
+ let ans be rpc(r["method"], r["params"], 120)
151
+ bus_publish("mcp.res." + name + "." + text(r["id"]), when ans == nothing then {"error": "no answer"} otherwise ans)
152
+ otherwise
153
+ set open to false
154
+ otherwise when ev["type"] == "exit"
155
+ share {"status": "exited", "tools": [], "error": "server exited with code " + text(ev["data"]["exit_code"]), "started": now()} as key
156
+ bus_publish("mcp." + name, {"name": name, "status": "exited"})
157
+ set open to false
158
+ otherwise when ev["type"] == "stderr"
159
+ bus_publish("mcp." + name, {"name": name, "status": "ready", "line": ev["data"]})
160
+ proc_close(p)
161
+ when proc_status(p) != "running"
162
+ observe key as st
163
+ when st["status"] == "ready"
164
+ share {"status": "stopped", "tools": [], "error": nothing, "started": now()} as key
165
+ bus_unsubscribe(sub)
166
+
167
+ export task state(name)
168
+ observe "mcp:" + name as st
169
+ give st
170
+
171
+ -- arrancar todos los servers configurados (chat/web al iniciar); espera hasta `wait_s` a que estén listos
172
+ export task start_all(wait_s)
173
+ require exec
174
+ require time
175
+ require env("LAMPSON_*")
176
+ require env("OS")
177
+ require file(".lampson")
178
+ require file(".lampson/*")
179
+ require file("workspace")
180
+ require file("workspace/*")
181
+ let list be servers()
182
+ each s in list
183
+ let st be state(s["name"])
184
+ when st == nothing or (st["status"] != "ready" and st["status"] != "starting")
185
+ spawn McpSup with name = s["name"], command = s["command"], args_json = json_encode(s["args"]), env_json = json_encode(s["env"]), cwd = s["cwd"]
186
+ let waited be 0
187
+ while waited < wait_s * 10 and length(where(list, (s) => state(s["name"]) == nothing or state(s["name"])["status"] == "starting")) > 0
188
+ sleep(0.1)
189
+ set waited to waited + 1
190
+ give list
191
+
192
+ export task stop_all()
193
+ bus_publish("mcp.stop_all", {})
194
+
195
+ -- ---------- conectar / desconectar servers (usuario: web o /mcp add · modelo: tool `mcp`, con aprobación) ----------
196
+
197
+ -- "npx -y @modelcontextprotocol/server-github" → {command, args[]}; respeta comillas simples y dobles
198
+ -- (rutas de Windows con espacios). No hay expansión de nada: los tokens van tal cual al spawn.
199
+ export task parse_command(line)
200
+ let toks be []
201
+ let cur be ""
202
+ let quote be ""
203
+ let i be 0
204
+ let n be length(text(line))
205
+ while i < n
206
+ let ch be slice(text(line), i, i + 1)
207
+ when quote != ""
208
+ when ch == quote
209
+ set quote to ""
210
+ otherwise
211
+ set cur to cur + ch
212
+ otherwise when ch == "\"" or ch == "'"
213
+ set quote to ch
214
+ otherwise when ch == " " or ch == "\t"
215
+ when cur != ""
216
+ set toks to append(toks, cur)
217
+ set cur to ""
218
+ otherwise
219
+ set cur to cur + ch
220
+ set i to i + 1
221
+ when cur != ""
222
+ set toks to append(toks, cur)
223
+ when length(toks) == 0
224
+ give {"command": "", "args": []}
225
+ give {"command": toks[0], "args": slice(toks, 1, length(toks))}
226
+
227
+ task config_path(scope)
228
+ give when scope == "project" then PROJECT_CONFIG otherwise GLOBAL_CONFIG
229
+
230
+ -- alta (o reemplazo) en el mcp.json del scope + arranque del supervisor; devuelve una línea de estado.
231
+ -- El archivo queda en el formato estándar {"mcpServers": {...}} — editable a mano y compatible copy-paste.
232
+ export task add_server(name, command_line, env_map, scope)
233
+ require exec
234
+ require time
235
+ require env("LAMPSON_*")
236
+ require env("OS")
237
+ require file(".lampson")
238
+ require file(".lampson/*")
239
+ require file("workspace")
240
+ require file("workspace/*")
241
+ when not valid_name(name)
242
+ raise("invalid server name '" + text(name) + "' (letters, digits, - or _, max 32)")
243
+ let parsed be parse_command(command_line)
244
+ when parsed["command"] == ""
245
+ raise("empty command")
246
+ let sc be when scope == "project" then "project" otherwise "global"
247
+ let path be config_path(sc)
248
+ let doc be {"mcpServers": {}}
249
+ try
250
+ set doc to json_decode(read_file(path))
251
+ recover err
252
+ set doc to {"mcpServers": {}}
253
+ when not contains(doc, "mcpServers")
254
+ set doc["mcpServers"] to {}
255
+ let entry be {"command": parsed["command"], "args": parsed["args"]}
256
+ when env_map != nothing
257
+ when length(keys(env_map)) > 0
258
+ set entry["env"] to env_map
259
+ let servers_map be doc["mcpServers"]
260
+ set servers_map[name] to entry
261
+ set doc["mcpServers"] to servers_map
262
+ write_file(path, json_encode(doc))
263
+ -- reconexión: si ya había un supervisor con este nombre, apagarlo antes de arrancar el nuevo
264
+ let st be state(name)
265
+ when st != nothing
266
+ when st["status"] == "ready" or st["status"] == "starting"
267
+ bus_publish("mcp.stop." + name, {})
268
+ let w be 0
269
+ while w < 20 and state(name)["status"] == "ready"
270
+ sleep(0.1)
271
+ set w to w + 1
272
+ start_all(10)
273
+ let after be state(name)
274
+ when after == nothing
275
+ give "added '" + name + "' (" + sc + ") to " + path + " but it did not start — check the command"
276
+ when after["status"] == "ready"
277
+ give "connected '" + name + "' (" + sc + "): " + text(length(after["tools"])) + " tools — available as mcp_" + name + "_<tool> from the next turn"
278
+ give "added '" + name + "' (" + sc + ") but status is " + after["status"] + (when after["error"] != nothing then ": " + text(after["error"]) otherwise "")
279
+
280
+ -- baja: lo saca del archivo donde esté (de ambos si aparece dos veces) y apaga su supervisor;
281
+ -- las tools mcp_<name>_* desaparecen del catálogo en el próximo turno
282
+ export task remove_server(name)
283
+ require time
284
+ require env("LAMPSON_*")
285
+ require file(".lampson")
286
+ require file(".lampson/*")
287
+ require file("workspace")
288
+ require file("workspace/*")
289
+ when not valid_name(name)
290
+ raise("invalid server name")
291
+ let removed be []
292
+ each pth in [PROJECT_CONFIG, GLOBAL_CONFIG]
293
+ try
294
+ let doc be json_decode(read_file(pth))
295
+ when contains(doc, "mcpServers")
296
+ when contains(doc["mcpServers"], name)
297
+ let kept be {}
298
+ each k in keys(doc["mcpServers"])
299
+ when k != name
300
+ set kept[k] to doc["mcpServers"][k]
301
+ set doc["mcpServers"] to kept
302
+ write_file(pth, json_encode(doc))
303
+ set removed to append(removed, pth)
304
+ recover err
305
+ set removed to removed
306
+ bus_publish("mcp.stop." + name, {})
307
+ when length(removed) == 0
308
+ give "no server named '" + name + "' in " + PROJECT_CONFIG + " or " + GLOBAL_CONFIG
309
+ give "removed '" + name + "' (" + join(removed, ", ") + ") and stopped its supervisor"
310
+
311
+ -- [{name, scope, status, error, tools: n}]
312
+ export task summary()
313
+ require file(".lampson")
314
+ require file(".lampson/*")
315
+ require file("workspace")
316
+ require file("workspace/*")
317
+ let out be []
318
+ each s in servers()
319
+ let st be state(s["name"])
320
+ set out to append(out, {"name": s["name"], "scope": s["scope"], "command": s["command"] + " " + join(apply((a) => text(a), s["args"]), " "), "status": when st == nothing then "off" otherwise st["status"], "error": when st == nothing then nothing otherwise st["error"], "tools": when st == nothing then [] otherwise apply((t) => t["name"], st["tools"])})
321
+ give out
322
+
323
+ -- ---------- tools para el modelo ----------
324
+
325
+ export task tool_name(server, tool)
326
+ give "mcp_" + server + "_" + tool
327
+
328
+ -- catálogo (specs OpenAI-style) de las tools MCP listas; readonly_only = true para perfiles de solo lectura
329
+ export task catalog(readonly_only)
330
+ require file(".lampson")
331
+ require file(".lampson/*")
332
+ require file("workspace")
333
+ require file("workspace/*")
334
+ let out be []
335
+ each s in servers()
336
+ let st be state(s["name"])
337
+ when st != nothing and st["status"] == "ready"
338
+ each t in st["tools"]
339
+ when t["readonly"] or not readonly_only
340
+ set out to append(out, {"name": tool_name(s["name"], t["name"]), "description": "[MCP " + s["name"] + (when t["readonly"] then ", read-only" otherwise "") + "] " + t["description"], "parameters": t["inputSchema"]})
341
+ give out
342
+
343
+ export task names(readonly_only)
344
+ require file(".lampson")
345
+ require file(".lampson/*")
346
+ require file("workspace")
347
+ require file("workspace/*")
348
+ give apply((s) => s["name"], catalog(readonly_only))
349
+
350
+ task split_name(full)
351
+ -- mcp_<server>_<tool>: el server no tiene "_" garantizado, así que probamos cada server configurado
352
+ let rest be slice(full, 4, length(full))
353
+ each s in servers()
354
+ let pre be s["name"] + "_"
355
+ when length(rest) > length(pre) and slice(rest, 0, length(pre)) == pre
356
+ give {"server": s["name"], "tool": slice(rest, length(pre), length(rest))}
357
+ give nothing
358
+
359
+ -- llamada: request por bus al supervisor, respuesta por bus; el contenido vuelve como texto para el modelo
360
+ export task call(full_name, args)
361
+ require time
362
+ require file(".lampson")
363
+ require file(".lampson/*")
364
+ require file("workspace")
365
+ require file("workspace/*")
366
+ let parts be split_name(full_name)
367
+ when parts == nothing
368
+ raise("unknown MCP tool " + full_name)
369
+ let st be state(parts["server"])
370
+ when st == nothing or st["status"] != "ready"
371
+ raise("MCP server '" + parts["server"] + "' is not ready (" + (when st == nothing then "off" otherwise st["status"]) + ")")
372
+ let id be text(floor(now() * 1000000))
373
+ let sub be bus_subscribe("mcp.res." + parts["server"] + "." + id)
374
+ bus_publish("mcp.req." + parts["server"], {"id": id, "method": "tools/call", "params": {"name": parts["tool"], "arguments": when args == nothing then {} otherwise args}})
375
+ let ev be bus_recv(sub, CALL_TIMEOUT + 5)
376
+ bus_unsubscribe(sub)
377
+ when ev == nothing
378
+ raise("MCP call timed out (" + full_name + ")")
379
+ let ans be ev["data"]
380
+ when contains(ans, "error")
381
+ raise("MCP error: " + text(ans["error"]))
382
+ give render(ans["result"])
383
+
384
+ -- resultado MCP → texto: bloques text concatenados; otros tipos se describen; isError → ERROR: …
385
+ task render(result)
386
+ let parts be []
387
+ when contains(result, "content")
388
+ each b in result["content"]
389
+ let ty be when contains(b, "type") then b["type"] otherwise "?"
390
+ when ty == "text"
391
+ set parts to append(parts, text(b["text"]))
392
+ otherwise when ty == "image"
393
+ set parts to append(parts, "[image " + (when contains(b, "mimeType") then b["mimeType"] otherwise "") + ", " + text(length(text(b["data"]))) + " base64 chars]")
394
+ otherwise when ty == "resource"
395
+ set parts to append(parts, "[resource] " + json_encode(b["resource"]))
396
+ otherwise
397
+ set parts to append(parts, json_encode(b))
398
+ when length(parts) == 0 and contains(result, "structuredContent")
399
+ set parts to append(parts, json_encode(result["structuredContent"]))
400
+ let out be join(parts, "\n")
401
+ when contains(result, "isError") and result["isError"] == true
402
+ give "ERROR: " + out
403
+ give c.truncate(out, c.MAX_OUTPUT)