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
27 changes: 24 additions & 3 deletions api/src/org/labkey/api/data/TableSelectorTestCase.java
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

import org.apache.commons.lang3.mutable.MutableInt;
import org.apache.logging.log4j.Level;
import org.apache.logging.log4j.Logger;
import org.junit.Test;
import org.labkey.api.collections.CsvSet;
import org.labkey.api.data.Selector.ForEachBlock;
Expand All @@ -30,6 +31,7 @@
import org.labkey.api.util.ExceptionUtil;
import org.labkey.api.util.PageFlowUtil;
import org.labkey.api.util.TestContext;
import org.labkey.api.util.logging.LogHelper;
import org.springframework.jdbc.UncategorizedSQLException;

import java.sql.ResultSet;
Expand All @@ -50,6 +52,8 @@

public class TableSelectorTestCase extends AbstractSelectorTestCase<TableSelector>
{
private static final Logger LOG = LogHelper.getLogger(TableSelectorTestCase.class, "Test progress");

@Test
public void testTableSelector() throws SQLException
{
Expand All @@ -59,13 +63,25 @@ public void testTableSelector() throws SQLException
// testTableSelector(DbSchema.get("oracle.granite", DbSchemaType.Bare).getTable("account"), Account.class);

// Test MySQL or MariaDB database, if present
List<DbScope> mySqlScopes = Stream.of("mySql", "mariadb")
.map(DbScope::getDbScope).filter(Objects::nonNull).toList();
List<DbScope> mySqlScopes = DbScope.getDbScopesToTest().stream()
.filter(scope -> Set.of("MySQL", "MariaDB").contains(scope.getSqlDialect().getProductName()))
.toList();

for (DbScope mySqlScope: mySqlScopes)
{
DbSchema sakila = mySqlScope.getSchema("sakila", DbSchemaType.Bare);
if (sakila.existsInDatabase())
testTableSelector(sakila.getTable("country"), Country.class);
{
testTableSelector(sakila.getTable("Country"), Country.class);
}
else
{
DbSchema sys = mySqlScope.getSchema("sys", DbSchemaType.Bare);
if (sys.existsInDatabase())
{
testTableSelector(sys.getTable("sys_config"), Config.class);
}
}
}
testTableSelector(CoreSchema.getInstance().getTableInfoActiveUsers(), User.class);
testTableSelector(CoreSchema.getInstance().getTableInfoModules(), ModuleContext.class);
Expand Down Expand Up @@ -124,6 +140,8 @@ public int hashCode()
}
}

record Config(String Variable, String Value, Date Set_Time, String Set_By){}

// public static class Account
// {
// private int _account_id;
Expand Down Expand Up @@ -430,6 +448,9 @@ private void testColumnList(TableSelector selector, boolean stable) throws SQLEx

private <K> void testTableSelector(TableInfo table, Class<K> clazz) throws SQLException
{
DbSchema schema = table.getSchema();
LOG.info("Testing {}.{}.{}", schema.getScope().getDisplayName(), schema.getName(), table.getName());

TableSelector selector = new TableSelector(table);

test(selector, clazz);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ protected String parseDatabase(String url) throws ServletException
if (-1 == dbEnd)
dbEnd = url.length();

// Last '/' is the database delimiter, except for "jdbc:postgresql:database"
// Last '/' is the database delimiter, except for "jdbc:postgresql:database" and old Oracle formats
char dbDelimiter = url.contains("/") ? '/' : ':';
int dbDelimiterIndex = url.lastIndexOf(dbDelimiter, dbEnd);

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
Loading