Skip to content

CAMEL-23078: camel-openai - Parallel MCP tool execution and runtime tool refresh - #25270

Open
Croway wants to merge 2 commits into
apache:mainfrom
Croway:CAMEL-23078-openai-mcp-parallel-tools-and-refresh
Open

CAMEL-23078: camel-openai - Parallel MCP tool execution and runtime tool refresh#25270
Croway wants to merge 2 commits into
apache:mainfrom
Croway:CAMEL-23078-openai-mcp-parallel-tools-and-refresh

Conversation

@Croway

@Croway Croway commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Fixes CAMEL-23078.

Two independent MCP improvements to camel-openai, one per commit.

1. Optional parallel MCP tool execution

A model can request several tool calls in a single response, and those calls are independent by design, but they were executed one after another in OpenAIProducer.processNonStreamingAgentic and OpenAIToolExecutionProducer.process, so a batch took as long as the sum of its tools.

  • New parallelToolExecution option (default false, so existing routes are unaffected) dispatches a batch concurrently.
  • New parallelToolTimeout option (milliseconds, default 0 = disabled) bounds the batch as a whole so one slow tool cannot block it. A call that exceeds it is cancelled and then handled according to toolExecutionErrorStrategy. Left disabled by default because mcpTimeout already bounds each individual MCP request.
  • Results are collected positionally and always fed back to the model in the order it requested the tools, since the OpenAI API pairs each tool message with its tool_call_id.
  • A batch of a single tool call still runs inline, with no thread hand-off.
  • MDC is carried onto the worker threads so tool-call logs stay correlated with the exchange.

The thread pool comes from Camel's ExecutorServiceManager rather than being hand-rolled. That matters beyond tidiness: it follows the configured thread pool profile, is exposed over JMX, is shut down with the CamelContext, and — via DefaultThreadPoolFactory — automatically becomes a thread-per-task virtual thread executor when camel.main.virtualThreadsEnabled=true. The blocking done on those threads is McpSyncClient.callTool, which blocks on a Reactor CountDownLatch rather than inside a synchronized block, and the endpoint's shared state is guarded by ReentrantLock, so nothing pins a carrier thread.

Refactor included in the same commit

The per-batch execution logic was duplicated almost line for line between the two producers. It is extracted into a shared McpToolCallExecutor, which keeps hallucinated tool name handling, argument parsing, the error strategies and returnDirect detection from drifting between the two paths, and gives both the new option. The executor also snapshots the immutable McpToolState once per batch, so every call in a batch sees a consistent view.

Behaviour change (documented in the upgrade guide)

With parallelToolExecution=true and toolExecutionErrorStrategy=failExchange, the sibling calls already dispatched complete before the exchange fails, instead of being abandoned mid-loop. Routes whose tools have side effects and that rely on a failure preventing later calls in the same batch should keep the sequential default.

2. Refresh the MCP tool list on tools/list_changed

MCP servers announce tool additions, removals and changes with a tools/list_changed notification, but the component ignored it: the advertised tool list was fixed at endpoint startup and was only ever re-listed as a side effect of a transport reconnect. A long-running route therefore kept offering the model a stale set of tools.

  • A toolsChangeConsumer is registered on each MCP client, and the tool state is republished when it fires. The MCP SDK already re-lists the tools before invoking the consumer, so there is no extra round trip.
  • The list the SDK passes is unfiltered, so the per-server toolNames include list is re-applied before publishing — a server cannot widen its exposed tool set by announcing new tools.
  • New mcpToolRefresh option, default true, since honouring the notification is what the MCP specification expects of a client. Set it to false to pin the tool list to endpoint startup for deployments that require a deterministic set of tools. Documented in the upgrade guide.
  • A notification arriving from a client that a reconnect has already superseded is discarded, so a stale in-flight notification cannot map tools back to a dead client.

Refactor included in the same commit

The per-server state rebuild was inlined in doReconnectMcpServer; it is extracted into republishServerTools and shared with the refresh path, so duplicate-name handling (CAMEL-23958), the toolNames filter (CAMEL-23964) and returnDirect detection cannot diverge between reconnect and refresh.

Drive-by fix

