From 7a6a9dff5700f98f5ee65681288c073d8e17380b Mon Sep 17 00:00:00 2001 From: Daniel Garnier-Moiroux Date: Sun, 25 Oct 2026 17:38:10 +0100 Subject: [PATCH] Add stream, putAll and putAllMap to McpTransportContext - Transports need to list a context's entries and combine contexts, for instance to add the decoded Mcp-Param-* headers to a context built by a user-provided extractor. - This introduces a way to merge McpTransportContext. To do so, it introduces a way to read entries from the contexts, by key-value. - This change is non-breaking but custom implementations of McpTransportContext used in the modern server MUST implement stream(). The implementation is inspired from Reactor's Context. Signed-off-by: Daniel Garnier-Moiroux --- CHANGELOG.md | 8 + .../common/DefaultMcpTransportContext.java | 6 + .../common/McpTransportContext.java | 64 ++++++++ .../common/McpTransportContextTests.java | 137 +++++++++++++++++- 4 files changed, 213 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8153de2c6..0d9a40130 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,14 @@ 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) + +- `McpTransportContext` gains the default methods `stream()`, `putAll` and + `putAllMap` to list and merge contexts. The default `stream()` throws + `UnsupportedOperationException`; custom implementations used with the + modern server must implement it, as the servlet transport merges the + `Mcp-Param-*` headers into the context of `tools/call` requests. + ## 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..b5cfe02ce 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/common/DefaultMcpTransportContext.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/common/DefaultMcpTransportContext.java @@ -5,6 +5,7 @@ package io.modelcontextprotocol.common; import java.util.Map; +import java.util.stream.Stream; import io.modelcontextprotocol.util.Assert; @@ -28,6 +29,11 @@ public Object get(String key) { return this.metadata.get(key); } + @Override + public Stream> stream() { + return this.metadata.entrySet().stream(); + } + @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..beb4cd8b6 100644 --- a/mcp-core/src/main/java/io/modelcontextprotocol/common/McpTransportContext.java +++ b/mcp-core/src/main/java/io/modelcontextprotocol/common/McpTransportContext.java @@ -5,7 +5,10 @@ package io.modelcontextprotocol.common; import java.util.Collections; +import java.util.HashMap; +import java.util.Iterator; import java.util.Map; +import java.util.stream.Stream; /** * Context associated with the transport layer. It allows to add transport-level metadata @@ -26,6 +29,7 @@ * * * @author Dariusz Jędrzejczyk + * @author Daniel Garnier-Moiroux */ public interface McpTransportContext { @@ -58,4 +62,64 @@ static McpTransportContext create(Map metadata) { */ Object get(String key); + /** + * Stream the entries of this context, in no particular order. + *

