Skip to content

Integrate with plumcp for MCP functionality #316

@kumarshantanu

Description

@kumarshantanu

This issue it to request integration with plumcp for MCP functionality. Below are my notes w.r.t. the existing implementation in eca.features.tools.mcp ns:

Integrating plumcp

Add this dependency in deps.edn (because ECA already uses Cheshire):

io.github.plumce/plumcp.core-json-cheshire {:mvn/version "0.2.0-alpha1"}

Assume the following namespaces required:

    [plumcp.core.api.capability-support :as pcs]
    [plumcp.core.api.entity-support :as pes]
    [plumcp.core.api.mcp-client :as pmc]
    [plumcp.core.client.http-client-transport :as phct]
    [plumcp.core.client.stdio-client-transport :as psct]
    [plumcp.core.client.http-client-transport-auth :as phcta]
    [plumcp.core.support.http-client :as phc]

The sections below suggest how to refactor existing code in eca.features.tools.mcp ns 1-to-1 to use
Plumcp, avoiding unnecessary changes.

Fn: ->transport

No support for SSE (deprecated)

Plumcp does not support the SSE transport, which was deprecated in the 2025-03-26 spec.

Streamable HTTP transport

  • The purpose of customizer is to modify the outgoing HTTP request. The
    equivalent in plumcp is a request-middleware, defined as follows:

    rm (fn [request]  ; request middleware applied before request goes out
         (-> request
             (update :headers merge config-headers)
             (update :headers merge
                     (if-let [access-token (get-in db [:mcp-auth
                                                       server-name
                                                       :access-token])]
                       {"Authorization" (str "Bearer " access-token)}
                       {}))))
    hc (phc/make-http-client url {:request-middleware rm})

    Here we define a request middleware, and an HTTP Client.

  • Finally, create and return a client Streamable HTTP transport:

    (phct/make-streamable-http-transport hc)

STDIO transport

The client STDIO transport is created by spawning a process (i.e. run a
command):

(psct/run-command
  {:command-tokens (->> (or args [])
                        (mapv replace-env-vars)
                        (cons command))
   :dir work-dir
   :env (-> env
            (update-keys name))
   :on-stderr-text (fn [msg]
                     (->> (format "[%s] %s" server-name msg)
                          (logger/info logger-tag)))})

Fn: ->client

The plumcp client construction is more explicit:

(let [make-root-item (fn [ws]
                       (pcs/make-root-item (:uri ws)
                                           {:name (:name ws)}))
      tools-consumer (fn [tools]
                        (logger/info
                          logger-tag
                          (-> "[%s] Tools list changed, received %d tools"
                              (format name (count tools))))
                        (on-tools-change tools))
      tools-nhandler (fn [jsonrpc-notification]
                       (->> {:on-tools tools-consumer}
                            (pmc/fetch-tools jsonrpc-notification)))
      client (pmc/make-client
               {:info (pes/make-info name "current")
                :client-transport transport
                :primitives {:roots (mapv make-root-item workspaces)}
                :notification-handlers {pmc/on-tools-list-changed
                                        tools-nhandler}
                :print-banner? false})]
  ;; handshake: initialize the client and notify
  (->> {:timeout-millis (* 1000 init-timeout)}
       (pmc/initialize-and-notify! client))
  client)

Here the handshake (initialize+notify) is done when creating the client.
You may want to do it together or later. To disconnect the client later:

(pmc/disconnect! client)

Fn: ->content

Assumption: Argument content-client implies TextContent in the MCP spec.
Straight-forward translation:

(case (:type content-client)
  "text" {:type :text
          :text (:text content-client)}
  nil)

Fn: ->resource-content

Assumption: Argument resource-content-client is TextResourceContents in
MCP spec. Again, straight-forward translation:

{:type :text
 :uri (:uri resource-content-client)
 :text (:text resource-content-client)}

Fn: tool-client->tool

May not be required with plumcp, because plumcp already returns this data
in the list-tools call.

Function: list-server-tools

Get a vector of tools declaration:

(when (-> (pmc/get-initialize-result client)
          (get-in [:capabilities :tools]))
  (->> {:on-error (fn [jsonrpc-error-response]
                    (->> [:error :message]
                         (get-in jsonrpc-error-response)
                         (logger/warn logger-tag "Could not list tools:"))
                    [])}
       (pmc/list-tools client)))

Fn: list-server-prompts

It is a copy-paste of list-server-tools with tools replaced with
prompts. Perhaps the :on-error fn may be extracted out as a common fn
with arity [jsonrpc-error-response].

Fn: list-server-resources

It may be yet again a copy paste of list-server-tools. However, be aware
that in plumcp:

  • list-resources and list-resource-templates are distinct functions
  • Both fns share same capability, so check [:capabilities :resources]