returnDirect flags set programmatically through addReturnDirectTool / removeReturnDirectTool were silently discarded whenever the tool state was rebuilt. They are now remembered and re-applied — except for tools that no longer exist, which stay fully pruned, preserving the guarantee asserted by the CAMEL-23957 reconnect tests.

Notes on the original issue description

The description predates CAMEL-23957, which replaced the individual cachedMcpTools / toolClientMap / returnDirectTools fields with the immutable McpToolState record guarded by a global lock. The refresh therefore publishes a new snapshot rather than mutating maps, as the description suggested.

openai:responses passes hosted MCP tools straight through to the API and does not execute tools locally, so it is out of scope for both changes.

Testing

Unit tests: mvn test -pl components/camel-ai/camel-openai182 tests, all green, including the pre-existing MCP reconnect, tool-filtering and error-strategy suites.

  • McpToolCallExecutorTest (10) — ordering, returnDirect per call, both error strategies, hallucinated tool names, and the batch timeout. Concurrency is proven with a CountDownLatch rendezvous that only completes if the calls are dispatched in parallel, rather than by measuring wall-clock time, so there is no timing flakiness and no Thread.sleep.
  • OpenAIParallelToolExecutionTest (3) — the same, end to end through a route using camel-test-infra-openai-mock (andInvokeTool emits several tool calls in one response).
  • OpenAIEndpointMcpToolRefreshTest (13) — tools added, removed, toolNames filter re-applied, duplicates across servers, other servers untouched, returnDirect annotations and manual overrides, refresh disabled, superseded client, unknown server, and a notification arriving before the client is ready.

Integration tests: run locally against Ollama and the dockerised MCP Everything Server — 21 ITs green across the whole suite (OpenAIChatCompletionIT, OpenAIEmbeddingsIT, OpenAIMcpToolsIT, OpenAIMcpAdvancedIT, OpenAIMcpManualToolLoopIT, OpenAIMcpConversationStoreIT, and the new OpenAIMcpParallelToolExecutionIT).

OpenAIMcpParallelToolExecutionIT is new and covers parallelToolExecution=true end to end, including the batch timeout and a comparison against the sequential default. Reaching the parallel dispatch path depends on the model emitting more than one tool call in a single response, since a batch of one runs inline — so it is asserted on outcome rather than on batch size, which would be flaky. Verified with qwen3.5:2b-mlx, which reliably produces two-call batches for these prompts; the McpToolCallExecutor debug log confirms Executing 2 tool call(s) in parallel, so the concurrent path really is exercised against a real MCP server. test_execution.md records the model choice and how to confirm it.

This also closes the gap noted in the earlier revision of this description: the toolsChangeConsumer registration is now exercised against a real MCP server over Streamable HTTP by every MCP IT, since mcpToolRefresh defaults to true. What remains uncovered by automation is only the firing of the callback, which would need an MCP server that mutates its tool list on demand; the handler behaviour itself is unit tested directly through onToolsChanged.

One unrelated observation from the IT runs: OpenAIMcpToolsIT.testAgenticLoopHeaders asserts that at least one tool iteration occurred, which flakes with small models that sometimes answer without calling a tool. It is pre-existing and passes on retry.


Claude Code on behalf of Croway

@Croway
Croway requested review from davsclaus, gnodet and oscerd July 31, 2026 07:58
A model can request several tool calls in a single response, and those calls
are independent by design, but they were executed one after another so a batch
took as long as the sum of its tools.

Add a `parallelToolExecution` option (default false, so existing routes are
unaffected) that dispatches a batch concurrently, and a `parallelToolTimeout`
option that bounds the batch as a whole so one slow tool cannot block it.
Results are collected positionally and always fed back to the model in the
order it requested the tools, as the OpenAI API pairs each tool message with
its tool_call_id. A batch of a single tool call still runs inline.

The thread pool is obtained from Camel's ExecutorServiceManager rather than
being hand-rolled, so it follows the configured thread pool profile, is exposed
over JMX, is shut down with the CamelContext, and automatically becomes a
thread-per-task virtual thread executor when virtual threads are enabled.

