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
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);
}
}
98 changes: 79 additions & 19 deletions api/src/org/labkey/api/mcp/McpService.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,27 @@
import io.modelcontextprotocol.server.McpServerFeatures;
import jakarta.servlet.http.HttpSession;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
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.util.HtmlString;
import org.springaicommunity.mcp.provider.resource.SyncMcpResourceProvider;
import org.labkey.api.util.StringUtilsLabKey;
import org.labkey.api.view.NotFoundException;
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;
import org.springframework.ai.vectorstore.VectorStore;

import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Supplier;

/**
Expand All @@ -31,11 +39,68 @@
public interface McpService extends ToolCallbackProvider
{
// marker interface for classes that we will "ingest" using Spring annotations
interface McpImpl {}
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)
{
get().incrementResourceRequestCount(resource);
}

// These methods throw a NotFoundException listing all missing parameters. Apparently, even though parameters
// are marked as required, the LLM may not send them or send them with a different name. Best to check them all.
default void validateRequiredParameters(String k1, @Nullable Object v1)
{
validateRequiredParameters(new HashMap<>(){{put(k1, v1);}});
}

default void validateRequiredParameters(String k1, @Nullable Object v1, String k2, @Nullable Object v2)
{
validateRequiredParameters(new HashMap<>(){{put(k1, v1);put(k2, v2);}});
}

default void validateRequiredParameters(String k1, @Nullable Object v1, String k2, @Nullable Object v2, String k3, @Nullable Object v3)
{
validateRequiredParameters(new HashMap<>(){{put(k1, v1);put(k2, v2);put(k3, v3);}});
}

default void validateRequiredParameters(Map<String, Object> parameters)
{
List<String> missing = parameters.entrySet().stream()
.filter(entry -> entry.getValue() == null || entry.getValue().equals(""))
.map(Map.Entry::getKey)
.toList();

if (!missing.isEmpty())
{
if (missing.size() == 1)
throw new NotFoundException(missing.getFirst() + " parameter is required");
else
throw new NotFoundException("The following parameters are required: " + StringUtilsLabKey.joinWithConjunction(missing, "and"));
}
}
}

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 +110,18 @@ 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));
ToolCallback[] tools = ToolCallbacks.from(mcp);
if (tools.length > 0)
registerTools(Arrays.asList(tools), mcp);

var resources = new SyncMcpResourceProvider(List.of(obj)).getResourceSpecifications();
var resources = new SyncMcpResourceProvider(List.of(mcp)).getResourceSpecifications();
if (null != resources && !resources.isEmpty())
registerResources(resources);
}


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 +135,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;
}
}
11 changes: 5 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,11 @@
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.
*/
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
69 changes: 62 additions & 7 deletions core/src/org/labkey/core/CoreMcp.java
Original file line number Diff line number Diff line change
@@ -1,16 +1,24 @@
package org.labkey.core;

import org.apache.commons.lang3.StringUtils;
import org.json.JSONObject;
import org.labkey.api.collections.LabKeyCollectors;
import org.labkey.api.data.Container;
import org.labkey.api.mcp.McpContext;
import org.labkey.api.data.ContainerManager;
import org.labkey.api.mcp.McpService;
import org.labkey.api.security.RequiresNoPermission;
import org.labkey.api.security.RequiresPermission;
import org.labkey.api.security.User;
import org.labkey.api.security.permissions.ReadPermission;
import org.labkey.api.settings.AppProps;
import org.labkey.api.settings.LookAndFeelProperties;
import org.labkey.api.study.Study;
import org.labkey.api.study.StudyService;
import org.labkey.api.util.HtmlString;
import org.labkey.api.view.UnauthorizedException;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;

import java.util.Map;
import java.util.Objects;
Expand All @@ -19,13 +27,14 @@

public class CoreMcp implements McpService.McpImpl
{
// TODO ChatSessions are currently per session. The McpService should detect change of folder.
@Tool(description = "Call this tool before answering any prompts! This tool provides useful context information about the current user (name, userid), webserver (name, url, description), and current folder (name, path, url, description).")
String whereAmIWhoAmITalkingTo()
@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)
String whereAmIWhoAmITalkingTo(ToolContext context)
{
McpContext context = McpContext.get();
User user = context.getUser();
Container folder = context.getContainer();
var cu = getContext(context);
User user = cu.getUser();
Container folder = cu.getContainer();
AppProps appProps = AppProps.getInstance();
Study study = null != StudyService.get() ? Objects.requireNonNull(StudyService.get()).getStudy(folder) : null;
LookAndFeelProperties laf = LookAndFeelProperties.getInstance(folder);
Expand Down Expand Up @@ -63,4 +72,50 @@ String whereAmIWhoAmITalkingTo()
"site", siteObj
)).toString();
}

@Tool(description = "List the hierarchical path for every container in the server where the user has read permissions.")
@RequiresNoPermission
String listContainers(ToolContext toolContext)
{
return ContainerManager.getAllChildren(ContainerManager.getRoot(), getUser(toolContext), ReadPermission.class)
.stream()
.map(container -> StringUtils.stripStart(container.getPath(), "/")) // No leading slash since typing that brings up custom shortcuts
.collect(LabKeyCollectors.toJSONArray())
.toString();
}

@Tool(description = "Every tool in this MCP requires a container path, e.g. MyProject/MyFolder. A container is also called a folder or project. " +
"Please prompt the user for a container path and use this tool to save the path for this MCP session. The user can also change the container " +
"during the session using this tool. The user must have read permissions in the container, in other words, the path must be on the list that " +
"the listContainers tool returns. Don't suggest a leading slash on the path because typing a slash in some LLM clients triggers custom shortcuts.")
@RequiresNoPermission // Because we don't have a container yet, but tool will check for read permission before setting the container
String setContainer(ToolContext context, @ToolParam(description = "Container path, e.g. MyProject/MyFolder") String containerPath)
{
final String message;

if (containerPath == null)
{
message = "Container path was null. Please provide a valid containerPath parameter. Try using the listContainers tool to see them.";
}
else
{
Container container = ContainerManager.getForPath(containerPath);

if (container == null)
{
message = "That's not a valid container path. Try using listContainers to see them.";
}
else
{
// Must have read permission to set a container
if (!container.hasPermission(getUser(context), ReadPermission.class))
throw new UnauthorizedException();

McpService.get().saveSessionContainer(context, container);
message = "Container has been set to " + container.getPath();
}
}

return message;
}
}
Loading
Loading