From 59066253f66c625670c46d8bc9525fdb57d5240c Mon Sep 17 00:00:00 2001 From: Christian Tzolov Date: Fri, 6 Dec 2024 16:40:49 +0100 Subject: [PATCH] initial --- .gitattributes | 2 + .gitignore | 33 ++ .mvn/wrapper/maven-wrapper.properties | 19 + mvnw | 259 +++++++++ mvnw.cmd | 149 +++++ pom.xml | 77 +++ .../java/spring/ai/mcp/McpApplication.java | 77 +++ .../spring/ai/mcp/McpFunctionCallback.java | 67 +++ .../java/spring/ai/mcp/client/McpClient.java | 278 ++++++++++ .../client/stdio/StdioServerParameters.java | 130 +++++ .../client/stdio/StdioServerTransport.java | 240 ++++++++ .../client/util/McpServerParameterParser.java | 87 +++ .../ai/mcp/spec/DefaultMcpTransport.java | 131 +++++ .../java/spring/ai/mcp/spec/McpSchema.java | 522 ++++++++++++++++++ .../java/spring/ai/mcp/spec/McpSession.java | 160 ++++++ .../java/spring/ai/mcp/spec/McpTransport.java | 38 ++ src/main/resources/application.properties | 4 + .../spring/ai/mcp/ClientSessionTests.java | 67 +++ .../spring/ai/mcp/ClientSessionTests2.java | 77 +++ .../spring/ai/mcp/ClientSessionTests3.java | 73 +++ .../spring/ai/mcp/McpApplicationTests.java | 13 + .../ai/mcp/spec/McpSessionJSONRPCTest.java | 158 ++++++ .../spring/ai/mcp/spec/McpSessionTest.java | 93 ++++ .../spring/ai/mcp/spec/McpTransportTest.java | 128 +++++ 24 files changed, 2882 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 .mvn/wrapper/maven-wrapper.properties create mode 100755 mvnw create mode 100644 mvnw.cmd create mode 100644 pom.xml create mode 100644 src/main/java/spring/ai/mcp/McpApplication.java create mode 100644 src/main/java/spring/ai/mcp/McpFunctionCallback.java create mode 100644 src/main/java/spring/ai/mcp/client/McpClient.java create mode 100644 src/main/java/spring/ai/mcp/client/stdio/StdioServerParameters.java create mode 100644 src/main/java/spring/ai/mcp/client/stdio/StdioServerTransport.java create mode 100644 src/main/java/spring/ai/mcp/client/util/McpServerParameterParser.java create mode 100644 src/main/java/spring/ai/mcp/spec/DefaultMcpTransport.java create mode 100644 src/main/java/spring/ai/mcp/spec/McpSchema.java create mode 100644 src/main/java/spring/ai/mcp/spec/McpSession.java create mode 100644 src/main/java/spring/ai/mcp/spec/McpTransport.java create mode 100644 src/main/resources/application.properties create mode 100644 src/test/java/spring/ai/mcp/ClientSessionTests.java create mode 100644 src/test/java/spring/ai/mcp/ClientSessionTests2.java create mode 100644 src/test/java/spring/ai/mcp/ClientSessionTests3.java create mode 100644 src/test/java/spring/ai/mcp/McpApplicationTests.java create mode 100644 src/test/java/spring/ai/mcp/spec/McpSessionJSONRPCTest.java create mode 100644 src/test/java/spring/ai/mcp/spec/McpSessionTest.java create mode 100644 src/test/java/spring/ai/mcp/spec/McpTransportTest.java diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..3b41682 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +/mvnw text eol=lf +*.cmd text eol=crlf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..549e00a --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +HELP.md +target/ +!.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..d58dfb7 --- /dev/null +++ b/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,19 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +wrapperVersion=3.3.2 +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip diff --git a/mvnw b/mvnw new file mode 100755 index 0000000..19529dd --- /dev/null +++ b/mvnw @@ -0,0 +1,259 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.2 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + if [ -n "${JAVA_HOME-}" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"${0%/*}/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in ${0%/*}/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${0##*/mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/mvnw.cmd b/mvnw.cmd new file mode 100644 index 0000000..249bdf3 --- /dev/null +++ b/mvnw.cmd @@ -0,0 +1,149 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.2 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" (%__MVNW_CMD__% %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace '^.*'+$MVNW_REPO_PATTERN,'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' +$MAVEN_HOME_PARENT = "$HOME/.m2/wrapper/dists/$distributionUrlNameMain" +if ($env:MAVEN_USER_HOME) { + $MAVEN_HOME_PARENT = "$env:MAVEN_USER_HOME/wrapper/dists/$distributionUrlNameMain" +} +$MAVEN_HOME_NAME = ([System.Security.Cryptography.MD5]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..f127e26 --- /dev/null +++ b/pom.xml @@ -0,0 +1,77 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.4.0 + + + spring.ai.experiment + mcp + 0.0.1-SNAPSHOT + mcp + Demo project for Spring Boot + + + + + + + + + + + + + + + 21 + 1.0.0-M4 + + + + org.springframework.ai + spring-ai-openai-spring-boot-starter + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + org.springframework.ai + spring-ai-bom + ${spring-ai.version} + pom + import + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + + spring-milestones + Spring Milestones + https://repo.spring.io/milestone + + false + + + + + \ No newline at end of file diff --git a/src/main/java/spring/ai/mcp/McpApplication.java b/src/main/java/spring/ai/mcp/McpApplication.java new file mode 100644 index 0000000..e1c64ed --- /dev/null +++ b/src/main/java/spring/ai/mcp/McpApplication.java @@ -0,0 +1,77 @@ +package spring.ai.mcp; + +import java.time.Duration; +import java.util.List; + +import com.fasterxml.jackson.databind.ObjectMapper; +import spring.ai.mcp.client.McpClient; +import spring.ai.mcp.client.stdio.StdioServerParameters; +import spring.ai.mcp.client.stdio.StdioServerTransport; + +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.boot.CommandLineRunner; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; + +@SpringBootApplication +public class McpApplication { + + public static void main(String[] args) { + SpringApplication.run(McpApplication.class, args); + } + + @Bean + public CommandLineRunner test(ChatClient.Builder chatClientBuilder, List functionCallbacks) { + + return args -> { + + var chatClient = chatClientBuilder.defaultFunctions(functionCallbacks.toArray(new McpFunctionCallback[0])) + .build(); + + var response = chatClient.prompt("What is the content of the test.txt file?").call().content(); + System.out.println(response); + + response = chatClient.prompt( + "Please summarize the content of the test.txt file, covert the result in markdown format and store it into test.md") + .call() + .content(); + System.out.println("Summary:" + response); + }; + } + + @Bean + public List functionCallbacks(McpClient mcpClient) { + + return mcpClient.listTools(null) + .tools() + .stream() + .map(tool -> new McpFunctionCallback(mcpClient, tool)) + .toList(); + } + + @Bean(destroyMethod = "close") + public McpClient clientSession() { + + var stdioParams = StdioServerParameters.builder("npx") + .args("-y", "@modelcontextprotocol/server-filesystem", + "/Users/christiantzolov/Dev/projects/demo/mcp-server/dir") + .build(); + + McpClient mcpClient = null; + try { + mcpClient = new McpClient(new StdioServerTransport(stdioParams, Duration.ofMillis(100)), + Duration.ofSeconds(10), new ObjectMapper()); + + var init = mcpClient.initialize(); + + System.out.println("MCP Initialized: " + init); + + return mcpClient; + } + catch (Exception e) { + throw new RuntimeException(e); + } + } + +} diff --git a/src/main/java/spring/ai/mcp/McpFunctionCallback.java b/src/main/java/spring/ai/mcp/McpFunctionCallback.java new file mode 100644 index 0000000..3236468 --- /dev/null +++ b/src/main/java/spring/ai/mcp/McpFunctionCallback.java @@ -0,0 +1,67 @@ +/* +* Copyright 2024 - 2024 the original author or authors. +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* https://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ +package spring.ai.mcp; + +import java.util.Map; + +import spring.ai.mcp.client.McpClient; +import spring.ai.mcp.spec.McpSchema.CallToolRequest; +import spring.ai.mcp.spec.McpSchema.CallToolResult; +import spring.ai.mcp.spec.McpSchema.Tool; + +import org.springframework.ai.model.ModelOptionsUtils; +import org.springframework.ai.model.function.FunctionCallback; + +/** + * @author Christian Tzolov + * @since 1.0.0 + */ + +public class McpFunctionCallback implements FunctionCallback { + + private final McpClient mcpClient; + + private final Tool tool; + + public McpFunctionCallback(McpClient clientSession, Tool tool) { + this.mcpClient = clientSession; + this.tool = tool; + } + + @Override + public String getName() { + return this.tool.name(); + } + + @Override + public String getDescription() { + return this.tool.description(); + } + + @Override + public String getInputTypeSchema() { + return ModelOptionsUtils.toJsonString(this.tool.inputSchema()); + } + + @Override + public String call(String functionInput) { + Map arguments = ModelOptionsUtils.jsonToMap(functionInput); + CallToolResult response = this.mcpClient.callTool(new CallToolRequest(this.getName(), arguments)); + // Todo handle errors + return ModelOptionsUtils.toJsonString(response.content()); + } + +} diff --git a/src/main/java/spring/ai/mcp/client/McpClient.java b/src/main/java/spring/ai/mcp/client/McpClient.java new file mode 100644 index 0000000..d617b34 --- /dev/null +++ b/src/main/java/spring/ai/mcp/client/McpClient.java @@ -0,0 +1,278 @@ +/* +* Copyright 2024 - 2024 the original author or authors. +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* https://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ +package spring.ai.mcp.client; + +import java.time.Duration; +import java.util.concurrent.CompletableFuture; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import spring.ai.mcp.spec.McpSchema; +import spring.ai.mcp.spec.McpSession; +import spring.ai.mcp.spec.McpTransport; +import spring.ai.mcp.spec.McpSchema.CallToolRequest; +import spring.ai.mcp.spec.McpSchema.CallToolResult; +import spring.ai.mcp.spec.McpSchema.ClientCapabilities; +import spring.ai.mcp.spec.McpSchema.Implementation; +import spring.ai.mcp.spec.McpSchema.InitializeRequest; +import spring.ai.mcp.spec.McpSchema.InitializeResult; +import spring.ai.mcp.spec.McpSchema.ListResourceTemplatesResult; +import spring.ai.mcp.spec.McpSchema.ListResourcesResult; +import spring.ai.mcp.spec.McpSchema.ListToolsResult; +import spring.ai.mcp.spec.McpSchema.PaginatedRequest; +import spring.ai.mcp.spec.McpSchema.ReadResourceRequest; +import spring.ai.mcp.spec.McpSchema.Resource; +import spring.ai.mcp.spec.McpSchema.SubscribeRequest; +import spring.ai.mcp.spec.McpSchema.UnsubscribeRequest; +import spring.ai.mcp.spec.McpSchema.ClientCapabilities.RootCapabilities; + +/** + * The MCP client is the main entry point for interacting with the Model Context Protocol + * (MCP) server. + * + * + * @author Christian Tzolov + * @since 1.0.0 + */ +public class McpClient extends McpSession { + + public McpClient(McpTransport transport) { + this(transport, Duration.ofSeconds(10), new ObjectMapper()); + } + + public McpClient(McpTransport transport, Duration readTimeout, ObjectMapper objectMapper) { + super(transport, readTimeout, objectMapper); + } + + /** + * The initialization phase MUST be the first interaction between client and server. + * During this phase, the client and server: + *
    + *
  • Establish protocol version compatibility
  • + *
  • Exchange and negotiate capabilities
  • + *
  • Share implementation details
  • + *
+ *
+ * The client MUST initiate this phase by sending an initialize request containing: + *
    + *
  • The protocol version the client supports
  • + *
  • The client's capabilities
  • + *
  • Client implementation information
  • + *
+ * + * The server MUST respond with its own capabilities and information: + * {@link McpSchema.ServerCapabilities}.
+ * After successful initialization, the client MUST send an initialized notification + * to indicate it is ready to begin normal operations. + * + *
+ * + * Initialization + * Spec + * @return the initialize result. + */ + public InitializeResult initialize() { + + InitializeRequest initializeRequest = new InitializeRequest(// @formatter:off + McpSchema.LATEST_PROTOCOL_VERSION, + new ClientCapabilities(null, new RootCapabilities(true), null), + new Implementation("mcp-java-client", "0.0.1")); // @formatter:on + + CompletableFuture initializeResultFuture = this.sendRequest("initialize", initializeRequest, + new TypeReference() { + }); + + try { + InitializeResult initializeResult = initializeResultFuture.get(); + + if (initializeRequest.protocolVersion() != McpSchema.LATEST_PROTOCOL_VERSION) { + throw new McpError( + "Unsupported protocol version from the server: " + initializeRequest.protocolVersion()); + } + + this.sendNotification("notifications/initialized", null); + + return initializeResult; + } + catch (Exception e) { + throw new McpError(e); + } + } + + /** + * Send a roots/list_changed notification. + */ + public void sendRootsListChanged() { + this.sendNotification("notifications/roots/list_changed"); + } + + /** + * Send a synchronous ping request. + */ + public void ping() { + + try { + this.sendRequest("ping", null, new TypeReference() { + }).get(); + } + catch (Exception e) { + throw new McpError(e); + } + } + + // -------------------------- + // Tools + // -------------------------- + /** + * Send a tools/call request. + * @param callToolRequest the call tool request. + * @return the call tool result. + */ + public CallToolResult callTool(CallToolRequest callToolRequest) { + try { + return this.sendRequest("tools/call", callToolRequest, new TypeReference() { + }).get(); + } + catch (Exception e) { + throw new McpError(e); + } + } + + /** + * Send a tools/list request. + * @return the list of tools result. + */ + public ListToolsResult listTools(String cursor) { + try { + return this.sendRequest("tools/list", new PaginatedRequest(cursor), new TypeReference() { + }).get(); + } + catch (Exception e) { + throw new McpError(e); + } + } + + // -------------------------- + // Resources + // -------------------------- + + /** + * Send a resources/list request. + * @param cursor the cursor + * @return the list of resources result. + */ + public ListResourcesResult listResources(String cursor) { + try { + return this + .sendRequest("resources/list", new PaginatedRequest(cursor), new TypeReference() { + }) + .get(); + } + catch (Exception e) { + throw new McpError(e); + } + } + + /** + * Send a resources/read request. + * @param resource the resource to read + * @return the resource content. + */ + public McpSchema.ReadResourceResult readResource(Resource resource) { + return this.readResource(new ReadResourceRequest(resource.uri())); + } + + /** + * Send a resources/read request. + * @param readResourceRequest the read resource request. + * @return the resource content. + */ + public McpSchema.ReadResourceResult readResource(ReadResourceRequest readResourceRequest) { + try { + return this + .sendRequest("resources/read", readResourceRequest, new TypeReference() { + }) + .get(); + } + catch (Exception e) { + e.printStackTrace(); + throw new McpError(e); + } + } + + /** + * Resource templates allow servers to expose parameterized resources using URI + * templates. Arguments may be auto-completed through the completion API. + * + * Request a list of resource templates the server has. + * @param cursor the cursor + * @return the list of resource templates result. + */ + public ListResourceTemplatesResult listResourceTemplates(String cursor) { + try { + return this.sendRequest("resources/templates/list", new PaginatedRequest(cursor), + new TypeReference() { + }) + .get(); + } + catch (Exception e) { + throw new McpError(e); + } + } + + /** + * List Changed Notification. When the list of available resources changes, servers + * that declared the listChanged capability SHOULD send a notification: + */ + public void sendResourcesListChanged() { + this.sendNotification("notifications/resources/list_changed"); + } + + /** + * Subscriptions. The protocol supports optional subscriptions to resource changes. + * Clients can subscribe to specific resources and receive notifications when they + * change. + * + * Send a resources/subscribe request. + * @param subscribeRequest the subscribe request contains the uri of the resource to + * subscribe to. + */ + public void subscribeResource(SubscribeRequest subscribeRequest) { + try { + this.sendRequest("resources/subscribe", subscribeRequest, new TypeReference() { + }).get(); + } + catch (Exception e) { + throw new McpError(e); + } + } + + /** + * Send a resources/unsubscribe request. + * @param unsubscribeRequest the unsubscribe request contains the uri of the resource + * to unsubscribe from. + */ + public void unsubscribeResource(UnsubscribeRequest unsubscribeRequest) { + try { + this.sendRequest("resources/unsubscribe", unsubscribeRequest, new TypeReference() { + }).get(); + } + catch (Exception e) { + throw new McpError(e); + } + } +} diff --git a/src/main/java/spring/ai/mcp/client/stdio/StdioServerParameters.java b/src/main/java/spring/ai/mcp/client/stdio/StdioServerParameters.java new file mode 100644 index 0000000..d9617f2 --- /dev/null +++ b/src/main/java/spring/ai/mcp/client/stdio/StdioServerParameters.java @@ -0,0 +1,130 @@ +package spring.ai.mcp.client.stdio; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; + +/** + * Server parameters for stdio client. + */ +public class StdioServerParameters { + + // Environment variables to inherit by default + private static final List DEFAULT_INHERITED_ENV_VARS = System.getProperty("os.name") + .toLowerCase() + .contains("win") + ? Arrays.asList("APPDATA", "HOMEDRIVE", "HOMEPATH", "LOCALAPPDATA", "PATH", + "PROCESSOR_ARCHITECTURE", "SYSTEMDRIVE", "SYSTEMROOT", "TEMP", "USERNAME", "USERPROFILE") + : Arrays.asList("HOME", "LOGNAME", "PATH", "SHELL", "TERM", "USER"); + + @JsonProperty("command") + private String command; + + @JsonProperty("args") + private List args = new ArrayList<>(); + + @JsonProperty("env") + private Map env; + + private StdioServerParameters(String command, List args, Map env) { + Assert.hasText(command, "Command must not be empty"); + Assert.notNull(args, "Arguments must not be null"); + + this.command = command; + this.args = args; + this.env = new HashMap<>(getDefaultEnvironment()); + if (!CollectionUtils.isEmpty(env)) { + this.env.putAll(env); + } + } + + public String getCommand() { + return this.command; + } + + public List getArgs() { + return this.args; + } + + public Map getEnv() { + return this.env; + } + + public static Builder builder(String command) { + return new Builder(command); + } + + public static class Builder { + + private String command; + + private List args = new ArrayList<>(); + + private Map env = new HashMap<>(); + + public Builder(String command) { + Assert.hasText(command, "Command must not be empty"); + this.command = command; + } + + public Builder args(String... args) { + Assert.notNull(args, "Arguments must not be null"); + this.args = Arrays.asList(args); + return this; + } + + public Builder args(List args) { + Assert.notNull(args, "Arguments must not be null"); + this.args = new ArrayList<>(args); + return this; + } + + public Builder arg(String arg) { + Assert.hasText(arg, "Argument must not be empty"); + this.args.add(arg); + return this; + } + + public Builder env(Map env) { + if (!CollectionUtils.isEmpty(env)) { + this.env.putAll(env); + } + return this; + } + + public Builder addEnvVar(String key, String value) { + Assert.hasText(key, "Environment variable key must not be empty"); + Assert.notNull(value, "Environment variable value must not be null"); + this.env.put(key, value); + return this; + } + + public StdioServerParameters build() { + return new StdioServerParameters(command, args, env); + } + + } + + /** + * Returns a default environment object including only environment variables + * deemed safe to inherit. + */ + private static Map getDefaultEnvironment() { + return System.getenv() + .entrySet() + .stream() + .filter(entry -> DEFAULT_INHERITED_ENV_VARS.contains(entry.getKey())) + .filter(entry -> entry.getValue() != null) + .filter(entry -> !entry.getValue().startsWith("()")) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + } + +} \ No newline at end of file diff --git a/src/main/java/spring/ai/mcp/client/stdio/StdioServerTransport.java b/src/main/java/spring/ai/mcp/client/stdio/StdioServerTransport.java new file mode 100644 index 0000000..a522cd0 --- /dev/null +++ b/src/main/java/spring/ai/mcp/client/stdio/StdioServerTransport.java @@ -0,0 +1,240 @@ +package spring.ai.mcp.client.stdio; + +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.IOException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import spring.ai.mcp.spec.DefaultMcpTransport; +import spring.ai.mcp.spec.McpSchema.JSONRPCMessage; +import spring.ai.mcp.spec.McpSchema.JSONRPCNotification; +import spring.ai.mcp.spec.McpSchema.JSONRPCRequest; +import spring.ai.mcp.spec.McpSchema.JSONRPCResponse; + +import org.springframework.ai.model.ModelOptionsUtils; +import org.springframework.util.Assert; + +/** + * Stdio client for communicating with a server process. + */ +public class StdioServerTransport extends DefaultMcpTransport { + + private Process process; + + private BufferedReader processErrorReader; + + private BufferedReader processReader; + + private BufferedWriter processWriter; + + private ExecutorService executorService; + + private volatile boolean isRunning; + + private final StdioServerParameters params; + + public StdioServerTransport(StdioServerParameters params) { + this(params, Duration.ofMillis(100)); + } + + public StdioServerTransport(StdioServerParameters params, Duration writeTimeout) { + super(writeTimeout); + + Assert.notNull(params, "Server parameters must not be null"); + + this.params = params; + + // Start threads + this.executorService = Executors.newFixedThreadPool(3); + } + + @Override + public void start() { + // Prepare command and environment + List fullCommand = new ArrayList<>(); + fullCommand.add(params.getCommand()); + fullCommand.addAll(params.getArgs()); + + ProcessBuilder processBuilder = new ProcessBuilder(fullCommand); + processBuilder.environment().putAll(params.getEnv()); + + // Start the process + try { + this.process = processBuilder.start(); + } + catch (IOException e) { + throw new RuntimeException("Failed to start process with command: " + fullCommand, e); + } + + // Validate process streams + if (this.process.getInputStream() == null || process.getOutputStream() == null) { + this.process.destroy(); + throw new RuntimeException("Process input or output stream is null"); + } + + // Initialize readers and writers + this.processErrorReader = this.process.errorReader(); + this.processReader = this.process.inputReader(); + this.processWriter = this.process.outputWriter(); + + // Start threads + this.isRunning = true; + startReaderThread(); + startWriterThread(); + startErrorReaderThread(); + + } + + public void awaitForExit() { + try { + this.process.waitFor(); + } + catch (InterruptedException e) { + throw new RuntimeException("Process interrupted", e); + } + } + + private void startErrorReaderThread() { + + this.executorService.execute(() -> { + try { + String line; + while (isRunning && processErrorReader != null && (line = processErrorReader.readLine()) != null) { + try { + System.out.println("Received error line: " + line); + this.getErrorReadQueue().offer(line, 100, TimeUnit.MILLISECONDS); + } + catch (Exception e) { + throw new RuntimeException(e); + } + } + } + catch (IOException e) { + if (this.isRunning) { + throw new RuntimeException(e); + } + } + finally { + this.isRunning = false; + } + }); + } + + private JSONRPCMessage deserializeJsonRpcMessage(String jsonText) throws IOException { + + var map = ModelOptionsUtils.jsonToMap(jsonText); + + // Determine message type based on specific JSON structure + if (map.containsKey("method") && map.containsKey("id")) { + return ModelOptionsUtils.OBJECT_MAPPER.convertValue(map, JSONRPCRequest.class); + } + else if (map.containsKey("method") && !map.containsKey("id")) { + return ModelOptionsUtils.OBJECT_MAPPER.convertValue(map, JSONRPCNotification.class); + } + else if (map.containsKey("result") || map.containsKey("error")) { + return ModelOptionsUtils.OBJECT_MAPPER.convertValue(map, JSONRPCResponse.class); + } + + throw new IllegalArgumentException("Cannot deserialize JSONRPCMessage: " + jsonText); + } + + private void startReaderThread() { + this.executorService.execute(() -> { + try { + String line; + while (this.isRunning && this.processReader != null && (line = this.processReader.readLine()) != null) { + try { + JSONRPCMessage message = deserializeJsonRpcMessage(line); + this.getDataReadQueue().offer(message, 100, TimeUnit.MILLISECONDS); + } + catch (Exception e) { + throw new RuntimeException(e); + } + } + } + catch (IOException e) { + if (isRunning) { + throw new RuntimeException(e); + } + } + finally { + isRunning = false; + } + }); + } + + private void startWriterThread() { + this.executorService.execute(() -> { + try { + while (this.isRunning && this.processWriter != null && !Thread.currentThread().isInterrupted()) { + try { + JSONRPCMessage message = this.getDataWriteQueue().poll(100, TimeUnit.MILLISECONDS); + if (message != null) { + this.processWriter.write(ModelOptionsUtils.toJsonString(message)); + this.processWriter.newLine(); + this.processWriter.flush(); + } + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + } + } + catch (IOException e) { + throw new RuntimeException(e); + } + finally { + this.isRunning = false; + } + }); + } + + public void stop() { + + this.isRunning = false; + + // Interrupt threads + this.executorService.shutdownNow(); + + // Destroy process + if (this.process != null) { + this.process.destroyForcibly(); + } + } + + public boolean isRunning() { + return this.isRunning; + } + + @Override + public void close() { + stop(); + + super.close(); // Do we need this? + + // Close resources + if (this.processReader != null) { + try { + this.processReader.close(); + } + catch (IOException e) { + e.printStackTrace(); + } + } + if (this.processWriter != null) { + try { + this.processWriter.close(); + } + catch (IOException e) { + e.printStackTrace(); + } + } + } + +} \ No newline at end of file diff --git a/src/main/java/spring/ai/mcp/client/util/McpServerParameterParser.java b/src/main/java/spring/ai/mcp/client/util/McpServerParameterParser.java new file mode 100644 index 0000000..0dc61b5 --- /dev/null +++ b/src/main/java/spring/ai/mcp/client/util/McpServerParameterParser.java @@ -0,0 +1,87 @@ +/* +* Copyright 2024 - 2024 the original author or authors. +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* https://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ +package spring.ai.mcp.client.util; + +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.ObjectMapper; +import spring.ai.mcp.client.util.McpServerParameterParser.McpServers.McpServer; + +/** + * @author Christian Tzolov + * @since 1.0.0 + */ + +public class McpServerParameterParser { + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + public record McpServers(// @formatter:off + @JsonProperty("mcpServers") Map mcpServers) { + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + public record McpServer( + @JsonProperty("command") String command, + @JsonProperty("args") String[] args, + @JsonProperty("env") Map env) { + } + } // @formatter:on + + public static void main(String[] args) throws Exception { + ObjectMapper objectMapper = new ObjectMapper(); + + // Assuming the JSON is in a file named "servers.json" + String jsonInput = """ + { + "mcpServers": { + "filesystem": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/allowed/files"] + }, + "git": { + "command": "uvx", + "args": ["mcp-server-git", "--repository", "path/to/git/repo"] + }, + "github": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-github"], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "" + } + }, + "postgres": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-postgres", "postgresql://localhost/mydb"] + } + } + } + """; + + // Deserialize JSON into McpServers + McpServers servers = objectMapper.readValue(jsonInput, McpServers.class); + + // Access individual servers + for (Map.Entry entry : servers.mcpServers().entrySet()) { + System.out.println("Key: " + entry.getKey()); + System.out.println("Command: " + entry.getValue().command()); + System.out.println("Arguments: " + String.join(", ", entry.getValue().args())); + System.out.println("Environment Variables: " + entry.getValue().env()); + System.out.println("-----------"); + } + } + +} diff --git a/src/main/java/spring/ai/mcp/spec/DefaultMcpTransport.java b/src/main/java/spring/ai/mcp/spec/DefaultMcpTransport.java new file mode 100644 index 0000000..c2dd107 --- /dev/null +++ b/src/main/java/spring/ai/mcp/spec/DefaultMcpTransport.java @@ -0,0 +1,131 @@ +/* +* Copyright 2024 - 2024 the original author or authors. +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* https://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ +package spring.ai.mcp.spec; + +import java.time.Duration; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + +import spring.ai.mcp.spec.McpSchema.JSONRPCMessage; + +/** + * @author Christian Tzolov + * @since 1.0.0 + */ +public class DefaultMcpTransport implements McpTransport { + + private final BlockingQueue errorReadQueue; + + private final BlockingQueue dataReadQueue; + + private final BlockingQueue dataWriteQueue; + + private final ExecutorService executorService; + + private final Duration writeTimeout; + + private Consumer messageReader = message -> System.out.println("Received message: " + message); + + private Consumer errorReader = error -> System.err.println("Received error: " + error); + + public DefaultMcpTransport(Duration readTimeout) { + + this.errorReadQueue = new LinkedBlockingQueue<>(); + this.dataReadQueue = new LinkedBlockingQueue<>(); + this.dataWriteQueue = new LinkedBlockingQueue<>(); + this.writeTimeout = readTimeout; + + this.executorService = Executors.newFixedThreadPool(2); + this.handleIncomingMessages(); + } + + private void handleIncomingMessages() { + this.executorService.execute(() -> { + while (!Thread.currentThread().isInterrupted()) { + try { + JSONRPCMessage message = this.dataReadQueue.take(); + this.messageReader.accept(message); + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + }); + } + + private void handleIncomingErrors() { + this.executorService.execute(() -> { + while (!Thread.currentThread().isInterrupted()) { + try { + String error = this.errorReadQueue.take(); + this.errorReader.accept(error); + System.err.println(error); + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + }); + } + + protected BlockingQueue getDataReadQueue() { + return dataReadQueue; + } + + protected BlockingQueue getDataWriteQueue() { + return dataWriteQueue; + } + + protected BlockingQueue getErrorReadQueue() { + return errorReadQueue; + } + + public void setMessageHandler(Consumer messageReader) { + this.messageReader = messageReader; + } + + public void setErrorHandler(Consumer errorReader) { + this.errorReader = errorReader; + } + + // Start processing incoming messages + public void start() { + this.handleIncomingErrors(); + this.handleIncomingMessages(); + } + + // Close the transport + @Override + public void close() { + // this.onClose(); + this.executorService.shutdownNow(); + } + + public void sendMessage(JSONRPCMessage message) { + // Use offer with timeout to prevent blocking indefinitely + try { + if (!this.dataWriteQueue.offer(message, writeTimeout.toMillis(), TimeUnit.MILLISECONDS)) { + throw new RuntimeException("Failed to enqueue message"); + } + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } +} diff --git a/src/main/java/spring/ai/mcp/spec/McpSchema.java b/src/main/java/spring/ai/mcp/spec/McpSchema.java new file mode 100644 index 0000000..b719772 --- /dev/null +++ b/src/main/java/spring/ai/mcp/spec/McpSchema.java @@ -0,0 +1,522 @@ +/* +* Copyright 2024 - 2024 the original author or authors. +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* https://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ +package spring.ai.mcp.spec; + +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.annotation.JsonTypeInfo.As; + +/** + * Based on the JSON-RPC 2.0 + * specification and the Model + * Context Protocol Schema. + * + * @author Christian Tzolov + * @since 1.0.0 + */ +public class McpSchema { + + public static final String LATEST_PROTOCOL_VERSION = "2024-11-05"; + + public static final String JSONRPC_VERSION = "2.0"; + + // --------------------------- + // JSON-RPC Error Codes + // --------------------------- + public final class ErrorCodes { + + public static final int PARSE_ERROR = -32700; + + public static final int INVALID_REQUEST = -32600; + + public static final int METHOD_NOT_FOUND = -32601; + + public static final int INVALID_PARAMS = -32602; + + public static final int INTERNAL_ERROR = -32603; + + } + + public sealed interface Request permits InitializeRequest, CallToolRequest, CreateMessageRequest, CompleteRequest { + + } + + // --------------------------- + // JSON-RPC Message Types + // --------------------------- + public sealed interface JSONRPCMessage permits JSONRPCRequest, JSONRPCNotification, JSONRPCResponse { + + String jsonrpc(); + + } + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + public record JSONRPCRequest( // @formatter:off + @JsonProperty("jsonrpc") String jsonrpc, + @JsonProperty("method") String method, + @JsonProperty("id") Object id, + @JsonProperty("params") Object params) implements JSONRPCMessage { + } // @formatter:on + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + public record JSONRPCNotification( // @formatter:off + @JsonProperty("jsonrpc") String jsonrpc, + @JsonProperty("method") String method, + @JsonProperty("params") Map params) implements JSONRPCMessage { + } // @formatter:on + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + public record JSONRPCResponse( // @formatter:off + @JsonProperty("jsonrpc") String jsonrpc, + @JsonProperty("id") Object id, + @JsonProperty("result") Object result, + @JsonProperty("error") JSONRPCError error) implements JSONRPCMessage { + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + public record JSONRPCError( + @JsonProperty("code") int code, + @JsonProperty("message") String message, + @JsonProperty("data") Object data) { + } + }// @formatter:on + + // --------------------------- + // Initialization + // --------------------------- + @JsonInclude(JsonInclude.Include.NON_ABSENT) + public record InitializeRequest( // @formatter:off + @JsonProperty("protocolVersion") String protocolVersion, + @JsonProperty("capabilities") ClientCapabilities capabilities, + @JsonProperty("clientInfo") Implementation clientInfo) implements Request { + } // @formatter:on + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + public record InitializeResult( // @formatter:off + @JsonProperty("protocolVersion") String protocolVersion, + @JsonProperty("capabilities") ServerCapabilities capabilities, + @JsonProperty("serverInfo") Implementation serverInfo, + @JsonProperty("instructions") String instructions) { + } // @formatter:on + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + public record ClientCapabilities( // @formatter:off + @JsonProperty("experimental") Map experimental, + @JsonProperty("roots") RootCapabilities roots, + @JsonProperty("sampling") Object sampling) { + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + public record RootCapabilities( + @JsonProperty("listChanged") Boolean listChanged) { + } + }// @formatter:on + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + public record ServerCapabilities( // @formatter:off + @JsonProperty("experimental") Map experimental, + @JsonProperty("logging") Object logging, + @JsonProperty("prompts") PromptCapabilities prompts, + @JsonProperty("resources") ResourceCapabilities resources, + @JsonProperty("tools") ToolCapabilities tools) { + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + public record PromptCapabilities( + @JsonProperty("listChanged") Boolean listChanged) { + } + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + public record ResourceCapabilities( + @JsonProperty("subscribe") Boolean subscribe, + @JsonProperty("listChanged") Boolean listChanged) { + } + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + public record ToolCapabilities( + @JsonProperty("listChanged") Boolean listChanged) { + } + } // @formatter:on + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + public record Implementation(// @formatter:off + @JsonProperty("name") String name, + @JsonProperty("version") String version) { + } // @formatter:on + + // Existing Enums and Base Types (from previous implementation) + public enum Role { + + USER, ASSISTANT + + } + + public enum LoggingLevel { + + DEBUG, INFO, NOTICE, WARNING, ERROR, CRITICAL, ALERT, EMERGENCY + + } + + // --------------------------- + // Resource Interfaces + // --------------------------- + /** + * Base for objects that include optional annotations for the client. The client can + * use annotations to inform how objects are used or displayed + */ + public interface Annotated { + + Annotations annotations(); + + } + + /** + * @param audience Describes who the intended customer of this object or data is. It + * can include multiple entries to indicate content useful for multiple audiences + * (e.g., `["user", "assistant"]`). + * @param priority Describes how important this data is for operating the server. A + * value of 1 means "most important," and indicates that the data is effectively + * required, while 0 means "least important," and indicates that the data is entirely + * optional. It is a number between 0 and 1. + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + public record Annotations( // @formatter:off + @JsonProperty("audience") List audience, + @JsonProperty("priority") Double priority) { + } // @formatter:on + + /** + * A known resource that the server is capable of reading. + * + * @param uri the URI of the resource. + * @param name A human-readable name for this resource. This can be used by clients to + * populate UI elements. + * @param description A description of what this resource represents. This can be used + * by clients to improve the LLM's understanding of available resources. It can be + * thought of like a "hint" to the model. + * @param mimeType The MIME type of this resource, if known. + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + public record Resource( // @formatter:off + @JsonProperty("uri") String uri, + @JsonProperty("name") String name, + @JsonProperty("description") String description, + @JsonProperty("mimeType") String mimeType, + @JsonProperty("annotations") Annotations annotations) implements Annotated { + } // @formatter:on + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + public record ResourceTemplate( // @formatter:off + @JsonProperty("uriTemplate") String uriTemplate, + @JsonProperty("name") String name, + @JsonProperty("description") String description, + @JsonProperty("mimeType") String mimeType, + @JsonProperty("annotations") Annotations annotations) implements Annotated { + } // @formatter:on + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + public record ListResourcesResult( // @formatter:off + @JsonProperty("resources") List resources, + @JsonProperty("nextCursor") String nextCursor) { + } // @formatter:on + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + public record ListResourceTemplatesResult( // @formatter:off + @JsonProperty("resourceTemplates") List resourceTemplates, + @JsonProperty("nextCursor") String nextCursor) { + } // @formatter:on + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + public record ReadResourceRequest( // @formatter:off + @JsonProperty("uri") String uri){ + } // @formatter:on + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + public record ReadResourceResult( // @formatter:off + @JsonProperty("contents") List contents){ + } // @formatter:on + + /** + * Sent from the client to request resources/updated notifications from the server + * whenever a particular resource changes. + * + * @param uri the URI of the resource to subscribe to. The URI can use any protocol; + * it is up to the server how to interpret it. + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + public record SubscribeRequest( // @formatter:off + @JsonProperty("uri") String uri){ + } // @formatter:on + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + public record UnsubscribeRequest( // @formatter:off + @JsonProperty("uri") String uri){ + } // @formatter:on + + /** + * The contents of a specific resource or sub-resource. + */ + @JsonTypeInfo(use = JsonTypeInfo.Id.DEDUCTION, include = As.PROPERTY) + @JsonSubTypes({ @JsonSubTypes.Type(value = TextResourceContents.class, name = "text"), + @JsonSubTypes.Type(value = BlobResourceContents.class, name = "blob") }) + public sealed interface ResourceContents permits TextResourceContents, BlobResourceContents { + + /** + * The URI of this resource. + * @return the URI of this resource. + */ + String uri(); + + /** + * The MIME type of this resource. + * @return the MIME type of this resource. + */ + String mimeType(); + + } + + /** + * Text contents of a resource. + * + * @param uri the URI of this resource. + * @param mimeType the MIME type of this resource. + * @param text the text of the resource. This must only be set if the resource can + * actually be represented as text (not binary data). + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + public record TextResourceContents( // @formatter:off + @JsonProperty("uri") String uri, + @JsonProperty("mimeType") String mimeType, + @JsonProperty("text") String text) implements ResourceContents { + } // @formatter:on + + /** + * Binary contents of a resource. + * + * @param uri the URI of this resource. + * @param mimeType the MIME type of this resource. + * @param blob a base64-encoded string representing the binary data of the resource. + * This must only be set if the resource can actually be represented as binary data + * (not text). + */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + public record BlobResourceContents( // @formatter:off + @JsonProperty("uri") String uri, + @JsonProperty("mimeType") String mimeType, + @JsonProperty("blob") String blob) implements ResourceContents { + } // @formatter:on + + // --------------------------- + // Prompt Interfaces + // --------------------------- + public record Prompt(String name, String description, List arguments) { + } + + public record PromptArgument(String name, String description, Boolean required) { + } + + public record PromptMessage(Role role, Content content) { + } + + // --------------------------- + // Tool Interfaces + // --------------------------- + @JsonInclude(JsonInclude.Include.NON_ABSENT) + public record ListToolsResult( // @formatter:off + @JsonProperty("tools") List tools, + @JsonProperty("nextCursor") String nextCursor) { + }// @formatter:on + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + public record Tool( // @formatter:off + @JsonProperty("name") String name, + @JsonProperty("description") String description, + @JsonProperty("inputSchema") Map inputSchema) { + } // @formatter:on + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + public record CallToolRequest(// @formatter:off + @JsonProperty("name") String name, + @JsonProperty("arguments") Map arguments) implements Request { + }// @formatter:off + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + public record CallToolResult( // @formatter:off + @JsonProperty("content") List content, + @JsonProperty("isError") Boolean isError) { + } // @formatter:on + + // --------------------------- + // Sampling Interfaces + // --------------------------- + @JsonInclude(JsonInclude.Include.NON_ABSENT) + public record SamplingMessage(// @formatter:off + @JsonProperty("role") Role role, + @JsonProperty("content") Content content) { + } // @formatter:on + + // Sampling and Message Creation + @JsonInclude(JsonInclude.Include.NON_ABSENT) + public record CreateMessageRequest(List messages, ModelPreferences modelPreferences, + String systemPrompt, ContextInclusionStrategy includeContext, Double temperature, int maxTokens, + List stopSequences, Map metadata) implements Request { + + public enum ContextInclusionStrategy { + + NONE, THIS_SERVER, ALL_SERVERS + + } + } + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + public record CreateMessageResult(Role role, Content content, String model, StopReason stopReason) { + public enum StopReason { + + END_TURN, STOP_SEQUENCE, MAX_TOKENS + + } + } + + // --------------------------- + // Model Preferences + // --------------------------- + @JsonInclude(JsonInclude.Include.NON_ABSENT) + public record ModelPreferences(List hints, Double costPriority, Double speedPriority, + Double intelligencePriority) { + } + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + public record ModelHint(String name) { + } + + // --------------------------- + // Pagination Interfaces + // --------------------------- + @JsonInclude(JsonInclude.Include.NON_ABSENT) + public record PaginatedRequest(@JsonProperty("cursor") String cursor) { + } + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + public record PaginatedResult(@JsonProperty("nextCursor") String nextCursor) { + } + + // --------------------------- + // Progress and Logging + // --------------------------- + public record ProgressNotification(String progressToken, double progress, Double total) { + } + + public record LoggingMessageNotification(LoggingLevel level, String logger, Object data) { + } + + // --------------------------- + // Autocomplete + // --------------------------- + public record CompleteRequest(PromptOrResourceReference ref, CompleteArgument argument) implements Request { + public sealed interface PromptOrResourceReference permits PromptReference, ResourceReference { + + String type(); + + } + + public record PromptReference(String type, String name) implements PromptOrResourceReference { + } + + public record ResourceReference(String type, String uri) implements PromptOrResourceReference { + } + + public record CompleteArgument(String name, String value) { + } + } + + public record CompleteResult(CompleteCompletion completion) { + public record CompleteCompletion(List values, Integer total, Boolean hasMore) { + } + } + + // --------------------------- + // Content Types + // --------------------------- + @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "type") + @JsonSubTypes({ @JsonSubTypes.Type(value = TextContent.class, name = "text"), + @JsonSubTypes.Type(value = ImageContent.class, name = "image"), + @JsonSubTypes.Type(value = EmbeddedResource.class, name = "resource") }) + public sealed interface Content permits TextContent, ImageContent, EmbeddedResource { + + String type(); + + } + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + public record TextContent( // @formatter:off + @JsonProperty("audience") List audience, + @JsonProperty("priority") Double priority, + @JsonProperty("type") String type, + @JsonProperty("text") String text) implements Content { // @formatter:on + + public TextContent { + type = "text"; + } + + public String type() { + return type; + } + } + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + public record ImageContent( // @formatter:off + @JsonProperty("audience") List audience, + @JsonProperty("priority") Double priority, + @JsonProperty("type") String type, + @JsonProperty("data") String data, + @JsonProperty("mimeType") String mimeType) implements Content { // @formatter:on + + public ImageContent { + type = "image"; + } + + public String type() { + return type; + } + } + + @JsonInclude(JsonInclude.Include.NON_ABSENT) + public record EmbeddedResource( // @formatter:off + @JsonProperty("audience") List audience, + @JsonProperty("priority") Double priority, + @JsonProperty("type") String type, + @JsonProperty("resource") ResourceContents resource) implements Content { // @formatter:on + + public EmbeddedResource { + type = "resource"; + } + + public String type() { + return type; + } + } + + // Roots + @JsonInclude(JsonInclude.Include.NON_ABSENT) + public record Root( // @formatter:off + @JsonProperty("uri") String uri, + @JsonProperty("name") String name) { + } // @formatter:on + +} \ No newline at end of file diff --git a/src/main/java/spring/ai/mcp/spec/McpSession.java b/src/main/java/spring/ai/mcp/spec/McpSession.java new file mode 100644 index 0000000..9cfbe11 --- /dev/null +++ b/src/main/java/spring/ai/mcp/spec/McpSession.java @@ -0,0 +1,160 @@ +/* +* Copyright 2024 - 2024 the original author or authors. +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* https://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ +package spring.ai.mcp.spec; + +import java.time.Duration; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import spring.ai.mcp.spec.McpSchema.JSONRPCNotification; +import spring.ai.mcp.spec.McpSchema.JSONRPCRequest; +import spring.ai.mcp.spec.McpSchema.JSONRPCResponse; + +import org.springframework.util.Assert; + +public class McpSession implements AutoCloseable { + + final ConcurrentHashMap> waitingForResponseStreams; + + private final Duration readTimeout; + + private final ObjectMapper objectMapper; + + private final McpTransport transport; + + public McpSession(McpTransport transport, Duration readTimeout, ObjectMapper objectMapper) { + + Assert.notNull(transport, "Transport must not be null"); + Assert.notNull(readTimeout, "Read timeout must not be null"); + Assert.notNull(objectMapper, "Object mapper must not be null"); + + this.waitingForResponseStreams = new ConcurrentHashMap<>(); + + this.readTimeout = readTimeout; + this.objectMapper = objectMapper; + + this.transport = transport; + + this.transport.setMessageHandler(message -> { + if (message instanceof JSONRPCResponse jsonRpcResponse) { + CompletableFuture responseFuture = this.waitingForResponseStreams + .remove(jsonRpcResponse.id()); + if (responseFuture != null) { + responseFuture.complete(jsonRpcResponse); + } + } + else if (message instanceof JSONRPCRequest jsonRpcRequest) { + handleRequest(new RequestResponder<>(jsonRpcRequest.id(), jsonRpcRequest)); + } + else if (message instanceof JSONRPCNotification jsonRpcNotification) { + handleNotification(jsonRpcNotification); + } + }); + + this.transport.setErrorHandler(error -> { + System.out.println("Received error: " + error); + }); + + this.transport.start(); + } + + public CompletableFuture sendRequest(String method, Object requestParams, TypeReference typeRef) { + + String requestId = UUID.randomUUID().toString(); + CompletableFuture responseFuture = new CompletableFuture<>(); + this.waitingForResponseStreams.put(requestId, responseFuture); + + JSONRPCRequest jsonrpcRequest = new JSONRPCRequest(McpSchema.JSONRPC_VERSION, method, requestId, requestParams); + + try { + this.transport.sendMessage(jsonrpcRequest); + } + catch (Exception e) { + return CompletableFuture.failedFuture(new McpError(e)); + } + + return responseFuture.orTimeout(this.readTimeout.toMillis(), TimeUnit.MILLISECONDS) + .thenApply(jsonRpcResponse -> { + if (jsonRpcResponse.error() != null) { + throw new McpError(jsonRpcResponse.error()); + } + + return this.objectMapper.convertValue(jsonRpcResponse.result(), typeRef); + }); + } + + public CompletableFuture sendNotification(String method) { + return sendNotification(method, null); + } + + public CompletableFuture sendNotification(String method, Map params) { + JSONRPCNotification jsonrpcNotification = new JSONRPCNotification(McpSchema.JSONRPC_VERSION, method, params); + try { + this.transport.sendMessage(jsonrpcNotification); + } + catch (Exception e) { + return CompletableFuture.failedFuture(new McpError(e)); + } + return CompletableFuture.completedFuture(null); + } + + protected void handleRequest(RequestResponder responder) { + // Override to handle requests + System.out.println("Handling request: " + responder); + } + + protected void handleNotification(JSONRPCNotification notification) { + // Override to handle notifications + System.out.println("Handling notification: " + notification); + } + + @Override + public void close() { + this.transport.close(); + } + + public static class RequestResponder { + + private final Object requestId; + + private final R request; + + public RequestResponder(Object requestId, R request) { + this.requestId = requestId; + this.request = request; + } + + public CompletableFuture respond(Object response) { + // Send response + return CompletableFuture.completedFuture(null); + } + + } + + public static class McpError extends RuntimeException { + + public McpError(Object error) { + super(error.toString()); + } + + } + +} diff --git a/src/main/java/spring/ai/mcp/spec/McpTransport.java b/src/main/java/spring/ai/mcp/spec/McpTransport.java new file mode 100644 index 0000000..819ef5c --- /dev/null +++ b/src/main/java/spring/ai/mcp/spec/McpTransport.java @@ -0,0 +1,38 @@ +/* +* Copyright 2024 - 2024 the original author or authors. +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* https://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ +package spring.ai.mcp.spec; + +import java.util.function.Consumer; + +import spring.ai.mcp.spec.McpSchema.JSONRPCMessage; + +/** + * @author Christian Tzolov + * @since 1.0.0 + */ + +public interface McpTransport extends AutoCloseable{ + + void start(); + + void close(); + + void setMessageHandler(Consumer messageReader); + + void setErrorHandler(Consumer errorReader); + + void sendMessage(JSONRPCMessage message); +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties new file mode 100644 index 0000000..863de4f --- /dev/null +++ b/src/main/resources/application.properties @@ -0,0 +1,4 @@ +spring.application.name=mcp +spring.main.web-application-type=none + +spring.ai.openai.api-key=${OPENAI_API_KEY} \ No newline at end of file diff --git a/src/test/java/spring/ai/mcp/ClientSessionTests.java b/src/test/java/spring/ai/mcp/ClientSessionTests.java new file mode 100644 index 0000000..b019db5 --- /dev/null +++ b/src/test/java/spring/ai/mcp/ClientSessionTests.java @@ -0,0 +1,67 @@ +/* +* Copyright 2024 - 2024 the original author or authors. +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* https://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ +package spring.ai.mcp; + +import java.time.Duration; + +import com.fasterxml.jackson.databind.ObjectMapper; +import spring.ai.mcp.client.McpClient; +import spring.ai.mcp.client.stdio.StdioServerParameters; +import spring.ai.mcp.client.stdio.StdioServerTransport; +import spring.ai.mcp.spec.McpSchema.ListResourcesResult; + +/** + * @author Christian Tzolov + * @since 1.0.0 + */ +public class ClientSessionTests { + + public static void main(String[] args) { + + var stdioParams = StdioServerParameters.builder("npx") + .args("-y", "@modelcontextprotocol/server-everything", + "/Users/christiantzolov/Dev/projects/demo/mcp-server/dir") + .build(); + + try (McpClient clientSession = new McpClient(new StdioServerTransport(stdioParams), Duration.ofSeconds(10), + new ObjectMapper())) { + // clientSession.handleIncomingMessages(); + clientSession.initialize(); + + // ListToolsResult tools = clientSession.listTools(null); + + // System.out.println("Tools: " + tools); + + // clientSession.ping(); + + // CallToolRequest callToolRequest = new CallToolRequest("echo", + // Map.of("message", "Hello MCP Spring AI!")); + // CallToolResult callToolResult = clientSession.callTool(callToolRequest); + // System.out.println("Call Tool Result: " + callToolResult); + + // // + // clientSession.sendRootsListChanged(); + + // Resources + ListResourcesResult resources = clientSession.listResources(null); + System.out.println("Resources: " + resources); + } + catch (Exception e) { + e.printStackTrace(); + } + } + +} diff --git a/src/test/java/spring/ai/mcp/ClientSessionTests2.java b/src/test/java/spring/ai/mcp/ClientSessionTests2.java new file mode 100644 index 0000000..b7110d9 --- /dev/null +++ b/src/test/java/spring/ai/mcp/ClientSessionTests2.java @@ -0,0 +1,77 @@ +/* +* Copyright 2024 - 2024 the original author or authors. +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* https://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ +package spring.ai.mcp; + +import java.time.Duration; + +import com.fasterxml.jackson.databind.ObjectMapper; +import spring.ai.mcp.client.McpClient; +import spring.ai.mcp.client.stdio.StdioServerParameters; +import spring.ai.mcp.client.stdio.StdioServerTransport; +import spring.ai.mcp.spec.McpSchema.ListResourcesResult; +import spring.ai.mcp.spec.McpSchema.ListToolsResult; +import spring.ai.mcp.spec.McpSchema.Resource; + +/** + * @author Christian Tzolov + * @since 1.0.0 + */ +public class ClientSessionTests2 { + + public static void main(String[] args) { + + var stdioParams = StdioServerParameters.builder("uv") + .args("--directory", "/Users/christiantzolov/Dev/projects/demo/mcp-server/dir", "run", "mcp-server-sqlite", + "--db-path", "~/test.db") + .build(); + + try (McpClient clientSession = new McpClient(new StdioServerTransport(stdioParams), Duration.ofSeconds(10), new ObjectMapper())) { + + clientSession.initialize(); + + ListToolsResult tools = clientSession.listTools(null); + System.out.println("Tools: " + tools); + + clientSession.ping(); + + // CallToolRequest callToolRequest = new CallToolRequest("echo", + // Map.of("message", "Hello MCP Spring AI!")); + // CallToolResult callToolResult = + // clientSession.callTool(callToolRequest); + // System.out.println("Call Tool Result: " + callToolResult); + // // + // clientSession.sendRootsListChanged(); + + // Resources + ListResourcesResult resources = clientSession.listResources(null); + System.out.println("Resources Size: " + resources.resources().size()); + System.out.println("Resources: " + resources); + for (Resource resource : resources.resources()) { + System.out.println(clientSession.readResource(resource)); + + } + + // var resourceTemplate = clientSession.listResourceTemplates(null); + // System.out.println("Resource Templates: " + resourceTemplate); + + // stdioClient.awaitForExit(); + } + catch (Exception e) { + e.printStackTrace(); + } + } + +} diff --git a/src/test/java/spring/ai/mcp/ClientSessionTests3.java b/src/test/java/spring/ai/mcp/ClientSessionTests3.java new file mode 100644 index 0000000..81d0535 --- /dev/null +++ b/src/test/java/spring/ai/mcp/ClientSessionTests3.java @@ -0,0 +1,73 @@ +/* +* Copyright 2024 - 2024 the original author or authors. +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* https://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ +package spring.ai.mcp; + +import java.time.Duration; + +import com.fasterxml.jackson.databind.ObjectMapper; +import spring.ai.mcp.client.McpClient; +import spring.ai.mcp.client.stdio.StdioServerParameters; +import spring.ai.mcp.client.stdio.StdioServerTransport; +import spring.ai.mcp.spec.McpSchema.ListResourcesResult; +import spring.ai.mcp.spec.McpSchema.ListToolsResult; +import spring.ai.mcp.spec.McpSchema.Resource; + +/** + * @author Christian Tzolov + * @since 1.0.0 + */ +public class ClientSessionTests3 { + + public static void main(String[] args) { + + var stdioParams = StdioServerParameters.builder("uv") + .args("--directory", "/Users/christiantzolov/Dev/projects/demo/mcp-server/dir", "run", "mcp-server-sqlite", + "--db-path", "~/test.db") + .build(); + + + McpClient clientSession = null; + try { + + clientSession = new McpClient(new StdioServerTransport(stdioParams), Duration.ofSeconds(10), new ObjectMapper()); + + clientSession.initialize(); + + ListToolsResult tools = clientSession.listTools(null); + System.out.println("Tools: " + tools); + + clientSession.ping(); + + // Resources + ListResourcesResult resources = clientSession.listResources(null); + System.out.println("Resources Size: " + resources.resources().size()); + System.out.println("Resources: " + resources); + for (Resource resource : resources.resources()) { + System.out.println(clientSession.readResource(resource)); + + } + } + catch (Exception e) { + e.printStackTrace(); + } + finally { + if (clientSession != null) { + clientSession.close(); + } + } + } + +} diff --git a/src/test/java/spring/ai/mcp/McpApplicationTests.java b/src/test/java/spring/ai/mcp/McpApplicationTests.java new file mode 100644 index 0000000..398e9e0 --- /dev/null +++ b/src/test/java/spring/ai/mcp/McpApplicationTests.java @@ -0,0 +1,13 @@ +package spring.ai.mcp; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class McpApplicationTests { + + @Test + void contextLoads() { + } + +} diff --git a/src/test/java/spring/ai/mcp/spec/McpSessionJSONRPCTest.java b/src/test/java/spring/ai/mcp/spec/McpSessionJSONRPCTest.java new file mode 100644 index 0000000..3a56a89 --- /dev/null +++ b/src/test/java/spring/ai/mcp/spec/McpSessionJSONRPCTest.java @@ -0,0 +1,158 @@ +/* +* Copyright 2024 - 2024 the original author or authors. +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* https://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ +package spring.ai.mcp.spec; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import spring.ai.mcp.spec.McpSchema.JSONRPCMessage; +import spring.ai.mcp.spec.McpSchema.JSONRPCNotification; +import spring.ai.mcp.spec.McpSchema.JSONRPCRequest; +import spring.ai.mcp.spec.McpSchema.JSONRPCResponse; + +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.function.Consumer; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class McpSessionJSONRPCTest { + + private McpTransport mockTransport; + private ObjectMapper objectMapper; + private McpSession mcpSession; + private ArgumentCaptor> messageHandlerCaptor; + + @BeforeEach + void setUp() { + mockTransport = mock(McpTransport.class); + objectMapper = new ObjectMapper(); + mcpSession = new McpSession(mockTransport, Duration.ofSeconds(5), objectMapper); + + // Capture the message handler set during the session initialization + messageHandlerCaptor = ArgumentCaptor.forClass(Consumer.class); + verify(mockTransport).setMessageHandler(messageHandlerCaptor.capture()); + } + + @Test + void sendRequestShouldSendValidRequestMessage() { + String method = "testMethod"; + Map params = Map.of("key", "value"); + + // Call sendRequest + CompletableFuture future = mcpSession.sendRequest(method, params, new TypeReference<>() {}); + + // Capture the JSONRPCRequest sent to the transport + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(JSONRPCMessage.class); + verify(mockTransport).sendMessage(messageCaptor.capture()); + + // Assert the message details + JSONRPCRequest request = (JSONRPCRequest) messageCaptor.getValue(); + assertEquals(method, request.method()); + assertEquals("value", ((Map) request.params()).get("key")); + assertNotNull(request.id()); + } + + @Test + void sendNotificationShouldSendValidNotificationMessage() { + String method = "notifyMethod"; + Map params = Map.of("param1", "value1"); + + // Call sendNotification + mcpSession.sendNotification(method, params); + + // Capture the JSONRPCNotification sent to the transport + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(JSONRPCMessage.class); + verify(mockTransport).sendMessage(messageCaptor.capture()); + + // Assert the notification details + JSONRPCNotification notification = (JSONRPCNotification) messageCaptor.getValue(); + assertEquals(method, notification.method()); + assertEquals("value1", ((Map) notification.params()).get("param1")); + } + + @Test + void messageHandlerShouldProcessResponseCorrectly() { + // Retrieve the captured message handler + Consumer messageHandler = messageHandlerCaptor.getValue(); + + // Simulate a JSONRPCResponse + JSONRPCResponse response = new JSONRPCResponse("2.0", "1", "resultValue", null); + CompletableFuture responseFuture = new CompletableFuture<>(); + mcpSession.waitingForResponseStreams.put("1", responseFuture); + + // Trigger the handler with the response + messageHandler.accept(response); + + // Verify the response is completed + assertTrue(responseFuture.isDone()); + assertEquals("resultValue", responseFuture.join().result()); + } + + @Test + @Disabled + void messageHandlerShouldInvokeHandleRequestForRequestMessage() { + // Retrieve the captured message handler + Consumer messageHandler = messageHandlerCaptor.getValue(); + + // Mock request data + JSONRPCRequest request = new JSONRPCRequest("2.0", "testMethod", "1", Map.of("key", "value")); + + // Override the handleRequest to check invocation + McpSession spySession = spy(mcpSession); + doNothing().when(spySession).handleRequest(any()); + + // Trigger the handler with a request + messageHandler.accept(request); + + // Verify handleRequest was called + verify(spySession).handleRequest(any()); + } + + @Test + @Disabled + void messageHandlerShouldInvokeHandleNotificationForNotificationMessage() { + // Retrieve the captured message handler + Consumer messageHandler = messageHandlerCaptor.getValue(); + + // Mock notification data + JSONRPCNotification notification = new JSONRPCNotification("2.0", "notifyMethod", Map.of("key", "value")); + + // Override the handleNotification to check invocation + McpSession spySession = spy(mcpSession); + doNothing().when(spySession).handleNotification(any()); + + // Trigger the handler with a notification + messageHandler.accept(notification); + + // Verify handleNotification was called + verify(spySession).handleNotification(notification); + } + + @Test + void closeShouldInvokeTransportClose() { + // Call close on the session + mcpSession.close(); + + // Verify transport close was called + verify(mockTransport).close(); + } +} diff --git a/src/test/java/spring/ai/mcp/spec/McpSessionTest.java b/src/test/java/spring/ai/mcp/spec/McpSessionTest.java new file mode 100644 index 0000000..2826b60 --- /dev/null +++ b/src/test/java/spring/ai/mcp/spec/McpSessionTest.java @@ -0,0 +1,93 @@ +/* +* Copyright 2024 - 2024 the original author or authors. +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* https://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ +package spring.ai.mcp.spec; + +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import spring.ai.mcp.spec.McpSchema.JSONRPCResponse; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class McpSessionTest { + + private McpTransport mockTransport; + + private ObjectMapper mockObjectMapper; + + private McpSession mcpSession; + + @BeforeEach + void setUp() { + mockTransport = mock(McpTransport.class); + mockObjectMapper = mock(ObjectMapper.class); + mcpSession = new McpSession(mockTransport, Duration.ofSeconds(5), mockObjectMapper); + } + + @Test + void constructorShouldThrowIfArgumentsAreNull() { + assertThrows(IllegalArgumentException.class, + () -> new McpSession(null, Duration.ofSeconds(5), mockObjectMapper)); + assertThrows(IllegalArgumentException.class, () -> new McpSession(mockTransport, null, mockObjectMapper)); + assertThrows(IllegalArgumentException.class, () -> new McpSession(mockTransport, Duration.ofSeconds(5), null)); + } + + @Test + @Disabled + void sendRequestShouldSendJsonRpcRequest() throws Exception { + String method = "testMethod"; + Object params = Map.of("key", "value"); + TypeReference typeRef = new TypeReference<>() { + }; + + CompletableFuture mockResponseFuture = new CompletableFuture<>(); + mockResponseFuture.complete(new JSONRPCResponse("2.0", "1", "result", null)); + // when(mockTransport.sendMessage(any())).then(invocation -> null); + when(mockObjectMapper.convertValue("result", typeRef)).thenReturn("result"); + + CompletableFuture result = mcpSession.sendRequest(method, params, typeRef); + assertEquals("result", result.get()); + } + + @Test + void sendRequestShouldHandleTimeout() { + String method = "timeoutMethod"; + Object params = Map.of("key", "value"); + TypeReference typeRef = new TypeReference<>() { + }; + + CompletableFuture result = mcpSession.sendRequest(method, params, typeRef); + assertThrows(Exception.class, result::get); + } + + @Test + void closeShouldInvokeTransportClose() { + mcpSession.close(); + verify(mockTransport, times(1)).close(); + } + +} diff --git a/src/test/java/spring/ai/mcp/spec/McpTransportTest.java b/src/test/java/spring/ai/mcp/spec/McpTransportTest.java new file mode 100644 index 0000000..07be6fd --- /dev/null +++ b/src/test/java/spring/ai/mcp/spec/McpTransportTest.java @@ -0,0 +1,128 @@ +/* +* Copyright 2024 - 2024 the original author or authors. +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* https://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ +package spring.ai.mcp.spec; + + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import spring.ai.mcp.spec.McpSchema.JSONRPCMessage; +import spring.ai.mcp.spec.McpSchema.JSONRPCNotification; +import spring.ai.mcp.spec.McpSchema.JSONRPCRequest; +import spring.ai.mcp.spec.McpSchema.JSONRPCResponse; + +import java.time.Duration; +import java.util.Map; +import java.util.function.Consumer; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class McpTransportTest { + + private McpTransport mockTransport; + private McpSession mcpSession; + + @BeforeEach + void setUp() { + mockTransport = mock(McpTransport.class); + mcpSession = new McpSession(mockTransport, Duration.ofSeconds(5), new ObjectMapper()); + } + + @Test + void constructorShouldInitializeTransport() { + // Verify transport setup during construction + verify(mockTransport).setMessageHandler(any(Consumer.class)); + verify(mockTransport).setErrorHandler(any(Consumer.class)); + verify(mockTransport).start(); + } + + @Test + void sendRequestShouldCallTransportSendMessage() { + String method = "testMethod"; + Object params = Map.of("key", "value"); + + // Call sendRequest + mcpSession.sendRequest(method, params, new TypeReference() {}); + + // Capture the message sent to transport + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(JSONRPCMessage.class); + verify(mockTransport).sendMessage(messageCaptor.capture()); + + // Verify the message details + JSONRPCRequest request = (JSONRPCRequest) messageCaptor.getValue(); + assertEquals("testMethod", request.method()); + assertEquals("value", ((Map) request.params()).get("key")); + assertNotNull(request.id()); + } + + @Test + void sendNotificationShouldCallTransportSendMessage() { + String method = "notifyMethod"; + Map params = Map.of("param1", "value1"); + + // Call sendNotification + mcpSession.sendNotification(method, params); + + // Capture the notification sent to transport + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(JSONRPCMessage.class); + verify(mockTransport).sendMessage(messageCaptor.capture()); + + // Verify the notification details + JSONRPCNotification notification = (JSONRPCNotification) messageCaptor.getValue(); + assertEquals("notifyMethod", notification.method()); + assertEquals("value1", ((Map) notification.params()).get("param1")); + } + + @Test + void messageHandlerShouldProcessResponse() { + // Capture the message handler during initialization + ArgumentCaptor> handlerCaptor = ArgumentCaptor.forClass(Consumer.class); + verify(mockTransport).setMessageHandler(handlerCaptor.capture()); + Consumer handler = handlerCaptor.getValue(); + + // Simulate a JSONRPCResponse + JSONRPCResponse response = new JSONRPCResponse("2.0", "1", "result", null); + handler.accept(response); + + // Verify the response was processed + // (Use CompletableFuture to match the response if needed in real tests) + } + + @Test + void errorHandlerShouldLogErrors() { + // Capture the error handler during initialization + ArgumentCaptor> handlerCaptor = ArgumentCaptor.forClass(Consumer.class); + verify(mockTransport).setErrorHandler(handlerCaptor.capture()); + Consumer errorHandler = handlerCaptor.getValue(); + + // Simulate an error + String errorMessage = "Test error"; + errorHandler.accept(errorMessage); + + // Verify the error was logged or processed + // (In real tests, capture logging output or other side effects) + } + + @Test + void closeShouldStopTransport() { + mcpSession.close(); + verify(mockTransport).close(); + } +}