From d5032b7285e1cc07e4d7d132dec6b848e2766585 Mon Sep 17 00:00:00 2001 From: Adam Rauch Date: Wed, 18 Mar 2026 18:16:20 -0700 Subject: [PATCH 01/19] MCP server production MVP --- core/src/org/labkey/core/CoreMcp.java | 13 +- .../org/labkey/core/mcp/McpServiceImpl.java | 113 +++++++++++------- 2 files changed, 75 insertions(+), 51 deletions(-) diff --git a/core/src/org/labkey/core/CoreMcp.java b/core/src/org/labkey/core/CoreMcp.java index 06482fe5627..de0c5f9a7c1 100644 --- a/core/src/org/labkey/core/CoreMcp.java +++ b/core/src/org/labkey/core/CoreMcp.java @@ -1,8 +1,8 @@ package org.labkey.core; +import io.modelcontextprotocol.server.McpSyncServerExchange; import org.json.JSONObject; import org.labkey.api.data.Container; -import org.labkey.api.mcp.McpContext; import org.labkey.api.mcp.McpService; import org.labkey.api.security.User; import org.labkey.api.settings.AppProps; @@ -10,6 +10,7 @@ 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 java.util.Map; @@ -19,13 +20,11 @@ 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 = "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(ToolContext context) { - McpContext context = McpContext.get(); - User user = context.getUser(); - Container folder = context.getContainer(); + User user = (User)context.getContext().get("user"); + Container folder = (Container)context.getContext().get("container"); AppProps appProps = AppProps.getInstance(); Study study = null != StudyService.get() ? Objects.requireNonNull(StudyService.get()).getStudy(folder) : null; LookAndFeelProperties laf = LookAndFeelProperties.getInstance(folder); diff --git a/core/src/org/labkey/core/mcp/McpServiceImpl.java b/core/src/org/labkey/core/mcp/McpServiceImpl.java index 0937f308a20..eca9b1a8ae6 100644 --- a/core/src/org/labkey/core/mcp/McpServiceImpl.java +++ b/core/src/org/labkey/core/mcp/McpServiceImpl.java @@ -1,8 +1,10 @@ package org.labkey.core.mcp; +import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.google.genai.Client; import com.google.genai.types.ClientOptions; +import io.modelcontextprotocol.common.McpTransportContext; import io.modelcontextprotocol.json.McpJsonMapper; import io.modelcontextprotocol.server.McpServer; import io.modelcontextprotocol.server.McpServerFeatures; @@ -23,9 +25,12 @@ import org.jetbrains.annotations.NotNull; import org.jspecify.annotations.NonNull; import org.labkey.api.collections.CopyOnWriteHashMap; +import org.labkey.api.data.Container; +import org.labkey.api.data.ContainerManager; import org.labkey.api.markdown.MarkdownService; import org.labkey.api.mcp.McpContext; import org.labkey.api.mcp.McpService; +import org.labkey.api.security.User; import org.labkey.api.util.ContextListener; import org.labkey.api.util.FileUtil; import org.labkey.api.util.HtmlString; @@ -35,13 +40,6 @@ import org.labkey.api.util.logging.LogHelper; import org.springframework.ai.anthropic.AnthropicChatModel; import org.springframework.ai.anthropic.AnthropicChatOptions; -import org.springframework.ai.google.genai.common.GoogleGenAiThinkingLevel; -import org.springframework.ai.openai.OpenAiChatModel; -import org.springframework.ai.openai.OpenAiChatOptions; -import org.springframework.ai.openai.OpenAiEmbeddingModel; -import org.springframework.ai.openai.OpenAiEmbeddingOptions; -import org.springframework.ai.openai.api.OpenAiApi; -import org.springframework.ai.document.MetadataMode; import org.springframework.ai.anthropic.api.AnthropicApi; import org.springframework.ai.chat.client.ChatClient; import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor; @@ -53,8 +51,11 @@ import org.springframework.ai.chat.memory.MessageWindowChatMemory; import org.springframework.ai.chat.model.ChatModel; import org.springframework.ai.chat.model.Generation; +import org.springframework.ai.chat.model.ToolContext; import org.springframework.ai.chat.prompt.ChatOptions; import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.document.Document; +import org.springframework.ai.document.MetadataMode; import org.springframework.ai.embedding.EmbeddingModel; import org.springframework.ai.google.genai.GoogleGenAiChatModel; import org.springframework.ai.google.genai.GoogleGenAiChatOptions; @@ -62,11 +63,14 @@ import org.springframework.ai.google.genai.text.GoogleGenAiTextEmbeddingModel; import org.springframework.ai.google.genai.text.GoogleGenAiTextEmbeddingOptions; import org.springframework.ai.mcp.McpToolUtils; -import org.springframework.ai.chat.model.ToolContext; +import org.springframework.ai.openai.OpenAiChatModel; +import org.springframework.ai.openai.OpenAiChatOptions; +import org.springframework.ai.openai.OpenAiEmbeddingModel; +import org.springframework.ai.openai.OpenAiEmbeddingOptions; +import org.springframework.ai.openai.api.OpenAiApi; import org.springframework.ai.tool.ToolCallback; import org.springframework.ai.tool.definition.ToolDefinition; import org.springframework.ai.tool.metadata.ToolMetadata; -import org.springframework.ai.document.Document; import org.springframework.ai.vectorstore.SearchRequest; import org.springframework.ai.vectorstore.SimpleVectorStore; import org.springframework.ai.vectorstore.VectorStore; @@ -82,6 +86,7 @@ import java.util.Arrays; import java.util.ConcurrentModificationException; import java.util.List; +import java.util.Map; import java.util.NoSuchElementException; import java.util.Objects; import java.util.function.Supplier; @@ -222,24 +227,72 @@ private class _McpServlet extends HttpServlet // wraps HttpServletSseServerTrans _McpServlet(ObjectMapper objectMapper, String messageEndpoint, String sseEndpoint) { transportProvider = HttpServletStreamableServerTransportProvider.builder() - .jsonMapper(McpJsonMapper.getDefault()) - .mcpEndpoint(messageEndpoint) - .build(); + .jsonMapper(McpJsonMapper.getDefault()) + .mcpEndpoint(messageEndpoint) + .contextExtractor(req -> { + User user = (User) req.getUserPrincipal(); + return McpTransportContext.create(Map.of( + "container", ContainerManager.getHomeContainer(), + "user", user + ) + ); + }) + .build(); } void startMcpServer() { - List tools = Arrays.stream(getToolCallbacks()).map(McpToolUtils::toSyncToolSpecification).toList(); + List tools = Arrays.stream(getToolCallbacks()) + .map(this::toSyncToolSpecification) + .toList(); + List resources = new ArrayList<>(resourceMap.values()); mcpServer = McpServer.sync(transportProvider) - .tools(tools) - .resources(resources) + .tools(tools) + .resources(resources) // .capabilities(new McpSchema.ServerCapabilities()) - .build(); + .build(); ContextListener.addShutdownListener(new _ShutdownListener()); } + private McpServerFeatures.SyncToolSpecification toSyncToolSpecification(ToolCallback toolCallback) + { + var toolDef = toolCallback.getToolDefinition(); + var schema = McpSchema.Tool.builder() + .name(toolDef.name()) + .description(toolDef.description()) + .inputSchema(McpJsonMapper.getDefault(), toolDef.inputSchema()) + .build(); + + return new McpServerFeatures.SyncToolSpecification(schema, (exchange, args) -> { + var transportCtx = exchange.transportContext(); + var container = (Container) transportCtx.get("container"); // TODO: Pull container from session instead. Or insist that LLM provides it? + var user = (User) transportCtx.get("user"); + var sessionId = exchange.sessionId(); + LOG.info("MCP sessionId: {}", sessionId); + + var toolContext = new ToolContext(Map.of("container", container, "user", user, "sessionId", sessionId)); + + String toolInput = /* serialize args to JSON */ null; + try + { + toolInput = JsonUtil.DEFAULT_MAPPER.writeValueAsString(args); + } + catch (JsonProcessingException e) + { + throw new RuntimeException(e); + } + String result = toolCallback.call(toolInput, toolContext); + return new McpSchema.CallToolResult( + List.of( + new McpSchema.TextContent(result) + ), + false + ); + }); + } + @Override public void service(ServletRequest sreq, ServletResponse sres) throws ServletException, IOException { @@ -255,34 +308,6 @@ public void service(ServletRequest sreq, ServletResponse sres) throws ServletExc return; } - if ("POST".equals(req.getMethod())) - { - if (null == req.getParameter("sessionId") && null == req.getSession(true).getAttribute("McpServiceImpl#mcpSessionId")) - { - // USE SSE endpoint to get a sessionId - MockHttpServletRequest mockRequest = new MockHttpServletRequest(req.getServletContext(), "GET", SSE_ENDPOINT); - mockRequest.setAsyncSupported(true); - MockHttpServletResponse mockResponse = new MockHttpServletResponse(); - transportProvider.service(mockRequest, mockResponse); - String body = new String(mockResponse.getContentAsByteArray(), StandardCharsets.UTF_8); - String mcpSessionId = StringUtils.substringBetween(body, "sessionId=", "\n"); - req.getSession(true).setAttribute("McpServiceImpl#mcpSessionId", mcpSessionId); - mockRequest.close(); - mockResponse.getOutputStream().close(); - } - - req = new HttpServletRequestWrapper(req) - { - @Override - public String getParameter(String name) - { - var ret = super.getParameter(name); - if (null == ret && "sessionId".equals(name)) - return String.valueOf(Objects.requireNonNull(((HttpServletRequest) getRequest()).getSession(true).getAttribute("McpServiceImpl#mcpSessionId"))); - return ret; - } - }; - } transportProvider.service(req, res); } From 5a656f36481e28fb8d3a51137771a61fc92e5809 Mon Sep 17 00:00:00 2001 From: Adam Rauch Date: Wed, 18 Mar 2026 18:36:49 -0700 Subject: [PATCH 02/19] Imports --- core/src/org/labkey/core/CoreMcp.java | 1 - core/src/org/labkey/core/mcp/McpServiceImpl.java | 7 ------- 2 files changed, 8 deletions(-) diff --git a/core/src/org/labkey/core/CoreMcp.java b/core/src/org/labkey/core/CoreMcp.java index de0c5f9a7c1..4d49e9d4c5c 100644 --- a/core/src/org/labkey/core/CoreMcp.java +++ b/core/src/org/labkey/core/CoreMcp.java @@ -1,6 +1,5 @@ package org.labkey.core; -import io.modelcontextprotocol.server.McpSyncServerExchange; import org.json.JSONObject; import org.labkey.api.data.Container; import org.labkey.api.mcp.McpService; diff --git a/core/src/org/labkey/core/mcp/McpServiceImpl.java b/core/src/org/labkey/core/mcp/McpServiceImpl.java index eca9b1a8ae6..01da6597676 100644 --- a/core/src/org/labkey/core/mcp/McpServiceImpl.java +++ b/core/src/org/labkey/core/mcp/McpServiceImpl.java @@ -17,10 +17,8 @@ import jakarta.servlet.ServletResponse; import jakarta.servlet.http.HttpServlet; import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletRequestWrapper; import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpSession; -import org.apache.commons.lang3.StringUtils; import org.apache.logging.log4j.Logger; import org.jetbrains.annotations.NotNull; import org.jspecify.annotations.NonNull; @@ -62,7 +60,6 @@ import org.springframework.ai.google.genai.GoogleGenAiEmbeddingConnectionDetails; import org.springframework.ai.google.genai.text.GoogleGenAiTextEmbeddingModel; import org.springframework.ai.google.genai.text.GoogleGenAiTextEmbeddingOptions; -import org.springframework.ai.mcp.McpToolUtils; import org.springframework.ai.openai.OpenAiChatModel; import org.springframework.ai.openai.OpenAiChatOptions; import org.springframework.ai.openai.OpenAiEmbeddingModel; @@ -75,12 +72,9 @@ import org.springframework.ai.vectorstore.SimpleVectorStore; import org.springframework.ai.vectorstore.VectorStore; import org.springframework.ai.vectorstore.filter.Filter; -import org.springframework.mock.web.MockHttpServletRequest; -import org.springframework.mock.web.MockHttpServletResponse; import reactor.core.publisher.Mono; import java.io.IOException; -import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.ArrayList; import java.util.Arrays; @@ -88,7 +82,6 @@ import java.util.List; import java.util.Map; import java.util.NoSuchElementException; -import java.util.Objects; import java.util.function.Supplier; import static org.apache.commons.lang3.StringUtils.isBlank; From bf1cf2bf90f686d66ea722d1251f1380f664b0a4 Mon Sep 17 00:00:00 2001 From: Adam Rauch Date: Thu, 19 Mar 2026 12:18:32 -0700 Subject: [PATCH 03/19] listContainers --- core/src/org/labkey/core/CoreMcp.java | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/core/src/org/labkey/core/CoreMcp.java b/core/src/org/labkey/core/CoreMcp.java index 4d49e9d4c5c..555a782e423 100644 --- a/core/src/org/labkey/core/CoreMcp.java +++ b/core/src/org/labkey/core/CoreMcp.java @@ -1,9 +1,12 @@ package org.labkey.core; import org.json.JSONObject; +import org.labkey.api.collections.LabKeyCollectors; import org.labkey.api.data.Container; +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; @@ -61,4 +64,15 @@ String whereAmIWhoAmITalkingTo(ToolContext context) "site", siteObj )).toString(); } + + @Tool(description = "List the hierarchical path for every container in the server where the user has read permissions.") + String listContainers(ToolContext context) + { + User user = (User)context.getContext().get("user"); + return ContainerManager.getAllChildren(ContainerManager.getRoot(), user, ReadPermission.class) + .stream() + .map(Container::getPath) + .collect(LabKeyCollectors.toJSONArray()) + .toString(); + } } From 8ee2e9fdd104418d9dcf287015b7a1d5e40ef30d Mon Sep 17 00:00:00 2001 From: Adam Rauch Date: Thu, 19 Mar 2026 16:11:24 -0700 Subject: [PATCH 04/19] Ask user for container path and cache it. Clean up all endpoints. --- api/src/org/labkey/api/mcp/McpService.java | 16 ++++- core/src/org/labkey/core/CoreMcp.java | 25 ++++++-- .../org/labkey/core/mcp/McpServiceImpl.java | 25 ++++++-- .../labkey/query/controllers/QueryMcp.java | 63 ++++--------------- .../src/org/labkey/search/SearchModule.java | 11 ++-- 5 files changed, 70 insertions(+), 70 deletions(-) diff --git a/api/src/org/labkey/api/mcp/McpService.java b/api/src/org/labkey/api/mcp/McpService.java index 67854a58c61..c8d7da6115a 100644 --- a/api/src/org/labkey/api/mcp/McpService.java +++ b/api/src/org/labkey/api/mcp/McpService.java @@ -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.labkey.api.writer.ContainerUser; import org.springaicommunity.mcp.provider.resource.SyncMcpResourceProvider; import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.model.ToolContext; import org.springframework.ai.support.ToolCallbacks; import org.springframework.ai.tool.ToolCallback; import org.springframework.ai.tool.ToolCallbackProvider; @@ -31,7 +35,17 @@ 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 IllegalArgumentException("You need to set a container path before invoking this tool"); + return ContainerUser.create(container, user); + } + } static @NotNull McpService get() { diff --git a/core/src/org/labkey/core/CoreMcp.java b/core/src/org/labkey/core/CoreMcp.java index 555a782e423..33e88b9b108 100644 --- a/core/src/org/labkey/core/CoreMcp.java +++ b/core/src/org/labkey/core/CoreMcp.java @@ -14,19 +14,22 @@ 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; import static org.apache.commons.lang3.StringUtils.isNotBlank; +import static org.labkey.core.mcp.McpServiceImpl.PATH_CACHE; public class CoreMcp implements McpService.McpImpl { @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(ToolContext context) { - User user = (User)context.getContext().get("user"); - Container folder = (Container)context.getContext().get("container"); + 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); @@ -66,13 +69,25 @@ String whereAmIWhoAmITalkingTo(ToolContext context) } @Tool(description = "List the hierarchical path for every container in the server where the user has read permissions.") - String listContainers(ToolContext context) + String listContainers(ToolContext toolContext) { - User user = (User)context.getContext().get("user"); - return ContainerManager.getAllChildren(ContainerManager.getRoot(), user, ReadPermission.class) + return ContainerManager.getAllChildren(ContainerManager.getRoot(), getContext(toolContext).getUser(), 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) + { + Container container = ContainerManager.getForPath(containerPath); + if (container != null) + { + PATH_CACHE.put((String) context.getContext().get("sessionId"), containerPath); + return "OK!"; + } + + return "That's not a valid container path. Try using listContainers to see them."; + } } diff --git a/core/src/org/labkey/core/mcp/McpServiceImpl.java b/core/src/org/labkey/core/mcp/McpServiceImpl.java index 01da6597676..2ed41ed863e 100644 --- a/core/src/org/labkey/core/mcp/McpServiceImpl.java +++ b/core/src/org/labkey/core/mcp/McpServiceImpl.java @@ -22,6 +22,8 @@ import org.apache.logging.log4j.Logger; import org.jetbrains.annotations.NotNull; import org.jspecify.annotations.NonNull; +import org.labkey.api.cache.Cache; +import org.labkey.api.cache.CacheManager; import org.labkey.api.collections.CopyOnWriteHashMap; import org.labkey.api.data.Container; import org.labkey.api.data.ContainerManager; @@ -79,6 +81,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.ConcurrentModificationException; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.NoSuchElementException; @@ -212,6 +215,8 @@ public List tools() ).toList(); } + public final static Cache PATH_CACHE = CacheManager.getCache(1000, CacheManager.DAY, "MCP container paths"); + private class _McpServlet extends HttpServlet // wraps HttpServletSseServerTransportProvider { HttpServletStreamableServerTransportProvider transportProvider; @@ -225,10 +230,8 @@ private class _McpServlet extends HttpServlet // wraps HttpServletSseServerTrans .contextExtractor(req -> { User user = (User) req.getUserPrincipal(); return McpTransportContext.create(Map.of( - "container", ContainerManager.getHomeContainer(), "user", user - ) - ); + )); }) .build(); } @@ -260,12 +263,22 @@ private McpServerFeatures.SyncToolSpecification toSyncToolSpecification(ToolCall return new McpServerFeatures.SyncToolSpecification(schema, (exchange, args) -> { var transportCtx = exchange.transportContext(); - var container = (Container) transportCtx.get("container"); // TODO: Pull container from session instead. Or insist that LLM provides it? var user = (User) transportCtx.get("user"); var sessionId = exchange.sessionId(); - LOG.info("MCP sessionId: {}", sessionId); - var toolContext = new ToolContext(Map.of("container", container, "user", user, "sessionId", sessionId)); + Map map = new HashMap<>(); + map.put("user", user); + map.put("sessionId", sessionId); + + String containerPath = PATH_CACHE.get(exchange.sessionId()); + if (containerPath != null) + { + Container container = ContainerManager.getForPath(containerPath); + map.put("container", container); + map.put("containerPath", containerPath); + } + + var toolContext = new ToolContext(map); String toolInput = /* serialize args to JSON */ null; try diff --git a/query/src/org/labkey/query/controllers/QueryMcp.java b/query/src/org/labkey/query/controllers/QueryMcp.java index 99bb2df99c0..18b6364da0d 100644 --- a/query/src/org/labkey/query/controllers/QueryMcp.java +++ b/query/src/org/labkey/query/controllers/QueryMcp.java @@ -5,10 +5,8 @@ import org.apache.commons.lang3.StringUtils; import org.json.JSONArray; import org.json.JSONObject; -import org.labkey.api.action.SpringActionController; import org.labkey.api.collections.CaseInsensitiveHashSet; import org.labkey.api.data.ColumnInfo; -import org.labkey.api.data.ContainerManager; import org.labkey.api.data.PropertyManager; import org.labkey.api.data.TableDescription; import org.labkey.api.data.TableInfo; @@ -22,9 +20,10 @@ import org.labkey.api.query.SchemaKey; import org.labkey.api.query.SimpleSchemaTreeVisitor; import org.labkey.api.query.UserSchema; -import org.labkey.api.security.UserManager; +import org.labkey.api.writer.ContainerUser; import org.labkey.query.sql.SqlParser; import org.springaicommunity.mcp.annotation.McpResource; +import org.springframework.ai.chat.model.ToolContext; import org.springframework.ai.tool.annotation.Tool; import org.springframework.ai.tool.annotation.ToolParam; @@ -66,18 +65,18 @@ String listColumnMetaData(@ToolParam(description = "Fully qualified table name a } @Tool(description = "Provide list of tables within the provided schema.") - String listTablesForSchema(@ToolParam(description = "Fully qualified schema name as it would appear in SQL e.g. \"schema\"") String quotedSchemaName) + String listTablesForSchema(ToolContext toolContext, @ToolParam(description = "Fully qualified schema name as it would appear in SQL e.g. \"schema\"") String quotedSchemaName) { - var json = _listTablesForSchema(quotedSchemaName); + var json = _listTablesForSchema(quotedSchemaName, getContext(toolContext)); // can I just return a JSONObject return json.toString(); } @Tool(description = "Provide list of database schemas") - String listSchemas() + String listSchemas(ToolContext toolContext) { - McpContext context = getContext(); - var map = _listAllSchemas(DefaultSchema.get(context.getUser(), context.getContainer())); + ContainerUser cu = getContext(toolContext); + var map = _listAllSchemas(DefaultSchema.get(cu.getUser(), cu.getContainer())); var array = new JSONArray(); for (var entry : map.entrySet()) { @@ -92,54 +91,15 @@ String listSchemas() @Tool(description = "Provide the SQL source for a saved query.") - String getSourceForSavedQuery(@ToolParam(description = "Fully qualified query name as it would appear in SQL e.g. \"schema\".\"table or query\"") String fullQuotedTableName) + String getSourceForSavedQuery(ToolContext toolContext, @ToolParam(description = "Fully qualified query name as it would appear in SQL e.g. \"schema\".\"table or query\"") String fullQuotedTableName) { - var json = _listTablesForSchema(fullQuotedTableName); + var json = _listTablesForSchema(fullQuotedTableName, getContext(toolContext)); if (json.has("sql")) return "```sql\n" + json.getString("sql") + "\n```\n"; else return "I could not find the source for " + fullQuotedTableName; } - - @Tool(description = """ - Save addition information for database columns. If additional metadata is gathered via - chat, it can be saved to improve further interactions. - """) - String saveColumnDescription( - @ToolParam(description = "Fully qualified table or query name as it would appear in SQL e.g. \"schema\".\"table or query\"") - String fullQuotedTableName, - @ToolParam(description = "Quoted column name as it would appear in SQL e.g. \"column name\"") - String quotedColumnName, - @ToolParam(description = "Additional metadata to remember for future use. This will replace any currently saved value") - String columnMetadata - ) - { - McpContext context = McpContext.get(); - var map = PropertyManager.getWritableProperties(context.getContainer(), "QueryMCP.annotations", true); - String fullPath = normalizeIdentifier(fullQuotedTableName + "." + quotedColumnName); - map.put(fullPath, columnMetadata); - try (var ignore = SpringActionController.ignoreSqlUpdates()) - { - map.save(); - } - return new JSONObject(Map.of("success",Boolean.TRUE)).toString(); - } - - /* TODO McpContext setup */ - - static McpContext getContext() - { - try - { - return McpContext.get(); - } - catch (Exception x) - { - return new McpContext(ContainerManager.getHomeContainer(), UserManager.getGuestUser()); - } - } - /* For now, list all schemas. CONSIDER support incremental querying. */ public static Map _listAllSchemas(DefaultSchema root) { @@ -179,7 +139,7 @@ public Map reduce(Map r1, Map Date: Thu, 19 Mar 2026 17:41:48 -0700 Subject: [PATCH 05/19] Provide less severe guidance when container is missing --- api/src/org/labkey/api/mcp/McpException.java | 11 +++++++++++ api/src/org/labkey/api/mcp/McpService.java | 7 ++++++- core/src/org/labkey/core/CoreMcp.java | 2 +- core/src/org/labkey/core/mcp/McpServiceImpl.java | 16 +++++++++++++++- 4 files changed, 33 insertions(+), 3 deletions(-) create mode 100644 api/src/org/labkey/api/mcp/McpException.java diff --git a/api/src/org/labkey/api/mcp/McpException.java b/api/src/org/labkey/api/mcp/McpException.java new file mode 100644 index 00000000000..e9270a8f88f --- /dev/null +++ b/api/src/org/labkey/api/mcp/McpException.java @@ -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); + } +} diff --git a/api/src/org/labkey/api/mcp/McpService.java b/api/src/org/labkey/api/mcp/McpService.java index c8d7da6115a..cc6273a7d47 100644 --- a/api/src/org/labkey/api/mcp/McpService.java +++ b/api/src/org/labkey/api/mcp/McpService.java @@ -42,9 +42,14 @@ default ContainerUser getContext(ToolContext toolContext) User user = (User)toolContext.getContext().get("user"); Container container = (Container)toolContext.getContext().get("container"); if (container == null) - throw new IllegalArgumentException("You need to set a container path before invoking this tool"); + throw new McpException("You need to set a container path before invoking this tool"); return ContainerUser.create(container, user); } + + default User getUser(ToolContext toolContext) + { + return (User)toolContext.getContext().get("user"); + } } static @NotNull McpService get() diff --git a/core/src/org/labkey/core/CoreMcp.java b/core/src/org/labkey/core/CoreMcp.java index 33e88b9b108..55ef74486da 100644 --- a/core/src/org/labkey/core/CoreMcp.java +++ b/core/src/org/labkey/core/CoreMcp.java @@ -71,7 +71,7 @@ String whereAmIWhoAmITalkingTo(ToolContext context) @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(), getContext(toolContext).getUser(), ReadPermission.class) + return ContainerManager.getAllChildren(ContainerManager.getRoot(), getUser(toolContext), ReadPermission.class) .stream() .map(Container::getPath) .collect(LabKeyCollectors.toJSONArray()) diff --git a/core/src/org/labkey/core/mcp/McpServiceImpl.java b/core/src/org/labkey/core/mcp/McpServiceImpl.java index 2ed41ed863e..48dce674460 100644 --- a/core/src/org/labkey/core/mcp/McpServiceImpl.java +++ b/core/src/org/labkey/core/mcp/McpServiceImpl.java @@ -29,6 +29,7 @@ import org.labkey.api.data.ContainerManager; import org.labkey.api.markdown.MarkdownService; import org.labkey.api.mcp.McpContext; +import org.labkey.api.mcp.McpException; import org.labkey.api.mcp.McpService; import org.labkey.api.security.User; import org.labkey.api.util.ContextListener; @@ -69,6 +70,7 @@ import org.springframework.ai.openai.api.OpenAiApi; import org.springframework.ai.tool.ToolCallback; import org.springframework.ai.tool.definition.ToolDefinition; +import org.springframework.ai.tool.execution.ToolExecutionException; import org.springframework.ai.tool.metadata.ToolMetadata; import org.springframework.ai.vectorstore.SearchRequest; import org.springframework.ai.vectorstore.SimpleVectorStore; @@ -289,7 +291,19 @@ private McpServerFeatures.SyncToolSpecification toSyncToolSpecification(ToolCall { throw new RuntimeException(e); } - String result = toolCallback.call(toolInput, toolContext); + String result; + try + { + result = toolCallback.call(toolInput, toolContext); + } + catch (ToolExecutionException e) + { + // If a tool threw McpException then just send back the message without making a big fuss + if (e.getCause() instanceof McpException) + result = e.getMessage(); + else + throw e; + } return new McpSchema.CallToolResult( List.of( new McpSchema.TextContent(result) From 748f35bb4f79b65cf380361dc45ce1f9b0d26f85 Mon Sep 17 00:00:00 2001 From: Adam Rauch Date: Fri, 20 Mar 2026 12:34:35 -0700 Subject: [PATCH 06/19] Update Spring AI to 2.0.0-M3 --- api/src/org/labkey/api/mcp/McpService.java | 2 +- .../org/labkey/core/mcp/McpServiceImpl.java | 51 +++++++++---------- .../labkey/query/controllers/QueryMcp.java | 45 ++++++++-------- 3 files changed, 45 insertions(+), 53 deletions(-) diff --git a/api/src/org/labkey/api/mcp/McpService.java b/api/src/org/labkey/api/mcp/McpService.java index cc6273a7d47..0be809abc5c 100644 --- a/api/src/org/labkey/api/mcp/McpService.java +++ b/api/src/org/labkey/api/mcp/McpService.java @@ -11,9 +11,9 @@ import org.labkey.api.services.ServiceRegistry; import org.labkey.api.util.HtmlString; import org.labkey.api.writer.ContainerUser; -import org.springaicommunity.mcp.provider.resource.SyncMcpResourceProvider; 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; diff --git a/core/src/org/labkey/core/mcp/McpServiceImpl.java b/core/src/org/labkey/core/mcp/McpServiceImpl.java index 48dce674460..6d1087aa96e 100644 --- a/core/src/org/labkey/core/mcp/McpServiceImpl.java +++ b/core/src/org/labkey/core/mcp/McpServiceImpl.java @@ -5,7 +5,7 @@ import com.google.genai.Client; import com.google.genai.types.ClientOptions; import io.modelcontextprotocol.common.McpTransportContext; -import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.json.McpJsonDefaults; import io.modelcontextprotocol.server.McpServer; import io.modelcontextprotocol.server.McpServerFeatures; import io.modelcontextprotocol.server.McpSyncServer; @@ -41,7 +41,6 @@ import org.labkey.api.util.logging.LogHelper; import org.springframework.ai.anthropic.AnthropicChatModel; import org.springframework.ai.anthropic.AnthropicChatOptions; -import org.springframework.ai.anthropic.api.AnthropicApi; import org.springframework.ai.chat.client.ChatClient; import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor; import org.springframework.ai.chat.client.advisor.api.Advisor; @@ -207,13 +206,12 @@ public void registerResources(@NotNull List tools() { - McpJsonMapper mapper = McpJsonMapper.getDefault(); return toolMap.values().stream().map(ToolCallback::getToolDefinition).map(td -> - McpSchema.Tool.builder() - .name(td.name()) - .description(td.description()) - .inputSchema(mapper, td.inputSchema()) - .build() + McpSchema.Tool.builder() + .name(td.name()) + .description(td.description()) + .inputSchema(McpJsonDefaults.getMapper(), td.inputSchema()) + .build() ).toList(); } @@ -227,7 +225,7 @@ private class _McpServlet extends HttpServlet // wraps HttpServletSseServerTrans _McpServlet(ObjectMapper objectMapper, String messageEndpoint, String sseEndpoint) { transportProvider = HttpServletStreamableServerTransportProvider.builder() - .jsonMapper(McpJsonMapper.getDefault()) + .jsonMapper(McpJsonDefaults.getMapper()) .mcpEndpoint(messageEndpoint) .contextExtractor(req -> { User user = (User) req.getUserPrincipal(); @@ -260,7 +258,7 @@ private McpServerFeatures.SyncToolSpecification toSyncToolSpecification(ToolCall var schema = McpSchema.Tool.builder() .name(toolDef.name()) .description(toolDef.description()) - .inputSchema(McpJsonMapper.getDefault(), toolDef.inputSchema()) + .inputSchema(McpJsonDefaults.getMapper(), toolDef.inputSchema()) .build(); return new McpServerFeatures.SyncToolSpecification(schema, (exchange, args) -> { @@ -272,7 +270,7 @@ private McpServerFeatures.SyncToolSpecification toSyncToolSpecification(ToolCall map.put("user", user); map.put("sessionId", sessionId); - String containerPath = PATH_CACHE.get(exchange.sessionId()); + String containerPath = PATH_CACHE.get(exchange.sessionId()); // TODO: Cache Container IDs instead of paths? if (containerPath != null) { Container container = ContainerManager.getForPath(containerPath); @@ -305,10 +303,10 @@ private McpServerFeatures.SyncToolSpecification toSyncToolSpecification(ToolCall throw e; } return new McpSchema.CallToolResult( - List.of( - new McpSchema.TextContent(result) - ), - false + List.of(new McpSchema.TextContent(result)), + false, + null, + null ); }); } @@ -880,25 +878,22 @@ public String getEmbeddingModel() return null; } + @Override public AnthropicChatOptions getChatOptions() { - AnthropicChatOptions chatOptions = AnthropicChatOptions.builder() - .model(getModel()) - .toolCallbacks(getToolCallbacks()) - .build(); - return chatOptions; + return AnthropicChatOptions.builder() + .model(getModel()) + .apiKey(System.getenv("CLAUDE_API_KEY")) + .toolCallbacks(getToolCallbacks()) + .build(); } + @Override public AnthropicChatModel getChatModel() { - AnthropicChatOptions chatOptions = getChatOptions(); - AnthropicApi api = AnthropicApi.builder() - .apiKey(System.getenv("CLAUDE_API_KEY")) - .build(); - AnthropicChatModel chatModel = AnthropicChatModel.builder() - .anthropicApi(api) - .build(); - return chatModel; + return AnthropicChatModel.builder() + .options(getChatOptions()) + .build(); } @Override diff --git a/query/src/org/labkey/query/controllers/QueryMcp.java b/query/src/org/labkey/query/controllers/QueryMcp.java index 18b6364da0d..e33cf2dddfe 100644 --- a/query/src/org/labkey/query/controllers/QueryMcp.java +++ b/query/src/org/labkey/query/controllers/QueryMcp.java @@ -10,7 +10,6 @@ import org.labkey.api.data.PropertyManager; import org.labkey.api.data.TableDescription; import org.labkey.api.data.TableInfo; -import org.labkey.api.mcp.McpContext; import org.labkey.api.mcp.McpService; import org.labkey.api.query.DefaultSchema; import org.labkey.api.query.QueryDefinition; @@ -22,8 +21,8 @@ import org.labkey.api.query.UserSchema; import org.labkey.api.writer.ContainerUser; import org.labkey.query.sql.SqlParser; -import org.springaicommunity.mcp.annotation.McpResource; import org.springframework.ai.chat.model.ToolContext; +import org.springframework.ai.mcp.annotation.McpResource; import org.springframework.ai.tool.annotation.Tool; import org.springframework.ai.tool.annotation.ToolParam; @@ -35,8 +34,6 @@ import static org.apache.commons.lang3.StringUtils.isNotBlank; -/* TODO: integrate ToolContext support */ - public class QueryMcp implements McpService.McpImpl { @McpResource( @@ -48,26 +45,26 @@ public McpSchema.ReadResourceResult getLabKeySQLDocumentation() throws IOExcepti { String markdown = IOUtils.resourceToString("org/labkey/query/controllers/LabKeySql.md", null, QueryController.class.getClassLoader()); return new McpSchema.ReadResourceResult(List.of( - new McpSchema.TextResourceContents( - "resource://org/labkey/query/controllers/LabKeySql.md", - "application/markdown", - markdown) + new McpSchema.TextResourceContents( + "resource://org/labkey/query/controllers/LabKeySql.md", + "application/markdown", + markdown + ) )); } - - @Tool(description = "Provide column metadata for a sql table. This tool will also return SQL source for saved queries.") - String listColumnMetaData(@ToolParam(description = "Fully qualified table name as it would appear in SQL e.g. \"schema\".\"table\"") String fullQuotedTableName) + @Tool(description = "Provide column metadata for a sql table. This tool will also return SQL source for saved queries.") + String listColumns(ToolContext toolContext, @ToolParam(description = "Fully qualified table name as it would appear in SQL e.g. \"schema\".\"table\"") String fullQuotedTableName) { - var json = _listColumnsForTable(fullQuotedTableName); + var json = _listColumns(fullQuotedTableName, toolContext); // can I just return a JSONObject return json.toString(); } @Tool(description = "Provide list of tables within the provided schema.") - String listTablesForSchema(ToolContext toolContext, @ToolParam(description = "Fully qualified schema name as it would appear in SQL e.g. \"schema\"") String quotedSchemaName) + String listTables(ToolContext toolContext, @ToolParam(description = "Fully qualified schema name as it would appear in SQL e.g. \"schema\"") String quotedSchemaName) { - var json = _listTablesForSchema(quotedSchemaName, getContext(toolContext)); + var json = _listTables(quotedSchemaName, getContext(toolContext)); // can I just return a JSONObject return json.toString(); } @@ -80,11 +77,11 @@ String listSchemas(ToolContext toolContext) var array = new JSONArray(); for (var entry : map.entrySet()) { - array.put(new JSONObject(Map.of( - "name", entry.getKey().getName(), - "quotedName", entry.getKey().toSQLString(), - "description", StringUtils.trimToEmpty(entry.getValue().getDescription()) - ))); + array.put(new JSONObject(Map.of( + "name", entry.getKey().getName(), + "quotedName", entry.getKey().toSQLString(), + "description", StringUtils.trimToEmpty(entry.getValue().getDescription()) + ))); } return new JSONObject(Map.of("success", "true", "schemas", array)).toString(); } @@ -93,7 +90,7 @@ String listSchemas(ToolContext toolContext) @Tool(description = "Provide the SQL source for a saved query.") String getSourceForSavedQuery(ToolContext toolContext, @ToolParam(description = "Fully qualified query name as it would appear in SQL e.g. \"schema\".\"table or query\"") String fullQuotedTableName) { - var json = _listTablesForSchema(fullQuotedTableName, getContext(toolContext)); + var json = _listTables(fullQuotedTableName, getContext(toolContext)); if (json.has("sql")) return "```sql\n" + json.getString("sql") + "\n```\n"; else @@ -139,7 +136,7 @@ public Map reduce(Map r1, Map Date: Fri, 20 Mar 2026 17:36:39 -0700 Subject: [PATCH 07/19] Fix arguments after Spring AI 2.0.0-M3 upgrade. Metrics. Add some @NotNulls. --- api/src/org/labkey/api/mcp/McpService.java | 7 ++++ .../org/labkey/core/mcp/McpServiceImpl.java | 34 +++++++++++++------ .../labkey/query/controllers/QueryMcp.java | 1 + 3 files changed, 32 insertions(+), 10 deletions(-) diff --git a/api/src/org/labkey/api/mcp/McpService.java b/api/src/org/labkey/api/mcp/McpService.java index 0be809abc5c..6112a729f9c 100644 --- a/api/src/org/labkey/api/mcp/McpService.java +++ b/api/src/org/labkey/api/mcp/McpService.java @@ -9,6 +9,7 @@ import org.labkey.api.module.McpProvider; import org.labkey.api.security.User; import org.labkey.api.services.ServiceRegistry; +import org.labkey.api.usageMetrics.SimpleMetricsService; import org.labkey.api.util.HtmlString; import org.labkey.api.writer.ContainerUser; import org.springframework.ai.chat.client.ChatClient; @@ -50,6 +51,12 @@ 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) + { + SimpleMetricsService.get().increment("core", "mcpResourceReads", resource); + } } static @NotNull McpService get() diff --git a/core/src/org/labkey/core/mcp/McpServiceImpl.java b/core/src/org/labkey/core/mcp/McpServiceImpl.java index 6d1087aa96e..681960d7729 100644 --- a/core/src/org/labkey/core/mcp/McpServiceImpl.java +++ b/core/src/org/labkey/core/mcp/McpServiceImpl.java @@ -32,6 +32,7 @@ import org.labkey.api.mcp.McpException; import org.labkey.api.mcp.McpService; import org.labkey.api.security.User; +import org.labkey.api.usageMetrics.SimpleMetricsService; import org.labkey.api.util.ContextListener; import org.labkey.api.util.FileUtil; import org.labkey.api.util.HtmlString; @@ -261,7 +262,7 @@ private McpServerFeatures.SyncToolSpecification toSyncToolSpecification(ToolCall .inputSchema(McpJsonDefaults.getMapper(), toolDef.inputSchema()) .build(); - return new McpServerFeatures.SyncToolSpecification(schema, (exchange, args) -> { + return new McpServerFeatures.SyncToolSpecification(schema, (exchange, request) -> { var transportCtx = exchange.transportContext(); var user = (User) transportCtx.get("user"); var sessionId = exchange.sessionId(); @@ -283,28 +284,39 @@ private McpServerFeatures.SyncToolSpecification toSyncToolSpecification(ToolCall String toolInput = /* serialize args to JSON */ null; try { - toolInput = JsonUtil.DEFAULT_MAPPER.writeValueAsString(args); + toolInput = JsonUtil.DEFAULT_MAPPER.writeValueAsString(request.arguments()); } catch (JsonProcessingException e) { throw new RuntimeException(e); } String result; + boolean isError = false; try { + SimpleMetricsService.get().increment("core", "mcpToolInvocations", request.name()); result = toolCallback.call(toolInput, toolContext); } catch (ToolExecutionException e) { - // If a tool threw McpException then just send back the message without making a big fuss + // If a tool threw McpException then send back the message as an MCP-level error if (e.getCause() instanceof McpException) - result = e.getMessage(); + { + result = e.getCause().getMessage(); + isError = true; + } else throw e; } + catch (Throwable t) + { + // Set a breakpoint below to inspect exceptions during development + throw t; + } + return new McpSchema.CallToolResult( List.of(new McpSchema.TextContent(result)), - false, + isError, null, null ); @@ -379,25 +391,25 @@ private static class _LoggingVectorStore implements VectorStore } @Override - public void add(List documents) + public void add(@NotNull List documents) { delegate.add(documents); } @Override - public void delete(Filter.Expression filterExpression) + public void delete(@NotNull Filter.Expression filterExpression) { delegate.delete(filterExpression); } @Override - public void delete(List idList) + public void delete(@NotNull List idList) { delegate.delete(idList); } @Override - public List similaritySearch(SearchRequest request) + public @NotNull List similaritySearch(SearchRequest request) { LOG.info("Vector store search: query=\"{}\"", request.getQuery()); List results = delegate.similaritySearch(request); @@ -419,7 +431,7 @@ public List similaritySearch(SearchRequest request) } @Override - public String getName() + public @NotNull String getName() { return delegate.getName(); } @@ -783,6 +795,7 @@ Client getLlmClient() │ labels │ Map │ Custom labels attached to requests │ └───────────────────────┴────────────────────────────────┴────────────────────────────────────┘ */ + @Override public GoogleGenAiChatOptions getChatOptions() { GoogleGenAiChatOptions chatOptions = GoogleGenAiChatOptions.builder() @@ -792,6 +805,7 @@ public GoogleGenAiChatOptions getChatOptions() return chatOptions; } + @Override public ChatModel getChatModel() { Client genAiClient = getLlmClient(); diff --git a/query/src/org/labkey/query/controllers/QueryMcp.java b/query/src/org/labkey/query/controllers/QueryMcp.java index e33cf2dddfe..c551bc534c2 100644 --- a/query/src/org/labkey/query/controllers/QueryMcp.java +++ b/query/src/org/labkey/query/controllers/QueryMcp.java @@ -43,6 +43,7 @@ public class QueryMcp implements McpService.McpImpl description = "Provide documentation for LabKey SQL specific syntax") public McpSchema.ReadResourceResult getLabKeySQLDocumentation() throws IOException { + incrementResourceReadCount("LabKey SQL"); String markdown = IOUtils.resourceToString("org/labkey/query/controllers/LabKeySql.md", null, QueryController.class.getClassLoader()); return new McpSchema.ReadResourceResult(List.of( new McpSchema.TextResourceContents( From 6daabae9ad242314676707bcccfb2921a597d91c Mon Sep 17 00:00:00 2001 From: Adam Rauch Date: Fri, 20 Mar 2026 17:45:52 -0700 Subject: [PATCH 08/19] Treat McpException as guidance --- core/src/org/labkey/core/mcp/McpServiceImpl.java | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/core/src/org/labkey/core/mcp/McpServiceImpl.java b/core/src/org/labkey/core/mcp/McpServiceImpl.java index 681960d7729..6c74fe02fc4 100644 --- a/core/src/org/labkey/core/mcp/McpServiceImpl.java +++ b/core/src/org/labkey/core/mcp/McpServiceImpl.java @@ -290,8 +290,9 @@ private McpServerFeatures.SyncToolSpecification toSyncToolSpecification(ToolCall { throw new RuntimeException(e); } + String result; - boolean isError = false; + try { SimpleMetricsService.get().increment("core", "mcpToolInvocations", request.name()); @@ -299,12 +300,9 @@ private McpServerFeatures.SyncToolSpecification toSyncToolSpecification(ToolCall } catch (ToolExecutionException e) { - // If a tool threw McpException then send back the message as an MCP-level error + // If a tool threw McpException then just send back the message, not as an error if (e.getCause() instanceof McpException) - { result = e.getCause().getMessage(); - isError = true; - } else throw e; } @@ -314,9 +312,10 @@ private McpServerFeatures.SyncToolSpecification toSyncToolSpecification(ToolCall throw t; } + // Responses and McpExceptions are treated as "success". Tools should throw for true error conditions. return new McpSchema.CallToolResult( List.of(new McpSchema.TextContent(result)), - isError, + false, null, null ); From 0dc76f77ffb6e67b01fee6c23fe1232a07eb49b9 Mon Sep 17 00:00:00 2001 From: Adam Rauch Date: Tue, 24 Mar 2026 17:14:41 -0700 Subject: [PATCH 09/19] Move McpServiceImpl to the Professional module --- api/src/org/labkey/api/mcp/McpService.java | 7 +- core/src/org/labkey/core/CoreMcp.java | 3 +- core/src/org/labkey/core/CoreModule.java | 6 - .../org/labkey/core/mcp/McpServiceImpl.java | 969 ------------------ 4 files changed, 6 insertions(+), 979 deletions(-) delete mode 100644 core/src/org/labkey/core/mcp/McpServiceImpl.java diff --git a/api/src/org/labkey/api/mcp/McpService.java b/api/src/org/labkey/api/mcp/McpService.java index 6112a729f9c..73de78297bb 100644 --- a/api/src/org/labkey/api/mcp/McpService.java +++ b/api/src/org/labkey/api/mcp/McpService.java @@ -9,7 +9,6 @@ import org.labkey.api.module.McpProvider; import org.labkey.api.security.User; import org.labkey.api.services.ServiceRegistry; -import org.labkey.api.usageMetrics.SimpleMetricsService; import org.labkey.api.util.HtmlString; import org.labkey.api.writer.ContainerUser; import org.springframework.ai.chat.client.ChatClient; @@ -55,7 +54,7 @@ default User getUser(ToolContext toolContext) // Every MCP resource should call this on every invocation default void incrementResourceReadCount(String resource) { - SimpleMetricsService.get().increment("core", "mcpResourceReads", resource); + get().incrementResourceCount(resource); } } @@ -105,6 +104,10 @@ default ChatClient getChat(HttpSession session, String agentName, Supplier systemPromptSupplier, boolean createIfNotExists); void close(HttpSession session, ChatClient chat); diff --git a/core/src/org/labkey/core/CoreMcp.java b/core/src/org/labkey/core/CoreMcp.java index 55ef74486da..ece801f2d5b 100644 --- a/core/src/org/labkey/core/CoreMcp.java +++ b/core/src/org/labkey/core/CoreMcp.java @@ -20,7 +20,6 @@ import java.util.Objects; import static org.apache.commons.lang3.StringUtils.isNotBlank; -import static org.labkey.core.mcp.McpServiceImpl.PATH_CACHE; public class CoreMcp implements McpService.McpImpl { @@ -84,7 +83,7 @@ String setContainer(ToolContext context, @ToolParam(description = "Container pat Container container = ContainerManager.getForPath(containerPath); if (container != null) { - PATH_CACHE.put((String) context.getContext().get("sessionId"), containerPath); + McpService.get().saveSessionContainer(context, container); return "OK!"; } diff --git a/core/src/org/labkey/core/CoreModule.java b/core/src/org/labkey/core/CoreModule.java index 676ce04da99..fc5057d7b75 100644 --- a/core/src/org/labkey/core/CoreModule.java +++ b/core/src/org/labkey/core/CoreModule.java @@ -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; @@ -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 @@ -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 diff --git a/core/src/org/labkey/core/mcp/McpServiceImpl.java b/core/src/org/labkey/core/mcp/McpServiceImpl.java deleted file mode 100644 index 6c74fe02fc4..00000000000 --- a/core/src/org/labkey/core/mcp/McpServiceImpl.java +++ /dev/null @@ -1,969 +0,0 @@ -package org.labkey.core.mcp; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.google.genai.Client; -import com.google.genai.types.ClientOptions; -import io.modelcontextprotocol.common.McpTransportContext; -import io.modelcontextprotocol.json.McpJsonDefaults; -import io.modelcontextprotocol.server.McpServer; -import io.modelcontextprotocol.server.McpServerFeatures; -import io.modelcontextprotocol.server.McpSyncServer; -import io.modelcontextprotocol.server.transport.HttpServletStreamableServerTransportProvider; -import io.modelcontextprotocol.spec.McpSchema; -import jakarta.servlet.ServletContext; -import jakarta.servlet.ServletException; -import jakarta.servlet.ServletRequest; -import jakarta.servlet.ServletResponse; -import jakarta.servlet.http.HttpServlet; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -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.cache.Cache; -import org.labkey.api.cache.CacheManager; -import org.labkey.api.collections.CopyOnWriteHashMap; -import org.labkey.api.data.Container; -import org.labkey.api.data.ContainerManager; -import org.labkey.api.markdown.MarkdownService; -import org.labkey.api.mcp.McpContext; -import org.labkey.api.mcp.McpException; -import org.labkey.api.mcp.McpService; -import org.labkey.api.security.User; -import org.labkey.api.usageMetrics.SimpleMetricsService; -import org.labkey.api.util.ContextListener; -import org.labkey.api.util.FileUtil; -import org.labkey.api.util.HtmlString; -import org.labkey.api.util.JsonUtil; -import org.labkey.api.util.SessionHelper; -import org.labkey.api.util.ShutdownListener; -import org.labkey.api.util.logging.LogHelper; -import org.springframework.ai.anthropic.AnthropicChatModel; -import org.springframework.ai.anthropic.AnthropicChatOptions; -import org.springframework.ai.chat.client.ChatClient; -import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor; -import org.springframework.ai.chat.client.advisor.api.Advisor; -import org.springframework.ai.chat.client.advisor.vectorstore.QuestionAnswerAdvisor; -import org.springframework.ai.chat.memory.ChatMemory; -import org.springframework.ai.chat.memory.ChatMemoryRepository; -import org.springframework.ai.chat.memory.InMemoryChatMemoryRepository; -import org.springframework.ai.chat.memory.MessageWindowChatMemory; -import org.springframework.ai.chat.model.ChatModel; -import org.springframework.ai.chat.model.Generation; -import org.springframework.ai.chat.model.ToolContext; -import org.springframework.ai.chat.prompt.ChatOptions; -import org.springframework.ai.chat.prompt.Prompt; -import org.springframework.ai.document.Document; -import org.springframework.ai.document.MetadataMode; -import org.springframework.ai.embedding.EmbeddingModel; -import org.springframework.ai.google.genai.GoogleGenAiChatModel; -import org.springframework.ai.google.genai.GoogleGenAiChatOptions; -import org.springframework.ai.google.genai.GoogleGenAiEmbeddingConnectionDetails; -import org.springframework.ai.google.genai.text.GoogleGenAiTextEmbeddingModel; -import org.springframework.ai.google.genai.text.GoogleGenAiTextEmbeddingOptions; -import org.springframework.ai.openai.OpenAiChatModel; -import org.springframework.ai.openai.OpenAiChatOptions; -import org.springframework.ai.openai.OpenAiEmbeddingModel; -import org.springframework.ai.openai.OpenAiEmbeddingOptions; -import org.springframework.ai.openai.api.OpenAiApi; -import org.springframework.ai.tool.ToolCallback; -import org.springframework.ai.tool.definition.ToolDefinition; -import org.springframework.ai.tool.execution.ToolExecutionException; -import org.springframework.ai.tool.metadata.ToolMetadata; -import org.springframework.ai.vectorstore.SearchRequest; -import org.springframework.ai.vectorstore.SimpleVectorStore; -import org.springframework.ai.vectorstore.VectorStore; -import org.springframework.ai.vectorstore.filter.Filter; -import reactor.core.publisher.Mono; - -import java.io.IOException; -import java.time.Duration; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.ConcurrentModificationException; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.NoSuchElementException; -import java.util.function.Supplier; - -import static org.apache.commons.lang3.StringUtils.isBlank; -import static org.apache.commons.lang3.StringUtils.isNotBlank; -import static org.springframework.ai.chat.messages.MessageType.ASSISTANT; - - -public class McpServiceImpl implements McpService -{ - public static final String MESSAGE_ENDPOINT = "/_mcp/message"; - public static final String SSE_ENDPOINT = "/_mcp/sse"; - private static final Logger LOG = LogHelper.getLogger(McpServiceImpl.class, "MCP services"); - - private final CopyOnWriteHashMap toolMap = new CopyOnWriteHashMap<>(); - private final CopyOnWriteHashMap promptMap = new CopyOnWriteHashMap<>(); - private final CopyOnWriteHashMap resourceMap = new CopyOnWriteHashMap<>(); - - private final _McpServlet mcpServlet = new _McpServlet(JsonUtil.DEFAULT_MAPPER, MESSAGE_ENDPOINT, SSE_ENDPOINT); - private final ChatMemoryRepository chatMemoryRepository = new InMemoryChatMemoryRepository(); - private VectorStore vectorStore = null; - private boolean serverReady = false; - - private final _ModelProvider modelProvider; - private final _ModelProvider embeddingProvider; - - - public static McpServiceImpl get() - { - return (McpServiceImpl) McpService.get(); - } - - public McpServiceImpl() - { - _ModelProvider model = null; - _ModelProvider embedding = null; - if (isNotBlank(System.getenv("CLAUDE_API_KEY"))) - { - model = new _ClaudeProvider(); - } - if (isNotBlank(System.getenv("OPENAI_API_KEY"))) - { - var openai = new _ChatGptProvider(); - if (null == embedding) - embedding = openai; - if (null == model) - model = openai; - } - if (isNotBlank(System.getenv("GEMINI_API_KEY"))) - { - var gemini = new _GeminiProvider(); - if (null == embedding) - embedding = gemini; - if (null == model) - model = gemini; - } - modelProvider = model; - embeddingProvider = embedding; - } - - - /** - * Called by CoreModule.registerServlets() - * The servlet will return SC_SERVICE_UNAVAILABLE until startMcpServer() is called - */ - public void registerServlets(ServletContext servletCtx) - { - var mcpServletDynamic = servletCtx.addServlet("mcpServlet", mcpServlet); - mcpServletDynamic.setAsyncSupported(true); - mcpServletDynamic.addMapping(MESSAGE_ENDPOINT + "/*"); - mcpServletDynamic.addMapping(SSE_ENDPOINT + "/*"); - } - - - public void startMpcServer() - { - if (null == modelProvider) - { - return; - } - vectorStore = createVectorStore(); - mcpServlet.startMcpServer(); - serverReady = true; - } - - - @Override - public boolean isReady() - { - return serverReady; - } - - - @Override - public void registerTools(@NotNull List tools) - { - tools.forEach(tool -> toolMap.put(tool.getToolDefinition().name(), new _LoggingToolCallback(tool))); - } - - @Override - public void registerPrompts(@NotNull List prompts) - { - prompts.forEach(prompt -> promptMap.put(prompt.prompt().name(), prompt)); - } - - @Override - public void registerResources(@NotNull List resources) - { - resources.forEach(resource -> resourceMap.put(resource.resource().name(), resource)); - } - - - @Override - public ToolCallback @NonNull [] getToolCallbacks() - { - return toolMap.values().toArray(new ToolCallback[0]); - } - - - public List tools() - { - return toolMap.values().stream().map(ToolCallback::getToolDefinition).map(td -> - McpSchema.Tool.builder() - .name(td.name()) - .description(td.description()) - .inputSchema(McpJsonDefaults.getMapper(), td.inputSchema()) - .build() - ).toList(); - } - - public final static Cache PATH_CACHE = CacheManager.getCache(1000, CacheManager.DAY, "MCP container paths"); - - private class _McpServlet extends HttpServlet // wraps HttpServletSseServerTransportProvider - { - HttpServletStreamableServerTransportProvider transportProvider; - McpSyncServer mcpServer = null; - - _McpServlet(ObjectMapper objectMapper, String messageEndpoint, String sseEndpoint) - { - transportProvider = HttpServletStreamableServerTransportProvider.builder() - .jsonMapper(McpJsonDefaults.getMapper()) - .mcpEndpoint(messageEndpoint) - .contextExtractor(req -> { - User user = (User) req.getUserPrincipal(); - return McpTransportContext.create(Map.of( - "user", user - )); - }) - .build(); - } - - void startMcpServer() - { - List tools = Arrays.stream(getToolCallbacks()) - .map(this::toSyncToolSpecification) - .toList(); - - List resources = new ArrayList<>(resourceMap.values()); - - mcpServer = McpServer.sync(transportProvider) - .tools(tools) - .resources(resources) -// .capabilities(new McpSchema.ServerCapabilities()) - .build(); - ContextListener.addShutdownListener(new _ShutdownListener()); - } - - private McpServerFeatures.SyncToolSpecification toSyncToolSpecification(ToolCallback toolCallback) - { - var toolDef = toolCallback.getToolDefinition(); - var schema = McpSchema.Tool.builder() - .name(toolDef.name()) - .description(toolDef.description()) - .inputSchema(McpJsonDefaults.getMapper(), toolDef.inputSchema()) - .build(); - - return new McpServerFeatures.SyncToolSpecification(schema, (exchange, request) -> { - var transportCtx = exchange.transportContext(); - var user = (User) transportCtx.get("user"); - var sessionId = exchange.sessionId(); - - Map map = new HashMap<>(); - map.put("user", user); - map.put("sessionId", sessionId); - - String containerPath = PATH_CACHE.get(exchange.sessionId()); // TODO: Cache Container IDs instead of paths? - if (containerPath != null) - { - Container container = ContainerManager.getForPath(containerPath); - map.put("container", container); - map.put("containerPath", containerPath); - } - - var toolContext = new ToolContext(map); - - String toolInput = /* serialize args to JSON */ null; - try - { - toolInput = JsonUtil.DEFAULT_MAPPER.writeValueAsString(request.arguments()); - } - catch (JsonProcessingException e) - { - throw new RuntimeException(e); - } - - String result; - - try - { - SimpleMetricsService.get().increment("core", "mcpToolInvocations", request.name()); - result = toolCallback.call(toolInput, toolContext); - } - catch (ToolExecutionException e) - { - // If a tool threw McpException then just send back the message, not as an error - if (e.getCause() instanceof McpException) - result = e.getCause().getMessage(); - else - throw e; - } - catch (Throwable t) - { - // Set a breakpoint below to inspect exceptions during development - throw t; - } - - // Responses and McpExceptions are treated as "success". Tools should throw for true error conditions. - return new McpSchema.CallToolResult( - List.of(new McpSchema.TextContent(result)), - false, - null, - null - ); - }); - } - - @Override - public void service(ServletRequest sreq, ServletResponse sres) throws ServletException, IOException - { - if (!(sreq instanceof HttpServletRequest req) || !(sres instanceof HttpServletResponse res)) - { - // how to set error??? - throw new ServletException("non-HTTP request"); - } - - if (null == mcpServer) - { - res.sendError(HttpServletResponse.SC_SERVICE_UNAVAILABLE); - return; - } - - transportProvider.service(req, res); - } - - public Mono closeGracefully() - { - if (null != transportProvider) - return transportProvider.closeGracefully(); - return Mono.empty(); - } - } - - - // ShutdownListener - - class _ShutdownListener implements ShutdownListener - { - @Override - public String getName() - { - return "MpcService"; - } - - - Mono closing = null; - - @Override - public void shutdownPre() - { - closing = mcpServlet.closeGracefully(); - saveVectorDatabase(); - } - - @Override - public void shutdownStarted() - { - if (null == closing) - closing = mcpServlet.closeGracefully(); - closing.block(Duration.ofSeconds(5)); - } - } - - - /** Delegating wrapper that logs vector store similarity searches */ - private static class _LoggingVectorStore implements VectorStore - { - private final VectorStore delegate; - - _LoggingVectorStore(VectorStore delegate) - { - this.delegate = delegate; - } - - @Override - public void add(@NotNull List documents) - { - delegate.add(documents); - } - - @Override - public void delete(@NotNull Filter.Expression filterExpression) - { - delegate.delete(filterExpression); - } - - @Override - public void delete(@NotNull List idList) - { - delegate.delete(idList); - } - - @Override - public @NotNull List similaritySearch(SearchRequest request) - { - LOG.info("Vector store search: query=\"{}\"", request.getQuery()); - List results = delegate.similaritySearch(request); - if (results.isEmpty()) - { - LOG.info("Vector store search returned no results"); - } - else - { - LOG.info("Vector store search returned {} result(s):", results.size()); - for (Document doc : results) - { - String content = doc.getText(); - String snippet = content.length() > 200 ? content.substring(0, 200) + "..." : content; - LOG.info(" - [{}] {}", doc.getMetadata(), snippet); - } - } - return results; - } - - @Override - public @NotNull String getName() - { - return delegate.getName(); - } - } - - - @Override - public ChatClient getChat(HttpSession session, String agentName, Supplier systemPromptSupplier, boolean createIfNotExists) - { - if (!serverReady) - return null; - - String sessionKey = ChatClient.class.getName() + "#" + agentName; - if (createIfNotExists) - { - return SessionHelper.getAttribute(session, sessionKey, () -> - { - var springClient = createSpringChat(session, agentName, systemPromptSupplier); - return new _ChatClient(springClient, sessionKey); - }); - } - return SessionHelper.getAttribute(session, sessionKey, null); - } - - private ChatClient createSpringChat(HttpSession session, String agentName, Supplier systemPromptSupplier) - { - String systemPrompt = systemPromptSupplier.get(); - String conversationId = session.getId() + ":" + agentName; - List advisors = new ArrayList<>(); - - ChatMemory chatMemory = MessageWindowChatMemory.builder() - .maxMessages(100) - .chatMemoryRepository(chatMemoryRepository) - .build(); - - MessageChatMemoryAdvisor chatMemoryAdvisor = MessageChatMemoryAdvisor.builder(chatMemory) - .conversationId(conversationId) - .build(); - advisors.add(chatMemoryAdvisor); - - VectorStore vs = getVectorStore(); - if (null != vs) - advisors.add(QuestionAnswerAdvisor.builder(new _LoggingVectorStore(vs)).build()); - - return ChatClient.builder(modelProvider.getChatModel()) - .defaultOptions(modelProvider.getChatOptions()) - .defaultAdvisors(advisors) - .defaultSystem(systemPrompt) - .build(); - } - - private class _ChatClient implements ChatClient - { - final ChatClient springClient; - final String key; - _ChatClient(ChatClient client, String key) - { - this.springClient = client; - this.key = key; - } - - @Override - public ChatClientRequestSpec prompt() - { - return springClient.prompt(); - } - - @Override - public ChatClientRequestSpec prompt(String content) - { - return springClient.prompt(content); - } - - @Override - public ChatClientRequestSpec prompt(Prompt prompt) - { - return springClient.prompt(prompt); - } - - @Override - public Builder mutate() - { - throw new UnsupportedOperationException(); - } - } - - @Override - public void close(HttpSession session, ChatClient chat) - { - if (null == chat) - return; - session.removeAttribute(((_ChatClient)chat).key); - } - - @Override - public MessageResponse sendMessage(ChatClient chatSession, String message) - { - try - { - var callResponse = chatSession - .prompt(message) - .toolContext(McpContext.get().getToolContext().getContext()) - .call(); - StringBuilder sb = new StringBuilder(); - for (Generation result : callResponse.chatResponse().getResults()) - { - var output = result.getOutput(); - if (ASSISTANT == output.getMessageType()) - { - sb.append(output.getText()); - sb.append("\n\n"); - } - } - String md = sb.toString().strip(); - HtmlString html = HtmlString.unsafe(MarkdownService.get().toHtml(md)); - return new MessageResponse("text/markdown", md, html); - } - catch (java.util.NoSuchElementException x) - { - // Spring AI GoogleGenAiChatModel bug: empty candidates cause NoSuchElementException - // https://github.com/spring-projects/spring-ai/issues/4556 - LOG.warn("Empty response from chat model (likely a filtered or empty candidate)", x); - return new MessageResponse("text/plain", "The model returned an empty response. Please try resubmitting and, if the problem continues, rephrase your question/prompt.", HtmlString.of("The model returned an empty response. Please try rephrasing your question.")); - } - } - - @Override - public List sendMessageEx(ChatClient chatSession, String message) - { - if (isBlank(message)) - return List.of(); - try - { - var callResponse = chatSession - .prompt(message) - .toolContext(McpContext.get().getToolContext().getContext()) - .call(); - List ret = new ArrayList<>(); - for (Generation result : callResponse.chatResponse().getResults()) - { - var output = result.getOutput(); - if (ASSISTANT == output.getMessageType()) - { - String md = output.getText(); - HtmlString html = HtmlString.unsafe(MarkdownService.get().toHtml(md)); - ret.add(new MessageResponse("text/markdown", md, html)); - } - } - return ret; - } - catch (NoSuchElementException x) - { - // Spring AI GoogleGenAiChatModel bug: empty candidates cause NoSuchElementException - // https://github.com/spring-projects/spring-ai/issues/4556 - LOG.warn("Empty response from chat model (likely a filtered or empty candidate)", x); - return List.of(new MessageResponse("text/plain", "The model returned an empty response. Please try rephrasing your question.", HtmlString.of("The model returned an empty response. Please try rephrasing your question."))); - } - catch (ConcurrentModificationException x) - { - // This can happen when the vector store is still loading, typically a problem shortly after startup - // Should do better synchronization or state checking - LOG.warn("Vector store not ready", x); - return List.of(new MessageResponse("text/plain", "Vector store likely not ready yet. Try again.", HtmlString.of("Vector store likely not ready yet. Try again."))); - } - } - - - @Override - public VectorStore getVectorStore() - { - return !serverReady ? null : vectorStore; - } - - - VectorStore createVectorStore() - { - SimpleVectorStore ret = null; - - try - { - EmbeddingModel embeddingModel = embeddingProvider.createEmbeddingModel(); - if (null == embeddingModel) - return null; - - ret = SimpleVectorStore.builder(embeddingModel).build(); - - var savedFile = FileUtil.getTempDirectoryFileLike().resolveChild("VectorStore.database"); - if (savedFile.exists()) - { - try - { - ret.load(savedFile.toNioPathForRead().toFile()); - } - catch (Exception x) - { - LogHelper.getLogger(McpServiceImpl.class,"mcp service") - .error("error restoring saved vectordb: " + savedFile.toNioPathForRead(), x); - } - } - } - catch (Exception x) - { - LOG.error("Can't create vector store", x); - } - - return ret; - } - - void saveVectorDatabase() - { - SimpleVectorStore vectorStore = (SimpleVectorStore)getVectorStore(); - if (null == vectorStore) - return; - - var db = FileUtil.getTempDirectoryFileLike().resolveChild("VectorStore.database"); - try - { - vectorStore.save(db.toNioPathForRead().toFile()); - } - catch (Exception x) - { - LOG.error("Can't save vector store", x); - } - } - - - interface _ModelProvider - { - String getModel(); - - String getEmbeddingModel(); - - ChatOptions getChatOptions(); - - ChatModel getChatModel(); - - EmbeddingModel createEmbeddingModel(); - } - - - class _GeminiProvider implements _ModelProvider - { - final Object _initClientLock = new Object(); - Client _genAiClient = null; - - @Override - public String getModel() - { - return "gemini-3-pro-preview"; - } - - @Override - public String getEmbeddingModel() - { - return "gemini-embedding-001"; - } - - Client getLlmClient() - { - synchronized (_initClientLock) - { - if (null == _genAiClient) - { - ClientOptions clientOptions = ClientOptions.builder() - .build(); - _genAiClient = Client.builder() - .clientOptions(clientOptions) - .build(); - } - - return _genAiClient; - } - } - -/* - Generation Options - - ┌──────────────────┬──────────────┬─────────────────────────────────────────────────┐ - │ Option │ Type │ Description │ - ├──────────────────┼──────────────┼─────────────────────────────────────────────────┤ - │ model │ String │ Model name (e.g., gemini-2.5-pro-preview) │ - ├──────────────────┼──────────────┼─────────────────────────────────────────────────┤ - │ temperature │ Double │ Randomness of predictions (0.0–2.0) │ - ├──────────────────┼──────────────┼─────────────────────────────────────────────────┤ - │ topP │ Double │ Nucleus sampling threshold │ - ├──────────────────┼──────────────┼─────────────────────────────────────────────────┤ - │ topK │ Integer │ Top-k sampling parameter │ - ├──────────────────┼──────────────┼─────────────────────────────────────────────────┤ - │ candidateCount │ Integer │ Number of candidate responses to generate │ - ├──────────────────┼──────────────┼─────────────────────────────────────────────────┤ - │ maxOutputTokens │ Integer │ Maximum tokens in the response │ - ├──────────────────┼──────────────┼─────────────────────────────────────────────────┤ - │ frequencyPenalty │ Double │ Penalizes frequently used tokens │ - ├──────────────────┼──────────────┼─────────────────────────────────────────────────┤ - │ presencePenalty │ Double │ Penalizes tokens already present in the output │ - ├──────────────────┼──────────────┼─────────────────────────────────────────────────┤ - │ stopSequences │ List │ Sequences that stop generation when encountered │ - └──────────────────┴──────────────┴─────────────────────────────────────────────────┘ - - Response Format - - ┌──────────────────┬────────┬───────────────────────────────────┐ - │ Option │ Type │ Description │ - ├──────────────────┼────────┼───────────────────────────────────┤ - │ responseMimeType │ String │ text/plain or application/json │ - ├──────────────────┼────────┼───────────────────────────────────┤ - │ responseSchema │ String │ JSON schema for structured output │ - └──────────────────┴────────┴───────────────────────────────────┘ - - Thinking (Extended Reasoning) - - ┌──────────────────────────────┬──────────────────────────┬────────────────────────────────────────────────────┐ - │ Option │ Type │ Description │ - ├──────────────────────────────┼──────────────────────────┼────────────────────────────────────────────────────┤ - │ thinkingBudget │ Integer │ Token budget for the thinking process │ - ├──────────────────────────────┼──────────────────────────┼────────────────────────────────────────────────────┤ - │ includeThoughts │ Boolean │ Whether to include reasoning steps in the response │ - ├──────────────────────────────┼──────────────────────────┼────────────────────────────────────────────────────┤ - │ thinkingLevel │ GoogleGenAiThinkingLevel │ Enum: THINKING_LEVEL_UNSPECIFIED, LOW, HIGH │ - ├──────────────────────────────┼──────────────────────────┼────────────────────────────────────────────────────┤ - │ includeExtendedUsageMetadata │ Boolean │ Include token-level usage details in the response │ - └──────────────────────────────┴──────────────────────────┴────────────────────────────────────────────────────┘ - - Caching - - ┌────────────────────┬──────────┬───────────────────────────────────────────────┐ - │ Option │ Type │ Description │ - ├────────────────────┼──────────┼───────────────────────────────────────────────┤ - │ cachedContentName │ String │ Name of cached content to reuse │ - ├────────────────────┼──────────┼───────────────────────────────────────────────┤ - │ useCachedContent │ Boolean │ Whether to use cached content if available │ - ├────────────────────┼──────────┼───────────────────────────────────────────────┤ - │ autoCacheThreshold │ Integer │ Auto-cache prompts exceeding this token count │ - ├────────────────────┼──────────┼───────────────────────────────────────────────┤ - │ autoCacheTtl │ Duration │ TTL for auto-cached content │ - └────────────────────┴──────────┴───────────────────────────────────────────────┘ - - Tools / Function Calling - - ┌──────────────────────────────┬─────────────────────┬───────────────────────────────────────────────────┐ - │ Option │ Type │ Description │ - ├──────────────────────────────┼─────────────────────┼───────────────────────────────────────────────────┤ - │ toolCallbacks │ List │ Tool implementations for function calling │ - ├──────────────────────────────┼─────────────────────┼───────────────────────────────────────────────────┤ - │ toolNames │ Set │ Tool names resolved at runtime │ - ├──────────────────────────────┼─────────────────────┼───────────────────────────────────────────────────┤ - │ internalToolExecutionEnabled │ Boolean │ Whether Spring AI handles the tool execution loop │ - ├──────────────────────────────┼─────────────────────┼───────────────────────────────────────────────────┤ - │ toolContext │ Map │ Contextual data passed to tools │ - └──────────────────────────────┴─────────────────────┴───────────────────────────────────────────────────┘ - - Safety & Search - - ┌───────────────────────┬────────────────────────────────┬────────────────────────────────────┐ - │ Option │ Type │ Description │ - ├───────────────────────┼────────────────────────────────┼────────────────────────────────────┤ - │ safetySettings │ List │ Safety filters and harm thresholds │ - ├───────────────────────┼────────────────────────────────┼────────────────────────────────────┤ - │ googleSearchRetrieval │ Boolean │ Enable Google Search grounding │ - ├───────────────────────┼────────────────────────────────┼────────────────────────────────────┤ - │ labels │ Map │ Custom labels attached to requests │ - └───────────────────────┴────────────────────────────────┴────────────────────────────────────┘ -*/ - @Override - public GoogleGenAiChatOptions getChatOptions() - { - GoogleGenAiChatOptions chatOptions = GoogleGenAiChatOptions.builder() - .model(getModel()) - .toolCallbacks(getToolCallbacks()) - .build(); - return chatOptions; - } - - @Override - public ChatModel getChatModel() - { - Client genAiClient = getLlmClient(); - GoogleGenAiChatOptions chatOptions = getChatOptions(); - - ChatModel chatModel = GoogleGenAiChatModel.builder() - .genAiClient(genAiClient) - .defaultOptions(chatOptions) - .build(); - return chatModel; - } - - @Override - public EmbeddingModel createEmbeddingModel() - { - ClientOptions clientOptions = ClientOptions.builder() - .build(); - Client client = Client.builder() // not shared with getLlmClient() ??? maybe causing problems? - .clientOptions(clientOptions) - .build(); - GoogleGenAiEmbeddingConnectionDetails connectionDetails = GoogleGenAiEmbeddingConnectionDetails.builder() - .genAiClient(client) - .build(); - GoogleGenAiTextEmbeddingOptions embeddingOptions = GoogleGenAiTextEmbeddingOptions.builder() - .model(getEmbeddingModel()) - .build(); - EmbeddingModel embeddingModel; - embeddingModel = new GoogleGenAiTextEmbeddingModel(connectionDetails, embeddingOptions); - return embeddingModel; - } - } - - - private static class _LoggingToolCallback implements ToolCallback - { - private final ToolCallback delegate; - - _LoggingToolCallback(ToolCallback delegate) - { - this.delegate = delegate; - } - - @Override - public ToolDefinition getToolDefinition() - { - return delegate.getToolDefinition(); - } - - @Override - public ToolMetadata getToolMetadata() - { - return delegate.getToolMetadata(); - } - - @Override - public String call(String toolInput) - { - LOG.info("MCP tool invoked: {}", delegate.getToolDefinition().name()); - return delegate.call(toolInput); - } - - @Override - public String call(String toolInput, ToolContext toolContext) - { - LOG.info("MCP tool invoked: {}", delegate.getToolDefinition().name()); - return delegate.call(toolInput, toolContext); - } - } - - - class _ClaudeProvider implements _ModelProvider - { - @Override - public String getModel() - { - return "claude-sonnet-4-5-20250929"; - } - - @Override - public String getEmbeddingModel() - { - // NYI in spring-ai -- need to use a different service (or Claude java library) - // return "voyage-3.5-lite"; - return null; - } - - @Override - public AnthropicChatOptions getChatOptions() - { - return AnthropicChatOptions.builder() - .model(getModel()) - .apiKey(System.getenv("CLAUDE_API_KEY")) - .toolCallbacks(getToolCallbacks()) - .build(); - } - - @Override - public AnthropicChatModel getChatModel() - { - return AnthropicChatModel.builder() - .options(getChatOptions()) - .build(); - } - - @Override - public EmbeddingModel createEmbeddingModel() - { - return null; - } - } - - class _ChatGptProvider implements _ModelProvider - { - @Override - public String getModel() - { - return "gpt-4o"; - } - - @Override - public String getEmbeddingModel() - { - return "text-embedding-3-small"; - } - - @Override - public OpenAiChatOptions getChatOptions() - { - return OpenAiChatOptions.builder() - .model(getModel()) - .toolCallbacks(getToolCallbacks()) - .build(); - } - - @Override - public OpenAiChatModel getChatModel() - { - OpenAiApi openAiApi = OpenAiApi.builder() - .apiKey(System.getenv("OPENAI_API_KEY")) - .build(); - - return OpenAiChatModel.builder() - .openAiApi(openAiApi) - .defaultOptions(getChatOptions()) - .build(); - } - - @Override - public EmbeddingModel createEmbeddingModel() - { - OpenAiApi openAiApi = OpenAiApi.builder() - .apiKey(System.getenv("OPENAI_API_KEY")) - .build(); - - OpenAiEmbeddingOptions embeddingOptions = OpenAiEmbeddingOptions.builder() - .model(getEmbeddingModel()) - .build(); - - return new OpenAiEmbeddingModel(openAiApi, MetadataMode.EMBED, embeddingOptions); - } - } -} From a9c195927d3a4b851862081241e06e1066b06235 Mon Sep 17 00:00:00 2001 From: Adam Rauch Date: Tue, 24 Mar 2026 17:54:16 -0700 Subject: [PATCH 10/19] Better error handling --- core/src/org/labkey/core/CoreMcp.java | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/core/src/org/labkey/core/CoreMcp.java b/core/src/org/labkey/core/CoreMcp.java index ece801f2d5b..4e8653029ee 100644 --- a/core/src/org/labkey/core/CoreMcp.java +++ b/core/src/org/labkey/core/CoreMcp.java @@ -80,13 +80,27 @@ String listContainers(ToolContext toolContext) @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) { - Container container = ContainerManager.getForPath(containerPath); - if (container != null) + final String message; + + if (containerPath == null) + { + message = "Container path was null. Please enter a valid container path. Try using listContainers to see them."; + } + else { - McpService.get().saveSessionContainer(context, container); - return "OK!"; + 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 "That's not a valid container path. Try using listContainers to see them."; + return message; } } From 3adc8e134453feb81a8636c0012578795ad71eee Mon Sep 17 00:00:00 2001 From: Adam Rauch Date: Tue, 24 Mar 2026 22:35:18 -0700 Subject: [PATCH 11/19] Be more explicit about no container path --- api/src/org/labkey/api/mcp/McpService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/org/labkey/api/mcp/McpService.java b/api/src/org/labkey/api/mcp/McpService.java index 73de78297bb..97ddd8bd474 100644 --- a/api/src/org/labkey/api/mcp/McpService.java +++ b/api/src/org/labkey/api/mcp/McpService.java @@ -42,7 +42,7 @@ 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("You need to set a container path before invoking this tool"); + 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); } From 93e85757db8f8939825a32dc7d12c1f131115e64 Mon Sep 17 00:00:00 2001 From: Adam Rauch Date: Tue, 24 Mar 2026 23:13:09 -0700 Subject: [PATCH 12/19] Claude feedback --- core/src/org/labkey/core/CoreMcp.java | 3 ++- query/src/org/labkey/query/controllers/QueryMcp.java | 12 ++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/core/src/org/labkey/core/CoreMcp.java b/core/src/org/labkey/core/CoreMcp.java index 4e8653029ee..064c8506c39 100644 --- a/core/src/org/labkey/core/CoreMcp.java +++ b/core/src/org/labkey/core/CoreMcp.java @@ -23,7 +23,8 @@ public class CoreMcp implements McpService.McpImpl { - @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).") + @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) { var cu = getContext(context); diff --git a/query/src/org/labkey/query/controllers/QueryMcp.java b/query/src/org/labkey/query/controllers/QueryMcp.java index c551bc534c2..ac8bfaaffcf 100644 --- a/query/src/org/labkey/query/controllers/QueryMcp.java +++ b/query/src/org/labkey/query/controllers/QueryMcp.java @@ -19,6 +19,7 @@ import org.labkey.api.query.SchemaKey; import org.labkey.api.query.SimpleSchemaTreeVisitor; import org.labkey.api.query.UserSchema; +import org.labkey.api.view.NotFoundException; import org.labkey.api.writer.ContainerUser; import org.labkey.query.sql.SqlParser; import org.springframework.ai.chat.model.ToolContext; @@ -156,7 +157,7 @@ public static JSONObject _listTables(String fullQuotedName, ContainerUser cu) var defaultSchema = DefaultSchema.get(cu.getUser(), cu.getContainer()); var schema = DefaultSchema.resolve(defaultSchema, fullKey); if (!(schema instanceof UserSchema userSchema)) - return new JSONObject("error", "could not find schema for : " + fullQuotedName); + throw new NotFoundException("Could not find schema for " + fullQuotedName); JSONArray array = new JSONArray(); CaseInsensitiveHashSet names = new CaseInsensitiveHashSet(schema.getTableNames()); @@ -212,12 +213,11 @@ public JSONObject _listColumns(String fullQuotedName, ToolContext toolContext) } else if (fullKey.size() == 1) { - schemaKey = SchemaKey.fromParts("study"); - tableName = fullKey.getName(); + throw new NotFoundException("You need to provide a fully qualified schema and table"); } else { - return new JSONObject("error", "could not find table"); + throw new NotFoundException("Could not find table " + fullQuotedName); } SchemaKey tableKey = new SchemaKey(schemaKey, tableName); @@ -226,11 +226,11 @@ else if (fullKey.size() == 1) var schema = DefaultSchema.resolve(defaultSchema, schemaKey); if (null == schema) - return new JSONObject("error", "could not find table"); + throw new NotFoundException("Could not find schema for : " + fullQuotedName); TableInfo td = schema.getTable(tableName, null); if (null == td) - return new JSONObject("error", "could not find table"); + throw new NotFoundException("Could not find table for : " + fullQuotedName); String sourceSQL = null; if (schema instanceof UserSchema userSchema) From 437abe6e21d5ece43cf14bda0ae2200e4b9f697c Mon Sep 17 00:00:00 2001 From: Adam Rauch Date: Wed, 25 Mar 2026 06:47:42 -0700 Subject: [PATCH 13/19] Document search endpoints --- search/src/org/labkey/search/SearchModule.java | 1 + 1 file changed, 1 insertion(+) diff --git a/search/src/org/labkey/search/SearchModule.java b/search/src/org/labkey/search/SearchModule.java index 41b544777a6..f3b777cdd52 100644 --- a/search/src/org/labkey/search/SearchModule.java +++ b/search/src/org/labkey/search/SearchModule.java @@ -133,6 +133,7 @@ public WebdavResource resolve(@NotNull String path) } }); + // Search endpoints are not ready for prime time. For now, don't register. // var mcp = McpService.get(); // if (null != mcp) // { From 2e38bd362b56afd4f8b35ce13618eff17d076d36 Mon Sep 17 00:00:00 2001 From: Adam Rauch Date: Wed, 25 Mar 2026 08:37:58 -0700 Subject: [PATCH 14/19] No leading slash --- core/src/org/labkey/core/CoreMcp.java | 11 +++++++---- query/src/org/labkey/query/controllers/QueryMcp.java | 2 -- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/core/src/org/labkey/core/CoreMcp.java b/core/src/org/labkey/core/CoreMcp.java index 064c8506c39..d2c31eb4184 100644 --- a/core/src/org/labkey/core/CoreMcp.java +++ b/core/src/org/labkey/core/CoreMcp.java @@ -1,5 +1,6 @@ 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; @@ -24,7 +25,7 @@ public class CoreMcp implements McpService.McpImpl { @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.") + "(name, url, description), and current container/folder (name, path, url, description) once the container is set via setContainer.") String whereAmIWhoAmITalkingTo(ToolContext context) { var cu = getContext(context); @@ -73,13 +74,15 @@ String listContainers(ToolContext toolContext) { return ContainerManager.getAllChildren(ContainerManager.getRoot(), getUser(toolContext), ReadPermission.class) .stream() - .map(Container::getPath) + .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 session.") - String setContainer(ToolContext context, @ToolParam(description = "Container path, e.g. /MyProject/MyFolder", required = true) String containerPath) + @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. Don't suggest a leading slash on the path " + + "because typing a slash in some LLM clients triggers custom shortcuts.") + String setContainer(ToolContext context, @ToolParam(description = "Container path, e.g. MyProject/MyFolder") String containerPath) { final String message; diff --git a/query/src/org/labkey/query/controllers/QueryMcp.java b/query/src/org/labkey/query/controllers/QueryMcp.java index ac8bfaaffcf..710a0219ad1 100644 --- a/query/src/org/labkey/query/controllers/QueryMcp.java +++ b/query/src/org/labkey/query/controllers/QueryMcp.java @@ -59,7 +59,6 @@ public McpSchema.ReadResourceResult getLabKeySQLDocumentation() throws IOExcepti String listColumns(ToolContext toolContext, @ToolParam(description = "Fully qualified table name as it would appear in SQL e.g. \"schema\".\"table\"") String fullQuotedTableName) { var json = _listColumns(fullQuotedTableName, toolContext); - // can I just return a JSONObject return json.toString(); } @@ -67,7 +66,6 @@ String listColumns(ToolContext toolContext, @ToolParam(description = "Fully qual String listTables(ToolContext toolContext, @ToolParam(description = "Fully qualified schema name as it would appear in SQL e.g. \"schema\"") String quotedSchemaName) { var json = _listTables(quotedSchemaName, getContext(toolContext)); - // can I just return a JSONObject return json.toString(); } From d378c1a6ab556e3bab4f8f8a9ac7caa799d6d35f Mon Sep 17 00:00:00 2001 From: Adam Rauch Date: Wed, 25 Mar 2026 09:06:52 -0700 Subject: [PATCH 15/19] Tweaks --- core/src/org/labkey/core/CoreMcp.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/org/labkey/core/CoreMcp.java b/core/src/org/labkey/core/CoreMcp.java index d2c31eb4184..b5733f22904 100644 --- a/core/src/org/labkey/core/CoreMcp.java +++ b/core/src/org/labkey/core/CoreMcp.java @@ -101,7 +101,7 @@ String setContainer(ToolContext context, @ToolParam(description = "Container pat else { McpService.get().saveSessionContainer(context, container); - message = "Container has been set"; + message = "Container has been set to " + container.getPath(); } } From f21d9fd3a30a52092a172b471b798694b27f6c57 Mon Sep 17 00:00:00 2001 From: Adam Rauch Date: Wed, 25 Mar 2026 14:41:28 -0700 Subject: [PATCH 16/19] Annotation-based permission checking for tools --- api/src/org/labkey/api/mcp/McpService.java | 28 ++++++------------- .../api/security/RequiresNoPermission.java | 11 ++++---- .../api/security/RequiresPermission.java | 6 ++-- core/src/org/labkey/core/CoreMcp.java | 15 ++++++++-- .../labkey/query/controllers/QueryMcp.java | 10 +++++-- 5 files changed, 37 insertions(+), 33 deletions(-) diff --git a/api/src/org/labkey/api/mcp/McpService.java b/api/src/org/labkey/api/mcp/McpService.java index 97ddd8bd474..0c0dfb48333 100644 --- a/api/src/org/labkey/api/mcp/McpService.java +++ b/api/src/org/labkey/api/mcp/McpService.java @@ -6,7 +6,6 @@ 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; @@ -52,9 +51,9 @@ default User getUser(ToolContext toolContext) } // Every MCP resource should call this on every invocation - default void incrementResourceReadCount(String resource) + default void incrementResourceRequestCount(String resource) { - get().incrementResourceCount(resource); + get().incrementResourceRequestCount(resource); } } @@ -70,27 +69,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 tools); + void registerTools(@NotNull List tools, McpImpl mcp); void registerPrompts(@NotNull List prompts); @@ -106,7 +96,7 @@ default ChatClient getChat(HttpSession session, String agentName, Supplier systemPromptSupplier, boolean createIfNotExists); diff --git a/api/src/org/labkey/api/security/RequiresNoPermission.java b/api/src/org/labkey/api/security/RequiresNoPermission.java index 77d640cb657..5ab4b3b3a77 100644 --- a/api/src/org/labkey/api/security/RequiresNoPermission.java +++ b/api/src/org/labkey/api/security/RequiresNoPermission.java @@ -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 { } \ No newline at end of file diff --git a/api/src/org/labkey/api/security/RequiresPermission.java b/api/src/org/labkey/api/security/RequiresPermission.java index 8630a20a2ad..ed7dec3a3e0 100644 --- a/api/src/org/labkey/api/security/RequiresPermission.java +++ b/api/src/org/labkey/api/security/RequiresPermission.java @@ -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 value(); diff --git a/core/src/org/labkey/core/CoreMcp.java b/core/src/org/labkey/core/CoreMcp.java index b5733f22904..58a5f4c9a94 100644 --- a/core/src/org/labkey/core/CoreMcp.java +++ b/core/src/org/labkey/core/CoreMcp.java @@ -6,6 +6,8 @@ import org.labkey.api.data.Container; 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; @@ -13,6 +15,7 @@ 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; @@ -26,6 +29,7 @@ public class CoreMcp implements McpService.McpImpl { @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) { var cu = getContext(context); @@ -70,6 +74,7 @@ String whereAmIWhoAmITalkingTo(ToolContext context) } @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) @@ -80,8 +85,10 @@ String listContainers(ToolContext toolContext) } @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. Don't suggest a leading slash on the path " + - "because typing a slash in some LLM clients triggers custom shortcuts.") + "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; @@ -100,6 +107,10 @@ String setContainer(ToolContext context, @ToolParam(description = "Container pat } 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(); } diff --git a/query/src/org/labkey/query/controllers/QueryMcp.java b/query/src/org/labkey/query/controllers/QueryMcp.java index 710a0219ad1..101585a7cdf 100644 --- a/query/src/org/labkey/query/controllers/QueryMcp.java +++ b/query/src/org/labkey/query/controllers/QueryMcp.java @@ -19,6 +19,8 @@ import org.labkey.api.query.SchemaKey; import org.labkey.api.query.SimpleSchemaTreeVisitor; import org.labkey.api.query.UserSchema; +import org.labkey.api.security.RequiresPermission; +import org.labkey.api.security.permissions.ReadPermission; import org.labkey.api.view.NotFoundException; import org.labkey.api.writer.ContainerUser; import org.labkey.query.sql.SqlParser; @@ -44,7 +46,7 @@ public class QueryMcp implements McpService.McpImpl description = "Provide documentation for LabKey SQL specific syntax") public McpSchema.ReadResourceResult getLabKeySQLDocumentation() throws IOException { - incrementResourceReadCount("LabKey SQL"); + incrementResourceRequestCount("LabKey SQL"); String markdown = IOUtils.resourceToString("org/labkey/query/controllers/LabKeySql.md", null, QueryController.class.getClassLoader()); return new McpSchema.ReadResourceResult(List.of( new McpSchema.TextResourceContents( @@ -56,6 +58,7 @@ public McpSchema.ReadResourceResult getLabKeySQLDocumentation() throws IOExcepti } @Tool(description = "Provide column metadata for a sql table. This tool will also return SQL source for saved queries.") + @RequiresPermission(ReadPermission.class) String listColumns(ToolContext toolContext, @ToolParam(description = "Fully qualified table name as it would appear in SQL e.g. \"schema\".\"table\"") String fullQuotedTableName) { var json = _listColumns(fullQuotedTableName, toolContext); @@ -63,6 +66,7 @@ String listColumns(ToolContext toolContext, @ToolParam(description = "Fully qual } @Tool(description = "Provide list of tables within the provided schema.") + @RequiresPermission(ReadPermission.class) String listTables(ToolContext toolContext, @ToolParam(description = "Fully qualified schema name as it would appear in SQL e.g. \"schema\"") String quotedSchemaName) { var json = _listTables(quotedSchemaName, getContext(toolContext)); @@ -70,6 +74,7 @@ String listTables(ToolContext toolContext, @ToolParam(description = "Fully quali } @Tool(description = "Provide list of database schemas") + @RequiresPermission(ReadPermission.class) String listSchemas(ToolContext toolContext) { ContainerUser cu = getContext(toolContext); @@ -88,6 +93,7 @@ String listSchemas(ToolContext toolContext) @Tool(description = "Provide the SQL source for a saved query.") + @RequiresPermission(ReadPermission.class) String getSourceForSavedQuery(ToolContext toolContext, @ToolParam(description = "Fully qualified query name as it would appear in SQL e.g. \"schema\".\"table or query\"") String fullQuotedTableName) { var json = _listTables(fullQuotedTableName, getContext(toolContext)); @@ -309,7 +315,6 @@ static String normalizeIdentifier(String compoundIdentifier) return new SqlParser().parseIdentifier(compoundIdentifier).toSQLString(true).toLowerCase(); } - /** JSON schema example provided by GEMINI, using triple tick-marks to delimit the machine-readable structured data * * Here is the database schema in JSON format: @@ -338,4 +343,3 @@ static String normalizeIdentifier(String compoundIdentifier) * }``` */ } - From dff04ac47f353088ae333908d14caecdbf68a56e Mon Sep 17 00:00:00 2001 From: Adam Rauch Date: Wed, 25 Mar 2026 16:33:08 -0700 Subject: [PATCH 17/19] Simple validation/guidance for missing parameters. Fix getSourceForSavedQuery(). --- api/src/org/labkey/api/mcp/McpService.java | 43 ++++++++- .../org/labkey/api/mcp/NoopMcpService.java | 87 +++++++++++++++++++ core/src/org/labkey/core/CoreMcp.java | 2 +- .../labkey/query/controllers/QueryMcp.java | 19 ++-- 4 files changed, 141 insertions(+), 10 deletions(-) create mode 100644 api/src/org/labkey/api/mcp/NoopMcpService.java diff --git a/api/src/org/labkey/api/mcp/McpService.java b/api/src/org/labkey/api/mcp/McpService.java index 0c0dfb48333..d012684b264 100644 --- a/api/src/org/labkey/api/mcp/McpService.java +++ b/api/src/org/labkey/api/mcp/McpService.java @@ -4,11 +4,14 @@ 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.data.Container; import org.labkey.api.security.User; import org.labkey.api.services.ServiceRegistry; import org.labkey.api.util.HtmlString; +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; @@ -19,7 +22,9 @@ 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; /** @@ -55,11 +60,47 @@ 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 parameters) + { + List 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) diff --git a/api/src/org/labkey/api/mcp/NoopMcpService.java b/api/src/org/labkey/api/mcp/NoopMcpService.java new file mode 100644 index 00000000000..f6f63534ce0 --- /dev/null +++ b/api/src/org/labkey/api/mcp/NoopMcpService.java @@ -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 tools, McpImpl mcp) + { + + } + + @Override + public void registerPrompts(@NotNull List prompts) + { + + } + + @Override + public void registerResources(@NotNull List 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 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; + } +} diff --git a/core/src/org/labkey/core/CoreMcp.java b/core/src/org/labkey/core/CoreMcp.java index 58a5f4c9a94..1b773501ac4 100644 --- a/core/src/org/labkey/core/CoreMcp.java +++ b/core/src/org/labkey/core/CoreMcp.java @@ -95,7 +95,7 @@ String setContainer(ToolContext context, @ToolParam(description = "Container pat if (containerPath == null) { - message = "Container path was null. Please enter a valid container path. Try using listContainers to see them."; + message = "Container path was null. Please provide a valid containerPath parameter. Try using the listContainers tool to see them."; } else { diff --git a/query/src/org/labkey/query/controllers/QueryMcp.java b/query/src/org/labkey/query/controllers/QueryMcp.java index 101585a7cdf..ab4bd9aebe9 100644 --- a/query/src/org/labkey/query/controllers/QueryMcp.java +++ b/query/src/org/labkey/query/controllers/QueryMcp.java @@ -59,17 +59,19 @@ public McpSchema.ReadResourceResult getLabKeySQLDocumentation() throws IOExcepti @Tool(description = "Provide column metadata for a sql table. This tool will also return SQL source for saved queries.") @RequiresPermission(ReadPermission.class) - String listColumns(ToolContext toolContext, @ToolParam(description = "Fully qualified table name as it would appear in SQL e.g. \"schema\".\"table\"") String fullQuotedTableName) + String listColumns(ToolContext toolContext, @ToolParam(description = "Fully qualified table name as it would appear in SQL e.g. \"schema\".\"table\"") String tableName) { - var json = _listColumns(fullQuotedTableName, toolContext); + validateRequiredParameters("tableName", tableName); + var json = _listColumns(tableName, toolContext); return json.toString(); } @Tool(description = "Provide list of tables within the provided schema.") @RequiresPermission(ReadPermission.class) - String listTables(ToolContext toolContext, @ToolParam(description = "Fully qualified schema name as it would appear in SQL e.g. \"schema\"") String quotedSchemaName) + String listTables(ToolContext toolContext, @ToolParam(description = "Fully qualified schema name as it would appear in SQL e.g. \"schema\"") String schemaName) { - var json = _listTables(quotedSchemaName, getContext(toolContext)); + validateRequiredParameters("schemaName", schemaName); + var json = _listTables(schemaName, getContext(toolContext)); return json.toString(); } @@ -94,16 +96,17 @@ String listSchemas(ToolContext toolContext) @Tool(description = "Provide the SQL source for a saved query.") @RequiresPermission(ReadPermission.class) - String getSourceForSavedQuery(ToolContext toolContext, @ToolParam(description = "Fully qualified query name as it would appear in SQL e.g. \"schema\".\"table or query\"") String fullQuotedTableName) + String getSourceForSavedQuery(ToolContext toolContext, @ToolParam(description = "Fully qualified query name as it would appear in SQL e.g. \"schema\".\"table or query\"") String tableName) { - var json = _listTables(fullQuotedTableName, getContext(toolContext)); + validateRequiredParameters("tableName", tableName); + var json = _listColumns(tableName, toolContext); if (json.has("sql")) return "```sql\n" + json.getString("sql") + "\n```\n"; else - return "I could not find the source for " + fullQuotedTableName; + return "I could not find the source for " + tableName; } - /* For now, list all schemas. CONSIDER support incremental querying. */ + /* For now, list all schemas. CONSIDER support incremental querying. */ public static Map _listAllSchemas(DefaultSchema root) { SimpleSchemaTreeVisitor, Void> visitor = new SimpleSchemaTreeVisitor<>(false) From f17141e8821b6f66ed585284757f163e559ab542 Mon Sep 17 00:00:00 2001 From: Adam Rauch Date: Thu, 26 Mar 2026 08:52:25 -0700 Subject: [PATCH 18/19] File-based module development guide --- core/src/org/labkey/core/CoreMcp.java | 24 + core/src/org/labkey/core/FileBasedModules.md | 442 ++++++++++++++++++ .../labkey/query/controllers/QueryMcp.java | 9 +- 3 files changed, 471 insertions(+), 4 deletions(-) create mode 100644 core/src/org/labkey/core/FileBasedModules.md diff --git a/core/src/org/labkey/core/CoreMcp.java b/core/src/org/labkey/core/CoreMcp.java index 1b773501ac4..17ac2956dfd 100644 --- a/core/src/org/labkey/core/CoreMcp.java +++ b/core/src/org/labkey/core/CoreMcp.java @@ -1,5 +1,8 @@ package org.labkey.core; +import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.spec.McpSchema.ReadResourceResult; +import org.apache.commons.io.IOUtils; import org.apache.commons.lang3.StringUtils; import org.json.JSONObject; import org.labkey.api.collections.LabKeyCollectors; @@ -17,9 +20,12 @@ import org.labkey.api.util.HtmlString; import org.labkey.api.view.UnauthorizedException; import org.springframework.ai.chat.model.ToolContext; +import org.springframework.ai.mcp.annotation.McpResource; import org.springframework.ai.tool.annotation.Tool; import org.springframework.ai.tool.annotation.ToolParam; +import java.io.IOException; +import java.util.List; import java.util.Map; import java.util.Objects; @@ -118,4 +124,22 @@ String setContainer(ToolContext context, @ToolParam(description = "Container pat return message; } + + @McpResource( + uri = "resource://org/labkey/core/FileBasedModules.md", + mimeType = "application/markdown", + name = "File-Based Module Development Guide", + description = "Provide documentation for developing LabKey file-based modules") + public ReadResourceResult getFileBasedModuleDevelopmentGuide() throws IOException + { + incrementResourceRequestCount("File-Based Modules"); + String markdown = IOUtils.resourceToString("org/labkey/core/FileBasedModules.md", null, CoreModule.class.getClassLoader()); + return new ReadResourceResult(List.of( + new McpSchema.TextResourceContents( + "resource://org/labkey/core/FileBasedModules.md", + "application/markdown", + markdown + ) + )); + } } diff --git a/core/src/org/labkey/core/FileBasedModules.md b/core/src/org/labkey/core/FileBasedModules.md new file mode 100644 index 00000000000..8fe58647b60 --- /dev/null +++ b/core/src/org/labkey/core/FileBasedModules.md @@ -0,0 +1,442 @@ +# FileBasedModules.md - File-Based Module Development Guide + +This file provides guidance for creating and developing LabKey file-based modules. + +## What is a File-Based Module? + +A file-based module is a LabKey module that doesn't contain any Java code. It enables custom development without compiling, letting you directly deploy and test module resources, often without restarting the server. File-based modules support: + +- SQL queries and views +- Reports (R, JavaScript, HTML) +- Custom data views +- Web parts and HTML/JavaScript client-side applications +- Assay definitions +- ETL configurations +- Pipeline definitions + +## Module Directory Structure + +### Development/Source Layout +``` +myModule/ +├── module.properties # Module configuration (REQUIRED) +├── README.md # Module documentation +└── resources/ # All module resources go here + ├── queries/ # SQL queries and query metadata + │ └── [schema_name]/ # Organize by schema + │ ├── [query_name].sql + │ ├── [query_name].query.xml + │ └── [query_name]/ # Query-specific views + │ └── [view_name].html + ├── reports/ # Report definitions + │ └── schemas/ + │ └── [schema_name]/ + │ └── [query_name]/ + │ ├── [report_name].r + │ ├── [report_name].rhtml + │ └── [report_name].report.xml + ├── views/ # Custom views and web parts + │ ├── [view_name].html + │ └── [view_name].webpart.xml + ├── schemas/ # Database schema definitions + │ └── dbscripts/ + │ ├── postgresql/ + │ └── sqlserver/ + ├── web/ # JavaScript, CSS, images + │ └── [moduleName]/ + │ ├── [moduleName].js + │ └── [moduleName].css + ├── assay/ # Assay type definitions + ├── etls/ # ETL configurations + ├── folderTypes/ # Custom folder type definitions + └── pipeline/ # Pipeline task definitions +``` + +### Deployed Layout +When deployed, the structure changes slightly: +- `resources/` directory contents move to root level +- `module.properties` becomes `config/module.xml` +- Compiled code (if any) goes to `lib/` + +## module.properties File + +This is the **required** configuration file for your module. Place it in the module root. + +### Required Properties +```properties +ModuleClass: org.labkey.api.module.SimpleModule +Name: myModule +``` + +### Recommended Properties +```properties +ModuleClass: org.labkey.api.module.SimpleModule +Name: myModule +Label: My Custom Module +Description: A file-based module for custom queries, reports, and views.\ + Multi-line descriptions can span multiple lines using backslash continuation. +Version: 1.0.0 +Author: Your Name +Organization: Your Organization +OrganizationURL: https://example.com +License: Apache 2.0 +LicenseURL: https://www.apache.org/licenses/LICENSE-2.0 +Maintainer: Your Name +RequiredServerVersion: 23.11 +``` +`Name` should usually be the same as the directory name, especially for file-based modules. + +### Additional Properties +- **SchemaVersion**: Version number for SQL schema upgrade scripts (e.g., `1.00`) +- **ManageVersion**: Boolean (true/false) for schema version management +- **BuildType**: "Development" or "Production" +- **SupportedDatabases**: "pgsql" or "mssql" (comma-separated) +- **URL**: Homepage URL for the module + +### Auto-Generated Properties (Don't Set) +These are set during build: BuildNumber, BuildOS, BuildPath, BuildTime, BuildUser, EnlistmentId, ResourcePath, SourcePath, VcsRevision, VcsURL + +## Creating Web Parts + +Web parts are HTML views that can be added to LabKey pages. + +### Basic Web Part Structure + +**File**: `resources/views/myWebPart.html` +```html +
+

