fix: improve streamable HTTP session reinitialization (#459)
Implements the MCP spec guidelines for streamable HTTP (re)initialization: - Server MAY terminate session and MUST respond with HTTP 404 for terminated session IDs - Client MUST start new session when receiving HTTP 404 for requests with session ID Changes: - Replace generic McpError with McpTransportException for transport-layer errors - Only throw McpTransportSessionNotFoundException when session ID is present in request (per spec: 404 with session ID means session terminated, without means general error) - Enhance error messages with more context (status codes, response events) - Use RuntimeException for non-transport specific SSE endpoint failures - Ensure consistent error handling across HTTP client transports - Improve error handling with standard Java exceptions. Replace generic McpError with appropriate standard exceptions: - Use IllegalArgumentException for invalid input parameters - Use IllegalStateException for state-related issues - Use RuntimeException wrapper for initialization failures - Use McpError.builder() with proper error codes for protocol errors Fixes #459 Signed-off-by: Christian Tzolov <christian.tzolov@broadcom.com>
This commit is contained in:
parent
eb427add79
commit
6c38f37637
15 changed files with 955 additions and 90 deletions
|
|
@ -289,9 +289,7 @@ class LifecycleInitializer {
|
|||
return initializationJob.map(initializeResult -> this.initializationRef.get())
|
||||
.timeout(this.initializationTimeout)
|
||||
.onErrorResume(ex -> {
|
||||
logger.warn("Failed to initialize", ex);
|
||||
return Mono.error(
|
||||
new McpError("Client failed to initialize " + actionName + " due to: " + ex.getMessage()));
|
||||
return Mono.error(new RuntimeException("Client failed to initialize " + actionName, ex));
|
||||
})
|
||||
.flatMap(operation);
|
||||
});
|
||||
|
|
@ -316,8 +314,10 @@ class LifecycleInitializer {
|
|||
initializeResult.instructions());
|
||||
|
||||
if (!this.protocolVersions.contains(initializeResult.protocolVersion())) {
|
||||
return Mono.error(new McpError(
|
||||
"Unsupported protocol version from the server: " + initializeResult.protocolVersion()));
|
||||
return Mono.error(McpError.builder(-32602)
|
||||
.message("Unsupported protocol version")
|
||||
.data("Unsupported protocol version from the server: " + initializeResult.protocolVersion())
|
||||
.build());
|
||||
}
|
||||
|
||||
return mcpClientSession.sendNotification(McpSchema.METHOD_NOTIFICATION_INITIALIZED, null)
|
||||
|
|
|
|||
|
|
@ -190,7 +190,8 @@ public class McpAsyncClient {
|
|||
// Sampling Handler
|
||||
if (this.clientCapabilities.sampling() != null) {
|
||||
if (features.samplingHandler() == null) {
|
||||
throw new McpError("Sampling handler must not be null when client capabilities include sampling");
|
||||
throw new IllegalArgumentException(
|
||||
"Sampling handler must not be null when client capabilities include sampling");
|
||||
}
|
||||
this.samplingHandler = features.samplingHandler();
|
||||
requestHandlers.put(McpSchema.METHOD_SAMPLING_CREATE_MESSAGE, samplingCreateMessageHandler());
|
||||
|
|
@ -199,7 +200,8 @@ public class McpAsyncClient {
|
|||
// Elicitation Handler
|
||||
if (this.clientCapabilities.elicitation() != null) {
|
||||
if (features.elicitationHandler() == null) {
|
||||
throw new McpError("Elicitation handler must not be null when client capabilities include elicitation");
|
||||
throw new IllegalArgumentException(
|
||||
"Elicitation handler must not be null when client capabilities include elicitation");
|
||||
}
|
||||
this.elicitationHandler = features.elicitationHandler();
|
||||
requestHandlers.put(McpSchema.METHOD_ELICITATION_CREATE, elicitationCreateHandler());
|
||||
|
|
@ -413,15 +415,15 @@ public class McpAsyncClient {
|
|||
public Mono<Void> addRoot(Root root) {
|
||||
|
||||
if (root == null) {
|
||||
return Mono.error(new McpError("Root must not be null"));
|
||||
return Mono.error(new IllegalArgumentException("Root must not be null"));
|
||||
}
|
||||
|
||||
if (this.clientCapabilities.roots() == null) {
|
||||
return Mono.error(new McpError("Client must be configured with roots capabilities"));
|
||||
return Mono.error(new IllegalStateException("Client must be configured with roots capabilities"));
|
||||
}
|
||||
|
||||
if (this.roots.containsKey(root.uri())) {
|
||||
return Mono.error(new McpError("Root with uri '" + root.uri() + "' already exists"));
|
||||
return Mono.error(new IllegalStateException("Root with uri '" + root.uri() + "' already exists"));
|
||||
}
|
||||
|
||||
this.roots.put(root.uri(), root);
|
||||
|
|
@ -447,11 +449,11 @@ public class McpAsyncClient {
|
|||
public Mono<Void> removeRoot(String rootUri) {
|
||||
|
||||
if (rootUri == null) {
|
||||
return Mono.error(new McpError("Root uri must not be null"));
|
||||
return Mono.error(new IllegalArgumentException("Root uri must not be null"));
|
||||
}
|
||||
|
||||
if (this.clientCapabilities.roots() == null) {
|
||||
return Mono.error(new McpError("Client must be configured with roots capabilities"));
|
||||
return Mono.error(new IllegalStateException("Client must be configured with roots capabilities"));
|
||||
}
|
||||
|
||||
Root removed = this.roots.remove(rootUri);
|
||||
|
|
@ -469,7 +471,7 @@ public class McpAsyncClient {
|
|||
}
|
||||
return Mono.empty();
|
||||
}
|
||||
return Mono.error(new McpError("Root with uri '" + rootUri + "' not found"));
|
||||
return Mono.error(new IllegalStateException("Root with uri '" + rootUri + "' not found"));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -540,7 +542,7 @@ public class McpAsyncClient {
|
|||
public Mono<McpSchema.CallToolResult> callTool(McpSchema.CallToolRequest callToolRequest) {
|
||||
return this.initializer.withIntitialization("calling tools", init -> {
|
||||
if (init.initializeResult().capabilities().tools() == null) {
|
||||
return Mono.error(new McpError("Server does not provide tools capability"));
|
||||
return Mono.error(new IllegalStateException("Server does not provide tools capability"));
|
||||
}
|
||||
return init.mcpSession()
|
||||
.sendRequest(McpSchema.METHOD_TOOLS_CALL, callToolRequest, CALL_TOOL_RESULT_TYPE_REF);
|
||||
|
|
@ -569,7 +571,7 @@ public class McpAsyncClient {
|
|||
public Mono<McpSchema.ListToolsResult> listTools(String cursor) {
|
||||
return this.initializer.withIntitialization("listing tools", init -> {
|
||||
if (init.initializeResult().capabilities().tools() == null) {
|
||||
return Mono.error(new McpError("Server does not provide tools capability"));
|
||||
return Mono.error(new IllegalStateException("Server does not provide tools capability"));
|
||||
}
|
||||
return init.mcpSession()
|
||||
.sendRequest(McpSchema.METHOD_TOOLS_LIST, new McpSchema.PaginatedRequest(cursor),
|
||||
|
|
@ -633,7 +635,7 @@ public class McpAsyncClient {
|
|||
public Mono<McpSchema.ListResourcesResult> listResources(String cursor) {
|
||||
return this.initializer.withIntitialization("listing resources", init -> {
|
||||
if (init.initializeResult().capabilities().resources() == null) {
|
||||
return Mono.error(new McpError("Server does not provide the resources capability"));
|
||||
return Mono.error(new IllegalStateException("Server does not provide the resources capability"));
|
||||
}
|
||||
return init.mcpSession()
|
||||
.sendRequest(McpSchema.METHOD_RESOURCES_LIST, new McpSchema.PaginatedRequest(cursor),
|
||||
|
|
@ -665,7 +667,7 @@ public class McpAsyncClient {
|
|||
public Mono<McpSchema.ReadResourceResult> readResource(McpSchema.ReadResourceRequest readResourceRequest) {
|
||||
return this.initializer.withIntitialization("reading resources", init -> {
|
||||
if (init.initializeResult().capabilities().resources() == null) {
|
||||
return Mono.error(new McpError("Server does not provide the resources capability"));
|
||||
return Mono.error(new IllegalStateException("Server does not provide the resources capability"));
|
||||
}
|
||||
return init.mcpSession()
|
||||
.sendRequest(McpSchema.METHOD_RESOURCES_READ, readResourceRequest, READ_RESOURCE_RESULT_TYPE_REF);
|
||||
|
|
@ -703,7 +705,7 @@ public class McpAsyncClient {
|
|||
public Mono<McpSchema.ListResourceTemplatesResult> listResourceTemplates(String cursor) {
|
||||
return this.initializer.withIntitialization("listing resource templates", init -> {
|
||||
if (init.initializeResult().capabilities().resources() == null) {
|
||||
return Mono.error(new McpError("Server does not provide the resources capability"));
|
||||
return Mono.error(new IllegalStateException("Server does not provide the resources capability"));
|
||||
}
|
||||
return init.mcpSession()
|
||||
.sendRequest(McpSchema.METHOD_RESOURCES_TEMPLATES_LIST, new McpSchema.PaginatedRequest(cursor),
|
||||
|
|
@ -863,7 +865,7 @@ public class McpAsyncClient {
|
|||
*/
|
||||
public Mono<Void> setLoggingLevel(LoggingLevel loggingLevel) {
|
||||
if (loggingLevel == null) {
|
||||
return Mono.error(new McpError("Logging level must not be null"));
|
||||
return Mono.error(new IllegalArgumentException("Logging level must not be null"));
|
||||
}
|
||||
|
||||
return this.initializer.withIntitialization("setting logging level", init -> {
|
||||
|
|
|
|||
|
|
@ -24,10 +24,10 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
|||
|
||||
import io.modelcontextprotocol.client.transport.ResponseSubscribers.ResponseEvent;
|
||||
import io.modelcontextprotocol.spec.McpClientTransport;
|
||||
import io.modelcontextprotocol.spec.McpError;
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
import io.modelcontextprotocol.spec.ProtocolVersions;
|
||||
import io.modelcontextprotocol.spec.McpSchema.JSONRPCMessage;
|
||||
import io.modelcontextprotocol.spec.McpTransportException;
|
||||
import io.modelcontextprotocol.util.Assert;
|
||||
import io.modelcontextprotocol.util.Utils;
|
||||
import reactor.core.Disposable;
|
||||
|
|
@ -431,7 +431,7 @@ public class HttpClientSseClientTransport implements McpClientTransport {
|
|||
return Flux.empty(); // No further processing needed
|
||||
}
|
||||
else {
|
||||
sink.error(new McpError("Failed to handle SSE endpoint event"));
|
||||
sink.error(new RuntimeException("Failed to handle SSE endpoint event"));
|
||||
}
|
||||
}
|
||||
else if (MESSAGE_EVENT_TYPE.equals(responseEvent.sseEvent().event())) {
|
||||
|
|
@ -446,8 +446,7 @@ public class HttpClientSseClientTransport implements McpClientTransport {
|
|||
}
|
||||
}
|
||||
catch (IOException e) {
|
||||
logger.error("Error processing SSE event", e);
|
||||
sink.error(new McpError("Error processing SSE event"));
|
||||
sink.error(new McpTransportException("Error processing SSE event", e));
|
||||
}
|
||||
}
|
||||
return Flux.<McpSchema.JSONRPCMessage>error(
|
||||
|
|
@ -520,8 +519,7 @@ public class HttpClientSseClientTransport implements McpClientTransport {
|
|||
return Mono.just(objectMapper.writeValueAsString(message));
|
||||
}
|
||||
catch (IOException e) {
|
||||
// TODO: why McpError and not RuntimeException?
|
||||
return Mono.error(new McpError("Failed to serialize message"));
|
||||
return Mono.error(new McpTransportException("Failed to serialize message", e));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,8 +30,8 @@ import io.modelcontextprotocol.spec.DefaultMcpTransportSession;
|
|||
import io.modelcontextprotocol.spec.DefaultMcpTransportStream;
|
||||
import io.modelcontextprotocol.spec.HttpHeaders;
|
||||
import io.modelcontextprotocol.spec.McpClientTransport;
|
||||
import io.modelcontextprotocol.spec.McpError;
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
import io.modelcontextprotocol.spec.McpTransportException;
|
||||
import io.modelcontextprotocol.spec.McpTransportSession;
|
||||
import io.modelcontextprotocol.spec.McpTransportSessionNotFoundException;
|
||||
import io.modelcontextprotocol.spec.McpTransportStream;
|
||||
|
|
@ -288,9 +288,8 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
|
|||
|
||||
}
|
||||
catch (IOException ioException) {
|
||||
return Flux.<McpSchema.JSONRPCMessage>error(
|
||||
new McpError("Error parsing JSON-RPC message: "
|
||||
+ responseEvent.sseEvent().data()));
|
||||
return Flux.<McpSchema.JSONRPCMessage>error(new McpTransportException(
|
||||
"Error parsing JSON-RPC message: " + responseEvent, ioException));
|
||||
}
|
||||
}
|
||||
else {
|
||||
|
|
@ -304,19 +303,39 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
|
|||
return Flux.empty();
|
||||
}
|
||||
else if (statusCode == NOT_FOUND) {
|
||||
String sessionIdRepresentation = sessionIdOrPlaceholder(transportSession);
|
||||
McpTransportSessionNotFoundException exception = new McpTransportSessionNotFoundException(
|
||||
"Session not found for session ID: " + sessionIdRepresentation);
|
||||
return Flux.<McpSchema.JSONRPCMessage>error(exception);
|
||||
|
||||
if (transportSession != null && transportSession.sessionId().isPresent()) {
|
||||
// only if the request was sent with a session id
|
||||
// and the response is 404, we consider it a
|
||||
// session not found error.
|
||||
logger.debug("Session not found for session ID: {}",
|
||||
transportSession.sessionId().get());
|
||||
String sessionIdRepresentation = sessionIdOrPlaceholder(transportSession);
|
||||
McpTransportSessionNotFoundException exception = new McpTransportSessionNotFoundException(
|
||||
"Session not found for session ID: " + sessionIdRepresentation);
|
||||
return Flux.<McpSchema.JSONRPCMessage>error(exception);
|
||||
}
|
||||
return Flux.<McpSchema.JSONRPCMessage>error(
|
||||
new McpTransportException("Server Not Found. Status code:" + statusCode
|
||||
+ ", response-event:" + responseEvent));
|
||||
}
|
||||
else if (statusCode == BAD_REQUEST) {
|
||||
String sessionIdRepresentation = sessionIdOrPlaceholder(transportSession);
|
||||
McpTransportSessionNotFoundException exception = new McpTransportSessionNotFoundException(
|
||||
"Session not found for session ID: " + sessionIdRepresentation);
|
||||
return Flux.<McpSchema.JSONRPCMessage>error(exception);
|
||||
if (transportSession != null && transportSession.sessionId().isPresent()) {
|
||||
// only if the request was sent with a session id
|
||||
// and thre response is 404, we consider it a
|
||||
// session not found error.
|
||||
String sessionIdRepresentation = sessionIdOrPlaceholder(transportSession);
|
||||
McpTransportSessionNotFoundException exception = new McpTransportSessionNotFoundException(
|
||||
"Session not found for session ID: " + sessionIdRepresentation);
|
||||
return Flux.<McpSchema.JSONRPCMessage>error(exception);
|
||||
}
|
||||
return Flux.<McpSchema.JSONRPCMessage>error(
|
||||
new McpTransportException("Bad Request. Status code:" + statusCode
|
||||
+ ", response-event:" + responseEvent));
|
||||
|
||||
}
|
||||
|
||||
return Flux.<McpSchema.JSONRPCMessage>error(new McpError(
|
||||
return Flux.<McpSchema.JSONRPCMessage>error(new McpTransportException(
|
||||
"Received unrecognized SSE event type: " + responseEvent.sseEvent().event()));
|
||||
}).<McpSchema
|
||||
.JSONRPCMessage>flatMap(
|
||||
|
|
@ -468,8 +487,8 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
|
|||
return Flux.from(sessionStream.consumeSseStream(Flux.just(idWithMessages)));
|
||||
}
|
||||
catch (IOException ioException) {
|
||||
return Flux.<McpSchema.JSONRPCMessage>error(
|
||||
new McpError("Error parsing JSON-RPC message: " + sseEvent.data()));
|
||||
return Flux.<McpSchema.JSONRPCMessage>error(new McpTransportException(
|
||||
"Error parsing JSON-RPC message: " + responseEvent, ioException));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -485,8 +504,8 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
|
|||
return Mono.just(McpSchema.deserializeJsonRpcMessage(objectMapper, data));
|
||||
}
|
||||
catch (IOException e) {
|
||||
// TODO: this should be a McpTransportError
|
||||
return Mono.error(e);
|
||||
return Mono.error(new McpTransportException(
|
||||
"Error deserializing JSON-RPC message: " + responseEvent, e));
|
||||
}
|
||||
}
|
||||
logger.warn("Unknown media type {} returned for POST in session {}", contentType,
|
||||
|
|
@ -496,18 +515,32 @@ public class HttpClientStreamableHttpTransport implements McpClientTransport {
|
|||
new RuntimeException("Unknown media type returned: " + contentType));
|
||||
}
|
||||
else if (statusCode == NOT_FOUND) {
|
||||
McpTransportSessionNotFoundException exception = new McpTransportSessionNotFoundException(
|
||||
"Session not found for session ID: " + sessionRepresentation);
|
||||
return Flux.<McpSchema.JSONRPCMessage>error(exception);
|
||||
if (transportSession != null && transportSession.sessionId().isPresent()) {
|
||||
// only if the request was sent with a session id and the
|
||||
// response is 404, we consider it a session not found error.
|
||||
logger.debug("Session not found for session ID: {}", transportSession.sessionId().get());
|
||||
McpTransportSessionNotFoundException exception = new McpTransportSessionNotFoundException(
|
||||
"Session not found for session ID: " + sessionRepresentation);
|
||||
return Flux.<McpSchema.JSONRPCMessage>error(exception);
|
||||
}
|
||||
return Flux.<McpSchema.JSONRPCMessage>error(new McpTransportException(
|
||||
"Server Not Found. Status code:" + statusCode + ", response-event:" + responseEvent));
|
||||
}
|
||||
// Some implementations can return 400 when presented with a
|
||||
// session id that it doesn't know about, so we will
|
||||
// invalidate the session
|
||||
// https://github.com/modelcontextprotocol/typescript-sdk/issues/389
|
||||
else if (statusCode == BAD_REQUEST) {
|
||||
McpTransportSessionNotFoundException exception = new McpTransportSessionNotFoundException(
|
||||
"Session not found for session ID: " + sessionRepresentation);
|
||||
return Flux.<McpSchema.JSONRPCMessage>error(exception);
|
||||
// Some implementations can return 400 when presented with a
|
||||
// session id that it doesn't know about, so we will
|
||||
// invalidate the session
|
||||
// https://github.com/modelcontextprotocol/typescript-sdk/issues/389
|
||||
|
||||
if (transportSession != null && transportSession.sessionId().isPresent()) {
|
||||
// only if the request was sent with a session id and the
|
||||
// response is 404, we consider it a session not found error.
|
||||
McpTransportSessionNotFoundException exception = new McpTransportSessionNotFoundException(
|
||||
"Session not found for session ID: " + sessionRepresentation);
|
||||
return Flux.<McpSchema.JSONRPCMessage>error(exception);
|
||||
}
|
||||
return Flux.<McpSchema.JSONRPCMessage>error(new McpTransportException(
|
||||
"Bad Request. Status code:" + statusCode + ", response-event:" + responseEvent));
|
||||
}
|
||||
|
||||
return Flux.<McpSchema.JSONRPCMessage>error(
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import org.reactivestreams.Subscription;
|
|||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import io.modelcontextprotocol.spec.McpError;
|
||||
import io.modelcontextprotocol.spec.McpTransportException;
|
||||
import reactor.core.publisher.BaseSubscriber;
|
||||
import reactor.core.publisher.FluxSink;
|
||||
|
||||
|
|
@ -178,8 +178,7 @@ class ResponseSubscribers {
|
|||
}
|
||||
else {
|
||||
// If the response is not successful, emit an error
|
||||
// TODO: This should be a McpTransportError
|
||||
this.sink.error(new McpError(
|
||||
this.sink.error(new McpTransportException(
|
||||
"Invalid SSE response. Status code: " + this.responseInfo.statusCode() + " Line: " + line));
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
/*
|
||||
* Copyright 2025 - 2025 the original author or authors.
|
||||
*/
|
||||
package io.modelcontextprotocol.spec;
|
||||
|
||||
/**
|
||||
* Exception thrown when there is an issue with the transport layer of the Model Context
|
||||
* Protocol (MCP).
|
||||
*
|
||||
* <p>
|
||||
* This exception is used to indicate errors that occur during communication between the
|
||||
* MCP client and server, such as connection failures, protocol violations, or unexpected
|
||||
* responses.
|
||||
*
|
||||
* @author Christian Tzolov
|
||||
*/
|
||||
public class McpTransportException extends RuntimeException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
public McpTransportException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public McpTransportException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
|
||||
public McpTransportException(Throwable cause) {
|
||||
super(cause);
|
||||
}
|
||||
|
||||
public McpTransportException(String message, Throwable cause, boolean enableSuppression,
|
||||
boolean writableStackTrace) {
|
||||
super(message, cause, enableSuppression, writableStackTrace);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -487,7 +487,8 @@ public abstract class AbstractMcpAsyncClientTests {
|
|||
void testAddRootWithNullValue() {
|
||||
withClient(createMcpTransport(), mcpAsyncClient -> {
|
||||
StepVerifier.create(mcpAsyncClient.addRoot(null))
|
||||
.consumeErrorWith(e -> assertThat(e).isInstanceOf(McpError.class).hasMessage("Root must not be null"))
|
||||
.consumeErrorWith(e -> assertThat(e).isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("Root must not be null"))
|
||||
.verify();
|
||||
});
|
||||
}
|
||||
|
|
@ -506,7 +507,7 @@ public abstract class AbstractMcpAsyncClientTests {
|
|||
void testRemoveNonExistentRoot() {
|
||||
withClient(createMcpTransport(), mcpAsyncClient -> {
|
||||
StepVerifier.create(mcpAsyncClient.removeRoot("nonexistent-uri"))
|
||||
.consumeErrorWith(e -> assertThat(e).isInstanceOf(McpError.class)
|
||||
.consumeErrorWith(e -> assertThat(e).isInstanceOf(IllegalStateException.class)
|
||||
.hasMessage("Root with uri 'nonexistent-uri' not found"))
|
||||
.verify();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ import org.mockito.Mock;
|
|||
import org.mockito.MockitoAnnotations;
|
||||
|
||||
import io.modelcontextprotocol.spec.McpClientSession;
|
||||
import io.modelcontextprotocol.spec.McpError;
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
import io.modelcontextprotocol.spec.McpTransportSessionNotFoundException;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
|
@ -154,7 +153,7 @@ class LifecycleInitializerTests {
|
|||
.thenReturn(Mono.just(unsupportedResult));
|
||||
|
||||
StepVerifier.create(initializer.withIntitialization("test", init -> Mono.just(init.initializeResult())))
|
||||
.expectError(McpError.class)
|
||||
.expectError(RuntimeException.class)
|
||||
.verify();
|
||||
|
||||
verify(mockClientSession, never()).sendNotification(eq(McpSchema.METHOD_NOTIFICATION_INITIALIZED), any());
|
||||
|
|
@ -178,7 +177,7 @@ class LifecycleInitializerTests {
|
|||
init -> Mono.just(init.initializeResult())), () -> virtualTimeScheduler, Long.MAX_VALUE)
|
||||
.expectSubscription()
|
||||
.expectNoEvent(INITIALIZE_TIMEOUT)
|
||||
.expectError(McpError.class)
|
||||
.expectError(RuntimeException.class)
|
||||
.verify();
|
||||
}
|
||||
|
||||
|
|
@ -234,7 +233,7 @@ class LifecycleInitializerTests {
|
|||
.thenReturn(Mono.error(new RuntimeException("Connection failed")));
|
||||
|
||||
StepVerifier.create(initializer.withIntitialization("test", init -> Mono.just(init.initializeResult())))
|
||||
.expectError(McpError.class)
|
||||
.expectError(RuntimeException.class)
|
||||
.verify();
|
||||
|
||||
assertThat(initializer.isInitialized()).isFalse();
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ import com.fasterxml.jackson.core.JsonProcessingException;
|
|||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.modelcontextprotocol.MockMcpClientTransport;
|
||||
import io.modelcontextprotocol.spec.McpError;
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
import io.modelcontextprotocol.spec.McpSchema.ClientCapabilities;
|
||||
import io.modelcontextprotocol.spec.McpSchema.InitializeResult;
|
||||
|
|
@ -373,7 +372,7 @@ class McpAsyncClientResponseHandlerTests {
|
|||
// Create client with sampling capability but null handler
|
||||
assertThatThrownBy(
|
||||
() -> McpClient.async(transport).capabilities(ClientCapabilities.builder().sampling().build()).build())
|
||||
.isInstanceOf(McpError.class)
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("Sampling handler must not be null when client capabilities include sampling");
|
||||
}
|
||||
|
||||
|
|
@ -521,7 +520,7 @@ class McpAsyncClientResponseHandlerTests {
|
|||
// Create client with elicitation capability but null handler
|
||||
assertThatThrownBy(() -> McpClient.async(transport)
|
||||
.capabilities(ClientCapabilities.builder().elicitation().build())
|
||||
.build()).isInstanceOf(McpError.class)
|
||||
.build()).isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessage("Elicitation handler must not be null when client capabilities include elicitation");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -113,7 +113,7 @@ class McpClientProtocolVersionTests {
|
|||
new McpSchema.InitializeResult(unsupportedVersion, null,
|
||||
new McpSchema.Implementation("test-server", "1.0.0"), null),
|
||||
null));
|
||||
}).expectError(McpError.class).verify();
|
||||
}).expectError(RuntimeException.class).verify();
|
||||
}
|
||||
finally {
|
||||
StepVerifier.create(client.closeGracefully()).verifyComplete();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,345 @@
|
|||
/*
|
||||
* Copyright 2025-2025 the original author or authors.
|
||||
*/
|
||||
|
||||
package io.modelcontextprotocol.client.transport;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.Timeout;
|
||||
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
|
||||
import io.modelcontextprotocol.server.transport.TomcatTestUtil;
|
||||
import io.modelcontextprotocol.spec.HttpHeaders;
|
||||
import io.modelcontextprotocol.spec.McpClientTransport;
|
||||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
import io.modelcontextprotocol.spec.McpTransportException;
|
||||
import io.modelcontextprotocol.spec.McpTransportSessionNotFoundException;
|
||||
import io.modelcontextprotocol.spec.ProtocolVersions;
|
||||
import reactor.test.StepVerifier;
|
||||
|
||||
/**
|
||||
* Tests for error handling changes in HttpClientStreamableHttpTransport. Specifically
|
||||
* tests the distinction between session-related errors and general transport errors for
|
||||
* 404 and 400 status codes.
|
||||
*
|
||||
* @author Christian Tzolov
|
||||
*/
|
||||
@Timeout(15)
|
||||
public class HttpClientStreamableHttpTransportErrorHandlingTest {
|
||||
|
||||
private static final int PORT = TomcatTestUtil.findAvailablePort();
|
||||
|
||||
private static final String HOST = "http://localhost:" + PORT;
|
||||
|
||||
private HttpServer server;
|
||||
|
||||
private AtomicReference<Integer> serverResponseStatus = new AtomicReference<>(200);
|
||||
|
||||
private AtomicReference<String> currentServerSessionId = new AtomicReference<>(null);
|
||||
|
||||
private AtomicReference<String> lastReceivedSessionId = new AtomicReference<>(null);
|
||||
|
||||
private McpClientTransport transport;
|
||||
|
||||
@BeforeEach
|
||||
void startServer() throws IOException {
|
||||
server = HttpServer.create(new InetSocketAddress(PORT), 0);
|
||||
|
||||
// Configure the /mcp endpoint with dynamic response
|
||||
server.createContext("/mcp", httpExchange -> {
|
||||
if ("DELETE".equals(httpExchange.getRequestMethod())) {
|
||||
httpExchange.sendResponseHeaders(200, 0);
|
||||
}
|
||||
else {
|
||||
// Capture session ID from request if present
|
||||
String requestSessionId = httpExchange.getRequestHeaders().getFirst(HttpHeaders.MCP_SESSION_ID);
|
||||
lastReceivedSessionId.set(requestSessionId);
|
||||
|
||||
int status = serverResponseStatus.get();
|
||||
|
||||
// Set response headers
|
||||
httpExchange.getResponseHeaders().set("Content-Type", "application/json");
|
||||
|
||||
// Add session ID to response if configured
|
||||
String responseSessionId = currentServerSessionId.get();
|
||||
if (responseSessionId != null) {
|
||||
httpExchange.getResponseHeaders().set(HttpHeaders.MCP_SESSION_ID, responseSessionId);
|
||||
}
|
||||
|
||||
// Send response based on configured status
|
||||
if (status == 200) {
|
||||
String response = "{\"jsonrpc\":\"2.0\",\"result\":{},\"id\":\"test-id\"}";
|
||||
httpExchange.sendResponseHeaders(200, response.length());
|
||||
httpExchange.getResponseBody().write(response.getBytes());
|
||||
}
|
||||
else {
|
||||
httpExchange.sendResponseHeaders(status, 0);
|
||||
}
|
||||
}
|
||||
httpExchange.close();
|
||||
});
|
||||
|
||||
server.setExecutor(null);
|
||||
server.start();
|
||||
|
||||
transport = HttpClientStreamableHttpTransport.builder(HOST).build();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void stopServer() {
|
||||
if (server != null) {
|
||||
server.stop(0);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that 404 response WITHOUT session ID throws McpTransportException (not
|
||||
* SessionNotFoundException)
|
||||
*/
|
||||
@Test
|
||||
void test404WithoutSessionId() {
|
||||
serverResponseStatus.set(404);
|
||||
currentServerSessionId.set(null); // No session ID in response
|
||||
|
||||
var testMessage = createTestRequestMessage();
|
||||
|
||||
StepVerifier.create(transport.sendMessage(testMessage))
|
||||
.expectErrorMatches(throwable -> throwable instanceof McpTransportException
|
||||
&& throwable.getMessage().contains("Not Found") && throwable.getMessage().contains("404")
|
||||
&& !(throwable instanceof McpTransportSessionNotFoundException))
|
||||
.verify();
|
||||
|
||||
StepVerifier.create(transport.closeGracefully()).verifyComplete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that 404 response WITH session ID throws McpTransportSessionNotFoundException
|
||||
*/
|
||||
@Test
|
||||
void test404WithSessionId() {
|
||||
// First establish a session
|
||||
serverResponseStatus.set(200);
|
||||
currentServerSessionId.set("test-session-123");
|
||||
|
||||
// Set up exception handler to verify session invalidation
|
||||
@SuppressWarnings("unchecked")
|
||||
Consumer<Throwable> exceptionHandler = mock(Consumer.class);
|
||||
transport.setExceptionHandler(exceptionHandler);
|
||||
|
||||
// Connect with handler
|
||||
StepVerifier.create(transport.connect(msg -> msg)).verifyComplete();
|
||||
|
||||
// Send initial message to establish session
|
||||
var testMessage = createTestRequestMessage();
|
||||
StepVerifier.create(transport.sendMessage(testMessage)).verifyComplete();
|
||||
|
||||
// The session should now be established, next request will include session ID
|
||||
// Now return 404 for next request
|
||||
serverResponseStatus.set(404);
|
||||
|
||||
// Send another message - should get SessionNotFoundException
|
||||
StepVerifier.create(transport.sendMessage(testMessage))
|
||||
.expectError(McpTransportSessionNotFoundException.class)
|
||||
.verify();
|
||||
|
||||
// Verify exception handler was called with SessionNotFoundException
|
||||
verify(exceptionHandler).accept(any(McpTransportSessionNotFoundException.class));
|
||||
|
||||
StepVerifier.create(transport.closeGracefully()).verifyComplete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that 400 response WITHOUT session ID throws McpTransportException (not
|
||||
* SessionNotFoundException)
|
||||
*/
|
||||
@Test
|
||||
void test400WithoutSessionId() {
|
||||
serverResponseStatus.set(400);
|
||||
currentServerSessionId.set(null); // No session ID
|
||||
|
||||
var testMessage = createTestRequestMessage();
|
||||
|
||||
StepVerifier.create(transport.sendMessage(testMessage))
|
||||
.expectErrorMatches(throwable -> throwable instanceof McpTransportException
|
||||
&& throwable.getMessage().contains("Bad Request") && throwable.getMessage().contains("400")
|
||||
&& !(throwable instanceof McpTransportSessionNotFoundException))
|
||||
.verify();
|
||||
|
||||
StepVerifier.create(transport.closeGracefully()).verifyComplete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that 400 response WITH session ID throws McpTransportSessionNotFoundException
|
||||
* This handles the case mentioned in the code comment about some implementations
|
||||
* returning 400 for unknown session IDs.
|
||||
*/
|
||||
@Test
|
||||
void test400WithSessionId() {
|
||||
// First establish a session
|
||||
serverResponseStatus.set(200);
|
||||
currentServerSessionId.set("test-session-456");
|
||||
|
||||
// Set up exception handler
|
||||
@SuppressWarnings("unchecked")
|
||||
Consumer<Throwable> exceptionHandler = mock(Consumer.class);
|
||||
transport.setExceptionHandler(exceptionHandler);
|
||||
|
||||
// Connect with handler
|
||||
StepVerifier.create(transport.connect(msg -> msg)).verifyComplete();
|
||||
|
||||
// Send initial message to establish session
|
||||
var testMessage = createTestRequestMessage();
|
||||
StepVerifier.create(transport.sendMessage(testMessage)).verifyComplete();
|
||||
|
||||
// The session should now be established, next request will include session ID
|
||||
// Now return 400 for next request (simulating unknown session ID)
|
||||
serverResponseStatus.set(400);
|
||||
|
||||
// Send another message - should get SessionNotFoundException
|
||||
StepVerifier.create(transport.sendMessage(testMessage))
|
||||
.expectError(McpTransportSessionNotFoundException.class)
|
||||
.verify();
|
||||
|
||||
// Verify exception handler was called
|
||||
verify(exceptionHandler).accept(any(McpTransportSessionNotFoundException.class));
|
||||
|
||||
StepVerifier.create(transport.closeGracefully()).verifyComplete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test session recovery after SessionNotFoundException Verifies that a new session
|
||||
* can be established after the old one is invalidated
|
||||
*/
|
||||
@Test
|
||||
void testSessionRecoveryAfter404() {
|
||||
// First establish a session
|
||||
serverResponseStatus.set(200);
|
||||
currentServerSessionId.set("session-1");
|
||||
|
||||
// Send initial message to establish session
|
||||
var testMessage = createTestRequestMessage();
|
||||
StepVerifier.create(transport.sendMessage(testMessage)).verifyComplete();
|
||||
|
||||
assertThat(lastReceivedSessionId.get()).isNull();
|
||||
|
||||
// The session should now be established
|
||||
// Simulate session loss - return 404
|
||||
serverResponseStatus.set(404);
|
||||
|
||||
// This should fail with SessionNotFoundException
|
||||
StepVerifier.create(transport.sendMessage(testMessage))
|
||||
.expectError(McpTransportSessionNotFoundException.class)
|
||||
.verify();
|
||||
|
||||
// Now server is back with new session
|
||||
serverResponseStatus.set(200);
|
||||
currentServerSessionId.set("session-2");
|
||||
lastReceivedSessionId.set(null); // Reset to verify new session
|
||||
|
||||
// Should be able to establish new session
|
||||
StepVerifier.create(transport.sendMessage(testMessage)).verifyComplete();
|
||||
|
||||
// Verify no session ID was sent (since old session was invalidated)
|
||||
assertThat(lastReceivedSessionId.get()).isNull();
|
||||
|
||||
// Next request should use the new session ID
|
||||
StepVerifier.create(transport.sendMessage(testMessage)).verifyComplete();
|
||||
|
||||
// Session ID should now be sent with requests
|
||||
assertThat(lastReceivedSessionId.get()).isEqualTo("session-2");
|
||||
|
||||
StepVerifier.create(transport.closeGracefully()).verifyComplete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that reconnect (GET request) also properly handles 404/400 errors
|
||||
*/
|
||||
@Test
|
||||
void testReconnectErrorHandling() {
|
||||
|
||||
// Set up SSE endpoint for GET requests
|
||||
server.createContext("/mcp-sse", exchange -> {
|
||||
String method = exchange.getRequestMethod();
|
||||
String requestSessionId = exchange.getRequestHeaders().getFirst(HttpHeaders.MCP_SESSION_ID);
|
||||
|
||||
if ("GET".equals(method)) {
|
||||
int status = serverResponseStatus.get();
|
||||
|
||||
if (status == 404 && requestSessionId != null) {
|
||||
// 404 with session ID - should trigger SessionNotFoundException
|
||||
exchange.sendResponseHeaders(404, 0);
|
||||
}
|
||||
else if (status == 404) {
|
||||
// 404 without session ID - should trigger McpTransportException
|
||||
exchange.sendResponseHeaders(404, 0);
|
||||
}
|
||||
else {
|
||||
// Normal SSE response
|
||||
exchange.getResponseHeaders().set("Content-Type", "text/event-stream");
|
||||
exchange.sendResponseHeaders(200, 0);
|
||||
// Send a test SSE event
|
||||
String sseData = "event: message\ndata: {\"jsonrpc\":\"2.0\",\"method\":\"test\",\"params\":{}}\n\n";
|
||||
exchange.getResponseBody().write(sseData.getBytes());
|
||||
}
|
||||
}
|
||||
else {
|
||||
// POST request handling
|
||||
exchange.getResponseHeaders().set("Content-Type", "application/json");
|
||||
String responseSessionId = currentServerSessionId.get();
|
||||
if (responseSessionId != null) {
|
||||
exchange.getResponseHeaders().set(HttpHeaders.MCP_SESSION_ID, responseSessionId);
|
||||
}
|
||||
String response = "{\"jsonrpc\":\"2.0\",\"result\":{},\"id\":\"test-id\"}";
|
||||
exchange.sendResponseHeaders(200, response.length());
|
||||
exchange.getResponseBody().write(response.getBytes());
|
||||
}
|
||||
exchange.close();
|
||||
});
|
||||
|
||||
// Test with session ID - should get SessionNotFoundException
|
||||
serverResponseStatus.set(200);
|
||||
currentServerSessionId.set("sse-session-1");
|
||||
|
||||
var transport = HttpClientStreamableHttpTransport.builder(HOST)
|
||||
.endpoint("/mcp-sse")
|
||||
.openConnectionOnStartup(true) // This will trigger GET request on connect
|
||||
.build();
|
||||
|
||||
// First connect successfully
|
||||
StepVerifier.create(transport.connect(msg -> msg)).verifyComplete();
|
||||
|
||||
// Send message to establish session
|
||||
var testMessage = createTestRequestMessage();
|
||||
StepVerifier.create(transport.sendMessage(testMessage)).verifyComplete();
|
||||
|
||||
// Now simulate server returning 404 on reconnect
|
||||
serverResponseStatus.set(404);
|
||||
|
||||
// This should trigger reconnect which will fail
|
||||
// The error should be handled internally and passed to exception handler
|
||||
|
||||
StepVerifier.create(transport.closeGracefully()).verifyComplete();
|
||||
}
|
||||
|
||||
private McpSchema.JSONRPCRequest createTestRequestMessage() {
|
||||
var initializeRequest = new McpSchema.InitializeRequest(ProtocolVersions.MCP_2025_03_26,
|
||||
McpSchema.ClientCapabilities.builder().roots(true).build(),
|
||||
new McpSchema.Implementation("Test Client", "1.0.0"));
|
||||
return new McpSchema.JSONRPCRequest(McpSchema.JSONRPC_VERSION, McpSchema.METHOD_INITIALIZE, "test-id",
|
||||
initializeRequest);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ import org.apache.catalina.LifecycleState;
|
|||
import org.apache.catalina.startup.Tomcat;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Timeout;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
|
|
@ -23,6 +24,7 @@ import io.modelcontextprotocol.server.McpServer.SyncSpecification;
|
|||
import io.modelcontextprotocol.server.transport.HttpServletSseServerTransportProvider;
|
||||
import io.modelcontextprotocol.server.transport.TomcatTestUtil;
|
||||
|
||||
@Timeout(15)
|
||||
class HttpServletSseIntegrationTests extends AbstractMcpClientServerIntegrationTests {
|
||||
|
||||
private static final int PORT = TomcatTestUtil.findAvailablePort();
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import org.apache.catalina.startup.Tomcat;
|
|||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.Timeout;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
|
|
@ -49,6 +50,7 @@ import static net.javacrumbs.jsonunit.assertj.JsonAssertions.json;
|
|||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.awaitility.Awaitility.await;
|
||||
|
||||
@Timeout(15)
|
||||
class HttpServletStatelessIntegrationTests {
|
||||
|
||||
private static final int PORT = TomcatTestUtil.findAvailablePort();
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import org.apache.catalina.LifecycleState;
|
|||
import org.apache.catalina.startup.Tomcat;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Timeout;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
|
|
@ -23,6 +24,7 @@ import io.modelcontextprotocol.server.McpServer.SyncSpecification;
|
|||
import io.modelcontextprotocol.server.transport.HttpServletStreamableServerTransportProvider;
|
||||
import io.modelcontextprotocol.server.transport.TomcatTestUtil;
|
||||
|
||||
@Timeout(15)
|
||||
class HttpServletStreamableIntegrationTests extends AbstractMcpClientServerIntegrationTests {
|
||||
|
||||
private static final int PORT = TomcatTestUtil.findAvailablePort();
|
||||
|
|
|
|||
|
|
@ -27,10 +27,12 @@ import io.modelcontextprotocol.server.transport.TomcatTestUtil;
|
|||
import io.modelcontextprotocol.spec.McpSchema;
|
||||
import io.modelcontextprotocol.spec.McpSchema.CompleteRequest;
|
||||
import io.modelcontextprotocol.spec.McpSchema.CompleteResult;
|
||||
import io.modelcontextprotocol.spec.McpSchema.ErrorCodes;
|
||||
import io.modelcontextprotocol.spec.McpSchema.InitializeResult;
|
||||
import io.modelcontextprotocol.spec.McpSchema.Prompt;
|
||||
import io.modelcontextprotocol.spec.McpSchema.PromptArgument;
|
||||
import io.modelcontextprotocol.spec.McpSchema.ReadResourceResult;
|
||||
import io.modelcontextprotocol.spec.McpSchema.Resource;
|
||||
import io.modelcontextprotocol.spec.McpSchema.ResourceReference;
|
||||
import io.modelcontextprotocol.spec.McpSchema.PromptReference;
|
||||
import io.modelcontextprotocol.spec.McpSchema.ServerCapabilities;
|
||||
|
|
@ -84,7 +86,7 @@ class McpCompletionTests {
|
|||
tomcat.destroy();
|
||||
}
|
||||
catch (LifecycleException e) {
|
||||
throw new RuntimeException("Failed to stop Tomcat", e);
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -99,8 +101,13 @@ class McpCompletionTests {
|
|||
|
||||
ResourceReference resourceRef = new ResourceReference("ref/resource", "test://resource/{param}");
|
||||
|
||||
McpSchema.Resource resource = new McpSchema.Resource("test://resource/{param}", "Test Resource",
|
||||
"A resource for testing", "text/plain", 123L, null);
|
||||
var resource = Resource.builder()
|
||||
.uri("test://resource/{param}")
|
||||
.name("Test Resource")
|
||||
.description("A resource for testing")
|
||||
.mimeType("text/plain")
|
||||
.size(123L)
|
||||
.build();
|
||||
|
||||
var mcpServer = McpServer.sync(mcpServerTransportProvider)
|
||||
.capabilities(ServerCapabilities.builder().completions().build())
|
||||
|
|
@ -199,8 +206,13 @@ class McpCompletionTests {
|
|||
return new CompleteResult(new CompleteResult.CompleteCompletion(List.of(), 0, false));
|
||||
};
|
||||
|
||||
McpSchema.Resource resource = new McpSchema.Resource("db://{database}/{table}", "Database Table",
|
||||
"Resource representing a table in a database", "application/json", 456L, null);
|
||||
McpSchema.Resource resource = Resource.builder()
|
||||
.uri("db://{database}/{table}")
|
||||
.name("Database Table")
|
||||
.description("Resource representing a table in a database")
|
||||
.mimeType("application/json")
|
||||
.size(456L)
|
||||
.build();
|
||||
|
||||
var mcpServer = McpServer.sync(mcpServerTransportProvider)
|
||||
.capabilities(ServerCapabilities.builder().completions().build())
|
||||
|
|
@ -254,7 +266,10 @@ class McpCompletionTests {
|
|||
// Check if database context is provided
|
||||
if (request.context() == null || request.context().arguments() == null
|
||||
|| !request.context().arguments().containsKey("database")) {
|
||||
throw new McpError("Please select a database first to see available tables");
|
||||
|
||||
throw McpError.builder(ErrorCodes.INVALID_REQUEST)
|
||||
.message("Please select a database first to see available tables")
|
||||
.build();
|
||||
}
|
||||
// Normal completion if context is provided
|
||||
String db = request.context().arguments().get("database");
|
||||
|
|
@ -268,8 +283,13 @@ class McpCompletionTests {
|
|||
return new CompleteResult(new CompleteResult.CompleteCompletion(List.of(), 0, false));
|
||||
};
|
||||
|
||||
McpSchema.Resource resource = new McpSchema.Resource("db://{database}/{table}", "Database Table",
|
||||
"Resource representing a table in a database", "application/json", 456L, null);
|
||||
McpSchema.Resource resource = Resource.builder()
|
||||
.uri("db://{database}/{table}")
|
||||
.name("Database Table")
|
||||
.description("Resource representing a table in a database")
|
||||
.mimeType("application/json")
|
||||
.size(456L)
|
||||
.build();
|
||||
|
||||
var mcpServer = McpServer.sync(mcpServerTransportProvider)
|
||||
.capabilities(ServerCapabilities.builder().completions().build())
|
||||
|
|
|
|||
Loading…
Reference in a new issue