diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/openai.json b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/openai.json index a8ae3a444debb..f0c60e6ac00c0 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/openai.json +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/openai.json @@ -117,7 +117,7 @@ "maxToolIterations": { "index": 28, "kind": "parameter", "displayName": "Max Tool Iterations", "group": "producer", "label": "", "required": false, "type": "integer", "javaType": "int", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": 50, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "Maximum number of tool call loop iterations to prevent infinite loops" }, "mcpProtocolVersions": { "index": 29, "kind": "parameter", "displayName": "Mcp Protocol Versions", "group": "producer", "label": "", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "Comma-separated list of MCP protocol versions to advertise when connecting to MCP servers using Streamable HTTP transport. When not set, the SDK default is used. Example: 2024-11-05,2025-03-26,2025-06-18" }, "mcpReconnect": { "index": 30, "kind": "parameter", "displayName": "Mcp Reconnect", "group": "producer", "label": "", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": true, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "Automatically reconnect to MCP servers when a tool call fails due to a transport error, and retry the call once." }, - "mcpServer": { "index": 31, "kind": "parameter", "displayName": "Mcp Server", "group": "producer", "label": "", "required": false, "type": "object", "javaType": "java.util.Map", "prefix": "mcpServer.", "multiValue": true, "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "MCP (Model Context Protocol) server configurations. Define servers using prefix notation: mcpServer..transportType=stdiossestreamableHttp, (Note that sse is deprecated) mcpServer..command= (stdio), mcpServer..args= (stdio), mcpServer..url= (sse\/streamableHttp), mcpServer..oauthProfile= (OAuth profile for HTTP auth, requires camel-oauth). This is a multi-value option with prefix: mcpServer." }, + "mcpServer": { "index": 31, "kind": "parameter", "displayName": "Mcp Server", "group": "producer", "label": "", "required": false, "type": "object", "javaType": "java.util.Map", "prefix": "mcpServer.", "multiValue": true, "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "MCP (Model Context Protocol) server configurations. Define servers using prefix notation: mcpServer..transportType=stdiossestreamableHttp, (Note that sse is deprecated) mcpServer..command= (stdio), mcpServer..args= (stdio), mcpServer..url= (sse\/streamableHttp), mcpServer..oauthProfile= (OAuth profile for HTTP auth, requires camel-oauth), mcpServer..toolNames= (optional include list to restrict which tools are registered from this server). This is a multi-value option with prefix: mcpServer." }, "mcpTimeout": { "index": 32, "kind": "parameter", "displayName": "Mcp Timeout", "group": "producer", "label": "", "required": false, "type": "integer", "javaType": "int", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": 20, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "Timeout in seconds for MCP tool call requests. Applies to all MCP operations including tool execution and initialization." }, "model": { "index": 33, "kind": "parameter", "displayName": "Model", "group": "producer", "label": "", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "The model to use for chat completion" }, "outputClass": { "index": 34, "kind": "parameter", "displayName": "Output Class", "group": "producer", "label": "", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "Fully qualified class name for structured output using response format" }, diff --git a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/openai-mcp.adoc b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/openai-mcp.adoc index 866621a92f51b..8635736c2da34 100644 --- a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/openai-mcp.adoc +++ b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/openai-mcp.adoc @@ -177,6 +177,64 @@ YAML:: ---- ==== +==== Tool Filtering + +By default, all tools from every configured MCP server are registered and advertised to the model. +Use the per-server `toolNames` property to restrict which tools are included. This is useful to +reduce prompt size and cost, keep destructive tools away from the model, or avoid duplicate tool +names across servers. + +[tabs] +==== +Java:: ++ +[source,java] +---- +from("direct:chat") + .to("openai:chat-completion?model=gpt-4" + + "&mcpServer.fs.transportType=stdio" + + "&mcpServer.fs.command=npx" + + "&mcpServer.fs.args=-y,@modelcontextprotocol/server-filesystem,/tmp" + + "&mcpServer.fs.toolNames=read_file,list_directory"); +---- + +XML:: ++ +[source,xml] +---- + + + + +---- + +YAML:: ++ +[source,yaml] +---- +- route: + from: + uri: direct:chat + steps: + - to: + uri: openai:chat-completion + parameters: + model: gpt-4 + mcpServer.fs.transportType: stdio + mcpServer.fs.command: npx + mcpServer.fs.args: "-y,@modelcontextprotocol/server-filesystem,/tmp" + mcpServer.fs.toolNames: read_file,list_directory +---- +==== + +When `toolNames` is set, only tools whose names appear in the comma-separated list are registered. +Tools not in the list are silently excluded. If a name in the list does not match any tool provided +by the server, a warning is logged. When `toolNames` is not set or is empty, all tools from the +server are registered (the default behavior). + +The filter is also applied consistently on MCP server reconnection (`mcpReconnect=true`), so the +tool set remains stable across connection recovery. + === Agentic Loop Behavior When the model responds with tool calls, the component automatically: diff --git a/components/camel-ai/camel-openai/src/generated/resources/META-INF/org/apache/camel/component/openai/openai.json b/components/camel-ai/camel-openai/src/generated/resources/META-INF/org/apache/camel/component/openai/openai.json index a8ae3a444debb..f0c60e6ac00c0 100644 --- a/components/camel-ai/camel-openai/src/generated/resources/META-INF/org/apache/camel/component/openai/openai.json +++ b/components/camel-ai/camel-openai/src/generated/resources/META-INF/org/apache/camel/component/openai/openai.json @@ -117,7 +117,7 @@ "maxToolIterations": { "index": 28, "kind": "parameter", "displayName": "Max Tool Iterations", "group": "producer", "label": "", "required": false, "type": "integer", "javaType": "int", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": 50, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "Maximum number of tool call loop iterations to prevent infinite loops" }, "mcpProtocolVersions": { "index": 29, "kind": "parameter", "displayName": "Mcp Protocol Versions", "group": "producer", "label": "", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "Comma-separated list of MCP protocol versions to advertise when connecting to MCP servers using Streamable HTTP transport. When not set, the SDK default is used. Example: 2024-11-05,2025-03-26,2025-06-18" }, "mcpReconnect": { "index": 30, "kind": "parameter", "displayName": "Mcp Reconnect", "group": "producer", "label": "", "required": false, "type": "boolean", "javaType": "boolean", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": true, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "Automatically reconnect to MCP servers when a tool call fails due to a transport error, and retry the call once." }, - "mcpServer": { "index": 31, "kind": "parameter", "displayName": "Mcp Server", "group": "producer", "label": "", "required": false, "type": "object", "javaType": "java.util.Map", "prefix": "mcpServer.", "multiValue": true, "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "MCP (Model Context Protocol) server configurations. Define servers using prefix notation: mcpServer..transportType=stdiossestreamableHttp, (Note that sse is deprecated) mcpServer..command= (stdio), mcpServer..args= (stdio), mcpServer..url= (sse\/streamableHttp), mcpServer..oauthProfile= (OAuth profile for HTTP auth, requires camel-oauth). This is a multi-value option with prefix: mcpServer." }, + "mcpServer": { "index": 31, "kind": "parameter", "displayName": "Mcp Server", "group": "producer", "label": "", "required": false, "type": "object", "javaType": "java.util.Map", "prefix": "mcpServer.", "multiValue": true, "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "MCP (Model Context Protocol) server configurations. Define servers using prefix notation: mcpServer..transportType=stdiossestreamableHttp, (Note that sse is deprecated) mcpServer..command= (stdio), mcpServer..args= (stdio), mcpServer..url= (sse\/streamableHttp), mcpServer..oauthProfile= (OAuth profile for HTTP auth, requires camel-oauth), mcpServer..toolNames= (optional include list to restrict which tools are registered from this server). This is a multi-value option with prefix: mcpServer." }, "mcpTimeout": { "index": 32, "kind": "parameter", "displayName": "Mcp Timeout", "group": "producer", "label": "", "required": false, "type": "integer", "javaType": "int", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "defaultValue": 20, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "Timeout in seconds for MCP tool call requests. Applies to all MCP operations including tool execution and initialization." }, "model": { "index": 33, "kind": "parameter", "displayName": "Model", "group": "producer", "label": "", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "The model to use for chat completion" }, "outputClass": { "index": 34, "kind": "parameter", "displayName": "Output Class", "group": "producer", "label": "", "required": false, "type": "string", "javaType": "java.lang.String", "deprecated": false, "deprecationNote": "", "autowired": false, "secret": false, "configurationClass": "org.apache.camel.component.openai.OpenAIConfiguration", "configurationField": "configuration", "description": "Fully qualified class name for structured output using response format" }, diff --git a/components/camel-ai/camel-openai/src/main/docs/openai-mcp.adoc b/components/camel-ai/camel-openai/src/main/docs/openai-mcp.adoc index 866621a92f51b..8635736c2da34 100644 --- a/components/camel-ai/camel-openai/src/main/docs/openai-mcp.adoc +++ b/components/camel-ai/camel-openai/src/main/docs/openai-mcp.adoc @@ -177,6 +177,64 @@ YAML:: ---- ==== +==== Tool Filtering + +By default, all tools from every configured MCP server are registered and advertised to the model. +Use the per-server `toolNames` property to restrict which tools are included. This is useful to +reduce prompt size and cost, keep destructive tools away from the model, or avoid duplicate tool +names across servers. + +[tabs] +==== +Java:: ++ +[source,java] +---- +from("direct:chat") + .to("openai:chat-completion?model=gpt-4" + + "&mcpServer.fs.transportType=stdio" + + "&mcpServer.fs.command=npx" + + "&mcpServer.fs.args=-y,@modelcontextprotocol/server-filesystem,/tmp" + + "&mcpServer.fs.toolNames=read_file,list_directory"); +---- + +XML:: ++ +[source,xml] +---- + + + + +---- + +YAML:: ++ +[source,yaml] +---- +- route: + from: + uri: direct:chat + steps: + - to: + uri: openai:chat-completion + parameters: + model: gpt-4 + mcpServer.fs.transportType: stdio + mcpServer.fs.command: npx + mcpServer.fs.args: "-y,@modelcontextprotocol/server-filesystem,/tmp" + mcpServer.fs.toolNames: read_file,list_directory +---- +==== + +When `toolNames` is set, only tools whose names appear in the comma-separated list are registered. +Tools not in the list are silently excluded. If a name in the list does not match any tool provided +by the server, a warning is logged. When `toolNames` is not set or is empty, all tools from the +server are registered (the default behavior). + +The filter is also applied consistently on MCP server reconnection (`mcpReconnect=true`), so the +tool set remains stable across connection recovery. + === Agentic Loop Behavior When the model responds with tool calls, the component automatically: diff --git a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIConfiguration.java b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIConfiguration.java index 51c80de213f37..ba8ea8f484ddc 100644 --- a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIConfiguration.java +++ b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIConfiguration.java @@ -172,7 +172,8 @@ public class OpenAIConfiguration implements Cloneable { + "Define servers using prefix notation: mcpServer..transportType=stdio|sse|streamableHttp, (Note that sse is deprecated) " + "mcpServer..command= (stdio), mcpServer..args= (stdio), " + "mcpServer..url= (sse/streamableHttp), " - + "mcpServer..oauthProfile= (OAuth profile for HTTP auth, requires camel-oauth)") + + "mcpServer..oauthProfile= (OAuth profile for HTTP auth, requires camel-oauth), " + + "mcpServer..toolNames= (optional include list to restrict which tools are registered from this server)") private Map mcpServer; @UriParam(defaultValue = "50") diff --git a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIEndpoint.java b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIEndpoint.java index db715e187a6ee..e33369776aa3a 100644 --- a/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIEndpoint.java +++ b/components/camel-ai/camel-openai/src/main/java/org/apache/camel/component/openai/OpenAIEndpoint.java @@ -196,7 +196,7 @@ private void initializeMcpServers() throws Exception { LOG.debug("MCP server '{}' initialized, listing tools", serverName); McpSchema.ListToolsResult toolsResult = mcpClient.listTools(); - List serverTools = toolsResult.tools(); + List serverTools = filterTools(toolsResult.tools(), serverName, props); for (McpSchema.Tool tool : serverTools) { if (toolClientMap.putIfAbsent(tool.name(), mcpClient) != null) { @@ -363,7 +363,7 @@ private McpSyncClient doReconnectMcpServer(McpSyncClient oldClient, String serve try { McpSyncClient newClient = createMcpClient(serverName, props); - List tools = newClient.listTools().tools(); + List tools = filterTools(newClient.listTools().tools(), serverName, props); globalMcpLock.lock(); try { @@ -412,6 +412,45 @@ private McpSyncClient doReconnectMcpServer(McpSyncClient oldClient, String serve } } + /** + * Filters the tools listed from an MCP server according to the per-server {@code toolNames} include list. When no + * {@code toolNames} property is configured, all tools are returned unchanged. + * + * @param allTools the full list of tools from the server + * @param serverName the logical server name (for logging) + * @param props the per-server configuration properties + * @return the filtered tool list (or the original list if no filter is configured) + */ + private List filterTools(List allTools, String serverName, Map props) { + String toolNamesCsv = props.get("toolNames"); + if (toolNamesCsv == null || toolNamesCsv.isBlank()) { + return allTools; + } + + Set allowed = new HashSet<>(); + for (String name : toolNamesCsv.split(",")) { + String trimmed = name.trim(); + if (!trimmed.isEmpty()) { + allowed.add(trimmed); + } + } + + List filtered = allTools.stream() + .filter(t -> allowed.contains(t.name())) + .toList(); + + Set found = filtered.stream().map(McpSchema.Tool::name).collect(Collectors.toSet()); + Set missing = new HashSet<>(allowed); + missing.removeAll(found); + if (!missing.isEmpty()) { + LOG.warn("MCP server '{}' does not provide the following toolNames: {}", serverName, missing); + } + + LOG.info("MCP server '{}': filtered {} tools to {} via toolNames include list", + serverName, allTools.size(), filtered.size()); + return filtered; + } + private Set toolsForServer(String serverName) { return mcpToolState.toolToServerName().entrySet().stream() .filter(e -> serverName.equals(e.getValue())) diff --git a/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/OpenAIEndpointMcpToolFilteringTest.java b/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/OpenAIEndpointMcpToolFilteringTest.java new file mode 100644 index 0000000000000..39f2f23497cac --- /dev/null +++ b/components/camel-ai/camel-openai/src/test/java/org/apache/camel/component/openai/OpenAIEndpointMcpToolFilteringTest.java @@ -0,0 +1,332 @@ +/* + * 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.component.openai; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import com.openai.client.OpenAIClient; +import io.modelcontextprotocol.client.McpSyncClient; +import io.modelcontextprotocol.spec.McpSchema; +import org.apache.camel.impl.DefaultCamelContext; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * Unit tests for CAMEL-23964: per-server MCP tool filtering via the {@code toolNames} include list. + */ +class OpenAIEndpointMcpToolFilteringTest { + + private static final String SERVER_A = "serverA"; + private static final String SERVER_B = "serverB"; + private static final List ALL_TOOLS = List.of("read_file", "write_file", "list_directory", "delete_file"); + + /** + * Overrides the client-creation seam so that tests return mock MCP clients instead of opening real transports, and + * stubs createClient to avoid needing real API keys. + */ + private static class TestEndpoint extends OpenAIEndpoint { + private final Map> serverToolNames; + + TestEndpoint(OpenAIComponent component, OpenAIConfiguration config, + Map> serverToolNames) { + super("openai:chat-completion", component, config); + this.serverToolNames = serverToolNames; + } + + @Override + McpSyncClient createMcpClient(String serverName, Map props) { + List toolNames = serverToolNames.getOrDefault(serverName, List.of()); + McpSyncClient client = mock(McpSyncClient.class); + List tools = toolNames.stream() + .map(n -> McpSchema.Tool.builder(n, Map.of("type", "object")).description("mock " + n).build()) + .toList(); + when(client.listTools()).thenReturn(McpSchema.ListToolsResult.builder(tools).build()); + return client; + } + + @Override + protected OpenAIClient createClient() { + return mock(OpenAIClient.class); + } + } + + private TestEndpoint createAndStartEndpoint( + Map> serverConfigs, + Map> serverToolNames) + throws Exception { + DefaultCamelContext ctx = new DefaultCamelContext(); + OpenAIComponent component = new OpenAIComponent(); + component.setCamelContext(ctx); + + // Build the flat mcpServer config map that OpenAIConfiguration expects + Map flatConfig = new HashMap<>(); + for (Map.Entry> entry : serverConfigs.entrySet()) { + String name = entry.getKey(); + for (Map.Entry prop : entry.getValue().entrySet()) { + flatConfig.put(name + "." + prop.getKey(), prop.getValue()); + } + } + + OpenAIConfiguration config = new OpenAIConfiguration(); + config.setMcpServer(flatConfig); + + TestEndpoint endpoint = new TestEndpoint(component, config, serverToolNames); + endpoint.setCamelContext(ctx); + endpoint.setOperation(OpenAIOperations.chatCompletion); + endpoint.doStart(); + return endpoint; + } + + @Test + void toolNamesFilterKeepsOnlySpecifiedTools() throws Exception { + Map> serverConfigs = Map.of( + SERVER_A, Map.of("transportType", "stdio", "command", "echo", "toolNames", "read_file,list_directory")); + + Map> serverToolNames = Map.of(SERVER_A, ALL_TOOLS); + + TestEndpoint endpoint = createAndStartEndpoint(serverConfigs, serverToolNames); + + McpToolState state = endpoint.getMcpToolState(); + Set registeredNames = state.tools().stream() + .map(t -> t.function().name()) + .collect(Collectors.toSet()); + + assertThat(registeredNames).containsExactlyInAnyOrder("read_file", "list_directory"); + assertThat(registeredNames).doesNotContain("write_file", "delete_file"); + + // Also verify tool-to-client and tool-to-server maps are consistent + assertThat(state.toolClientMap()).containsOnlyKeys("read_file", "list_directory"); + assertThat(state.toolToServerName()).containsOnlyKeys("read_file", "list_directory"); + + endpoint.doStop(); + } + + @Test + void noToolNamesPropertyRegistersAllTools() throws Exception { + Map> serverConfigs = Map.of( + SERVER_A, Map.of("transportType", "stdio", "command", "echo")); + + Map> serverToolNames = Map.of(SERVER_A, ALL_TOOLS); + + TestEndpoint endpoint = createAndStartEndpoint(serverConfigs, serverToolNames); + + McpToolState state = endpoint.getMcpToolState(); + Set registeredNames = state.tools().stream() + .map(t -> t.function().name()) + .collect(Collectors.toSet()); + + assertThat(registeredNames).containsExactlyInAnyOrder("read_file", "write_file", "list_directory", "delete_file"); + + endpoint.doStop(); + } + + @Test + void emptyToolNamesPropertyRegistersAllTools() throws Exception { + Map> serverConfigs = Map.of( + SERVER_A, Map.of("transportType", "stdio", "command", "echo", "toolNames", "")); + + Map> serverToolNames = Map.of(SERVER_A, ALL_TOOLS); + + TestEndpoint endpoint = createAndStartEndpoint(serverConfigs, serverToolNames); + + McpToolState state = endpoint.getMcpToolState(); + Set registeredNames = state.tools().stream() + .map(t -> t.function().name()) + .collect(Collectors.toSet()); + + assertThat(registeredNames).containsExactlyInAnyOrder("read_file", "write_file", "list_directory", "delete_file"); + + endpoint.doStop(); + } + + @Test + void toolNamesWithSpacesAreTrimmed() throws Exception { + Map> serverConfigs = Map.of( + SERVER_A, Map.of("transportType", "stdio", "command", "echo", "toolNames", " read_file , list_directory ")); + + Map> serverToolNames = Map.of(SERVER_A, ALL_TOOLS); + + TestEndpoint endpoint = createAndStartEndpoint(serverConfigs, serverToolNames); + + McpToolState state = endpoint.getMcpToolState(); + Set registeredNames = state.tools().stream() + .map(t -> t.function().name()) + .collect(Collectors.toSet()); + + assertThat(registeredNames).containsExactlyInAnyOrder("read_file", "list_directory"); + + endpoint.doStop(); + } + + @Test + void multipleServersWithDifferentFilters() throws Exception { + Map> serverConfigs = new HashMap<>(); + serverConfigs.put(SERVER_A, Map.of("transportType", "stdio", "command", "echo", "toolNames", "read_file")); + serverConfigs.put(SERVER_B, Map.of("transportType", "stdio", "command", "echo", "toolNames", "search")); + + Map> serverToolNames = new HashMap<>(); + serverToolNames.put(SERVER_A, ALL_TOOLS); + serverToolNames.put(SERVER_B, List.of("search", "index", "delete")); + + TestEndpoint endpoint = createAndStartEndpoint(serverConfigs, serverToolNames); + + McpToolState state = endpoint.getMcpToolState(); + Set registeredNames = state.tools().stream() + .map(t -> t.function().name()) + .collect(Collectors.toSet()); + + assertThat(registeredNames).containsExactlyInAnyOrder("read_file", "search"); + + // Verify correct server mapping + assertThat(state.toolToServerName().get("read_file")).isEqualTo(SERVER_A); + assertThat(state.toolToServerName().get("search")).isEqualTo(SERVER_B); + + endpoint.doStop(); + } + + @Test + void oneServerFilteredOtherUnfiltered() throws Exception { + Map> serverConfigs = new HashMap<>(); + serverConfigs.put(SERVER_A, Map.of("transportType", "stdio", "command", "echo", "toolNames", "read_file")); + serverConfigs.put(SERVER_B, Map.of("transportType", "stdio", "command", "echo")); + + Map> serverToolNames = new HashMap<>(); + serverToolNames.put(SERVER_A, ALL_TOOLS); + serverToolNames.put(SERVER_B, List.of("search", "index")); + + TestEndpoint endpoint = createAndStartEndpoint(serverConfigs, serverToolNames); + + McpToolState state = endpoint.getMcpToolState(); + Set registeredNames = state.tools().stream() + .map(t -> t.function().name()) + .collect(Collectors.toSet()); + + // Server A: only read_file (filtered); Server B: search and index (unfiltered) + assertThat(registeredNames).containsExactlyInAnyOrder("read_file", "search", "index"); + + endpoint.doStop(); + } + + @Test + void toolNamesFilterAppliedOnReconnect() throws Exception { + // Set up a server with toolNames filter + Map> serverConfigs = new HashMap<>(); + serverConfigs.put(SERVER_A, + new HashMap<>(Map.of("transportType", "stdio", "command", "echo", "toolNames", "read_file,list_directory"))); + + Map> serverToolNames = new HashMap<>(); + serverToolNames.put(SERVER_A, ALL_TOOLS); + + TestEndpoint endpoint = createAndStartEndpoint(serverConfigs, serverToolNames); + + // Verify initial state is filtered + McpToolState initialState = endpoint.getMcpToolState(); + assertThat(initialState.tools()).hasSize(2); + + // Now trigger reconnect — the filter should still apply + McpSyncClient oldClient = initialState.toolClientMap().get("read_file"); + McpSyncClient newClient = endpoint.reconnectMcpServer(oldClient, "read_file"); + assertThat(newClient).isNotNull(); + + // Verify the reconnected state still has only the filtered tools + McpToolState reconnectedState = endpoint.getMcpToolState(); + Set reconnectedNames = reconnectedState.tools().stream() + .map(t -> t.function().name()) + .collect(Collectors.toSet()); + + assertThat(reconnectedNames).containsExactlyInAnyOrder("read_file", "list_directory"); + assertThat(reconnectedNames).doesNotContain("write_file", "delete_file"); + + endpoint.doStop(); + } + + @Test + void singleToolNameFilter() throws Exception { + Map> serverConfigs = Map.of( + SERVER_A, Map.of("transportType", "stdio", "command", "echo", "toolNames", "delete_file")); + + Map> serverToolNames = Map.of(SERVER_A, ALL_TOOLS); + + TestEndpoint endpoint = createAndStartEndpoint(serverConfigs, serverToolNames); + + McpToolState state = endpoint.getMcpToolState(); + Set registeredNames = state.tools().stream() + .map(t -> t.function().name()) + .collect(Collectors.toSet()); + + assertThat(registeredNames).containsExactly("delete_file"); + + endpoint.doStop(); + } + + @Test + void returnDirectToolsRespectedWithFilter() throws Exception { + DefaultCamelContext ctx = new DefaultCamelContext(); + OpenAIComponent component = new OpenAIComponent(); + component.setCamelContext(ctx); + + Map flatConfig = new HashMap<>(); + flatConfig.put(SERVER_A + ".transportType", "stdio"); + flatConfig.put(SERVER_A + ".command", "echo"); + flatConfig.put(SERVER_A + ".toolNames", "read_file,direct_tool"); + + OpenAIConfiguration config = new OpenAIConfiguration(); + config.setMcpServer(flatConfig); + + // Create endpoint with a tool that has returnDirect=true annotation + McpSchema.ToolAnnotations returnDirectAnnotations = McpSchema.ToolAnnotations.builder() + .returnDirect(true) + .build(); + + TestEndpoint endpoint = new TestEndpoint( + component, config, + Map.of(SERVER_A, List.of("read_file", "direct_tool", "other_tool"))) { + @Override + McpSyncClient createMcpClient(String serverName, Map props) { + McpSyncClient client = mock(McpSyncClient.class); + List tools = List.of( + McpSchema.Tool.builder("read_file", Map.of("type", "object")).description("read").build(), + McpSchema.Tool.builder("direct_tool", Map.of("type", "object")) + .description("direct") + .annotations(returnDirectAnnotations) + .build(), + McpSchema.Tool.builder("other_tool", Map.of("type", "object")).description("other").build()); + when(client.listTools()).thenReturn(McpSchema.ListToolsResult.builder(tools).build()); + return client; + } + }; + endpoint.setCamelContext(ctx); + endpoint.setOperation(OpenAIOperations.chatCompletion); + endpoint.doStart(); + + McpToolState state = endpoint.getMcpToolState(); + + // Only read_file and direct_tool should be registered + assertThat(state.tools()).hasSize(2); + assertThat(state.returnDirectTools()).containsExactly("direct_tool"); + assertThat(state.returnDirectTools()).doesNotContain("other_tool"); + + endpoint.doStop(); + } +} diff --git a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/OpenAIEndpointBuilderFactory.java b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/OpenAIEndpointBuilderFactory.java index 2f003379e2f36..62deec247e962 100644 --- a/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/OpenAIEndpointBuilderFactory.java +++ b/dsl/camel-endpointdsl/src/generated/java/org/apache/camel/builder/endpoint/dsl/OpenAIEndpointBuilderFactory.java @@ -800,8 +800,9 @@ default OpenAIEndpointBuilder mcpReconnect(String mcpReconnect) { * mcpServer..transportType=stdiossestreamableHttp, (Note that sse is * deprecated) mcpServer..command= (stdio), mcpServer..args= (stdio), * mcpServer..url= (sse/streamableHttp), mcpServer..oauthProfile= (OAuth - * profile for HTTP auth, requires camel-oauth). This is a multi-value - * option with prefix: mcpServer. + * profile for HTTP auth, requires camel-oauth), mcpServer..toolNames= + * (optional include list to restrict which tools are registered from + * this server). This is a multi-value option with prefix: mcpServer. * * The option is a: java.util.Map<java.lang.String, * java.lang.Object> type. @@ -825,8 +826,9 @@ default OpenAIEndpointBuilder mcpServer(String key, Object value) { * mcpServer..transportType=stdiossestreamableHttp, (Note that sse is * deprecated) mcpServer..command= (stdio), mcpServer..args= (stdio), * mcpServer..url= (sse/streamableHttp), mcpServer..oauthProfile= (OAuth - * profile for HTTP auth, requires camel-oauth). This is a multi-value - * option with prefix: mcpServer. + * profile for HTTP auth, requires camel-oauth), mcpServer..toolNames= + * (optional include list to restrict which tools are registered from + * this server). This is a multi-value option with prefix: mcpServer. * * The option is a: java.util.Map<java.lang.String, * java.lang.Object> type.