Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
package io.modelcontextprotocol.common;

import java.util.Map;
import java.util.stream.Stream;

import io.modelcontextprotocol.util.Assert;

Expand All @@ -28,6 +29,11 @@ public Object get(String key) {
return this.metadata.get(key);
}

@Override
public Stream<Map.Entry<String, Object>> stream() {
return this.metadata.entrySet().stream();
}

@Override
public boolean equals(Object o) {
if (o == null || getClass() != o.getClass())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -26,6 +29,7 @@
* </ul>
*
* @author Dariusz Jędrzejczyk
* @author Daniel Garnier-Moiroux
*/
public interface McpTransportContext {

Expand Down Expand Up @@ -58,4 +62,64 @@ static McpTransportContext create(Map<String, Object> metadata) {
*/
Object get(String key);

/**
* Stream the entries of this context, in no particular order.
* <p>
* 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<Map.Entry<String, Object>> 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.
* <p>
* 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<Map.Entry<String, Object>> entries = other.stream().iterator();
if (!entries.hasNext()) {
return this;
}
Map<String, Object> 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.
* <p>
* 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<String, ?> map) {
if (map.isEmpty()) {
return this;
}
Map<String, Object> merged = new HashMap<>();
stream().forEach(entry -> merged.put(entry.getKey(), entry.getValue()));
merged.putAll(map);
return create(merged);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down Expand Up @@ -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<String, Object> 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;
}

}

}
Loading