The per-batch execution logic was duplicated between the agentic loop in
OpenAIProducer and the manual loop in OpenAIToolExecutionProducer, so it is
extracted into a shared McpToolCallExecutor. That keeps hallucinated tool name
handling, argument parsing, the error strategies and returnDirect detection
from drifting between the two paths, and gives both the parallel option. The
executor also snapshots the immutable McpToolState once per batch, so all calls
in a batch see a consistent view of the tools.

With toolExecutionErrorStrategy=failExchange the sibling calls already
dispatched now complete before the exchange fails, instead of being abandoned
mid-loop; this is documented in the upgrade guide.

Concurrency is covered deterministically by unit tests that rendezvous the tool
calls on a latch which can only be released if they run at the same time.
OpenAIMcpParallelToolExecutionIT additionally covers the option end to end
against the MCP Everything Server; reaching the parallel path there depends on
the model emitting multi-call batches, which test_execution.md documents.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Croway
Croway force-pushed the CAMEL-23078-openai-mcp-parallel-tools-and-refresh branch from e87bd35 to 57a4e9d Compare July 31, 2026 08:17
@github-actions

Copy link
Copy Markdown
Contributor

🌟 Thank you for your contribution to the Apache Camel project! 🌟
🤖 CI automation will test this PR automatically.

🐫 Apache Camel Committers, please review the following items:

  • First-time contributors require MANUAL approval for the GitHub Actions to run
  • You can use the command /component-test (camel-)component-name1 (camel-)component-name2.. to request a test from the test bot although they are normally detected and executed by CI.
  • You can label PRs using skip-tests and test-dependents to fine-tune the checks executed by this PR.
  • Build and test logs are available in the summary page. Only Apache Camel committers have access to the summary.

⚠️ Be careful when sharing logs. Review their contents before sharing them publicly.

@Croway
Croway force-pushed the CAMEL-23078-openai-mcp-parallel-tools-and-refresh branch from 57a4e9d to 14ff372 Compare July 31, 2026 09:09
@github-actions github-actions Bot added the dsl label Jul 31, 2026
MCP servers announce tool additions, removals and changes with a
tools/list_changed notification, but the component ignored it: the advertised
tool list was fixed to whatever was listed when the endpoint started, and was
only ever re-listed as a side effect of a transport reconnect. A long-running
route therefore kept offering the model a stale set of tools.

Register a toolsChangeConsumer on each MCP client and republish the tool state
when it fires. The MCP SDK already re-lists the tools before invoking the
consumer, so no extra round trip is needed, but the list it passes is
unfiltered and the per-server `toolNames` include list is re-applied before
publishing, so a server cannot widen its exposed tool set by announcing new
tools. The new `mcpToolRefresh` option (default true, matching what the MCP
specification expects of a client) pins the tool list to endpoint startup when
set to false.

The per-server state rebuild was inlined in doReconnectMcpServer; it is
extracted into republishServerTools and shared with the refresh path so that
duplicate name handling, the toolNames filter and returnDirect detection cannot
drift between the two. A notification arriving from a client that a reconnect
has already superseded is discarded, so a stale in-flight notification cannot
map tools back to a dead client.

Also fixes returnDirect flags set programmatically through addReturnDirectTool
and removeReturnDirectTool being silently discarded whenever the tool state was
rebuilt. They are now remembered and re-applied, except for tools that no
longer exist, which stay fully pruned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Croway
Croway force-pushed the CAMEL-23078-openai-mcp-parallel-tools-and-refresh branch from 14ff372 to 0f5a79d Compare July 31, 2026 09:38
@github-actions

Copy link
Copy Markdown
Contributor

🧪 CI tested the following changed modules:

  • catalog/camel-catalog
  • components/camel-ai/camel-openai
  • docs
  • dsl/camel-endpointdsl

🔬 Scalpel shadow comparison — Scalpel: 12 tested, 26 compile-only — current: 9 all tested

Maveniverse Scalpel detected 38 affected modules (current approach: 9).

