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);
}
}
33 changes: 31 additions & 2 deletions api/src/org/labkey/api/mcp/McpService.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,15 @@
import jakarta.servlet.http.HttpSession;
import org.jetbrains.annotations.NotNull;
import org.jspecify.annotations.NonNull;
import org.labkey.api.data.Container;
import org.labkey.api.module.McpProvider;
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.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 @@ -31,7 +35,28 @@
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 incrementResourceReadCount(String resource)
{
get().incrementResourceCount(resource);
}
}

static @NotNull McpService get()
{
Expand Down Expand Up @@ -79,6 +104,10 @@ default ChatClient getChat(HttpSession session, String agentName, Supplier<Strin
return getChat(session, agentName, systemPromptSupplier, true);
}

void saveSessionContainer(ToolContext context, Container container);

void incrementResourceCount(String resource);

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

void close(HttpSession session, ChatClient chat);
Expand Down
55 changes: 48 additions & 7 deletions core/src/org/labkey/core/CoreMcp.java
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
package org.labkey.core;

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.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.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 +23,13 @@

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 folder (name, path, url, description) once the container is set via setContainer.")
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 +67,41 @@ String whereAmIWhoAmITalkingTo()
"site", siteObj
)).toString();
}

@Tool(description = "List the hierarchical path for every container in the server where the user has read permissions.")
String listContainers(ToolContext toolContext)
{
return ContainerManager.getAllChildren(ContainerManager.getRoot(), getUser(toolContext), ReadPermission.class)
.stream()
.map(Container::getPath)
.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 session.")
String setContainer(ToolContext context, @ToolParam(description = "Container path, e.g. /MyProject/MyFolder", required = true) String containerPath)
{
final String message;

if (containerPath == null)
{
message = "Container path was null. Please enter a valid container path. Try using listContainers 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
{
McpService.get().saveSessionContainer(context, container);
message = "Container has been set";
}
}

return message;
}
}
6 changes: 0 additions & 6 deletions core/src/org/labkey/core/CoreModule.java
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,6 @@
import org.labkey.core.login.DbLoginAuthenticationProvider;
import org.labkey.core.login.DbLoginManager;
import org.labkey.core.login.LoginController;
import org.labkey.core.mcp.McpServiceImpl;
import org.labkey.core.metrics.SimpleMetricsServiceImpl;
import org.labkey.core.metrics.WebSocketConnectionManager;
import org.labkey.core.notification.EmailPreferenceConfigServiceImpl;
Expand Down Expand Up @@ -1288,8 +1287,6 @@ public void moduleStartupComplete(ServletContext servletContext)
UserManager.addUserListener(new EmailPreferenceUserListener());

Encryption.checkMigration();

McpServiceImpl.get().startMpcServer();
}

// Issue 7527: Auto-detect missing SQL views and attempt to recreate
Expand Down Expand Up @@ -1324,9 +1321,6 @@ public void registerServlets(ServletContext servletCtx)
_webdavServletDynamic = servletCtx.addServlet("static", new WebdavServlet(true));
_webdavServletDynamic.setMultipartConfig(SpringActionController.getMultiPartConfigElement());
_webdavServletDynamic.addMapping("/_webdav/*");

McpService.setInstance(new McpServiceImpl());
McpServiceImpl.get().registerServlets(servletCtx);
}

@Override
Expand Down
Loading
Loading