diff --git a/mcp-server/README.adoc b/mcp-server/README.adoc new file mode 100644 index 0000000000..661ecaf1b7 --- /dev/null +++ b/mcp-server/README.adoc @@ -0,0 +1,63 @@ += mcp-server: quickstart for MCP server +:toc: left +:icons: font +:idprefix: +:idseparator: - +:keywords: mcp +:level: Intermediate +:technologies: Jakarta REST, MCP +:archiveType: war + +[abstract] +The `mcp-server` quickstart demonstrates how to implement MCP server resources with WildFly. + +== Build the Application + +[source,shell] +---- +mvn clean package +---- + +== Run WildFly + +[source,shell] +---- +./target/server/bin/standalone.sh --stability=experimental +---- + +== Connect with the MCP introspector + +You can use the MCP inspector to test your tools + +[source,shell] +---- +npx @modelcontextprotocol/inspector +---- + +Once you are on the http://127.0.0.1:6274[MPC inspector UI], you can connect to your WildFly server with the Streamable HTTP transport type using the parameters: + +* Transport Type: **Streamable HTTP** +* URL: **http://localhost:8080/mcp-server/stream** + +Alternatively, you can connect with the Server-Sent Events (SSE) transport type using the parameters: + +* Transport Type: **SSE** +* URL: **http://localhost:8080/mcp-server/sse** + +You should see all those tools. + +== Connect with Claude Code + +If you are using Claude Code, you can add this MCP server with: + +[source,shell] +---- +claude mcp add --scope project --transport http wildfly-quickstart http://localhost:8080/mcp-server/stream +---- + +You can then ask `claude` questions such as: + +* `What is the weather forecast for tomorrow at San Antonio?` +* `Are there any weather alerts for Denver?` +* `What is the length of the string "abcdef"?` + diff --git a/mcp-server/pom.xml b/mcp-server/pom.xml new file mode 100644 index 0000000000..6f291b40c0 --- /dev/null +++ b/mcp-server/pom.xml @@ -0,0 +1,188 @@ + + 4.0.0 + + org.wildfly.quickstarts + wildfly-quickstart-parent + + 12 + + + + mcp-server + 40.0.1.Final-SNAPSHOT + war + + Quickstart: mcp-server + + + + 17 + + 40.0.0.Final + + ${version.server} + ${version.server} + 6.0.0.Final + 0.11.0-SNAPSHOT + true + + + + + + + org.wildfly.bom + wildfly-expansion + ${version.bom.expansion} + pom + import + + + + org.wildfly.bom + wildfly-ee-with-tools + ${version.bom.ee} + pom + import + + + org.wildfly.generative-ai + wildfly-ai-bom + ${version.ai.feature-pack} + pom + import + + + + + + + jakarta.ws.rs + jakarta.ws.rs-api + provided + + + jakarta.json + jakarta.json-api + provided + + + org.mcp-java + mcp-server-api + provided + + + + + junit + junit + test + jar + + + + + + ${project.artifactId} + + + + org.wildfly.plugins + wildfly-maven-plugin + ${version.plugin.wildfly} + + + + + + org.asciidoctor + asciidoctor-maven-plugin + + + + ${version.bom.expansion} + + + + + + + + + provisioned-server + + true + + + + + org.wildfly.plugins + wildfly-maven-plugin + + + + org.wildfly:wildfly-galleon-pack:${version.server} + + + org.wildfly.generative-ai:wildfly-ai-feature-pack:${version.ai.feature-pack} + + + + jaxrs-server + mcp-server + + experimental + + + + + package + + + + + + + + + integration-testing + + + + org.apache.maven.plugins + maven-failsafe-plugin + + + + + + integration-test + verify + + + + + + + + + + + + jboss-public-maven-repository + JBoss Public Maven Repository + https://repository.jboss.org/nexus/content/groups/public/ + + + + + jboss-public-maven-repository + JBoss Public Maven Repository + https://repository.jboss.org/nexus/content/groups/public/ + + + diff --git a/mcp-server/src/main/java/org/wildfly/quickstart/mcp/SimpleTool.java b/mcp-server/src/main/java/org/wildfly/quickstart/mcp/SimpleTool.java new file mode 100644 index 0000000000..552abace56 --- /dev/null +++ b/mcp-server/src/main/java/org/wildfly/quickstart/mcp/SimpleTool.java @@ -0,0 +1,33 @@ +/* + * Copyright 2025 JBoss by Red Hat. + * + * 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 + * + * 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. + */ +package org.wildfly.quickstart.mcp; + + +import org.mcp_java.server.tools.Tool; +import org.mcp_java.server.tools.ToolArg; + +/* + * Example of a closed-world tool that does not rely on any external system + */ +public class SimpleTool { + + @Tool(name = "length-of-string", + description = "Calculates the length of a string", + annotations = @Tool.Annotations(openWorldHint = false, readOnlyHint = true, destructiveHint = false, idempotentHint = true)) + int stringLength(@ToolArg(description = "the string the length of of which we want to calculate") String string) { + return string.length(); + } +} diff --git a/mcp-server/src/main/java/org/wildfly/quickstart/mcp/WeatherApplication.java b/mcp-server/src/main/java/org/wildfly/quickstart/mcp/WeatherApplication.java new file mode 100644 index 0000000000..26834c8195 --- /dev/null +++ b/mcp-server/src/main/java/org/wildfly/quickstart/mcp/WeatherApplication.java @@ -0,0 +1,33 @@ +package org.wildfly.quickstart.mcp; + +import jakarta.json.Json; +import jakarta.ws.rs.ApplicationPath; +import jakarta.ws.rs.core.Application; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.ext.Provider; +import jakarta.ws.rs.ext.ReaderInterceptor; +import jakarta.ws.rs.ext.ReaderInterceptorContext; + +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; + +@ApplicationPath("/") +public class WeatherApplication extends Application { + + /** + * Interceptor to handle "application/geo+json" content as a {@code JsonObject}. + */ + @Provider + public static class GeoJsonReaderInterceptor implements ReaderInterceptor { + public static final MediaType APPLICATION_GEO_JSON_TYPE = new MediaType("application", "geo+json"); + @Override + public Object aroundReadFrom(ReaderInterceptorContext context) throws IOException { + if (context.getMediaType().isCompatible(APPLICATION_GEO_JSON_TYPE)) { + InputStreamReader reader = new InputStreamReader(context.getInputStream(), StandardCharsets.UTF_8); + return Json.createReader(reader).readObject(); + } + return context.proceed(); + } + } +} \ No newline at end of file diff --git a/mcp-server/src/main/java/org/wildfly/quickstart/mcp/WeatherClient.java b/mcp-server/src/main/java/org/wildfly/quickstart/mcp/WeatherClient.java new file mode 100644 index 0000000000..a7136ace36 --- /dev/null +++ b/mcp-server/src/main/java/org/wildfly/quickstart/mcp/WeatherClient.java @@ -0,0 +1,81 @@ +package org.wildfly.quickstart.mcp; + +import jakarta.json.JsonObject; +import jakarta.ws.rs.client.Client; +import jakarta.ws.rs.client.ClientBuilder; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; + +import java.text.DecimalFormat; +import java.text.DecimalFormatSymbols; +import java.util.List; +import java.util.Locale; + +public class WeatherClient { + + private static final String REST_URI = "https://api.weather.gov"; + + private static final Client HTTP_CLIENT = ClientBuilder.newClient() + .property("dev.resteasy.client.follow.redirects", "true"); + + Alerts getAlerts(String state) { + try (Response response = HTTP_CLIENT.target(REST_URI) + .path("/alerts/active/area/%s".formatted(state)) + .request(MediaType.APPLICATION_JSON).get();) { + return response.readEntity(Alerts.class); + } + } + + JsonObject getPoints(double latitude, double longitude) { + DecimalFormat decimal = new DecimalFormat("##.####", DecimalFormatSymbols.getInstance(Locale.US)); + String endpoint = "/points/%s,%s".formatted( + decimal.format(latitude), + decimal.format(longitude)); + Response response = HTTP_CLIENT.target(REST_URI) + .path(endpoint) + .request(MediaType.APPLICATION_JSON) + .get(); + return response.readEntity(JsonObject.class); + } + + Forecast getForecast(String url) { + return HTTP_CLIENT.target(url) + .request(MediaType.APPLICATION_JSON) + .get(Forecast.class); + } + + public record Properties( + String id, + String areaDesc, + String event, + String severity, + String description, + String instruction) {} + + public record Feature( + String id, + String type, + Object geometry, + Properties properties) {} + + public record Alerts( + List context, + String type, + List features, + String title, + String updated) {} + + public record Period( + String name, + int temperature, + String temperatureUnit, + String windSpeed, + String windDirection, + String detailedForecast) {} + + public record ForecastProperties( + List periods) {} + + public record Forecast( + ForecastProperties properties) {} +} diff --git a/mcp-server/src/main/java/org/wildfly/quickstart/mcp/WeatherTools.java b/mcp-server/src/main/java/org/wildfly/quickstart/mcp/WeatherTools.java new file mode 100644 index 0000000000..4067fd86ff --- /dev/null +++ b/mcp-server/src/main/java/org/wildfly/quickstart/mcp/WeatherTools.java @@ -0,0 +1,68 @@ +package org.wildfly.quickstart.mcp; + + +import org.mcp_java.server.tools.Tool; +import org.mcp_java.server.tools.ToolArg; + +import java.util.stream.Collectors; + +/** + * Example of "open-world" MCP tools that relies on an external system + * (provided by ...) to provide + * weather alerts and forecasts in the USA. + */ +public class WeatherTools { + + private final static WeatherClient WEATHER = new WeatherClient(); + + @Tool(description = "Get weather alerts for a US state.", + name = "alerts", + annotations = @Tool.Annotations(readOnlyHint = true, destructiveHint = false)) + public String getAlerts( + @ToolArg(description = "Two-letter US state code (e.g. CA, NY)") String state) { + return formatAlerts(WEATHER.getAlerts(state)); + } + + @Tool(description = "Get weather forecast for a location.", + annotations = @Tool.Annotations(readOnlyHint = true, destructiveHint = false)) + public String forecast( + @ToolArg(description = "Latitude of the location") double latitude, + @ToolArg(description = "Longitude of the location") double longitude) { + var points = WEATHER.getPoints(latitude, longitude); + var url = points.get("properties").asJsonObject().getString("forecast"); + + return formatForecast(WEATHER.getForecast(url)); + } + + private static String formatForecast(WeatherClient.Forecast forecast) { + return forecast.properties().periods().stream() + .map(WeatherTools::formatPeriod) + .collect(Collectors.joining("\n---\n")); + } + + private static String formatPeriod(WeatherClient.Period period) { + return """ + Temperature: %d°%s + Wind: %s %s + Forecast: %s + """.formatted(period.temperature(), period.temperatureUnit(), + period.windSpeed(), period.windDirection(), + period.detailedForecast()); + } + + private static String formatAlerts(WeatherClient.Alerts alerts) { + return alerts.features().stream() + .map(feature -> formatProperties(feature.properties())) + .collect(Collectors.joining("\n---\n")); + } + + private static String formatProperties(WeatherClient.Properties p) { + return """ + Event: %s + Area: %s + Severity: %s + Description: %s + Instructions: %s + """.formatted(p.event(), p.areaDesc(), p.severity(), p.description(), p.instruction()); + } +} diff --git a/mcp-server/src/main/webapp/WEB-INF/beans.xml b/mcp-server/src/main/webapp/WEB-INF/beans.xml new file mode 100644 index 0000000000..9dfae34df1 --- /dev/null +++ b/mcp-server/src/main/webapp/WEB-INF/beans.xml @@ -0,0 +1,6 @@ + + + \ No newline at end of file diff --git a/mcp-server/src/main/webapp/WEB-INF/web.xml b/mcp-server/src/main/webapp/WEB-INF/web.xml new file mode 100644 index 0000000000..4ae23ceae3 --- /dev/null +++ b/mcp-server/src/main/webapp/WEB-INF/web.xml @@ -0,0 +1,11 @@ + + + + + 30 + + + diff --git a/pom.xml b/pom.xml index 0bf26d8354..8a5b52475d 100644 --- a/pom.xml +++ b/pom.xml @@ -195,6 +195,7 @@ kitchensink logging mail + mcp-server messaging-clustering-singleton micrometer microprofile-config