|
443 | 443 | (swap! db* assoc-in [:mcp-clients name :status] :failed) |
444 | 444 | (on-server-updated (->server name server-config :failed @db*)))))) |
445 | 445 |
|
| 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 | + |
446 | 458 | (defn initialize-servers-async! [{:keys [on-server-updated]} db* config metrics] |
447 | 459 | (let [db @db*] |
448 | 460 | (doseq [[name-kwd server-config] (:mcpServers config)] |
449 | 461 | (let [server-name (name name-kwd)] |
450 | 462 | (when-not (get-in db [:mcp-clients server-name]) |
451 | 463 | (if (get server-config :disabled false) |
452 | 464 | (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))))))) |
460 | 466 |
|
461 | 467 | (def ^:private disconnect-timeout-ms 3000) |
462 | 468 |
|
|
570 | 576 | {:source :global})))) |
571 | 577 | {:source :global}))) |
572 | 578 |
|
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 | | - |
604 | 579 | (defn ^:private update-config-file! |
605 | 580 | "Apply rewrite-json edits to a config file, preserving comments and formatting. |
606 | 581 | `edit-fn` receives a parsed rj root node and returns the modified root." |
|
610 | 585 | (io/make-parents config-file) |
611 | 586 | (spit config-file (rj/to-string root)))) |
612 | 587 |
|
| 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 | + |
613 | 613 | (defn ^:private resolve-config-file [server-name db] |
614 | 614 | (let [{:keys [source workspace-root-uri]} (find-server-config-source server-name db)] |
615 | 615 | (if (= source :local) |
616 | 616 | (io/file (shared/uri->filename workspace-root-uri) ".eca" "config.json") |
617 | 617 | (config/global-config-file)))) |
618 | 618 |
|
| 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 | + |
619 | 664 | (defn disable-server! |
620 | 665 | "Disable an MCP server: persist disabled=true in config, stop if running, notify." |
621 | 666 | [server-name db* config {:keys [on-server-updated]}] |
|
640 | 685 | (let [fresh-config (config/all @db*)] |
641 | 686 | (start-server! server-name db* fresh-config metrics {:on-server-updated on-server-updated})))) |
642 | 687 |
|
| 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 | + |
643 | 759 | (defn all-tools [db] |
644 | 760 | (into [] |
645 | 761 | (mapcat (fn [[name {:keys [tools version]}]] |
|
0 commit comments