diff --git a/.gitattributes b/.gitattributes index 3b41682ac..ac2f483e5 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,3 @@ /mvnw text eol=lf *.cmd text eol=crlf + diff --git a/mcp-test/src/main/java/io/modelcontextprotocol/server/AbstractMcpSyncServerTests.java b/mcp-test/src/main/java/io/modelcontextprotocol/server/AbstractMcpSyncServerTests.java index 9a63143c9..290bd4d5d 100644 --- a/mcp-test/src/main/java/io/modelcontextprotocol/server/AbstractMcpSyncServerTests.java +++ b/mcp-test/src/main/java/io/modelcontextprotocol/server/AbstractMcpSyncServerTests.java @@ -38,6 +38,8 @@ public abstract class AbstractMcpSyncServerTests { private static final String TEST_RESOURCE_URI = "test://resource"; + private static final String TEST_RESOURCE_TEMPLATE_URI = "test://resource/{id}"; + private static final String TEST_PROMPT_NAME = "test-prompt"; abstract protected McpServerTransportProvider createMcpTransportProvider(); @@ -208,6 +210,26 @@ void testAddResource() { assertThatCode(() -> mcpSyncServer.closeGracefully()).doesNotThrowAnyException(); } + // @Test + // void testAddResourceTemplate() { + // var mcpSyncServer = McpServer.sync(createMcpTransport()) + // .serverInfo("test-server", "1.0.0") + // .capabilities(ServerCapabilities.builder().resources(true, false).build()) + // .build(); + // + // McpSchema.ResourceTemplate resource = new + // McpSchema.ResourceTemplate(TEST_RESOURCE_TEMPLATE_URI, + // "Test Resource", "text/plain", "Test resource description", null); + // McpServerFeatures.SyncResourceTemplateRegistration registration = new + // McpServerFeatures.SyncResourceTemplateRegistration( + // resource, req -> new ReadResourceResult(List.of())); + // + // assertThatCode(() -> + // mcpSyncServer.addResourceTemplate(registration)).doesNotThrowAnyException(); + // + // assertThatCode(mcpSyncServer::closeGracefully).doesNotThrowAnyException(); + // } + @Test void testAddResourceWithNullSpecifiation() { var mcpSyncServer = McpServer.sync(createMcpTransportProvider()) @@ -222,6 +244,23 @@ void testAddResourceWithNullSpecifiation() { assertThatCode(() -> mcpSyncServer.closeGracefully()).doesNotThrowAnyException(); } + // @Test + // void testAddResourceTemplateWithNullRegistration() { + // var mcpSyncServer = McpServer.sync(createMcpTransport()) + // .serverInfo("test-server", "1.0.0") + // .capabilities(ServerCapabilities.builder().resources(true, false).build()) + // .build(); + // + // assertThatThrownBy( + // () -> + // mcpSyncServer.addResourceTemplate((McpServerFeatures.SyncResourceTemplateRegistration) + // null)) + // .isInstanceOf(McpError.class) + // .hasMessage("Resource must not be null"); + // + // assertThatCode(() -> mcpSyncServer.closeGracefully()).doesNotThrowAnyException(); + // } + @Test void testAddResourceWithoutCapability() { var serverWithoutResources = McpServer.sync(createMcpTransportProvider()) @@ -237,6 +276,23 @@ void testAddResourceWithoutCapability() { .hasMessage("Server must be configured with resource capabilities"); } + // @Test + // void testAddResourceTemplateWithoutCapability() { + // var serverWithoutResources = + // McpServer.sync(createMcpTransport()).serverInfo("test-server", "1.0.0").build(); + // + // McpSchema.ResourceTemplate resource = new + // McpSchema.ResourceTemplate(TEST_RESOURCE_TEMPLATE_URI, + // "Test Resource", "text/plain", "Test resource description", null); + // McpServerFeatures.SyncResourceTemplateRegistration registration = new + // McpServerFeatures.SyncResourceTemplateRegistration( + // resource, req -> new ReadResourceResult(List.of())); + // + // assertThatThrownBy(() -> + // serverWithoutResources.addResourceTemplate(registration)).isInstanceOf(McpError.class) + // .hasMessage("Server must be configured with resource capabilities"); + // } + @Test void testRemoveResourceWithoutCapability() { var serverWithoutResources = McpServer.sync(createMcpTransportProvider()) @@ -247,6 +303,17 @@ void testRemoveResourceWithoutCapability() { .hasMessage("Server must be configured with resource capabilities"); } + // @Test + // void testRemoveResourceTemplateWithoutCapability() { + // var serverWithoutResources = + // McpServer.sync(createMcpTransport()).serverInfo("test-server", "1.0.0").build(); + // + // assertThatThrownBy(() -> + // serverWithoutResources.removeResourceTemplate(TEST_RESOURCE_TEMPLATE_URI)) + // .isInstanceOf(McpError.class) + // .hasMessage("Server must be configured with resource capabilities"); + // } + // --------------------------------------- // Prompts Tests // --------------------------------------- diff --git a/mcp/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java b/mcp/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java index 062de13ed..12b3a4861 100644 --- a/mcp/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java +++ b/mcp/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java @@ -25,6 +25,7 @@ import io.modelcontextprotocol.spec.McpSchema.Tool; import io.modelcontextprotocol.spec.McpServerSession; import io.modelcontextprotocol.spec.McpServerTransportProvider; +import io.modelcontextprotocol.util.UriTemplate; import io.modelcontextprotocol.util.Utils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -183,6 +184,27 @@ public Mono notifyResourcesListChanged() { return this.delegate.notifyResourcesListChanged(); } + // --------------------------------------- + // Resource Template Management + // --------------------------------------- + /** + * Add a new resource template handler at runtime. + * @param resourceHandler The resource template handler to add + * @return Mono that completes when clients have been notified of the change + */ + public Mono addResourceTemplate(McpServerFeatures.AsyncResourceTemplateSpecification resourceHandler) { + return this.delegate.addResourceTemplate(resourceHandler); + } + + /** + * Remove a resource template handler at runtime. + * @param resourceUri The URI of the resource template handler to remove + * @return Mono that completes when clients have been notified of the change + */ + public Mono removeResourceTemplate(String resourceUri) { + return this.delegate.removeResourceTemplate(resourceUri); + } + // --------------------------------------- // Prompt Management // --------------------------------------- @@ -258,7 +280,7 @@ private static class AsyncServerImpl extends McpAsyncServer { private final CopyOnWriteArrayList tools = new CopyOnWriteArrayList<>(); - private final CopyOnWriteArrayList resourceTemplates = new CopyOnWriteArrayList<>(); + private final ConcurrentHashMap resourceTemplates = new ConcurrentHashMap<>(); private final ConcurrentHashMap resources = new ConcurrentHashMap<>(); @@ -279,7 +301,7 @@ private static class AsyncServerImpl extends McpAsyncServer { this.instructions = features.instructions(); this.tools.addAll(features.tools()); this.resources.putAll(features.resources()); - this.resourceTemplates.addAll(features.resourceTemplates()); + this.resourceTemplates.putAll(features.resourceTemplates()); this.prompts.putAll(features.prompts()); Map> requestHandlers = new HashMap<>(); @@ -488,6 +510,72 @@ private McpServerSession.RequestHandler toolsCallRequestHandler( }; } + // --------------------------------------- + // Resource Template Management + // --------------------------------------- + + @Override + public Mono addResourceTemplate( + McpServerFeatures.AsyncResourceTemplateSpecification resourceTemplateSpecification) { + if (resourceTemplateSpecification == null || resourceTemplateSpecification.resource() == null) { + return Mono.error(new McpError("Resource template must not be null")); + } + + if (this.serverCapabilities.resources() == null) { + return Mono.error(new McpError("Server must be configured with resource capabilities")); + } + + return Mono.defer(() -> { + if (this.resourceTemplates.putIfAbsent( + new UriTemplate(resourceTemplateSpecification.resource().uriTemplate()), + resourceTemplateSpecification) != null) { + return Mono.error(new McpError("Resource template with URI Template'" + + resourceTemplateSpecification.resource().uriTemplate() + "' already exists")); + } + logger.debug("Added resource template handler: {}", + resourceTemplateSpecification.resource().uriTemplate()); + if (this.serverCapabilities.resources().listChanged()) { + return notifyResourcesListChanged(); + } + return Mono.empty(); + }); + } + + @Override + public Mono removeResourceTemplate(String resourceUriTemplate) { + if (resourceUriTemplate == null) { + return Mono.error(new McpError("Resource Template URI must not be null")); + } + if (this.serverCapabilities.resources() == null) { + return Mono.error(new McpError("Server must be configured with resource capabilities")); + } + + return Mono.defer(() -> { + // Lookup the key in the map using the UriTemplate + McpServerFeatures.AsyncResourceTemplateSpecification removed = this.resourceTemplates + .remove(new UriTemplate(resourceUriTemplate)); + if (removed != null) { + logger.debug("Removed resource template handler: {}", resourceUriTemplate); + if (this.serverCapabilities.resources().listChanged()) { + return notifyResourcesListChanged(); + } + return Mono.empty(); + } + return Mono + .error(new McpError("Resource template with URI template '" + resourceUriTemplate + "' not found")); + }); + } + + private McpServerSession.RequestHandler resourceTemplateListRequestHandler() { + return (exchange, params) -> { + var resourceList = this.resourceTemplates.values() + .stream() + .map(McpServerFeatures.AsyncResourceTemplateSpecification::resource) + .toList(); + return Mono.just(new McpSchema.ListResourceTemplatesResult(resourceList, null)); + }; + } + // --------------------------------------- // Resource Management // --------------------------------------- @@ -552,22 +640,26 @@ private McpServerSession.RequestHandler resources }; } - private McpServerSession.RequestHandler resourceTemplateListRequestHandler() { - return (exchange, params) -> Mono - .just(new McpSchema.ListResourceTemplatesResult(this.resourceTemplates, null)); - - } - private McpServerSession.RequestHandler resourcesReadRequestHandler() { return (exchange, params) -> { McpSchema.ReadResourceRequest resourceRequest = objectMapper.convertValue(params, - new TypeReference() { + new TypeReference<>() { }); var resourceUri = resourceRequest.uri(); McpServerFeatures.AsyncResourceSpecification specification = this.resources.get(resourceUri); if (specification != null) { return specification.readHandler().apply(exchange, resourceRequest); } + + // If the resource is not found, we can check if it is a template + for (var entry : this.resourceTemplates.entrySet()) { + UriTemplate resourceUriTemplate = entry.getKey(); + if (resourceUriTemplate.matchesTemplate(resourceUri)) { + McpServerFeatures.AsyncResourceTemplateSpecification spec = entry.getValue(); + return spec.readHandler().apply(exchange, resourceRequest); + } + } + return Mono.error(new McpError("Resource not found: " + resourceUri)); }; } diff --git a/mcp/src/main/java/io/modelcontextprotocol/server/McpServer.java b/mcp/src/main/java/io/modelcontextprotocol/server/McpServer.java index d5427335d..bb5aa091c 100644 --- a/mcp/src/main/java/io/modelcontextprotocol/server/McpServer.java +++ b/mcp/src/main/java/io/modelcontextprotocol/server/McpServer.java @@ -15,9 +15,9 @@ import com.fasterxml.jackson.databind.ObjectMapper; import io.modelcontextprotocol.spec.McpSchema; import io.modelcontextprotocol.spec.McpSchema.CallToolResult; -import io.modelcontextprotocol.spec.McpSchema.ResourceTemplate; import io.modelcontextprotocol.spec.McpServerTransportProvider; import io.modelcontextprotocol.util.Assert; +import io.modelcontextprotocol.util.UriTemplate; import reactor.core.publisher.Mono; /** @@ -173,14 +173,21 @@ class AsyncSpecification { /** * The Model Context Protocol (MCP) provides a standardized way for servers to - * expose resources to clients. Resources allow servers to share data that + * expose parameterized resources using URI templates. Resources allow servers to + * share data that provides context to language models, such as files, database + * schemas, or application-specific information. Each resource is uniquely + * identified by a URI. + */ + private final Map resources = new HashMap<>(); + + /** + * The Model Context Protocol (MCP) provides a standardized way for servers to + * expose resource template to clients. Resources allow servers to share data that * provides context to language models, such as files, database schemas, or * application-specific information. Each resource is uniquely identified by a * URI. */ - private final Map resources = new HashMap<>(); - - private final List resourceTemplates = new ArrayList<>(); + private final Map resourceTemplates = new HashMap<>(); /** * The Model Context Protocol (MCP) provides a standardized way for servers to @@ -409,26 +416,31 @@ public AsyncSpecification resources(McpServerFeatures.AsyncResourceSpecification * templates. * @return This builder instance for method chaining * @throws IllegalArgumentException if resourceTemplates is null. - * @see #resourceTemplates(ResourceTemplate...) + * @see #resourceTemplates(McpServerFeatures.AsyncResourceTemplateSpecification...) */ - public AsyncSpecification resourceTemplates(List resourceTemplates) { + public AsyncSpecification resourceTemplates( + List resourceTemplates) { Assert.notNull(resourceTemplates, "Resource templates must not be null"); - this.resourceTemplates.addAll(resourceTemplates); + for (McpServerFeatures.AsyncResourceTemplateSpecification resource : resourceTemplates) { + this.resourceTemplates.put(new UriTemplate(resource.resource().uriTemplate()), resource); + } return this; } /** * Sets the resource templates using varargs for convenience. This is an * alternative to {@link #resourceTemplates(List)}. - * @param resourceTemplates The resource templates to set. + * @param resourceTemplatesSpecifications The resource templates to set. * @return This builder instance for method chaining * @throws IllegalArgumentException if resourceTemplates is null. * @see #resourceTemplates(List) */ - public AsyncSpecification resourceTemplates(ResourceTemplate... resourceTemplates) { - Assert.notNull(resourceTemplates, "Resource templates must not be null"); - for (ResourceTemplate resourceTemplate : resourceTemplates) { - this.resourceTemplates.add(resourceTemplate); + public AsyncSpecification resourceTemplates( + McpServerFeatures.AsyncResourceTemplateSpecification... resourceTemplatesSpecifications) { + Assert.notNull(resourceTemplatesSpecifications, "Resource templates must not be null"); + for (McpServerFeatures.AsyncResourceTemplateSpecification resourceTemplate : resourceTemplatesSpecifications) { + this.resourceTemplates.put(new UriTemplate(resourceTemplate.resource().uriTemplate()), + resourceTemplate); } return this; } @@ -606,7 +618,7 @@ class SyncSpecification { */ private final Map resources = new HashMap<>(); - private final List resourceTemplates = new ArrayList<>(); + private final Map resourceTemplates = new HashMap<>(); /** * The Model Context Protocol (MCP) provides a standardized way for servers to @@ -834,11 +846,14 @@ public SyncSpecification resources(McpServerFeatures.SyncResourceSpecification.. * templates. * @return This builder instance for method chaining * @throws IllegalArgumentException if resourceTemplates is null. - * @see #resourceTemplates(ResourceTemplate...) + * @see #resourceTemplates(McpServerFeatures.SyncResourceTemplateSpecification...) */ - public SyncSpecification resourceTemplates(List resourceTemplates) { + public SyncSpecification resourceTemplates( + List resourceTemplates) { Assert.notNull(resourceTemplates, "Resource templates must not be null"); - this.resourceTemplates.addAll(resourceTemplates); + for (McpServerFeatures.SyncResourceTemplateSpecification resource : resourceTemplates) { + this.resourceTemplates.put(resource.resource().uriTemplate(), resource); + } return this; } @@ -850,10 +865,11 @@ public SyncSpecification resourceTemplates(List resourceTempla * @throws IllegalArgumentException if resourceTemplates is null * @see #resourceTemplates(List) */ - public SyncSpecification resourceTemplates(ResourceTemplate... resourceTemplates) { + public SyncSpecification resourceTemplates( + McpServerFeatures.SyncResourceTemplateSpecification... resourceTemplates) { Assert.notNull(resourceTemplates, "Resource templates must not be null"); - for (ResourceTemplate resourceTemplate : resourceTemplates) { - this.resourceTemplates.add(resourceTemplate); + for (McpServerFeatures.SyncResourceTemplateSpecification resource : resourceTemplates) { + this.resourceTemplates.put(resource.resource().uriTemplate(), resource); } return this; } diff --git a/mcp/src/main/java/io/modelcontextprotocol/server/McpServerFeatures.java b/mcp/src/main/java/io/modelcontextprotocol/server/McpServerFeatures.java index e0f337b78..93b77ca4b 100644 --- a/mcp/src/main/java/io/modelcontextprotocol/server/McpServerFeatures.java +++ b/mcp/src/main/java/io/modelcontextprotocol/server/McpServerFeatures.java @@ -13,6 +13,7 @@ import io.modelcontextprotocol.spec.McpSchema; import io.modelcontextprotocol.util.Assert; +import io.modelcontextprotocol.util.UriTemplate; import io.modelcontextprotocol.util.Utils; import reactor.core.publisher.Mono; import reactor.core.scheduler.Schedulers; @@ -35,11 +36,10 @@ public class McpServerFeatures { * @param prompts The map of prompt specifications * @param rootsChangeConsumers The list of consumers that will be notified when the * roots list changes - * @param instructions The server instructions text */ record Async(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities serverCapabilities, List tools, Map resources, - List resourceTemplates, + Map resourceTemplates, Map prompts, List, Mono>> rootsChangeConsumers, String instructions) { @@ -54,11 +54,10 @@ record Async(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities s * @param prompts The map of prompt specifications * @param rootsChangeConsumers The list of consumers that will be notified when * the roots list changes - * @param instructions The server instructions text */ Async(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities serverCapabilities, List tools, Map resources, - List resourceTemplates, + Map resourceTemplates, Map prompts, List, Mono>> rootsChangeConsumers, String instructions) { @@ -79,7 +78,7 @@ record Async(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities s this.tools = (tools != null) ? tools : List.of(); this.resources = (resources != null) ? resources : Map.of(); - this.resourceTemplates = (resourceTemplates != null) ? resourceTemplates : List.of(); + this.resourceTemplates = (resourceTemplates != null) ? resourceTemplates : Map.of(); this.prompts = (prompts != null) ? prompts : Map.of(); this.rootsChangeConsumers = (rootsChangeConsumers != null) ? rootsChangeConsumers : List.of(); this.instructions = instructions; @@ -104,6 +103,11 @@ static Async fromSync(Sync syncSpec) { resources.put(key, AsyncResourceSpecification.fromSync(resource)); }); + Map resourceTemplates = new HashMap<>(); + syncSpec.resourceTemplates().forEach((key, resource) -> { + resourceTemplates.put(new UriTemplate(key), AsyncResourceTemplateSpecification.fromSync(resource)); + }); + Map prompts = new HashMap<>(); syncSpec.prompts().forEach((key, prompt) -> { prompts.put(key, AsyncPromptSpecification.fromSync(prompt)); @@ -117,8 +121,8 @@ static Async fromSync(Sync syncSpec) { .subscribeOn(Schedulers.boundedElastic())); } - return new Async(syncSpec.serverInfo(), syncSpec.serverCapabilities(), tools, resources, - syncSpec.resourceTemplates(), prompts, rootChangeConsumers, syncSpec.instructions()); + return new Async(syncSpec.serverInfo(), syncSpec.serverCapabilities(), tools, resources, resourceTemplates, + prompts, rootChangeConsumers, syncSpec.instructions()); } } @@ -138,7 +142,7 @@ static Async fromSync(Sync syncSpec) { record Sync(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities serverCapabilities, List tools, Map resources, - List resourceTemplates, + Map resourceTemplates, Map prompts, List>> rootsChangeConsumers, String instructions) { @@ -157,7 +161,7 @@ record Sync(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities se Sync(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities serverCapabilities, List tools, Map resources, - List resourceTemplates, + Map resourceTemplates, Map prompts, List>> rootsChangeConsumers, String instructions) { @@ -178,7 +182,7 @@ record Sync(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities se this.tools = (tools != null) ? tools : new ArrayList<>(); this.resources = (resources != null) ? resources : new HashMap<>(); - this.resourceTemplates = (resourceTemplates != null) ? resourceTemplates : new ArrayList<>(); + this.resourceTemplates = (resourceTemplates != null) ? resourceTemplates : new HashMap<>(); this.prompts = (prompts != null) ? prompts : new HashMap<>(); this.rootsChangeConsumers = (rootsChangeConsumers != null) ? rootsChangeConsumers : new ArrayList<>(); this.instructions = instructions; @@ -279,6 +283,49 @@ static AsyncResourceSpecification fromSync(SyncResourceSpecification resource) { } } + /** + * Specification of a resource template with its asynchronous handler function. + * Resource templates provide context to AI models by exposing data such as: + *
    + *
  • File contents + *
  • Database records + *
  • API responses + *
  • System information + *
  • Application state + *
+ * + *

+ * Example resource specification:

{@code
+	 * new McpServerFeatures.AsyncResourceTemplateSpecification(
+	 *     new Resource("file:///{path}", "docs", "Documentation files", "text/markdown"),
+	 *     (exchange, request) ->
+	 *         Mono.fromSupplier(() -> readFile(request.getPath()))
+	 *             .map(ReadResourceResult::new)
+	 * )
+	 * }
+ * + * @param resource The resource template definition including uriTemplate, name, + * description, and MIME type + * @param readHandler The function that handles resource read requests. The function's + * first argument is an {@link McpAsyncServerExchange} upon which the server can + * interact with the connected client. The second arguments is a + * {@link io.modelcontextprotocol.spec.McpSchema.ReadResourceRequest}. + */ + public record AsyncResourceTemplateSpecification(McpSchema.ResourceTemplate resource, + BiFunction> readHandler) { + + static AsyncResourceTemplateSpecification fromSync(SyncResourceTemplateSpecification resource) { + // FIXME: This is temporary, proper validation should be implemented + if (resource == null) { + return null; + } + return new AsyncResourceTemplateSpecification(resource.resource(), + (exchange, req) -> Mono + .fromCallable(() -> resource.readHandler().apply(new McpSyncServerExchange(exchange), req)) + .subscribeOn(Schedulers.boundedElastic())); + } + } + /** * Specification of a prompt template with its asynchronous handler function. Prompts * provide structured templates for AI model interactions, supporting: @@ -396,6 +443,39 @@ public record SyncResourceSpecification(McpSchema.Resource resource, BiFunction readHandler) { } + /** + * Specification of a resource template with its synchronous handler function. + * Resource templates provide context to AI models by exposing data such as: + *
    + *
  • File contents + *
  • Database records + *
  • API responses + *
  • System information + *
  • Application state + *
+ * + *

+ * Example resource specification:

{@code
+	 * new McpServerFeatures.SyncResourceTemplateSpecification(
+	 *     new ResourceTemplate("file:///{path}", "docs", "Documentation files", "text/markdown"),
+	 *     (exchange, request) -> {
+	 *         String content = readFile(request.getPath());
+	 *         return new ReadResourceResult(content);
+	 *     }
+	 * )
+	 * }
+ * + * @param resource The resource template definition including uriTemplate, name, + * description, and MIME type + * @param readHandler The function that handles resource read requests. The function's + * first argument is an {@link McpSyncServerExchange} upon which the server can + * interact with the connected client. The second arguments is a + * {@link io.modelcontextprotocol.spec.McpSchema.ReadResourceRequest}. + */ + public record SyncResourceTemplateSpecification(McpSchema.ResourceTemplate resource, + BiFunction readHandler) { + } + /** * Specification of a prompt template with its synchronous handler function. Prompts * provide structured templates for AI model interactions, supporting: diff --git a/mcp/src/main/java/io/modelcontextprotocol/server/McpSyncServer.java b/mcp/src/main/java/io/modelcontextprotocol/server/McpSyncServer.java index bf3104508..909c7d344 100644 --- a/mcp/src/main/java/io/modelcontextprotocol/server/McpSyncServer.java +++ b/mcp/src/main/java/io/modelcontextprotocol/server/McpSyncServer.java @@ -95,6 +95,24 @@ public void removeResource(String resourceUri) { this.asyncServer.removeResource(resourceUri).block(); } + /** + * Add a new resource template handler. + * @param resourceHandler The resource handler to add + */ + public void addResourceTemplate(McpServerFeatures.SyncResourceTemplateSpecification resourceHandler) { + this.asyncServer + .addResourceTemplate(McpServerFeatures.AsyncResourceTemplateSpecification.fromSync(resourceHandler)) + .block(); + } + + /** + * Remove a resource template handler. + * @param resourceUri The URI of the resource template handler to remove + */ + public void removeResourceTemplate(String resourceUri) { + this.asyncServer.removeResourceTemplate(resourceUri).block(); + } + /** * Add a new prompt handler. * @param promptSpecification The prompt specification to add diff --git a/mcp/src/main/java/io/modelcontextprotocol/util/UriTemplate.java b/mcp/src/main/java/io/modelcontextprotocol/util/UriTemplate.java new file mode 100644 index 000000000..3578f9fe3 --- /dev/null +++ b/mcp/src/main/java/io/modelcontextprotocol/util/UriTemplate.java @@ -0,0 +1,240 @@ +package io.modelcontextprotocol.util; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * A utility class for validating and matching URI templates. + *

+ * This class provides methods to validate the syntax of URI templates and check if a + * given URI matches a specified template. + *

+ */ +public class UriTemplate { + + // Constants for security and performance limits + private static final int MAX_TEMPLATE_LENGTH = 1_000_000; + + private static final int MAX_VARIABLE_LENGTH = 1_000_000; + + private static final int MAX_TEMPLATE_EXPRESSIONS = 10_000; + + private static final int MAX_REGEX_LENGTH = 1_000_000; + + private final String template; + + private final Pattern pattern; + + public String getTemplate() { + return template; + } + + /** + * Constructor to create a new UriTemplate instance. Validates the template length, + * parses it into parts, and compiles a regex pattern. + * @param template The URI template string + * @throws IllegalArgumentException if the template is invalid or too long + */ + public UriTemplate(String template) { + if (template == null || template.isBlank()) { + throw new IllegalArgumentException("Template cannot be null or empty"); + } + validateLength(template, MAX_TEMPLATE_LENGTH, "Template"); + + this.template = template; + final List parts = parseTemplate(template); + this.pattern = Pattern.compile(createMatchingPattern(parts)); + } + + /** + * Returns the original template string. + */ + @Override + public String toString() { + return template; + } + + /** + * Checks if a given URI matches the compiled template pattern. + * @param uri The URI to check + * @return true if the URI matches the template pattern, false otherwise + */ + public boolean matchesTemplate(String uri) { + validateLength(uri, MAX_TEMPLATE_LENGTH, "URI"); + return pattern.matcher(uri).matches(); + } + + /** + * Validates that a string does not exceed a maximum allowed length. + * @param str String to validate + * @param max Maximum allowed length + * @param context Context description for error message + * @throws IllegalArgumentException if the string exceeds the maximum length + */ + private static void validateLength(String str, int max, String context) { + if (str.length() > max) { + throw new IllegalArgumentException( + context + " exceeds maximum length of " + max + " characters (got " + str.length() + ")"); + } + } + + /** + * Parses the URI template into a list of parts (literals and expressions). + * @param template The URI template string + * @return List of parts + */ + private List parseTemplate(String template) { + List parts = new ArrayList<>(); + StringBuilder literal = new StringBuilder(); + int expressionCount = 0; + int index = 0; + + while (index < template.length()) { + char ch = template.charAt(index); + if (ch == '{') { + if (!literal.isEmpty()) { + parts.add(new LiteralPart(literal.toString())); + literal.setLength(0); + } + int end = template.indexOf('}', index); + if (end == -1) { + throw new IllegalArgumentException("Unclosed template expression"); + } + expressionCount++; + if (expressionCount > MAX_TEMPLATE_EXPRESSIONS) { + throw new IllegalArgumentException("Too many template expressions"); + } + String expr = template.substring(index + 1, end); + parts.add(parseExpression(expr)); + index = end + 1; + } + else { + literal.append(ch); + index++; + } + } + if (!literal.isEmpty()) { + parts.add(new LiteralPart(literal.toString())); + } + return parts; + } + + /** + * Parses a template expression into an ExpressionPart. + * @param expr The template expression string + * @return An ExpressionPart representing the expression + */ + private ExpressionPart parseExpression(String expr) { + if (expr.trim().isEmpty()) { + throw new IllegalArgumentException("Empty template expression"); + } + String operator = extractOperator(expr); + boolean exploded = expr.contains("*"); + List names = extractNames(expr); + if (names.isEmpty()) { + throw new IllegalArgumentException("No variable names in template expression: " + expr); + } + names.forEach(name -> validateLength(name, MAX_VARIABLE_LENGTH, "Variable name")); + return new ExpressionPart(operator, names, exploded); + } + + /** + * Extracts the operator from a template expression if present. + * @param expr The template expression string + * @return The operator as a string, or an empty string if none + */ + private String extractOperator(String expr) { + return switch (expr.charAt(0)) { + case '+', '#', '.', '/', '?', '&' -> String.valueOf(expr.charAt(0)); + default -> ""; + }; + } + + /** + * Extracts variable names from a template expression. + * @param expr The template expression string + * @return A list of variable names + */ + private List extractNames(String expr) { + String cleaned = expr.replaceAll("^[+.#/?&]", ""); + String[] nameParts = cleaned.split(","); + List names = new ArrayList<>(); + for (String part : nameParts) { + String trimmed = part.replace("*", "").trim(); + if (!trimmed.isEmpty()) { + names.add(trimmed); + } + } + return names; + } + + /** + * Constructs a regex pattern string to match URIs based on the parsed template parts. + * @return A regex pattern string + */ + private String createMatchingPattern(List parts) { + StringBuilder patternBuilder = new StringBuilder("^"); + for (Part part : parts) { + if (part instanceof ExpressionPart exprPart) { + patternBuilder.append(createPatternForExpressionPart(exprPart)); + } + else if (part instanceof LiteralPart literalPart) { + patternBuilder.append(Pattern.quote(literalPart.literal())); + } + } + patternBuilder.append("$"); + String patternStr = patternBuilder.toString(); + validateLength(patternStr, MAX_REGEX_LENGTH, "Generated regex pattern"); + return patternStr; + } + + /** + * Creates a regex pattern for a specific expression part based on its operator. + * @param part The expression part + * @return A regex pattern string + */ + private String createPatternForExpressionPart(ExpressionPart part) { + return switch (part.operator()) { + case "", "+" -> part.exploded() ? "([^/]+(?:,[^/]+)*)" : "([^/,]+)"; + case "#" -> "(.+)"; + case "." -> "\\.([^/,]+)"; + case "/" -> "/" + (part.exploded() ? "([^/]+(?:,[^/]+)*)" : "([^/,]+)"); + case "?", "&" -> "\\?" + part.variableNames().get(0) + "=([^&]+)"; + default -> "([^/]+)"; + }; + } + + // --- Internal types --- + + /** + * A marker interface for parts of the URI template. + */ + private interface Part { + + } + + /** + * Represents a literal segment of the template. + */ + private record LiteralPart(String literal) implements Part { + } + + /** + * Represents an expression segment of the template. + */ + private record ExpressionPart(String operator, List variableNames, boolean exploded) implements Part { + } + + @Override + public int hashCode() { + return template.hashCode(); + } + + @Override + public boolean equals(Object obj) { + return (obj instanceof UriTemplate other && template.equals(other.template)); + } + +} \ No newline at end of file diff --git a/mcp/src/test/java/io/modelcontextprotocol/server/AbstractMcpAsyncServerTests.java b/mcp/src/test/java/io/modelcontextprotocol/server/AbstractMcpAsyncServerTests.java index c7c69b52b..351c3f08b 100644 --- a/mcp/src/test/java/io/modelcontextprotocol/server/AbstractMcpAsyncServerTests.java +++ b/mcp/src/test/java/io/modelcontextprotocol/server/AbstractMcpAsyncServerTests.java @@ -40,6 +40,8 @@ public abstract class AbstractMcpAsyncServerTests { private static final String TEST_RESOURCE_URI = "test://resource"; + private static final String TEST_RESOURCE_TEMPLATE_URI = "test://resource/{path}"; + private static final String TEST_PROMPT_NAME = "test-prompt"; abstract protected McpServerTransportProvider createMcpTransportProvider(); @@ -256,6 +258,91 @@ void testRemoveResourceWithoutCapability() { }); } + // --------------------------------------- + // Resources Template Tests + // --------------------------------------- + + @Test + void testAddResourceTemplate() { + var mcpAsyncServer = McpServer.async(createMcpTransportProvider()) + .serverInfo("test-server", "1.0.0") + .capabilities(ServerCapabilities.builder().resources(true, false).build()) + .build(); + + McpSchema.ResourceTemplate resource = new McpSchema.ResourceTemplate(TEST_RESOURCE_TEMPLATE_URI, + "Test Resource", "text/plain", "Test resource description", null); + McpServerFeatures.AsyncResourceTemplateSpecification specification = new McpServerFeatures.AsyncResourceTemplateSpecification( + resource, (exchange, req) -> Mono.just(new ReadResourceResult(List.of()))); + + StepVerifier.create(mcpAsyncServer.addResourceTemplate(specification)).verifyComplete(); + + assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException(); + } + + @Test + void testAddResourceAndRemoveTemplate() { + var mcpAsyncServer = McpServer.async(createMcpTransportProvider()) + .serverInfo("test-server", "1.0.0") + .capabilities(ServerCapabilities.builder().resources(true, false).build()) + .build(); + + McpSchema.ResourceTemplate resource = new McpSchema.ResourceTemplate(TEST_RESOURCE_TEMPLATE_URI, + "Test Resource", "text/plain", "Test resource description", null); + McpServerFeatures.AsyncResourceTemplateSpecification specification = new McpServerFeatures.AsyncResourceTemplateSpecification( + resource, (exchange, req) -> Mono.just(new ReadResourceResult(List.of()))); + + StepVerifier.create(mcpAsyncServer.addResourceTemplate(specification)).verifyComplete(); + StepVerifier.create(mcpAsyncServer.removeResourceTemplate(TEST_RESOURCE_TEMPLATE_URI)).verifyComplete(); + + assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException(); + } + + @Test + void testAddResourceTemplateWithNullSpecification() { + var mcpAsyncServer = McpServer.async(createMcpTransportProvider()) + .serverInfo("test-server", "1.0.0") + .capabilities(ServerCapabilities.builder().resources(true, false).build()) + .build(); + + StepVerifier.create(mcpAsyncServer.addResourceTemplate(null)).verifyErrorSatisfies(error -> { + assertThat(error).isInstanceOf(McpError.class).hasMessage("Resource template must not be null"); + }); + + assertThatCode(() -> mcpAsyncServer.closeGracefully().block(Duration.ofSeconds(10))).doesNotThrowAnyException(); + } + + @Test + void testAddResourceTemplateWithoutCapability() { + // Create a server without resource capabilities + McpAsyncServer serverWithoutResources = McpServer.async(createMcpTransportProvider()) + .serverInfo("test-server", "1.0.0") + .build(); + + McpSchema.ResourceTemplate resource = new McpSchema.ResourceTemplate(TEST_RESOURCE_TEMPLATE_URI, + "Test Resource", "text/plain", "Test resource description", null); + McpServerFeatures.AsyncResourceTemplateSpecification specification = new McpServerFeatures.AsyncResourceTemplateSpecification( + resource, (exchange, req) -> Mono.just(new ReadResourceResult(List.of()))); + + StepVerifier.create(serverWithoutResources.addResourceTemplate(specification)).verifyErrorSatisfies(error -> { + assertThat(error).isInstanceOf(McpError.class) + .hasMessage("Server must be configured with resource capabilities"); + }); + } + + @Test + void testRemoveResourceTemplateWithoutCapability() { + // Create a server without resource capabilities + McpAsyncServer serverWithoutResources = McpServer.async(createMcpTransportProvider()) + .serverInfo("test-server", "1.0.0") + .build(); + + StepVerifier.create(serverWithoutResources.removeResourceTemplate(TEST_RESOURCE_TEMPLATE_URI)) + .verifyErrorSatisfies(error -> { + assertThat(error).isInstanceOf(McpError.class) + .hasMessage("Server must be configured with resource capabilities"); + }); + } + // --------------------------------------- // Prompts Tests // --------------------------------------- diff --git a/mcp/src/test/java/io/modelcontextprotocol/server/AbstractMcpSyncServerTests.java b/mcp/src/test/java/io/modelcontextprotocol/server/AbstractMcpSyncServerTests.java index 8c9328cc7..efc004291 100644 --- a/mcp/src/test/java/io/modelcontextprotocol/server/AbstractMcpSyncServerTests.java +++ b/mcp/src/test/java/io/modelcontextprotocol/server/AbstractMcpSyncServerTests.java @@ -37,6 +37,8 @@ public abstract class AbstractMcpSyncServerTests { private static final String TEST_RESOURCE_URI = "test://resource"; + private static final String TEST_RESOURCE_TEMPLATE_URI = "test://resource/{path}"; + private static final String TEST_PROMPT_NAME = "test-prompt"; abstract protected McpServerTransportProvider createMcpTransportProvider(); @@ -246,6 +248,66 @@ void testRemoveResourceWithoutCapability() { .hasMessage("Server must be configured with resource capabilities"); } + // --------------------------------------- + // Resources Template Tests + // --------------------------------------- + + @Test + void testAddResourceTemplate() { + var mcpSyncServer = McpServer.sync(createMcpTransportProvider()) + .serverInfo("test-server", "1.0.0") + .capabilities(ServerCapabilities.builder().resources(true, false).build()) + .build(); + + McpSchema.ResourceTemplate resource = new McpSchema.ResourceTemplate(TEST_RESOURCE_TEMPLATE_URI, + "Test Template Resource", "text/plain", "Test resource description", null); + McpServerFeatures.SyncResourceTemplateSpecification specification = new McpServerFeatures.SyncResourceTemplateSpecification( + resource, (exchange, req) -> new ReadResourceResult(List.of())); + + assertThatCode(() -> mcpSyncServer.addResourceTemplate(specification)).doesNotThrowAnyException(); + + assertThatCode(() -> mcpSyncServer.closeGracefully()).doesNotThrowAnyException(); + } + + @Test + void testAddResourceTemplateWithNullSpecifiation() { + var mcpSyncServer = McpServer.sync(createMcpTransportProvider()) + .serverInfo("test-server", "1.0.0") + .capabilities(ServerCapabilities.builder().resources(true, false).build()) + .build(); + + assertThatThrownBy(() -> mcpSyncServer.addResourceTemplate(null)).isInstanceOf(McpError.class) + .hasMessage("Resource template must not be null"); + + assertThatCode(() -> mcpSyncServer.closeGracefully()).doesNotThrowAnyException(); + } + + @Test + void testAddResourceTemplateWithoutCapability() { + var serverWithoutResources = McpServer.sync(createMcpTransportProvider()) + .serverInfo("test-server", "1.0.0") + .build(); + + McpSchema.ResourceTemplate resource = new McpSchema.ResourceTemplate(TEST_RESOURCE_TEMPLATE_URI, + "Test Template Resource", "text/plain", "Test resource description", null); + McpServerFeatures.SyncResourceTemplateSpecification specification = new McpServerFeatures.SyncResourceTemplateSpecification( + resource, (exchange, req) -> new ReadResourceResult(List.of())); + + assertThatThrownBy(() -> serverWithoutResources.addResourceTemplate(specification)).isInstanceOf(McpError.class) + .hasMessage("Server must be configured with resource capabilities"); + } + + @Test + void testRemoveResourceTemplateWithoutCapability() { + var serverWithoutResources = McpServer.sync(createMcpTransportProvider()) + .serverInfo("test-server", "1.0.0") + .build(); + + assertThatThrownBy(() -> serverWithoutResources.removeResourceTemplate(TEST_RESOURCE_TEMPLATE_URI)) + .isInstanceOf(McpError.class) + .hasMessage("Server must be configured with resource capabilities"); + } + // --------------------------------------- // Prompts Tests // --------------------------------------- diff --git a/mcp/src/test/java/io/modelcontextprotocol/util/UriTemplateTests.java b/mcp/src/test/java/io/modelcontextprotocol/util/UriTemplateTests.java new file mode 100644 index 000000000..5b8097124 --- /dev/null +++ b/mcp/src/test/java/io/modelcontextprotocol/util/UriTemplateTests.java @@ -0,0 +1,95 @@ +package io.modelcontextprotocol.util; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +public class UriTemplateTests { + + @Test + void testValidTemplate() { + String template = "/api/{resource}/{id}"; + UriTemplate validator = new UriTemplate(template); + Assertions.assertEquals(template, validator.getTemplate()); + } + + @Test + void testNullTemplate() { + Assertions.assertThrows(IllegalArgumentException.class, () -> new UriTemplate(null)); + } + + @Test + void testEmptyTemplate() { + Assertions.assertThrows(IllegalArgumentException.class, () -> new UriTemplate("")); + } + + @Test + void testLongTemplate() { + Assertions.assertThrows(IllegalArgumentException.class, () -> new UriTemplate("a".repeat(1_000_001))); + } + + @Test + void testTemplateWithEmptyExpression() { + String template = "/api/{}"; + Assertions.assertThrows(IllegalArgumentException.class, () -> new UriTemplate(template)); + } + + @Test + void testTemplateWithUnclosedExpression() { + String template = "/api/{resource"; + Assertions.assertThrows(IllegalArgumentException.class, () -> new UriTemplate(template)); + } + + @Test + void testMatchesTemplate() { + String template = "/api/{resource}/{id}"; + UriTemplate validator = new UriTemplate(template); + Assertions.assertTrue(validator.matchesTemplate("/api/books/123")); + Assertions.assertFalse(validator.matchesTemplate("/api/books")); + Assertions.assertFalse(validator.matchesTemplate("/api/books/123/extra")); + } + + @Test + public void testValidTemplates() { + Assertions.assertEquals("/users/{id}", new UriTemplate("/users/{id}").getTemplate()); + Assertions.assertEquals("/search{?q}", new UriTemplate("/search{?q}").getTemplate()); + Assertions.assertEquals("/map/{+location}", new UriTemplate("/map/{+location}").getTemplate()); + Assertions.assertEquals("/path/{/segments}", new UriTemplate("/path/{/segments}").getTemplate()); + Assertions.assertEquals("/list{;item,lang}", new UriTemplate("/list{;item,lang}").getTemplate()); + } + + @Test + public void testInvalidTemplates() { + String[] urls = { "/bad/{id", "/mismatch/{id}/{name" }; + + for (String url : urls) { + Assertions.assertThrows(IllegalArgumentException.class, () -> new UriTemplate(url)); + } + } + + @Test + public void testMatchingTemplates() { + Map templates = Map.of("/users/{id}", "/users/123", "/search{?q}", "/search?q=test", + "/map/{+location}", "/map/NYC", "/list{;item,lang}", "/list;item=book;lang=en"); + + for (Map.Entry entry : templates.entrySet()) { + String template = entry.getKey(); + String url = entry.getValue(); + Assertions.assertTrue(new UriTemplate(template).matchesTemplate(url)); + } + } + + @Test + public void testNonMatchingTemplates() { + Map templates = Map.of("/users/{id}", "/posts/123", "/otherusers/{id}", "/otherusers/", + "/users2/{id}", "/users2", "/map/{+location}", "/map/", "/path/{/segments}", "/path"); + + for (Map.Entry entry : templates.entrySet()) { + String template = entry.getKey(); + String url = entry.getValue(); + Assertions.assertFalse(new UriTemplate(template).matchesTemplate(url)); + } + } + +}