Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
d5032b7
MCP server production MVP
labkey-adam Mar 19, 2026
915d2a6
Merge remote-tracking branch 'origin/develop' into fb_mcp_mvp
labkey-adam Mar 19, 2026
5a656f3
Imports
labkey-adam Mar 19, 2026
bf1cf2b
listContainers
labkey-adam Mar 19, 2026
8ee2e9f
Ask user for container path and cache it. Clean up all endpoints.
labkey-adam Mar 19, 2026
6292044
Provide less severe guidance when container is missing
labkey-adam Mar 20, 2026
748f35b
Update Spring AI to 2.0.0-M3
labkey-adam Mar 20, 2026
b69c047
Fix arguments after Spring AI 2.0.0-M3 upgrade. Metrics. Add some @No…
labkey-adam Mar 21, 2026
6daabae
Treat McpException as guidance
labkey-adam Mar 21, 2026
ef65c92
Merge remote-tracking branch 'origin/develop' into fb_mcp_mvp
labkey-adam Mar 24, 2026
0dc76f7
Move McpServiceImpl to the Professional module
labkey-adam Mar 25, 2026
a9c1959
Better error handling
labkey-adam Mar 25, 2026
3adc8e1
Be more explicit about no container path
labkey-adam Mar 25, 2026
93e8575
Claude feedback
labkey-adam Mar 25, 2026
437abe6
Document search endpoints
labkey-adam Mar 25, 2026
2e38bd3
No leading slash
labkey-adam Mar 25, 2026
d378c1a
Tweaks
labkey-adam Mar 25, 2026
f21d9fd
Annotation-based permission checking for tools
labkey-adam Mar 25, 2026
dff04ac
Simple validation/guidance for missing parameters. Fix getSourceForSa…
labkey-adam Mar 25, 2026
f17141e
File-based module development guide
labkey-adam Mar 26, 2026
8fbc903
Send same message for non-existent and non-authorized container
labkey-adam Mar 26, 2026
1a05eeb
Validate all required parameters are provided before invoking tools
labkey-adam Mar 26, 2026
b50ea4d
Update message
labkey-adam Mar 26, 2026
db42f67
MCP developer guide
labkey-adam Mar 26, 2026
26f6c18
Merge remote-tracking branch 'origin/develop' into fb_mcp_mvp
labkey-adam Mar 26, 2026
78321e3
Merge remote-tracking branch 'origin/develop' into fb_mcp_mvp
labkey-adam Mar 30, 2026
b7cb058
Tweak content
labkey-adam Mar 30, 2026
00ab5af
No need for this check: framework handles missing parameters
labkey-adam Mar 30, 2026
c6338e1
Merge remote-tracking branch 'origin/develop' into fb_mcp_mvp
labkey-adam Mar 31, 2026
6a9bcee
Finish test
labkey-adam Apr 1, 2026
518071d
More targeted catch
labkey-adam Apr 1, 2026
e493fbb
Consistency
labkey-adam Apr 1, 2026
0d324b7
Switch to schemaName, tableName. New descriptions. Update test.
labkey-adam Apr 2, 2026
1d373b6
tableName -> queryName for consistency with other APIs
labkey-adam Apr 2, 2026
8f2a6c0
MCP server experimental feature
labkey-adam Apr 2, 2026
0a8758b
Merge remote-tracking branch 'origin/develop' into fb_mcp_mvp
labkey-adam Apr 2, 2026
f55386d
Merge remote-tracking branch 'origin/develop' into fb_mcp_mvp
labkey-adam Apr 3, 2026
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
11 changes: 11 additions & 0 deletions api/src/org/labkey/api/mcp/McpException.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package org.labkey.api.mcp;

// A special exception that MCP endpoints can throw when they want to provide guidance to the client without making
// it a big red error. The message will be extracted and sent as text to the client.
public class McpException extends RuntimeException
{
public McpException(String message)
{
super(message);
}
}
147 changes: 115 additions & 32 deletions api/src/org/labkey/api/mcp/McpService.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,19 @@

import io.modelcontextprotocol.server.McpServerFeatures;
import jakarta.servlet.http.HttpSession;
import org.apache.logging.log4j.Logger;
import org.jetbrains.annotations.NotNull;
import org.jspecify.annotations.NonNull;
import org.labkey.api.module.McpProvider;
import org.labkey.api.data.Container;
import org.labkey.api.security.User;
import org.labkey.api.services.ServiceRegistry;
import org.labkey.api.settings.OptionalFeatureService;
import org.labkey.api.util.HtmlString;
import org.springaicommunity.mcp.provider.resource.SyncMcpResourceProvider;
import org.labkey.api.util.logging.LogHelper;
import org.labkey.api.writer.ContainerUser;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.mcp.annotation.provider.resource.SyncMcpResourceProvider;
import org.springframework.ai.support.ToolCallbacks;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.ToolCallbackProvider;
Expand All @@ -19,23 +25,98 @@
import java.util.List;
import java.util.function.Supplier;

