From 318eedd79c4404735d2c9fd01516c7d410280922 Mon Sep 17 00:00:00 2001 From: Dariusz Jedrzejczyk <2554306+chemicL@users.noreply.github.com> Date: Tue, 27 Oct 2026 14:38:21 +0100 Subject: [PATCH] Add putAll(Map) to McpTransportContext Transports need to add their own keys to the context a user-provided extractor built, for instance the decoded Mcp-Param-* headers: - the provided keys take precedence over the user-extracted ones - neither the user's context nor the map is modified - custom McpTransportContext implementations keep working unchanged Contexts created via McpTransportContext.create are merged into a single map. Other implementations are wrapped once, and further merges combine into a single wrapper rather than adding layers. Signed-off-by: Dariusz Jedrzejczyk <2554306+chemicL@users.noreply.github.com> --- CHANGELOG.md | 5 + .../common/DefaultMcpTransportContext.java | 12 ++ .../common/McpTransportContext.java | 22 +++ .../common/OverlayMcpTransportContext.java | 63 +++++++++ .../common/McpTransportContextTests.java | 127 +++++++++++++++++- 5 files changed, 227 insertions(+), 2 deletions(-) create mode 100644 mcp-core/src/main/java/io/modelcontextprotocol/common/OverlayMcpTransportContext.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 8153de2c6..200ab8531 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,11 @@ 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. + ## 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..8118baea8 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; @@ -28,6 +29,17 @@ public Object get(String key) { return this.metadata.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.metadata); + merged.putAll(map); + return new DefaultMcpTransportContext(merged); + } + @Override public boolean equals(Object o) { if (o == null || getClass() != o.getClass()) 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..2eef7285e --- /dev/null +++ b/mcp-core/src/main/java/io/modelcontextprotocol/common/OverlayMcpTransportContext.java @@ -0,0 +1,63 @@ +/* + * Copyright 2026-2026 the original author or authors. + */ + +package io.modelcontextprotocol.common; + +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +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); + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) + return false; + + OverlayMcpTransportContext that = (OverlayMcpTransportContext) o; + return this.base.equals(that.base) && this.overlay.equals(that.overlay); + } + + @Override + public int hashCode() { + return Objects.hash(this.base, this.overlay); + } + +} 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..04b7a2aa3 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 { @@ -86,4 +88,125 @@ void createdContextFromAnImmutableMapIsAlreadyCorrect() { assertThat(context.get("tenant")).isEqualTo("acme"); } + @Test + void putAllShouldKeepTheEntriesOfTheContextAndTheMap() { + McpTransportContext context = McpTransportContext.create(Map.of("tenant", "acme")); + + McpTransportContext merged = context.putAll(Map.of("region", "eu")); + + assertThat(merged.get("tenant")).isEqualTo("acme"); + assertThat(merged.get("region")).isEqualTo("eu"); + } + + @Test + void putAllShouldLetTheMapWinOnSharedKeys() { + McpTransportContext context = McpTransportContext.create(Map.of("tenant", "acme")); + + assertThat(context.putAll(Map.of("tenant", "other")).get("tenant")).isEqualTo("other"); + } + + @Test + 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 putAllOnTheDefaultContextShouldBeEqualToAContextCreatedFromTheMergedEntries() { + McpTransportContext context = McpTransportContext.create(Map.of("tenant", "acme", "region", "us")); + + McpTransportContext merged = context.putAll(Map.of("region", "eu")); + + McpTransportContext expected = McpTransportContext.create(Map.of("tenant", "acme", "region", "eu")); + assertThat(merged).isEqualTo(expected); + assertThat(merged).hasSameHashCodeAs(expected); + } + + @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 repeatedPutAllShouldEqualASinglePutAllOfTheCombinedEntries() { + 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).isEqualTo(custom.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); + } + + } + }