Server to client
MCP is bidirectional: the server can talk to the client too. Everything it sends goes through the SSE channel the client opens with GET /mcp, and comes in two flavours:
-
notifications, one way: logging,
list_changed, progress, -
requests, awaiting an answer: sampling, elicitation, roots. The client posts its response back on
POST /mcp.
|
IMPORTANT
|
messages are buffered per session, so nothing is lost when no stream is currently open - a client reconnecting gets what it missed. But a client which never opens a stream never receives anything, and a request sent to it times out ( |
Notifications: MCPNotifier
Inject MCPNotifier to broadcast to all the connected clients:
@ApplicationScoped
public class MyService {
private final MCPNotifier notifier;
public MyService(final MCPNotifier notifier) {
this.notifier = notifier;
}
public void onSomething() {
notifier.log(LoggingLevel.info, "my-service", "something happened");
notifier.resourceUpdated("app://config");
notifier.resourceListChanged();
notifier.toolListChanged();
notifier.promptListChanged();
notifier.broadcast("notifications/custom", Map.of("any", "payload"));
}
}
| Method | MCP notification |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
anything else, the params being any |
Log levels are the syslog ones. LoggingLevel#isEnabled(minimum) implements the comparison - use it rather than ordinal(), the enum is declared in the JSON (alphabetical) order:
if (LoggingLevel.debug.isEnabled(session.loggingLevel())) { // false unless the client asked for debug
session.log(new MessageNotification("my-service", LoggingLevel.debug, expensiveDump()));
}
Progress of a long tool
When the client sends a progressToken in the _meta of its request, it expects progress notifications carrying it back:
notifier.progress(new ProgressNotification(progressToken, 0.5, 1., "half way"));
Requests: MCPSession
A request targets one client, the one of the current call, so it starts from the session:
@ApplicationScoped
public class MyTools {
private final MCPSessions sessions;
public MyTools(final MCPSessions sessions) {
this.sessions = sessions;
}
@MCPTool
@JsonRpc(value = "my/ask", documentation = "Asks the client model to summarize a document.")
public CompletionStage<Summary> ask(@JsonRpcParam(required = true) final String text, final Request request) {
return sessions.of(request)
.createMessage(new CreateSamplingMessageParameters(
null, 512,
List.of(new SamplingMessage(Role.user, Content.text("Summarize:\n" + text))),
null, null, null, "You are a summarizer.", null))
.thenApply(response -> new Summary(response.content().text()));
}
}
| MCP request |
MCPSession method |
Required client capability |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
anything |
|
- |
Capabilities are declared by the client during initialize. Calling a method the client does not support fails fast with a -32601 naming the missing capability instead of hanging until the timeout, so a tool can degrade gracefully:
final var session = sessions.of(request);
if (session.capabilities() == null || session.capabilities().sampling() == null) {
return completedFuture(new Summary(localSummary(text))); // no client model available, do it ourselves
}
Sampling
Sampling asks the client to run a LLM completion: the server gets model access without holding an API key, and the user stays in control - clients typically ask for a confirmation. ModelPreferences expresses what matters (costPriority, speedPriority, intelligencePriority, plus hints), it is advisory.
Elicitation
Elicitation asks the client to get a structured input from its user - a missing parameter, a confirmation. The requested schema must be a flat object of primitives, which JsonSchema builds:
session.elicit(new ElicitRequestParameters(
"Which environment should I deploy to?",
JsonSchema.object("deployment", Map.of(
"environment", JsonSchema.enumeration("Target environment.", List.of("dev", "staging", "prod")),
"confirm", JsonSchema.bool("Really deploy?")), List.of("environment", "confirm"))))
.thenApply(response -> {
if (response.action() != ElicitResponse.Action.accept) {
return ToolResponse.error("deployment cancelled by the user");
}
return deploy(String.valueOf(response.content().get("environment")));
});
Always check action(): the user can accept, decline or cancel, and content is only set on accept.
Roots
listRoots() returns the filesystem locations the client gives access to - typically the open workspaces - which is how a tool stays inside what the user allowed:
return session.listRoots().thenApply(roots -> roots.roots().stream()
.map(ListRootsResponse.Root::uri)
.filter(uri -> uri.startsWith("file://"))
.toList());
The client sends notifications/roots/list_changed when they change; there is nothing to invalidate since listRoots() always asks.