/**
* This service lets you expose functionality over the MCP protocol (only simple http for now). This allows
* external chat sessions to pull information from LabKey Server. These methods are also made available
* to chat session hosted by LabKey (see AbstractAgentAction).
* <p></p>
* These calls are not security checked. Any tools registered here must check user permissions. Maybe that
* will come as we get further along. Note that the LLM may make callbacks concerning containers other than the
* current container. This is an area for investigation.
*/
///
/// ### MCP Development Guide
/// `McpService` lets you expose functionality over the MCP protocol (only simple http for now). This allows external
/// chat sessions to pull information from LabKey Server. Exposed functionality is also made available to chat sessions
/// hosted by LabKey (see `AbstractAgentAction``).
///
/// ### Adding a new MCP class
/// 1. Create a new class that implements `McpImpl` (see below) in the appropriate module
/// 2. Register that class in your module `init()` method: `McpService.get().register(new MyMcp())`
/// 3. Add tools and resources
///
/// ### Adding a new MCP tool
/// 1. In your MCP class, create a new method that returns a String with the name you want to advertise
/// 2. Annotate it with `@Tool` and provide a detailed description. This description is important since it instructs
/// the LLM client (and the user) in the use of your tool.
/// 3. Annotate it with `@RequiredPermission(Class&lt;? extends Permission>)` or `@RequiredNoPermission`. **A
/// permission annotation is required, otherwise your tool will not be registered.**
/// 4. Add `ToolContext` as the first parameter to the method
/// 5. Add additional required or optional parameters to the method signature, as needed. Note that "required" is the
/// default. Again here, the parameter descriptions are very important. Provide examples.
/// 6. Use the helper method `getContext(ToolContext)` to retrieve the current `Container` and `User`
/// 7. Use the helper method `getUser(ToolContext)` in the rare cases where you need just a `User`
/// 8. Perform additional permissions checking (beyond what the annotations offer), where appropriate
/// 9. Filter all results to the current container, of course
/// 10. For any error conditions, throw exceptions with detailed information. These will get translated into appropriate
/// failure responses and the LLM client will attempt to correct the problem.
/// 11. For success cases, return a String with a message or JSON content, for example, `JSONObject.toString()`. Spring
/// has some limited ability to convert other objects into JSON strings, but we haven't experimented with that. See
/// `DefaultToolCallResultConverter` and the ability to provide a custom result converter via the `@Tool` annotation.
///
/// At registration time, the framework will:
/// - Ensure all tools are annotated for permissions
/// - Ensure there aren't multiple tools with the same name
///
/// On every tool request, before invoking any tool code, the framework will:
/// - Authenticate the user or provide a guest user
/// - Ensure a container has been set if the tool requires a container
/// - Verify that the user has whatever permissions are required based on the tool's annotation(s)
/// - Verify that every required parameter is non-null and every string parameter is non-blank
/// - Push the container and user into the ToolContext to give the tool access
/// - Increment a metrics counter for that tool
///
/// CoreMcp and QueryMcp have examples of tool declarations.
///
/// ### Adding a new MCP resource
/// 1. In your MCP class, create a new method that returns `ReadResourceResult` with an appropriate name
/// 2. Annotate it with `@McpResource` and provide a uri, mimeType, name, and description
/// 3. Call `incrementResourceRequestCount()` with a short but unique name to increment its metrics count
/// 4. Read the resource, construct a `ReadResourceResult`, and return it.
///
/// No permissions checking is performed on resources. All resources are public.
///
/// CoreMcp and QueryMcp have examples of resource declarations.
///
public interface McpService extends ToolCallbackProvider
{
// marker interface for classes that we will "ingest" using Spring annotations
interface McpImpl {}
Logger LOG = LogHelper.getLogger(McpService.class, "MCP registration exceptions");
String ENABLE_MCP_SERVER_FLAG = "enableMcpServer";

// Interface for MCP classes that we will "ingest" using Spring annotations. Provides a few helper methods.
interface McpImpl
{
default ContainerUser getContext(ToolContext toolContext)
{
User user = (User)toolContext.getContext().get("user");
Container container = (Container)toolContext.getContext().get("container");
if (container == null)
throw new McpException("No container path is set. Ask the user which container/folder they want to use (you can call listContainers to show available options), then call setContainer before retrying.");
return ContainerUser.create(container, user);
}

default User getUser(ToolContext toolContext)
{
return (User)toolContext.getContext().get("user");
}

// Every MCP resource should call this on every invocation
default void incrementResourceRequestCount(String resource)
{
if (!OptionalFeatureService.get().isFeatureEnabled(ENABLE_MCP_SERVER_FLAG))
throw new RuntimeException("The MCP server is not enabled for external requests. Consider toggling the experimental feature flag.");

get().incrementResourceRequestCount(resource);
}
}

static @NotNull McpService get()
{
return ServiceRegistry.get().getService(McpService.class);
McpService svc = ServiceRegistry.get().getService(McpService.class);
if (svc == null)
svc = NoopMcpService.get();
return svc;
}

static void setInstance(McpService service)
Expand All @@ -45,27 +126,25 @@ static void setInstance(McpService service)

boolean isReady();


default void register(McpImpl obj)
default void register(McpImpl mcp)
{
ToolCallback[] tools = ToolCallbacks.from(obj);
if (null != tools && tools.length > 0)
registerTools(Arrays.asList(tools));

var resources = new SyncMcpResourceProvider(List.of(obj)).getResourceSpecifications();
if (null != resources && !resources.isEmpty())
registerResources(resources);
try
{
ToolCallback[] tools = ToolCallbacks.from(mcp);
if (tools.length > 0)
registerTools(Arrays.asList(tools), mcp);

var resources = new SyncMcpResourceProvider(List.of(mcp)).getResourceSpecifications();
if (null != resources && !resources.isEmpty())
registerResources(resources);
}
catch (NoSuchMethodError t)
{
LOG.error("You likely need to do a clean build of API! Exception while registering an MCP implementation.", t);
}
}


default void register(McpProvider mcp)
{
registerTools(mcp.getMcpTools());
registerPrompts(mcp.getMcpPrompts());
registerResources(mcp.getMcpResources());
}

void registerTools(@NotNull List<ToolCallback> tools);
void registerTools(@NotNull List<ToolCallback> tools, McpImpl mcp);

void registerPrompts(@NotNull List<McpServerFeatures.SyncPromptSpecification> prompts);

Expand All @@ -79,6 +158,10 @@ default ChatClient getChat(HttpSession session, String agentName, Supplier<Strin
return getChat(session, agentName, systemPromptSupplier, true);
}

void saveSessionContainer(ToolContext context, Container container);

void incrementResourceRequestCount(String resource);

ChatClient getChat(HttpSession session, String agentName, Supplier<String> systemPromptSupplier, boolean createIfNotExists);

void close(HttpSession session, ChatClient chat);
Expand Down
87 changes: 87 additions & 0 deletions api/src/org/labkey/api/mcp/NoopMcpService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package org.labkey.api.mcp;

import io.modelcontextprotocol.server.McpServerFeatures;
import jakarta.servlet.http.HttpSession;
import org.jetbrains.annotations.NotNull;
import org.jspecify.annotations.NonNull;
import org.labkey.api.data.Container;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.vectorstore.VectorStore;

import java.util.List;
import java.util.function.Supplier;

class NoopMcpService implements McpService
{
private static final McpService INSTANCE = new NoopMcpService();

static McpService get()
{
return INSTANCE;
}

@Override
public boolean isReady()
{
return false;
}

@Override
public void registerTools(@NotNull List<ToolCallback> tools, McpImpl mcp)
{

}

@Override
public void registerPrompts(@NotNull List<McpServerFeatures.SyncPromptSpecification> prompts)
{

}

@Override
public void registerResources(@NotNull List<McpServerFeatures.SyncResourceSpecification> resources)
{

}

@Override
public ToolCallback @NonNull [] getToolCallbacks()
{
return new ToolCallback[0];
}

@Override
public void saveSessionContainer(ToolContext context, Container container)
{
}

@Override
public void incrementResourceRequestCount(String resource)
{
}

@Override
public ChatClient getChat(HttpSession session, String agentName, Supplier<String> systemPromptSupplier, boolean createIfNotExists)
{
return null;
}

@Override
public void close(HttpSession session, ChatClient chat)
{
}

@Override
public MessageResponse sendMessage(ChatClient chat, String message)
{
return null;
}

@Override
public VectorStore getVectorStore()
{
return null;
}
}
24 changes: 0 additions & 24 deletions api/src/org/labkey/api/module/McpProvider.java

This file was deleted.

12 changes: 6 additions & 6 deletions api/src/org/labkey/api/security/RequiresNoPermission.java
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,12 @@
import java.lang.annotation.Target;

/**
* Indicates that an action does not require any kind of authentication or permission to invoke. Use with extreme
* caution. Typically, actions marked with this annotation will handle their own permission checks in their own code path.
* User: adam
* Date: Dec 22, 2009
*/
public @Retention(java.lang.annotation.RetentionPolicy.RUNTIME) @Target(ElementType.TYPE)
* Indicates that an action class or an MCP tool method does not require any kind of authentication or permission to
* invoke. Use with extreme caution. Typically, actions marked with this annotation will handle their own permission
* checks in their own code path. Note that this is the lowest priority permission annotation; all other @Requires*
* annotations effectively override this annotation.
*/
public @Retention(java.lang.annotation.RetentionPolicy.RUNTIME) @Target({ElementType.TYPE, ElementType.METHOD})
@interface RequiresNoPermission
{
}
6 changes: 3 additions & 3 deletions api/src/org/labkey/api/security/RequiresPermission.java
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,10 @@
import java.lang.annotation.Target;

/**
* Specifies the required permission for an action. It does not imply that the user needs to be logged in or otherwise
* authenticated.
* Specifies the required permission for an action class or an MCP tool method. It does not imply that the user needs
* to be logged in or otherwise authenticated.
*/
@Retention(java.lang.annotation.RetentionPolicy.RUNTIME) @Target(ElementType.TYPE)
@Retention(java.lang.annotation.RetentionPolicy.RUNTIME) @Target({ElementType.TYPE, ElementType.METHOD})
public @interface RequiresPermission
{
Class<? extends Permission> value();
Expand Down
Loading
Loading