Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 51 additions & 4 deletions api/src/org/labkey/api/mcp/DocumentationService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
56 changes: 56 additions & 0 deletions api/src/org/labkey/api/mcp/ForwardingDocumentationService.java
Original file line number Diff line number Diff line change
@@ -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<String, Object> 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));
}
}
2 changes: 1 addition & 1 deletion api/src/org/labkey/api/settings/AppProps.java
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,7 @@ static WriteableAppProps getWriteableInstance()

@NotNull String getAllowedExternalResourceHosts();

default @Nullable String getDocumentationServer()
default @NotNull String getDocumentationServer()
{
return "https://www.labkey.org";
}
Expand Down
57 changes: 2 additions & 55 deletions core/src/org/labkey/core/CoreMcp.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -38,16 +36,13 @@
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;
import org.springframework.ai.tool.annotation.Tool;
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;
Expand All @@ -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<String, Object> 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 " +
Expand All @@ -94,35 +61,15 @@ 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<String, Object> 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`.")
@RequiresNoPermission
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 " +
Expand Down
Loading