diff --git a/CHANGELOG.md b/CHANGELOG.md index 8153de2c6..54c5702b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,15 @@ Versioning follows [Semantic Versioning](https://semver.org/); see | 1.1.x | [1.1.4](https://github.com/modelcontextprotocol/java-sdk/releases/tag/v1.1.4) (2026-08-19) | 2025-06-18 | Security patches only | | 0.18.x | [0.18.4](https://github.com/modelcontextprotocol/java-sdk/releases/tag/v0.18.4) (2026-08-19) | 2025-06-18 | Security patches only | +## Unreleased (2.2.0) + +- Add `McpTransportContext.putAll(Map)`, returning a context that combines an + existing context with additional entries, which take precedence. +- `McpTransportContext` no longer compares contexts by their contents in + `equals` and `hashCode`. With `putAll`, transports can layer their own entries + over the context a user's extractor returns, and comparing contexts was never + a goal; read entries via `get(String)` instead. + ## 2.0.1 — 2026-08-19 - Bound STDIO and HTTP client/server reads to a configurable maximum size diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/common/DefaultMcpTransportContext.java b/mcp-core/src/main/java/io/modelcontextprotocol/common/DefaultMcpTransportContext.java index 322aa3a07..63aa6df29 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/common/DefaultMcpTransportContext.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/common/DefaultMcpTransportContext.java @@ -4,6 +4,7 @@ package io.modelcontextprotocol.common; +import java.util.HashMap; import java.util.Map; import io.modelcontextprotocol.util.Assert; @@ -18,7 +19,7 @@ class DefaultMcpTransportContext implements McpTransportContext { private final Map metadata; - DefaultMcpTransportContext(Map metadata) { + DefaultMcpTransportContext(Map metadata) { Assert.notNull(metadata, "The metadata cannot be null"); this.metadata = Map.copyOf(metadata); } @@ -29,17 +30,17 @@ public Object get(String key) { } @Override - public boolean equals(Object o) { - if (o == null || getClass() != o.getClass()) - return false; - - DefaultMcpTransportContext that = (DefaultMcpTransportContext) o; - return this.metadata.equals(that.metadata); - } - - @Override - public int hashCode() { - return this.metadata.hashCode(); + public McpTransportContext putAll(Map map) { + Assert.notNull(map, "The map cannot be null"); + if (map.isEmpty()) { + return this; + } + if (this.metadata.isEmpty()) { + return new DefaultMcpTransportContext(map); + } + Map merged = new HashMap<>(this.metadata); + merged.putAll(map); + return new DefaultMcpTransportContext(merged); } } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/common/McpTransportContext.java b/mcp-core/src/main/java/io/modelcontextprotocol/common/McpTransportContext.java index 4691c13a2..ec8f1749f 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/common/McpTransportContext.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/common/McpTransportContext.java @@ -7,6 +7,8 @@ import java.util.Collections; import java.util.Map; +import io.modelcontextprotocol.util.Assert; + /** * Context associated with the transport layer. It allows to add transport-level metadata * for use further down the line. Specifically, it can be beneficial to extract HTTP @@ -58,4 +60,24 @@ static McpTransportContext create(Map metadata) { */ Object get(String key); + /** + * Create a new context holding the values of this context and of the specified map. + * When both hold the same key, the value from the provided map wins. Neither this + * context nor the provided map is modified. When the provided map is empty, this + * context is returned as is. + *

+ * Keys absent from the provided map are looked up in this context. As with + * {@link #create(Map)}, {@code null} keys and values are rejected. + * @param map the values to add, taking precedence over the values of this context + * @return a new context holding the merged values + * @since 2.2.0 + */ + default McpTransportContext putAll(Map map) { + Assert.notNull(map, "The map cannot be null"); + if (map.isEmpty()) { + return this; + } + return new OverlayMcpTransportContext(this, map); + } + } diff --git a/mcp-core/src/main/java/io/modelcontextprotocol/common/OverlayMcpTransportContext.java b/mcp-core/src/main/java/io/modelcontextprotocol/common/OverlayMcpTransportContext.java new file mode 100644 index 000000000..0fac147b6 --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/common/OverlayMcpTransportContext.java @@ -0,0 +1,48 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.common; + +import java.util.HashMap; +import java.util.Map; + +import io.modelcontextprotocol.util.Assert; + +/** + * {@link McpTransportContext} that looks up keys in a map first and falls back to a base + * context. {@link #putAll(Map)} returns a new instance over the same base. + * + * @author Dariusz Jędrzejczyk + */ +final class OverlayMcpTransportContext implements McpTransportContext { + + private final McpTransportContext base; + + private final Map overlay; + + OverlayMcpTransportContext(McpTransportContext base, Map overlay) { + Assert.notNull(base, "The base context cannot be null"); + Assert.notNull(overlay, "The overlay cannot be null"); + this.base = base; + this.overlay = Map.copyOf(overlay); + } + + @Override + public Object get(String key) { + Object value = this.overlay.get(key); + return (value != null) ? value : this.base.get(key); + } + + @Override + public McpTransportContext putAll(Map map) { + Assert.notNull(map, "The map cannot be null"); + if (map.isEmpty()) { + return this; + } + Map merged = new HashMap<>(this.overlay); + merged.putAll(map); + return new OverlayMcpTransportContext(this.base, merged); + } + +} diff --git a/mcp-core/src/test/java/io/modelcontextprotocol/common/McpTransportContextTests.java b/mcp-core/src/test/java/io/modelcontextprotocol/common/McpTransportContextTests.java index a19b1015d..57b5f553e 100644 --- a/mcp-core/src/test/java/io/modelcontextprotocol/common/McpTransportContextTests.java +++ b/mcp-core/src/test/java/io/modelcontextprotocol/common/McpTransportContextTests.java @@ -10,10 +10,12 @@ import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; /** - * Tests for {@link McpTransportContext#create(Map)}, which is documented to return an - * unmodifiable context. + * Tests for {@link McpTransportContext}: {@link McpTransportContext#create(Map)}, which + * is documented to return an unmodifiable context, and + * {@link McpTransportContext#putAll(Map)}. */ class McpTransportContextTests { @@ -51,39 +53,130 @@ void createdContextShouldNotBeEmptiedByClearingTheSourceMap() { } @Test - void createdContextShouldRemainUsableAsAMapKey() { - Map metadata = new HashMap<>(); - metadata.put("tenant", "acme"); - McpTransportContext context = McpTransportContext.create(metadata); - - Map byContext = new HashMap<>(); - byContext.put(context, "value"); - metadata.put("tenant", "other"); + void createdContextFromAnImmutableMapIsAlreadyCorrect() { + McpTransportContext context = McpTransportContext.create(Map.of("tenant", "acme")); - assertThat(byContext.get(context)).isEqualTo("value"); + assertThat(context.get("tenant")).isEqualTo("acme"); } @Test - void twoContextsCreatedFromEqualMapsShouldStayEqual() { - Map first = new HashMap<>(); - first.put("tenant", "acme"); - Map second = new HashMap<>(); - second.put("tenant", "acme"); + void putAllShouldKeepTheEntriesOfTheContextAndTheMap() { + McpTransportContext context = McpTransportContext.create(Map.of("tenant", "acme")); - McpTransportContext firstContext = McpTransportContext.create(first); - McpTransportContext secondContext = McpTransportContext.create(second); - assertThat(firstContext).isEqualTo(secondContext); + McpTransportContext merged = context.putAll(Map.of("region", "eu")); - first.put("tenant", "other"); + assertThat(merged.get("tenant")).isEqualTo("acme"); + assertThat(merged.get("region")).isEqualTo("eu"); + } - assertThat(firstContext).isEqualTo(secondContext); + @Test + void putAllShouldLetTheMapWinOnSharedKeys() { + McpTransportContext context = McpTransportContext.create(Map.of("tenant", "acme")); + + assertThat(context.putAll(Map.of("tenant", "other")).get("tenant")).isEqualTo("other"); } @Test - void createdContextFromAnImmutableMapIsAlreadyCorrect() { + void putAllShouldLeaveTheOriginalContextUnchanged() { McpTransportContext context = McpTransportContext.create(Map.of("tenant", "acme")); + context.putAll(Map.of("tenant", "other", "region", "eu")); + assertThat(context.get("tenant")).isEqualTo("acme"); + assertThat(context.get("region")).isNull(); + } + + @Test + void putAllShouldNotSeeLaterWritesToTheSourceMap() { + Map entries = new HashMap<>(); + entries.put("region", "eu"); + + McpTransportContext merged = new LookupTransportContext(Map.of()).putAll(entries); + entries.put("region", "us"); + + assertThat(merged.get("region")).isEqualTo("eu"); + } + + @Test + void putAllWithAnEmptyMapShouldReturnTheSameContext() { + McpTransportContext context = McpTransportContext.create(Map.of("tenant", "acme")); + McpTransportContext custom = new LookupTransportContext(Map.of("tenant", "acme")); + + assertThat(context.putAll(Map.of())).isSameAs(context); + assertThat(custom.putAll(Map.of())).isSameAs(custom); + } + + @Test + void putAllOnTheDefaultContextShouldHoldTheMergedEntries() { + McpTransportContext context = McpTransportContext.create(Map.of("tenant", "acme", "region", "us")); + + McpTransportContext merged = context.putAll(Map.of("region", "eu")); + + assertThat(merged.get("tenant")).isEqualTo("acme"); + assertThat(merged.get("region")).isEqualTo("eu"); + assertThat(merged.get("missing")).isNull(); + } + + @Test + void putAllShouldRejectNullValues() { + Map withNullValue = new HashMap<>(); + withNullValue.put("region", null); + + assertThatThrownBy(() -> McpTransportContext.create(Map.of()).putAll(withNullValue)) + .isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> new LookupTransportContext(Map.of()).putAll(withNullValue)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void putAllOnACustomContextShouldDelegateLookupsForKeysNotInTheMap() { + McpTransportContext custom = new LookupTransportContext(Map.of("tenant", "acme", "region", "us")); + + McpTransportContext merged = custom.putAll(Map.of("region", "eu")); + + assertThat(merged.get("tenant")).isEqualTo("acme"); + assertThat(merged.get("region")).isEqualTo("eu"); + assertThat(merged.get("missing")).isNull(); + } + + @Test + void putAllOnACustomContextShouldSeeLaterChangesToIt() { + Map source = new HashMap<>(); + McpTransportContext custom = new LookupTransportContext(source); + + McpTransportContext merged = custom.putAll(Map.of("region", "eu")); + source.put("tenant", "acme"); + + assertThat(merged.get("tenant")).isEqualTo("acme"); + } + + @Test + void repeatedPutAllShouldHoldTheCombinedEntries() { + McpTransportContext custom = new LookupTransportContext(Map.of("tenant", "acme")); + + McpTransportContext merged = custom.putAll(Map.of("region", "us")).putAll(Map.of("region", "eu", "zone", "a")); + + assertThat(merged.get("tenant")).isEqualTo("acme"); + assertThat(merged.get("region")).isEqualTo("eu"); + assertThat(merged.get("zone")).isEqualTo("a"); + } + + /** + * A context reading its backing map on each lookup. + */ + static class LookupTransportContext implements McpTransportContext { + + private final Map source; + + LookupTransportContext(Map source) { + this.source = source; + } + + @Override + public Object get(String key) { + return this.source.get(key); + } + } } diff --git a/mcp-test/src/main/java/io/modelcontextprotocol/AbstractMcpClientServerIntegrationTests.java b/mcp-test/src/main/java/io/modelcontextprotocol/AbstractMcpClientServerIntegrationTests.java index dd4d9f702..7dad7dca6 100644 --- a/mcp-test/src/main/java/io/modelcontextprotocol/AbstractMcpClientServerIntegrationTests.java +++ b/mcp-test/src/main/java/io/modelcontextprotocol/AbstractMcpClientServerIntegrationTests.java @@ -1416,7 +1416,7 @@ void testToolCallSuccessWithTranportContextExtraction(String clientType) { var clientBuilder = clientBuilders.get(clientType); var transportContextIsNull = new AtomicBoolean(false); - var transportContextIsEmpty = new AtomicBoolean(false); + var importantValue = new AtomicReference(); var responseBodyIsNullOrBlank = new AtomicBoolean(false); var expectedCallResponse = McpSchema.CallToolResult.builder() @@ -1428,7 +1428,7 @@ void testToolCallSuccessWithTranportContextExtraction(String clientType) { McpTransportContext transportContext = exchange.transportContext(); transportContextIsNull.set(transportContext == null); - transportContextIsEmpty.set(transportContext.equals(McpTransportContext.EMPTY)); + importantValue.set(transportContext.get("important")); String ctxValue = (String) transportContext.get("important"); try { @@ -1460,7 +1460,7 @@ void testToolCallSuccessWithTranportContextExtraction(String clientType) { .callTool(McpSchema.CallToolRequest.builder("tool1").arguments(Map.of()).build()); assertThat(transportContextIsNull.get()).isFalse(); - assertThat(transportContextIsEmpty.get()).isFalse(); + assertThat(importantValue.get()).isEqualTo("value"); assertThat(responseBodyIsNullOrBlank.get()).isFalse(); assertThat(response).isNotNull().isEqualTo(expectedCallResponse); }