Skip to content
Open
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
6 changes: 4 additions & 2 deletions api/src/org/labkey/api/action/ConfirmAction.java
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,10 @@ public final ModelAndView handleRequest() throws Exception
ModelAndView mv = getSuccessView(form);
if (null != mv)
return mv;
throw new RedirectException(getSuccessURL(form));
URLHelper redirect = getSuccessURL(form);
if (null != redirect)
throw new RedirectException(redirect);
return null;
}
}
else
Expand Down Expand Up @@ -137,7 +140,6 @@ public void validate(@NotNull Object form, @NotNull Errors errors)
/* Generic version of validate */
public abstract void validateCommand(FORM form, Errors errors);

@NotNull
public abstract URLHelper getSuccessURL(FORM form);

// not usually used but some actions return views that close the current window etc...
Expand Down
1 change: 1 addition & 0 deletions api/src/org/labkey/api/admin/AdminUrls.java
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ public interface AdminUrls extends UrlProvider

ActionURL getAllowedExternalRedirectHostsURL();
ActionURL getDeleteEncryptedContentURL();
ActionURL getOptionalFeaturesURL();

/**
* Simply adds an "Admin Console" link to nav trail if invoked in the root container. Otherwise, root is unchanged.
Expand Down
51 changes: 51 additions & 0 deletions api/src/org/labkey/api/mcp/DocumentationService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/*
* 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.Nullable;
import org.labkey.api.services.ServiceRegistry;

/**
* 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()}.
*/
public interface DocumentationService
{
static @Nullable DocumentationService get()
{
return ServiceRegistry.get().getService(DocumentationService.class);
}

static void setInstance(DocumentationService impl)
{
ServiceRegistry.get().registerService(DocumentationService.class, impl);
}

/**
* True if this server is enabled as the documentation source. The backing optional feature flag is owned by
* whichever module registers an implementation (currently serviceTools), not this interface, so the flag
* only exists at all on servers that have that module installed.
*/
boolean isEnabled();

/** Returns a JSON string; see CoreMcp's searchDocumentation tool description for the response shape. */
String searchDocumentation(String query, @Nullable Integer topK);

/** Returns a JSON string; see CoreMcp's retrieveDocument tool description for the response shape. */
String retrieveDocument(String id);
}
114 changes: 114 additions & 0 deletions api/src/org/labkey/api/mcp/McpToolProxy.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/*
* 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 io.modelcontextprotocol.client.McpClient;
import io.modelcontextprotocol.client.McpSyncClient;
import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport;
import io.modelcontextprotocol.spec.McpSchema;
import org.apache.logging.log4j.Logger;
import org.labkey.api.util.logging.LogHelper;

import java.util.Map;
import java.util.stream.Collectors;

/**
* Proxy for calling an MCP tool on a remote server.
*
* A single {@link McpSyncClient} is created lazily and reused for the lifetime of this instance; the MCP
* initialize handshake only happens once, on the first forwarded call.
*/
public class McpToolProxy
{
private static final Logger LOG = LogHelper.getLogger(McpToolProxy.class, "MCP tool forwarding");

private final String remoteBaseUrl;
private volatile McpSyncClient client;

public McpToolProxy(String remoteBaseUrl)
{
this.remoteBaseUrl = remoteBaseUrl;
}

private McpSyncClient getClient()
{
McpSyncClient c = client;
if (c == null)
{
synchronized (this)
{
c = client;
if (c == null)
{
var transport = HttpClientStreamableHttpTransport.builder(remoteBaseUrl).build();
c = McpClient.sync(transport)
.clientInfo(McpSchema.Implementation.builder("labkey-server-forwarder", "1.0").build())
.build();
c.initialize();
client = c;
}
}
}
return c;
}

// Drop the cached client after a failed call, so the next attempt reconnects instead of reusing a dead session
private synchronized void resetClient()
{
if (client != null)
{
try
{
client.closeGracefully();
}
catch (RuntimeException ignore)
{
// already broken; nothing to do
}
client = null;
}
}

/**
* Calls {@code remoteToolName} on the remote MCP server with {@code arguments} and returns its text content
* (joined, if the tool returned more than one text content block). Throws if the remote server can't be
* reached or the remote tool itself reports an error.
*/
public String forward(String remoteToolName, Map<String, Object> arguments)
{
McpSchema.CallToolResult result;
try
{
result = getClient().callTool(McpSchema.CallToolRequest.builder(remoteToolName).arguments(arguments).build());
}
catch (RuntimeException e)
{
LOG.error("Failed to forward MCP tool call '{}' to {}", remoteToolName, remoteBaseUrl, e);
resetClient();
throw new McpException("Unable to reach " + remoteBaseUrl + " to forward '" + remoteToolName + "': " + e.getMessage());
}

String text = result.content().stream()
.filter(content -> content instanceof McpSchema.TextContent)
.map(content -> ((McpSchema.TextContent) content).text())
.collect(Collectors.joining("\n"));

if (Boolean.TRUE.equals(result.isError()))
throw new McpException("Remote tool '" + remoteToolName + "' at " + remoteBaseUrl + " reported an error: " + text);

return text;
}
}
5 changes: 5 additions & 0 deletions api/src/org/labkey/api/settings/AppProps.java
Original file line number Diff line number Diff line change
Expand Up @@ -279,4 +279,9 @@ static WriteableAppProps getWriteableInstance()
@NotNull List<String> getAllowedExtensions();

@NotNull String getAllowedExternalResourceHosts();

default @Nullable String getDocumentationServer()
{
return "https://www.labkey.org";
}
}
5 changes: 2 additions & 3 deletions api/src/org/labkey/api/wiki/WikiService.java
Original file line number Diff line number Diff line change
Expand Up @@ -124,9 +124,8 @@ default HtmlString getHtml(Container c, String name)
* <p>Each {@link org.labkey.api.mcp.McpService.VectorDocument} is assigned an ID of the form
* {@code "<containerEntityId>/<wikiEntityId>"}, where both components are GUIDs
* (as returned by {@link Container#getId()} and the wiki's own entity ID).
* Tools that consume vector store results (e.g. {@code listDocuments},
* {@code retrieveDocument}) must use this same format when constructing or
* interpreting document IDs.</p>
* Tools that consume vector store results (e.g. {@code retrieveDocument}) must use this same
* format when constructing or interpreting document IDs.</p>
*
* @return the number of documents added
*/
Expand Down
98 changes: 98 additions & 0 deletions core/src/org/labkey/core/CoreMcp.java
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@
import org.labkey.api.collections.LabKeyCollectors;
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 @@ -35,21 +38,116 @@
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;

import static org.apache.commons.lang3.StringUtils.isBlank;
import static org.apache.commons.lang3.StringUtils.isNotBlank;

public class CoreMcp implements McpService.McpImpl
{
// Lazily created, then reused for the lifetime of this instance -- see McpToolProxy's class javadoc.
private volatile McpToolProxy documentationProxy;

public CoreMcp()
{
}

private static boolean isDocumentationSourceRemote()
{
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()));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add comment: "Don't allow proxying to yourself"

}
catch (URISyntaxException x)
{
return false;
}
}

private McpToolProxy getDocumentationProxy()
{
McpToolProxy proxy = documentationProxy;
if (proxy == null)
{
synchronized (this)
{
proxy = documentationProxy;
if (proxy == null)
{
proxy = new McpToolProxy(AppProps.getInstance().getDocumentationServer());
documentationProxy = proxy;
}
}
}
return proxy;
}

private String forward(String remoteToolName, Map<String, Object> arguments)
{
return getDocumentationProxy().forward(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 " +
"an id identifying the source document; pass that id to retrieveDocument to fetch the entire document.")
@RequiresNoPermission
String searchDocumentation(
@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())
{
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);
}

@Tool(description = "Return the entire document from the LabKey documentation using the `id` as returned by `searchDocumentation`.")
@RequiresNoPermission
String retrieveDocument(
@ToolParam(description = "Id of the document to return") String id)
{
if (isDocumentationSourceRemote())
{
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);
}

@Tool(description = "This tool provides useful context information about the current user (name, userid), webserver " +
"(name, url, description), and current container/folder (name, path, url, description) once the container is set via setContainer.")
@RequiresPermission(ReadPermission.class)
Expand Down
4 changes: 2 additions & 2 deletions core/src/org/labkey/core/DataAnalysis_Python.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ When writing Python scripts, read the server URL from `.mcp.json` to pre-populat
Confirm all of these settings with the analyst before writing any script.

## Online Reference Material
https://www.labkey.org/Documentation/wiki-page.view?name=python
For questions about the `labkey` Python package, LabKey SQL, or server concepts not covered here, call `searchDocumentation` (e.g. `searchDocumentation("labkey python APIWrapper select_rows filters")`), then `retrieveDocument` for the full page.

## MCP Tools Available

Expand Down Expand Up @@ -291,7 +291,7 @@ All modification APIs accept `timeout=300`, `container_path=None`, `transacted=T

10. **select_rows sends a GET request** to `query-getQuery.api`. `execute_sql` sends a POST to `query-executeSql.api`.

11. **LabKey SQL is not standard SQL.** It is a SQL dialect specific to LabKey. Use `mcp__labkey__validateSQL` to check syntax before executing. Refer to LabKey documentation for dialect-specific features (e.g., lookup column traversal via `/` or `.` notation).
11. **LabKey SQL is not standard SQL.** It is a SQL dialect specific to LabKey. Use `mcp__labkey__validateSQL` to check syntax before executing. For dialect-specific features (e.g., lookup column traversal via `/` or `.` notation), read the LabKey SQL resource (`resource://org/labkey/query/controllers/prompts/LabKeySql.md`) or call `searchDocumentation`.

## Typical Analysis Workflow

Expand Down
4 changes: 2 additions & 2 deletions core/src/org/labkey/core/DataAnalysis_R.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ When writing R scripts, read the server URL from `.mcp.json` (via `jsonlite::fro
Confirm all of these settings with the analyst before writing any script.

## Online Reference Material
https://www.labkey.org/Documentation/wiki-page.view?name=rAPI
For questions about the Rlabkey package, LabKey SQL, or server concepts not covered here, call `searchDocumentation` (e.g. `searchDocumentation("Rlabkey selectRows executeSql filters")`), then `retrieveDocument` for the full page.

## MCP Tools Available

Expand Down Expand Up @@ -420,7 +420,7 @@ Data frames passed to modification functions must be created with `stringsAsFact

10. **baseUrl must include the context path and trailing slash**: e.g. `"http://localhost:8080/labkey/"` if the server uses a context path, or `"http://localhost:8080/"` if it does not.

11. **LabKey SQL is not standard SQL.** It is a SQL dialect specific to LabKey. Use `mcp__labkey__validateSQL` to check syntax before executing. Refer to LabKey documentation for dialect-specific features (e.g., lookup column traversal via `/` or `.` notation).
11. **LabKey SQL is not standard SQL.** It is a SQL dialect specific to LabKey. Use `mcp__labkey__validateSQL` to check syntax before executing. For dialect-specific features (e.g., lookup column traversal via `/` or `.` notation), read the LabKey SQL resource (`resource://org/labkey/query/controllers/prompts/LabKeySql.md`) or call `searchDocumentation`.

12. **SSL configuration**: For HTTPS servers on Windows, you may need to set the `RLABKEY_CAINFO_FILE` environment variable pointing to a CA bundle file. Use `labkey.acceptSelfSignedCerts()` for development servers with self-signed certificates.

Expand Down
6 changes: 1 addition & 5 deletions core/src/org/labkey/core/FileBasedModules.md
Original file line number Diff line number Diff line change
Expand Up @@ -420,12 +420,8 @@ Simply refresh your browser to see changes.

## Documentation Resources

For more information, see:
- Simple Modules Overview: https://www.labkey.org/Documentation/wiki-page.view?name=simpleModules
- File-Based Module Tutorial: https://www.labkey.org/Documentation/wiki-page.view?name=moduleqvr
- JavaScript API Documentation: https://labkey.github.io/labkey-api-js/
- Module Directory Structures: https://www.labkey.org/Documentation/wiki-page.view?name=moduleDirectoryStructures
- Query Development: https://www.labkey.org/Documentation/wiki-page.view?name=addSQLQuery
- For everything else (simple modules overview, the file-based module tutorial, module directory structures, query development), call `searchDocumentation` — e.g. `searchDocumentation("file-based module directory structure")` — then `retrieveDocument` for the full page.

## Quick Start Checklist

Expand Down
Loading