⚠️ Modules only in Scalpel (29)
  • apache-camel
  • camel-allcomponents
  • camel-catalog
  • camel-catalog-console
  • camel-catalog-lucene
  • camel-catalog-maven
  • camel-catalog-suggest
  • camel-componentdsl
  • camel-csimple-maven-plugin
  • camel-endpointdsl
  • camel-endpointdsl-support
  • camel-itest
  • camel-jbang-core
  • camel-jbang-it
  • camel-jbang-main
  • camel-jbang-plugin-edit
  • camel-jbang-plugin-generate
  • camel-jbang-plugin-kubernetes
  • camel-jbang-plugin-test
  • camel-kamelet-main
  • camel-launcher
  • camel-report-maven-plugin
  • camel-route-parser
  • camel-yaml-dsl
  • camel-yaml-dsl-deserializers
  • camel-yaml-dsl-maven-plugin
  • coverage
  • docs
  • dummy-component

Skip-tests mode would test 12 modules (4 direct + 8 downstream), skip tests for 26 (generated code, meta-modules)

Modules Scalpel would test (12)
  • camel-catalog
  • camel-endpointdsl
  • camel-jbang-mcp
  • camel-jbang-plugin-mcp
  • camel-jbang-plugin-route-parser
  • camel-jbang-plugin-tui
  • camel-jbang-plugin-validate
  • camel-launcher-container
  • camel-openai
  • camel-yaml-dsl-validator
  • camel-yaml-dsl-validator-maven-plugin
  • docs
Modules with tests skipped (26)
  • apache-camel
  • camel-allcomponents
  • camel-catalog-console
  • camel-catalog-lucene
  • camel-catalog-maven
  • camel-catalog-suggest
  • camel-componentdsl
  • camel-csimple-maven-plugin
  • camel-endpointdsl-support
  • camel-itest
  • camel-jbang-core
  • camel-jbang-it
  • camel-jbang-main
  • camel-jbang-plugin-edit
  • camel-jbang-plugin-generate
  • camel-jbang-plugin-kubernetes
  • camel-jbang-plugin-test
  • camel-kamelet-main
  • camel-launcher
  • camel-report-maven-plugin
  • camel-route-parser
  • camel-yaml-dsl
  • camel-yaml-dsl-deserializers
  • camel-yaml-dsl-maven-plugin
  • coverage
  • dummy-component

ℹ️ Shadow mode — Scalpel observes but does not affect test execution. Learn more

⚠️ Some tests are disabled on GitHub Actions (@DisabledIfSystemProperty(named = "ci.env.name")) and require manual verification:

  • components/camel-ai/camel-openai: 7 test(s) disabled on GitHub Actions
All tested modules (38 modules)
  • Camel :: AI :: OpenAI
  • Camel :: All Components Sync point
  • Camel :: Assembly
  • Camel :: Catalog :: CSimple Maven Plugin (deprecated)
  • Camel :: Catalog :: Camel Catalog
  • Camel :: Catalog :: Camel Report Maven Plugin
  • Camel :: Catalog :: Camel Route Parser
  • Camel :: Catalog :: Console
  • Camel :: Catalog :: Dummy Component
  • Camel :: Catalog :: Lucene (deprecated)
  • Camel :: Catalog :: Maven
  • Camel :: Catalog :: Suggest
  • Camel :: Component DSL
  • Camel :: Coverage
  • Camel :: Docs
  • Camel :: Endpoint DSL
  • Camel :: Endpoint DSL :: Support
  • Camel :: Integration Tests
  • Camel :: JBang :: Core
  • Camel :: JBang :: Integration tests
  • Camel :: JBang :: MCP
  • Camel :: JBang :: Main
  • Camel :: JBang :: Plugin :: Edit
  • Camel :: JBang :: Plugin :: Generate
  • Camel :: JBang :: Plugin :: Kubernetes
  • Camel :: JBang :: Plugin :: MCP
  • Camel :: JBang :: Plugin :: Route Parser
  • Camel :: JBang :: Plugin :: TUI
  • Camel :: JBang :: Plugin :: Testing
  • Camel :: JBang :: Plugin :: Validate
  • Camel :: Kamelet Main
  • Camel :: Launcher
  • Camel :: Launcher :: Container
  • Camel :: YAML DSL
  • Camel :: YAML DSL :: Deserializers
  • Camel :: YAML DSL :: Maven Plugins
  • Camel :: YAML DSL :: Validator
  • Camel :: YAML DSL :: Validator Maven Plugin

⚙️ View full build and test results

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants