diff --git a/model-context-protocol/mcp-annotations-server/.mvn/wrapper/maven-wrapper.properties b/model-context-protocol/mcp-annotations-server/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..d58dfb7 --- /dev/null +++ b/model-context-protocol/mcp-annotations-server/.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/model-context-protocol/mcp-annotations-server/README.md b/model-context-protocol/mcp-annotations-server/README.md new file mode 100644 index 0000000..11af7b8 --- /dev/null +++ b/model-context-protocol/mcp-annotations-server/README.md @@ -0,0 +1,233 @@ +# Spring AI MCP Weather Server Sample with WebMVC Starter + +This sample project demonstrates how to create an MCP server using the Spring AI MCP Server Boot Starter with WebMVC transport. It implements a weather service that exposes tools for retrieving weather information using the National Weather Service API. + +For more information, see the [MCP Server Boot Starter](https://docs.spring.io/spring-ai/reference/api/mcp/mcp-server-boot-starter-docs.html) reference documentation. + +## Overview + +The sample showcases: +- Integration with `spring-ai-mcp-server-webmvc-spring-boot-starter` +- Support for both SSE (Server-Sent Events) and STDIO transports +- Automatic tool registration using Spring AI's `@Tool` annotation +- Two weather-related tools: + - Get weather forecast by location (latitude/longitude) + - Get weather alerts by US state + +## Dependencies + +The project requires the Spring AI MCP Server WebMVC Boot Starter: + +```xml + + org.springframework.ai + spring-ai-mcp-server-webmvc-spring-boot-starter + +``` + +This starter provides: +- HTTP-based transport using Spring MVC (`WebMvcSseServerTransport`) +- Auto-configured SSE endpoints +- Optional STDIO transport +- Included `spring-boot-starter-web` and `mcp-spring-webmvc` dependencies + +## Building the Project + +Build the project using Maven: +```bash +./mvnw clean install -DskipTests +``` + +## Running the Server + +The server supports two transport modes: + +### WebMVC SSE Mode (Default) +```bash +java -jar target/mcp-weather-starter-webmvc-server-0.0.1-SNAPSHOT.jar +``` + +### STDIO Mode +To enable STDIO transport, set the appropriate properties: +```bash +java -Dspring.ai.mcp.server.stdio=true -Dspring.main.web-application-type=none -jar target/mcp-weather-starter-webmvc-server-0.0.1-SNAPSHOT.jar +``` + +## Configuration + +Configure the server through `application.properties`: + +```properties +# Server identification +spring.ai.mcp.server.name=my-weather-server +spring.ai.mcp.server.version=0.0.1 + +# Server type (SYNC/ASYNC) +spring.ai.mcp.server.type=SYNC + +# Transport configuration +spring.ai.mcp.server.stdio=false +spring.ai.mcp.server.sse-message-endpoint=/mcp/message + +# Change notifications +spring.ai.mcp.server.resource-change-notification=true +spring.ai.mcp.server.tool-change-notification=true +spring.ai.mcp.server.prompt-change-notification=true + +# Logging (required for STDIO transport) +spring.main.banner-mode=off +logging.file.name=./target/starter-webmvc-server.log +``` + +## Available Tools + +### Weather Forecast Tool +- Name: `getWeatherForecastByLocation` +- Description: Get weather forecast for a specific latitude/longitude +- Parameters: + - `latitude`: double - Latitude coordinate + - `longitude`: double - Longitude coordinate + +### Weather Alerts Tool +- Name: `getAlerts` +- Description: Get weather alerts for a US state +- Parameters: + - `state`: String - Two-letter US state code (e.g., CA, NY) + +## Server Implementation + +The server uses Spring Boot and Spring AI's tool annotations for automatic tool registration: + +```java +@SpringBootApplication +public class McpServerApplication { + public static void main(String[] args) { + SpringApplication.run(McpServerApplication.class, args); + } + + @Bean + public ToolCallbackProvider weatherTools(WeatherService weatherService){ + return MethodToolCallbackProvider.builder().toolObjects(weatherService).build(); + } +} +``` + +The `WeatherService` implements the weather tools using the `@Tool` annotation: + +```java +@Service +public class WeatherService { + @Tool(description = "Get weather forecast for a specific latitude/longitude") + public String getWeatherForecastByLocation(double latitude, double longitude) { + // Implementation using weather.gov API + } + + @Tool(description = "Get weather alerts for a US state. Input is Two-letter US state code (e.g., CA, NY)") + public String getAlerts(String state) { + // Implementation using weather.gov API + } +} +``` + +## MCP Clients + +You can connect to the weather server using either STDIO or SSE transport: + +### Manual Clients + +#### WebMVC SSE Client + +For servers using SSE transport: + +```java +var transport = new HttpClientSseClientTransport("http://localhost:8080"); +var client = McpClient.sync(transport).build(); +``` + +#### STDIO Client + +For servers using STDIO transport: + +```java +var stdioParams = ServerParameters.builder("java") + .args("-Dspring.ai.mcp.server.stdio=true", + "-Dspring.main.web-application-type=none", + "-Dspring.main.banner-mode=off", + "-Dlogging.pattern.console=", + "-jar", + "target/mcp-weather-starter-webmvc-server-0.0.1-SNAPSHOT.jar") + .build(); + +var transport = new StdioClientTransport(stdioParams); +var client = McpClient.sync(transport).build(); +``` + +The sample project includes example client implementations: +- [SampleClient.java](src/test/java/org/springframework/ai/mcp/sample/client/SampleClient.java): Manual MCP client implementation +- [ClientStdio.java](src/test/java/org/springframework/ai/mcp/sample/client/ClientStdio.java): STDIO transport connection +- [ClientSse.java](src/test/java/org/springframework/ai/mcp/sample/client/ClientSse.java): SSE transport connection + +For a better development experience, consider using the [MCP Client Boot Starters](https://docs.spring.io/spring-ai/reference/api/mcp/mcp-client-boot-starter-docs.html). These starters enable auto-configuration of multiple STDIO and/or SSE connections to MCP servers. See the [starter-default-client](../../client-starter/starter-default-client) project for examples. + +### Boot Starter Clients + +Let's use the [starter-default-client](../../client-starter/starter-default-client) client to connect to our weather `starter-webmvc-server`. + +Follow the `starter-default-client` readme instruction to build a `mcp-starter-default-client-0.0.1-SNAPSHOT.jar` client application. + +#### STDIO Transport + +1. Create a `mcp-servers-config.json` configuration file with this content: + +```json +{ + "mcpServers": { + "weather-starter-webmvc-server": { + "command": "java", + "args": [ + "-Dspring.ai.mcp.server.stdio=true", + "-Dspring.main.web-application-type=none", + "-Dlogging.pattern.console=", + "-jar", + "/absolute/path/to/mcp-weather-starter-webmvc-server-0.0.1-SNAPSHOT.jar" + ] + } + } +} +``` + +2. Run the client using the configuration file: + +```bash +java -Dspring.ai.mcp.client.stdio.servers-configuration=file:mcp-servers-config.json \ + -Dai.user.input='What is the weather in NY?' \ + -Dlogging.pattern.console= \ + -jar mcp-starter-default-client-0.0.1-SNAPSHOT.jar +``` + +#### SSE (WebMVC) Transport + +1. Start the `mcp-weather-starter-webmvc-server`: + +```bash +java -jar mcp-weather-starter-webmvc-server-0.0.1-SNAPSHOT.jar +``` + +starts the MCP server on port 8080. + +2. In another console start the client configured with SSE transport: + +```bash +java -Dspring.ai.mcp.client.sse.connections.weather-server.url=http://localhost:8080 \ + -Dlogging.pattern.console= \ + -Dai.user.input='What is the weather in NY?' \ + -jar mcp-starter-default-client-0.0.1-SNAPSHOT.jar +``` + +## Additional Resources + +* [Spring AI Documentation](https://docs.spring.io/spring-ai/reference/) +* [MCP Server Boot Starter](https://docs.spring.io/spring-ai/reference/api/mcp/mcp-server-boot-starter-docs.html) +* [MCP Client Boot Starter](https://docs.spring.io/spring-ai/reference/api/mcp/mcp-server-boot-client-docs.html) +* [Model Context Protocol Specification](https://modelcontextprotocol.github.io/specification/) +* [Spring Boot Auto-configuration](https://docs.spring.io/spring-boot/docs/current/reference/html/features.html#features.developing-auto-configuration) diff --git a/model-context-protocol/mcp-annotations-server/mvnw b/model-context-protocol/mcp-annotations-server/mvnw new file mode 100755 index 0000000..19529dd --- /dev/null +++ b/model-context-protocol/mcp-annotations-server/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/model-context-protocol/mcp-annotations-server/mvnw.cmd b/model-context-protocol/mcp-annotations-server/mvnw.cmd new file mode 100644 index 0000000..b150b91 --- /dev/null +++ b/model-context-protocol/mcp-annotations-server/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/model-context-protocol/mcp-annotations-server/pom.xml b/model-context-protocol/mcp-annotations-server/pom.xml new file mode 100644 index 0000000..52e2d9c --- /dev/null +++ b/model-context-protocol/mcp-annotations-server/pom.xml @@ -0,0 +1,83 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.3.6 + + + + com.example + + mcp-annotations-server + 0.0.1-SNAPSHOT + + Spring AI MCP Annotations Sample + Sample Spring Boot application demonstrating MCP client and server usage + + + + + org.springframework.ai + spring-ai-bom + 1.0.0-SNAPSHOT + pom + import + + + + + + + com.logaritex.mcp + spring-ai-mcp-annotations + 0.1.0-SNAPSHOT + + + org.springframework.ai + spring-ai-starter-mcp-server-webmvc + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + + + spring-milestones + Spring Milestones + https://repo.spring.io/milestone + + false + + + + spring-snapshots + Spring Snapshots + https://repo.spring.io/snapshot + + false + + + + central-portal-snapshots + Central Portal Snapshots + https://central.sonatype.com/repository/maven-snapshots/ + + false + + + true + + + + \ No newline at end of file diff --git a/model-context-protocol/mcp-annotations-server/src/main/java/org/springframework/ai/mcp/sample/server/AutocompleteProvider.java b/model-context-protocol/mcp-annotations-server/src/main/java/org/springframework/ai/mcp/sample/server/AutocompleteProvider.java new file mode 100644 index 0000000..113a4e1 --- /dev/null +++ b/model-context-protocol/mcp-annotations-server/src/main/java/org/springframework/ai/mcp/sample/server/AutocompleteProvider.java @@ -0,0 +1,108 @@ +/* +* Copyright 2025 - 2025 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 org.springframework.ai.mcp.sample.server; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import com.logaritex.mcp.annotation.McpComplete; +import io.modelcontextprotocol.spec.McpSchema.CompleteRequest; +import io.modelcontextprotocol.spec.McpSchema.CompleteResult; +import io.modelcontextprotocol.spec.McpSchema.CompleteResult.CompleteCompletion; + +import org.springframework.stereotype.Service; + +/** + * @author Christian Tzolov + */ +@Service +public class AutocompleteProvider { + + private final Map> countryDatabase = new HashMap<>(); + + private final Map> usernameDatabase = new HashMap<>(); + + public AutocompleteProvider() { + + usernameDatabase.put("a", List.of("alex123", "admin", "alice_wonder", "andrew99")); + usernameDatabase.put("b", List.of("bob_builder", "blue_sky", "batman", "butterfly")); + usernameDatabase.put("c", List.of("charlie", "cool_cat", "coder42", "captain_marvel")); + usernameDatabase.put("d", List.of("david_dev", "dragon_slayer", "diamond_hand", "dancer")); + usernameDatabase.put("j", List.of("john_doe", "java_expert", "jazz_lover", "jupiter")); + usernameDatabase.put("m", List.of("martin123", "moon_walker", "master_chef", "music_fan")); + usernameDatabase.put("s", List.of("sarah", "super_coder", "star_gazer", "swift_dev")); + usernameDatabase.put("t", List.of("tech_guru", "traveler", "tiger", "tester101")); + + countryDatabase.put("a", List.of("Afghanistan", "Albania", "Algeria", "Argentina", "Australia", "Austria")); + countryDatabase.put("b", List.of("Bahamas", "Belgium", "Brazil", "Bulgaria")); + countryDatabase.put("c", List.of("Canada", "Chile", "China", "Colombia", "Croatia")); + countryDatabase.put("f", List.of("Finland", "France")); + countryDatabase.put("g", List.of("Germany", "Greece")); + countryDatabase.put("i", List.of("Iceland", "India", "Indonesia", "Ireland", "Italy")); + countryDatabase.put("j", List.of("Japan")); + countryDatabase.put("u", List.of("Uganda", "Ukraine", "United Kingdom", "United States")); + } + + /** + * Complete method for usernames in a user status prompt. + */ + @McpComplete(uri = "user-status://{username}") + public List completeUsername(String usernamePrefix) { + String prefix = usernamePrefix.toLowerCase(); + if (prefix.isEmpty()) { + return List.of("Enter a username"); + } + + String firstLetter = prefix.substring(0, 1); + List usernames = usernameDatabase.getOrDefault(firstLetter, List.of()); + + return usernames.stream().filter(username -> username.toLowerCase().startsWith(prefix)).toList(); + } + + @McpComplete(prompt = "personalized-message") + public List completeName(String name) { + String prefix = name.toLowerCase(); + if (prefix.isEmpty()) { + return List.of("Enter a username"); + } + + String firstLetter = prefix.substring(0, 1); + List usernames = usernameDatabase.getOrDefault(firstLetter, List.of()); + + return usernames.stream().filter(username -> username.toLowerCase().startsWith(prefix)).toList(); + } + + /** + * Complete method for country names in a travel prompt. + */ + @McpComplete(prompt = "travel-planner") + public CompleteResult completeCountryName(CompleteRequest request) { + String prefix = request.argument().value().toLowerCase(); + if (prefix.isEmpty()) { + return new CompleteResult(new CompleteCompletion(List.of("Enter a country name"), 1, false)); + } + + String firstLetter = prefix.substring(0, 1); + List countries = countryDatabase.getOrDefault(firstLetter, List.of()); + + List matches = countries.stream() + .filter(country -> country.toLowerCase().startsWith(prefix)) + .toList(); + + return new CompleteResult(new CompleteCompletion(matches, matches.size(), false)); + } +} diff --git a/model-context-protocol/mcp-annotations-server/src/main/java/org/springframework/ai/mcp/sample/server/McpServerApplication.java b/model-context-protocol/mcp-annotations-server/src/main/java/org/springframework/ai/mcp/sample/server/McpServerApplication.java new file mode 100644 index 0000000..72165e6 --- /dev/null +++ b/model-context-protocol/mcp-annotations-server/src/main/java/org/springframework/ai/mcp/sample/server/McpServerApplication.java @@ -0,0 +1,43 @@ +package org.springframework.ai.mcp.sample.server; + +import java.util.List; + +import com.logaritex.mcp.spring.SpringAiMcpAnnotationProvider; +import io.modelcontextprotocol.server.McpServerFeatures.SyncCompletionSpecification; +import io.modelcontextprotocol.server.McpServerFeatures.SyncPromptSpecification; +import io.modelcontextprotocol.server.McpServerFeatures.SyncResourceSpecification; + +import org.springframework.ai.tool.ToolCallbackProvider; +import org.springframework.ai.tool.method.MethodToolCallbackProvider; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; + +@SpringBootApplication +public class McpServerApplication { + + public static void main(String[] args) { + SpringApplication.run(McpServerApplication.class, args); + } + + @Bean + public ToolCallbackProvider weatherTools(WeatherService weatherService) { + return MethodToolCallbackProvider.builder().toolObjects(weatherService).build(); + } + + @Bean + public List resourceSpecs(UserProfileResourceProvider userProfileResourceProvider) { + return SpringAiMcpAnnotationProvider.createSyncResourceSpecifications(List.of(userProfileResourceProvider)); + } + + @Bean + public List promptSpecs(PromptProvider promptProvider) { + return SpringAiMcpAnnotationProvider.createSyncPromptSpecifications(List.of(promptProvider)); + } + + @Bean + public List completionSpecs(AutocompleteProvider autocompleteProvider) { + return SpringAiMcpAnnotationProvider.createSyncCompleteSpecifications(List.of(autocompleteProvider)); + } + +} diff --git a/model-context-protocol/mcp-annotations-server/src/main/java/org/springframework/ai/mcp/sample/server/PromptProvider.java b/model-context-protocol/mcp-annotations-server/src/main/java/org/springframework/ai/mcp/sample/server/PromptProvider.java new file mode 100644 index 0000000..d252ec2 --- /dev/null +++ b/model-context-protocol/mcp-annotations-server/src/main/java/org/springframework/ai/mcp/sample/server/PromptProvider.java @@ -0,0 +1,172 @@ +/* +* Copyright 2025 - 2025 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 org.springframework.ai.mcp.sample.server; + +import java.util.List; +import java.util.Map; + +import com.logaritex.mcp.annotation.McpArg; +import com.logaritex.mcp.annotation.McpPrompt; +import io.modelcontextprotocol.server.McpSyncServerExchange; +import io.modelcontextprotocol.spec.McpSchema.GetPromptRequest; +import io.modelcontextprotocol.spec.McpSchema.GetPromptResult; +import io.modelcontextprotocol.spec.McpSchema.LoggingLevel; +import io.modelcontextprotocol.spec.McpSchema.LoggingMessageNotification; +import io.modelcontextprotocol.spec.McpSchema.PromptMessage; +import io.modelcontextprotocol.spec.McpSchema.Role; +import io.modelcontextprotocol.spec.McpSchema.TextContent; + +import org.springframework.stereotype.Service; + +/** + * @author Christian Tzolov + */ +@Service +public class PromptProvider { + /** + * A simple greeting prompt that takes a name parameter. + * @param name The name to greet + * @return A greeting message + */ + @McpPrompt(name = "greeting", description = "A simple greeting prompt") + public GetPromptResult greetingPrompt( + @McpArg(name = "name", description = "The name to greet", required = true) String name) { + return new GetPromptResult("Greeting", List.of(new PromptMessage(Role.ASSISTANT, + new TextContent("Hello, " + name + "! Welcome to the MCP system.")))); + } + + /** + * A more complex prompt that generates a personalized message. + * @param exchange The server exchange + * @param name The user's name + * @param age The user's age + * @param interests The user's interests + * @return A personalized message + */ + @McpPrompt(name = "personalized-message", + description = "Generates a personalized message based on user information") + public GetPromptResult personalizedMessage(McpSyncServerExchange exchange, + @McpArg(name = "name", description = "The user's name", required = true) String name, + @McpArg(name = "age", description = "The user's age", required = false) Integer age, + @McpArg(name = "interests", description = "The user's interests", required = false) String interests) { + + exchange.loggingNotification(LoggingMessageNotification.builder() + .level(LoggingLevel.INFO) + .data("personalized-message event").build()); + + StringBuilder message = new StringBuilder(); + message.append("Hello, ").append(name).append("!\n\n"); + + if (age != null) { + message.append("At ").append(age).append(" years old, you have "); + if (age < 30) { + message.append("so much ahead of you.\n\n"); + } + else if (age < 60) { + message.append("gained valuable life experience.\n\n"); + } + else { + message.append("accumulated wisdom to share with others.\n\n"); + } + } + + if (interests != null && !interests.isEmpty()) { + message.append("Your interest in ") + .append(interests) + .append(" shows your curiosity and passion for learning.\n\n"); + } + + message + .append("I'm here to assist you with any questions you might have about the Model Context Protocol."); + + return new GetPromptResult("Personalized Message", + List.of(new PromptMessage(Role.ASSISTANT, new TextContent(message.toString())))); + } + + /** + * A prompt that returns a list of messages forming a conversation. + * @param request The prompt request + * @return A list of messages + */ + @McpPrompt(name = "conversation-starter", description = "Provides a conversation starter with the system") + public List conversationStarter(GetPromptRequest request) { + return List.of( + new PromptMessage(Role.ASSISTANT, + new TextContent("Hello! I'm the MCP assistant. How can I help you today?")), + new PromptMessage(Role.USER, + new TextContent("I'd like to learn more about the Model Context Protocol.")), + new PromptMessage(Role.ASSISTANT, new TextContent( + "Great choice! The Model Context Protocol (MCP) is a standardized way for servers " + + "to communicate with language models. It provides a structured approach for " + + "exchanging information, making requests, and handling responses. " + + "What specific aspect would you like to explore first?"))); + } + + /** + * A prompt that accepts arguments as a map. + * @param arguments The arguments map + * @return A prompt result + */ + @McpPrompt(name = "map-arguments", description = "Demonstrates using a map for arguments") + public GetPromptResult mapArguments(Map arguments) { + StringBuilder message = new StringBuilder("I received the following arguments:\n\n"); + + if (arguments != null && !arguments.isEmpty()) { + for (Map.Entry entry : arguments.entrySet()) { + message.append("- ").append(entry.getKey()).append(": ").append(entry.getValue()).append("\n"); + } + } + else { + message.append("No arguments were provided."); + } + + return new GetPromptResult("Map Arguments Demo", + List.of(new PromptMessage(Role.ASSISTANT, new TextContent(message.toString())))); + } + + /** + * A prompt that returns a single PromptMessage. + * @param name The user's name + * @return A single PromptMessage + */ + @McpPrompt(name = "single-message", description = "Demonstrates returning a single PromptMessage") + public PromptMessage singleMessagePrompt( + @McpArg(name = "name", description = "The user's name", required = true) String name) { + return new PromptMessage(Role.ASSISTANT, + new TextContent("Hello, " + name + "! This is a single message response.")); + } + + /** + * A prompt that returns a list of strings. + * @param topic The topic to provide information about + * @return A list of strings with information about the topic + */ + @McpPrompt(name = "string-list", description = "Demonstrates returning a list of strings") + public List stringListPrompt(@McpArg(name = "topic", + description = "The topic to provide information about", required = true) String topic) { + if ("MCP".equalsIgnoreCase(topic)) { + return List.of( + "The Model Context Protocol (MCP) is a standardized way for servers to communicate with language models.", + "It provides a structured approach for exchanging information, making requests, and handling responses.", + "MCP allows servers to expose resources, tools, and prompts to clients in a consistent way."); + } + else { + return List.of("I don't have specific information about " + topic + ".", + "Please try a different topic or ask a more specific question."); + } + } + +} diff --git a/model-context-protocol/mcp-annotations-server/src/main/java/org/springframework/ai/mcp/sample/server/UserProfileResourceProvider.java b/model-context-protocol/mcp-annotations-server/src/main/java/org/springframework/ai/mcp/sample/server/UserProfileResourceProvider.java new file mode 100644 index 0000000..1668685 --- /dev/null +++ b/model-context-protocol/mcp-annotations-server/src/main/java/org/springframework/ai/mcp/sample/server/UserProfileResourceProvider.java @@ -0,0 +1,207 @@ +/* +* Copyright 2025 - 2025 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 org.springframework.ai.mcp.sample.server; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import com.logaritex.mcp.annotation.McpResource; +import io.modelcontextprotocol.server.McpSyncServerExchange; +import io.modelcontextprotocol.spec.McpSchema.ReadResourceRequest; +import io.modelcontextprotocol.spec.McpSchema.ReadResourceResult; +import io.modelcontextprotocol.spec.McpSchema.ResourceContents; +import io.modelcontextprotocol.spec.McpSchema.TextResourceContents; + +import org.springframework.stereotype.Service; + +/** + * @author Christian Tzolov + */ +@Service +public class UserProfileResourceProvider { + + private final Map> userProfiles = new HashMap<>(); + + public UserProfileResourceProvider() { + // Initialize with some sample data + Map johnProfile = new HashMap<>(); + johnProfile.put("name", "John Smith"); + johnProfile.put("email", "john.smith@example.com"); + johnProfile.put("age", "32"); + johnProfile.put("location", "New York"); + + Map janeProfile = new HashMap<>(); + janeProfile.put("name", "Jane Doe"); + janeProfile.put("email", "jane.doe@example.com"); + janeProfile.put("age", "28"); + janeProfile.put("location", "London"); + + Map bobProfile = new HashMap<>(); + bobProfile.put("name", "Bob Johnson"); + bobProfile.put("email", "bob.johnson@example.com"); + bobProfile.put("age", "45"); + bobProfile.put("location", "Tokyo"); + + Map aliceProfile = new HashMap<>(); + aliceProfile.put("name", "Alice Brown"); + aliceProfile.put("email", "alice.brown@example.com"); + aliceProfile.put("age", "36"); + aliceProfile.put("location", "Sydney"); + + userProfiles.put("john", johnProfile); + userProfiles.put("jane", janeProfile); + userProfiles.put("bob", bobProfile); + userProfiles.put("alice", aliceProfile); + } + + /** + * Resource method that takes a ReadResourceRequest parameter and URI variable. + */ + @McpResource(uri = "user-profile://{username}", name = "User Profile", description = "Provides user profile information for a specific user") + public ReadResourceResult getUserProfile(ReadResourceRequest request, String username) { + String profileInfo = formatProfileInfo(userProfiles.getOrDefault(username.toLowerCase(), new HashMap<>())); + + return new ReadResourceResult(List.of(new TextResourceContents(request.uri(), "text/plain", profileInfo))); + } + + /** + * Resource method that takes URI variables directly as parameters. The URI + * template in the annotation defines the variables that will be extracted. + */ + @McpResource(uri = "user-profile://{username}", name = "User Details", description = "Provides user details for a specific user using URI variables") + public ReadResourceResult getUserDetails(String username) { + String profileInfo = formatProfileInfo(userProfiles.getOrDefault(username.toLowerCase(), new HashMap<>())); + + return new ReadResourceResult( + List.of(new TextResourceContents("user-profile://" + username, "text/plain", profileInfo))); + } + + /** + * Resource method that takes multiple URI variables as parameters. + */ + @McpResource(uri = "user-attribute://{username}/{attribute}", name = "User Attribute", description = "Provides a specific attribute from a user's profile") + public ReadResourceResult getUserAttribute(String username, String attribute) { + Map profile = userProfiles.getOrDefault(username.toLowerCase(), new HashMap<>()); + String attributeValue = profile.getOrDefault(attribute, "Attribute not found"); + + return new ReadResourceResult( + List.of(new TextResourceContents("user-attribute://" + username + "/" + attribute, "text/plain", + username + "'s " + attribute + ": " + attributeValue))); + } + + /** + * Resource method that takes an exchange and URI variables. + */ + @McpResource(uri = "user-profile-exchange://{username}", name = "User Profile with Exchange", description = "Provides user profile information with server exchange context") + public ReadResourceResult getProfileWithExchange(McpSyncServerExchange exchange, String username) { + String profileInfo = formatProfileInfo(userProfiles.getOrDefault(username.toLowerCase(), new HashMap<>())); + + return new ReadResourceResult(List.of(new TextResourceContents("user-profile-exchange://" + username, + "text/plain", "Profile with exchange for " + username + ": " + profileInfo))); + } + + /** + * Resource method that takes a String URI variable parameter. + */ + @McpResource(uri = "user-connections://{username}", name = "User Connections", description = "Provides a list of connections for a specific user") + public List getUserConnections(String username) { + // Generate a simple list of connections based on username + return List.of(username + " is connected with Alice", username + " is connected with Bob", + username + " is connected with Charlie"); + } + + /** + * Resource method that takes both McpSyncServerExchange, ReadResourceRequest + * and + * URI variable parameters. + */ + @McpResource(uri = "user-notifications://{username}", name = "User Notifications", description = "Provides notifications for a specific user") + public List getUserNotifications(McpSyncServerExchange exchange, ReadResourceRequest request, + String username) { + // Generate notifications based on username + String notifications = generateNotifications(username); + + return List.of(new TextResourceContents(request.uri(), "text/plain", notifications)); + } + + /** + * Resource method that returns a single ResourceContents with TEXT content + * type. + */ + @McpResource(uri = "user-status://{username}", name = "User Status", description = "Provides the current status for a specific user") + public ResourceContents getUserStatus(ReadResourceRequest request, String username) { + // Generate a simple status based on username + String status = generateUserStatus(username); + + return new TextResourceContents(request.uri(), "text/plain", status); + } + + /** + * Resource method that returns a single String with TEXT content type. + */ + @McpResource(uri = "user-location://{username}", name = "User Location", description = "Provides the current location for a specific user") + public String getUserLocation(String username) { + Map profile = userProfiles.getOrDefault(username.toLowerCase(), new HashMap<>()); + + // Extract location from profile data + return profile.getOrDefault("location", "Location not available"); + } + + /** + * Resource method that returns a single String with BLOB content type. This + * demonstrates how a String can be treated as binary data. + */ + @McpResource(uri = "user-avatar://{username}", name = "User Avatar", description = "Provides a base64-encoded avatar image for a specific user", mimeType = "image/png") + public String getUserAvatar(ReadResourceRequest request, String username) { + // In a real implementation, this would be a base64-encoded image + // For this example, we're just returning a placeholder string + return "base64-encoded-avatar-image-for-" + username; + } + + private String formatProfileInfo(Map profile) { + if (profile.isEmpty()) { + return "User profile not found"; + } + + StringBuilder sb = new StringBuilder(); + for (Map.Entry entry : profile.entrySet()) { + sb.append(entry.getKey()).append(": ").append(entry.getValue()).append("\n"); + } + return sb.toString().trim(); + } + + private String generateNotifications(String username) { + // Simple logic to generate notifications + return "You have 3 new messages\n" + "2 people viewed your profile\n" + "You have 1 new connection request"; + } + + private String generateUserStatus(String username) { + // Simple logic to generate a status + if (username.equals("john")) { + return "🟢 Online"; + } else if (username.equals("jane")) { + return "🟠 Away"; + } else if (username.equals("bob")) { + return "⚪ Offline"; + } else if (username.equals("alice")) { + return "🔴 Busy"; + } else { + return "⚪ Offline"; + } + } + +} diff --git a/model-context-protocol/mcp-annotations-server/src/main/java/org/springframework/ai/mcp/sample/server/WeatherService.java b/model-context-protocol/mcp-annotations-server/src/main/java/org/springframework/ai/mcp/sample/server/WeatherService.java new file mode 100644 index 0000000..5966ea1 --- /dev/null +++ b/model-context-protocol/mcp-annotations-server/src/main/java/org/springframework/ai/mcp/sample/server/WeatherService.java @@ -0,0 +1,145 @@ +/* +* 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 org.springframework.ai.mcp.sample.server; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientException; + +@Service +public class WeatherService { + + private static final String BASE_URL = "https://api.weather.gov"; + + private final RestClient restClient; + + public WeatherService() { + + this.restClient = RestClient.builder() + .baseUrl(BASE_URL) + .defaultHeader("Accept", "application/geo+json") + .defaultHeader("User-Agent", "WeatherApiClient/1.0 (your@email.com)") + .build(); + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record Points(@JsonProperty("properties") Props properties) { + @JsonIgnoreProperties(ignoreUnknown = true) + public record Props(@JsonProperty("forecast") String forecast) { + } + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record Forecast(@JsonProperty("properties") Props properties) { + @JsonIgnoreProperties(ignoreUnknown = true) + public record Props(@JsonProperty("periods") List periods) { + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record Period(@JsonProperty("number") Integer number, @JsonProperty("name") String name, + @JsonProperty("startTime") String startTime, @JsonProperty("endTime") String endTime, + @JsonProperty("isDaytime") Boolean isDayTime, @JsonProperty("temperature") Integer temperature, + @JsonProperty("temperatureUnit") String temperatureUnit, + @JsonProperty("temperatureTrend") String temperatureTrend, + @JsonProperty("probabilityOfPrecipitation") Map probabilityOfPrecipitation, + @JsonProperty("windSpeed") String windSpeed, @JsonProperty("windDirection") String windDirection, + @JsonProperty("icon") String icon, @JsonProperty("shortForecast") String shortForecast, + @JsonProperty("detailedForecast") String detailedForecast) { + } + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record Alert(@JsonProperty("features") List features) { + + @JsonIgnoreProperties(ignoreUnknown = true) + public record Feature(@JsonProperty("properties") Properties properties) { + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record Properties(@JsonProperty("event") String event, @JsonProperty("areaDesc") String areaDesc, + @JsonProperty("severity") String severity, @JsonProperty("description") String description, + @JsonProperty("instruction") String instruction) { + } + } + + /** + * Get forecast for a specific latitude/longitude + * @param latitude Latitude + * @param longitude Longitude + * @return The forecast for the given location + * @throws RestClientException if the request fails + */ + @Tool(description = "Get weather forecast for a specific latitude/longitude") + public String getWeatherForecastByLocation(double latitude, double longitude) { + + var points = restClient.get() + .uri("/points/{latitude},{longitude}", latitude, longitude) + .retrieve() + .body(Points.class); + + var forecast = restClient.get().uri(points.properties().forecast()).retrieve().body(Forecast.class); + + String forecastText = forecast.properties().periods().stream().map(p -> { + return String.format(""" + %s: + Temperature: %s %s + Wind: %s %s + Forecast: %s + """, p.name(), p.temperature(), p.temperatureUnit(), p.windSpeed(), p.windDirection(), + p.detailedForecast()); + }).collect(Collectors.joining()); + + return forecastText; + } + + /** + * Get alerts for a specific area + * @param state Area code. Two-letter US state code (e.g. CA, NY) + * @return Human readable alert information + * @throws RestClientException if the request fails + */ + @Tool(description = "Get weather alerts for a US state. Input is Two-letter US state code (e.g. CA, NY)") + public String getAlerts(String state) { + Alert alert = restClient.get().uri("/alerts/active/area/{state}", state).retrieve().body(Alert.class); + + return alert.features() + .stream() + .map(f -> String.format(""" + Event: %s + Area: %s + Severity: %s + Description: %s + Instructions: %s + """, f.properties().event(), f.properties.areaDesc(), f.properties.severity(), + f.properties.description(), f.properties.instruction())) + .collect(Collectors.joining("\n")); + } + + public static void main(String[] args) { + WeatherService client = new WeatherService(); + System.out.println(client.getWeatherForecastByLocation(47.6062, -122.3321)); + System.out.println(client.getAlerts("NY")); + } + +} \ No newline at end of file diff --git a/model-context-protocol/mcp-annotations-server/src/main/resources/application.properties b/model-context-protocol/mcp-annotations-server/src/main/resources/application.properties new file mode 100644 index 0000000..e1ab7a4 --- /dev/null +++ b/model-context-protocol/mcp-annotations-server/src/main/resources/application.properties @@ -0,0 +1,13 @@ +# spring.main.web-application-type=none + +# NOTE: You must disable the banner and the console logging +# to allow the STDIO transport to work !!! +spring.main.banner-mode=off +# logging.pattern.console= + +# spring.ai.mcp.server.stdio=false + +spring.ai.mcp.server.name=my-weather-server +spring.ai.mcp.server.version=0.0.1 + +logging.file.name=./model-context-protocol/weather/starter-webmvc-server/target/starter-webmvc-server.log diff --git a/model-context-protocol/mcp-annotations-server/src/test/java/org/springframework/ai/mcp/sample/client/ClientSse.java b/model-context-protocol/mcp-annotations-server/src/test/java/org/springframework/ai/mcp/sample/client/ClientSse.java new file mode 100644 index 0000000..a74e653 --- /dev/null +++ b/model-context-protocol/mcp-annotations-server/src/test/java/org/springframework/ai/mcp/sample/client/ClientSse.java @@ -0,0 +1,33 @@ +/* +* 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 org.springframework.ai.mcp.sample.client; + +import io.modelcontextprotocol.client.transport.HttpClientSseClientTransport; + + +/** + * @author Christian Tzolov + */ +public class ClientSse { + + public static void main(String[] args) { + + var transport = HttpClientSseClientTransport.builder("http://localhost:8080").build(); + + new SampleClient(transport).run(); + } + +} diff --git a/model-context-protocol/mcp-annotations-server/src/test/java/org/springframework/ai/mcp/sample/client/ClientStdio.java b/model-context-protocol/mcp-annotations-server/src/test/java/org/springframework/ai/mcp/sample/client/ClientStdio.java new file mode 100644 index 0000000..4343ab0 --- /dev/null +++ b/model-context-protocol/mcp-annotations-server/src/test/java/org/springframework/ai/mcp/sample/client/ClientStdio.java @@ -0,0 +1,49 @@ +/* +* 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 org.springframework.ai.mcp.sample.client; + +import java.io.File; + +import io.modelcontextprotocol.client.transport.ServerParameters; +import io.modelcontextprotocol.client.transport.StdioClientTransport; + +/** + * With stdio transport, the MCP server is automatically started by the client. + * But you + * have to build the server jar first: + * + *
+ * ./mvnw clean install -DskipTests
+ * 
+ */ +public class ClientStdio { + + public static void main(String[] args) { + + System.out.println(new File(".").getAbsolutePath()); + + var stdioParams = ServerParameters.builder("java") + .args("-Dspring.ai.mcp.server.stdio=true", "-Dspring.main.web-application-type=none", + "-Dlogging.pattern.console=", "-jar", + "model-context-protocol/weather/starter-webmvc-server/target/mcp-weather-starter-webmvc-server-0.0.1-SNAPSHOT.jar") + .build(); + + var transport = new StdioClientTransport(stdioParams); + + new SampleClient(transport).run(); + } + +} diff --git a/model-context-protocol/mcp-annotations-server/src/test/java/org/springframework/ai/mcp/sample/client/SampleClient.java b/model-context-protocol/mcp-annotations-server/src/test/java/org/springframework/ai/mcp/sample/client/SampleClient.java new file mode 100644 index 0000000..2ef43c3 --- /dev/null +++ b/model-context-protocol/mcp-annotations-server/src/test/java/org/springframework/ai/mcp/sample/client/SampleClient.java @@ -0,0 +1,97 @@ +/* +* 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 org.springframework.ai.mcp.sample.client; + +import java.util.Map; + +import io.modelcontextprotocol.client.McpClient; +import io.modelcontextprotocol.spec.McpClientTransport; +import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; +import io.modelcontextprotocol.spec.McpSchema.CallToolResult; +import io.modelcontextprotocol.spec.McpSchema.CompleteRequest; +import io.modelcontextprotocol.spec.McpSchema.CompleteResult; +import io.modelcontextprotocol.spec.McpSchema.GetPromptRequest; +import io.modelcontextprotocol.spec.McpSchema.GetPromptResult; +import io.modelcontextprotocol.spec.McpSchema.ListToolsResult; +import io.modelcontextprotocol.spec.McpSchema.ReadResourceRequest; +import io.modelcontextprotocol.spec.McpSchema.ReadResourceResult; +import io.modelcontextprotocol.spec.McpSchema.ResourceReference; +import io.modelcontextprotocol.spec.McpSchema.PromptReference; + +/** + * @author Christian Tzolov + */ + +public class SampleClient { + + private final McpClientTransport transport; + + public SampleClient(McpClientTransport transport) { + this.transport = transport; + } + + public void run() { + + var client = McpClient.sync(this.transport) + .loggingConsumer(message -> { + System.out.println(">> Client Logging: " + message); + }) + .build(); + + client.initialize(); + + client.ping(); + + // List and demonstrate tools + ListToolsResult toolsList = client.listTools(); + System.out.println("Available Tools = " + toolsList); + toolsList.tools().stream().forEach(tool -> { + System.out.println("Tool: " + tool.name() + ", description: " + tool.description() + ", schema: " + + tool.inputSchema()); + }); + + CallToolResult weatherForcastResult = client.callTool(new CallToolRequest("getWeatherForecastByLocation", + Map.of("latitude", "47.6062", "longitude", "-122.3321"))); + System.out.println("Weather Forcast: " + weatherForcastResult); + + + // // Resources + ReadResourceResult resource = client.readResource(new ReadResourceRequest("user-status://alice")); + + System.out.println("Resource = " + resource); + + // Prompts + GetPromptResult prompt = client.getPrompt( + new GetPromptRequest("personalized-message", Map.of("name", "Alice", "age", "14", "interests", "AI"))); + + System.out.println("Prompt = " + prompt); + + // Completions + CompleteResult completion = client.completeCompletion(new CompleteRequest(new ResourceReference("user-status://{username}"), + new CompleteRequest.CompleteArgument("username", "a"))); + + System.out.println("Completion = " + completion); + + CompleteResult completion2 = client.completeCompletion(new CompleteRequest(new PromptReference("personalized-message"), + new CompleteRequest.CompleteArgument("name", "a"))); + + System.out.println("Completion2 = " + completion2); + + client.closeGracefully(); + + } + +} diff --git a/pom.xml b/pom.xml index 8fd1224..e21a373 100644 --- a/pom.xml +++ b/pom.xml @@ -41,6 +41,8 @@ model-context-protocol/web-search/brave-chatbot model-context-protocol/sampling/mcp-weather-webmvc-server model-context-protocol/sampling/mcp-sampling-client + + model-context-protocol/mcp-annotations-server model-context-protocol/dynamic-tool-update/client model-context-protocol/dynamic-tool-update/server