Fns: initialize-mcp-oauth, token-expired?, try-refresh-token!, initialize-server!

Plumcp already implements the client-side OAuth PKCE flow, which includes:

  • starting a local HTTP server for callback
  • opening authorization URL in browser
  • stopping the server after verying callback result

So, only some of the existing functionality in initialize-mcp-oauth may
be required. The client OAuth integration is disabled by default in Plumcp.
To enable client OAuth you should pass :auth-options when making Streaming
HTTP transport.

Make auth-options:

plumcp.core.client.http-client-transport-auth/make-client-auth-options

Make client Streamable HTTP transport

plumcp.core.client.http-client-transport/make-streamable-http-transport

To use custom OAuth handling, you need to pass the options below:

{:auth-enabled? true
 :get-auth-tokens (fn [headers-lower-map auth-options]
                    ...)}

Example

If you check the plumcp.core.main.client ns in the Plumcp repo under
src/main path, you would find this:

(ns plumcp.core.main.client
  (:require
   [plumcp.core.api.entity-support :as es]
   [plumcp.core.client.http-client-transport :as hct]
   [plumcp.core.client.http-client-transport-auth :as hcta]
   [plumcp.core.dev.api :as dev]
   [plumcp.core.support.http-client :as hc]
   [plumcp.core.support.http-server :as hs]
   [plumcp.core.support.traffic-logger :as stl]
   [plumcp.core.util :as u]))


(def client-options (-> {:info (es/make-info "Test client" "0.1.0"
                                             "Test client v0.1.0")}
                        (merge dev/client-options)))


(defn make-http-transport
  [endpoint-uri & {:keys [traffic-logger
                          auth-options]
                   :or {traffic-logger stl/compact-client-traffic-logger
                        auth-options {}}
                   :as options}]
  (let [http-client (->> {:traffic-logger traffic-logger}
                         (hc/make-http-client endpoint-uri))
        auth-options (-> {:http-client http-client
                          :on-error    u/eprintln
                          :redirect-uris ["http://localhost:6277/"]
                          :mcp-server "http://localhost:3000"
                          :callback-redirect-uri "http://localhost:6277/"
                          :callback-start-server #(hs/run-http-server %
                                                    {:port 6277})
                          ;:token-cache hcta/local-token-cache
                          ;;
                          }
                         ;; for :client-name
                         (u/copy-keys client-options [:info])
                         (merge auth-options)
                         hcta/make-client-auth-options)]
    (->> {:auth-options auth-options}
         (hct/make-streamable-http-transport http-client))))

You should leave the plumcp.core.dev.api usage out, because it is not in
plumcp.core module. Rest of the config applies. By default, it uses an
in-memory token cache keyed by the auth server endpoint, so it can take in
multiple auth servers with ease. To cache the tokens on disk (consider the
SECURITY RISK) there is hcta/local-token-cache - this may survive ECA
restarts (better UX) but again, consider the security risk.

Fn: stop-server!

The line

(.closeGracefully ^McpSyncClient client)

needs to become

(pmc/disconnect! client)

Fn: call-tool!

Binding result to the call-tool result and processing that for return
value may be re-written as:

(locking mcp-client
  (->> {:on-result (fn [result]
                     {:error (:isError result)
                      :contents (:content result)})
        :on-error (fn [jsonrpc-error-response]
                    (->> [:error :message]
                         (get-in jsonrpc-error-response)
                         (logger/warn logger-tag "Error calling tool:"))
                    {:error true
                     :contents nil})}
       (pmc/call-tool mcp-client name arguments)))

Here :on-error is optional to keep parity with the existing version.

Fn: get-prompt!

Binding prompt to the get-prompt result and processing that for return
value may be re-written as:

(->> {:on-result (fn [prompt]
                   {:description (:description prompt)
                    :messages (mapv (fn [each-message]
                                      {:role (string/lower-case
                                               (:role each-message))
                                       :content [(-> each-message
                                                     :content
                                                     ->content)]})
                                    (:messages prompt))})
      :on-error (fn [jsonrpc-error-response]
                  (->> [:error :message]
                       (get-in jsonrpc-error-response)
                       (logger/warn logger-tag "Error getting prompt:")))}
     (pmc/get-prompt mcp-client name arguments))

Fn: get-resource!

Binding resource to the read-resource result and processing that for return
value may be re-written as:

(->> {:on-result (fn [resource]
                   {:contents (->> (:contents resource)
                                   (mapv ->resource-content))})
      :on-error (fn [jsonrpc-error-response]
                  (->> [:error :message]
                       (get-in jsonrpc-error-response)
                       (logger/warn logger-tag
                                    "Error reading resource:")))}
     (pmc/read-resource mcp-client uri))

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    Projects

    Status

    Ready

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions