Repository: langchain4j/langchain4j (2026-07-17T03:33:27Z)

Issue:
[BUG] MCP: user-supplied _meta (metaSupplier) overwrites the framework progressToken, so progress notifications never fire
**Describe the bug**

When an MCP client is configured with **both** a `progressHandler` and a `metaSupplier`, progress notifications never reach the handler.

`DefaultMcpClient` injects a `progressToken` into a request's `_meta` when a `progressHandler` is set (via the `McpCallToolRequest(..., progressToken)` constructor). Then `applyMeta(...)` applies the user's `metaSupplier` by calling `McpClientParams#setMeta(meta)`, which **replaces** the `_meta` map wholesale rather than merging. This wipes the previously-injected `progressToken`, so the server never receives it and — per the MCP spec — never emits `notifications/progress`. The user's `progressHandler` is silently never called.

Relevant code:
- `DefaultMcpClient#applyMeta` calls `getParams().setMeta(meta)` (replace, not merge).
- `McpCallToolRequest` constructor sets `_meta = {progressToken}`.
- `McpClientParams#setMeta` overwrites the field.

**To Reproduce**

```java
McpClient client = new DefaultMcpClient.Builder()
        .transport(transport)
        .progressHandler(n -> { /* ... */ })      // injects progressToken into _meta
        .metaSupplier(ctx -> Map.of("tenant", "acme")) // replaces _meta -> progressToken lost
        .build();

client.executeTool(ToolExecutionRequest.builder().name("test").arguments("{}").build());
// The outgoing request _meta is {"tenant":"acme"} — progressToken is gone.
```

**Expected behavior**

The user-supplied `_meta` should be **merged** with the `_meta` already present on the message, preserving the framework-managed `progressToken` (and any other client-managed protocol metadata). The outgoing `_meta` should contain both `progressToken` and `tenant`.

**Additional context**

Each feature works in isolation; the bug only appears when `progressHandler` and `metaSupplier` are used together.

- LangChain4j version: 1.18.0-SNAPSHOT (current `main`)


Discussion:

Patch:
diff --git a/langchain4j-mcp/src/main/java/dev/langchain4j/mcp/client/DefaultMcpClient.java b/langchain4j-mcp/src/main/java/dev/langchain4j/mcp/client/DefaultMcpClient.java
index 356454a30f9..feb91e2a8b6 100644
--- a/langchain4j-mcp/src/main/java/dev/langchain4j/mcp/client/DefaultMcpClient.java
+++ b/langchain4j-mcp/src/main/java/dev/langchain4j/mcp/client/DefaultMcpClient.java
@@ -44,6 +44,7 @@
 import dev.langchain4j.service.tool.ToolExecutionResult;
 import java.time.Duration;
 import java.util.ArrayList;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.UUID;
@@ -810,15 +811,30 @@ private void applyMeta(McpClientMessage message, McpCallContext context) {
             if (request.getParams() == null) {
                 request.setParams(new McpClientParams());
             }
-            request.getParams().setMeta(meta);
+            request.getParams().setMeta(mergeMeta(request.getParams().getMeta(), meta));
         } else if (message instanceof McpClientNotification notification) {
             if (notification.getParams() == null) {
                 notification.setParams(new McpClientParams());
             }
-            notification.getParams().setMeta(meta);
+            notification.getParams().setMeta(mergeMeta(notification.getParams().getMeta(), meta));
         }
     }
 
+    /**
+     * Merges the user-supplied {@code _meta} entries into the {@code _meta} already present on the
+     * message. Entries already set by the client (such as the framework-managed
+     * {@code progressToken}) take precedence, so that protocol metadata is never overwritten by the
+     * user-supplied values.
+     */
+    private static Map<String, Object> mergeMeta(Map<String, Object> existing, Map<String, Object> supplied) {
+        if (existing == null || existing.isEmpty()) {
+            return supplied;
+        }
+        Map<String, Object> merged = new LinkedHashMap<>(supplied);
+        merged.putAll(existing);
+        return merged;
+    }
+
     private void notifyListeners(Consumer<McpClientListener> action) {
         for (McpClientListener listener : listeners) {
             try {


Test patch:
diff --git a/langchain4j-mcp/src/test/java/dev/langchain4j/mcp/client/DefaultMcpClientTest.java b/langchain4j-mcp/src/test/java/dev/langchain4j/mcp/client/DefaultMcpClientTest.java
index ac45796f7a4..2c0c5fed4bf 100644
--- a/langchain4j-mcp/src/test/java/dev/langchain4j/mcp/client/DefaultMcpClientTest.java
+++ b/langchain4j-mcp/src/test/java/dev/langchain4j/mcp/client/DefaultMcpClientTest.java
@@ -21,6 +21,7 @@
 import dev.langchain4j.exception.ToolExecutionException;
 import dev.langchain4j.mcp.client.transport.McpOperationHandler;
 import dev.langchain4j.mcp.client.transport.McpTransport;
+import dev.langchain4j.mcp.protocol.McpClientRequest;
 import dev.langchain4j.mcp.protocol.McpListToolsParams;
 import dev.langchain4j.mcp.protocol.McpListToolsRequest;
 import dev.langchain4j.service.tool.ToolExecutionResult;
@@ -530,6 +531,39 @@ public void should_paginate_tool_list_using_cursor() throws Exception {
         assertThat(((McpListToolsParams) secondRequest.getParams()).getCursor()).isEqualTo("cursor-page2");
     }
 
+    @Test
+    public void meta_supplier_should_not_drop_progress_token() throws Exception {
+        final McpTransport transport = getMinimalMcpTransportMock();
+
+        ObjectNode toolResult = JsonNodeFactory.instance.objectNode();
+        toolResult
+                .putObject("result")
+                .putArray("content")
+                .addObject()
+                .put("type", "text")
+                .put("text", "ok");
+        when(transport.executeOperationWithResponse(any(McpCallContext.class)))
+                .thenReturn(CompletableFuture.completedFuture(toolResult));
+
+        DefaultMcpClient client = new DefaultMcpClient.Builder()
+                .transport(transport)
+                .progressHandler(notification -> {})
+                .metaSupplier(context -> Map.of("tenant", "acme"))
+                .build();
+
+        client.executeTool(
+                ToolExecutionRequest.builder().name("test").arguments("{}").build());
+
+        ArgumentCaptor<McpCallContext> captor = ArgumentCaptor.forClass(McpCallContext.class);
+        verify(transport).executeOperationWithResponse(captor.capture());
+        McpClientRequest request = (McpClientRequest) captor.getValue().message();
+        Map<String, Object> meta = request.getParams().getMeta();
+
+        // The user-supplied _meta must not overwrite the framework-managed progressToken.
+        assertThat(meta).containsKey("tenant");
+        assertThat(meta).containsKey("progressToken");
+    }
+
     private static McpTransport getMinimalMcpTransportMock() {
         McpTransport transport = mock(McpTransport.class);
         ObjectNode emptyJsonNode = JsonNodeFactory.instance.objectNode();