Skip to content

Commit 402d9dd

Browse files
ericdalloeca-agent
andcommitted
Support adding and removing MCP servers at runtime
Lets clients (eca-desktop webview, remote web UI) create and delete MCP server definitions without hand-editing config files. Writes go through rewrite-json so user comments and formatting are preserved. - JSON-RPC: mcp/addServer and mcp/removeServer requests; new tool/serverRemoved notification emitted on successful removal - mcp/updateServer now accepts :env and :headers, and its persistence path is migrated off the cheshire round-trip that stripped comments - REST mirror: POST /api/v1/mcp and DELETE /api/v1/mcp/:name - Validation returns {:error {:code "invalid_request" ...}} for duplicate name, missing transport, command+url conflict, blank name, and unknown server on remove 🤖 Generated with [eca](https://eca.dev) Co-Authored-By: eca-agent <git@eca.dev>
1 parent a4014fe commit 402d9dd

File tree

11 files changed

+544
-41
lines changed

11 files changed

+544
-41
lines changed

src/eca/features/tools.clj

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,11 @@
279279
(fn [t] (assoc t :server {:name (:name server)})))
280280
%)))))
281281

282+
(defn ^:private notify-server-removed [metrics messenger params]
283+
(metrics/count-up! "mcp-server-status" {:name (:name params)
284+
:status "removed"} metrics)
285+
(messenger/tool-server-removed messenger params))
286+
282287
(defn init-servers! [db* messenger config metrics]
283288
(let [default-agent (get config :defaultAgent)
284289
tool-status-fn (make-tool-status-fn config default-agent)]
@@ -356,6 +361,26 @@
356361
metrics
357362
{:on-server-updated (partial notify-server-updated metrics messenger tool-status-fn)})))
358363

364+
(defn add-server! [name server-config opts db* messenger config metrics]
365+
(let [tool-status-fn (make-tool-status-fn config nil)]
366+
(f.mcp/add-server!
367+
name
368+
server-config
369+
opts
370+
db*
371+
config
372+
metrics
373+
{:on-server-updated (partial notify-server-updated metrics messenger tool-status-fn)})))
374+
375+
(defn remove-server! [name db* messenger config metrics]
376+
(let [tool-status-fn (make-tool-status-fn config nil)]
377+
(f.mcp/remove-server!
378+
name
379+
db*
380+
config
381+
{:on-server-updated (partial notify-server-updated metrics messenger tool-status-fn)
382+
:on-server-removed (partial notify-server-removed metrics messenger)})))
383+
359384
(defn tool-call-summary [all-tools full-name args config db]
360385
(when-let [summary-fn (:summary-fn (first (filter #(= full-name (:full-name %))
361386
all-tools)))]

src/eca/features/tools/mcp.clj

Lines changed: 154 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -443,20 +443,26 @@
443443
(swap! db* assoc-in [:mcp-clients name :status] :failed)
444444
(on-server-updated (->server name server-config :failed @db*))))))
445445

446+
(defn ^:private start-single-server-async!
447+
"Spawn a daemon init thread for a single MCP server. Used by both initial
448+
startup and runtime add-server!."
449+
[server-name db* config metrics on-server-updated]
450+
(let [t (Thread.
451+
(fn []
452+
(with-init-thread server-name
453+
(initialize-server! server-name db* config metrics on-server-updated))))]
454+
(.setName t (str "mcp-init-" server-name))
455+
(.setDaemon t true)
456+
(.start t)))
457+
446458
(defn initialize-servers-async! [{:keys [on-server-updated]} db* config metrics]
447459
(let [db @db*]
448460
(doseq [[name-kwd server-config] (:mcpServers config)]
449461
(let [server-name (name name-kwd)]
450462
(when-not (get-in db [:mcp-clients server-name])
451463
(if (get server-config :disabled false)
452464
(on-server-updated (->server server-name server-config :disabled db))
453-
(let [t (Thread.
454-
(fn []
455-
(with-init-thread server-name
456-
(initialize-server! server-name db* config metrics on-server-updated))))]
457-
(.setName t (str "mcp-init-" server-name))
458-
(.setDaemon t true)
459-
(.start t))))))))
465+
(start-single-server-async! server-name db* config metrics on-server-updated)))))))
460466

461467
(def ^:private disconnect-timeout-ms 3000)
462468

@@ -570,37 +576,6 @@
570576
{:source :global}))))
571577
{:source :global})))
572578

573-
(defn ^:private replace-server-in-config-file!
574-
"Replace a single MCP server entry in a JSON config file using assoc-in
575-
instead of deep-merge, so old keys (e.g. :command when switching to :url)
576-
are removed. Note: comments in the original file are stripped since JSON
577-
output cannot preserve them."
578-
[^java.io.File config-file server-name new-server-config]
579-
(let [raw (when (.exists config-file)
580-
(parse-json-with-comments (slurp config-file)))
581-
updated (assoc-in (or raw {}) ["mcpServers" server-name]
582-
(json/parse-string (json/generate-string new-server-config)))]
583-
(io/make-parents config-file)
584-
(spit config-file (json/generate-string updated {:pretty true}))))
585-
586-
(defn update-server!
587-
"Update an MCP server's connection config (command/args/url), persist to the
588-
correct config file (local or global), clear the config cache, then restart."
589-
[server-name server-fields db* config metrics {:keys [on-server-updated]}]
590-
(let [db @db*
591-
{:keys [source workspace-root-uri]} (find-server-config-source server-name db)
592-
current-server-config (get-in config [:mcpServers server-name])
593-
;; Build clean server entry: preserve env/disabled/headers, replace connection fields
594-
preserved-keys (select-keys current-server-config [:env :disabled :headers])
595-
new-server-config (merge preserved-keys server-fields)
596-
config-file (if (= source :local)
597-
(io/file (shared/uri->filename workspace-root-uri) ".eca" "config.json")
598-
(config/global-config-file))]
599-
(replace-server-in-config-file! config-file server-name new-server-config)
600-
(memoize/memo-clear! config/all)
601-
(let [fresh-config (config/all @db*)]
602-
(restart-server! server-name db* fresh-config metrics on-server-updated))))
603-
604579
(defn ^:private update-config-file!
605580
"Apply rewrite-json edits to a config file, preserving comments and formatting.
606581
`edit-fn` receives a parsed rj root node and returns the modified root."
@@ -610,12 +585,82 @@
610585
(io/make-parents config-file)
611586
(spit config-file (rj/to-string root))))
612587

588+
(defn ^:private walk-server-leaves
589+
"Walks an MCP server-config map and returns a seq of [string-path-vec value] pairs,
590+
stringifying keyword keys. Vectors (e.g. :args) are treated as leaves so they
591+
serialize as JSON arrays."
592+
([m] (walk-server-leaves [] m))
593+
([prefix m]
594+
(reduce-kv
595+
(fn [acc k v]
596+
(let [path (conj prefix (if (keyword? k) (name k) (str k)))]
597+
(if (and (map? v) (seq v))
598+
(into acc (walk-server-leaves path v))
599+
(conj acc [path v]))))
600+
[]
601+
m)))
602+
603+
(defn ^:private rj-assoc-server-entry
604+
"Replace the `mcpServers[server-name]` subtree in a rewrite-json root with
605+
the contents of `server-config`. Dissocs the existing entry first so stale
606+
keys (e.g. :url after switching to stdio) do not leak in."
607+
[root server-name server-config]
608+
(reduce (fn [r [leaf-path v]]
609+
(rj/assoc-in r (into ["mcpServers" server-name] leaf-path) v))
610+
(rj/dissoc-in root ["mcpServers" server-name])
611+
(walk-server-leaves server-config)))
612+
613613
(defn ^:private resolve-config-file [server-name db]
614614
(let [{:keys [source workspace-root-uri]} (find-server-config-source server-name db)]
615615
(if (= source :local)
616616
(io/file (shared/uri->filename workspace-root-uri) ".eca" "config.json")
617617
(config/global-config-file))))
618618

619+
(defn ^:private resolve-target-config-file
620+
"Resolve the config file for a NEW server (no existing source to look up).
621+
scope is :global (default) or :workspace (workspace-uri required)."
622+
[scope workspace-uri db]
623+
(case scope
624+
:workspace (if workspace-uri
625+
(let [roots (:workspace-folders db)
626+
uris (into #{} (map :uri) roots)]
627+
(when-not (contains? uris workspace-uri)
628+
(throw (ex-info (format "workspaceUri '%s' is not an open workspace root" workspace-uri)
629+
{:workspace-uri workspace-uri
630+
:workspace-roots (vec uris)})))
631+
(io/file (shared/uri->filename workspace-uri) ".eca" "config.json"))
632+
(throw (ex-info ":workspace scope requires :workspace-uri"
633+
{:scope scope})))
634+
(config/global-config-file)))
635+
636+
(defn update-server!
637+
"Update an MCP server's config fields (command/args/env/url/headers), persist
638+
to the correct config file preserving comments and formatting, clear the
639+
config cache, then restart.
640+
641+
`server-fields` is a partial map of fields to override. Fields not present
642+
are preserved from the existing entry EXCEPT when the transport flips:
643+
switching to HTTP (by supplying :url) strips :command/:args/:env, and
644+
switching to stdio (by supplying :command or :args) strips :url/:headers."
645+
[server-name server-fields db* config metrics {:keys [on-server-updated]}]
646+
(let [db @db*
647+
current-server-config (get-in config [:mcpServers server-name])
648+
switching-to-http? (some? (:url server-fields))
649+
switching-to-stdio? (or (some? (:command server-fields))
650+
(some? (:args server-fields)))
651+
stripped-keys (cond
652+
switching-to-http? [:command :args :env]
653+
switching-to-stdio? [:url :headers]
654+
:else [])
655+
new-server-config (-> (apply dissoc current-server-config stripped-keys)
656+
(merge server-fields))
657+
config-file (resolve-config-file server-name db)]
658+
(update-config-file! config-file
659+
#(rj-assoc-server-entry % server-name new-server-config))
660+
(memoize/memo-clear! config/all)
661+
(let [fresh-config (config/all @db*)]
662+
(restart-server! server-name db* fresh-config metrics on-server-updated))))
663+
619664
(defn disable-server!
620665
"Disable an MCP server: persist disabled=true in config, stop if running, notify."
621666
[server-name db* config {:keys [on-server-updated]}]
@@ -640,6 +685,77 @@
640685
(let [fresh-config (config/all @db*)]
641686
(start-server! server-name db* fresh-config metrics {:on-server-updated on-server-updated}))))
642687

688+
(defn ^:private normalize-new-server-config
689+
"Coerce incoming map (from JSON-RPC) into the shape stored in :mcpServers.
690+
Keyword-izes top-level keys and nested :env/:headers keys so internal
691+
code paths (->transport, start-server!) work uniformly after config/all
692+
is re-read."
693+
[server-config]
694+
(letfn [(kw-map [m] (into {} (map (fn [[k v]] [(keyword (name k)) v])) m))]
695+
(cond-> (kw-map server-config)
696+
(:env server-config) (update :env kw-map)
697+
(:headers server-config) (update :headers kw-map))))
698+
699+
(defn add-server!
700+
"Add a new MCP server definition: validate, persist to the chosen config file
701+
preserving comments, clear the config cache, then start the server async
702+
(unless :disabled true, in which case only emit the disabled status).
703+
704+
`server-config` is the wire-shape entry map. Accepted fields:
705+
stdio: :command, :args, :env, :disabled
706+
HTTP: :url, :headers, :clientId, :clientSecret, :oauthPort, :disabled
707+
708+
`opts`:
709+
:scope :global (default) or :workspace
710+
:workspace-uri required when :scope = :workspace"
711+
[server-name server-config {:keys [scope workspace-uri]} db* config metrics
712+
{:keys [on-server-updated]}]
713+
(when (string/blank? server-name)
714+
(throw (ex-info "MCP server name must be non-blank" {})))
715+
(when (get-in config [:mcpServers server-name])
716+
(throw (ex-info (format "MCP server '%s' already exists" server-name)
717+
{:server-name server-name})))
718+
(let [normalized (normalize-new-server-config server-config)
719+
has-command? (some? (:command normalized))
720+
has-url? (some? (:url normalized))]
721+
(when (and has-command? has-url?)
722+
(throw (ex-info "MCP server entry must not specify both :command and :url"
723+
{:server-name server-name})))
724+
(when-not (or has-command? has-url?)
725+
(throw (ex-info "MCP server entry must specify :command (stdio) or :url (HTTP)"
726+
{:server-name server-name})))
727+
(let [scope (or scope :global)
728+
config-file (resolve-target-config-file scope workspace-uri @db*)]
729+
(update-config-file! config-file
730+
#(rj-assoc-server-entry % server-name normalized))
731+
(memoize/memo-clear! config/all)
732+
(let [fresh-config (config/all @db*)
733+
fresh-server-config (get-in fresh-config [:mcpServers server-name])]
734+
(if (get fresh-server-config :disabled false)
735+
(do (on-server-updated (->server server-name fresh-server-config :disabled @db*))
736+
(->server server-name fresh-server-config :disabled @db*))
737+
(do (start-single-server-async! server-name db* fresh-config metrics on-server-updated)
738+
(->server server-name fresh-server-config :starting @db*)))))))
739+
740+
(defn remove-server!
741+
"Remove an MCP server: stop if running, dissoc from the config file
742+
preserving comments, clear cache, clear any stored OAuth tokens, and
743+
fire on-server-removed."
744+
[server-name db* config {:keys [on-server-updated on-server-removed]}]
745+
(when-not (get-in config [:mcpServers server-name])
746+
(throw (ex-info (format "MCP server '%s' does not exist" server-name)
747+
{:server-name server-name})))
748+
(let [db @db*
749+
config-file (resolve-config-file server-name db)]
750+
(when (get-in db [:mcp-clients server-name :client])
751+
(stop-server! server-name db* config {:on-server-updated on-server-updated}))
752+
(swap! db* update :mcp-auth dissoc server-name)
753+
(update-config-file! config-file
754+
#(rj/dissoc-in % ["mcpServers" server-name]))
755+
(memoize/memo-clear! config/all)
756+
(on-server-removed {:name server-name})
757+
{:name server-name :removed true}))
758+
643759
(defn all-tools [db]
644760
(into []
645761
(mapcat (fn [[name {:keys [tools version]}]]

src/eca/handlers.clj

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -281,7 +281,9 @@
281281
server-fields (cond-> {}
282282
(:command params) (assoc :command (:command params))
283283
(:args params) (assoc :args (:args params))
284-
(:url params) (assoc :url (:url params)))]
284+
(:url params) (assoc :url (:url params))
285+
(:env params) (assoc :env (:env params))
286+
(:headers params) (assoc :headers (:headers params)))]
285287
(f.tools/update-server! server-name server-fields db* messenger config metrics)
286288
{})))
287289

@@ -293,6 +295,64 @@
293295
(metrics/task metrics :eca/mcp-enable-server
294296
(f.tools/enable-server! (:name params) db* messenger config metrics)))
295297

298+
(defn ^:private ->scope-keyword [scope]
299+
(cond
300+
(nil? scope) :global
301+
(keyword? scope) scope
302+
(string? scope) (keyword scope)
303+
:else :global))
304+
305+
(defn mcp-add-server
306+
"Add a new MCP server definition at runtime.
307+
308+
Params:
309+
:name (required) server identifier.
310+
:command :args :env for stdio servers.
311+
:url :headers :clientId :clientSecret :oauthPort for HTTP servers.
312+
:disabled optional boolean.
313+
:scope \"global\" (default) or \"workspace\".
314+
:workspaceUri required when :scope = \"workspace\".
315+
316+
Returns the canonical server map (as sent over tool/serverUpdated) with
317+
status :starting, :disabled, or :failed."
318+
[{:keys [db* messenger metrics config]} params]
319+
(metrics/task metrics :eca/mcp-add-server
320+
(let [server-name (:name params)
321+
server-config (cond-> {}
322+
(:command params) (assoc :command (:command params))
323+
(:args params) (assoc :args (:args params))
324+
(:env params) (assoc :env (:env params))
325+
(:url params) (assoc :url (:url params))
326+
(:headers params) (assoc :headers (:headers params))
327+
(:clientId params) (assoc :clientId (:clientId params))
328+
(:clientSecret params) (assoc :clientSecret (:clientSecret params))
329+
(:oauthPort params) (assoc :oauthPort (:oauthPort params))
330+
(contains? params :disabled) (assoc :disabled (boolean (:disabled params))))
331+
opts {:scope (->scope-keyword (or (:scope params) (:workspaceScope params)))
332+
:workspace-uri (or (:workspaceUri params) (:workspace-uri params))}]
333+
(try
334+
(let [server (f.tools/add-server! server-name server-config opts db* messenger config metrics)]
335+
{:server server})
336+
(catch clojure.lang.ExceptionInfo e
337+
{:error {:code "invalid_request"
338+
:message (.getMessage e)
339+
:data (ex-data e)}})))))
340+
341+
(defn mcp-remove-server
342+
"Remove an MCP server definition at runtime. Stops the server if running,
343+
removes the entry from the owning config file, and emits tool/serverRemoved.
344+
345+
Params:
346+
:name (required)"
347+
[{:keys [db* messenger metrics config]} params]
348+
(metrics/task metrics :eca/mcp-remove-server
349+
(try
350+
(f.tools/remove-server! (:name params) db* messenger config metrics)
351+
(catch clojure.lang.ExceptionInfo e
352+
{:error {:code "invalid_request"
353+
:message (.getMessage e)
354+
:data (ex-data e)}}))))
355+
296356
(defn ^:private update-agent-model-and-variants!
297357
"Updates the selected model and variants based on agent configuration."
298358
[agent-config config messenger db*]

src/eca/messenger.clj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
(chat-opened [this params])
1313
(rewrite-content-received [this data])
1414
(tool-server-updated [this params])
15+
(tool-server-removed [this params])
1516
(config-updated [this params])
1617
(provider-updated [this params])
1718
(jobs-updated [this params])

src/eca/remote/handlers.clj

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,23 @@
291291
(handlers/mcp-enable-server (assoc components :config config) {:name server-name})
292292
(no-content)))
293293

