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/README.md +394 -382
- package/bin/lampson.js +8 -2
- package/chat.syn +1029 -799
- package/lampson.cmd +4 -4
- package/lampson.ps1 +88 -88
- package/lampson.sh +42 -42
- package/lib/agents.syn +471 -471
- package/lib/diff.syn +142 -0
- package/lib/lamps.syn +386 -386
- package/lib/line.syn +365 -0
- package/lib/lsp.syn +503 -503
- package/lib/mcp.syn +403 -403
- package/lib/md.syn +171 -0
- package/lib/permission.syn +189 -154
- package/lib/prompt.syn +75 -75
- package/lib/session.syn +111 -111
- package/lib/skills.syn +179 -179
- package/lib/tools/bash.syn +105 -105
- package/lib/tools/edit.syn +35 -32
- package/lib/tools/memo.syn +148 -148
- package/lib/tools/process.syn +46 -46
- package/lib/tools/write.syn +4 -0
- package/lib/tools.syn +198 -198
- package/lib/tree.syn +59 -59
- package/package.json +2 -2
- package/public/index.html +1268 -1268
- package/skills/lampson/SKILL.md +117 -117
- package/skills/synsema/SKILL.md +15 -2
- package/web.syn +468 -468
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)
|