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
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