diff --git a/components-starter/camel-mcp-server-starter/pom.xml b/components-starter/camel-mcp-server-starter/pom.xml new file mode 100644 index 00000000000..93922ae05f4 --- /dev/null +++ b/components-starter/camel-mcp-server-starter/pom.xml @@ -0,0 +1,92 @@ + + + + 4.0.0 + + org.apache.camel.springboot + components-starter + 4.22.0-SNAPSHOT + + camel-mcp-server-starter + jar + Camel SB Starters :: MCP Server + Spring Boot Starter to expose ai-tool routes as MCP tools through the Spring AI MCP server + + + 2.0.0 + + + + org.springframework.boot + spring-boot-starter + ${spring-boot-version} + + + + org.apache.camel + camel-mcp-server-api + ${camel-version} + + + org.springframework.ai + spring-ai-starter-mcp-server-webmvc + ${spring-ai-mcp-version} + + + + org.springframework.boot + spring-boot-starter-web + ${spring-boot-version} + test + + + org.apache.camel + camel-ai-tool + ${camel-version} + test + + + org.awaitility + awaitility + test + + + + org.apache.camel.springboot + camel-core-starter + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + true + + + + + diff --git a/components-starter/camel-mcp-server-starter/src/main/doc/intro.adoc b/components-starter/camel-mcp-server-starter/src/main/doc/intro.adoc new file mode 100644 index 00000000000..4af6d66884c --- /dev/null +++ b/components-starter/camel-mcp-server-starter/src/main/doc/intro.adoc @@ -0,0 +1,14 @@ +The MCP Server starter exposes Camel routes registered via the `ai-tool` +component as tools of a Model Context Protocol (MCP) server, served through +the Spring AI MCP server over streamable HTTP. No route is needed for the +server itself: add the starter, tag the `ai-tool` routes to expose, and any +MCP client (another Camel application, an IDE, a coding agent) can discover +and call them. + +Tool semantics — tag-based opt-in (the untagged default pool is never +exposed), flat-namespace collision refusal, per-call timeout and error +sanitization — are owned by the runtime-agnostic `camel-mcp-server-api` +bridge and are identical on every Camel runtime. Serving concerns (endpoint +path, protocol, server identity, authentication) are owned by the Spring AI +MCP server and configured via `spring.ai.mcp.server.*`; use +`spring.ai.mcp.server.protocol=STREAMABLE` for the streamable HTTP transport. diff --git a/components-starter/camel-mcp-server-starter/src/main/doc/usage.adoc b/components-starter/camel-mcp-server-starter/src/main/doc/usage.adoc new file mode 100644 index 00000000000..1eaa70ceae3 --- /dev/null +++ b/components-starter/camel-mcp-server-starter/src/main/doc/usage.adoc @@ -0,0 +1,210 @@ +Define tools as regular `ai-tool` routes and give them tags: + +[source,java] +---- +from("ai-tool:query_db?tags=crm&description=Query customer database" + + "¶meter.customerId=string¶meter.customerId.required=true") + .to("jdbc:dataSource"); +---- + +or in YAML DSL: + +[source,yaml] +---- +- route: + from: + uri: "ai-tool:send_email" + parameters: + description: "Send email notification" + tags: "notify" + parameter.to: string + parameter.to.description: "Recipient address" + parameter.to.required: "true" + parameter.subject: string + parameter.priority: string + parameter.priority.enum: "low,normal,high" + steps: + - to: "smtp://mail.example.com" +---- + +Tool parameters are declared with the `parameter.NAME` options: the value is +the JSON type (`string`, `integer`, `number`, `boolean`), and the +`parameter.NAME.description`, `parameter.NAME.required` and +`parameter.NAME.enum` options refine the generated JSON Schema. Arguments +arrive as message headers in the route (`${header.customerId}`). + +Select the tags to expose in `application.properties`: + +[source,properties] +---- +camel.mcp-server.tags = crm,notify + +# per-call execution timeout (milliseconds, default 20000) +camel.mcp-server.tool-timeout = 10000 +---- + +The streamable HTTP transport has to be selected explicitly with +`spring.ai.mcp.server.protocol=STREAMABLE`; when the property is not set, +Spring AI serves the deprecated SSE transport instead and there is no +endpoint at `/mcp`. + +Serving concerns are configured on the Spring AI MCP server — see the +https://docs.spring.io/spring-ai/reference/api/mcp/mcp-streamable-http-server-boot-starter-docs.html[Spring AI MCP server documentation] +for the full list of `spring.ai.mcp.server.*` options. For example: + +[source,properties] +---- +spring.ai.mcp.server.protocol = STREAMABLE +spring.ai.mcp.server.name = my-integration-app +spring.ai.mcp.server.version = 1.0.0 +spring.ai.mcp.server.streamable-http.mcp-endpoint = /mcp +---- + +=== Connecting MCP clients + +Any MCP client can connect over streamable HTTP. Another Camel integration +can consume the tools with the camel-openai MCP client and automatic tool +execution: + +[source,java] +---- +from("direct:agent") + .to("openai:chat-completion" + + "?model={{llm.model}}" + + "&autoToolExecution=true" + + "&mcpServer.myCamelTools.transportType=streamableHttp" + + "&mcpServer.myCamelTools.url=http://localhost:8080/mcp"); +---- + +A coding agent or IDE is configured with the same URL, e.g. in an +`mcp.json`-style client configuration: + +[source,json] +---- +{ + "mcpServers": { + "my-integration-app": { + "type": "http", + "url": "http://localhost:8080/mcp" + } + } +} +---- + +=== Serving over stdio + +Out of the box the tools are served over streamable HTTP: the starter brings +in `spring-ai-starter-mcp-server-webmvc`. MCP clients that launch the server +as a subprocess speak over stdin/stdout instead, which Spring AI serves with +the plain `spring-ai-starter-mcp-server`. Swap the transport starter: + +[source,xml] +---- + + org.apache.camel.springboot + camel-mcp-server-starter + + + org.springframework.ai + spring-ai-starter-mcp-server-webmvc + + + + + org.springframework.ai + spring-ai-starter-mcp-server + 2.0.0 + +---- + +and enable the stdio transport: + +[source,properties] +---- +spring.ai.mcp.server.stdio = true +spring.main.web-application-type = none +spring.main.banner-mode = off + +# stdout carries the MCP protocol, so nothing else may be written to it +logging.threshold.console = OFF +logging.file.name = my-integration-app.log +---- + +Because stdout carries the MCP protocol, the application must not run as a +web application, the banner has to be switched off and console logging has +to be turned off — writing the log to a file instead. Leaving console +logging on corrupts the protocol stream, as the Spring Boot startup log is +then interleaved with the JSON-RPC messages. + +A stdio server is launched by the client instead of being connected to over +a URL: + +[source,json] +---- +{ + "mcpServers": { + "my-integration-app": { + "command": "java", + "args": ["-jar", "/path/to/my-integration-app-1.0.0.jar"] + } + } +} +---- + +See the +https://docs.spring.io/spring-ai/reference/api/mcp/mcp-server-boot-starter-docs.html[Spring AI MCP Server Boot Starter documentation] +for the transport options and the full list of `spring.ai.mcp.server.*` +properties. On Quarkus the equivalent setup is described in the +https://docs.quarkiverse.io/quarkus-mcp-server/dev/getting-started-stdio.html[quarkus-mcp-server stdio guide]. + +=== Mixing with Spring-defined tools + +Camel tools coexist with tools defined natively in Spring AI — both are +served by the same MCP server and appear in the same `tools/list`. For +example, a `@McpTool`-annotated bean: + +[source,java] +---- +@Component +public class CalculatorTools { + + @McpTool(name = "add_numbers", description = "Add two numbers") + public String add( + @McpToolParam(description = "First addend", required = true) int a, + @McpToolParam(description = "Second addend", required = true) int b) { + return String.valueOf(a + b); + } +} +---- + +is exposed alongside the `ai-tool` routes. Spring-defined tools are +registered when the server is created; Camel tools are added and removed +dynamically with the route lifecycle. Choose distinct tool names — MCP has a +flat tool namespace. + +=== Dynamic tools + +The exposed tool list follows the route lifecycle: stopping or suspending an +`ai-tool` route removes its tool, starting or resuming it publishes the tool +again, and connected clients are notified via +`notifications/tools/list_changed`: + +[source,java] +---- +camelContext.getRouteController().stopRoute("query-db-route"); // tool disappears +camelContext.getRouteController().startRoute("query-db-route"); // tool is back +---- + +=== Error handling + +Results returned to MCP clients are sanitized by the bridge: a route +exception produces an `isError` result with the generic message +`Tool execution failed` (the cause is logged server-side and never sent to +the client), a missing or invalid argument returns the validation message, +and a call exceeding `camel.mcp-server.tool-timeout` returns +`Tool execution timed out` while the route keeps running until it completes +on its own. + +On Camel Main and Camel JBang the equivalent setup is the `camel-mcp-server` +module with the `camel.server.mcp-*` options; on Quarkus it is the +`camel-quarkus-mcp-server` extension. diff --git a/components-starter/camel-mcp-server-starter/src/main/docs/mcp-server.json b/components-starter/camel-mcp-server-starter/src/main/docs/mcp-server.json new file mode 100644 index 00000000000..ecb58231709 --- /dev/null +++ b/components-starter/camel-mcp-server-starter/src/main/docs/mcp-server.json @@ -0,0 +1,35 @@ +{ + "groups": [ + { + "name": "camel.mcp-server", + "type": "org.apache.camel.springboot.mcp.server.McpServerConfigurationProperties", + "sourceType": "org.apache.camel.springboot.mcp.server.McpServerConfigurationProperties" + } + ], + "properties": [ + { + "name": "camel.mcp-server.enabled", + "type": "java.lang.Boolean", + "description": "Whether to expose ai-tool routes as MCP tools through the Spring AI MCP server. Enabled by default when the starter is on the classpath.", + "sourceType": "org.apache.camel.springboot.mcp.server.McpServerConfigurationProperties", + "defaultValue": true + }, + { + "name": "camel.mcp-server.tags", + "type": "java.lang.String", + "description": "Comma-separated list of ai-tool tags to expose as MCP tools. Only tools registered under one of these tags are exposed; the untagged default pool is never exposed. When not set, no tools are exposed.", + "sourceType": "org.apache.camel.springboot.mcp.server.McpServerConfigurationProperties" + }, + { + "name": "camel.mcp-server.tool-timeout", + "type": "java.lang.Long", + "description": "Per-call tool execution timeout in milliseconds. A call exceeding the timeout returns an error result to the MCP client; the underlying route keeps running until it completes on its own.", + "sourceType": "org.apache.camel.springboot.mcp.server.McpServerConfigurationProperties", + "defaultValue": 20000 + } + ], + "hints": [], + "ignored": { + "properties": [] + } +} \ No newline at end of file diff --git a/components-starter/camel-mcp-server-starter/src/main/java/org/apache/camel/springboot/mcp/server/CamelMcpServerAutoConfiguration.java b/components-starter/camel-mcp-server-starter/src/main/java/org/apache/camel/springboot/mcp/server/CamelMcpServerAutoConfiguration.java new file mode 100644 index 00000000000..4c9c1bac7f2 --- /dev/null +++ b/components-starter/camel-mcp-server-starter/src/main/java/org/apache/camel/springboot/mcp/server/CamelMcpServerAutoConfiguration.java @@ -0,0 +1,63 @@ +/* + * 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. + */ +package org.apache.camel.springboot.mcp.server; + +import io.modelcontextprotocol.server.McpSyncServer; +import org.apache.camel.CamelContext; +import org.apache.camel.component.mcp.server.McpServerBridge; +import org.apache.camel.component.mcp.server.McpServerConfiguration; +import org.apache.camel.component.mcp.server.McpServerEngine; +import org.apache.camel.spring.boot.CamelAutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; + +/** + * Auto-configures the Camel MCP server bridge over the Spring AI MCP server: every ai-tool route whose tags match + * {@code camel.mcp-server.tags} is exposed as an MCP tool of the auto-configured {@link McpSyncServer}. + */ +@AutoConfiguration(after = CamelAutoConfiguration.class, + afterName = "org.springframework.ai.mcp.server.common.autoconfigure.McpServerAutoConfiguration") +@ConditionalOnBean(McpSyncServer.class) +@ConditionalOnProperty(value = "camel.mcp-server.enabled", matchIfMissing = true) +@EnableConfigurationProperties(McpServerConfigurationProperties.class) +public class CamelMcpServerAutoConfiguration { + + @Bean(initMethod = "", destroyMethod = "") + // Camel handles the lifecycle of this bean + @ConditionalOnMissingBean(McpServerEngine.class) + McpServerEngine camelMcpServerEngine(McpSyncServer mcpSyncServer) { + return new SpringAiMcpServerEngine(mcpSyncServer); + } + + @Bean(initMethod = "", destroyMethod = "") + // Camel handles the lifecycle of this bean + @ConditionalOnMissingBean(McpServerBridge.class) + McpServerBridge camelMcpServerBridge( + CamelContext camelContext, McpServerConfigurationProperties properties) + throws Exception { + McpServerConfiguration configuration = new McpServerConfiguration(); + configuration.setTags(properties.getTags()); + configuration.setToolTimeout(properties.getToolTimeout()); + McpServerBridge bridge = new McpServerBridge(configuration); + camelContext.addService(bridge); + return bridge; + } +} diff --git a/components-starter/camel-mcp-server-starter/src/main/java/org/apache/camel/springboot/mcp/server/McpServerConfigurationProperties.java b/components-starter/camel-mcp-server-starter/src/main/java/org/apache/camel/springboot/mcp/server/McpServerConfigurationProperties.java new file mode 100644 index 00000000000..b5023fe62b4 --- /dev/null +++ b/components-starter/camel-mcp-server-starter/src/main/java/org/apache/camel/springboot/mcp/server/McpServerConfigurationProperties.java @@ -0,0 +1,69 @@ +/* + * 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. + */ +package org.apache.camel.springboot.mcp.server; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Bridge-owned configuration of the Camel MCP server. Serving concerns (endpoint path, protocol, server identity, + * authentication) are owned by the Spring AI MCP server and configured via {@code spring.ai.mcp.server.*}. + */ +@ConfigurationProperties(prefix = "camel.mcp-server") +public class McpServerConfigurationProperties { + + /** + * Whether to expose ai-tool routes as MCP tools through the Spring AI MCP server. Enabled by default when the + * starter is on the classpath. + */ + private boolean enabled = true; + + /** + * Comma-separated list of ai-tool tags to expose as MCP tools. Only tools registered under one of these tags are + * exposed; the untagged default pool is never exposed. When not set, no tools are exposed. + */ + private String tags; + + /** + * Per-call tool execution timeout in milliseconds. A call exceeding the timeout returns an error result to the MCP + * client; the underlying route keeps running until it completes on its own. + */ + private long toolTimeout = 20000; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getTags() { + return tags; + } + + public void setTags(String tags) { + this.tags = tags; + } + + public long getToolTimeout() { + return toolTimeout; + } + + public void setToolTimeout(long toolTimeout) { + this.toolTimeout = toolTimeout; + } +} diff --git a/components-starter/camel-mcp-server-starter/src/main/java/org/apache/camel/springboot/mcp/server/SpringAiMcpServerEngine.java b/components-starter/camel-mcp-server-starter/src/main/java/org/apache/camel/springboot/mcp/server/SpringAiMcpServerEngine.java new file mode 100644 index 00000000000..aeb5bd24f3e --- /dev/null +++ b/components-starter/camel-mcp-server-starter/src/main/java/org/apache/camel/springboot/mcp/server/SpringAiMcpServerEngine.java @@ -0,0 +1,107 @@ +/* + * 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. + */ +package org.apache.camel.springboot.mcp.server; + +import java.util.Map; + +import io.modelcontextprotocol.json.McpJsonDefaults; +import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.server.McpServerFeatures; +import io.modelcontextprotocol.server.McpSyncServer; +import io.modelcontextprotocol.spec.McpSchema; +import org.apache.camel.CamelContext; +import org.apache.camel.component.mcp.server.McpServerEngine; +import org.apache.camel.component.mcp.server.McpServerInfo; +import org.apache.camel.component.mcp.server.McpServerTool; +import org.apache.camel.component.mcp.server.McpToolCallResult; +import org.apache.camel.support.service.ServiceSupport; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * {@link McpServerEngine} publishing tools into the Spring AI MCP server: {@code toolAdded}/{@code toolRemoved} map to + * the auto-configured {@link McpSyncServer}'s {@code addTool}/{@code removeTool}, which emit + * {@code notifications/tools/list_changed} to connected clients. Serving concerns (endpoint path, protocol, server + * identity, authentication) are owned by the Spring AI MCP server configuration ({@code spring.ai.mcp.server.*}). + */ +public class SpringAiMcpServerEngine extends ServiceSupport implements McpServerEngine { + + private static final Logger LOG = LoggerFactory.getLogger(SpringAiMcpServerEngine.class); + + private static final String EMPTY_OBJECT_SCHEMA = """ + { + "type": "object", + "properties": {}, + "additionalProperties": false + } + """; + + private final McpSyncServer server; + private final McpJsonMapper jsonMapper = McpJsonDefaults.getMapper(); + private CamelContext camelContext; + + public SpringAiMcpServerEngine(McpSyncServer server) { + this.server = server; + } + + @Override + public CamelContext getCamelContext() { + return camelContext; + } + + @Override + public void setCamelContext(CamelContext camelContext) { + this.camelContext = camelContext; + } + + @Override + public void initialize(McpServerInfo info) { + // serving identity is owned by spring.ai.mcp.server.*; the hint is deliberately ignored + LOG.debug("Spring AI MCP server engine initialized; serving configuration is owned by spring.ai.mcp.server.*"); + } + + @Override + public void toolAdded(McpServerTool tool) { + String schema = tool.inputSchemaJson() != null ? tool.inputSchemaJson() : EMPTY_OBJECT_SCHEMA; + McpSchema.Tool mcpTool = McpSchema.Tool.builder(tool.name(), jsonMapper, schema) + .description(tool.description()) + .build(); + McpServerFeatures.SyncToolSpecification spec = McpServerFeatures.SyncToolSpecification.builder() + .tool(mcpTool) + .callHandler((exchange, request) -> { + Map arguments = request.arguments() != null ? request.arguments() : Map.of(); + McpToolCallResult result = tool.handler().call(arguments); + return McpSchema.CallToolResult.builder() + .addTextContent(result.text()) + .isError(result.isError()) + .build(); + }) + .build(); + server.addTool(spec); + LOG.debug("MCP tool added: {}", tool.name()); + } + + @Override + public void toolRemoved(String toolName) { + try { + server.removeTool(toolName); + LOG.debug("MCP tool removed: {}", toolName); + } catch (Exception e) { + LOG.debug("Failed to remove MCP tool {}: {}", toolName, e.getMessage()); + } + } +} diff --git a/components-starter/camel-mcp-server-starter/src/main/resources/META-INF/LICENSE.txt b/components-starter/camel-mcp-server-starter/src/main/resources/META-INF/LICENSE.txt new file mode 100644 index 00000000000..6b0b1270ff0 --- /dev/null +++ b/components-starter/camel-mcp-server-starter/src/main/resources/META-INF/LICENSE.txt @@ -0,0 +1,203 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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. + diff --git a/components-starter/camel-mcp-server-starter/src/main/resources/META-INF/NOTICE.txt b/components-starter/camel-mcp-server-starter/src/main/resources/META-INF/NOTICE.txt new file mode 100644 index 00000000000..2e215bf2e6b --- /dev/null +++ b/components-starter/camel-mcp-server-starter/src/main/resources/META-INF/NOTICE.txt @@ -0,0 +1,11 @@ + ========================================================================= + == NOTICE file corresponding to the section 4 d of == + == the Apache License, Version 2.0, == + == in this case for the Apache Camel distribution. == + ========================================================================= + + This product includes software developed by + The Apache Software Foundation (http://www.apache.org/). + + Please read the different LICENSE files present in the licenses directory of + this distribution. diff --git a/components-starter/camel-mcp-server-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/components-starter/camel-mcp-server-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000000..4e2892e1553 --- /dev/null +++ b/components-starter/camel-mcp-server-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,17 @@ +## --------------------------------------------------------------------------- +## 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. +## --------------------------------------------------------------------------- +org.apache.camel.springboot.mcp.server.CamelMcpServerAutoConfiguration diff --git a/components-starter/camel-mcp-server-starter/src/test/java/org/apache/camel/springboot/mcp/server/CamelMcpServerAutoConfigurationTest.java b/components-starter/camel-mcp-server-starter/src/test/java/org/apache/camel/springboot/mcp/server/CamelMcpServerAutoConfigurationTest.java new file mode 100644 index 00000000000..d7f70c20109 --- /dev/null +++ b/components-starter/camel-mcp-server-starter/src/test/java/org/apache/camel/springboot/mcp/server/CamelMcpServerAutoConfigurationTest.java @@ -0,0 +1,74 @@ +/* + * 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. + */ +package org.apache.camel.springboot.mcp.server; + +import io.modelcontextprotocol.server.McpSyncServer; +import org.apache.camel.component.mcp.server.McpServerBridge; +import org.apache.camel.component.mcp.server.McpServerEngine; +import org.apache.camel.spring.boot.CamelAutoConfiguration; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import static org.assertj.core.api.Assertions.assertThat; + +class CamelMcpServerAutoConfigurationTest { + + private final ApplicationContextRunner runner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(CamelAutoConfiguration.class, CamelMcpServerAutoConfiguration.class)) + .withPropertyValues("spring.main.banner-mode=off"); + + @Configuration + static class McpSyncServerConfiguration { + @Bean + McpSyncServer mcpSyncServer() { + return Mockito.mock(McpSyncServer.class); + } + } + + @Test + void testBridgeAndEngineConfiguredByDefault() { + runner.withUserConfiguration(McpSyncServerConfiguration.class) + .withPropertyValues("camel.mcp-server.tags=crm") + .run(context -> { + assertThat(context).hasSingleBean(McpServerEngine.class); + assertThat(context).hasSingleBean(McpServerBridge.class); + assertThat(context.getBean(McpServerBridge.class).getConfiguration().getTags()).isEqualTo("crm"); + }); + } + + @Test + void testDisabledProperty() { + runner.withUserConfiguration(McpSyncServerConfiguration.class) + .withPropertyValues("camel.mcp-server.enabled=false") + .run(context -> { + assertThat(context).doesNotHaveBean(McpServerEngine.class); + assertThat(context).doesNotHaveBean(McpServerBridge.class); + }); + } + + @Test + void testBacksOffWithoutMcpSyncServer() { + runner.run(context -> { + assertThat(context).doesNotHaveBean(McpServerEngine.class); + assertThat(context).doesNotHaveBean(McpServerBridge.class); + }); + } +} diff --git a/components-starter/camel-mcp-server-starter/src/test/java/org/apache/camel/springboot/mcp/server/SpringBootMcpServerStdioTest.java b/components-starter/camel-mcp-server-starter/src/test/java/org/apache/camel/springboot/mcp/server/SpringBootMcpServerStdioTest.java new file mode 100644 index 00000000000..07140fb7e8c --- /dev/null +++ b/components-starter/camel-mcp-server-starter/src/test/java/org/apache/camel/springboot/mcp/server/SpringBootMcpServerStdioTest.java @@ -0,0 +1,145 @@ +/* + * 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. + */ +package org.apache.camel.springboot.mcp.server; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.PipedInputStream; +import java.io.PipedOutputStream; + +import io.modelcontextprotocol.json.McpJsonDefaults; +import io.modelcontextprotocol.server.transport.StdioServerTransportProvider; +import io.modelcontextprotocol.spec.McpServerTransportProviderBase; +import org.apache.camel.builder.RouteBuilder; +import org.apache.camel.test.spring.junit6.CamelSpringBootTest; +import org.junit.jupiter.api.Test; +import org.springframework.ai.mcp.server.webmvc.autoconfigure.McpServerSseWebMvcAutoConfiguration; +import org.springframework.ai.mcp.server.webmvc.autoconfigure.McpServerStatelessWebMvcAutoConfiguration; +import org.springframework.ai.mcp.server.webmvc.autoconfigure.McpServerStreamableHttpWebMvcAutoConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Serves the same {@code ai-tool} routes over the stdio transport instead of streamable HTTP. + *

