Skip to content

Commit 4edb6c2

Browse files
Merge branch 'main' into fix/listroots-reject-without-capability
2 parents 6a97f6a + 4186ca1 commit 4edb6c2

46 files changed

Lines changed: 3149 additions & 725 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/workflows/ci.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ name: CI
22

33
on:
44
pull_request: {}
5+
workflow_dispatch: {}
56

67
jobs:
78
build:

‎CONTRIBUTING.md‎

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,12 @@ propose an enhancement. Bug reports should have a reproducer in the form of a co
3535
sample or a repository attached that the maintainers or contributors can work with to
3636
address the problem.
3737

38+
## AI agents
39+
40+
**We accept contributions created with the help of AI coding agents, but they must be carefully reviewed by a human who remains accountable for the quality of the contribution.**
41+
These can be issues or pull requests. For issues, please ensure you describe your particular use-case, and not general considerations found by an AI agent.
42+
Contributions submitted by GitHub accounts controlled by autonomous AI bots are forbidden.
43+
3844
## Making Changes
3945

4046
1. Create a new branch:
@@ -63,15 +69,25 @@ git checkout -b feature/your-feature-name
6369

6470
## Submitting Changes
6571

72+
Please don't submit pull requests:
73+
74+
- With GitHub accounts managed by autonomous AI bots
75+
- For already assigned issues (as the assignee is or plans to work on it)
76+
77+
When submitting changes:
78+
6679
1. For non-trivial changes, please clarify with the maintainers in an issue whether
6780
you can contribute the change and the desired scope of the change.
6881
2. For trivial changes (for example a couple of lines or documentation changes) there
6982
is no need to open an issue first.
70-
3. Push your changes to your fork.
71-
4. Submit a pull request to the main repository.
72-
5. Follow the pull request template.
73-
6. Wait for review.
74-
7. For any follow-up work, please add new commits instead of force-pushing. This will
83+
3. Maintainers will triage issues and act on them. There is no such guarantee on pull requests.
84+
PRs opened without being discussed in an issue MAY be ignored, and maintainers reserve the right
85+
to close these PRs without justification.
86+
4. Push your changes to your fork.
87+
5. Submit a pull request to the main repository.
88+
6. Follow the pull request template.
89+
7. Wait for review.
90+
8. For any follow-up work, please add new commits instead of force-pushing. This will
7591
allow the reviewer to focus on incremental changes instead of having to restart the
7692
review process.
7793

‎docs/server.md‎

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -441,6 +441,102 @@ var syncToolSpecification = SyncToolSpecification.builder()
441441