294+
(defn handle-mcp-add [{:keys [db*] :as components} request]
295+
(let [body (parse-body request)]
296+
(if-not (:name body)
297+
(error-response 400 "invalid_request" "Missing required field: name")
298+
(let [config (config/all @db*)
299+
result (handlers/mcp-add-server (assoc components :config config) body)]
300+
(if-let [{:keys [code message]} (:error result)]
301+
(error-response 400 (or code "invalid_request") (or message "Failed to add MCP server"))
302+
(json-response (camel-keys result)))))))
303+
304+
(defn handle-mcp-remove [{:keys [db*] :as components} _request server-name]
305+
(let [config (config/all @db*)
306+
result (handlers/mcp-remove-server (assoc components :config config) {:name server-name})]
307+
(if-let [{:keys [code message]} (:error result)]
308+
(error-response 400 (or code "invalid_request") (or message "Failed to remove MCP server"))
309+
(no-content))))
310+
294311
(deftype SSEBody [db* sse-connections*]
295312
ring.protocols/StreamableResponseBody
296313
(write-body-to-stream [_ _response os]

src/eca/remote/messenger.clj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@
4141
(messenger/tool-server-updated inner params)
4242
(sse/broadcast! sse-connections* "tool:server-updated" (->camel params)))
4343

44+
(tool-server-removed [_this params]
45+
(messenger/tool-server-removed inner params)
46+
(sse/broadcast! sse-connections* "tool:server-removed" (->camel params)))
47+
4448
(provider-updated [_this params]
4549
(messenger/provider-updated inner params)
4650
(sse/broadcast! sse-connections* "providers:updated" (->camel params)))

0 commit comments

Comments
 (0)