+ * The transport provider is fed with in-memory pipes rather than the real {@code System.in} / {@code System.out}, so + * the JSON-RPC conversation a stdio MCP client would have with the application can be driven from the test without + * spawning a process. Supplying the bean also replaces the one the Spring AI auto-configuration would create for + * {@code spring.ai.mcp.server.stdio=true} (it is annotated with {@code @ConditionalOnMissingBean}); everything below + * that bean — the MCP server, this starter's engine and the Camel bridge — is the production wiring. + */ +// a stdio application does not depend on spring-ai-starter-mcp-server-webmvc at all; the test classpath has it, so +// its transports are excluded here to leave the stdio one as the only MCP server transport +@EnableAutoConfiguration(exclude = { + McpServerSseWebMvcAutoConfiguration.class, + McpServerStreamableHttpWebMvcAutoConfiguration.class, + McpServerStatelessWebMvcAutoConfiguration.class }) +@CamelSpringBootTest +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.NONE, + classes = { SpringBootMcpServerStdioTest.TestConfiguration.class }, + properties = { + "spring.main.banner-mode=off", + "camel.mcp-server.tags=stdio-conformance", + "camel.springboot.main-run-controller=false" }) +public class SpringBootMcpServerStdioTest { + + private static final PipedOutputStream TO_SERVER; + private static final PipedInputStream SERVER_IN; + private static final PipedOutputStream SERVER_OUT; + private static final BufferedReader FROM_SERVER; + + static { + try { + TO_SERVER = new PipedOutputStream(); + SERVER_IN = new PipedInputStream(TO_SERVER); + SERVER_OUT = new PipedOutputStream(); + FROM_SERVER = new BufferedReader(new InputStreamReader(new PipedInputStream(SERVER_OUT), UTF_8)); + } catch (IOException e) { + throw new ExceptionInInitializerError(e); + } + } + + @Test + void toolsAreServedOverStdio() throws Exception { + send("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"2024-11-05\"," + + "\"capabilities\":{},\"clientInfo\":{\"name\":\"test\",\"version\":\"1\"}}}"); + assertThat(responseWith("\"id\":1")).contains("\"protocolVersion\""); + + send("{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}"); + + // only the selected tag is exposed + send("{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/list\"}"); + assertThat(responseWith("\"id\":2")) + .contains("say_hello") + .doesNotContain("hidden_tool"); + + send("{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\"," + + "\"params\":{\"name\":\"say_hello\",\"arguments\":{\"name\":\"World\"}}}"); + assertThat(responseWith("\"id\":3")).contains("Hello World"); + } + + private static void send(String message) throws IOException { + TO_SERVER.write((message + "\n").getBytes(UTF_8)); + TO_SERVER.flush(); + } + + /** + * Reads server output until the response carrying the given id shows up, skipping the notifications the server + * emits on its own (such as {@code notifications/tools/list_changed}). + */ + private static String responseWith(String id) throws IOException { + for (int i = 0; i < 20; i++) { + String line = FROM_SERVER.readLine(); + if (line != null && line.contains(id)) { + return line; + } + } + throw new AssertionError("No response containing " + id + " was received over stdio"); + } + + @Configuration + static class TestConfiguration { + + @Bean + McpServerTransportProviderBase stdioServerTransport() { + return new StdioServerTransportProvider(McpJsonDefaults.getMapper(), SERVER_IN, SERVER_OUT); + } + + @Bean + RouteBuilder stdioRoutes() { + return new RouteBuilder() { + @Override + public void configure() { + from("ai-tool:say_hello?tags=stdio-conformance&description=Say hello" + + "¶meter.name=string¶meter.name.description=Who to greet" + + "¶meter.name.required=true") + .setBody(simple("Hello ${header.name}")); + + from("ai-tool:hidden_tool?description=Untagged tool, must not be exposed") + .setBody(constant("hidden")); + } + }; + } + } +} diff --git a/components-starter/camel-mcp-server-starter/src/test/java/org/apache/camel/springboot/mcp/server/SpringBootMcpServerTest.java b/components-starter/camel-mcp-server-starter/src/test/java/org/apache/camel/springboot/mcp/server/SpringBootMcpServerTest.java new file mode 100644 index 00000000000..d240924a0bc --- /dev/null +++ b/components-starter/camel-mcp-server-starter/src/test/java/org/apache/camel/springboot/mcp/server/SpringBootMcpServerTest.java @@ -0,0 +1,218 @@ +/* + * 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. + */ +package org.apache.camel.springboot.mcp.server; + +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +import io.modelcontextprotocol.client.McpClient; +import io.modelcontextprotocol.client.McpSyncClient; +import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport; +import io.modelcontextprotocol.spec.McpSchema; +import org.apache.camel.CamelContext; +import org.apache.camel.builder.RouteBuilder; +import org.apache.camel.test.spring.junit6.CamelSpringBootTest; +import org.springframework.ai.mcp.annotation.McpTool; +import org.springframework.ai.mcp.annotation.McpToolParam; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +/** + * The engine conformance scenarios (CAMEL-24313) against the Spring AI MCP server engine, driven by the official MCP + * SDK client over streamable HTTP. Mirrors {@code McpServerConformanceTestSupport} from camel-mcp-server-api, which + * cannot be reused as-is here because it manages its own CamelContext outside Spring Boot. + */ +@EnableAutoConfiguration +@CamelSpringBootTest +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + // the auto-configurations (Camel, Spring AI MCP server, this starter) are activated by + // @EnableAutoConfiguration so their ordering and @ConditionalOnBean conditions apply + classes = { SpringBootMcpServerTest.TestConfiguration.class }, + properties = { + "spring.main.banner-mode=off", + "spring.ai.mcp.server.protocol=STREAMABLE", + "camel.mcp-server.tags=conformance", + "camel.mcp-server.tool-timeout=2000", + "camel.springboot.main-run-controller=false" }) +public class SpringBootMcpServerTest { + + @LocalServerPort + private int port; + + @Autowired + private CamelContext camelContext; + + private McpSyncClient client; + + /** + * A plain Spring-defined MCP tool ({@code @McpTool} annotation, registered by the Spring AI annotation scanner) + * living on the same MCP server as the Camel ai-tool routes. + */ + public static class SpringDefinedTools { + @McpTool(name = "add_numbers", description = "Add two numbers") + public String add( + @McpToolParam(description = "First addend", required = true) int a, + @McpToolParam(description = "Second addend", required = true) int b) { + return String.valueOf(a + b); + } + } + + @Configuration + public static class TestConfiguration { + @Bean + public SpringDefinedTools springDefinedTools() { + return new SpringDefinedTools(); + } + + @Bean + public RouteBuilder routes() { + return new RouteBuilder() { + @Override + public void configure() { + from("ai-tool:say_hello?tags=conformance&description=Say hello" + + "¶meter.name=string¶meter.name.required=true") + .routeId("say-hello-route") + .setBody(simple("Hello ${header.name}")); + + from("ai-tool:fail_tool?tags=conformance&description=Always fails") + .process(e -> { + throw new IllegalStateException("secret internal detail"); + }); + + from("ai-tool:slow_tool?tags=conformance&description=Exceeds the tool timeout") + .delay(6000) + .setBody(constant("done")); + + from("ai-tool:hidden_tool?description=Untagged tool, must not be exposed") + .setBody(constant("hidden")); + + from("ai-tool:other_tool?tags=untrusted&description=Not a selected tag, must not be exposed") + .setBody(constant("other")); + } + }; + } + } + + private McpSyncClient client() { + if (client == null) { + client = McpClient + .sync(HttpClientStreamableHttpTransport.builder("http://localhost:" + port).build()) + .requestTimeout(Duration.ofSeconds(10)) + .initializationTimeout(Duration.ofSeconds(10)) + .build(); + client.initialize(); + } + return client; + } + + @AfterEach + void closeClient() { + if (client != null) { + client.closeGracefully(); + client = null; + } + } + + @Test + void testListToolsExposesOnlySelectedTags() { + List tools = client().listTools().tools(); + + assertThat(tools).extracting(McpSchema.Tool::name) + .contains("say_hello", "fail_tool", "slow_tool") + .doesNotContain("hidden_tool", "other_tool"); + } + + @Test + void testSpringAnnotatedToolsCoexistWithCamelTools() { + // both tool sources are served by the same MCP server + assertThat(client().listTools().tools()).extracting(McpSchema.Tool::name) + .contains("add_numbers", "say_hello"); + + McpSchema.CallToolResult result + = client().callTool(new McpSchema.CallToolRequest("add_numbers", Map.of("a", 17, "b", 25))); + assertThat(result.isError()).as(textOf(result)).isNotEqualTo(Boolean.TRUE); + assertThat(textOf(result)).isEqualTo("42"); + } + + @Test + void testCallToolSuccess() { + McpSchema.CallToolResult result + = client().callTool(new McpSchema.CallToolRequest("say_hello", Map.of("name", "World"))); + + assertThat(result.isError()).isNotEqualTo(Boolean.TRUE); + assertThat(textOf(result)).isEqualTo("Hello World"); + } + + @Test + void testCallToolMissingRequiredArgument() { + McpSchema.CallToolResult result = client().callTool(new McpSchema.CallToolRequest("say_hello", Map.of())); + + assertThat(result.isError()).isEqualTo(Boolean.TRUE); + assertThat(textOf(result)).contains("name"); + } + + @Test + void testCallToolExecutionErrorIsSanitized() { + McpSchema.CallToolResult result = client().callTool(new McpSchema.CallToolRequest("fail_tool", Map.of())); + + assertThat(result.isError()).isEqualTo(Boolean.TRUE); + assertThat(textOf(result)) + .doesNotContain("secret internal detail") + .isEqualTo("Tool execution failed"); + } + + @Test + void testCallToolTimeout() { + McpSchema.CallToolResult result = client().callTool(new McpSchema.CallToolRequest("slow_tool", Map.of())); + + assertThat(result.isError()).isEqualTo(Boolean.TRUE); + assertThat(textOf(result)).contains("timed out"); + } + + @Test + void testToolsListReflectsRouteStopAndStart() throws Exception { + assertThat(client().listTools().tools()).extracting(McpSchema.Tool::name).contains("say_hello"); + + camelContext.getRouteController().stopRoute("say-hello-route"); + await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> assertThat(client().listTools().tools()) + .extracting(McpSchema.Tool::name).doesNotContain("say_hello")); + + camelContext.getRouteController().startRoute("say-hello-route"); + await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> assertThat(client().listTools().tools()) + .extracting(McpSchema.Tool::name).contains("say_hello")); + } + + private static String textOf(McpSchema.CallToolResult result) { + return result.content().stream() + .filter(McpSchema.TextContent.class::isInstance) + .map(c -> ((McpSchema.TextContent) c).text()) + .collect(Collectors.joining()); + } +} diff --git a/components-starter/pom.xml b/components-starter/pom.xml index f0a49e8a2de..a56292b8cd6 100644 --- a/components-starter/pom.xml +++ b/components-starter/pom.xml @@ -361,6 +361,7 @@ camel-management-starter camel-mapstruct-starter camel-master-starter + camel-mcp-server-starter camel-mdc-starter camel-metrics-starter camel-micrometer-observability-starter diff --git a/docs/spring-boot/modules/ROOT/nav.adoc b/docs/spring-boot/modules/ROOT/nav.adoc index cc4cdce1e24..907c9830116 100644 --- a/docs/spring-boot/modules/ROOT/nav.adoc +++ b/docs/spring-boot/modules/ROOT/nav.adoc @@ -260,6 +260,7 @@ ** xref:starters/management.adoc[Management] ** xref:starters/mapstruct.adoc[MapStruct] ** xref:starters/master.adoc[Master] +** xref:starters/mcp-server.adoc[Mcp Server] ** xref:starters/mdc.adoc[Mdc] ** xref:starters/metrics.adoc[Metrics] ** xref:starters/micrometer-observability.adoc[Micrometer Observability] diff --git a/docs/spring-boot/modules/ROOT/pages/starters/mcp-server.adoc b/docs/spring-boot/modules/ROOT/pages/starters/mcp-server.adoc new file mode 100644 index 00000000000..bd465cb2b0b --- /dev/null +++ b/docs/spring-boot/modules/ROOT/pages/starters/mcp-server.adoc @@ -0,0 +1,254 @@ +// Do not edit directly! +// This file was generated by camel-spring-boot-generator-maven-plugin += Mcp Server +:artifactid: camel-mcp-server-starter + +The MCP Server starter exposes Camel routes registered via the `ai-tool` +component as tools of a Model Context Protocol (MCP) server, served through +the Spring AI MCP server over streamable HTTP. No route is needed for the +server itself: add the starter, tag the `ai-tool` routes to expose, and any +MCP client (another Camel application, an IDE, a coding agent) can discover +and call them. + +Tool semantics — tag-based opt-in (the untagged default pool is never +exposed), flat-namespace collision refusal, per-call timeout and error +sanitization — are owned by the runtime-agnostic `camel-mcp-server-api` +bridge and are identical on every Camel runtime. Serving concerns (endpoint +path, protocol, server identity, authentication) are owned by the Spring AI +MCP server and configured via `spring.ai.mcp.server.*`; use +`spring.ai.mcp.server.protocol=STREAMABLE` for the streamable HTTP transport. + +== Maven coordinates + +[source,xml] +---- + + org.apache.camel.springboot + camel-mcp-server-starter + +---- + +== Usage + +Define tools as regular `ai-tool` routes and give them tags: + +[source,java] +---- +from("ai-tool:query_db?tags=crm&description=Query customer database" + + "¶meter.customerId=string¶meter.customerId.required=true") + .to("jdbc:dataSource"); +---- + +or in YAML DSL: + +[source,yaml] +---- +- route: + from: + uri: "ai-tool:send_email" + parameters: + description: "Send email notification" + tags: "notify" + parameter.to: string + parameter.to.description: "Recipient address" + parameter.to.required: "true" + parameter.subject: string + parameter.priority: string + parameter.priority.enum: "low,normal,high" + steps: + - to: "smtp://mail.example.com" +---- + +Tool parameters are declared with the `parameter.NAME` options: the value is +the JSON type (`string`, `integer`, `number`, `boolean`), and the +`parameter.NAME.description`, `parameter.NAME.required` and +`parameter.NAME.enum` options refine the generated JSON Schema. Arguments +arrive as message headers in the route (`${header.customerId}`). + +Select the tags to expose in `application.properties`: + +[source,properties] +---- +camel.mcp-server.tags = crm,notify + +# per-call execution timeout (milliseconds, default 20000) +camel.mcp-server.tool-timeout = 10000 +---- + +The streamable HTTP transport has to be selected explicitly with +`spring.ai.mcp.server.protocol=STREAMABLE`; when the property is not set, +Spring AI serves the deprecated SSE transport instead and there is no +endpoint at `/mcp`. + +Serving concerns are configured on the Spring AI MCP server — see the +https://docs.spring.io/spring-ai/reference/api/mcp/mcp-streamable-http-server-boot-starter-docs.html[Spring AI MCP server documentation] +for the full list of `spring.ai.mcp.server.*` options. For example: + +[source,properties] +---- +spring.ai.mcp.server.protocol = STREAMABLE +spring.ai.mcp.server.name = my-integration-app +spring.ai.mcp.server.version = 1.0.0 +spring.ai.mcp.server.streamable-http.mcp-endpoint = /mcp +---- + +=== Connecting MCP clients + +Any MCP client can connect over streamable HTTP. Another Camel integration +can consume the tools with the camel-openai MCP client and automatic tool +execution: + +[source,java] +---- +from("direct:agent") + .to("openai:chat-completion" + + "?model={{llm.model}}" + + "&autoToolExecution=true" + + "&mcpServer.myCamelTools.transportType=streamableHttp" + + "&mcpServer.myCamelTools.url=http://localhost:8080/mcp"); +---- + +A coding agent or IDE is configured with the same URL, e.g. in an +`mcp.json`-style client configuration: + +[source,json] +---- +{ + "mcpServers": { + "my-integration-app": { + "type": "http", + "url": "http://localhost:8080/mcp" + } + } +} +---- + +=== Serving over stdio + +Out of the box the tools are served over streamable HTTP: the starter brings +in `spring-ai-starter-mcp-server-webmvc`. MCP clients that launch the server +as a subprocess speak over stdin/stdout instead, which Spring AI serves with +the plain `spring-ai-starter-mcp-server`. Swap the transport starter: + +[source,xml] +---- + + org.apache.camel.springboot + camel-mcp-server-starter + + + org.springframework.ai + spring-ai-starter-mcp-server-webmvc + + + + + org.springframework.ai + spring-ai-starter-mcp-server + 2.0.0 + +---- + +and enable the stdio transport: + +[source,properties] +---- +spring.ai.mcp.server.stdio = true +spring.main.web-application-type = none +spring.main.banner-mode = off + +# stdout carries the MCP protocol, so nothing else may be written to it +logging.threshold.console = OFF +logging.file.name = my-integration-app.log +---- + +Because stdout carries the MCP protocol, the application must not run as a +web application, the banner has to be switched off and console logging has +to be turned off — writing the log to a file instead. Leaving console +logging on corrupts the protocol stream, as the Spring Boot startup log is +then interleaved with the JSON-RPC messages. + +A stdio server is launched by the client instead of being connected to over +a URL: + +[source,json] +---- +{ + "mcpServers": { + "my-integration-app": { + "command": "java", + "args": ["-jar", "/path/to/my-integration-app-1.0.0.jar"] + } + } +} +---- + +See the +https://docs.spring.io/spring-ai/reference/api/mcp/mcp-server-boot-starter-docs.html[Spring AI MCP Server Boot Starter documentation] +for the transport options and the full list of `spring.ai.mcp.server.*` +properties. On Quarkus the equivalent setup is described in the +https://docs.quarkiverse.io/quarkus-mcp-server/dev/getting-started-stdio.html[quarkus-mcp-server stdio guide]. + +=== Mixing with Spring-defined tools + +Camel tools coexist with tools defined natively in Spring AI — both are +served by the same MCP server and appear in the same `tools/list`. For +example, a `@McpTool`-annotated bean: + +[source,java] +---- +@Component +public class CalculatorTools { + + @McpTool(name = "add_numbers", description = "Add two numbers") + public String add( + @McpToolParam(description = "First addend", required = true) int a, + @McpToolParam(description = "Second addend", required = true) int b) { + return String.valueOf(a + b); + } +} +---- + +is exposed alongside the `ai-tool` routes. Spring-defined tools are +registered when the server is created; Camel tools are added and removed +dynamically with the route lifecycle. Choose distinct tool names — MCP has a +flat tool namespace. + +=== Dynamic tools + +The exposed tool list follows the route lifecycle: stopping or suspending an +`ai-tool` route removes its tool, starting or resuming it publishes the tool +again, and connected clients are notified via +`notifications/tools/list_changed`: + +[source,java] +---- +camelContext.getRouteController().stopRoute("query-db-route"); // tool disappears +camelContext.getRouteController().startRoute("query-db-route"); // tool is back +---- + +=== Error handling + +Results returned to MCP clients are sanitized by the bridge: a route +exception produces an `isError` result with the generic message +`Tool execution failed` (the cause is logged server-side and never sent to +the client), a missing or invalid argument returns the validation message, +and a call exceeding `camel.mcp-server.tool-timeout` returns +`Tool execution timed out` while the route keeps running until it completes +on its own. + +On Camel Main and Camel JBang the equivalent setup is the `camel-mcp-server` +module with the `camel.server.mcp-*` options; on Quarkus it is the +`camel-quarkus-mcp-server` extension. + +== Spring Boot Auto-Configuration + +The starter supports 3 options, which are listed below. + +[width="100%",cols="2,5,^1,2",options="header"] +|=== +| Name | Description | Default | Type +| camel.mcp-server.enabled | Whether to expose ai-tool routes as MCP tools through the Spring AI MCP server. Enabled by default when the starter is on the classpath. | true | Boolean +| camel.mcp-server.tags | Comma-separated list of ai-tool tags to expose as MCP tools. Only tools registered under one of these tags are exposed; the untagged default pool is never exposed. When not set, no tools are exposed. | | String +| camel.mcp-server.tool-timeout | Per-call tool execution timeout in milliseconds. A call exceeding the timeout returns an error result to the MCP client; the underlying route keeps running until it completes on its own. | 20000 | Long +|=== diff --git a/tooling/camel-spring-boot-dependencies/pom.xml b/tooling/camel-spring-boot-dependencies/pom.xml index 31cc5602135..b7ba8923994 100644 --- a/tooling/camel-spring-boot-dependencies/pom.xml +++ b/tooling/camel-spring-boot-dependencies/pom.xml @@ -4624,6 +4624,11 @@ camel-spring-ai-image 4.22.0-SNAPSHOT + + org.apache.camel + camel-spring-ai-tools + 4.22.0-SNAPSHOT + org.apache.camel camel-spring-ai-vector-store