diff --git a/examples/calendar/src/main/java/io/helidon/extensions/mcp/examples/calendar/ListCalendarEventTool.java b/examples/calendar/src/main/java/io/helidon/extensions/mcp/examples/calendar/ListCalendarEventTool.java new file mode 100644 index 00000000..8b73da4a --- /dev/null +++ b/examples/calendar/src/main/java/io/helidon/extensions/mcp/examples/calendar/ListCalendarEventTool.java @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2025 Oracle and/or its affiliates. + * + * 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 io.helidon.extensions.mcp.examples.calendar; + +import java.util.List; +import java.util.function.Function; + +import io.helidon.extensions.mcp.server.McpParameters; +import io.helidon.extensions.mcp.server.McpRequest; +import io.helidon.extensions.mcp.server.McpTool; +import io.helidon.extensions.mcp.server.McpToolContent; +import io.helidon.extensions.mcp.server.McpToolContents; + +/** + * MCP tool to list calendar events. Available as an alternative to using + * resources. + */ +final class ListCalendarEventTool implements McpTool { + private static final String SCHEMA = """ + { + "type": "object", + "description": "List calendar events", + "properties": { + "date": { + "description": "Event date in the following format YYYY-MM-DD", + "type": "string" + } + } + } + """; + + private final Calendar calendar; + + ListCalendarEventTool(Calendar calendar) { + this.calendar = calendar; + } + + @Override + public String name() { + return "list-calendar-event"; + } + + @Override + public String description() { + return "List calendar events."; + } + + @Override + public String schema() { + return SCHEMA; + } + + @Override + public Function> tool() { + return this::listCalendarEvents; + } + + private List listCalendarEvents(McpRequest request) { + McpParameters mcpParameters = request.parameters(); + + String date = mcpParameters.get("date") + .asString() + .orElse(null); + + String entries = calendar.readContentMatchesLine(line -> date == null || line.contains(date)); + return List.of(McpToolContents.textContent(entries)); + } +} diff --git a/examples/calendar/src/main/java/io/helidon/extensions/mcp/examples/calendar/Main.java b/examples/calendar/src/main/java/io/helidon/extensions/mcp/examples/calendar/Main.java index 5bd3b892..beff3093 100644 --- a/examples/calendar/src/main/java/io/helidon/extensions/mcp/examples/calendar/Main.java +++ b/examples/calendar/src/main/java/io/helidon/extensions/mcp/examples/calendar/Main.java @@ -52,6 +52,7 @@ static void setUpRoute(HttpRouting.Builder builder) { McpServerConfig.builder() .config(config.get("mcp.server")) .addTool(new AddCalendarEventTool(calendar)) + .addTool(new ListCalendarEventTool(calendar)) .addResource(new CalendarEventResource(calendar)) .addPrompt(new CreateCalendarEventPrompt()) .addCompletion(new CreateCalendarEventPromptCompletion()) diff --git a/examples/calendar/src/test/java/io/helidon/extensions/mcp/examples/calendar/BaseTest.java b/examples/calendar/src/test/java/io/helidon/extensions/mcp/examples/calendar/BaseTest.java index aa359ae9..8806839f 100644 --- a/examples/calendar/src/test/java/io/helidon/extensions/mcp/examples/calendar/BaseTest.java +++ b/examples/calendar/src/test/java/io/helidon/extensions/mcp/examples/calendar/BaseTest.java @@ -36,7 +36,6 @@ import static org.hamcrest.Matchers.hasItems; import static org.hamcrest.Matchers.instanceOf; import static org.hamcrest.Matchers.is; -import static org.hamcrest.Matchers.nullValue; import static org.hamcrest.Matchers.startsWith; @TestMethodOrder(OrderAnnotation.class) @@ -54,21 +53,31 @@ static void routing(HttpRouting.Builder builder) { void testToolList() { McpSchema.ListToolsResult listTool = client().listTools(); List tools = listTool.tools(); - assertThat(tools.size(), is(1)); + assertThat(tools.size(), is(2)); - McpSchema.Tool tool = tools.getFirst(); - assertThat(tool.name(), is("add-calendar-event")); - assertThat(tool.description(), is("Adds a new event to the calendar.")); + McpSchema.Tool tool1 = tools.getFirst(); + assertThat(tool1.name(), is("add-calendar-event")); + assertThat(tool1.description(), is("Adds a new event to the calendar.")); - McpSchema.JsonSchema schema = tool.inputSchema(); - assertThat(schema.type(), is("object")); - assertThat(schema.properties().keySet(), hasItems("name", "date", "attendees")); + McpSchema.JsonSchema schema1 = tool1.inputSchema(); + assertThat(schema1.type(), is("object")); + assertThat(schema1.properties().keySet(), hasItems("name", "date", "attendees")); + + McpSchema.Tool tool2 = tools.getLast(); + assertThat(tool2.name(), is("list-calendar-event")); + assertThat(tool2.description(), is("List calendar events.")); + + McpSchema.JsonSchema schema2 = tool2.inputSchema(); + assertThat(schema2.type(), is("object")); + assertThat(schema2.properties().keySet(), hasItems("date")); } @Test @Order(2) - void testToolCall() { - Map arguments = Map.of("name", "Frank-birthday", "date", "2021-04-20", "attendees", List.of("Frank")); + void testAddToolCall() { + Map arguments = Map.of("name", "Frank-birthday", + "date", "2021-04-20", + "attendees", List.of("Frank")); McpSchema.CallToolRequest request = new McpSchema.CallToolRequest("add-calendar-event", arguments); McpSchema.CallToolResult result = client().callTool(request); assertThat(result.isError(), is(false)); @@ -86,6 +95,25 @@ void testToolCall() { @Test @Order(3) + void testListToolCall() { + Map arguments = Map.of("date", "2021-04-20"); + McpSchema.CallToolRequest request = new McpSchema.CallToolRequest("list-calendar-event", arguments); + McpSchema.CallToolResult result = client().callTool(request); + assertThat(result.isError(), is(false)); + + List contents = result.content(); + assertThat(contents.size(), is(1)); + + McpSchema.Content content = contents.getFirst(); + assertThat(content.type(), is("text")); + assertThat(content, instanceOf(McpSchema.TextContent.class)); + + McpSchema.TextContent textContent = (McpSchema.TextContent) content; + assertThat(textContent.text(), containsString("Frank-birthday")); + } + + @Test + @Order(4) void testPromptList() { McpSchema.ListPromptsResult listPrompt = client().listPrompts(); List prompts = listPrompt.prompts(); @@ -116,7 +144,7 @@ void testPromptList() { } @Test - @Order(4) + @Order(5) void testPromptCall() { Map arguments = Map.of("name", "Frank-birthday", "date", "2021-04-20", "attendees", "Frank"); McpSchema.GetPromptRequest request = new McpSchema.GetPromptRequest("create-event", arguments); @@ -138,7 +166,7 @@ void testPromptCall() { } @Test - @Order(5) + @Order(6) void testResourceList() { McpSchema.ListResourcesResult result = client().listResources(); List resources = result.resources(); @@ -152,7 +180,7 @@ void testResourceList() { } @Test - @Order(6) + @Order(7) void testResourceCall() { String uri = client().listResources().resources().getFirst().uri(); McpSchema.ReadResourceRequest request = new McpSchema.ReadResourceRequest(uri); @@ -171,7 +199,7 @@ void testResourceCall() { } @Test - @Order(7) + @Order(8) void testResourceTemplateList() { McpSchema.ListResourceTemplatesResult result = client().listResourceTemplates(); List templates = result.resourceTemplates(); @@ -185,7 +213,7 @@ void testResourceTemplateList() { } @Test - @Order(8) + @Order(9) void testResourceTemplateCall() { McpSchema.ReadResourceRequest request = new McpSchema.ReadResourceRequest("file://events/Frank-birthday"); McpSchema.ReadResourceResult result = client().readResource(request); @@ -202,7 +230,7 @@ void testResourceTemplateCall() { } @Test - @Order(9) + @Order(10) void testCalendarEventPromptCompletion() { McpSchema.CompleteRequest request1 = new McpSchema.CompleteRequest( new McpSchema.PromptReference("create-event"), @@ -231,7 +259,7 @@ void testCalendarEventPromptCompletion() { } @Test - @Order(10) + @Order(11) void testCalendarEventResourceCompletion() { McpSchema.CompleteRequest request = new McpSchema.CompleteRequest( new McpSchema.ResourceReference(URI_TEMPLATE), diff --git a/examples/weather-application/mcp-server-declarative/src/main/resources/logging.properties b/examples/weather-application/mcp-server-declarative/src/main/resources/logging.properties new file mode 100644 index 00000000..9b6a7a7b --- /dev/null +++ b/examples/weather-application/mcp-server-declarative/src/main/resources/logging.properties @@ -0,0 +1,22 @@ +# +# Copyright (c) 2025 Oracle and/or its affiliates. +# +# 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. +# +handlers=io.helidon.logging.jul.HelidonConsoleHandler + +# HelidonConsoleHandler uses a SimpleFormatter subclass that replaces "!thread!" with the current thread +java.util.logging.SimpleFormatter.format=%1$tY.%1$tm.%1$td %1$tH:%1$tM:%1$tS %4$s %3$s !thread!: %5$s%6$s%n + +.level=INFO +io.helidon.extensions.mcp.server.McpServerFeature.level=FINEST diff --git a/server/src/main/java/io/helidon/extensions/mcp/server/McpJsonRpc.java b/server/src/main/java/io/helidon/extensions/mcp/server/McpJsonRpc.java index 8499aa54..680f862a 100644 --- a/server/src/main/java/io/helidon/extensions/mcp/server/McpJsonRpc.java +++ b/server/src/main/java/io/helidon/extensions/mcp/server/McpJsonRpc.java @@ -16,7 +16,9 @@ package io.helidon.extensions.mcp.server; +import java.io.ByteArrayOutputStream; import java.io.StringReader; +import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Map; import java.util.Set; @@ -28,11 +30,17 @@ import jakarta.json.JsonObject; import jakarta.json.JsonObjectBuilder; import jakarta.json.JsonReaderFactory; +import jakarta.json.JsonStructure; import jakarta.json.JsonValue; +import jakarta.json.JsonWriter; +import jakarta.json.JsonWriterFactory; +import jakarta.json.stream.JsonGenerator; final class McpJsonRpc { static final JsonBuilderFactory JSON_BUILDER_FACTORY = Json.createBuilderFactory(Map.of()); static final JsonReaderFactory JSON_READER_FACTORY = Json.createReaderFactory(Map.of()); + static final JsonWriterFactory JSON_PP_WRITER_FACTORY = Json.createWriterFactory( + Map.of(JsonGenerator.PRETTY_PRINTING, true)); private static final Map CACHE = new ConcurrentHashMap<>(); private static final JsonObject EMPTY_OBJECT_SCHEMA = JSON_BUILDER_FACTORY.createObjectBuilder() @@ -481,4 +489,12 @@ static JsonObject toJson(McpCompletionContent content) { static JsonObject disconnectSession() { return JSON_BUILDER_FACTORY.createObjectBuilder().add("disconnect", true).build(); } + + static String prettyPrint(JsonStructure json) { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (JsonWriter writer = JSON_PP_WRITER_FACTORY.createWriter(baos)) { + writer.write(json); + } + return baos.toString(StandardCharsets.UTF_8); + } } diff --git a/server/src/main/java/io/helidon/extensions/mcp/server/McpServerFeature.java b/server/src/main/java/io/helidon/extensions/mcp/server/McpServerFeature.java index ff3b426b..dda9d385 100644 --- a/server/src/main/java/io/helidon/extensions/mcp/server/McpServerFeature.java +++ b/server/src/main/java/io/helidon/extensions/mcp/server/McpServerFeature.java @@ -79,6 +79,7 @@ import static io.helidon.extensions.mcp.server.McpJsonRpc.listResourceTemplates; import static io.helidon.extensions.mcp.server.McpJsonRpc.listResources; import static io.helidon.extensions.mcp.server.McpJsonRpc.listTools; +import static io.helidon.extensions.mcp.server.McpJsonRpc.prettyPrint; import static io.helidon.extensions.mcp.server.McpJsonRpc.readResource; import static io.helidon.extensions.mcp.server.McpJsonRpc.toJson; import static io.helidon.extensions.mcp.server.McpJsonRpc.toolCall; @@ -386,6 +387,11 @@ private void notificationCancelRpc(JsonRpcRequest req, JsonRpcResponse res) { private void initializeRpc(JsonRpcRequest req, JsonRpcResponse res) { Optional foundSession = findSession(req); + // log initial request + if (LOGGER.isLoggable(Level.FINEST)) { + LOGGER.log(Level.FINEST, "Request:\n" + prettyPrint(req.asJsonObject())); + } + // is this streamable HTTP? if (foundSession.isEmpty()) { // create a new session @@ -403,7 +409,7 @@ private void initializeRpc(JsonRpcRequest req, JsonRpcResponse res) { session.protocolVersion(protocolVersion); res.header(SESSION_ID_HEADER, sessionId); res.result(toJson(protocolVersion, capabilities, config)); - LOGGER.log(Level.FINEST, () -> String.format("Streamable HTTP: %s", res.asJsonObject())); + LOGGER.log(Level.FINEST, "Streamable HTTP transport"); res.send(); } else { McpSession session = foundSession.get(); @@ -415,9 +421,14 @@ private void initializeRpc(JsonRpcRequest req, JsonRpcResponse res) { session.state(INITIALIZING); } res.result(toJson(protocolVersion, capabilities, config)); - LOGGER.log(Level.FINEST, () -> String.format("SSE: %s", res.asJsonObject())); + LOGGER.log(Level.FINEST, "SSE transport"); session.send(res); } + + // log initial response + if (LOGGER.isLoggable(Level.FINEST)) { + LOGGER.log(Level.FINEST, "Response:\n" + prettyPrint(res.asJsonObject())); + } } private String parseClientVersion(McpParameters parameters) { @@ -905,13 +916,14 @@ private void processSimpleCall(JsonRpcRequest req, JsonRpcResponse res, Consumer * @param session the active session */ private void sendResponse(JsonRpcRequest req, JsonRpcResponse res, McpSession session) { + if (LOGGER.isLoggable(Level.FINEST)) { + LOGGER.log(Level.FINEST, "Request:\n" + prettyPrint(req.asJsonObject())); + LOGGER.log(Level.FINEST, "Response:\n" + prettyPrint(res.asJsonObject())); + } + if (isStreamableHttp(req.headers())) { - LOGGER.log(Level.FINEST, - () -> String.format("Streamable HTTP: %s", res.asJsonObject())); res.send(); } else { - LOGGER.log(Level.FINEST, - () -> String.format("SSE: %s", res.asJsonObject())); session.send(res); } } @@ -945,26 +957,25 @@ private void sendResponse(JsonRpcRequest req, McpSession session, JsonValue requestId, SseSink sseSink) { + if (LOGGER.isLoggable(Level.FINEST)) { + LOGGER.log(Level.FINEST, "Request:\n" + prettyPrint(req.asJsonObject())); + LOGGER.log(Level.FINEST, "Response:\n" + prettyPrint(res.asJsonObject())); + } + // send response as HTTP or SSE with streamable HTTP if (isStreamableHttp(req.headers())) { if (sseSink != null) { try (sseSink) { // closes sink JsonObject jsonObject = res.asJsonObject(); - LOGGER.log(Level.FINEST, - () -> String.format("Streamable HTTP: %s", jsonObject)); sseSink.emit(SseEvent.builder() .name("message") .data(jsonObject) .build()); } } else { - LOGGER.log(Level.FINEST, - () -> String.format("HTTP: %s", res.asJsonObject())); res.send(); } } else { - LOGGER.log(Level.FINEST, - () -> String.format("SSE: %s", res.asJsonObject())); session.send(res); } session.clearRequest(requestId); @@ -1038,11 +1049,15 @@ private Optional sendError(JsonRpcRequest request, if (features.isPresent()) { sseSink = features.get().sseSink().orElse(null); } + + if (LOGGER.isLoggable(Level.FINEST)) { + LOGGER.log(Level.FINEST, "Request:\n" + prettyPrint(request.asJsonObject())); + LOGGER.log(Level.FINEST, "Response:\n" + prettyPrint(response.asJsonObject())); + } + // If streamable HTTP transport and did not switch to SSE // the handler manages the response if (isStreamableHttp(request.headers()) && sseSink == null) { - LOGGER.log(Level.FINEST, - () -> String.format("HTTP: %s", response.asJsonObject())); session.get().clearRequest(requestId); return response.error(); } diff --git a/server/src/main/java/io/helidon/extensions/mcp/server/McpSession.java b/server/src/main/java/io/helidon/extensions/mcp/server/McpSession.java index 6b80a7d6..78e1e385 100644 --- a/server/src/main/java/io/helidon/extensions/mcp/server/McpSession.java +++ b/server/src/main/java/io/helidon/extensions/mcp/server/McpSession.java @@ -90,7 +90,7 @@ void poll(Consumer consumer) { } consumer.accept(message); } catch (Exception e) { - throw new McpInternalException("Session interrupted.", e); + LOGGER.log(Level.FINEST, "Session interrupted."); } } }