feat: Add customizable URI template manager factory to MCP server

Implement URI template functionality for MCP resources, allowing dynamic
resource URIs with variables in the format {variableName}.

- Enable resource URIs with variable placeholders (e.g., "/api/users/{userId}")
- Automatic extraction of variable values from request URIs
- Validation of template arguments in completions
- Matching of request URIs against templates

- Add new URI template management interfaces and implementations
- Enhanced resource template listing to include templated resources
- Updated resource request handling to support template matching
- Test coverage for URI template functionality

- Adding a configurable uriTemplateManagerFactory field to both AsyncSpecification and SyncSpecification classes
- Adding builder methods to allow setting a custom URI template manager factory
- Modifying constructors to pass the URI template manager factory to the server implementation
- Updating the server implementation to use the provided factory
- Add bulk registration methods for async completions

Signed-off-by: Christian Tzolov <christian.tzolov@broadcom.com>
This commit is contained in:
Christian Tzolov 2025-04-20 10:58:51 +03:00 • committed by Christian Tzolov
commit e610d853f9
8 changed files with 489 additions and 16 deletions

View file

@ -776,7 +776,8 @@ class WebFluxSseIntegrationTests {
var mcpServer = McpServer.sync(mcpServerTransportProvider)
.capabilities(ServerCapabilities.builder().completions().build())
.prompts(new McpServerFeatures.SyncPromptSpecification(
new Prompt("code_review", "this is code review prompt", List.of()),
new Prompt("code_review", "this is code review prompt",
List.of(new PromptArgument("language", "string", false))),
(mcpSyncServerExchange, getPromptRequest) -> null))
.completions(new McpServerFeatures.SyncCompletionSpecification(
new McpSchema.PromptReference("ref/prompt", "code_review"), completionHandler))

View file

@ -5,6 +5,7 @@
package io.modelcontextprotocol.server;
import java.time.Duration;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@ -22,10 +23,13 @@ import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
import io.modelcontextprotocol.spec.McpSchema.LoggingLevel;
import io.modelcontextprotocol.spec.McpSchema.LoggingMessageNotification;
import io.modelcontextprotocol.spec.McpSchema.ResourceTemplate;
import io.modelcontextprotocol.spec.McpSchema.SetLevelRequest;
import io.modelcontextprotocol.spec.McpSchema.Tool;
import io.modelcontextprotocol.spec.McpServerSession;
import io.modelcontextprotocol.spec.McpServerTransportProvider;
import io.modelcontextprotocol.util.DeafaultMcpUriTemplateManagerFactory;
import io.modelcontextprotocol.util.McpUriTemplateManagerFactory;
import io.modelcontextprotocol.util.Utils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -92,8 +96,10 @@ public class McpAsyncServer {
* @param objectMapper The ObjectMapper to use for JSON serialization/deserialization
*/
McpAsyncServer(McpServerTransportProvider mcpTransportProvider, ObjectMapper objectMapper,
McpServerFeatures.Async features, Duration requestTimeout) {
this.delegate = new AsyncServerImpl(mcpTransportProvider, objectMapper, requestTimeout, features);
McpServerFeatures.Async features, Duration requestTimeout,
McpUriTemplateManagerFactory uriTemplateManagerFactory) {
this.delegate = new AsyncServerImpl(mcpTransportProvider, objectMapper, requestTimeout, features,
uriTemplateManagerFactory);
}
/**
@ -274,8 +280,11 @@ public class McpAsyncServer {
private List<String> protocolVersions = List.of(McpSchema.LATEST_PROTOCOL_VERSION);
private McpUriTemplateManagerFactory uriTemplateManagerFactory = new DeafaultMcpUriTemplateManagerFactory();
AsyncServerImpl(McpServerTransportProvider mcpTransportProvider, ObjectMapper objectMapper,
Duration requestTimeout, McpServerFeatures.Async features) {
Duration requestTimeout, McpServerFeatures.Async features,
McpUriTemplateManagerFactory uriTemplateManagerFactory) {
this.mcpTransportProvider = mcpTransportProvider;
this.objectMapper = objectMapper;
this.serverInfo = features.serverInfo();
@ -286,6 +295,7 @@ public class McpAsyncServer {
this.resourceTemplates.addAll(features.resourceTemplates());
this.prompts.putAll(features.prompts());
this.completions.putAll(features.completions());
this.uriTemplateManagerFactory = uriTemplateManagerFactory;
Map<String, McpServerSession.RequestHandler<?>> requestHandlers = new HashMap<>();
@ -564,21 +574,44 @@ public class McpAsyncServer {
private McpServerSession.RequestHandler<McpSchema.ListResourceTemplatesResult> resourceTemplateListRequestHandler() {
return (exchange, params) -> Mono
.just(new McpSchema.ListResourceTemplatesResult(this.resourceTemplates, null));
.just(new McpSchema.ListResourceTemplatesResult(this.getResourceTemplates(), null));
}
private List<McpSchema.ResourceTemplate> getResourceTemplates() {
var list = new ArrayList<>(this.resourceTemplates);
List<ResourceTemplate> resourceTemplates = this.resources.keySet()
.stream()
.filter(uri -> uri.contains("{"))
.map(uri -> {
var resource = this.resources.get(uri).resource();
var template = new McpSchema.ResourceTemplate(resource.uri(), resource.name(),
resource.description(), resource.mimeType(), resource.annotations());
return template;
})
.toList();
list.addAll(resourceTemplates);
return list;
}
private McpServerSession.RequestHandler<McpSchema.ReadResourceResult> resourcesReadRequestHandler() {
return (exchange, params) -> {
McpSchema.ReadResourceRequest resourceRequest = objectMapper.convertValue(params,
new TypeReference<McpSchema.ReadResourceRequest>() {
});
var resourceUri = resourceRequest.uri();
McpServerFeatures.AsyncResourceSpecification specification = this.resources.get(resourceUri);
if (specification != null) {
return specification.readHandler().apply(exchange, resourceRequest);
}
return Mono.error(new McpError("Resource not found: " + resourceUri));
McpServerFeatures.AsyncResourceSpecification specification = this.resources.values()
.stream()
.filter(resourceSpecification -> this.uriTemplateManagerFactory
.create(resourceSpecification.resource().uri())
.matches(resourceUri))
.findFirst()
.orElseThrow(() -> new McpError("Resource not found: " + resourceUri));
return specification.readHandler().apply(exchange, resourceRequest);
};
}
@ -729,20 +762,38 @@ public class McpAsyncServer {
String type = request.ref().type();
String argumentName = request.argument().name();
// check if the referenced resource exists
if (type.equals("ref/prompt") && request.ref() instanceof McpSchema.PromptReference promptReference) {
McpServerFeatures.AsyncPromptSpecification prompt = this.prompts.get(promptReference.name());
if (prompt == null) {
McpServerFeatures.AsyncPromptSpecification promptSpec = this.prompts.get(promptReference.name());
if (promptSpec == null) {
return Mono.error(new McpError("Prompt not found: " + promptReference.name()));
}
if (!promptSpec.prompt()
.arguments()
.stream()
.filter(arg -> arg.name().equals(argumentName))
.findFirst()
.isPresent()) {
return Mono.error(new McpError("Argument not found: " + argumentName));
}
}
if (type.equals("ref/resource")
&& request.ref() instanceof McpSchema.ResourceReference resourceReference) {
McpServerFeatures.AsyncResourceSpecification resource = this.resources.get(resourceReference.uri());
if (resource == null) {
McpServerFeatures.AsyncResourceSpecification resourceSpec = this.resources
.get(resourceReference.uri());
if (resourceSpec == null) {
return Mono.error(new McpError("Resource not found: " + resourceReference.uri()));
}
if (!uriTemplateManagerFactory.create(resourceSpec.resource().uri())
.getVariableNames()
.contains(argumentName)) {
return Mono.error(new McpError("Argument not found: " + argumentName));
}
}
McpServerFeatures.AsyncCompletionSpecification specification = this.completions.get(request.ref());

View file

@ -19,6 +19,8 @@ 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.DeafaultMcpUriTemplateManagerFactory;
import io.modelcontextprotocol.util.McpUriTemplateManagerFactory;
import reactor.core.publisher.Mono;
/**
@ -156,6 +158,8 @@ public interface McpServer {
private final McpServerTransportProvider transportProvider;
private McpUriTemplateManagerFactory uriTemplateManagerFactory = new DeafaultMcpUriTemplateManagerFactory();
private ObjectMapper objectMapper;
private McpSchema.Implementation serverInfo = DEFAULT_SERVER_INFO;
@ -204,6 +208,19 @@ public interface McpServer {
this.transportProvider = transportProvider;
}
/**
* Sets the URI template manager factory to use for creating URI templates. This
* allows for custom URI template parsing and variable extraction.
* @param uriTemplateManagerFactory The factory to use. Must not be null.
* @return This builder instance for method chaining
* @throws IllegalArgumentException if uriTemplateManagerFactory is null
*/
public AsyncSpecification uriTemplateManagerFactory(McpUriTemplateManagerFactory uriTemplateManagerFactory) {
Assert.notNull(uriTemplateManagerFactory, "URI template manager factory must not be null");
this.uriTemplateManagerFactory = uriTemplateManagerFactory;
return this;
}
/**
* Sets the duration to wait for server responses before timing out requests. This
* timeout applies to all requests made through the client, including tool calls,
@ -517,6 +534,36 @@ public interface McpServer {
return this;
}
/**
* Registers multiple completions with their handlers using a List. This method is
* useful when completions need to be added in bulk from a collection.
* @param completions List of completion specifications. Must not be null.
* @return This builder instance for method chaining
* @throws IllegalArgumentException if completions is null
*/
public AsyncSpecification completions(List<McpServerFeatures.AsyncCompletionSpecification> completions) {
Assert.notNull(completions, "Completions list must not be null");
for (McpServerFeatures.AsyncCompletionSpecification completion : completions) {
this.completions.put(completion.referenceKey(), completion);
}
return this;
}
/**
* Registers multiple completions with their handlers using varargs. This method
* is useful when completions are defined inline and added directly.
* @param completions Array of completion specifications. Must not be null.
* @return This builder instance for method chaining
* @throws IllegalArgumentException if completions is null
*/
public AsyncSpecification completions(McpServerFeatures.AsyncCompletionSpecification... completions) {
Assert.notNull(completions, "Completions list must not be null");
for (McpServerFeatures.AsyncCompletionSpecification completion : completions) {
this.completions.put(completion.referenceKey(), completion);
}
return this;
}
/**
* Registers a consumer that will be notified when the list of roots changes. This
* is useful for updating resource availability dynamically, such as when new
@ -587,7 +634,8 @@ public interface McpServer {
this.resources, this.resourceTemplates, this.prompts, this.completions, this.rootsChangeHandlers,
this.instructions);
var mapper = this.objectMapper != null ? this.objectMapper : new ObjectMapper();
return new McpAsyncServer(this.transportProvider, mapper, features, this.requestTimeout);
return new McpAsyncServer(this.transportProvider, mapper, features, this.requestTimeout,
this.uriTemplateManagerFactory);
}
}
@ -600,6 +648,8 @@ public interface McpServer {
private static final McpSchema.Implementation DEFAULT_SERVER_INFO = new McpSchema.Implementation("mcp-server",
"1.0.0");
private McpUriTemplateManagerFactory uriTemplateManagerFactory = new DeafaultMcpUriTemplateManagerFactory();
private final McpServerTransportProvider transportProvider;
private ObjectMapper objectMapper;
@ -650,6 +700,19 @@ public interface McpServer {
this.transportProvider = transportProvider;
}
/**
* Sets the URI template manager factory to use for creating URI templates. This
* allows for custom URI template parsing and variable extraction.
* @param uriTemplateManagerFactory The factory to use. Must not be null.
* @return This builder instance for method chaining
* @throws IllegalArgumentException if uriTemplateManagerFactory is null
*/
public SyncSpecification uriTemplateManagerFactory(McpUriTemplateManagerFactory uriTemplateManagerFactory) {
Assert.notNull(uriTemplateManagerFactory, "URI template manager factory must not be null");
this.uriTemplateManagerFactory = uriTemplateManagerFactory;
return this;
}
/**
* Sets the duration to wait for server responses before timing out requests. This
* timeout applies to all requests made through the client, including tool calls,
@ -1064,7 +1127,8 @@ public interface McpServer {
this.rootsChangeHandlers, this.instructions);
McpServerFeatures.Async asyncFeatures = McpServerFeatures.Async.fromSync(syncFeatures);
var mapper = this.objectMapper != null ? this.objectMapper : new ObjectMapper();
var asyncServer = new McpAsyncServer(this.transportProvider, mapper, asyncFeatures, this.requestTimeout);
var asyncServer = new McpAsyncServer(this.transportProvider, mapper, asyncFeatures, this.requestTimeout,
this.uriTemplateManagerFactory);
return new McpSyncServer(asyncServer);
}

View file

@ -0,0 +1,23 @@
/*
* Copyright 2025 - 2025 the original author or authors.
*/
package io.modelcontextprotocol.util;
/**
* @author Christian Tzolov
*/
public class DeafaultMcpUriTemplateManagerFactory implements McpUriTemplateManagerFactory {
/**
* Creates a new instance of {@link McpUriTemplateManager} with the specified URI
* template.
* @param uriTemplate The URI template to be used for variable extraction
* @return A new instance of {@link McpUriTemplateManager}
* @throws IllegalArgumentException if the URI template is null or empty
*/
@Override
public McpUriTemplateManager create(String uriTemplate) {
return new DefaultMcpUriTemplateManager(uriTemplate);
}
}

View file

@ -0,0 +1,163 @@
/*
* Copyright 2025-2025 the original author or authors.
*/
package io.modelcontextprotocol.util;
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;
/**
* Default implementation of the UriTemplateUtils interface.
* <p>
* This class provides methods for extracting variables from URI templates and matching
* them against actual URIs.
*
* @author Christian Tzolov
*/
public class DefaultMcpUriTemplateManager implements McpUriTemplateManager {
/**
* Pattern to match URI variables in the format {variableName}.
*/
private static final Pattern URI_VARIABLE_PATTERN = Pattern.compile("\\{([^/]+?)\\}");
private final String uriTemplate;
/**
* Constructor for DefaultMcpUriTemplateManager.
* @param uriTemplate The URI template to be used for variable extraction
*/
public DefaultMcpUriTemplateManager(String uriTemplate) {
if (uriTemplate == null || uriTemplate.isEmpty()) {
throw new IllegalArgumentException("URI template must not be null or empty");
}
this.uriTemplate = uriTemplate;
}
/**
* Extract URI variable names from a URI template.
* @param uriTemplate The URI template containing variables in the format
* {variableName}
* @return A list of variable names extracted from the template
* @throws IllegalArgumentException if duplicate variable names are found
*/
@Override
public List<String> getVariableNames() {
if (uriTemplate == null || uriTemplate.isEmpty()) {
return List.of();
}
List<String> variables = new ArrayList<>();
Matcher matcher = URI_VARIABLE_PATTERN.matcher(this.uriTemplate);
while (matcher.find()) {
String variableName = matcher.group(1);
if (variables.contains(variableName)) {
throw new IllegalArgumentException("Duplicate URI variable name in template: " + variableName);
}
variables.add(variableName);
}
return variables;
}
/**
* Extract URI variable values from the actual request URI.
* <p>
* This method converts the URI template into a regex pattern, then uses that pattern
* to extract variable values from the request URI.
* @param requestUri The actual URI from the request
* @return A map of variable names to their values
* @throws IllegalArgumentException if the URI template is invalid or the request URI
* doesn't match the template pattern
*/
@Override
public Map<String, String> extractVariableValues(String requestUri) {
Map<String, String> variableValues = new HashMap<>();
List<String> uriVariables = this.getVariableNames();
if (requestUri == null || uriVariables.isEmpty()) {
return variableValues;
}
try {
// Create a regex pattern by replacing each {variableName} with a capturing
// group
StringBuilder patternBuilder = new StringBuilder("^");
// Find all variable placeholders and their positions
Matcher variableMatcher = URI_VARIABLE_PATTERN.matcher(uriTemplate);
int lastEnd = 0;
while (variableMatcher.find()) {
// Add the text between the last variable and this one, escaped for regex
String textBefore = uriTemplate.substring(lastEnd, variableMatcher.start());
patternBuilder.append(Pattern.quote(textBefore));
// Add a capturing group for the variable
patternBuilder.append("([^/]+)");
lastEnd = variableMatcher.end();
}
// Add any remaining text after the last variable
if (lastEnd < uriTemplate.length()) {
patternBuilder.append(Pattern.quote(uriTemplate.substring(lastEnd)));
}
patternBuilder.append("$");
// Compile the pattern and match against the request URI
Pattern pattern = Pattern.compile(patternBuilder.toString());
Matcher matcher = pattern.matcher(requestUri);
if (matcher.find() && matcher.groupCount() == uriVariables.size()) {
for (int i = 0; i < uriVariables.size(); i++) {
String value = matcher.group(i + 1);
if (value == null || value.isEmpty()) {
throw new IllegalArgumentException(
"Empty value for URI variable '" + uriVariables.get(i) + "' in URI: " + requestUri);
}
variableValues.put(uriVariables.get(i), value);
}
}
}
catch (Exception e) {
throw new IllegalArgumentException("Error parsing URI template: " + uriTemplate + " for URI: " + requestUri,
e);
}
return variableValues;
}
/**
* Check if a URI matches the uriTemplate with variables.
* @param uri The URI to check
* @return true if the URI matches the pattern, false otherwise
*/
@Override
public boolean matches(String uri) {
// If the uriTemplate doesn't contain variables, do a direct comparison
if (!this.isUriTemplate(this.uriTemplate)) {
return uri.equals(this.uriTemplate);
}
// Convert the pattern to a regex
String regex = this.uriTemplate.replaceAll("\\{[^/]+?\\}", "([^/]+?)");
regex = regex.replace("/", "\\/");
// Check if the URI matches the regex
return Pattern.compile(regex).matcher(uri).matches();
}
@Override
public boolean isUriTemplate(String uri) {
return URI_VARIABLE_PATTERN.matcher(uri).find();
}
}

View file

@ -0,0 +1,52 @@
/*
* Copyright 2025-2025 the original author or authors.
*/
package io.modelcontextprotocol.util;
import java.util.List;
import java.util.Map;
/**
* Interface for working with URI templates.
* <p>
* This interface provides methods for extracting variables from URI templates and
* matching them against actual URIs.
*
* @author Christian Tzolov
*/
public interface McpUriTemplateManager {
/**
* Extract URI variable names from this URI template.
* @return A list of variable names extracted from the template
* @throws IllegalArgumentException if duplicate variable names are found
*/
List<String> getVariableNames();
/**
* Extract URI variable values from the actual request URI.
* <p>
* This method converts the URI template into a regex pattern, then uses that pattern
* to extract variable values from the request URI.
* @param uri The actual URI from the request
* @return A map of variable names to their values
* @throws IllegalArgumentException if the URI template is invalid or the request URI
* doesn't match the template pattern
*/
Map<String, String> extractVariableValues(String uri);
/**
* Indicate whether the given URI matches this template.
* @param uri the URI to match to
* @return {@code true} if it matches; {@code false} otherwise
*/
boolean matches(String uri);
/**
* Check if the given URI is a URI template.
* @return Returns true if the URI contains variables in the format {variableName}
*/
public boolean isUriTemplate(String uri);
}

View file

@ -0,0 +1,22 @@
/*
* Copyright 2025 - 2025 the original author or authors.
*/
package io.modelcontextprotocol.util;
/**
* Factory interface for creating instances of {@link McpUriTemplateManager}.
*
* @author Christian Tzolov
*/
public interface McpUriTemplateManagerFactory {
/**
* Creates a new instance of {@link McpUriTemplateManager} with the specified URI
* template.
* @param uriTemplate The URI template to be used for variable extraction
* @return A new instance of {@link McpUriTemplateManager}
* @throws IllegalArgumentException if the URI template is null or empty
*/
McpUriTemplateManager create(String uriTemplate);
}

View file

@ -0,0 +1,97 @@
/*
* Copyright 2025-2025 the original author or authors.
*/
package io.modelcontextprotocol;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.List;
import java.util.Map;
import io.modelcontextprotocol.util.DeafaultMcpUriTemplateManagerFactory;
import io.modelcontextprotocol.util.McpUriTemplateManager;
import io.modelcontextprotocol.util.McpUriTemplateManagerFactory;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
/**
* Tests for {@link McpUriTemplateManager} and its implementations.
*
* @author Christian Tzolov
*/
public class McpUriTemplateManagerTests {
private McpUriTemplateManagerFactory uriTemplateFactory;
@BeforeEach
void setUp() {
this.uriTemplateFactory = new DeafaultMcpUriTemplateManagerFactory();
}
@Test
void shouldExtractVariableNamesFromTemplate() {
List<String> variables = this.uriTemplateFactory.create("/api/users/{userId}/posts/{postId}")
.getVariableNames();
assertEquals(2, variables.size());
assertEquals("userId", variables.get(0));
assertEquals("postId", variables.get(1));
}
@Test
void shouldReturnEmptyListWhenTemplateHasNoVariables() {
List<String> variables = this.uriTemplateFactory.create("/api/users/all").getVariableNames();
assertEquals(0, variables.size());
}
@Test
void shouldThrowExceptionWhenExtractingVariablesFromNullTemplate() {
assertThrows(IllegalArgumentException.class, () -> this.uriTemplateFactory.create(null).getVariableNames());
}
@Test
void shouldThrowExceptionWhenExtractingVariablesFromEmptyTemplate() {
assertThrows(IllegalArgumentException.class, () -> this.uriTemplateFactory.create("").getVariableNames());
}
@Test
void shouldThrowExceptionWhenTemplateContainsDuplicateVariables() {
assertThrows(IllegalArgumentException.class,
() -> this.uriTemplateFactory.create("/api/users/{userId}/posts/{userId}").getVariableNames());
}
@Test
void shouldExtractVariableValuesFromRequestUri() {
Map<String, String> values = this.uriTemplateFactory.create("/api/users/{userId}/posts/{postId}")
.extractVariableValues("/api/users/123/posts/456");
assertEquals(2, values.size());
assertEquals("123", values.get("userId"));
assertEquals("456", values.get("postId"));
}
@Test
void shouldReturnEmptyMapWhenTemplateHasNoVariables() {
Map<String, String> values = this.uriTemplateFactory.create("/api/users/all")
.extractVariableValues("/api/users/all");
assertEquals(0, values.size());
}
@Test
void shouldReturnEmptyMapWhenRequestUriIsNull() {
Map<String, String> values = this.uriTemplateFactory.create("/api/users/{userId}/posts/{postId}")
.extractVariableValues(null);
assertEquals(0, values.size());
}
@Test
void shouldMatchUriAgainstTemplatePattern() {
var uriTemplateManager = this.uriTemplateFactory.create("/api/users/{userId}/posts/{postId}");
assertTrue(uriTemplateManager.matches("/api/users/123/posts/456"));
assertFalse(uriTemplateManager.matches("/api/users/123/comments/456"));
}
}