From ebcc519cc8b39d4628c0374c27a77997c37fc7a3 Mon Sep 17 00:00:00 2001 From: Pascal Vantrepote Date: Fri, 28 Feb 2025 15:36:36 -0500 Subject: [PATCH 1/9] Adding support for resource template --- .../server/AbstractMcpSyncServerTests.java | 55 ++ .../server/McpAsyncServer.java | 78 ++- .../server/McpServer.java | 33 +- .../server/McpServerFeatures.java | 99 +++- .../server/McpSyncServer.java | 16 + .../util/UriTemplate.java | 493 ++++++++++++++++++ 6 files changed, 744 insertions(+), 30 deletions(-) create mode 100644 mcp/src/main/java/io/modelcontextprotocol/util/UriTemplate.java 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 f8b957506..0a4a382c7 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 ServerMcpTransport createMcpTransport(); @@ -207,6 +209,24 @@ 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 testAddResourceWithNullRegistration() { var mcpSyncServer = McpServer.sync(createMcpTransport()) @@ -221,6 +241,20 @@ void testAddResourceWithNullRegistration() { 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(createMcpTransport()).serverInfo("test-server", "1.0.0").build(); @@ -234,6 +268,19 @@ 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(createMcpTransport()).serverInfo("test-server", "1.0.0").build(); @@ -242,6 +289,14 @@ 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 7b6916785..cb7a35800 100644 --- a/mcp/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java +++ b/mcp/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java @@ -96,7 +96,7 @@ public class 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<>(); @@ -120,7 +120,7 @@ public class McpAsyncServer { this.serverCapabilities = features.serverCapabilities(); 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<>(); @@ -445,6 +445,59 @@ public Mono removeResource(String resourceUri) { }); } + /** + * Add a new resource handler at runtime. + * @param resourceHandler The resource handler to add + * @return Mono that completes when clients have been notified of the change + */ + public Mono addResourceTemplate(McpServerFeatures.AsyncResourceTemplateRegistration resourceHandler) { + if (resourceHandler == null || resourceHandler.resource() == null) { + return Mono.error(new McpError("Resource 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(resourceHandler.resource().uriTemplate(), resourceHandler) != null) { + return Mono.error(new McpError( + "Resource with URI '" + resourceHandler.resource().uriTemplate() + "' already exists")); + } + logger.debug("Added resource handler: {}", resourceHandler.resource().uriTemplate()); + if (this.serverCapabilities.resources().listChanged()) { + return notifyResourcesListChanged(); + } + return Mono.empty(); + }); + } + + /** + * Remove a resource handler at runtime. + * @param resourceUri The URI of the resource handler to remove + * @return Mono that completes when clients have been notified of the change + */ + public Mono removeResourceTemplate(String resourceUri) { + if (resourceUri == null) { + return Mono.error(new McpError("Resource 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(() -> { + McpServerFeatures.AsyncResourceTemplateRegistration removed = this.resourceTemplates.remove(resourceUri); + if (removed != null) { + logger.debug("Removed resource handler: {}", resourceUri); + if (this.serverCapabilities.resources().listChanged()) { + return notifyResourcesListChanged(); + } + return Mono.empty(); + } + return Mono.error(new McpError("Resource with URI '" + resourceUri + "' not found")); + }); + } + /** * Notifies clients that the list of available resources has changed. * @return A Mono that completes when all clients have been notified @@ -464,20 +517,33 @@ private DefaultMcpSession.RequestHandler resource } private DefaultMcpSession.RequestHandler resourceTemplateListRequestHandler() { - return params -> Mono.just(new McpSchema.ListResourceTemplatesResult(this.resourceTemplates, null)); + return params -> { + var resourceList = this.resourceTemplates.values() + .stream() + .map(McpServerFeatures.AsyncResourceTemplateRegistration::resource) + .toList(); + return Mono.just(new McpSchema.ListResourceTemplatesResult(resourceList, null)); + }; } private DefaultMcpSession.RequestHandler resourcesReadRequestHandler() { return params -> { - McpSchema.ReadResourceRequest resourceRequest = transport.unmarshalFrom(params, - new TypeReference() { - }); + McpSchema.ReadResourceRequest resourceRequest = transport.unmarshalFrom(params, new TypeReference<>() { + }); var resourceUri = resourceRequest.uri(); McpServerFeatures.AsyncResourceRegistration registration = this.resources.get(resourceUri); if (registration != null) { return registration.readHandler().apply(resourceRequest); } + + // Check the resource templates + for (var templateRegistration : this.resourceTemplates.values()) { + if (templateRegistration.uriTemplate().match(resourceUri) != null) { + return templateRegistration.readHandler().apply(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 54c7a28fd..71fcd68aa 100644 --- a/mcp/src/main/java/io/modelcontextprotocol/server/McpServer.java +++ b/mcp/src/main/java/io/modelcontextprotocol/server/McpServer.java @@ -168,7 +168,7 @@ class AsyncSpec { */ 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 @@ -377,10 +377,15 @@ public AsyncSpec resources(McpServerFeatures.AsyncResourceRegistration... resour * @param resourceTemplates List of resource templates. If null, clears existing * templates. * @return This builder instance for method chaining - * @see #resourceTemplates(ResourceTemplate...) + * @see #resourceTemplates(McpServerFeatures.AsyncResourceTemplateRegistration...) */ - public AsyncSpec resourceTemplates(List resourceTemplates) { - this.resourceTemplates.addAll(resourceTemplates); + public AsyncSpec resourceTemplates( + List resourceTemplates) { + Assert.notNull(resourceTemplates, "Resource handlers list must not be null"); + + for (McpServerFeatures.AsyncResourceTemplateRegistration resource : resourceTemplates) { + this.resourceTemplates.put(resource.resource().uriTemplate(), resource); + } return this; } @@ -391,9 +396,9 @@ public AsyncSpec resourceTemplates(List resourceTemplates) { * @return This builder instance for method chaining * @see #resourceTemplates(List) */ - public AsyncSpec resourceTemplates(ResourceTemplate... resourceTemplates) { - for (ResourceTemplate resourceTemplate : resourceTemplates) { - this.resourceTemplates.add(resourceTemplate); + public AsyncSpec resourceTemplates(McpServerFeatures.AsyncResourceTemplateRegistration... resourceTemplates) { + for (McpServerFeatures.AsyncResourceTemplateRegistration resourceTemplate : resourceTemplates) { + this.resourceTemplates.put(resourceTemplate.resource().uriTemplate(), resourceTemplate); } return this; } @@ -546,7 +551,7 @@ class SyncSpec { */ 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 @@ -757,8 +762,10 @@ public SyncSpec resources(McpServerFeatures.SyncResourceRegistration... resource * @return This builder instance for method chaining * @see #resourceTemplates(ResourceTemplate...) */ - public SyncSpec resourceTemplates(List resourceTemplates) { - this.resourceTemplates.addAll(resourceTemplates); + public SyncSpec resourceTemplates(List resourceTemplates) { + for (McpServerFeatures.SyncResourceTemplateRegistration resourceTemplate : resourceTemplates) { + this.resourceTemplates.put(resourceTemplate.resource().uriTemplate(), resourceTemplate); + } return this; } @@ -769,9 +776,9 @@ public SyncSpec resourceTemplates(List resourceTemplates) { * @return This builder instance for method chaining * @see #resourceTemplates(List) */ - public SyncSpec resourceTemplates(ResourceTemplate... resourceTemplates) { - for (ResourceTemplate resourceTemplate : resourceTemplates) { - this.resourceTemplates.add(resourceTemplate); + public SyncSpec resourceTemplates(McpServerFeatures.SyncResourceTemplateRegistration... resourceTemplates) { + for (McpServerFeatures.SyncResourceTemplateRegistration resourceTemplate : resourceTemplates) { + this.resourceTemplates.put(resourceTemplate.resource().uriTemplate(), resourceTemplate); } 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 c8f8399ab..4e05772ca 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; @@ -31,14 +32,14 @@ public class McpServerFeatures { * @param serverCapabilities The server capabilities * @param tools The list of tool registrations * @param resources The map of resource registrations - * @param resourceTemplates The list of resource templates + * @param resourceTemplates The list of resource templates registrations * @param prompts The map of prompt registrations * @param rootsChangeConsumers The list of consumers that will be notified when the * roots list changes */ record Async(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities serverCapabilities, List tools, Map resources, - List resourceTemplates, + Map resourceTemplates, Map prompts, List, Mono>> rootsChangeConsumers) { @@ -48,14 +49,14 @@ record Async(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities s * @param serverCapabilities The server capabilities * @param tools The list of tool registrations * @param resources The map of resource registrations - * @param resourceTemplates The list of resource templates + * @param resourceTemplates The list of resource registrations templates * @param prompts The map of prompt registrations * @param rootsChangeConsumers The list of consumers that will be notified when * the roots list changes */ Async(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities serverCapabilities, List tools, Map resources, - List resourceTemplates, + Map resourceTemplates, Map prompts, List, Mono>> rootsChangeConsumers) { @@ -75,7 +76,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(); } @@ -99,6 +100,11 @@ static Async fromSync(Sync syncSpec) { resources.put(key, AsyncResourceRegistration.fromSync(resource)); }); + Map resourceTemplates = new HashMap<>(); + syncSpec.resourceTemplates().forEach((key, resource) -> { + resourceTemplates.put(key, AsyncResourceTemplateRegistration.fromSync(resource)); + }); + Map prompts = new HashMap<>(); syncSpec.prompts().forEach((key, prompt) -> { prompts.put(key, AsyncPromptRegistration.fromSync(prompt)); @@ -111,8 +117,8 @@ static Async fromSync(Sync syncSpec) { .subscribeOn(Schedulers.boundedElastic())); } - return new Async(syncSpec.serverInfo(), syncSpec.serverCapabilities(), tools, resources, - syncSpec.resourceTemplates(), prompts, rootChangeConsumers); + return new Async(syncSpec.serverInfo(), syncSpec.serverCapabilities(), tools, resources, resourceTemplates, + prompts, rootChangeConsumers); } } @@ -123,7 +129,7 @@ static Async fromSync(Sync syncSpec) { * @param serverCapabilities The server capabilities * @param tools The list of tool registrations * @param resources The map of resource registrations - * @param resourceTemplates The list of resource templates + * @param resourceTemplates The map of resource templates registrations * @param prompts The map of prompt registrations * @param rootsChangeConsumers The list of consumers that will be notified when the * roots list changes @@ -131,7 +137,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) { @@ -149,7 +155,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) { @@ -169,7 +175,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<>(); } @@ -261,6 +267,48 @@ static AsyncResourceRegistration fromSync(SyncResourceRegistration resource) { } } + /** + * Registration of a resource template with its asynchronous handler function. + * Resources provide context to AI models by exposing data such as: + *
    + *
  • File contents + *
  • Database records + *
  • API responses + *
  • System information + *
  • Application state + *
+ * + *

+ * Example resource registration:

{@code
+	 * new McpServerFeatures.AsyncResourceTemplateRegistration(
+	 *     new ResourceTemplate("docs", "Documentation files", "text/markdown"),
+	 *     request -> {
+	 *         String content = readFile(request.getPath());
+	 *         return Mono.just(new ReadResourceResult(content));
+	 *     }
+	 * )
+	 * }
+ * + * @param resource The resource template definition including name, description, and + * MIME type + * @param readHandler The function that handles resource read requests + */ + public record AsyncResourceTemplateRegistration(McpSchema.ResourceTemplate resource, UriTemplate uriTemplate, + Function> readHandler) { + + static AsyncResourceTemplateRegistration fromSync(SyncResourceTemplateRegistration resource) { + // FIXME: This is temporary, proper validation should be implemented + if (resource == null) { + return null; + } + + return new AsyncResourceTemplateRegistration(resource.resource(), + new UriTemplate(resource.resource().uriTemplate()), + req -> Mono.fromCallable(() -> resource.readHandler().apply(req)) + .subscribeOn(Schedulers.boundedElastic())); + } + } + /** * Registration of a prompt template with its asynchronous handler function. Prompts * provide structured templates for AI model interactions, supporting: @@ -369,6 +417,35 @@ public record SyncResourceRegistration(McpSchema.Resource resource, Function readHandler) { } + /** + * Registration of a resource template with its synchronous handler function. + * Resources provide context to AI models by exposing data such as: + *
    + *
  • File contents + *
  • Database records + *
  • API responses + *
  • System information + *
  • Application state + *
+ * + *

+ * Example resource registration:

{@code
+	 * new McpServerFeatures.SyncResourceRegistration(
+	 *     new Resource("docs", "Documentation files", "text/markdown"),
+	 *     request -> {
+	 *         String content = readFile(request.getPath());
+	 *         return new ReadResourceResult(content);
+	 *     }
+	 * )
+	 * }
+ * + * @param resource The resource definition including name, description, and MIME type + * @param readHandler The function that handles resource read requests + */ + public record SyncResourceTemplateRegistration(McpSchema.ResourceTemplate resource, + Function readHandler) { + } + /** * Registration 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 1de0139ba..56e1cc710 100644 --- a/mcp/src/main/java/io/modelcontextprotocol/server/McpSyncServer.java +++ b/mcp/src/main/java/io/modelcontextprotocol/server/McpSyncServer.java @@ -114,6 +114,22 @@ 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.SyncResourceTemplateRegistration resourceHandler) { + this.asyncServer.addResourceTemplate(McpServerFeatures.AsyncResourceTemplateRegistration.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 promptRegistration The prompt registration 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..2cd3354db --- /dev/null +++ b/mcp/src/main/java/io/modelcontextprotocol/util/UriTemplate.java @@ -0,0 +1,493 @@ +package io.modelcontextprotocol.util; + +import java.net.URI; +import java.net.URLEncoder; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import java.nio.charset.StandardCharsets; + +/** + * Implements URI Template handling according to RFC 6570. This class allows for the + * expansion of URI templates with variables and also supports matching URIs against + * templates to extract variables. + *

+ * URI templates are strings with embedded expressions enclosed in curly braces, such as: + * http://example.com/{username}/profile{?tab,section} + */ +public class UriTemplate { + + // Maximum allowed sizes to prevent DoS attacks + private static final int MAX_TEMPLATE_LENGTH = 1000000; // 1MB + + private static final int MAX_VARIABLE_LENGTH = 1000000; // 1MB + + private static final int MAX_TEMPLATE_EXPRESSIONS = 10000; + + private static final int MAX_REGEX_LENGTH = 1000000; // 1MB + + // The original template string + private final String template; + + // Parsed template parts (either strings or TemplatePart objects) + private final List parts; + + /** + * Returns true if the given string contains any URI template expressions. A template + * expression is a sequence of characters enclosed in curly braces, like {foo} or + * {?bar}. + * @param str String to check for template expressions + * @return true if the string contains template expressions, false otherwise + */ + public static boolean isTemplate(String str) { + // Look for any sequence of characters between curly braces + // that isn't just whitespace + return Pattern.compile("\\{[^}\\s]+\\}").matcher(str).find(); + } + + /** + * Validates that a string does not exceed the 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() + ")"); + } + } + + /** + * Creates a new URI template instance. + * @param template The URI template string + * @throws IllegalArgumentException if the template is invalid or too long + */ + public UriTemplate(String template) { + validateLength(template, MAX_TEMPLATE_LENGTH, "Template"); + this.template = template; + this.parts = parse(template); + } + + /** + * Returns the original template string. + */ + @Override + public String toString() { + return template; + } + + /** + * Parses a URI template into a list of literal strings and template parts. + * @param template The URI template to parse + * @return List of parts (Strings for literals, TemplatePart objects for expressions) + * @throws IllegalArgumentException if the template is invalid + */ + private List parse(String template) { + List parts = new ArrayList<>(); + StringBuilder currentText = new StringBuilder(); + int i = 0; + int expressionCount = 0; + + while (i < template.length()) { + if (template.charAt(i) == '{') { + // End current text segment if any + if (!currentText.isEmpty()) { + parts.add(currentText.toString()); + currentText = new StringBuilder(); + } + + // Find closing brace + int end = template.indexOf("}", i); + if (end == -1) + throw new IllegalArgumentException("Unclosed template expression"); + + // Limit number of expressions to prevent DoS + expressionCount++; + if (expressionCount > MAX_TEMPLATE_EXPRESSIONS) { + throw new IllegalArgumentException( + "Template contains too many expressions (max " + MAX_TEMPLATE_EXPRESSIONS + ")"); + } + + // Parse the expression + String expr = template.substring(i + 1, end); + String operator = getOperator(expr); + boolean exploded = expr.contains("*"); + List names = getNames(expr); + String name = names.get(0); + + // Validate variable name length + for (String n : names) { + validateLength(n, MAX_VARIABLE_LENGTH, "Variable name"); + } + + // Add the template part + parts.add(new TemplatePart(name, operator, names, exploded)); + i = end + 1; + } + else { + // Accumulate literal text + currentText.append(template.charAt(i)); + i++; + } + } + + // Add any remaining literal text + if (!currentText.isEmpty()) { + parts.add(currentText.toString()); + } + + return parts; + } + + /** + * Extracts the operator from a template expression. Operators are special characters + * at the beginning of the expression that change how the variables are expanded. + * @param expr The expression (contents inside curly braces) + * @return The operator ("+", "#", ".", "/", "?", "&", or "" if none) + */ + private String getOperator(String expr) { + String[] operators = { "+", "#", ".", "/", "?", "&" }; + for (String op : operators) { + if (expr.startsWith(op)) { + return op; + } + } + return ""; + } + + /** + * Extracts variable names from a template expression. + * @param expr The expression (contents inside curly braces) + * @return List of variable names + */ + private List getNames(String expr) { + String operator = getOperator(expr); + List names = new ArrayList<>(); + + // Split by comma to get multiple variable names + String[] nameParts = expr.substring(operator.length()).split(","); + for (String name : nameParts) { + String trimmed = name.replace("*", "").trim(); + if (!trimmed.isEmpty()) { + names.add(trimmed); + } + } + + return names; + } + + /** + * Encodes a value for inclusion in a URI according to the operator. Different + * operators have different encoding rules. + * @param value The value to encode + * @param operator The operator to determine encoding rules + * @return The encoded value + */ + private String encodeValue(String value, String operator) { + validateLength(value, MAX_VARIABLE_LENGTH, "Variable value"); + try { + if (operator.equals("+") || operator.equals("#")) { + // For + and #, don't encode reserved characters + return URI.create(value).toASCIIString(); + } + // For other operators, fully URL encode the value + // Replace + with %20 to ensure consistent handling of spaces + return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20"); + } + catch (Exception e) { + throw new RuntimeException("Error encoding value: " + value, e); + } + } + + /** + * Expands a single template part using the provided variables. + * @param part The template part to expand + * @param variables Map of variable names to values + * @return The expanded string for this part + */ + private String expandPart(TemplatePart part, Map variables) { + // Handle query parameters (? and & operators) + if (part.operator.equals("?") || part.operator.equals("&")) { + List pairs = new ArrayList<>(); + + for (String name : part.names) { + Object value = variables.get(name); + if (value == null) + continue; + + String encoded; + if (value instanceof List) { + // Handle list values + @SuppressWarnings("unchecked") + List listValue = (List) value; + encoded = listValue.stream() + .map(v -> encodeValue(v, part.operator)) + .collect(Collectors.joining(",")); + } + else { + encoded = encodeValue(value.toString(), part.operator); + } + + pairs.add(name + "=" + encoded); + } + + if (pairs.isEmpty()) + return ""; + + String separator = part.operator.equals("?") ? "?" : "&"; + return separator + String.join("&", pairs); + } + + // Handle multiple variables in one expression + if (part.names.size() > 1) { + List values = new ArrayList<>(); + for (String name : part.names) { + Object value = variables.get(name); + if (value != null) { + if (value instanceof List) { + @SuppressWarnings("unchecked") + List listValue = (List) value; + if (!listValue.isEmpty()) { + values.add(listValue.get(0)); + } + } + else { + values.add(value.toString()); + } + } + } + + if (values.isEmpty()) + return ""; + return String.join(",", values); + } + + // Handle single variable + Object value = variables.get(part.name); + if (value == null) + return ""; + + List values; + if (value instanceof List) { + @SuppressWarnings("unchecked") + List listValue = (List) value; + values = listValue; + } + else { + values = List.of(value.toString()); + } + + List encoded = values.stream().map(v -> encodeValue(v, part.operator)).collect(Collectors.toList()); + + // Format according to operator + return switch (part.operator) { + case "#" -> "#" + String.join(",", encoded); + case "." -> "." + String.join(".", encoded); + case "/" -> "/" + String.join("/", encoded); + default -> String.join(",", encoded); + }; + } + + /** + * Expands the URI template by replacing variables with their values. + * @param variables Map of variable names to values + * @return The expanded URI + */ + public String expand(Map variables) { + StringBuilder result = new StringBuilder(); + boolean hasQueryParam = false; + + for (Object part : parts) { + if (part instanceof String) { + // Literal part + result.append(part); + continue; + } + + // Template part + TemplatePart templatePart = (TemplatePart) part; + String expanded = expandPart(templatePart, variables); + if (expanded.isEmpty()) + continue; + + // Convert ? to & if we already have a query parameter + if ((templatePart.operator.equals("?") || templatePart.operator.equals("&")) && hasQueryParam) { + result.append(expanded.replace("?", "&")); + } + else { + result.append(expanded); + } + + // Track if we've added a query parameter + if (templatePart.operator.equals("?") || templatePart.operator.equals("&")) { + hasQueryParam = true; + } + } + + return result.toString(); + } + + /** + * Escapes special characters in a string for use in a regular expression. + * @param str The string to escape + * @return The escaped string + */ + private String escapeRegExp(String str) { + return Pattern.quote(str); + } + + /** + * Converts a template part to a regular expression pattern for matching. + * @param part The template part + * @return List of pattern information including the regex and variable name + */ + private List partToRegExp(TemplatePart part) { + List patterns = new ArrayList<>(); + + // Validate variable name length for matching + for (String name : part.names) { + validateLength(name, MAX_VARIABLE_LENGTH, "Variable name"); + } + + // Handle query parameters + if (part.operator.equals("?") || part.operator.equals("&")) { + for (int i = 0; i < part.names.size(); i++) { + String name = part.names.get(i); + String prefix = i == 0 ? "\\" + part.operator : "&"; + patterns.add(new PatternInfo(prefix + escapeRegExp(name) + "=([^&]+)", name)); + } + return patterns; + } + + String pattern; + String name = part.name; + + // Create pattern based on operator + pattern = switch (part.operator) { + case "" -> part.exploded ? "([^/]+(?:,[^/]+)*)" : "([^/,]+)"; + case "+", "#" -> "(.+)"; + case "." -> "\\.([^/,]+)"; + case "/" -> "/" + (part.exploded ? "([^/]+(?:,[^/]+)*)" : "([^/,]+)"); + default -> "([^/]+)"; + }; + + patterns.add(new PatternInfo(pattern, name)); + return patterns; + } + + /** + * Matches a URI against this template and extracts variable values. + * @param uri The URI to match + * @return Map of variable names to extracted values, or null if the URI doesn't match + */ + public Map match(String uri) { + validateLength(uri, MAX_TEMPLATE_LENGTH, "URI"); + StringBuilder patternBuilder = new StringBuilder("^"); + List names = new ArrayList<>(); + + // Build regex pattern from template parts + for (Object part : parts) { + if (part instanceof String) { + patternBuilder.append(escapeRegExp((String) part)); + } + else { + TemplatePart templatePart = (TemplatePart) part; + List patterns = partToRegExp(templatePart); + for (PatternInfo patternInfo : patterns) { + patternBuilder.append(patternInfo.pattern); + names.add(new NameInfo(patternInfo.name, templatePart.exploded)); + } + } + } + + patternBuilder.append("$"); + String patternStr = patternBuilder.toString(); + validateLength(patternStr, MAX_REGEX_LENGTH, "Generated regex pattern"); + + // Perform matching + Pattern pattern = Pattern.compile(patternStr); + Matcher matcher = pattern.matcher(uri); + + if (!matcher.matches()) + return null; + + // Extract values from match groups + Map result = new HashMap<>(); + for (int i = 0; i < names.size(); i++) { + NameInfo nameInfo = names.get(i); + String value = matcher.group(i + 1); + String cleanName = nameInfo.name.replace("*", ""); + + // Handle exploded values (comma-separated lists) + if (nameInfo.exploded && value.contains(",")) { + result.put(cleanName, List.of(value.split(","))); + } + else { + result.put(cleanName, value); + } + } + + return result; + } + + /** + * Represents a template expression part with its operator and variables. + */ + private static class TemplatePart { + + final String name; // Primary variable name + + final String operator; // Operator character + + final List names; // All variable names in this expression + + final boolean exploded; // Whether the variable is exploded with * + + TemplatePart(String name, String operator, List names, boolean exploded) { + this.name = name; + this.operator = operator; + this.names = names; + this.exploded = exploded; + } + + } + + /** + * Stores information about a regex pattern for a template part. + */ + private static class PatternInfo { + + final String pattern; // Regex pattern for matching + + final String name; // Variable name to extract + + PatternInfo(String pattern, String name) { + this.pattern = pattern; + this.name = name; + } + + } + + /** + * Stores information about a variable name for matching. + */ + private static class NameInfo { + + final String name; // Variable name + + final boolean exploded; // Whether it's exploded with * + + NameInfo(String name, boolean exploded) { + this.name = name; + this.exploded = exploded; + } + + } + +} \ No newline at end of file From 24ecf041c769235155261f0c03df48d0276edff9 Mon Sep 17 00:00:00 2001 From: Pascal Vantrepote Date: Sat, 1 Mar 2025 07:55:42 -0500 Subject: [PATCH 2/9] code cleanup --- .gitattributes | 1 + .../server/AbstractMcpSyncServerTests.java | 35 +- .../server/McpAsyncServer.java | 2 +- .../server/McpServerFeatures.java | 5 + .../server/McpSyncServer.java | 4 +- .../util/UriTemplate.java | 518 +++++------------- 6 files changed, 167 insertions(+), 398 deletions(-) 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 0a4a382c7..f4de7f77f 100644 --- a/mcp-test/src/main/java/io/modelcontextprotocol/server/AbstractMcpSyncServerTests.java +++ b/mcp-test/src/main/java/io/modelcontextprotocol/server/AbstractMcpSyncServerTests.java @@ -212,13 +212,12 @@ void testAddResource() { @Test void testAddResourceTemplate() { var mcpSyncServer = McpServer.sync(createMcpTransport()) - .serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().resources(true, false).build()) - .build(); + .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); + 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())); @@ -244,13 +243,14 @@ void testAddResourceWithNullRegistration() { @Test void testAddResourceTemplateWithNullRegistration() { var mcpSyncServer = McpServer.sync(createMcpTransport()) - .serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().resources(true, false).build()) - .build(); + .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"); + assertThatThrownBy( + () -> mcpSyncServer.addResourceTemplate((McpServerFeatures.SyncResourceTemplateRegistration) null)) + .isInstanceOf(McpError.class) + .hasMessage("Resource must not be null"); assertThatCode(() -> mcpSyncServer.closeGracefully()).doesNotThrowAnyException(); } @@ -272,13 +272,13 @@ void testAddResourceWithoutCapability() { 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); + 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"); + .hasMessage("Server must be configured with resource capabilities"); } @Test @@ -293,8 +293,9 @@ void testRemoveResourceWithoutCapability() { 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"); + assertThatThrownBy(() -> serverWithoutResources.removeResourceTemplate(TEST_RESOURCE_TEMPLATE_URI)) + .isInstanceOf(McpError.class) + .hasMessage("Server must be configured with resource capabilities"); } // --------------------------------------- diff --git a/mcp/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java b/mcp/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java index cb7a35800..c94ebae77 100644 --- a/mcp/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java +++ b/mcp/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java @@ -539,7 +539,7 @@ private DefaultMcpSession.RequestHandler resources // Check the resource templates for (var templateRegistration : this.resourceTemplates.values()) { - if (templateRegistration.uriTemplate().match(resourceUri) != null) { + if (templateRegistration.uriTemplate().isMatching(resourceUri)) { return templateRegistration.readHandler().apply(resourceRequest); } } diff --git a/mcp/src/main/java/io/modelcontextprotocol/server/McpServerFeatures.java b/mcp/src/main/java/io/modelcontextprotocol/server/McpServerFeatures.java index 4e05772ca..927d2dc19 100644 --- a/mcp/src/main/java/io/modelcontextprotocol/server/McpServerFeatures.java +++ b/mcp/src/main/java/io/modelcontextprotocol/server/McpServerFeatures.java @@ -296,6 +296,11 @@ static AsyncResourceRegistration fromSync(SyncResourceRegistration resource) { public record AsyncResourceTemplateRegistration(McpSchema.ResourceTemplate resource, UriTemplate uriTemplate, Function> readHandler) { + public AsyncResourceTemplateRegistration(McpSchema.ResourceTemplate resource, + Function> readHandler) { + this(resource, new UriTemplate(resource.uriTemplate()), readHandler); + } + static AsyncResourceTemplateRegistration fromSync(SyncResourceTemplateRegistration resource) { // FIXME: This is temporary, proper validation should be implemented if (resource == null) { diff --git a/mcp/src/main/java/io/modelcontextprotocol/server/McpSyncServer.java b/mcp/src/main/java/io/modelcontextprotocol/server/McpSyncServer.java index 56e1cc710..7af40e96c 100644 --- a/mcp/src/main/java/io/modelcontextprotocol/server/McpSyncServer.java +++ b/mcp/src/main/java/io/modelcontextprotocol/server/McpSyncServer.java @@ -119,7 +119,9 @@ public void removeResource(String resourceUri) { * @param resourceHandler The resource handler to add */ public void addResourceTemplate(McpServerFeatures.SyncResourceTemplateRegistration resourceHandler) { - this.asyncServer.addResourceTemplate(McpServerFeatures.AsyncResourceTemplateRegistration.fromSync(resourceHandler)).block(); + this.asyncServer + .addResourceTemplate(McpServerFeatures.AsyncResourceTemplateRegistration.fromSync(resourceHandler)) + .block(); } /** diff --git a/mcp/src/main/java/io/modelcontextprotocol/util/UriTemplate.java b/mcp/src/main/java/io/modelcontextprotocol/util/UriTemplate.java index 2cd3354db..450eec86c 100644 --- a/mcp/src/main/java/io/modelcontextprotocol/util/UriTemplate.java +++ b/mcp/src/main/java/io/modelcontextprotocol/util/UriTemplate.java @@ -2,14 +2,10 @@ import java.net.URI; import java.net.URLEncoder; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; +import java.nio.charset.StandardCharsets; +import java.util.*; import java.util.regex.Matcher; import java.util.regex.Pattern; -import java.util.stream.Collectors; -import java.nio.charset.StandardCharsets; /** * Implements URI Template handling according to RFC 6570. This class allows for the @@ -21,57 +17,33 @@ */ public class UriTemplate { - // Maximum allowed sizes to prevent DoS attacks - private static final int MAX_TEMPLATE_LENGTH = 1000000; // 1MB + // Constants for security and performance limits + private static final int MAX_TEMPLATE_LENGTH = 1_000_000; - private static final int MAX_VARIABLE_LENGTH = 1000000; // 1MB + private static final int MAX_VARIABLE_LENGTH = 1_000_000; - private static final int MAX_TEMPLATE_EXPRESSIONS = 10000; + private static final int MAX_TEMPLATE_EXPRESSIONS = 10_000; - private static final int MAX_REGEX_LENGTH = 1000000; // 1MB + private static final int MAX_REGEX_LENGTH = 1_000_000; - // The original template string + // The original template string and parsed components private final String template; - // Parsed template parts (either strings or TemplatePart objects) private final List parts; - /** - * Returns true if the given string contains any URI template expressions. A template - * expression is a sequence of characters enclosed in curly braces, like {foo} or - * {?bar}. - * @param str String to check for template expressions - * @return true if the string contains template expressions, false otherwise - */ - public static boolean isTemplate(String str) { - // Look for any sequence of characters between curly braces - // that isn't just whitespace - return Pattern.compile("\\{[^}\\s]+\\}").matcher(str).find(); - } + private final Pattern pattern; /** - * Validates that a string does not exceed the 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() + ")"); - } - } - - /** - * Creates a new URI template instance. + * 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) { validateLength(template, MAX_TEMPLATE_LENGTH, "Template"); this.template = template; - this.parts = parse(template); + this.parts = parseTemplate(template); + this.pattern = Pattern.compile(createMatchingPattern()); } /** @@ -83,411 +55,199 @@ public String toString() { } /** - * Parses a URI template into a list of literal strings and template parts. - * @param template The URI template to parse - * @return List of parts (Strings for literals, TemplatePart objects for expressions) - * @throws IllegalArgumentException if the template is invalid + * 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 */ - private List parse(String template) { - List parts = new ArrayList<>(); - StringBuilder currentText = new StringBuilder(); - int i = 0; - int expressionCount = 0; - - while (i < template.length()) { - if (template.charAt(i) == '{') { - // End current text segment if any - if (!currentText.isEmpty()) { - parts.add(currentText.toString()); - currentText = new StringBuilder(); - } - - // Find closing brace - int end = template.indexOf("}", i); - if (end == -1) - throw new IllegalArgumentException("Unclosed template expression"); - - // Limit number of expressions to prevent DoS - expressionCount++; - if (expressionCount > MAX_TEMPLATE_EXPRESSIONS) { - throw new IllegalArgumentException( - "Template contains too many expressions (max " + MAX_TEMPLATE_EXPRESSIONS + ")"); - } - - // Parse the expression - String expr = template.substring(i + 1, end); - String operator = getOperator(expr); - boolean exploded = expr.contains("*"); - List names = getNames(expr); - String name = names.get(0); - - // Validate variable name length - for (String n : names) { - validateLength(n, MAX_VARIABLE_LENGTH, "Variable name"); - } - - // Add the template part - parts.add(new TemplatePart(name, operator, names, exploded)); - i = end + 1; - } - else { - // Accumulate literal text - currentText.append(template.charAt(i)); - i++; - } - } - - // Add any remaining literal text - if (!currentText.isEmpty()) { - parts.add(currentText.toString()); - } - - return parts; + public boolean isMatching(String uri) { + validateLength(uri, MAX_TEMPLATE_LENGTH, "URI"); + return pattern.matcher(uri).matches(); } /** - * Extracts the operator from a template expression. Operators are special characters - * at the beginning of the expression that change how the variables are expanded. - * @param expr The expression (contents inside curly braces) - * @return The operator ("+", "#", ".", "/", "?", "&", or "" if none) + * Matches a URI against this template and extracts variable values. + * @param uri The URI to match + * @return Map of variable names to extracted values, or null if the URI doesn't match */ - private String getOperator(String expr) { - String[] operators = { "+", "#", ".", "/", "?", "&" }; - for (String op : operators) { - if (expr.startsWith(op)) { - return op; - } - } - return ""; - } + public Map match(String uri) { + validateLength(uri, MAX_TEMPLATE_LENGTH, "URI"); + Matcher matcher = pattern.matcher(uri); + if (!matcher.matches()) + return null; - /** - * Extracts variable names from a template expression. - * @param expr The expression (contents inside curly braces) - * @return List of variable names - */ - private List getNames(String expr) { - String operator = getOperator(expr); - List names = new ArrayList<>(); + // Extract variable names from parts and capture their values + List names = extractNamesFromParts(); + Map result = new HashMap<>(); + for (int i = 0; i < names.size(); i++) { + NameInfo nameInfo = names.get(i); + String value = matcher.group(i + 1); + String cleanName = nameInfo.name().replace("*", ""); - // Split by comma to get multiple variable names - String[] nameParts = expr.substring(operator.length()).split(","); - for (String name : nameParts) { - String trimmed = name.replace("*", "").trim(); - if (!trimmed.isEmpty()) { - names.add(trimmed); + // Handle exploded values (comma-separated lists) + if (nameInfo.exploded() && value.contains(",")) { + result.put(cleanName, List.of(value.split(","))); + } + else { + result.put(cleanName, value); } } - - return names; + return result; } /** - * Encodes a value for inclusion in a URI according to the operator. Different - * operators have different encoding rules. - * @param value The value to encode - * @param operator The operator to determine encoding rules - * @return The encoded value + * 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 String encodeValue(String value, String operator) { - validateLength(value, MAX_VARIABLE_LENGTH, "Variable value"); - try { - if (operator.equals("+") || operator.equals("#")) { - // For + and #, don't encode reserved characters - return URI.create(value).toASCIIString(); - } - // For other operators, fully URL encode the value - // Replace + with %20 to ensure consistent handling of spaces - return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20"); - } - catch (Exception e) { - throw new RuntimeException("Error encoding value: " + value, e); + 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() + ")"); } } /** - * Expands a single template part using the provided variables. - * @param part The template part to expand - * @param variables Map of variable names to values - * @return The expanded string for this part + * Parses a URI template into parts consisting of literal strings and template parts. + * @param template The URI template to parse + * @return List of parts (Strings for literals, TemplatePart objects for expressions) */ - private String expandPart(TemplatePart part, Map variables) { - // Handle query parameters (? and & operators) - if (part.operator.equals("?") || part.operator.equals("&")) { - List pairs = new ArrayList<>(); - - for (String name : part.names) { - Object value = variables.get(name); - if (value == null) - continue; - - String encoded; - if (value instanceof List) { - // Handle list values - @SuppressWarnings("unchecked") - List listValue = (List) value; - encoded = listValue.stream() - .map(v -> encodeValue(v, part.operator)) - .collect(Collectors.joining(",")); + private List parseTemplate(String template) { + List parsedParts = new ArrayList<>(); + StringBuilder literal = new StringBuilder(); + int expressionCount = 0; + + // Iteratively parse template into parts + for (int i = 0; i < template.length(); i++) { + if (template.charAt(i) == '{') { + if (!literal.isEmpty()) { + parsedParts.add(literal.toString()); + literal.setLength(0); } - else { - encoded = encodeValue(value.toString(), part.operator); + int end = template.indexOf("}", i); + if (end == -1) + throw new IllegalArgumentException("Unclosed template expression"); + + expressionCount++; + if (expressionCount > MAX_TEMPLATE_EXPRESSIONS) { + throw new IllegalArgumentException("Too many template expressions"); } - pairs.add(name + "=" + encoded); + String expr = template.substring(i + 1, end); + parsedParts.add(parseTemplatePart(expr)); + i = end; } - - if (pairs.isEmpty()) - return ""; - - String separator = part.operator.equals("?") ? "?" : "&"; - return separator + String.join("&", pairs); - } - - // Handle multiple variables in one expression - if (part.names.size() > 1) { - List values = new ArrayList<>(); - for (String name : part.names) { - Object value = variables.get(name); - if (value != null) { - if (value instanceof List) { - @SuppressWarnings("unchecked") - List listValue = (List) value; - if (!listValue.isEmpty()) { - values.add(listValue.get(0)); - } - } - else { - values.add(value.toString()); - } - } + else { + literal.append(template.charAt(i)); } - - if (values.isEmpty()) - return ""; - return String.join(",", values); } + if (!literal.isEmpty()) + parsedParts.add(literal.toString()); - // Handle single variable - Object value = variables.get(part.name); - if (value == null) - return ""; - - List values; - if (value instanceof List) { - @SuppressWarnings("unchecked") - List listValue = (List) value; - values = listValue; - } - else { - values = List.of(value.toString()); - } - - List encoded = values.stream().map(v -> encodeValue(v, part.operator)).collect(Collectors.toList()); - - // Format according to operator - return switch (part.operator) { - case "#" -> "#" + String.join(",", encoded); - case "." -> "." + String.join(".", encoded); - case "/" -> "/" + String.join("/", encoded); - default -> String.join(",", encoded); - }; + return parsedParts; } /** - * Expands the URI template by replacing variables with their values. - * @param variables Map of variable names to values - * @return The expanded URI + * Parses a single template expression into a TemplatePart object. + * @param expr The template expression string + * @return A TemplatePart object representing the expression */ - public String expand(Map variables) { - StringBuilder result = new StringBuilder(); - boolean hasQueryParam = false; + private TemplatePart parseTemplatePart(String expr) { + String operator = extractOperator(expr); + boolean exploded = expr.contains("*"); + List names = extractNames(expr); - for (Object part : parts) { - if (part instanceof String) { - // Literal part - result.append(part); - continue; - } - - // Template part - TemplatePart templatePart = (TemplatePart) part; - String expanded = expandPart(templatePart, variables); - if (expanded.isEmpty()) - continue; - - // Convert ? to & if we already have a query parameter - if ((templatePart.operator.equals("?") || templatePart.operator.equals("&")) && hasQueryParam) { - result.append(expanded.replace("?", "&")); - } - else { - result.append(expanded); - } - - // Track if we've added a query parameter - if (templatePart.operator.equals("?") || templatePart.operator.equals("&")) { - hasQueryParam = true; - } - } + for (String name : names) + validateLength(name, MAX_VARIABLE_LENGTH, "Variable name"); - return result.toString(); + return new TemplatePart(names.get(0), operator, names, exploded); } /** - * Escapes special characters in a string for use in a regular expression. - * @param str The string to escape - * @return The escaped string + * 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 escapeRegExp(String str) { - return Pattern.quote(str); + private String extractOperator(String expr) { + return switch (expr.charAt(0)) { + case '+', '#', '.', '/', '?', '&' -> String.valueOf(expr.charAt(0)); + default -> ""; + }; } /** - * Converts a template part to a regular expression pattern for matching. - * @param part The template part - * @return List of pattern information including the regex and variable name + * Extracts variable names from a template expression. + * @param expr The template expression string + * @return A list of variable names */ - private List partToRegExp(TemplatePart part) { - List patterns = new ArrayList<>(); - - // Validate variable name length for matching - for (String name : part.names) { - validateLength(name, MAX_VARIABLE_LENGTH, "Variable name"); - } - - // Handle query parameters - if (part.operator.equals("?") || part.operator.equals("&")) { - for (int i = 0; i < part.names.size(); i++) { - String name = part.names.get(i); - String prefix = i == 0 ? "\\" + part.operator : "&"; - patterns.add(new PatternInfo(prefix + escapeRegExp(name) + "=([^&]+)", name)); - } - return patterns; + private List extractNames(String expr) { + String[] nameParts = expr.replaceAll("^[+.#/?&]", "").split(","); + List names = new ArrayList<>(); + for (String name : nameParts) { + String trimmed = name.replace("*", "").trim(); + if (!trimmed.isEmpty()) + names.add(trimmed); } - - String pattern; - String name = part.name; - - // Create pattern based on operator - pattern = switch (part.operator) { - case "" -> part.exploded ? "([^/]+(?:,[^/]+)*)" : "([^/,]+)"; - case "+", "#" -> "(.+)"; - case "." -> "\\.([^/,]+)"; - case "/" -> "/" + (part.exploded ? "([^/]+(?:,[^/]+)*)" : "([^/,]+)"); - default -> "([^/]+)"; - }; - - patterns.add(new PatternInfo(pattern, name)); - return patterns; + return names; } /** - * Matches a URI against this template and extracts variable values. - * @param uri The URI to match - * @return Map of variable names to extracted values, or null if the URI doesn't match + * Constructs a regex pattern string to match URIs based on the template parts. + * @return A regex pattern string */ - public Map match(String uri) { - validateLength(uri, MAX_TEMPLATE_LENGTH, "URI"); + private String createMatchingPattern() { StringBuilder patternBuilder = new StringBuilder("^"); - List names = new ArrayList<>(); - - // Build regex pattern from template parts for (Object part : parts) { if (part instanceof String) { - patternBuilder.append(escapeRegExp((String) part)); + patternBuilder.append(Pattern.quote((String) part)); } else { TemplatePart templatePart = (TemplatePart) part; - List patterns = partToRegExp(templatePart); - for (PatternInfo patternInfo : patterns) { - patternBuilder.append(patternInfo.pattern); - names.add(new NameInfo(patternInfo.name, templatePart.exploded)); - } + patternBuilder.append(createPatternForPart(templatePart)); } } - patternBuilder.append("$"); String patternStr = patternBuilder.toString(); validateLength(patternStr, MAX_REGEX_LENGTH, "Generated regex pattern"); - - // Perform matching - Pattern pattern = Pattern.compile(patternStr); - Matcher matcher = pattern.matcher(uri); - - if (!matcher.matches()) - return null; - - // Extract values from match groups - Map result = new HashMap<>(); - for (int i = 0; i < names.size(); i++) { - NameInfo nameInfo = names.get(i); - String value = matcher.group(i + 1); - String cleanName = nameInfo.name.replace("*", ""); - - // Handle exploded values (comma-separated lists) - if (nameInfo.exploded && value.contains(",")) { - result.put(cleanName, List.of(value.split(","))); - } - else { - result.put(cleanName, value); - } - } - - return result; + return patternStr; } /** - * Represents a template expression part with its operator and variables. + * Creates a regex pattern for a specific template part based on its operator. + * @param part The template part + * @return A regex pattern string */ - private static class TemplatePart { - - final String name; // Primary variable name - - final String operator; // Operator character - - final List names; // All variable names in this expression - - final boolean exploded; // Whether the variable is exploded with * - - TemplatePart(String name, String operator, List names, boolean exploded) { - this.name = name; - this.operator = operator; - this.names = names; - this.exploded = exploded; - } - + private String createPatternForPart(TemplatePart part) { + return switch (part.operator()) { + case "", "+" -> part.exploded() ? "([^/]+(?:,[^/]+)*)" : "([^/,]+)"; + case "#" -> "(.+)"; + case "." -> "\\.([^/,]+)"; + case "/" -> "/" + (part.exploded() ? "([^/]+(?:,[^/]+)*)" : "([^/,]+)"); + case "?", "&" -> "\\?" + part.name() + "=([^&]+)"; + default -> "([^/]+)"; + }; } /** - * Stores information about a regex pattern for a template part. + * Extracts variable names from template parts. + * @return A list of NameInfo objects containing variable names and their properties */ - private static class PatternInfo { - - final String pattern; // Regex pattern for matching - - final String name; // Variable name to extract - - PatternInfo(String pattern, String name) { - this.pattern = pattern; - this.name = name; + private List extractNamesFromParts() { + List names = new ArrayList<>(); + for (Object part : parts) { + if (part instanceof TemplatePart templatePart) { + templatePart.names().forEach(name -> names.add(new NameInfo(name, templatePart.exploded()))); + } } - + return names; } - /** - * Stores information about a variable name for matching. - */ - private static class NameInfo { - - final String name; // Variable name - - final boolean exploded; // Whether it's exploded with * - - NameInfo(String name, boolean exploded) { - this.name = name; - this.exploded = exploded; - } + // Record classes for data encapsulation + private record TemplatePart(String name, String operator, List names, boolean exploded) { + } + private record NameInfo(String name, boolean exploded) { } } \ No newline at end of file From 277ac2ee8cf27613e1b3ed444d594960d5f5e6bb Mon Sep 17 00:00:00 2001 From: Pascal Vantrepote Date: Sun, 2 Mar 2025 18:33:12 -0500 Subject: [PATCH 3/9] Code cleanup --- .../util/UriTemplate.java | 83 +++++++++++++++---- 1 file changed, 68 insertions(+), 15 deletions(-) diff --git a/mcp/src/main/java/io/modelcontextprotocol/util/UriTemplate.java b/mcp/src/main/java/io/modelcontextprotocol/util/UriTemplate.java index 450eec86c..08238ef67 100644 --- a/mcp/src/main/java/io/modelcontextprotocol/util/UriTemplate.java +++ b/mcp/src/main/java/io/modelcontextprotocol/util/UriTemplate.java @@ -1,8 +1,5 @@ package io.modelcontextprotocol.util; -import java.net.URI; -import java.net.URLEncoder; -import java.nio.charset.StandardCharsets; import java.util.*; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -29,7 +26,7 @@ public class UriTemplate { // The original template string and parsed components private final String template; - private final List parts; + private final List parts; private final Pattern pattern; @@ -113,8 +110,8 @@ private static void validateLength(String str, int max, String context) { * @param template The URI template to parse * @return List of parts (Strings for literals, TemplatePart objects for expressions) */ - private List parseTemplate(String template) { - List parsedParts = new ArrayList<>(); + private List parseTemplate(String template) { + List parsedParts = new ArrayList<>(); StringBuilder literal = new StringBuilder(); int expressionCount = 0; @@ -122,7 +119,7 @@ private List parseTemplate(String template) { for (int i = 0; i < template.length(); i++) { if (template.charAt(i) == '{') { if (!literal.isEmpty()) { - parsedParts.add(literal.toString()); + parsedParts.add(new Part(literal.toString())); literal.setLength(0); } int end = template.indexOf("}", i); @@ -143,7 +140,7 @@ private List parseTemplate(String template) { } } if (!literal.isEmpty()) - parsedParts.add(literal.toString()); + parsedParts.add(new Part(literal.toString())); return parsedParts; } @@ -219,12 +216,12 @@ private String createMatchingPattern() { * @return A regex pattern string */ private String createPatternForPart(TemplatePart part) { - return switch (part.operator()) { - case "", "+" -> part.exploded() ? "([^/]+(?:,[^/]+)*)" : "([^/,]+)"; + return switch (part.getOperator()) { + case "", "+" -> part.isExploded() ? "([^/]+(?:,[^/]+)*)" : "([^/,]+)"; case "#" -> "(.+)"; case "." -> "\\.([^/,]+)"; - case "/" -> "/" + (part.exploded() ? "([^/]+(?:,[^/]+)*)" : "([^/,]+)"); - case "?", "&" -> "\\?" + part.name() + "=([^&]+)"; + case "/" -> "/" + (part.isExploded() ? "([^/]+(?:,[^/]+)*)" : "([^/,]+)"); + case "?", "&" -> "\\?" + part.getName() + "=([^&]+)"; default -> "([^/]+)"; }; } @@ -237,16 +234,72 @@ private List extractNamesFromParts() { List names = new ArrayList<>(); for (Object part : parts) { if (part instanceof TemplatePart templatePart) { - templatePart.names().forEach(name -> names.add(new NameInfo(name, templatePart.exploded()))); + templatePart.getNames().forEach(name -> names.add(new NameInfo(name, templatePart.isExploded()))); } } return names; } - // Record classes for data encapsulation - private record TemplatePart(String name, String operator, List names, boolean exploded) { + private static class Part { + + private String name; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Part(String name) { + this.name = name; + } + + } + + private static class TemplatePart extends Part { + + private String operator; + + private List names; + + private boolean exploded; + + public String getOperator() { + return operator; + } + + public void setOperator(String operator) { + this.operator = operator; + } + + public List getNames() { + return names; + } + + public void setNames(List names) { + this.names = names; + } + + public boolean isExploded() { + return exploded; + } + + public void setExploded(boolean exploded) { + this.exploded = exploded; + } + + public TemplatePart(String name, String operator, List names, boolean exploded) { + super(name); + this.operator = operator; + this.names = names; + this.exploded = exploded; + } + } + // Record classes for data encapsulation private record NameInfo(String name, boolean exploded) { } From fbcf913570703f9b72c2cd7f2900e0b15e9f0db8 Mon Sep 17 00:00:00 2001 From: Pascal Vantrepote Date: Mon, 3 Mar 2025 06:29:34 -0500 Subject: [PATCH 4/9] Fix cast issue --- .../java/io/modelcontextprotocol/util/UriTemplate.java | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/mcp/src/main/java/io/modelcontextprotocol/util/UriTemplate.java b/mcp/src/main/java/io/modelcontextprotocol/util/UriTemplate.java index 08238ef67..b30373886 100644 --- a/mcp/src/main/java/io/modelcontextprotocol/util/UriTemplate.java +++ b/mcp/src/main/java/io/modelcontextprotocol/util/UriTemplate.java @@ -195,13 +195,12 @@ private List extractNames(String expr) { */ private String createMatchingPattern() { StringBuilder patternBuilder = new StringBuilder("^"); - for (Object part : parts) { - if (part instanceof String) { - patternBuilder.append(Pattern.quote((String) part)); + for (Part part : parts) { + if (part instanceof TemplatePart templatePart) { + patternBuilder.append(createPatternForPart(templatePart)); } else { - TemplatePart templatePart = (TemplatePart) part; - patternBuilder.append(createPatternForPart(templatePart)); + patternBuilder.append(Pattern.quote(part.getName())); } } patternBuilder.append("$"); From c27d9295859d3ebbfb9b2f49ce770e014ec097ce Mon Sep 17 00:00:00 2001 From: Pascal Vantrepote Date: Fri, 28 Feb 2025 15:36:36 -0500 Subject: [PATCH 5/9] Adding support for resource template --- .../server/AbstractMcpSyncServerTests.java | 55 ++ .../server/McpSyncServer.java | 16 + .../util/UriTemplate.java | 493 ++++++++++++++++++ 3 files changed, 564 insertions(+) create mode 100644 mcp/src/main/java/io/modelcontextprotocol/util/UriTemplate.java 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 7846e053b..8eb926846 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,24 @@ 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 +242,20 @@ 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 +271,19 @@ 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 +294,14 @@ 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/McpSyncServer.java b/mcp/src/main/java/io/modelcontextprotocol/server/McpSyncServer.java index 72eba8b86..e316ace0d 100644 --- a/mcp/src/main/java/io/modelcontextprotocol/server/McpSyncServer.java +++ b/mcp/src/main/java/io/modelcontextprotocol/server/McpSyncServer.java @@ -97,6 +97,22 @@ 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.SyncResourceTemplateRegistration resourceHandler) { + this.asyncServer.addResourceTemplate(McpServerFeatures.AsyncResourceTemplateRegistration.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..2cd3354db --- /dev/null +++ b/mcp/src/main/java/io/modelcontextprotocol/util/UriTemplate.java @@ -0,0 +1,493 @@ +package io.modelcontextprotocol.util; + +import java.net.URI; +import java.net.URLEncoder; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import java.nio.charset.StandardCharsets; + +/** + * Implements URI Template handling according to RFC 6570. This class allows for the + * expansion of URI templates with variables and also supports matching URIs against + * templates to extract variables. + *

+ * URI templates are strings with embedded expressions enclosed in curly braces, such as: + * http://example.com/{username}/profile{?tab,section} + */ +public class UriTemplate { + + // Maximum allowed sizes to prevent DoS attacks + private static final int MAX_TEMPLATE_LENGTH = 1000000; // 1MB + + private static final int MAX_VARIABLE_LENGTH = 1000000; // 1MB + + private static final int MAX_TEMPLATE_EXPRESSIONS = 10000; + + private static final int MAX_REGEX_LENGTH = 1000000; // 1MB + + // The original template string + private final String template; + + // Parsed template parts (either strings or TemplatePart objects) + private final List parts; + + /** + * Returns true if the given string contains any URI template expressions. A template + * expression is a sequence of characters enclosed in curly braces, like {foo} or + * {?bar}. + * @param str String to check for template expressions + * @return true if the string contains template expressions, false otherwise + */ + public static boolean isTemplate(String str) { + // Look for any sequence of characters between curly braces + // that isn't just whitespace + return Pattern.compile("\\{[^}\\s]+\\}").matcher(str).find(); + } + + /** + * Validates that a string does not exceed the 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() + ")"); + } + } + + /** + * Creates a new URI template instance. + * @param template The URI template string + * @throws IllegalArgumentException if the template is invalid or too long + */ + public UriTemplate(String template) { + validateLength(template, MAX_TEMPLATE_LENGTH, "Template"); + this.template = template; + this.parts = parse(template); + } + + /** + * Returns the original template string. + */ + @Override + public String toString() { + return template; + } + + /** + * Parses a URI template into a list of literal strings and template parts. + * @param template The URI template to parse + * @return List of parts (Strings for literals, TemplatePart objects for expressions) + * @throws IllegalArgumentException if the template is invalid + */ + private List parse(String template) { + List parts = new ArrayList<>(); + StringBuilder currentText = new StringBuilder(); + int i = 0; + int expressionCount = 0; + + while (i < template.length()) { + if (template.charAt(i) == '{') { + // End current text segment if any + if (!currentText.isEmpty()) { + parts.add(currentText.toString()); + currentText = new StringBuilder(); + } + + // Find closing brace + int end = template.indexOf("}", i); + if (end == -1) + throw new IllegalArgumentException("Unclosed template expression"); + + // Limit number of expressions to prevent DoS + expressionCount++; + if (expressionCount > MAX_TEMPLATE_EXPRESSIONS) { + throw new IllegalArgumentException( + "Template contains too many expressions (max " + MAX_TEMPLATE_EXPRESSIONS + ")"); + } + + // Parse the expression + String expr = template.substring(i + 1, end); + String operator = getOperator(expr); + boolean exploded = expr.contains("*"); + List names = getNames(expr); + String name = names.get(0); + + // Validate variable name length + for (String n : names) { + validateLength(n, MAX_VARIABLE_LENGTH, "Variable name"); + } + + // Add the template part + parts.add(new TemplatePart(name, operator, names, exploded)); + i = end + 1; + } + else { + // Accumulate literal text + currentText.append(template.charAt(i)); + i++; + } + } + + // Add any remaining literal text + if (!currentText.isEmpty()) { + parts.add(currentText.toString()); + } + + return parts; + } + + /** + * Extracts the operator from a template expression. Operators are special characters + * at the beginning of the expression that change how the variables are expanded. + * @param expr The expression (contents inside curly braces) + * @return The operator ("+", "#", ".", "/", "?", "&", or "" if none) + */ + private String getOperator(String expr) { + String[] operators = { "+", "#", ".", "/", "?", "&" }; + for (String op : operators) { + if (expr.startsWith(op)) { + return op; + } + } + return ""; + } + + /** + * Extracts variable names from a template expression. + * @param expr The expression (contents inside curly braces) + * @return List of variable names + */ + private List getNames(String expr) { + String operator = getOperator(expr); + List names = new ArrayList<>(); + + // Split by comma to get multiple variable names + String[] nameParts = expr.substring(operator.length()).split(","); + for (String name : nameParts) { + String trimmed = name.replace("*", "").trim(); + if (!trimmed.isEmpty()) { + names.add(trimmed); + } + } + + return names; + } + + /** + * Encodes a value for inclusion in a URI according to the operator. Different + * operators have different encoding rules. + * @param value The value to encode + * @param operator The operator to determine encoding rules + * @return The encoded value + */ + private String encodeValue(String value, String operator) { + validateLength(value, MAX_VARIABLE_LENGTH, "Variable value"); + try { + if (operator.equals("+") || operator.equals("#")) { + // For + and #, don't encode reserved characters + return URI.create(value).toASCIIString(); + } + // For other operators, fully URL encode the value + // Replace + with %20 to ensure consistent handling of spaces + return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20"); + } + catch (Exception e) { + throw new RuntimeException("Error encoding value: " + value, e); + } + } + + /** + * Expands a single template part using the provided variables. + * @param part The template part to expand + * @param variables Map of variable names to values + * @return The expanded string for this part + */ + private String expandPart(TemplatePart part, Map variables) { + // Handle query parameters (? and & operators) + if (part.operator.equals("?") || part.operator.equals("&")) { + List pairs = new ArrayList<>(); + + for (String name : part.names) { + Object value = variables.get(name); + if (value == null) + continue; + + String encoded; + if (value instanceof List) { + // Handle list values + @SuppressWarnings("unchecked") + List listValue = (List) value; + encoded = listValue.stream() + .map(v -> encodeValue(v, part.operator)) + .collect(Collectors.joining(",")); + } + else { + encoded = encodeValue(value.toString(), part.operator); + } + + pairs.add(name + "=" + encoded); + } + + if (pairs.isEmpty()) + return ""; + + String separator = part.operator.equals("?") ? "?" : "&"; + return separator + String.join("&", pairs); + } + + // Handle multiple variables in one expression + if (part.names.size() > 1) { + List values = new ArrayList<>(); + for (String name : part.names) { + Object value = variables.get(name); + if (value != null) { + if (value instanceof List) { + @SuppressWarnings("unchecked") + List listValue = (List) value; + if (!listValue.isEmpty()) { + values.add(listValue.get(0)); + } + } + else { + values.add(value.toString()); + } + } + } + + if (values.isEmpty()) + return ""; + return String.join(",", values); + } + + // Handle single variable + Object value = variables.get(part.name); + if (value == null) + return ""; + + List values; + if (value instanceof List) { + @SuppressWarnings("unchecked") + List listValue = (List) value; + values = listValue; + } + else { + values = List.of(value.toString()); + } + + List encoded = values.stream().map(v -> encodeValue(v, part.operator)).collect(Collectors.toList()); + + // Format according to operator + return switch (part.operator) { + case "#" -> "#" + String.join(",", encoded); + case "." -> "." + String.join(".", encoded); + case "/" -> "/" + String.join("/", encoded); + default -> String.join(",", encoded); + }; + } + + /** + * Expands the URI template by replacing variables with their values. + * @param variables Map of variable names to values + * @return The expanded URI + */ + public String expand(Map variables) { + StringBuilder result = new StringBuilder(); + boolean hasQueryParam = false; + + for (Object part : parts) { + if (part instanceof String) { + // Literal part + result.append(part); + continue; + } + + // Template part + TemplatePart templatePart = (TemplatePart) part; + String expanded = expandPart(templatePart, variables); + if (expanded.isEmpty()) + continue; + + // Convert ? to & if we already have a query parameter + if ((templatePart.operator.equals("?") || templatePart.operator.equals("&")) && hasQueryParam) { + result.append(expanded.replace("?", "&")); + } + else { + result.append(expanded); + } + + // Track if we've added a query parameter + if (templatePart.operator.equals("?") || templatePart.operator.equals("&")) { + hasQueryParam = true; + } + } + + return result.toString(); + } + + /** + * Escapes special characters in a string for use in a regular expression. + * @param str The string to escape + * @return The escaped string + */ + private String escapeRegExp(String str) { + return Pattern.quote(str); + } + + /** + * Converts a template part to a regular expression pattern for matching. + * @param part The template part + * @return List of pattern information including the regex and variable name + */ + private List partToRegExp(TemplatePart part) { + List patterns = new ArrayList<>(); + + // Validate variable name length for matching + for (String name : part.names) { + validateLength(name, MAX_VARIABLE_LENGTH, "Variable name"); + } + + // Handle query parameters + if (part.operator.equals("?") || part.operator.equals("&")) { + for (int i = 0; i < part.names.size(); i++) { + String name = part.names.get(i); + String prefix = i == 0 ? "\\" + part.operator : "&"; + patterns.add(new PatternInfo(prefix + escapeRegExp(name) + "=([^&]+)", name)); + } + return patterns; + } + + String pattern; + String name = part.name; + + // Create pattern based on operator + pattern = switch (part.operator) { + case "" -> part.exploded ? "([^/]+(?:,[^/]+)*)" : "([^/,]+)"; + case "+", "#" -> "(.+)"; + case "." -> "\\.([^/,]+)"; + case "/" -> "/" + (part.exploded ? "([^/]+(?:,[^/]+)*)" : "([^/,]+)"); + default -> "([^/]+)"; + }; + + patterns.add(new PatternInfo(pattern, name)); + return patterns; + } + + /** + * Matches a URI against this template and extracts variable values. + * @param uri The URI to match + * @return Map of variable names to extracted values, or null if the URI doesn't match + */ + public Map match(String uri) { + validateLength(uri, MAX_TEMPLATE_LENGTH, "URI"); + StringBuilder patternBuilder = new StringBuilder("^"); + List names = new ArrayList<>(); + + // Build regex pattern from template parts + for (Object part : parts) { + if (part instanceof String) { + patternBuilder.append(escapeRegExp((String) part)); + } + else { + TemplatePart templatePart = (TemplatePart) part; + List patterns = partToRegExp(templatePart); + for (PatternInfo patternInfo : patterns) { + patternBuilder.append(patternInfo.pattern); + names.add(new NameInfo(patternInfo.name, templatePart.exploded)); + } + } + } + + patternBuilder.append("$"); + String patternStr = patternBuilder.toString(); + validateLength(patternStr, MAX_REGEX_LENGTH, "Generated regex pattern"); + + // Perform matching + Pattern pattern = Pattern.compile(patternStr); + Matcher matcher = pattern.matcher(uri); + + if (!matcher.matches()) + return null; + + // Extract values from match groups + Map result = new HashMap<>(); + for (int i = 0; i < names.size(); i++) { + NameInfo nameInfo = names.get(i); + String value = matcher.group(i + 1); + String cleanName = nameInfo.name.replace("*", ""); + + // Handle exploded values (comma-separated lists) + if (nameInfo.exploded && value.contains(",")) { + result.put(cleanName, List.of(value.split(","))); + } + else { + result.put(cleanName, value); + } + } + + return result; + } + + /** + * Represents a template expression part with its operator and variables. + */ + private static class TemplatePart { + + final String name; // Primary variable name + + final String operator; // Operator character + + final List names; // All variable names in this expression + + final boolean exploded; // Whether the variable is exploded with * + + TemplatePart(String name, String operator, List names, boolean exploded) { + this.name = name; + this.operator = operator; + this.names = names; + this.exploded = exploded; + } + + } + + /** + * Stores information about a regex pattern for a template part. + */ + private static class PatternInfo { + + final String pattern; // Regex pattern for matching + + final String name; // Variable name to extract + + PatternInfo(String pattern, String name) { + this.pattern = pattern; + this.name = name; + } + + } + + /** + * Stores information about a variable name for matching. + */ + private static class NameInfo { + + final String name; // Variable name + + final boolean exploded; // Whether it's exploded with * + + NameInfo(String name, boolean exploded) { + this.name = name; + this.exploded = exploded; + } + + } + +} \ No newline at end of file From 08186223f195b97742710d0103dec404e9269cca Mon Sep 17 00:00:00 2001 From: Pascal Vantrepote Date: Sat, 1 Mar 2025 07:55:42 -0500 Subject: [PATCH 6/9] code cleanup --- .gitattributes | 1 + .../server/AbstractMcpSyncServerTests.java | 35 +- .../server/McpSyncServer.java | 4 +- .../util/UriTemplate.java | 518 +++++------------- 4 files changed, 161 insertions(+), 397 deletions(-) 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 8eb926846..905cdd540 100644 --- a/mcp-test/src/main/java/io/modelcontextprotocol/server/AbstractMcpSyncServerTests.java +++ b/mcp-test/src/main/java/io/modelcontextprotocol/server/AbstractMcpSyncServerTests.java @@ -213,13 +213,12 @@ void testAddResource() { @Test void testAddResourceTemplate() { var mcpSyncServer = McpServer.sync(createMcpTransport()) - .serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().resources(true, false).build()) - .build(); + .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); + 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())); @@ -245,13 +244,14 @@ void testAddResourceWithNullSpecifiation() { @Test void testAddResourceTemplateWithNullRegistration() { var mcpSyncServer = McpServer.sync(createMcpTransport()) - .serverInfo("test-server", "1.0.0") - .capabilities(ServerCapabilities.builder().resources(true, false).build()) - .build(); + .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"); + assertThatThrownBy( + () -> mcpSyncServer.addResourceTemplate((McpServerFeatures.SyncResourceTemplateRegistration) null)) + .isInstanceOf(McpError.class) + .hasMessage("Resource must not be null"); assertThatCode(() -> mcpSyncServer.closeGracefully()).doesNotThrowAnyException(); } @@ -275,13 +275,13 @@ void testAddResourceWithoutCapability() { 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); + 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"); + .hasMessage("Server must be configured with resource capabilities"); } @Test @@ -298,8 +298,9 @@ void testRemoveResourceWithoutCapability() { 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"); + assertThatThrownBy(() -> serverWithoutResources.removeResourceTemplate(TEST_RESOURCE_TEMPLATE_URI)) + .isInstanceOf(McpError.class) + .hasMessage("Server must be configured with resource capabilities"); } // --------------------------------------- diff --git a/mcp/src/main/java/io/modelcontextprotocol/server/McpSyncServer.java b/mcp/src/main/java/io/modelcontextprotocol/server/McpSyncServer.java index e316ace0d..9192c4028 100644 --- a/mcp/src/main/java/io/modelcontextprotocol/server/McpSyncServer.java +++ b/mcp/src/main/java/io/modelcontextprotocol/server/McpSyncServer.java @@ -102,7 +102,9 @@ public void removeResource(String resourceUri) { * @param resourceHandler The resource handler to add */ public void addResourceTemplate(McpServerFeatures.SyncResourceTemplateRegistration resourceHandler) { - this.asyncServer.addResourceTemplate(McpServerFeatures.AsyncResourceTemplateRegistration.fromSync(resourceHandler)).block(); + this.asyncServer + .addResourceTemplate(McpServerFeatures.AsyncResourceTemplateRegistration.fromSync(resourceHandler)) + .block(); } /** diff --git a/mcp/src/main/java/io/modelcontextprotocol/util/UriTemplate.java b/mcp/src/main/java/io/modelcontextprotocol/util/UriTemplate.java index 2cd3354db..450eec86c 100644 --- a/mcp/src/main/java/io/modelcontextprotocol/util/UriTemplate.java +++ b/mcp/src/main/java/io/modelcontextprotocol/util/UriTemplate.java @@ -2,14 +2,10 @@ import java.net.URI; import java.net.URLEncoder; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; +import java.nio.charset.StandardCharsets; +import java.util.*; import java.util.regex.Matcher; import java.util.regex.Pattern; -import java.util.stream.Collectors; -import java.nio.charset.StandardCharsets; /** * Implements URI Template handling according to RFC 6570. This class allows for the @@ -21,57 +17,33 @@ */ public class UriTemplate { - // Maximum allowed sizes to prevent DoS attacks - private static final int MAX_TEMPLATE_LENGTH = 1000000; // 1MB + // Constants for security and performance limits + private static final int MAX_TEMPLATE_LENGTH = 1_000_000; - private static final int MAX_VARIABLE_LENGTH = 1000000; // 1MB + private static final int MAX_VARIABLE_LENGTH = 1_000_000; - private static final int MAX_TEMPLATE_EXPRESSIONS = 10000; + private static final int MAX_TEMPLATE_EXPRESSIONS = 10_000; - private static final int MAX_REGEX_LENGTH = 1000000; // 1MB + private static final int MAX_REGEX_LENGTH = 1_000_000; - // The original template string + // The original template string and parsed components private final String template; - // Parsed template parts (either strings or TemplatePart objects) private final List parts; - /** - * Returns true if the given string contains any URI template expressions. A template - * expression is a sequence of characters enclosed in curly braces, like {foo} or - * {?bar}. - * @param str String to check for template expressions - * @return true if the string contains template expressions, false otherwise - */ - public static boolean isTemplate(String str) { - // Look for any sequence of characters between curly braces - // that isn't just whitespace - return Pattern.compile("\\{[^}\\s]+\\}").matcher(str).find(); - } + private final Pattern pattern; /** - * Validates that a string does not exceed the 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() + ")"); - } - } - - /** - * Creates a new URI template instance. + * 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) { validateLength(template, MAX_TEMPLATE_LENGTH, "Template"); this.template = template; - this.parts = parse(template); + this.parts = parseTemplate(template); + this.pattern = Pattern.compile(createMatchingPattern()); } /** @@ -83,411 +55,199 @@ public String toString() { } /** - * Parses a URI template into a list of literal strings and template parts. - * @param template The URI template to parse - * @return List of parts (Strings for literals, TemplatePart objects for expressions) - * @throws IllegalArgumentException if the template is invalid + * 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 */ - private List parse(String template) { - List parts = new ArrayList<>(); - StringBuilder currentText = new StringBuilder(); - int i = 0; - int expressionCount = 0; - - while (i < template.length()) { - if (template.charAt(i) == '{') { - // End current text segment if any - if (!currentText.isEmpty()) { - parts.add(currentText.toString()); - currentText = new StringBuilder(); - } - - // Find closing brace - int end = template.indexOf("}", i); - if (end == -1) - throw new IllegalArgumentException("Unclosed template expression"); - - // Limit number of expressions to prevent DoS - expressionCount++; - if (expressionCount > MAX_TEMPLATE_EXPRESSIONS) { - throw new IllegalArgumentException( - "Template contains too many expressions (max " + MAX_TEMPLATE_EXPRESSIONS + ")"); - } - - // Parse the expression - String expr = template.substring(i + 1, end); - String operator = getOperator(expr); - boolean exploded = expr.contains("*"); - List names = getNames(expr); - String name = names.get(0); - - // Validate variable name length - for (String n : names) { - validateLength(n, MAX_VARIABLE_LENGTH, "Variable name"); - } - - // Add the template part - parts.add(new TemplatePart(name, operator, names, exploded)); - i = end + 1; - } - else { - // Accumulate literal text - currentText.append(template.charAt(i)); - i++; - } - } - - // Add any remaining literal text - if (!currentText.isEmpty()) { - parts.add(currentText.toString()); - } - - return parts; + public boolean isMatching(String uri) { + validateLength(uri, MAX_TEMPLATE_LENGTH, "URI"); + return pattern.matcher(uri).matches(); } /** - * Extracts the operator from a template expression. Operators are special characters - * at the beginning of the expression that change how the variables are expanded. - * @param expr The expression (contents inside curly braces) - * @return The operator ("+", "#", ".", "/", "?", "&", or "" if none) + * Matches a URI against this template and extracts variable values. + * @param uri The URI to match + * @return Map of variable names to extracted values, or null if the URI doesn't match */ - private String getOperator(String expr) { - String[] operators = { "+", "#", ".", "/", "?", "&" }; - for (String op : operators) { - if (expr.startsWith(op)) { - return op; - } - } - return ""; - } + public Map match(String uri) { + validateLength(uri, MAX_TEMPLATE_LENGTH, "URI"); + Matcher matcher = pattern.matcher(uri); + if (!matcher.matches()) + return null; - /** - * Extracts variable names from a template expression. - * @param expr The expression (contents inside curly braces) - * @return List of variable names - */ - private List getNames(String expr) { - String operator = getOperator(expr); - List names = new ArrayList<>(); + // Extract variable names from parts and capture their values + List names = extractNamesFromParts(); + Map result = new HashMap<>(); + for (int i = 0; i < names.size(); i++) { + NameInfo nameInfo = names.get(i); + String value = matcher.group(i + 1); + String cleanName = nameInfo.name().replace("*", ""); - // Split by comma to get multiple variable names - String[] nameParts = expr.substring(operator.length()).split(","); - for (String name : nameParts) { - String trimmed = name.replace("*", "").trim(); - if (!trimmed.isEmpty()) { - names.add(trimmed); + // Handle exploded values (comma-separated lists) + if (nameInfo.exploded() && value.contains(",")) { + result.put(cleanName, List.of(value.split(","))); + } + else { + result.put(cleanName, value); } } - - return names; + return result; } /** - * Encodes a value for inclusion in a URI according to the operator. Different - * operators have different encoding rules. - * @param value The value to encode - * @param operator The operator to determine encoding rules - * @return The encoded value + * 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 String encodeValue(String value, String operator) { - validateLength(value, MAX_VARIABLE_LENGTH, "Variable value"); - try { - if (operator.equals("+") || operator.equals("#")) { - // For + and #, don't encode reserved characters - return URI.create(value).toASCIIString(); - } - // For other operators, fully URL encode the value - // Replace + with %20 to ensure consistent handling of spaces - return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20"); - } - catch (Exception e) { - throw new RuntimeException("Error encoding value: " + value, e); + 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() + ")"); } } /** - * Expands a single template part using the provided variables. - * @param part The template part to expand - * @param variables Map of variable names to values - * @return The expanded string for this part + * Parses a URI template into parts consisting of literal strings and template parts. + * @param template The URI template to parse + * @return List of parts (Strings for literals, TemplatePart objects for expressions) */ - private String expandPart(TemplatePart part, Map variables) { - // Handle query parameters (? and & operators) - if (part.operator.equals("?") || part.operator.equals("&")) { - List pairs = new ArrayList<>(); - - for (String name : part.names) { - Object value = variables.get(name); - if (value == null) - continue; - - String encoded; - if (value instanceof List) { - // Handle list values - @SuppressWarnings("unchecked") - List listValue = (List) value; - encoded = listValue.stream() - .map(v -> encodeValue(v, part.operator)) - .collect(Collectors.joining(",")); + private List parseTemplate(String template) { + List parsedParts = new ArrayList<>(); + StringBuilder literal = new StringBuilder(); + int expressionCount = 0; + + // Iteratively parse template into parts + for (int i = 0; i < template.length(); i++) { + if (template.charAt(i) == '{') { + if (!literal.isEmpty()) { + parsedParts.add(literal.toString()); + literal.setLength(0); } - else { - encoded = encodeValue(value.toString(), part.operator); + int end = template.indexOf("}", i); + if (end == -1) + throw new IllegalArgumentException("Unclosed template expression"); + + expressionCount++; + if (expressionCount > MAX_TEMPLATE_EXPRESSIONS) { + throw new IllegalArgumentException("Too many template expressions"); } - pairs.add(name + "=" + encoded); + String expr = template.substring(i + 1, end); + parsedParts.add(parseTemplatePart(expr)); + i = end; } - - if (pairs.isEmpty()) - return ""; - - String separator = part.operator.equals("?") ? "?" : "&"; - return separator + String.join("&", pairs); - } - - // Handle multiple variables in one expression - if (part.names.size() > 1) { - List values = new ArrayList<>(); - for (String name : part.names) { - Object value = variables.get(name); - if (value != null) { - if (value instanceof List) { - @SuppressWarnings("unchecked") - List listValue = (List) value; - if (!listValue.isEmpty()) { - values.add(listValue.get(0)); - } - } - else { - values.add(value.toString()); - } - } + else { + literal.append(template.charAt(i)); } - - if (values.isEmpty()) - return ""; - return String.join(",", values); } + if (!literal.isEmpty()) + parsedParts.add(literal.toString()); - // Handle single variable - Object value = variables.get(part.name); - if (value == null) - return ""; - - List values; - if (value instanceof List) { - @SuppressWarnings("unchecked") - List listValue = (List) value; - values = listValue; - } - else { - values = List.of(value.toString()); - } - - List encoded = values.stream().map(v -> encodeValue(v, part.operator)).collect(Collectors.toList()); - - // Format according to operator - return switch (part.operator) { - case "#" -> "#" + String.join(",", encoded); - case "." -> "." + String.join(".", encoded); - case "/" -> "/" + String.join("/", encoded); - default -> String.join(",", encoded); - }; + return parsedParts; } /** - * Expands the URI template by replacing variables with their values. - * @param variables Map of variable names to values - * @return The expanded URI + * Parses a single template expression into a TemplatePart object. + * @param expr The template expression string + * @return A TemplatePart object representing the expression */ - public String expand(Map variables) { - StringBuilder result = new StringBuilder(); - boolean hasQueryParam = false; + private TemplatePart parseTemplatePart(String expr) { + String operator = extractOperator(expr); + boolean exploded = expr.contains("*"); + List names = extractNames(expr); - for (Object part : parts) { - if (part instanceof String) { - // Literal part - result.append(part); - continue; - } - - // Template part - TemplatePart templatePart = (TemplatePart) part; - String expanded = expandPart(templatePart, variables); - if (expanded.isEmpty()) - continue; - - // Convert ? to & if we already have a query parameter - if ((templatePart.operator.equals("?") || templatePart.operator.equals("&")) && hasQueryParam) { - result.append(expanded.replace("?", "&")); - } - else { - result.append(expanded); - } - - // Track if we've added a query parameter - if (templatePart.operator.equals("?") || templatePart.operator.equals("&")) { - hasQueryParam = true; - } - } + for (String name : names) + validateLength(name, MAX_VARIABLE_LENGTH, "Variable name"); - return result.toString(); + return new TemplatePart(names.get(0), operator, names, exploded); } /** - * Escapes special characters in a string for use in a regular expression. - * @param str The string to escape - * @return The escaped string + * 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 escapeRegExp(String str) { - return Pattern.quote(str); + private String extractOperator(String expr) { + return switch (expr.charAt(0)) { + case '+', '#', '.', '/', '?', '&' -> String.valueOf(expr.charAt(0)); + default -> ""; + }; } /** - * Converts a template part to a regular expression pattern for matching. - * @param part The template part - * @return List of pattern information including the regex and variable name + * Extracts variable names from a template expression. + * @param expr The template expression string + * @return A list of variable names */ - private List partToRegExp(TemplatePart part) { - List patterns = new ArrayList<>(); - - // Validate variable name length for matching - for (String name : part.names) { - validateLength(name, MAX_VARIABLE_LENGTH, "Variable name"); - } - - // Handle query parameters - if (part.operator.equals("?") || part.operator.equals("&")) { - for (int i = 0; i < part.names.size(); i++) { - String name = part.names.get(i); - String prefix = i == 0 ? "\\" + part.operator : "&"; - patterns.add(new PatternInfo(prefix + escapeRegExp(name) + "=([^&]+)", name)); - } - return patterns; + private List extractNames(String expr) { + String[] nameParts = expr.replaceAll("^[+.#/?&]", "").split(","); + List names = new ArrayList<>(); + for (String name : nameParts) { + String trimmed = name.replace("*", "").trim(); + if (!trimmed.isEmpty()) + names.add(trimmed); } - - String pattern; - String name = part.name; - - // Create pattern based on operator - pattern = switch (part.operator) { - case "" -> part.exploded ? "([^/]+(?:,[^/]+)*)" : "([^/,]+)"; - case "+", "#" -> "(.+)"; - case "." -> "\\.([^/,]+)"; - case "/" -> "/" + (part.exploded ? "([^/]+(?:,[^/]+)*)" : "([^/,]+)"); - default -> "([^/]+)"; - }; - - patterns.add(new PatternInfo(pattern, name)); - return patterns; + return names; } /** - * Matches a URI against this template and extracts variable values. - * @param uri The URI to match - * @return Map of variable names to extracted values, or null if the URI doesn't match + * Constructs a regex pattern string to match URIs based on the template parts. + * @return A regex pattern string */ - public Map match(String uri) { - validateLength(uri, MAX_TEMPLATE_LENGTH, "URI"); + private String createMatchingPattern() { StringBuilder patternBuilder = new StringBuilder("^"); - List names = new ArrayList<>(); - - // Build regex pattern from template parts for (Object part : parts) { if (part instanceof String) { - patternBuilder.append(escapeRegExp((String) part)); + patternBuilder.append(Pattern.quote((String) part)); } else { TemplatePart templatePart = (TemplatePart) part; - List patterns = partToRegExp(templatePart); - for (PatternInfo patternInfo : patterns) { - patternBuilder.append(patternInfo.pattern); - names.add(new NameInfo(patternInfo.name, templatePart.exploded)); - } + patternBuilder.append(createPatternForPart(templatePart)); } } - patternBuilder.append("$"); String patternStr = patternBuilder.toString(); validateLength(patternStr, MAX_REGEX_LENGTH, "Generated regex pattern"); - - // Perform matching - Pattern pattern = Pattern.compile(patternStr); - Matcher matcher = pattern.matcher(uri); - - if (!matcher.matches()) - return null; - - // Extract values from match groups - Map result = new HashMap<>(); - for (int i = 0; i < names.size(); i++) { - NameInfo nameInfo = names.get(i); - String value = matcher.group(i + 1); - String cleanName = nameInfo.name.replace("*", ""); - - // Handle exploded values (comma-separated lists) - if (nameInfo.exploded && value.contains(",")) { - result.put(cleanName, List.of(value.split(","))); - } - else { - result.put(cleanName, value); - } - } - - return result; + return patternStr; } /** - * Represents a template expression part with its operator and variables. + * Creates a regex pattern for a specific template part based on its operator. + * @param part The template part + * @return A regex pattern string */ - private static class TemplatePart { - - final String name; // Primary variable name - - final String operator; // Operator character - - final List names; // All variable names in this expression - - final boolean exploded; // Whether the variable is exploded with * - - TemplatePart(String name, String operator, List names, boolean exploded) { - this.name = name; - this.operator = operator; - this.names = names; - this.exploded = exploded; - } - + private String createPatternForPart(TemplatePart part) { + return switch (part.operator()) { + case "", "+" -> part.exploded() ? "([^/]+(?:,[^/]+)*)" : "([^/,]+)"; + case "#" -> "(.+)"; + case "." -> "\\.([^/,]+)"; + case "/" -> "/" + (part.exploded() ? "([^/]+(?:,[^/]+)*)" : "([^/,]+)"); + case "?", "&" -> "\\?" + part.name() + "=([^&]+)"; + default -> "([^/]+)"; + }; } /** - * Stores information about a regex pattern for a template part. + * Extracts variable names from template parts. + * @return A list of NameInfo objects containing variable names and their properties */ - private static class PatternInfo { - - final String pattern; // Regex pattern for matching - - final String name; // Variable name to extract - - PatternInfo(String pattern, String name) { - this.pattern = pattern; - this.name = name; + private List extractNamesFromParts() { + List names = new ArrayList<>(); + for (Object part : parts) { + if (part instanceof TemplatePart templatePart) { + templatePart.names().forEach(name -> names.add(new NameInfo(name, templatePart.exploded()))); + } } - + return names; } - /** - * Stores information about a variable name for matching. - */ - private static class NameInfo { - - final String name; // Variable name - - final boolean exploded; // Whether it's exploded with * - - NameInfo(String name, boolean exploded) { - this.name = name; - this.exploded = exploded; - } + // Record classes for data encapsulation + private record TemplatePart(String name, String operator, List names, boolean exploded) { + } + private record NameInfo(String name, boolean exploded) { } } \ No newline at end of file From 53e0f122763b23064a33dcaed76afa6535cec91a Mon Sep 17 00:00:00 2001 From: Pascal Vantrepote Date: Sun, 2 Mar 2025 18:33:12 -0500 Subject: [PATCH 7/9] Code cleanup --- .../util/UriTemplate.java | 83 +++++++++++++++---- 1 file changed, 68 insertions(+), 15 deletions(-) diff --git a/mcp/src/main/java/io/modelcontextprotocol/util/UriTemplate.java b/mcp/src/main/java/io/modelcontextprotocol/util/UriTemplate.java index 450eec86c..08238ef67 100644 --- a/mcp/src/main/java/io/modelcontextprotocol/util/UriTemplate.java +++ b/mcp/src/main/java/io/modelcontextprotocol/util/UriTemplate.java @@ -1,8 +1,5 @@ package io.modelcontextprotocol.util; -import java.net.URI; -import java.net.URLEncoder; -import java.nio.charset.StandardCharsets; import java.util.*; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -29,7 +26,7 @@ public class UriTemplate { // The original template string and parsed components private final String template; - private final List parts; + private final List parts; private final Pattern pattern; @@ -113,8 +110,8 @@ private static void validateLength(String str, int max, String context) { * @param template The URI template to parse * @return List of parts (Strings for literals, TemplatePart objects for expressions) */ - private List parseTemplate(String template) { - List parsedParts = new ArrayList<>(); + private List parseTemplate(String template) { + List parsedParts = new ArrayList<>(); StringBuilder literal = new StringBuilder(); int expressionCount = 0; @@ -122,7 +119,7 @@ private List parseTemplate(String template) { for (int i = 0; i < template.length(); i++) { if (template.charAt(i) == '{') { if (!literal.isEmpty()) { - parsedParts.add(literal.toString()); + parsedParts.add(new Part(literal.toString())); literal.setLength(0); } int end = template.indexOf("}", i); @@ -143,7 +140,7 @@ private List parseTemplate(String template) { } } if (!literal.isEmpty()) - parsedParts.add(literal.toString()); + parsedParts.add(new Part(literal.toString())); return parsedParts; } @@ -219,12 +216,12 @@ private String createMatchingPattern() { * @return A regex pattern string */ private String createPatternForPart(TemplatePart part) { - return switch (part.operator()) { - case "", "+" -> part.exploded() ? "([^/]+(?:,[^/]+)*)" : "([^/,]+)"; + return switch (part.getOperator()) { + case "", "+" -> part.isExploded() ? "([^/]+(?:,[^/]+)*)" : "([^/,]+)"; case "#" -> "(.+)"; case "." -> "\\.([^/,]+)"; - case "/" -> "/" + (part.exploded() ? "([^/]+(?:,[^/]+)*)" : "([^/,]+)"); - case "?", "&" -> "\\?" + part.name() + "=([^&]+)"; + case "/" -> "/" + (part.isExploded() ? "([^/]+(?:,[^/]+)*)" : "([^/,]+)"); + case "?", "&" -> "\\?" + part.getName() + "=([^&]+)"; default -> "([^/]+)"; }; } @@ -237,16 +234,72 @@ private List extractNamesFromParts() { List names = new ArrayList<>(); for (Object part : parts) { if (part instanceof TemplatePart templatePart) { - templatePart.names().forEach(name -> names.add(new NameInfo(name, templatePart.exploded()))); + templatePart.getNames().forEach(name -> names.add(new NameInfo(name, templatePart.isExploded()))); } } return names; } - // Record classes for data encapsulation - private record TemplatePart(String name, String operator, List names, boolean exploded) { + private static class Part { + + private String name; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Part(String name) { + this.name = name; + } + + } + + private static class TemplatePart extends Part { + + private String operator; + + private List names; + + private boolean exploded; + + public String getOperator() { + return operator; + } + + public void setOperator(String operator) { + this.operator = operator; + } + + public List getNames() { + return names; + } + + public void setNames(List names) { + this.names = names; + } + + public boolean isExploded() { + return exploded; + } + + public void setExploded(boolean exploded) { + this.exploded = exploded; + } + + public TemplatePart(String name, String operator, List names, boolean exploded) { + super(name); + this.operator = operator; + this.names = names; + this.exploded = exploded; + } + } + // Record classes for data encapsulation private record NameInfo(String name, boolean exploded) { } From 0b61135485e136c0760ab6b62d81ba57cf83ff74 Mon Sep 17 00:00:00 2001 From: Pascal Vantrepote Date: Mon, 3 Mar 2025 06:29:34 -0500 Subject: [PATCH 8/9] Fix cast issue --- .../java/io/modelcontextprotocol/util/UriTemplate.java | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/mcp/src/main/java/io/modelcontextprotocol/util/UriTemplate.java b/mcp/src/main/java/io/modelcontextprotocol/util/UriTemplate.java index 08238ef67..b30373886 100644 --- a/mcp/src/main/java/io/modelcontextprotocol/util/UriTemplate.java +++ b/mcp/src/main/java/io/modelcontextprotocol/util/UriTemplate.java @@ -195,13 +195,12 @@ private List extractNames(String expr) { */ private String createMatchingPattern() { StringBuilder patternBuilder = new StringBuilder("^"); - for (Object part : parts) { - if (part instanceof String) { - patternBuilder.append(Pattern.quote((String) part)); + for (Part part : parts) { + if (part instanceof TemplatePart templatePart) { + patternBuilder.append(createPatternForPart(templatePart)); } else { - TemplatePart templatePart = (TemplatePart) part; - patternBuilder.append(createPatternForPart(templatePart)); + patternBuilder.append(Pattern.quote(part.getName())); } } patternBuilder.append("$"); From f510ce7afc5347d1d317d2d5ae6812db42acabcb Mon Sep 17 00:00:00 2001 From: Pascal Vantrepote Date: Sun, 30 Mar 2025 13:07:17 -0400 Subject: [PATCH 9/9] Adding support for Resource template --- .../server/AbstractMcpSyncServerTests.java | 111 ++++---- .../server/McpAsyncServer.java | 110 +++++++- .../server/McpServer.java | 56 ++-- .../server/McpServerFeatures.java | 98 ++++++- .../server/McpSyncServer.java | 4 +- .../util/UriTemplate.java | 241 +++++++----------- .../server/AbstractMcpAsyncServerTests.java | 87 +++++++ .../server/AbstractMcpSyncServerTests.java | 62 +++++ .../util/UriTemplateTests.java | 95 +++++++ 9 files changed, 622 insertions(+), 242 deletions(-) create mode 100644 mcp/src/test/java/io/modelcontextprotocol/util/UriTemplateTests.java 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 905cdd540..daaad68d2 100644 --- a/mcp-test/src/main/java/io/modelcontextprotocol/server/AbstractMcpSyncServerTests.java +++ b/mcp-test/src/main/java/io/modelcontextprotocol/server/AbstractMcpSyncServerTests.java @@ -210,22 +210,25 @@ 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 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() { @@ -241,20 +244,22 @@ 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 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() { @@ -271,18 +276,22 @@ 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 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() { @@ -294,14 +303,16 @@ 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"); - } + // @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 188b0f48e..da8295d5c 100644 --- a/mcp/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java +++ b/mcp/src/main/java/io/modelcontextprotocol/server/McpAsyncServer.java @@ -24,6 +24,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; @@ -182,6 +183,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 // --------------------------------------- @@ -249,7 +271,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<>(); @@ -267,7 +289,7 @@ private static class AsyncServerImpl extends McpAsyncServer { this.serverCapabilities = features.serverCapabilities(); 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<>(); @@ -476,6 +498,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 // --------------------------------------- @@ -540,22 +628,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 091efac2f..74a13eebc 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; /** @@ -171,14 +171,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 @@ -395,26 +402,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; } @@ -590,7 +602,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 @@ -806,11 +818,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; } @@ -822,10 +837,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 8c110027c..5ca77fbe1 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; @@ -38,7 +39,7 @@ public class McpServerFeatures { */ record Async(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities serverCapabilities, List tools, Map resources, - List resourceTemplates, + Map resourceTemplates, Map prompts, List, Mono>> rootsChangeConsumers) { @@ -55,7 +56,7 @@ record Async(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities s */ Async(McpSchema.Implementation serverInfo, McpSchema.ServerCapabilities serverCapabilities, List tools, Map resources, - List resourceTemplates, + Map resourceTemplates, Map prompts, List, Mono>> rootsChangeConsumers) { @@ -75,7 +76,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(); } @@ -99,6 +100,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)); @@ -112,8 +118,8 @@ static Async fromSync(Sync syncSpec) { .subscribeOn(Schedulers.boundedElastic())); } - return new Async(syncSpec.serverInfo(), syncSpec.serverCapabilities(), tools, resources, - syncSpec.resourceTemplates(), prompts, rootChangeConsumers); + return new Async(syncSpec.serverInfo(), syncSpec.serverCapabilities(), tools, resources, resourceTemplates, + prompts, rootChangeConsumers); } } @@ -132,7 +138,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) { @@ -150,7 +156,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) { @@ -170,7 +176,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<>(); } @@ -270,6 +276,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: @@ -387,6 +436,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 9192c4028..e8ab9fe80 100644 --- a/mcp/src/main/java/io/modelcontextprotocol/server/McpSyncServer.java +++ b/mcp/src/main/java/io/modelcontextprotocol/server/McpSyncServer.java @@ -101,9 +101,9 @@ public void removeResource(String resourceUri) { * Add a new resource template handler. * @param resourceHandler The resource handler to add */ - public void addResourceTemplate(McpServerFeatures.SyncResourceTemplateRegistration resourceHandler) { + public void addResourceTemplate(McpServerFeatures.SyncResourceTemplateSpecification resourceHandler) { this.asyncServer - .addResourceTemplate(McpServerFeatures.AsyncResourceTemplateRegistration.fromSync(resourceHandler)) + .addResourceTemplate(McpServerFeatures.AsyncResourceTemplateSpecification.fromSync(resourceHandler)) .block(); } diff --git a/mcp/src/main/java/io/modelcontextprotocol/util/UriTemplate.java b/mcp/src/main/java/io/modelcontextprotocol/util/UriTemplate.java index b30373886..3578f9fe3 100644 --- a/mcp/src/main/java/io/modelcontextprotocol/util/UriTemplate.java +++ b/mcp/src/main/java/io/modelcontextprotocol/util/UriTemplate.java @@ -1,16 +1,16 @@ package io.modelcontextprotocol.util; -import java.util.*; +import java.util.ArrayList; +import java.util.List; import java.util.regex.Matcher; import java.util.regex.Pattern; /** - * Implements URI Template handling according to RFC 6570. This class allows for the - * expansion of URI templates with variables and also supports matching URIs against - * templates to extract variables. + * A utility class for validating and matching URI templates. *

- * URI templates are strings with embedded expressions enclosed in curly braces, such as: - * http://example.com/{username}/profile{?tab,section} + * This class provides methods to validate the syntax of URI templates and check if a + * given URI matches a specified template. + *

*/ public class UriTemplate { @@ -23,13 +23,14 @@ public class UriTemplate { private static final int MAX_REGEX_LENGTH = 1_000_000; - // The original template string and parsed components private final String template; - private final List parts; - 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. @@ -37,10 +38,14 @@ public class UriTemplate { * @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; - this.parts = parseTemplate(template); - this.pattern = Pattern.compile(createMatchingPattern()); + final List parts = parseTemplate(template); + this.pattern = Pattern.compile(createMatchingPattern(parts)); } /** @@ -56,41 +61,11 @@ public String toString() { * @param uri The URI to check * @return true if the URI matches the template pattern, false otherwise */ - public boolean isMatching(String uri) { + public boolean matchesTemplate(String uri) { validateLength(uri, MAX_TEMPLATE_LENGTH, "URI"); return pattern.matcher(uri).matches(); } - /** - * Matches a URI against this template and extracts variable values. - * @param uri The URI to match - * @return Map of variable names to extracted values, or null if the URI doesn't match - */ - public Map match(String uri) { - validateLength(uri, MAX_TEMPLATE_LENGTH, "URI"); - Matcher matcher = pattern.matcher(uri); - if (!matcher.matches()) - return null; - - // Extract variable names from parts and capture their values - List names = extractNamesFromParts(); - Map result = new HashMap<>(); - for (int i = 0; i < names.size(); i++) { - NameInfo nameInfo = names.get(i); - String value = matcher.group(i + 1); - String cleanName = nameInfo.name().replace("*", ""); - - // Handle exploded values (comma-separated lists) - if (nameInfo.exploded() && value.contains(",")) { - result.put(cleanName, List.of(value.split(","))); - } - else { - result.put(cleanName, value); - } - } - return result; - } - /** * Validates that a string does not exceed a maximum allowed length. * @param str String to validate @@ -106,59 +81,63 @@ private static void validateLength(String str, int max, String context) { } /** - * Parses a URI template into parts consisting of literal strings and template parts. - * @param template The URI template to parse - * @return List of parts (Strings for literals, TemplatePart objects for expressions) + * 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 parsedParts = new ArrayList<>(); + List parts = new ArrayList<>(); StringBuilder literal = new StringBuilder(); int expressionCount = 0; + int index = 0; - // Iteratively parse template into parts - for (int i = 0; i < template.length(); i++) { - if (template.charAt(i) == '{') { + while (index < template.length()) { + char ch = template.charAt(index); + if (ch == '{') { if (!literal.isEmpty()) { - parsedParts.add(new Part(literal.toString())); + parts.add(new LiteralPart(literal.toString())); literal.setLength(0); } - int end = template.indexOf("}", i); - if (end == -1) + 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(i + 1, end); - parsedParts.add(parseTemplatePart(expr)); - i = end; + String expr = template.substring(index + 1, end); + parts.add(parseExpression(expr)); + index = end + 1; } else { - literal.append(template.charAt(i)); + literal.append(ch); + index++; } } - if (!literal.isEmpty()) - parsedParts.add(new Part(literal.toString())); - - return parsedParts; + if (!literal.isEmpty()) { + parts.add(new LiteralPart(literal.toString())); + } + return parts; } /** - * Parses a single template expression into a TemplatePart object. + * Parses a template expression into an ExpressionPart. * @param expr The template expression string - * @return A TemplatePart object representing the expression + * @return An ExpressionPart representing the expression */ - private TemplatePart parseTemplatePart(String expr) { + 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); - - for (String name : names) - validateLength(name, MAX_VARIABLE_LENGTH, "Variable name"); - - return new TemplatePart(names.get(0), operator, names, exploded); + 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); } /** @@ -179,28 +158,30 @@ private String extractOperator(String expr) { * @return A list of variable names */ private List extractNames(String expr) { - String[] nameParts = expr.replaceAll("^[+.#/?&]", "").split(","); + String cleaned = expr.replaceAll("^[+.#/?&]", ""); + String[] nameParts = cleaned.split(","); List names = new ArrayList<>(); - for (String name : nameParts) { - String trimmed = name.replace("*", "").trim(); - if (!trimmed.isEmpty()) + 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 template parts. + * Constructs a regex pattern string to match URIs based on the parsed template parts. * @return A regex pattern string */ - private String createMatchingPattern() { + private String createMatchingPattern(List parts) { StringBuilder patternBuilder = new StringBuilder("^"); for (Part part : parts) { - if (part instanceof TemplatePart templatePart) { - patternBuilder.append(createPatternForPart(templatePart)); + if (part instanceof ExpressionPart exprPart) { + patternBuilder.append(createPatternForExpressionPart(exprPart)); } - else { - patternBuilder.append(Pattern.quote(part.getName())); + else if (part instanceof LiteralPart literalPart) { + patternBuilder.append(Pattern.quote(literalPart.literal())); } } patternBuilder.append("$"); @@ -210,96 +191,50 @@ private String createMatchingPattern() { } /** - * Creates a regex pattern for a specific template part based on its operator. - * @param part The template part + * 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 createPatternForPart(TemplatePart part) { - return switch (part.getOperator()) { - case "", "+" -> part.isExploded() ? "([^/]+(?:,[^/]+)*)" : "([^/,]+)"; + private String createPatternForExpressionPart(ExpressionPart part) { + return switch (part.operator()) { + case "", "+" -> part.exploded() ? "([^/]+(?:,[^/]+)*)" : "([^/,]+)"; case "#" -> "(.+)"; case "." -> "\\.([^/,]+)"; - case "/" -> "/" + (part.isExploded() ? "([^/]+(?:,[^/]+)*)" : "([^/,]+)"); - case "?", "&" -> "\\?" + part.getName() + "=([^&]+)"; + case "/" -> "/" + (part.exploded() ? "([^/]+(?:,[^/]+)*)" : "([^/,]+)"); + case "?", "&" -> "\\?" + part.variableNames().get(0) + "=([^&]+)"; default -> "([^/]+)"; }; } + // --- Internal types --- + /** - * Extracts variable names from template parts. - * @return A list of NameInfo objects containing variable names and their properties + * A marker interface for parts of the URI template. */ - private List extractNamesFromParts() { - List names = new ArrayList<>(); - for (Object part : parts) { - if (part instanceof TemplatePart templatePart) { - templatePart.getNames().forEach(name -> names.add(new NameInfo(name, templatePart.isExploded()))); - } - } - return names; - } - - private static class Part { - - private String name; - - public String getName() { - return name; - } - - public void setName(String name) { - this.name = name; - } - - public Part(String name) { - this.name = name; - } + private interface Part { } - private static class TemplatePart extends Part { - - private String operator; - - private List names; - - private boolean exploded; - - public String getOperator() { - return operator; - } - - public void setOperator(String operator) { - this.operator = operator; - } - - public List getNames() { - return names; - } - - public void setNames(List names) { - this.names = names; - } - - public boolean isExploded() { - return exploded; - } - - public void setExploded(boolean exploded) { - this.exploded = exploded; - } + /** + * Represents a literal segment of the template. + */ + private record LiteralPart(String literal) implements Part { + } - public TemplatePart(String name, String operator, List names, boolean exploded) { - super(name); - this.operator = operator; - this.names = names; - this.exploded = exploded; - } + /** + * 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(); } - // Record classes for data encapsulation - private record NameInfo(String name, boolean exploded) { + @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 4b4fc434f..35b9e7cfc 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 17feb36e5..2b80e9df4 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)); + } + } + +}