My Web Part

+

Content goes here

+
+ + +``` + +**Configuration**: `resources/views/myWebPart.webpart.xml` +```xml + + + My Web Part + Description of what this web part does + body + +``` + +### Important: Content Security Policy (CSP) + +LabKey enforces CSP, so **all inline scripts must include the nonce attribute**: +```html + +``` + +Without the nonce, your inline scripts will be blocked by the browser. + +### Template Variables + +LabKey automatically substitutes the following variables in HTML view files (use `<%=variableName%>` syntax): + +- **scriptNonce**: CSP nonce for inline scripts. **Required for all ` +``` + +### Adding Custom Button Actions + +```html +
+ +
+ + +``` + +## Documentation Resources + +For more information, see: +- Simple Modules Overview: https://www.labkey.org/Documentation/wiki-page.view?name=simpleModules +- File-Based Module Tutorial: https://www.labkey.org/Documentation/wiki-page.view?name=moduleqvr +- JavaScript API Documentation: https://labkey.github.io/labkey-api-js/ +- Module Directory Structures: https://www.labkey.org/Documentation/wiki-page.view?name=moduleDirectoryStructures +- Query Development: https://www.labkey.org/Documentation/wiki-page.view?name=addSQLQuery + +## Quick Start Checklist + +- [ ] Create module directory in `build/deploy/externalModules/` +- [ ] Add `module.properties` with required fields +- [ ] Create `resources/` directory structure +- [ ] Add at least one view or query +- [ ] Enable module in a test folder +- [ ] Test functionality in browser +- [ ] Document usage in README.md +- [ ] Back up module outside build directory diff --git a/query/src/org/labkey/query/controllers/QueryMcp.java b/query/src/org/labkey/query/controllers/QueryMcp.java index ab4bd9aebe9..26c6d367210 100644 --- a/query/src/org/labkey/query/controllers/QueryMcp.java +++ b/query/src/org/labkey/query/controllers/QueryMcp.java @@ -1,6 +1,7 @@ package org.labkey.query.controllers; -import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.spec.McpSchema.ReadResourceResult; +import io.modelcontextprotocol.spec.McpSchema.TextResourceContents; import org.apache.commons.io.IOUtils; import org.apache.commons.lang3.StringUtils; import org.json.JSONArray; @@ -44,12 +45,12 @@ public class QueryMcp implements McpService.McpImpl mimeType = "application/markdown", name = "LabKey SQL", description = "Provide documentation for LabKey SQL specific syntax") - public McpSchema.ReadResourceResult getLabKeySQLDocumentation() throws IOException + public ReadResourceResult getLabKeySQLDocumentation() throws IOException { incrementResourceRequestCount("LabKey SQL"); String markdown = IOUtils.resourceToString("org/labkey/query/controllers/LabKeySql.md", null, QueryController.class.getClassLoader()); - return new McpSchema.ReadResourceResult(List.of( - new McpSchema.TextResourceContents( + return new ReadResourceResult(List.of( + new TextResourceContents( "resource://org/labkey/query/controllers/LabKeySql.md", "application/markdown", markdown From 8fbc903f78316bbd3d8a1305f6e25c8885b43e3e Mon Sep 17 00:00:00 2001 From: Adam Rauch Date: Thu, 26 Mar 2026 09:03:58 -0700 Subject: [PATCH 19/19] Send same message for non-existent and non-authorized container --- core/src/org/labkey/core/CoreMcp.java | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/core/src/org/labkey/core/CoreMcp.java b/core/src/org/labkey/core/CoreMcp.java index 17ac2956dfd..e9630c58223 100644 --- a/core/src/org/labkey/core/CoreMcp.java +++ b/core/src/org/labkey/core/CoreMcp.java @@ -99,24 +99,22 @@ String setContainer(ToolContext context, @ToolParam(description = "Container pat { final String message; - if (containerPath == null) + if (StringUtils.isBlank(containerPath)) { - message = "Container path was null. Please provide a valid containerPath parameter. Try using the listContainers tool to see them."; + message = "Container path was missing. Please provide a valid containerPath parameter. Try using the listContainers tool to see them."; } else { Container container = ContainerManager.getForPath(containerPath); - if (container == null) + // Must exist and user must have read permission to set a container. Note: Send the same message in both + // cases to prevent information exposure. + if (container == null || !container.hasPermission(getUser(context), ReadPermission.class)) { 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(); }