diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 86fce627e..95dfaecc3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -319,15 +319,11 @@ jobs: - name: Install MSVC uses: ilammy/msvc-dev-cmd@v1 - - name: Install GraalVM - uses: DeLaGuardo/setup-graalvm@master + - uses: graalvm/setup-graalvm@v1 with: - graalvm: 22.2.0 - java: java17 - - - name: Install native-image component - run: | - gu.cmd install native-image + java-version: '24' + distribution: 'graalvm' + github-token: ${{ secrets.GITHUB_TOKEN }} # see https://github.com/oracle/graal/issues/4340 - name: GraalVM workaround to support UPX compression diff --git a/CHANGELOG.md b/CHANGELOG.md index 868579678..503d34895 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased - Clarify Task tool prompt for task ordering, task granularity, concurrent starts, and clearing finished task lists. +- Replace MCP Java SDK with plumcp for MCP client communication. SSE transport is no longer supported (deprecated in MCP spec 2025-03-26). ## 0.112.1 diff --git a/deps-lock.json b/deps-lock.json index ae77df9a4..45a5d5321 100644 --- a/deps-lock.json +++ b/deps-lock.json @@ -1052,6 +1052,26 @@ "mvn-repo": "https://repo.clojars.org/", "hash": "sha256-RLTLjpPU9rJiwE7Qdx1w3WbnbUXX/HVYIGcaYmVcVDk=" }, + { + "mvn-path": "io/github/plumce/plumcp.core-json-cheshire/0.2.0-beta3/plumcp.core-json-cheshire-0.2.0-beta3.jar", + "mvn-repo": "https://repo.clojars.org/", + "hash": "sha256-zv+Trbz3BWVF2jWgADInlyjqsDsanbWKVYGOceV+WnM=" + }, + { + "mvn-path": "io/github/plumce/plumcp.core-json-cheshire/0.2.0-beta3/plumcp.core-json-cheshire-0.2.0-beta3.pom", + "mvn-repo": "https://repo.clojars.org/", + "hash": "sha256-w6nOMod07L7uKT2Vl+Ici/Oonq3CvGCCrJix3f0y7Cs=" + }, + { + "mvn-path": "io/github/plumce/plumcp.core/0.2.0-beta3/plumcp.core-0.2.0-beta3.jar", + "mvn-repo": "https://repo.clojars.org/", + "hash": "sha256-M/6egOI3jY8hVnave6Cx12P5Hmd6M1J5QuZEszUx2nk=" + }, + { + "mvn-path": "io/github/plumce/plumcp.core/0.2.0-beta3/plumcp.core-0.2.0-beta3.pom", + "mvn-repo": "https://repo.clojars.org/", + "hash": "sha256-I6gSOKlouC7ZMoJYow4/XZEgwMeZYGLZwtj2oblpVoA=" + }, { "mvn-path": "io/methvin/directory-watcher/0.17.3/directory-watcher-0.17.3.jar", "mvn-repo": "https://repo1.maven.org/maven2/", @@ -3477,6 +3497,16 @@ "mvn-repo": "https://repo1.maven.org/maven2/", "hash": "sha256-D1omWgYzGwBJ41K+MsoyLeGLF/PU27cGNdQNppLjWC8=" }, + { + "mvn-path": "org/yaml/snakeyaml/2.4/snakeyaml-2.4.jar", + "mvn-repo": "https://repo1.maven.org/maven2/", + "hash": "sha256-73ea9dKand6MxwzgNB9cb3c14j7f+Whc6qnTU1m3u38=" + }, + { + "mvn-path": "org/yaml/snakeyaml/2.4/snakeyaml-2.4.pom", + "mvn-repo": "https://repo1.maven.org/maven2/", + "hash": "sha256-4VSjIxzWzeaKq/J0/RiWTUmpwaX16e079HHprnvfCOY=" + }, { "mvn-path": "progrock/progrock/0.1.2/progrock-0.1.2.jar", "mvn-repo": "https://repo.clojars.org/", diff --git a/deps.edn b/deps.edn index d22dc136b..5eddbd2de 100644 --- a/deps.edn +++ b/deps.edn @@ -3,7 +3,8 @@ org.clojure/core.async {:mvn/version "1.8.741"} org.babashka/cli {:mvn/version "0.8.65"} com.github.clojure-lsp/jsonrpc4clj {:mvn/version "1.0.2"} - io.modelcontextprotocol.sdk/mcp {:mvn/version "0.17.2"} + io.github.plumce/plumcp.core-json-cheshire {:mvn/version "0.2.0-beta3"} + org.yaml/snakeyaml {:mvn/version "2.4"} ;; used by eca.shared for YAML parsing borkdude/dynaload {:mvn/version "0.3.5"} selmer/selmer {:mvn/version "1.12.69"} babashka/fs {:mvn/version "0.5.26"} diff --git a/resources/META-INF/native-image/eca/eca/native-image.properties b/resources/META-INF/native-image/eca/eca/native-image.properties index abae79e2c..3729d3c73 100644 --- a/resources/META-INF/native-image/eca/eca/native-image.properties +++ b/resources/META-INF/native-image/eca/eca/native-image.properties @@ -4,6 +4,7 @@ Args=-J-Dborkdude.dynaload.aot=true \ -J-Dclojure.spec.skip-macros=true \ --enable-url-protocols=jar,http,https \ --enable-all-security-services \ + --add-modules=jdk.httpserver \ --initialize-at-build-time=com.fasterxml.jackson \ --initialize-at-build-time=org.yaml.snakeyaml \ --initialize-at-build-time=io.opentelemetry.api.common.AttributeType \ diff --git a/resources/META-INF/native-image/eca/eca/reflect-config.json b/resources/META-INF/native-image/eca/eca/reflect-config.json index 807ede5b6..fe51488c7 100644 --- a/resources/META-INF/native-image/eca/eca/reflect-config.json +++ b/resources/META-INF/native-image/eca/eca/reflect-config.json @@ -1,530 +1 @@ -[ - { - "name":"io.modelcontextprotocol.spec.McpError", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpJsonMapper", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$Annotated", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$Annotations", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$AudioContent", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$BlobResourceContents", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$CallToolRequest", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$CallToolResult", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$ClientCapabilities", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$ClientCapabilities$Elicitation", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$ClientCapabilities$Elicitation$Form", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$ClientCapabilities$Elicitation$Url", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$ClientCapabilities$RootCapabilities", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$ClientCapabilities$Sampling", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$CompleteReference", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$CompleteRequest", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$CompleteRequest$CompleteArgument", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$CompleteRequest$CompleteContext", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$CompleteResult", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$CompleteResult$CompleteCompletion", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$Content", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$CreateMessageRequest", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$CreateMessageRequest$ContextInclusionStrategy", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$CreateMessageResult", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$CreateMessageResult$StopReason", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$ElicitRequest", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$ElicitResult", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$ElicitResult$Action", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$EmbeddedResource", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$ErrorCodes", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$GetPromptRequest", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$GetPromptResult", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$Identifier", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$ImageContent", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$Implementation", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$InitializeRequest", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$InitializeResult", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$JSONRPCMessage", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$JSONRPCNotification", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$JSONRPCRequest", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$JSONRPCResponse", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$JSONRPCResponse$JSONRPCError", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$JsonSchema", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$ListPromptsResult", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$ListResourceTemplatesResult", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$ListResourcesResult", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$ListRootsResult", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$ListToolsResult", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$LoggingLevel", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$LoggingMessageNotification", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$Meta", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$ModelHint", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$ModelPreferences", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$Notification", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$PaginatedRequest", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$PaginatedResult", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$ProgressNotification", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$Prompt", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$PromptArgument", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$PromptMessage", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$PromptReference", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$ReadResourceRequest", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$ReadResourceResult", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$Request", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$Resource", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$ResourceContent", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$ResourceContents", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$ResourceLink", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$ResourceReference", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$ResourceTemplate", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$ResourcesUpdatedNotification", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$Result", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$Role", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$Root", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$SamplingMessage", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$ServerCapabilities", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$ServerCapabilities$CompletionCapabilities", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$ServerCapabilities$LoggingCapabilities", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$ServerCapabilities$PromptCapabilities", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$ServerCapabilities$ResourceCapabilities", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$ServerCapabilities$ToolCapabilities", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$SetLevelRequest", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$SubscribeRequest", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$TextContent", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$TextResourceContents", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$Tool", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$ToolAnnotations", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - }, - { - "name":"io.modelcontextprotocol.spec.McpSchema$UnsubscribeRequest", - "allDeclaredFields":true, - "allDeclaredConstructors":true, - "allDeclaredMethods":true - } -] +[] diff --git a/src/eca/features/tools/mcp.clj b/src/eca/features/tools/mcp.clj index dd4a32c7b..0e80d316d 100644 --- a/src/eca/features/tools/mcp.clj +++ b/src/eca/features/tools/mcp.clj @@ -1,53 +1,24 @@ (ns eca.features.tools.mcp (:require - [cheshire.core :as json] [clojure.java.browse :as browse] - [clojure.java.io :as io] [clojure.string :as string] [eca.config :as config] [eca.db :as db] [eca.logger :as logger] [eca.network :as network] [eca.oauth :as oauth] - [eca.shared :as shared]) + [eca.shared :as shared] + [plumcp.core.api.capability :as pcap] + [plumcp.core.api.entity-support :as pes] + [plumcp.core.api.mcp-client :as pmc] + [plumcp.core.client.client-support :as pcs] + [plumcp.core.client.http-client-transport :as phct] + [plumcp.core.client.stdio-client-transport :as psct] + [plumcp.core.protocol :as pp] + [plumcp.core.schema.schema-defs :as psd] + [plumcp.core.support.http-client :as phc]) (:import - [com.fasterxml.jackson.databind ObjectMapper] - [io.modelcontextprotocol.client McpClient McpSyncClient] - [io.modelcontextprotocol.client.transport - HttpClientSseClientTransport - HttpClientStreamableHttpTransport - ServerParameters - StdioClientTransport] - [io.modelcontextprotocol.client.transport.customizer McpSyncHttpClientRequestCustomizer] - [io.modelcontextprotocol.json McpJsonMapper] - [io.modelcontextprotocol.spec - McpSchema$BlobResourceContents - McpSchema$CallToolRequest - McpSchema$CallToolResult - McpSchema$ClientCapabilities - McpSchema$Content - McpSchema$EmbeddedResource - McpSchema$GetPromptRequest - McpSchema$ImageContent - McpSchema$LoggingMessageNotification - McpSchema$Prompt - McpSchema$PromptArgument - McpSchema$PromptMessage - McpSchema$ReadResourceRequest - McpSchema$Resource - McpSchema$ResourceContents - McpSchema$Root - McpSchema$TextContent - McpSchema$TextResourceContents - McpSchema$Tool - McpTransport] - [java.io IOException] - [java.net.http HttpClient$Builder] - [java.time Duration] - [java.util List Map] - [java.util.concurrent TimeoutException] - [java.util.function Consumer] - [javax.net.ssl SSLContext])) + [java.io IOException])) (set! *warn-on-reflection* true) @@ -67,100 +38,71 @@ (str "$" var1) (str "${" var2 "}")))))) -(defn ^:private split-url - "Split a URL into base URI and endpoint path. - Examples: - - 'https://api.example.com/v1/sse' -> ['https://api.example.com', '/v1/sse'] - - 'https://mcp.example.com/sse?key=abc' -> ['https://mcp.example.com', '/sse?key=abc'] - - 'https://api.z.ai/api/mcp/web_reader/mcp' -> ['https://api.z.ai', '/api/mcp/web_reader/mcp']" - [^String url] - (let [uri (java.net.URI. url) - scheme (.getScheme uri) - host (.getHost uri) - port (.getPort uri) - path (or (.getPath uri) "") - query (.getQuery uri) - base-uri (str scheme "://" host (when (and (pos? port) (not= port -1)) (str ":" port))) - endpoint (str path (when query (str "?" query)))] - [base-uri endpoint])) - -(defn ^:private ->transport ^McpTransport [server-name server-config workspaces db] +(defn ^:private ->transport [server-name server-config workspaces db] (if (:url server-config) - ;; HTTP transport (SSE or Streamable, inferred from URL) + ;; HTTP Streamable transport (let [url (replace-env-vars (:url server-config)) - sse? (string/includes? url "/sse") config-headers (:headers server-config) - ^SSLContext ssl-ctx network/*ssl-context* - ssl-customizer (when ssl-ctx - (reify Consumer - (accept [_this client-builder] - (.sslContext ^HttpClient$Builder client-builder ssl-ctx)))) - customizer (reify McpSyncHttpClientRequestCustomizer - (customize [_this builder _method _endpoint _body _context] - ;; First apply configured static headers - (doseq [[header-name header-value] config-headers] - (.header builder (name header-name) (replace-env-vars (str header-value)))) - ;; Then apply OAuth token if present (can override static Authorization) - (when-let [access-token (get-in db [:mcp-auth server-name :access-token])] - (.header builder "Authorization" (str "Bearer " access-token)))))] - (if sse? - (let [[base-uri sse-endpoint] (split-url url)] - (logger/info logger-tag (format "Creating SSE transport for server '%s' - base: %s, endpoint: %s" server-name base-uri sse-endpoint)) - (cond-> (HttpClientSseClientTransport/builder base-uri) - true (.sseEndpoint sse-endpoint) - ssl-customizer (.customizeClient ssl-customizer) - true (.httpRequestCustomizer customizer) - true (.build))) - (let [[base-uri endpoint] (split-url url)] - (logger/info logger-tag (format "Creating HTTP transport for server '%s' - base: %s, endpoint: %s" server-name base-uri endpoint)) - (cond-> (HttpClientStreamableHttpTransport/builder base-uri) - true (.endpoint endpoint) - ssl-customizer (.customizeClient ssl-customizer) - true (.httpRequestCustomizer customizer) - true (.build))))) + ssl-ctx network/*ssl-context* + rm (fn [request] + (-> request + (update :headers merge + (into {} (map (fn [[k v]] + [(name k) (replace-env-vars (str v))])) + config-headers)) + (update :headers merge + (when-let [access-token (get-in db [:mcp-auth server-name :access-token])] + {"Authorization" (str "Bearer " access-token)})))) + hc (phc/make-http-client url (cond-> {:request-middleware rm} + ssl-ctx (assoc :ssl-context ssl-ctx)))] + (when (string/includes? url "/sse") + (logger/warn logger-tag (format "SSE transport is no longer supported for server '%s'. Using Streamable HTTP instead. Consider updating the URL." server-name))) + (logger/info logger-tag (format "Creating HTTP transport for server '%s' at %s" server-name url)) + (phct/make-streamable-http-transport hc)) ;; STDIO transport (let [{:keys [command args env]} server-config - command ^String (replace-env-vars command) - b (ServerParameters/builder command) - b (if args - (.args b ^List (mapv replace-env-vars (or args []))) - b) - b (if env - (.env b (update-keys env name)) - b) - pb-init-args [] - ;; TODO we are hard coding the first workspace work-dir (or (some-> workspaces first :uri shared/uri->filename) - (config/get-property "user.home")) - stdio-transport (proxy [StdioClientTransport] [(.build b) (McpJsonMapper/getDefault)] - (getProcessBuilder [] (-> (ProcessBuilder. ^List pb-init-args) - (.directory (io/file work-dir)))))] - (.setStdErrorHandler stdio-transport (fn [msg] - (logger/info logger-tag (format "[%s] %s" server-name msg)))) - stdio-transport))) - -(defn ^:private ->client ^McpSyncClient [name transport init-timeout workspaces - {:keys [on-tools-change]}] - (-> (McpClient/sync transport) - ;; requestTimeout must be < initializationTimeout so that MCP Java SDK's - ;; DummyEvent bug on 202 notification responses can timeout without - ;; blocking the entire initialization flow. - (.requestTimeout (Duration/ofSeconds (max 10 (quot init-timeout 2)))) - (.initializationTimeout (Duration/ofSeconds init-timeout)) - (.capabilities (-> (McpSchema$ClientCapabilities/builder) - (.roots true) - (.build))) - (.roots ^List (mapv #(McpSchema$Root. (:uri %) (:name %)) workspaces)) - (.loggingConsumer (fn [^McpSchema$LoggingMessageNotification notification] - (logger/info logger-tag (str "[MCP-" name "]") (.data notification)))) - (.toolsChangeConsumer (fn [^List tools] - (logger/info logger-tag (format "[%s] Tools list changed, received %d tools" name (count tools))) - (on-tools-change tools))) - (.build))) + (config/get-property "user.home"))] + (psct/run-command + {:command-tokens (into [(replace-env-vars command)] + (map replace-env-vars) + (or args [])) + :dir work-dir + :env (when env (update-keys env name)) + :on-stderr-text (fn [msg] + (logger/info logger-tag (format "[%s] %s" server-name msg)))})))) + + +(defn ^:private ->client [name transport init-timeout workspaces + {:keys [on-tools-change]}] + (let [tools-consumer (fn [tools] + (logger/info logger-tag + (format "[%s] Tools list changed, received %d tools" + name (count tools))) + (on-tools-change tools)) + tools-nhandler (pcs/wrap-initialized-check + (fn [jsonrpc-notification] + (pcs/fetch-tools jsonrpc-notification + {:on-tools tools-consumer}))) + client (pmc/make-mcp-client + {:info (pes/make-info name "current") + :client-transport transport + :primitives {:roots (mapv #(pcap/make-root-item (:uri %) + {:name (:name %)}) + workspaces)} + :notification-handlers + {psd/method-notifications-tools-list_changed tools-nhandler + psd/method-notifications-message (fn [params] + (logger/info logger-tag + (format "[MCP-%s] %s" name (:data params))))} + :print-banner? false})] + (pmc/initialize-and-notify! client + {:timeout-millis (* 1000 init-timeout)}) + client)) (defn ^:private ->server [mcp-name server-config status db] {:name (name mcp-name) @@ -172,81 +114,66 @@ :resources (get-in db [:mcp-clients mcp-name :resources]) :status status}) -(defn ^:private ->content [^McpSchema$Content content-client] - (case (.type content-client) +(defn ^:private ->content [content-client] + (case (:type content-client) "text" {:type :text - :text (.text ^McpSchema$TextContent content-client)} + :text (:text content-client)} "image" {:type :image - :media-type (.mimeType ^McpSchema$ImageContent content-client) - :base64 (.data ^McpSchema$ImageContent content-client)} - "resource" (let [resource (.resource ^McpSchema$EmbeddedResource content-client)] + :media-type (:mimeType content-client) + :base64 (:data content-client)} + "resource" (let [resource (:resource content-client)] (cond - (instance? McpSchema$TextResourceContents resource) - {:type :text - :text (.text ^McpSchema$TextResourceContents resource)} - - (instance? McpSchema$BlobResourceContents resource) - {:type :text - :text (format "[Binary resource: %s]" (.uri ^McpSchema$BlobResourceContents resource))} - + (:text resource) {:type :text + :text (:text resource)} + (:blob resource) {:type :text + :text (format "[Binary resource: %s]" + (:uri resource))} :else nil)) - (do (logger/warn logger-tag (format "Unsupported MCP content type: %s" (.type content-client))) + (do (logger/warn logger-tag (format "Unsupported MCP content type: %s" + (:type content-client))) nil))) -(defn ^:private ->resource-content [^McpSchema$ResourceContents resource-content-client] - (cond - (instance? McpSchema$TextResourceContents resource-content-client) - {:type :text - :uri (.uri resource-content-client) - :text (.text ^McpSchema$TextResourceContents resource-content-client)} - - :else - nil)) - -(defn ^:private tool-client->tool [^McpSchema$Tool tool-client ^ObjectMapper obj-mapper] - {:name (.name tool-client) - :description (.description tool-client) - ;; We convert to json to then read so we have a clojure map - ;; TODO avoid this converting to clojure map directly - :parameters (json/parse-string (.writeValueAsString obj-mapper (.inputSchema tool-client)) true)}) - -(defn ^:private list-server-tools [^ObjectMapper obj-mapper ^McpSyncClient client] - (try - (when (.tools (.getServerCapabilities client)) - (mapv #(tool-client->tool % obj-mapper) - (.tools (.listTools client)))) - (catch Exception e - (logger/warn logger-tag "Could not list tools:" (.getMessage e)) - []))) - -(defn ^:private list-server-prompts [^McpSyncClient client] - (try - (when (.prompts (.getServerCapabilities client)) - (mapv (fn [^McpSchema$Prompt prompt-client] - {:name (.name prompt-client) - :description (.description prompt-client) - :arguments (mapv (fn [^McpSchema$PromptArgument content] - {:name (.name content) - :description (.description content) - :required (.required content)}) - (.arguments prompt-client))}) - (.prompts (.listPrompts client)))) - (catch Exception e - (logger/warn logger-tag "Could not list prompts:" (.getMessage e)) - []))) - -(defn ^:private list-server-resources [^McpSyncClient client] - (try - (when (.resources (.getServerCapabilities client)) - (mapv (fn [^McpSchema$Resource resource-client] - {:uri (.uri resource-client) - :name (.name resource-client) - :description (.description resource-client) - :mime-type (.mimeType resource-client)}) - (.resources (.listResources client)))) - (catch Exception e - (logger/warn logger-tag "Could not list resources:" (.getMessage e)) - []))) +(defn ^:private ->resource-content [resource-content-client] + (let [uri (:uri resource-content-client)] + (cond + (:text resource-content-client) + {:type :text :uri uri :text (:text resource-content-client)} + + (:blob resource-content-client) + {:type :text :uri uri :text (format "[Binary resource: %s]" uri)} + + :else nil))) + +(defn ^:private tool->internal + "Adapt plumcp tool map to ECA's internal tool shape." + [tool] + {:name (:name tool) + :description (:description tool) + :parameters (:inputSchema tool)}) + +(defn ^:private on-list-error [kind] + (fn [_id jsonrpc-error] + (logger/warn logger-tag (format "Could not list %s: %s" kind (:message jsonrpc-error))) + [])) + +(defn ^:private list-server-tools [client] + (if (get-in (pmc/get-initialize-result client) [:capabilities :tools]) + (or (some->> (pmc/list-tools client {:on-error (on-list-error "tools")}) + (mapv tool->internal)) + []) + [])) + +(defn ^:private list-server-prompts [client] + (if (get-in (pmc/get-initialize-result client) [:capabilities :prompts]) + (or (pmc/list-prompts client {:on-error (on-list-error "prompts")}) + []) + [])) + +(defn ^:private list-server-resources [client] + (if (get-in (pmc/get-initialize-result client) [:capabilities :resources]) + (or (pmc/list-resources client {:on-error (on-list-error "resources")}) + []) + [])) (defn ^:private initialize-mcp-oauth [{:keys [authorization-endpoint callback-port] :as oauth-info} @@ -302,9 +229,8 @@ (defn ^:private transient-http-error? "Checks if the exception root cause is a transient HTTP error (e.g. chunked - encoding EOF) that warrants a retry. This is a known issue with the MCP Java - SDK's Streamable HTTP transport when infrastructure (load balancers, proxies) - closes SSE connections." + encoding EOF) that warrants a retry. This can happen when infrastructure + (load balancers, proxies) closes HTTP streaming connections." [^Exception e] (let [cause (.getCause e)] (and (instance? IOException cause) @@ -349,68 +275,53 @@ (logger/error logger-tag error) (swap! db* assoc-in [:mcp-clients name :status] :failed) (on-server-updated (->server name server-config :failed db)))}) - (let [obj-mapper (ObjectMapper.) - init-timeout (:mcpTimeoutSeconds config) - on-tools-change (fn [tools-client] - (let [tools (mapv #(tool-client->tool % obj-mapper) - tools-client)] + (let [init-timeout (:mcpTimeoutSeconds config) + on-tools-change (fn [tools] + (let [tools (mapv tool->internal tools)] (swap! db* assoc-in [:mcp-clients name :tools] tools) (on-server-updated (->server name server-config :running @db*))))] (loop [attempt 1] (let [transport (->transport name server-config workspaces db) - client (->client name transport init-timeout workspaces - {:on-tools-change on-tools-change})] - (swap! db* assoc-in [:mcp-clients name] {:client client :status :starting}) - (let [result (try - (.initialize client) - (swap! db* assoc-in [:mcp-clients name :version] (.version (.getServerInfo client))) - (swap! db* assoc-in [:mcp-clients name :tools] (list-server-tools obj-mapper client)) + result (try + (let [client (->client name transport init-timeout workspaces + {:on-tools-change on-tools-change}) + init-result (pmc/get-initialize-result client) + version (get-in init-result [:serverInfo :version])] + (swap! db* assoc-in [:mcp-clients name] {:client client :status :starting}) + (swap! db* assoc-in [:mcp-clients name :version] version) + (swap! db* assoc-in [:mcp-clients name :tools] (list-server-tools client)) (swap! db* assoc-in [:mcp-clients name :prompts] (list-server-prompts client)) (swap! db* assoc-in [:mcp-clients name :resources] (list-server-resources client)) (swap! db* assoc-in [:mcp-clients name :status] :running) (on-server-updated (->server name server-config :running @db*)) (logger/info logger-tag (format "Started MCP server %s" name)) - :ok - (catch Exception e - (if (and (transient-http-error? e) (< attempt max-init-retries)) - (do - (logger/warn logger-tag (format "Transient HTTP error initializing MCP server %s (attempt %d/%d), retrying: %s" - name attempt max-init-retries (.getMessage (.getCause e)))) - (try (.close client) (catch Exception _)) - :retry) - (do - (let [cause (.getCause e) - is-sse-error (and cause - (string/includes? (.getMessage cause) "Invalid SSE response")) - is-404 (and cause - (string/includes? (.getMessage cause) "Status code: 404")) - cause-message (cond - (instance? TimeoutException cause) - (format "Timeout of %s secs waiting for server start" init-timeout) - - (and is-sse-error is-404) - (str "SSE endpoint returned 404 Not Found. " - "Please verify the URL is correct. " - "For SSE connections, the URL should point to the SSE stream endpoint " - "(e.g., ending with '/sse' or '/messages')") - - is-sse-error - (str "SSE connection failed: " (.getMessage cause)) - - cause - (.getMessage cause) - - :else - "Unknown error")] - (logger/error logger-tag (format "Could not initialize MCP server %s: %s: %s" name (.getMessage e) cause-message)) - (when (and is-sse-error (:url server-config)) - (logger/error logger-tag (format "SSE URL was: %s" (replace-env-vars (:url server-config)))))) - (swap! db* assoc-in [:mcp-clients name :status] :failed) - (on-server-updated (->server name server-config :failed db)) - (.close client)))))] - (when (= result :retry) - (Thread/sleep 1000) - (recur (inc attempt))))))))) + :ok) + (catch Exception e + (try (pp/stop-client-transport! transport false) (catch Exception _)) + (if (and (transient-http-error? e) (< attempt max-init-retries)) + (do + (logger/warn logger-tag (format "Transient HTTP error initializing MCP server %s (attempt %d/%d), retrying: %s" + name attempt max-init-retries + (some-> e .getCause .getMessage))) + :retry) + (do + (let [cause (.getCause e) + cause-message (cond + (and cause (instance? java.util.concurrent.TimeoutException cause)) + (format "Timeout of %s secs waiting for server start" init-timeout) + + cause + (.getMessage cause) + + :else + (.getMessage e))] + (logger/error logger-tag (format "Could not initialize MCP server %s: %s" name cause-message))) + (swap! db* assoc-in [:mcp-clients name :status] :failed) + (on-server-updated (->server name server-config :failed db)) + :failed))))] + (when (= result :retry) + (Thread/sleep 1000) + (recur (inc attempt)))))))) (catch Exception e (logger/error logger-tag (format "Unexpected error initializing MCP server %s: %s" name (.getMessage e))) (swap! db* assoc-in [:mcp-clients name :status] :failed) @@ -431,7 +342,7 @@ (let [server-config (get-in config [:mcpServers name])] (swap! db* assoc-in [:mcp-clients name :status] :stopping) (on-server-updated (->server name server-config :stopping @db*)) - (.closeGracefully ^McpSyncClient client) + (pmc/disconnect! client) (swap! db* assoc-in [:mcp-clients name :status] :stopped) (on-server-updated (->server name server-config :stopped @db*)) (swap! db* update :mcp-clients dissoc name) @@ -450,18 +361,24 @@ :version version}) tools))) (:mcp-clients db))) -(defn call-tool! [^String name ^Map arguments {:keys [db]}] - (let [mcp-client (->> (vals (:mcp-clients db)) - (keep (fn [{:keys [client tools]}] - (when (some #(= name (:name %)) tools) - client))) - first) - ;; Synchronize on the client to prevent concurrent tool calls to the same MCP server - ^McpSchema$CallToolResult result (locking mcp-client - (.callTool ^McpSyncClient mcp-client - (McpSchema$CallToolRequest. name arguments)))] - {:error (.isError result) - :contents (into [] (keep ->content) (.content result))})) +(defn call-tool! [name arguments {:keys [db]}] + (if-let [mcp-client (->> (vals (:mcp-clients db)) + (keep (fn [{:keys [client tools]}] + (when (some #(= name (:name %)) tools) + client))) + first)] + ;; Synchronize on the client to prevent concurrent tool calls to the same MCP server + (locking mcp-client + (if-let [result (->> {:on-error (fn [_id jsonrpc-error] + (logger/warn logger-tag "Error calling tool:" (:message jsonrpc-error)) + nil)} + (pmc/call-tool mcp-client name arguments))] + {:error (:isError result) + :contents (into [] (keep ->content) (:content result))} + {:error true + :contents nil})) + {:error true + :contents nil})) (defn all-prompts [db] (into [] @@ -475,27 +392,31 @@ (mapv #(assoc % :server (name server-name)) resources))) (:mcp-clients db))) -(defn get-prompt! [^String name ^Map arguments db] - (let [mcp-client (->> (vals (:mcp-clients db)) - (keep (fn [{:keys [client prompts]}] - (when (some #(= name (:name %)) prompts) - client))) - first) - prompt (.getPrompt ^McpSyncClient mcp-client (McpSchema$GetPromptRequest. name arguments))] - {:description (.description prompt) - :messages (mapv (fn [^McpSchema$PromptMessage message] - {:role (string/lower-case (str (.role message))) - :content [(->content (.content message))]}) - (.messages prompt))})) - -(defn get-resource! [^String uri db] - (let [mcp-client (->> (vals (:mcp-clients db)) - (keep (fn [{:keys [client resources]}] - (when (some #(= uri (:uri %)) resources) - client))) - first) - resource (.readResource ^McpSyncClient mcp-client (McpSchema$ReadResourceRequest. uri))] - {:contents (mapv ->resource-content (.contents resource))})) +(defn get-prompt! [name arguments db] + (when-let [mcp-client (->> (vals (:mcp-clients db)) + (keep (fn [{:keys [client prompts]}] + (when (some #(= name (:name %)) prompts) + client))) + first)] + (when-let [prompt (->> {:on-error (fn [_id jsonrpc-error] + (logger/warn logger-tag "Error getting prompt:" (:message jsonrpc-error)))} + (pmc/get-prompt mcp-client name arguments))] + {:description (:description prompt) + :messages (mapv (fn [each-message] + {:role (string/lower-case (:role each-message)) + :content [(->content (:content each-message))]}) + (:messages prompt))}))) + +(defn get-resource! [uri db] + (when-let [mcp-client (->> (vals (:mcp-clients db)) + (keep (fn [{:keys [client resources]}] + (when (some #(= uri (:uri %)) resources) + client))) + first)] + (when-let [resource (->> {:on-error (fn [_id jsonrpc-error] + (logger/warn logger-tag "Error reading resource:" (:message jsonrpc-error)))} + (pmc/read-resource mcp-client uri))] + {:contents (mapv ->resource-content (:contents resource))}))) (defn shutdown! "Shutdown MCP servers in parallel waiting max 5s in total." @@ -503,12 +424,12 @@ (try (let [clients (vals (:mcp-clients @db*)) futures (doall - (pmap (fn [{:keys [^McpSyncClient client]}] + (pmap (fn [{:keys [client]}] (future - (try (.closeGracefully client) + (try (pmc/disconnect! client) (catch Exception _ nil)))) clients))] (doseq [f futures] (try (deref f 5000 nil) (catch Exception _ nil)))) (catch Exception _ nil)) - (swap! db* assoc :mcp-clients {})) + (swap! db* assoc :mcp-clients {})) \ No newline at end of file