+ * Unimplemented and throws by default. Implementations used in the modern version of + * the spec MUST implement this method. + * @return a stream of the key-value pairs held by this context + * @since 2.2.0 + */ + default Stream> stream() { + throw new UnsupportedOperationException("%s must implement stream()".formatted(this.getClass().getName())); + } + + /** + * Create a new context holding the entries of this context and those of + * {@code other}. When both contexts hold the same key, the value from {@code other} + * wins. Neither context is modified. When {@code other} is empty, this context is + * returned as is. + *

+ * The entries are copied eagerly into a new context, as created by + * {@link #create(Map)}, so the result compares equal to a context created directly + * from the same entries. + * @param other the context whose entries are added, taking precedence over the + * entries of this context + * @return a new context holding the merged entries + * @since 2.2.0 + */ + default McpTransportContext putAll(McpTransportContext other) { + Iterator> entries = other.stream().iterator(); + if (!entries.hasNext()) { + return this; + } + Map merged = new HashMap<>(); + stream().forEach(entry -> merged.put(entry.getKey(), entry.getValue())); + entries.forEachRemaining(entry -> merged.put(entry.getKey(), entry.getValue())); + return create(merged); + } + + /** + * Create a new context holding the entries of this context and those of {@code map}. + * When both hold the same key, the value from {@code map} wins. Neither this context + * nor the map is modified. When {@code map} is empty, this context is returned as is. + *

+ * The entries are copied eagerly into a new context, as created by + * {@link #create(Map)}, so the result compares equal to a context created directly + * from the same entries. As with {@link #create(Map)}, {@code null} values are + * rejected. + * @param map the entries to add, taking precedence over the entries of this context + * @return a new context holding the merged entries + * @since 2.2.0 + */ + default McpTransportContext putAllMap(Map map) { + if (map.isEmpty()) { + return this; + } + Map merged = new HashMap<>(); + stream().forEach(entry -> merged.put(entry.getKey(), entry.getValue())); + merged.putAll(map); + return create(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..d0b330136 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 the {@code stream}, {@code putAll} + * and {@code putAllMap} operations. */ class McpTransportContextTests { @@ -86,4 +88,135 @@ void createdContextFromAnImmutableMapIsAlreadyCorrect() { assertThat(context.get("tenant")).isEqualTo("acme"); } + @Test + void streamShouldListEveryEntryOfTheContext() { + McpTransportContext context = McpTransportContext.create(Map.of("tenant", "acme", "region", "eu")); + + assertThat(context.stream()).containsExactlyInAnyOrder(Map.entry("tenant", "acme"), Map.entry("region", "eu")); + } + + @Test + void streamOfTheEmptyContextShouldBeEmpty() { + assertThat(McpTransportContext.EMPTY.stream()).isEmpty(); + } + + @Test + void putAllShouldKeepTheEntriesOfBothContexts() { + McpTransportContext context = McpTransportContext.create(Map.of("tenant", "acme")); + McpTransportContext other = McpTransportContext.create(Map.of("region", "eu")); + + McpTransportContext merged = context.putAll(other); + + assertThat(merged.get("tenant")).isEqualTo("acme"); + assertThat(merged.get("region")).isEqualTo("eu"); + } + + @Test + void putAllShouldLetTheArgumentWinOnSharedKeys() { + McpTransportContext context = McpTransportContext.create(Map.of("tenant", "acme")); + McpTransportContext other = McpTransportContext.create(Map.of("tenant", "other")); + + assertThat(context.putAll(other).get("tenant")).isEqualTo("other"); + } + + @Test + void putAllShouldLeaveBothOriginalContextsUnchanged() { + McpTransportContext context = McpTransportContext.create(Map.of("tenant", "acme")); + McpTransportContext other = McpTransportContext.create(Map.of("tenant", "other", "region", "eu")); + + context.putAll(other); + + assertThat(context.stream()).containsExactly(Map.entry("tenant", "acme")); + assertThat(other.stream()).containsExactlyInAnyOrder(Map.entry("tenant", "other"), Map.entry("region", "eu")); + } + + @Test + void putAllWithAnEmptyContextShouldReturnTheSameContext() { + McpTransportContext context = McpTransportContext.create(Map.of("tenant", "acme")); + + assertThat(context.putAll(McpTransportContext.EMPTY)).isSameAs(context); + assertThat(context.putAll(McpTransportContext.create(Map.of()))).isSameAs(context); + } + + @Test + void putAllShouldBeEqualToAContextCreatedFromTheMergedEntries() { + McpTransportContext context = McpTransportContext.create(Map.of("tenant", "acme", "region", "us")); + McpTransportContext other = McpTransportContext.create(Map.of("region", "eu")); + + McpTransportContext merged = context.putAll(other); + + McpTransportContext expected = McpTransportContext.create(Map.of("tenant", "acme", "region", "eu")); + assertThat(merged).isEqualTo(expected); + assertThat(merged).hasSameHashCodeAs(expected); + } + + @Test + void putAllMapShouldKeepTheEntriesOfTheContextAndTheMap() { + McpTransportContext context = McpTransportContext.create(Map.of("tenant", "acme")); + + McpTransportContext merged = context.putAllMap(Map.of("region", "eu")); + + assertThat(merged.get("tenant")).isEqualTo("acme"); + assertThat(merged.get("region")).isEqualTo("eu"); + } + + @Test + void putAllMapShouldLetTheMapWinOnSharedKeys() { + McpTransportContext context = McpTransportContext.create(Map.of("tenant", "acme")); + + assertThat(context.putAllMap(Map.of("tenant", "other")).get("tenant")).isEqualTo("other"); + } + + @Test + void putAllMapShouldLeaveTheOriginalContextUnchanged() { + McpTransportContext context = McpTransportContext.create(Map.of("tenant", "acme")); + + context.putAllMap(Map.of("tenant", "other")); + + assertThat(context.get("tenant")).isEqualTo("acme"); + } + + @Test + void putAllMapWithAnEmptyMapShouldReturnTheSameContext() { + McpTransportContext context = McpTransportContext.create(Map.of("tenant", "acme")); + + assertThat(context.putAllMap(Map.of())).isSameAs(context); + } + + @Test + void putAllMapShouldBeEqualToAContextCreatedFromTheMergedEntries() { + McpTransportContext context = McpTransportContext.create(Map.of("tenant", "acme", "region", "us")); + + McpTransportContext merged = context.putAllMap(Map.of("region", "eu")); + + assertThat(merged).isEqualTo(McpTransportContext.create(Map.of("tenant", "acme", "region", "eu"))); + } + + @Test + void putAllMapShouldRejectNullValues() { + McpTransportContext context = McpTransportContext.create(Map.of("tenant", "acme")); + Map withNullValue = new HashMap<>(); + withNullValue.put("region", null); + + assertThatThrownBy(() -> context.putAllMap(withNullValue)).isInstanceOf(NullPointerException.class); + } + + @Test + void shouldImplementStream() { + var context = new TestTransportContext(); + + assertThatThrownBy(context::stream).isInstanceOf(UnsupportedOperationException.class) + .hasMessage( + "io.modelcontextprotocol.common.McpTransportContextTests$TestTransportContext must implement stream()"); + } + + static class TestTransportContext implements McpTransportContext { + + @Override + public Object get(String key) { + return null; + } + + } + }