This is an automated email from the ASF dual-hosted git repository. rombert pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/sling-org-apache-sling-mcp-server.git
commit 128443db4e1fcd2f6203e28180b13a9512c9a79a Author: Robert Munteanu <[email protected]> AuthorDate: Fri Dec 12 17:03:33 2025 +0100 feat(mcp): repository prompts support parameters defined in the front matter --- pom.xml | 19 +++ .../sling/mcp/server/impl/DiscoveredPrompt.java | 10 +- .../apache/sling/mcp/server/impl/McpServlet.java | 15 +- .../impl/contribs/DiscoveredPromptBuilder.java | 160 +++++++++++++++++++++ .../impl/contribs/RepositoryPromptsRegistrar.java | 81 ++--------- .../libs/sling/mcp/prompts/new-sling-servlet.md | 17 +++ .../impl/contribs/DiscoveredPromptBuilderTest.java | 129 +++++++++++++++++ 7 files changed, 346 insertions(+), 85 deletions(-) diff --git a/pom.xml b/pom.xml index d90ecce..919caad 100644 --- a/pom.xml +++ b/pom.xml @@ -123,6 +123,25 @@ <version>0.27.0</version> <scope>provided</scope> </dependency> + + <!-- test dependencies --> + <dependency> + <groupId>org.junit.jupiter</groupId> + <artifactId>junit-jupiter-engine</artifactId> + <scope>test</scope> + </dependency> + <dependency> + <groupId>org.mockito</groupId> + <artifactId>mockito-core</artifactId> + <version>5.20.0</version> + <scope>test</scope> + </dependency> + <dependency> + <groupId>org.assertj</groupId> + <artifactId>assertj-core</artifactId> + <version>3.27.6</version> + <scope>test</scope> + </dependency> </dependencies> <build> diff --git a/src/main/java/org/apache/sling/mcp/server/impl/DiscoveredPrompt.java b/src/main/java/org/apache/sling/mcp/server/impl/DiscoveredPrompt.java index ad64197..4e6e54f 100644 --- a/src/main/java/org/apache/sling/mcp/server/impl/DiscoveredPrompt.java +++ b/src/main/java/org/apache/sling/mcp/server/impl/DiscoveredPrompt.java @@ -20,14 +20,16 @@ package org.apache.sling.mcp.server.impl; import java.util.List; +import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.spec.McpSchema.GetPromptRequest; +import io.modelcontextprotocol.spec.McpSchema.Prompt; import io.modelcontextprotocol.spec.McpSchema.PromptMessage; -import org.apache.sling.api.resource.ResourceResolver; public interface DiscoveredPrompt { public static final String SERVICE_PROP_NAME = "mcp.prompt.name"; - public static final String SERVICE_PROP_TITLE = "mcp.prompt.title"; - public static final String SERVICE_PROP_DESCRIPTION = "mcp.prompt.description"; - List<PromptMessage> getPromptMessages(ResourceResolver resolver); + List<PromptMessage> getPromptMessages(McpTransportContext c, GetPromptRequest r); + + Prompt asPrompt(); } diff --git a/src/main/java/org/apache/sling/mcp/server/impl/McpServlet.java b/src/main/java/org/apache/sling/mcp/server/impl/McpServlet.java index 8deb6dc..06bb9c4 100644 --- a/src/main/java/org/apache/sling/mcp/server/impl/McpServlet.java +++ b/src/main/java/org/apache/sling/mcp/server/impl/McpServlet.java @@ -33,7 +33,6 @@ import io.modelcontextprotocol.server.McpStatelessServerFeatures.SyncPromptSpeci import io.modelcontextprotocol.server.McpStatelessSyncServer; import io.modelcontextprotocol.server.transport.HttpServletStatelessServerTransport; import io.modelcontextprotocol.spec.McpSchema; -import io.modelcontextprotocol.spec.McpSchema.Prompt; import io.modelcontextprotocol.spec.McpSchema.ServerCapabilities; import jakarta.servlet.Servlet; import jakarta.servlet.ServletException; @@ -41,7 +40,6 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.apache.sling.api.SlingJakartaHttpServletRequest; import org.apache.sling.api.SlingJakartaHttpServletResponse; -import org.apache.sling.api.resource.ResourceResolver; import org.apache.sling.api.servlets.SlingJakartaAllMethodsServlet; import org.apache.sling.servlets.annotations.SlingServletPaths; import org.jetbrains.annotations.NotNull; @@ -163,15 +161,10 @@ public class McpServlet extends SlingJakartaAllMethodsServlet { @Reference(policy = ReferencePolicy.DYNAMIC, policyOption = GREEDY, cardinality = MULTIPLE) protected void bindPrompt(DiscoveredPrompt prompt, Map<String, Object> properties) { - String promptName = (String) properties.get(DiscoveredPrompt.SERVICE_PROP_NAME); - String promptTitle = (String) properties.get(DiscoveredPrompt.SERVICE_PROP_TITLE); - String promptDescription = (String) properties.get(DiscoveredPrompt.SERVICE_PROP_DESCRIPTION); - syncServer.addPrompt(new SyncPromptSpecification( - new Prompt(promptName, promptTitle, promptDescription, List.of()), (c, r) -> { - ResourceResolver resourceResolver = (ResourceResolver) c.get("resourceResolver"); - var messages = prompt.getPromptMessages(resourceResolver); - return new McpSchema.GetPromptResult(null, messages); - })); + syncServer.addPrompt(new SyncPromptSpecification(prompt.asPrompt(), (c, r) -> { + var messages = prompt.getPromptMessages(c, r); + return new McpSchema.GetPromptResult(null, messages); + })); } protected void unbindPrompt(Map<String, Object> properties) { diff --git a/src/main/java/org/apache/sling/mcp/server/impl/contribs/DiscoveredPromptBuilder.java b/src/main/java/org/apache/sling/mcp/server/impl/contribs/DiscoveredPromptBuilder.java new file mode 100644 index 0000000..ca2f9a7 --- /dev/null +++ b/src/main/java/org/apache/sling/mcp/server/impl/contribs/DiscoveredPromptBuilder.java @@ -0,0 +1,160 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.sling.mcp.server.impl.contribs; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.spec.McpSchema.GetPromptRequest; +import io.modelcontextprotocol.spec.McpSchema.Prompt; +import io.modelcontextprotocol.spec.McpSchema.PromptArgument; +import io.modelcontextprotocol.spec.McpSchema.PromptMessage; +import io.modelcontextprotocol.spec.McpSchema.Role; +import io.modelcontextprotocol.spec.McpSchema.TextContent; +import org.apache.sling.api.resource.Resource; +import org.apache.sling.api.resource.ResourceResolver; +import org.apache.sling.mcp.server.impl.DiscoveredPrompt; +import org.commonmark.ext.front.matter.YamlFrontMatterExtension; +import org.commonmark.ext.front.matter.YamlFrontMatterVisitor; +import org.commonmark.node.Node; +import org.commonmark.parser.Parser; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +class DiscoveredPromptBuilder { + + private final Logger logger = LoggerFactory.getLogger(getClass()); + + public DiscoveredPrompt buildPrompt(Resource prompt, String promptName) throws IOException { + + Parser parser = Parser.builder() + .extensions(List.of(YamlFrontMatterExtension.create())) + .build(); + String charset = prompt.getResourceMetadata().getCharacterEncoding(); + if (charset == null) { + charset = StandardCharsets.UTF_8.name(); + } + try (InputStream is = prompt.adaptTo(InputStream.class)) { + Node node = parser.parseReader(new InputStreamReader(is, charset)); + YamlFrontMatterVisitor visitor = new YamlFrontMatterVisitor(); + node.accept(visitor); + + String title = visitor.getData().getOrDefault("title", List.of()).stream() + .findFirst() + .orElse(null); + String description = visitor.getData().getOrDefault("description", List.of()).stream() + .findFirst() + .orElse(null); + + List<PromptArgument> arguments = new ArrayList<>(); + + visitor.getData().entrySet().stream() + .filter(e -> e.getKey().startsWith("argument.")) + .forEach(e -> { + String argName = e.getKey().substring("argument.".length()); + List<String> values = e.getValue(); + String argTitle = null; + String argDesc = null; + boolean argRequired = false; + for (String value : values) { + + if (value.startsWith("title:")) { + argTitle = value.substring("title:".length()).trim(); + } else if (value.startsWith("description:")) { + argDesc = + value.substring("description:".length()).trim(); + } else if (value.startsWith("required:")) { + String argRequiredRaw = + value.substring("required:".length()).trim(); + argRequired = Boolean.valueOf(argRequiredRaw); + } else { + logger.warn("Unknown argument property in prompt {}: {}", promptName, value); + } + } + + arguments.add(new PromptArgument(argName, argTitle, argDesc, argRequired)); + }); + + return new RepositoryPrompt(prompt.getPath(), promptName, title, description, arguments); + } + } + + static class RepositoryPrompt implements DiscoveredPrompt { + + private final String promptPath; + private final String promptName; + private final String promptTitle; + private final String promptDescription; + private final List<PromptArgument> arguments; + + RepositoryPrompt( + String promptPath, + String promptName, + String promptTitle, + String promptDescription, + List<PromptArgument> arguments) { + this.promptPath = promptPath; + this.promptName = promptName; + this.promptTitle = promptTitle; + this.promptDescription = promptDescription; + this.arguments = arguments; + } + + @Override + public List<PromptMessage> getPromptMessages(McpTransportContext c, GetPromptRequest req) { + + ResourceResolver rr = (ResourceResolver) c.get("resourceResolver"); + + try { + Resource promptResource = rr.getResource(promptPath); + String encoding = promptResource.getResourceMetadata().getCharacterEncoding(); + if (encoding == null) { + encoding = StandardCharsets.UTF_8.name(); + } + try (InputStream stream = promptResource.adaptTo(InputStream.class)) { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + stream.transferTo(baos); + String contents = baos.toString(encoding); + + for (PromptArgument arg : arguments) { + String placeholder = "{" + arg.name() + "}"; + Object requestedArg = req.arguments().get(arg.name()); + String value = requestedArg != null ? requestedArg.toString() : ""; + contents = contents.replace(placeholder, value); + } + + return List.of(new PromptMessage(Role.ASSISTANT, new TextContent(contents))); + } + } catch (IOException e) { + return List.of(); + } + } + + @Override + public Prompt asPrompt() { + return new Prompt(promptName, promptTitle, promptDescription, arguments); + } + } +} diff --git a/src/main/java/org/apache/sling/mcp/server/impl/contribs/RepositoryPromptsRegistrar.java b/src/main/java/org/apache/sling/mcp/server/impl/contribs/RepositoryPromptsRegistrar.java index fe0e97e..81b07a4 100644 --- a/src/main/java/org/apache/sling/mcp/server/impl/contribs/RepositoryPromptsRegistrar.java +++ b/src/main/java/org/apache/sling/mcp/server/impl/contribs/RepositoryPromptsRegistrar.java @@ -18,12 +18,7 @@ */ package org.apache.sling.mcp.server.impl.contribs; -import java.io.ByteArrayOutputStream; import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.nio.charset.StandardCharsets; -import java.util.HashMap; import java.util.Hashtable; import java.util.Iterator; import java.util.List; @@ -31,9 +26,6 @@ import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; -import io.modelcontextprotocol.spec.McpSchema.PromptMessage; -import io.modelcontextprotocol.spec.McpSchema.Role; -import io.modelcontextprotocol.spec.McpSchema.TextContent; import org.apache.sling.api.resource.LoginException; import org.apache.sling.api.resource.Resource; import org.apache.sling.api.resource.ResourceResolver; @@ -42,10 +34,6 @@ import org.apache.sling.api.resource.observation.ResourceChange; import org.apache.sling.api.resource.observation.ResourceChange.ChangeType; import org.apache.sling.api.resource.observation.ResourceChangeListener; import org.apache.sling.mcp.server.impl.DiscoveredPrompt; -import org.commonmark.ext.front.matter.YamlFrontMatterExtension; -import org.commonmark.ext.front.matter.YamlFrontMatterVisitor; -import org.commonmark.node.Node; -import org.commonmark.parser.Parser; import org.jetbrains.annotations.NotNull; import org.osgi.framework.BundleContext; import org.osgi.framework.ServiceRegistration; @@ -61,6 +49,7 @@ public class RepositoryPromptsRegistrar { private static final String PROMPT_LIBS_DIR = "/libs/sling/mcp/prompts"; private final Logger logger = LoggerFactory.getLogger(getClass()); private ConcurrentMap<String, ServiceRegistration<DiscoveredPrompt>> registrations = new ConcurrentHashMap<>(); + private final DiscoveredPromptBuilder promptBuilder = new DiscoveredPromptBuilder(); @Activate public RepositoryPromptsRegistrar(@Reference ResourceResolverFactory rrf, BundleContext ctx) throws LoginException { @@ -128,40 +117,20 @@ public class RepositoryPromptsRegistrar { } } - private void registerPrompt(BundleContext ctx, String promptName, Resource prompt) { + private void registerPrompt(BundleContext ctx, String promptName, Resource promptResource) { - Map<String, String> serviceProps = new HashMap<>(Map.of(DiscoveredPrompt.SERVICE_PROP_NAME, promptName)); + try { + DiscoveredPrompt prompt = promptBuilder.buildPrompt(promptResource, promptName); - Parser parser = Parser.builder() - .extensions(List.of(YamlFrontMatterExtension.create())) - .build(); - String charset = prompt.getResourceMetadata().getCharacterEncoding(); - if (charset == null) { - charset = StandardCharsets.UTF_8.name(); - } - try (InputStream is = prompt.adaptTo(InputStream.class)) { - Node node = parser.parseReader(new InputStreamReader(is, charset)); - YamlFrontMatterVisitor visitor = new YamlFrontMatterVisitor(); - node.accept(visitor); - - visitor.getData().getOrDefault("title", List.of()).stream() - .findFirst() - .ifPresent(title -> serviceProps.put(DiscoveredPrompt.SERVICE_PROP_TITLE, title)); - visitor.getData().getOrDefault("description", List.of()).stream() - .findFirst() - .ifPresent(title -> serviceProps.put(DiscoveredPrompt.SERVICE_PROP_DESCRIPTION, title)); + var sr = ctx.registerService( + DiscoveredPrompt.class, + prompt, + new Hashtable<>(Map.of(DiscoveredPrompt.SERVICE_PROP_NAME, promptName))); + + registrations.put(promptName, sr); } catch (IOException e) { - logger.error("Error reading prompt markdown file at {}", prompt.getPath(), e); + logger.warn("Error registering prompt {} at path {}", promptName, promptResource.getPath(), e); } - - // TODO - discover additional properties from the markdown file (front matter) - - var sr = ctx.registerService( - DiscoveredPrompt.class, - new RepositoryPrompt(prompt.getPath()), - new Hashtable<String, String>(serviceProps)); - - registrations.put(promptName, sr); } private String getPromptName(String path) { @@ -177,32 +146,4 @@ public class RepositoryPromptsRegistrar { // remove .md extension return promptName.substring(0, promptName.length() - ".md".length()); } - - class RepositoryPrompt implements DiscoveredPrompt { - private final String promptPath; - - RepositoryPrompt(String promptPath) { - this.promptPath = promptPath; - } - - @Override - public List<PromptMessage> getPromptMessages(ResourceResolver resolver) { - - try { - Resource promptResource = resolver.getResource(promptPath); - String encoding = promptResource.getResourceMetadata().getCharacterEncoding(); - if (encoding == null) { - encoding = StandardCharsets.UTF_8.name(); - } - try (InputStream stream = promptResource.adaptTo(InputStream.class)) { - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - stream.transferTo(baos); - String contents = baos.toString(encoding); - return List.of(new PromptMessage(Role.ASSISTANT, new TextContent(contents))); - } - } catch (IOException e) { - return List.of(); - } - } - } } diff --git a/src/main/resources/SLING-INF/libs/sling/mcp/prompts/new-sling-servlet.md b/src/main/resources/SLING-INF/libs/sling/mcp/prompts/new-sling-servlet.md new file mode 100644 index 0000000..c92cb83 --- /dev/null +++ b/src/main/resources/SLING-INF/libs/sling/mcp/prompts/new-sling-servlet.md @@ -0,0 +1,17 @@ +--- +title: "Create new Sling Servlet" +description: "Creates a new Sling Servlet in the current project using annotations" +argument.resource-type: + - title: Resource Type + - required: true + - description: The Sling resource type to bind this servlet to +--- + +# Create a new Sling Servlet + + Create a new Sling Servlet for resource type: `{resource-type}` + + Use the Sling-specific OSGi declarative services annotations - `@SlingServletResourceTypes` and `@Component` . + Configure by default with the GET method and the json extension. + Provide a basic implementation of the doGet method that returns a JSON response with a message 'Hello from Sling Servlet at resource type {resource-type}'. + \ No newline at end of file diff --git a/src/test/java/org/apache/sling/mcp/server/impl/contribs/DiscoveredPromptBuilderTest.java b/src/test/java/org/apache/sling/mcp/server/impl/contribs/DiscoveredPromptBuilderTest.java new file mode 100644 index 0000000..cd8d58e --- /dev/null +++ b/src/test/java/org/apache/sling/mcp/server/impl/contribs/DiscoveredPromptBuilderTest.java @@ -0,0 +1,129 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.sling.mcp.server.impl.contribs; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.List; + +import io.modelcontextprotocol.spec.McpSchema.Prompt; +import io.modelcontextprotocol.spec.McpSchema.PromptArgument; +import org.apache.sling.api.resource.Resource; +import org.apache.sling.api.resource.ResourceMetadata; +import org.apache.sling.mcp.server.impl.DiscoveredPrompt; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class DiscoveredPromptBuilderTest { + + @Test + void basicMarkdownFile() throws IOException { + + String markdownFile = """ + # Basic Prompt + """; + + Resource promptResource = mock(Resource.class); + when(promptResource.getResourceMetadata()).thenReturn(new ResourceMetadata()); + when(promptResource.adaptTo(InputStream.class)) + .thenReturn(new ByteArrayInputStream(markdownFile.getBytes(StandardCharsets.UTF_8))); + + DiscoveredPromptBuilder builder = new DiscoveredPromptBuilder(); + DiscoveredPrompt prompt = builder.buildPrompt(promptResource, "basic-prompt"); + + assertThat(prompt) + .as("prompt") + .extracting(DiscoveredPrompt::asPrompt) + .extracting(Prompt::name, Prompt::title, Prompt::description, Prompt::arguments) + .containsExactly("basic-prompt", null, null, List.of()); + } + + @Test + void basicFrontMatter() throws IOException { + + String markdownFile = """ + --- + title: Basic Prompt + description: | + A basic prompt for testing. + Spans multiple lines. + --- + # Basic Prompt + """; + + Resource promptResource = mock(Resource.class); + when(promptResource.getResourceMetadata()).thenReturn(new ResourceMetadata()); + when(promptResource.adaptTo(InputStream.class)) + .thenReturn(new ByteArrayInputStream(markdownFile.getBytes(StandardCharsets.UTF_8))); + + DiscoveredPromptBuilder builder = new DiscoveredPromptBuilder(); + DiscoveredPrompt prompt = builder.buildPrompt(promptResource, "prompt-with-front-matter"); + + assertThat(prompt) + .as("prompt") + .extracting(DiscoveredPrompt::asPrompt) + .extracting(Prompt::name, Prompt::title, Prompt::description, Prompt::arguments) + .containsExactly( + "prompt-with-front-matter", + "Basic Prompt", + "A basic prompt for testing.\nSpans multiple lines.", + List.of()); + } + + @Test + void arguments() throws IOException { + + String markdownFile = """ + --- + title: Basic Prompt + argument.first: + - title: First Argument + - description: This is the first argument + - required: true + argument.second: + - title: Second Argument + --- + # Basic Prompt + """; + + Resource promptResource = mock(Resource.class); + when(promptResource.getResourceMetadata()).thenReturn(new ResourceMetadata()); + when(promptResource.adaptTo(InputStream.class)) + .thenReturn(new ByteArrayInputStream(markdownFile.getBytes(StandardCharsets.UTF_8))); + + DiscoveredPromptBuilder builder = new DiscoveredPromptBuilder(); + DiscoveredPrompt prompt = builder.buildPrompt(promptResource, "prompt-with-arguments"); + assertThat(prompt) + .as("prompt") + .extracting(DiscoveredPrompt::asPrompt) + .extracting(Prompt::name, Prompt::title, Prompt::description, Prompt::arguments) + .containsExactly( + "prompt-with-arguments", + "Basic Prompt", + null, + List.of( + new PromptArgument("first", "First Argument", "This is the first argument", true), + new PromptArgument("second", "Second Argument", null, false))); + } +}
