diff --git a/api/src/org/labkey/api/mcp/DocumentationService.java b/api/src/org/labkey/api/mcp/DocumentationService.java index 3982e118560..ba5bcecf2e2 100644 --- a/api/src/org/labkey/api/mcp/DocumentationService.java +++ b/api/src/org/labkey/api/mcp/DocumentationService.java @@ -18,20 +18,67 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.labkey.api.services.ServiceRegistry; +import org.labkey.api.settings.AppProps; +import org.labkey.api.util.URLHelper; +import org.springframework.ai.chat.model.ToolContext; + +import java.net.URISyntaxException; + +import static org.apache.commons.lang3.StringUtils.isBlank; /** - * Local implementation of documentation search/retrieval, backing the {@code searchDocumentation}/ - * {@code retrieveDocument} MCP tools in {@code CoreMcp}. Only registered on servers that host the LabKey - * documentation content and its vector store (currently www.labkey.org); every other server forwards those - * tool calls to www.labkey.org instead of calling this service. See {@link #isEnabled()}. + * Documentation search/retrieval, backing the {@code searchDocumentation}/{@code retrieveDocument} MCP tools in + * {@code CoreMcp}. The registered implementation does the work locally and exists only on servers that host the + * LabKey documentation content and its vector store (currently www.labkey.org); elsewhere, {@link #get(ToolContext)} + * returns an unregistered implementation that forwards each call to that server. See {@link #isEnabled()}. */ public interface DocumentationService { + /** + * The registered local implementation, or null if no module provides one; it serves documentation only where + * {@link #isEnabled()}. Practically speaking, the local implementation is provided by the Service Tools module, + * which is deployed on labkey.org (and staging.labkey.org) only. + **/ static @Nullable DocumentationService get() { return ServiceRegistry.get().getService(DocumentationService.class); } + /** + * The implementation an MCP tool call should use: the local one if enabled, otherwise one that forwards to the + * configured documentation server. Chosen per call since settings and the forwarded-request guard can change. + * @throws McpException if neither is available + */ + static @NotNull DocumentationService get(ToolContext context) + { + DocumentationService local = get(); + if (local != null && local.isEnabled()) + return local; + + String documentationServer = AppProps.getInstance().getDocumentationServer(); + // A forwarded call must never be forwarded again + if (!McpToolProxy.isForwarded(context) && isRemote(documentationServer)) + return new ForwardingDocumentationService(documentationServer); + + throw new McpException("Documentation is not available on this server."); + } + + private static boolean isRemote(String documentationServer) + { + if (isBlank(documentationServer)) + return false; + + try + { + // Disallow call back to self. If this looks like a self-reference (www.labkey.org trying to call www.labkey.org) return false. + return !new URLHelper(documentationServer).getHost().equalsIgnoreCase(new URLHelper(AppProps.getInstance().getBaseServerUrl()).getHost()); + } + catch (URISyntaxException x) + { + return false; + } + } + static void setInstance(DocumentationService impl) { ServiceRegistry.get().registerService(DocumentationService.class, impl); diff --git a/api/src/org/labkey/api/mcp/ForwardingDocumentationService.java b/api/src/org/labkey/api/mcp/ForwardingDocumentationService.java new file mode 100644 index 00000000000..cf06da89232 --- /dev/null +++ b/api/src/org/labkey/api/mcp/ForwardingDocumentationService.java @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2026 LabKey Corporation + * + * Licensed 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.labkey.api.mcp; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.HashMap; +import java.util.Map; + +/** Forwards documentation tool calls to the documentation server's MCP endpoint, whose tools share CoreMcp's names and parameters */ +class ForwardingDocumentationService implements DocumentationService +{ + private final String _documentationServer; + + ForwardingDocumentationService(@NotNull String documentationServer) + { + _documentationServer = documentationServer; + } + + /** Created only when forwarding is possible; the feature flag belongs to the local implementation */ + @Override + public boolean isEnabled() + { + return true; + } + + @Override + public String searchDocumentation(@NotNull String query, @Nullable Integer topK) + { + Map arguments = new HashMap<>(); + arguments.put("query", query); + if (topK != null) + arguments.put("topK", topK); + return McpToolProxy.forward(_documentationServer, "searchDocumentation", arguments); + } + + @Override + public String retrieveDocument(@NotNull String id) + { + return McpToolProxy.forward(_documentationServer, "retrieveDocument", Map.of("id", id)); + } +} diff --git a/api/src/org/labkey/api/settings/AppProps.java b/api/src/org/labkey/api/settings/AppProps.java index a9c61fff7c4..0d62d816f28 100644 --- a/api/src/org/labkey/api/settings/AppProps.java +++ b/api/src/org/labkey/api/settings/AppProps.java @@ -275,7 +275,7 @@ static WriteableAppProps getWriteableInstance() @NotNull String getAllowedExternalResourceHosts(); - default @Nullable String getDocumentationServer() + default @NotNull String getDocumentationServer() { return "https://www.labkey.org"; } diff --git a/core/src/org/labkey/core/CoreMcp.java b/core/src/org/labkey/core/CoreMcp.java index 4522a248400..804295dc509 100644 --- a/core/src/org/labkey/core/CoreMcp.java +++ b/core/src/org/labkey/core/CoreMcp.java @@ -25,8 +25,6 @@ import org.labkey.api.data.Container; import org.labkey.api.data.ContainerManager; import org.labkey.api.mcp.DocumentationService; -import org.labkey.api.mcp.McpException; -import org.labkey.api.mcp.McpToolProxy; import org.labkey.api.mcp.McpService; import org.labkey.api.module.ModuleLoader; import org.labkey.api.security.RequiresNoPermission; @@ -38,7 +36,6 @@ import org.labkey.api.study.Study; import org.labkey.api.study.StudyService; import org.labkey.api.util.HtmlString; -import org.labkey.api.util.URLHelper; import org.labkey.api.view.ActionURL; import org.springframework.ai.chat.model.ToolContext; import org.springframework.ai.mcp.annotation.McpResource; @@ -46,8 +43,6 @@ import org.springframework.ai.tool.annotation.ToolParam; import java.io.IOException; -import java.net.URISyntaxException; -import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; @@ -57,34 +52,6 @@ public class CoreMcp implements McpService.McpImpl { - private static boolean isDocumentationSourceRemote(ToolContext context) - { - if (McpToolProxy.isForwarded(context)) - return false; - if (DocumentationService.get() instanceof DocumentationService s && s.isEnabled()) - return false; - var documentationServer = AppProps.getInstance().getDocumentationServer(); - if (isBlank(documentationServer)) - return false; - - var baseServerUrl = AppProps.getInstance().getBaseServerUrl(); - try - { - // Disallow call back to self. If this looks like a self-reference (www.labkey.org trying to call www.labkey.org) return false. - return !(new URLHelper(documentationServer).getHost().equalsIgnoreCase(new URLHelper(baseServerUrl).getHost())); - } - catch (URISyntaxException x) - { - return false; - } - } - - private String forward(String remoteToolName, Map arguments) - { - return McpToolProxy.forward(AppProps.getInstance().getDocumentationServer(), remoteToolName, arguments); - } - - @Tool(description = "Search the LabKey documentation for chunks of text semantically similar to a natural language query. " + "Each result is an excerpt from a larger document, not the full document -- multiple results may come from the same " + "source document. Returns each chunk's content, metadata (title, source URL, content type), a similarity score, and " + @@ -94,19 +61,7 @@ String searchDocumentation(ToolContext context, @ToolParam(description = "Natural language search query describing what you're looking for") String query, @ToolParam(required = false, description = "Maximum number of results to return, defaults to 5") Integer topK) { - if (isDocumentationSourceRemote(context)) - { - Map arguments = new HashMap<>(); - arguments.put("query", query); - if (topK != null) - arguments.put("topK", topK); - return forward("searchDocumentation", arguments); - } - - DocumentationService svc = DocumentationService.get(); - if (svc == null || !svc.isEnabled()) - throw new McpException("Documentation search is not available on this server."); - return svc.searchDocumentation(query, topK); + return DocumentationService.get(context).searchDocumentation(query, topK); } @Tool(description = "Return the entire document from the LabKey documentation using the `id` as returned by `searchDocumentation`.") @@ -114,15 +69,7 @@ String searchDocumentation(ToolContext context, String retrieveDocument(ToolContext context, @ToolParam(description = "Id of the document to return") String id) { - if (isDocumentationSourceRemote(context)) - { - return forward("retrieveDocument", Map.of("id", id)); - } - - DocumentationService svc = DocumentationService.get(); - if (svc == null || !svc.isEnabled()) - throw new McpException("Documentation retrieval is not available on this server."); - return svc.retrieveDocument(id); + return DocumentationService.get(context).retrieveDocument(id); } @Tool(description = "This tool provides useful context information about the current user (name, userid), webserver " +