442442
`ImageContent.builder(data, mimeType)` and `AudioContent.builder(data, mimeType)` both take base64-encoded binary data. `EmbeddedResource.builder(resourceContents)` wraps either a `TextResourceContents` (for text data) or a `BlobResourceContents` (for base64-encoded binary data) — see [Reading Binary Resources](#reading-binary-resources) for the `BlobResourceContents` shape.
443443

444+
### Filtering the Tool Listing per Request
445+
446+
By default every registered tool is advertised to every caller. Over an HTTP transport you can
447+
vary the `tools/list` response per request — to hide tools the caller is not authorized to see,
448+
or to trim a large catalog down to a relevant subset — by registering one or more tool filters.
449+
450+
The filter receives the `McpTransportContext` extracted from the current request, so it can key
451+
on HTTP headers, a token, a resolved principal, or anything else your
452+
`contextExtractor` puts there.
453+
454+
=== "Sync"
455+
456+
```java
457+
McpServer.sync(transportProvider)
458+
.tools(publicTool, adminTool)
459+
.addToolFilter((transportContext, tool) ->
460+
!tool.name().startsWith("admin-") || isAdmin(transportContext))
461+
.build();
462+
```
463+
464+
=== "Async"
465+
466+
```java
467+
McpServer.async(transportProvider)
468+
.tools(publicTool, adminTool)
469+
.addToolFilter((transportContext, tool) -> {
470+
if (!tool.name().startsWith("admin-")) {
471+
return Mono.just(true);
472+
}
473+
return isAdmin(transportContext); // Mono<Boolean>
474+
})
475+
.build();
476+
```
477+
478+
The same `addToolFilter(...)` method is available on the stateless builders.
479+
480+
!!! warning "Hiding a tool does not make it unreachable"
481+
482+
The filter controls **advertisement only**. A hidden tool called by name still executes:
483+
you MUST enforce permissions in the tool's call handler. Use the filter to control what a
484+
caller is told about, not what they are allowed to do.
485+
486+
**Evaluation semantics**
487+
488+
- The filter is consulted on **every** listing request and never cached, so the same session may
489+
legitimately see different results for two successive requests carrying different credentials.
490+
- Registration order is preserved; only omissions happen.
491+
- Returning `Mono.empty()` from an async filter omits the tool. An error fails the whole listing
492+
request rather than silently hiding tools: a client cannot tell a filtered-down listing from a
493+
partial one, and MCP has no way to signal "this listing was incomplete, retry".
494+
- A filter that errors is logged server-side and reported to the client as an opaque
495+
`-32603 Internal error` with no `data`. If you want the client to see a specific error, throw an
496+
`McpError`, those are passed through.
497+
- Filters accumulate as a boolean **AND**: a tool is listed only when every registered filter accepts it, so a
498+
later `addToolFilter(...)` can never widen access. Evaluation follows registration order and
499+
short-circuits on the first filter that hides a tool.
500+
- `toolFilters(Consumer<List<...>>)` hands you the list of filters registered so far, so you can
501+
inspect, reorder or clear them before building — useful when filters come from several places:
502+
503+
```java
504+
McpServer.sync(transportProvider)
505+
.addToolFilter(tenantFilter)
506+
.toolFilters(filters -> filters.add(0, cheapDenyAllForAnonymousFilter))
507+
.build();
508+
```
509+
510+
- Tools are tested one at a time, so a filter that performs I/O per tool costs one round trip per
511+
tool. Sync filters also run on a shared scheduler thread — not the request thread — unless
512+
`immediateExecution(true)` is set, so thread-bound request state (Spring Security's
513+
`SecurityContextHolder`, MDC, custom `ThreadLocal` holders) is **not visible** inside the filter.
514+
For both reasons, resolve per-request state **once** in the transport's `contextExtractor`,
515+
which does run on the request thread, and read only the extracted context in the filter:
516+
517+
```java
518+
// transport builder: one authorization lookup, on the request thread,
519+
// shared by every tool tested in this request
520+
var transportProvider = HttpServletStreamableServerTransportProvider.builder()
521+
.contextExtractor(request -> McpTransportContext.create(
522+
Map.of("perms", introspect(request.getHeader("Authorization")))))
523+
// ...
524+
.build();
525+
526+
// server builder: the filter reads only the extracted context
527+
McpServer.sync(transportProvider)
528+
.addToolFilter((context, tool) ->
529+
((Set<String>) context.get("perms")).contains(tool.name()))
530+
.build();
531+
```
532+
533+
- `notifications/tools/list_changed` is **not** filtered. It is a server-initiated broadcast with
534+
no request in flight, so there is no context to evaluate. A client may be told something changed
535+
when its own visible set did not; it gets the correct view on its next `tools/list`. Consider disabling
536+
this notification entirely when using tool filters.
537+
- With STDIO there is no per-request metadata, so the filter receives `McpTransportContext.EMPTY` and has nothing to key
538+
on.
539+
444540
### Resource Specification
445541

446542
Specification of a resource with its handler function.

‎mcp-core/src/main/java/io/modelcontextprotocol/client/McpClient.java‎

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -494,9 +494,10 @@ public SyncSpec elicitationCompleteConsumers(
494494
* calling any client operation. This allows to extract thread-locals and hand
495495
* them over to the underlying transport.
496496
* <p>
497-
* There is no direct equivalent in {@link AsyncSpec}. To achieve the same result,
498-
* append {@code contextWrite(McpTransportContext.KEY, context)} to any
499-
* {@link McpAsyncClient} call.
497+
* The supplier is invoked at subscription time, on the calling thread, and the
498+
* resulting context is visible to the transport for every leg of the operation,
499+
* including connections opened on other threads, such as the SSE stream started
500+
* during initialization.
500501
* @param contextProvider A supplier to create a context
501502
* @return This builder for method chaining
502503
*/
@@ -580,6 +581,21 @@ public McpSyncClient build() {
580581
* <li>Change notification handlers for tools, resources, and prompts
581582
* <li>Custom message sampling logic
582583
* </ul>
584+
*
585+
* <p>
586+
* Unlike {@link SyncSpec}, this specification has no
587+
* {@code transportContextProvider}. This is deliberate: in a reactive pipeline the
588+
* caller owns the Reactor context, and whatever the transport needs, such as an
589+
* {@link McpTransportContext}, can be written into it directly. Write it once where
590+
* the reactive chain starts, rather than at every call site, and every
591+
* {@link McpAsyncClient} call downstream inherits it, including the connections
592+
* opened during initialization: <pre>{@code
593+
* chain.filter(exchange)
594+
* .contextWrite(ctx -> ctx.put(McpTransportContext.KEY, context));
595+
* }</pre> To bridge thread-locals into the chain, use Reactor's context propagation
596+
* support.
597+
*
598+
* @see SyncSpec#transportContextProvider(Supplier)
583599
*/
584600
class AsyncSpec {
585601

‎mcp-core/src/main/java/io/modelcontextprotocol/client/transport/HttpClientStreamableHttpTransport.java‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -763,8 +763,8 @@ public static class Builder {
763763

764764
private Duration connectTimeout = Duration.ofSeconds(10);
765765

766-
private List<String> supportedProtocolVersions = List.of(ProtocolVersions.MCP_2024_11_05,
767-
ProtocolVersions.MCP_2025_03_26, ProtocolVersions.MCP_2025_06_18, ProtocolVersions.MCP_2025_11_25);
766+
private List<String> supportedProtocolVersions = List.of(ProtocolVersions.MCP_2025_03_26,
767+
ProtocolVersions.MCP_2025_06_18, ProtocolVersions.MCP_2025_11_25);
768768

769769
private McpHttpClientTransportAuthorizationErrorHandler authorizationErrorHandler = McpHttpClientTransportAuthorizationErrorHandler.NOOP;
770770

‎mcp-core/src/main/java/io/modelcontextprotocol/client/transport/StdioClientTransport.java‎

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -258,16 +258,13 @@ private void handleIncomingErrors() {
258258

259259
@Override
260260
public Mono<Void> sendMessage(JSONRPCMessage message) {
261-
if (this.outboundSink.tryEmitNext(message).isSuccess()) {
262-
// TODO: essentially we could reschedule ourselves in some time and make
263-
// another attempt with the already read data but pause reading until
264-
// success
265-
// In this approach we delegate the retry and the backpressure onto the
266-
// caller. This might be enough for most cases.
261+
try {
262+
// busyLooping retries FAIL_NON_SERIALIZED under concurrent senders
263+
this.outboundSink.emitNext(message, Sinks.EmitFailureHandler.busyLooping(Duration.ofMillis(100)));
267264
return Mono.empty();
268265
}
269-
else {
270-
return Mono.error(new RuntimeException("Failed to enqueue message"));
266+
catch (Sinks.EmissionException e) {
267+
return Mono.error(new RuntimeException("Failed to enqueue message", e));
271268
}
272269
}
273270

‎mcp-core/src/main/java/io/modelcontextprotocol/client/transport/customizer/McpAsyncHttpClientRequestCustomizer.java‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,26 @@
77
import java.net.URI;
88
import java.net.http.HttpRequest;
99

10+
import io.modelcontextprotocol.client.McpClient.SyncSpec;
11+
import io.modelcontextprotocol.common.McpTransportContext;
1012
import org.reactivestreams.Publisher;
1113
import reactor.core.publisher.Mono;
1214
import reactor.core.scheduler.Schedulers;
1315
import reactor.util.annotation.Nullable;
1416

15-
import io.modelcontextprotocol.common.McpTransportContext;
16-
1717
/**
1818
* Customize {@link HttpRequest.Builder} before executing the request, in either SSE or
1919
* Streamable HTTP transport.
2020
* <p>
2121
* When used in a non-blocking context, implementations MUST be non-blocking.
22+
* <p>
23+
* The {@link McpTransportContext} handed to {@code customize} is read from the Reactor
24+
* context, under {@link McpTransportContext#KEY}, and is
25+
* {@link McpTransportContext#EMPTY} when the caller wrote nothing there. Write it once
26+
* where the reactive chain starts, with
27+
* {@code contextWrite(ctx -> ctx.put(McpTransportContext.KEY, context))}, rather than at
28+
* every call site. With a synchronous client, configure
29+
* {@link SyncSpec#transportContextProvider} instead.
2230
*
2331
* @author Daniel Garnier-Moiroux
2432
*/

‎mcp-core/src/main/java/io/modelcontextprotocol/common/McpTransportContext.java‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,28 @@
1111
* Context associated with the transport layer. It allows to add transport-level metadata
1212
* for use further down the line. Specifically, it can be beneficial to extract HTTP
1313
* request metadata for use in MCP feature implementations.
14+
* <p>
15+
* The context travels in the Reactor context, under {@link #KEY}. On the server side, the
16+
* transports populate it from the incoming request. On the client side, writing it is the
17+
* caller's responsibility:
18+
* <ul>
19+
* <li>with a synchronous client, configure
20+
* {@code McpClient.SyncSpec#transportContextProvider(Supplier)}, which is invoked on the
21+
* calling thread before every operation;
22+
* <li>with an asynchronous client, write it into the Reactor context once, where the
23+
* reactive chain starts, using
24+
* {@code contextWrite(ctx -> ctx.put(McpTransportContext.KEY, context))}. Every client
25+
* call downstream inherits it, so there is no need to repeat it at each call site.
26+
* </ul>
1427
*
1528
* @author Dariusz Jędrzejczyk
1629
*/
1730
public interface McpTransportContext {
1831

1932
/**
20-
* Key for use in Reactor Context to transport the context to user land.
33+
* Key for use in Reactor Context to transport the context to user land. Write the
34+
* context under this key to make it visible to the transport, for example
35+
* {@code contextWrite(ctx -> ctx.put(McpTransportContext.KEY, context))}.
2136
*/
2237
String KEY = "MCP_TRANSPORT_CONTEXT";
2338

‎mcp-core/src/main/java/io/modelcontextprotocol/json/schema/JsonSchemaValidator.java‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,11 @@ public static ValidationResponse asInvalid(String message) {
5757

5858
/**
5959
* Validates the structured content against the provided JSON schema.
60+
* <p>
61+
* If {@code structuredContent} is a {@link String}, it is treated as a serialized
62+
* JSON document and parsed before validation; quote embedded strings accordingly (for
63+
* example {@code "\"red\""} for the JSON string value {@code red}). Any other type is
64+
* converted to its JSON representation directly.
6065
* @param schema The JSON schema to validate against.
6166
* @param structuredContent The structured content to validate.
6267
* @return A ValidationResponse indicating whether the validation was successful or
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/*
2+
* Copyright 2026-2026 the original author or authors.
3+
*/
4+
5+
package io.modelcontextprotocol.server;
6+
7+
import java.util.List;
8+
9+
import io.modelcontextprotocol.common.McpTransportContext;
10+
import io.modelcontextprotocol.spec.McpSchema.Tool;
11+
import io.modelcontextprotocol.util.Assert;
12+
import reactor.core.publisher.Flux;
13+
import reactor.core.publisher.Mono;
14+
import reactor.core.scheduler.Schedulers;
15+
16+
/**
17+
* Decide per request whether a primitive is advertised in the corresponding listing, such
18+
* as {@code tools/list}.
19+
* <p>
20+
* A primitive hidden by this filter is omitted from listings ONLY. It remains reachable
21+
* through its own endpoint: a hidden tool called by name still executes. Permissions MUST
22+
* be enforced in the primitive's handler.
23+
*
24+
* @author Daniel Garnier-Moiroux
25+
* @see McpSyncListFilter
26+
* @see McpTransportContextExtractor
27+
*/
28+
@FunctionalInterface
29+
public interface McpAsyncListFilter<T> {
30+
31+
/**
32+
* Whether the given primitive is visible to the caller of the current request.
33+
* @param transportContext transport context containing, for example, HTTP headers or
34+
* a resolved principal. Should never be {@code null}, but may
35+
* {@link McpTransportContext#EMPTY} for transports that carry no per-request
36+
* metadata, such as STDIO.
37+
* @param primitive the primitive that is a candidate for inclusion in the listing,
38+
* such as {@link Tool}.
39+
* @return a publisher emitting {@code true} to include the primitive in the listing,
40+
* {@code false} to omit it. Completing empty omits the primitive; erroring fails the
41+
* listing request.
42+
*/
43+
Mono<Boolean> isVisible(McpTransportContext transportContext, T primitive);
44+
45+
/**
46+
* Convert a potentially blocking, synchronous filter into an asynchronous one,
47+
* offloading it to prevent accidental blocking of a non-blocking transport.
48+
* @param filter the synchronous filter. MUST NOT be null.
49+
* @param immediateExecution When true, do not offload work asynchronously. Do NOT set
50+
* to true when the filter performs blocking I/O.
51+
*/
52+
static <T> McpAsyncListFilter<T> fromSync(McpSyncListFilter<T> filter, boolean immediateExecution) {
53+
Assert.notNull(filter, "filter must not be null");
54+
return (transportContext, primitive) -> {
55+
var visible = Mono.fromCallable(() -> filter.isVisible(transportContext, primitive));
56+
return immediateExecution ? visible : visible.subscribeOn(Schedulers.boundedElastic());
57+
};
58+
}
59+
60+
/**
61+
* Combine multiple filters in a single AND-filter. An empty or {@code null} list
62+
* makes everything visible, keeping listing on a single code path when nothing is
63+
* configured.
64+
* @param filters the filters to combine. May be {@code null} or empty, but MUST NOT
65+
* contain {@code null} elements.
66+
*/
67+
static <T> McpAsyncListFilter<T> and(List<McpAsyncListFilter<T>> filters) {
68+
if (filters == null || filters.isEmpty()) {
69+
return (transportContext, primitive) -> Mono.just(Boolean.TRUE);
70+
}
71+
Assert.noNullElements(filters, "filters must not contain null elements");
72+
if (filters.size() == 1) {
73+
return filters.get(0);
74+
}
75+
List<McpAsyncListFilter<T>> snapshot = List.copyOf(filters);
76+
return (transportContext, primitive) -> Flux.fromIterable(snapshot)
77+
.concatMap(filter -> filter.isVisible(transportContext, primitive).defaultIfEmpty(Boolean.FALSE))
78+
.all(Boolean.TRUE::equals);
79+
}
80+
81+
}

0 commit comments

Comments
 (0)