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 67854a58c61..5ac65ccfca3 100644
--- a/api/src/org/labkey/api/mcp/McpService.java
+++ b/api/src/org/labkey/api/mcp/McpService.java
@@ -5,11 +5,14 @@
import jakarta.servlet.http.HttpSession;
import org.jetbrains.annotations.NotNull;
import org.jspecify.annotations.NonNull;
-import org.labkey.api.module.McpProvider;
+import org.labkey.api.data.Container;
+import org.labkey.api.security.User;
import org.labkey.api.services.ServiceRegistry;
import org.labkey.api.util.HtmlString;
-import org.springaicommunity.mcp.provider.resource.SyncMcpResourceProvider;
+import org.labkey.api.writer.ContainerUser;
import org.springframework.ai.chat.client.ChatClient;
+import org.springframework.ai.chat.model.ToolContext;
+import org.springframework.ai.mcp.annotation.provider.resource.SyncMcpResourceProvider;
import org.springframework.ai.support.ToolCallbacks;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.ToolCallbackProvider;
@@ -19,23 +22,92 @@
import java.util.List;
import java.util.function.Supplier;
-/**
- * This service lets you expose functionality over the MCP protocol (only simple http for now). This allows
- * external chat sessions to pull information from LabKey Server. These methods are also made available
- * to chat session hosted by LabKey (see AbstractAgentAction).
- *
- * These calls are not security checked. Any tools registered here must check user permissions. Maybe that
- * will come as we get further along. Note that the LLM may make callbacks concerning containers other than the
- * current container. This is an area for investigation.
- */
+///
+/// ### MCP Development Guide
+/// `McpService` lets you expose functionality over the MCP protocol (only simple http for now). This allows external
+/// chat sessions to pull information from LabKey Server. Exposed functionality is also made available to chat sessions
+/// hosted by LabKey (see `AbstractAgentAction``).
+///
+/// ### Adding a new MCP class
+/// 1. Create a new class that implements `McpImpl` (see below) in the appropriate module
+/// 2. Register that class in your module `init()` method: `McpService.get().register(new MyMcp())`
+/// 3. Add tools and resources
+///
+/// ### Adding a new MCP tool
+/// 1. In your MCP class, create a new method that returns a String with the name you want to advertise
+/// 2. Annotate it with `@Tool` and provide a detailed description. This description is important since it instructs
+/// the LLM client (and the user) in the use of your tool.
+/// 3. Annotate it with `@RequiredPermission(Class<? extends Permission>)` or `@RequiredNoPermission`. **A
+/// permission annotation is required, otherwise your tool will not be registered.**
+/// 4. Add `ToolContext` as the first parameter to the method
+/// 5. Add additional required or optional parameters to the method signature, as needed. Note that "required" is the
+/// default. Again here, the parameter descriptions are very important. Provide examples.
+/// 6. Use the helper method `getContext(ToolContext)` to retrieve the current `Container` and `User`
+/// 7. Use the helper method `getUser(ToolContext)` in the rare cases where you need just a `User`
+/// 8. Perform additional permissions checking (beyond what the annotations offer), where appropriate
+/// 9. Filter all results to the current container, of course
+/// 10. For any error conditions, throw exceptions with detailed information. These will get translated into appropriate
+/// failure responses and the LLM client will attempt to correct the problem.
+/// 11. For success cases, return a String with a message or JSON content, for example, `JSONObject.toString()`. Spring
+/// has some limited ability to convert other objects into JSON strings, but we haven't experimented with that. See
+/// `DefaultToolCallResultConverter` and the ability to provide a custom result converter via the `@Tool` annotation.
+///
+/// At registration time, the framework will:
+/// - Ensure all tools are annotated for permissions
+/// - Ensure there aren't multiple tools with the same name
+///
+/// On every tool request, before invoking any tool code, the framework will:
+/// - Authenticate the user or provide a guest user
+/// - Ensure a container has been set if the tool requires a container
+/// - Verify that the user has whatever permissions are required based on the tool's annotation(s)
+/// - Verify that every required parameter is non-null and every string parameter is non-blank
+/// - Push the container and user into the ToolContext to give the tool access
+/// - Increment a metrics counter for that tool
+///
+/// CoreMcp and QueryMcp have examples of tool declarations.
+///
+/// ### Adding a new MCP resource
+/// 1. In your MCP class, create a new method that returns `ReadResourceResult` with an appropriate name
+/// 2. Annotate it with `@McpResource` and provide a uri, mimeType, name, and description
+/// 3. Call `incrementResourceRequestCount()` with a short but unique name to increment its metrics count
+/// 4. Read the resource, construct a `ReadResourceResult`, and return it.
+///
+/// No permissions checking is performed on resources. All resources are public.
+///
+/// CoreMcp and QueryMcp have examples of resource declarations.
+///
public interface McpService extends ToolCallbackProvider
{
- // marker interface for classes that we will "ingest" using Spring annotations
- interface McpImpl {}
+ // Interface for MCP classes that we will "ingest" using Spring annotations. Provides a few helper methods.
+ interface McpImpl
+ {
+ default ContainerUser getContext(ToolContext toolContext)
+ {
+ User user = (User)toolContext.getContext().get("user");
+ Container container = (Container)toolContext.getContext().get("container");
+ if (container == null)
+ throw new McpException("No container path is set. Ask the user which container/folder they want to use (you can call listContainers to show available options), then call setContainer before retrying.");
+ return ContainerUser.create(container, user);
+ }
+
+ default User getUser(ToolContext toolContext)
+ {
+ return (User)toolContext.getContext().get("user");
+ }
+
+ // Every MCP resource should call this on every invocation
+ default void incrementResourceRequestCount(String resource)
+ {
+ get().incrementResourceRequestCount(resource);
+ }
+ }
static @NotNull McpService get()
{
- return ServiceRegistry.get().getService(McpService.class);
+ McpService svc = ServiceRegistry.get().getService(McpService.class);
+ if (svc == null)
+ svc = NoopMcpService.get();
+ return svc;
}
static void setInstance(McpService service)
@@ -45,27 +117,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);
@@ -79,6 +142,10 @@ default ChatClient getChat(HttpSession session, String agentName, Supplier systemPromptSupplier, boolean createIfNotExists);
void close(HttpSession session, ChatClient chat);
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/api/src/org/labkey/api/module/McpProvider.java b/api/src/org/labkey/api/module/McpProvider.java
deleted file mode 100644
index 6ca9061e73f..00000000000
--- a/api/src/org/labkey/api/module/McpProvider.java
+++ /dev/null
@@ -1,24 +0,0 @@
-package org.labkey.api.module;
-
-import io.modelcontextprotocol.server.McpServerFeatures;
-import org.springframework.ai.tool.ToolCallback;
-
-import java.util.List;
-
-public interface McpProvider
-{
- default List getMcpTools()
- {
- return List.of();
- }
-
- default List getMcpPrompts()
- {
- return List.of();
- }
-
- default List getMcpResources()
- {
- return List.of();
- }
-}
diff --git a/api/src/org/labkey/api/security/RequiresNoPermission.java b/api/src/org/labkey/api/security/RequiresNoPermission.java
index 77d640cb657..c09a43a06b5 100644
--- a/api/src/org/labkey/api/security/RequiresNoPermission.java
+++ b/api/src/org/labkey/api/security/RequiresNoPermission.java
@@ -21,12 +21,12 @@
import java.lang.annotation.Target;
/**
- * Indicates that an action does not require any kind of authentication or permission to invoke. Use with extreme
- * caution. Typically, actions marked with this annotation will handle their own permission checks in their own code path.
- * User: adam
- * Date: Dec 22, 2009
-*/
-public @Retention(java.lang.annotation.RetentionPolicy.RUNTIME) @Target(ElementType.TYPE)
+ * Indicates that an action class or an MCP tool method does not require any kind of authentication or permission to
+ * invoke. Use with extreme caution. Typically, actions marked with this annotation will handle their own permission
+ * checks in their own code path. Note that this is the lowest priority permission annotation; all other @Requires*
+ * annotations effectively override this annotation.
+ */
+public @Retention(java.lang.annotation.RetentionPolicy.RUNTIME) @Target({ElementType.TYPE, ElementType.METHOD})
@interface RequiresNoPermission
{
}
\ 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 extends Permission> value();
diff --git a/core/src/org/labkey/core/CoreMcp.java b/core/src/org/labkey/core/CoreMcp.java
index 06482fe5627..2827c6e7827 100644
--- a/core/src/org/labkey/core/CoreMcp.java
+++ b/core/src/org/labkey/core/CoreMcp.java
@@ -1,17 +1,30 @@
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;
import org.labkey.api.data.Container;
-import org.labkey.api.mcp.McpContext;
+import org.labkey.api.data.ContainerManager;
import org.labkey.api.mcp.McpService;
+import org.labkey.api.security.RequiresNoPermission;
+import org.labkey.api.security.RequiresPermission;
import org.labkey.api.security.User;
+import org.labkey.api.security.permissions.ReadPermission;
import org.labkey.api.settings.AppProps;
import org.labkey.api.settings.LookAndFeelProperties;
import org.labkey.api.study.Study;
import org.labkey.api.study.StudyService;
import org.labkey.api.util.HtmlString;
+import org.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;
@@ -19,13 +32,14 @@
public class CoreMcp implements McpService.McpImpl
{
- // TODO ChatSessions are currently per session. The McpService should detect change of folder.
- @Tool(description = "Call this tool before answering any prompts! This tool provides useful context information about the current user (name, userid), webserver (name, url, description), and current folder (name, path, url, description).")
- String whereAmIWhoAmITalkingTo()
+ @Tool(description = "This tool provides useful context information about the current user (name, userid), webserver " +
+ "(name, url, description), and current container/folder (name, path, url, description) once the container is set via setContainer.")
+ @RequiresPermission(ReadPermission.class)
+ String whereAmIWhoAmITalkingTo(ToolContext context)
{
- McpContext context = McpContext.get();
- User user = context.getUser();
- Container folder = context.getContainer();
+ var cu = getContext(context);
+ User user = cu.getUser();
+ Container folder = cu.getContainer();
AppProps appProps = AppProps.getInstance();
Study study = null != StudyService.get() ? Objects.requireNonNull(StudyService.get()).getStudy(folder) : null;
LookAndFeelProperties laf = LookAndFeelProperties.getInstance(folder);
@@ -63,4 +77,66 @@ String whereAmIWhoAmITalkingTo()
"site", siteObj
)).toString();
}
+
+ @Tool(description = "List the hierarchical path for every container in the server where the user has read permissions.")
+ @RequiresNoPermission
+ String listContainers(ToolContext toolContext)
+ {
+ return ContainerManager.getAllChildren(ContainerManager.getRoot(), getUser(toolContext), ReadPermission.class)
+ .stream()
+ .map(container -> StringUtils.stripStart(container.getPath(), "/")) // No leading slash since typing that brings up custom shortcuts
+ .collect(LabKeyCollectors.toJSONArray())
+ .toString();
+ }
+
+ @Tool(description = "Every tool in this MCP requires a container path, e.g. MyProject/MyFolder. A container is also called a folder or project. " +
+ "Please prompt the user for a container path and use this tool to save the path for this MCP session. The user can also change the container " +
+ "during the session using this tool. The user must have read permissions in the container, in other words, the path must be on the list that " +
+ "the listContainers tool returns. Don't suggest a leading slash on the path because typing a slash in some LLM clients triggers custom shortcuts.")
+ @RequiresNoPermission // Because we don't have a container yet, but tool will check for read permission before setting the container
+ String setContainer(ToolContext context, @ToolParam(description = "Container path, e.g. MyProject/MyFolder") String containerPath)
+ {
+ final String message;
+
+ if (StringUtils.isBlank(containerPath))
+ {
+ 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);
+
+ // 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 the valid options.";
+ }
+ else
+ {
+ McpService.get().saveSessionContainer(context, container);
+ message = "Container has been set to " + container.getPath();
+ }
+ }
+
+ 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/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/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/core/src/org/labkey/core/mcp/McpServiceImpl.java b/core/src/org/labkey/core/mcp/McpServiceImpl.java
deleted file mode 100644
index 0937f308a20..00000000000
--- a/core/src/org/labkey/core/mcp/McpServiceImpl.java
+++ /dev/null
@@ -1,916 +0,0 @@
-package org.labkey.core.mcp;
-
-import com.fasterxml.jackson.databind.ObjectMapper;
-import com.google.genai.Client;
-import com.google.genai.types.ClientOptions;
-import io.modelcontextprotocol.json.McpJsonMapper;
-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.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;
-import org.labkey.api.collections.CopyOnWriteHashMap;
-import org.labkey.api.markdown.MarkdownService;
-import org.labkey.api.mcp.McpContext;
-import org.labkey.api.mcp.McpService;
-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.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;
-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.prompt.ChatOptions;
-import org.springframework.ai.chat.prompt.Prompt;
-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.mcp.McpToolUtils;
-import org.springframework.ai.chat.model.ToolContext;
-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;
-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;
-import java.util.ConcurrentModificationException;
-import java.util.List;
-import java.util.NoSuchElementException;
-import java.util.Objects;
-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()
- {
- 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()
- ).toList();
- }
-
- private class _McpServlet extends HttpServlet // wraps HttpServletSseServerTransportProvider
- {
- HttpServletStreamableServerTransportProvider transportProvider;
- McpSyncServer mcpServer = null;
-
- _McpServlet(ObjectMapper objectMapper, String messageEndpoint, String sseEndpoint)
- {
- transportProvider = HttpServletStreamableServerTransportProvider.builder()
- .jsonMapper(McpJsonMapper.getDefault())
- .mcpEndpoint(messageEndpoint)
- .build();
- }
-
- void startMcpServer()
- {
- List tools = Arrays.stream(getToolCallbacks()).map(McpToolUtils::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());
- }
-
- @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;
- }
-
- 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);
- }
-
- 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(List documents)
- {
- delegate.add(documents);
- }
-
- @Override
- public void delete(Filter.Expression filterExpression)
- {
- delegate.delete(filterExpression);
- }
-
- @Override
- public void delete(List idList)
- {
- delegate.delete(idList);
- }
-
- @Override
- public 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 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 │
- └───────────────────────┴────────────────────────────────┴────────────────────────────────────┘
-*/
- public GoogleGenAiChatOptions getChatOptions()
- {
- GoogleGenAiChatOptions chatOptions = GoogleGenAiChatOptions.builder()
- .model(getModel())
- .toolCallbacks(getToolCallbacks())
- .build();
- return chatOptions;
- }
-
- 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;
- }
-
- public AnthropicChatOptions getChatOptions()
- {
- AnthropicChatOptions chatOptions = AnthropicChatOptions.builder()
- .model(getModel())
- .toolCallbacks(getToolCallbacks())
- .build();
- return chatOptions;
- }
-
- 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;
- }
-
- @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);
- }
- }
-}
diff --git a/query/src/org/labkey/query/controllers/QueryMcp.java b/query/src/org/labkey/query/controllers/QueryMcp.java
index 99bb2df99c0..05f804a8ae4 100644
--- a/query/src/org/labkey/query/controllers/QueryMcp.java
+++ b/query/src/org/labkey/query/controllers/QueryMcp.java
@@ -1,18 +1,16 @@
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;
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;
-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,9 +20,13 @@
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.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;
-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;
@@ -36,8 +38,6 @@
import static org.apache.commons.lang3.StringUtils.isNotBlank;
-/* TODO: integrate ToolContext support */
-
public class QueryMcp implements McpService.McpImpl
{
@McpResource(
@@ -45,102 +45,66 @@ 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(
- "resource://org/labkey/query/controllers/LabKeySql.md",
- "application/markdown",
- markdown)
+ return new ReadResourceResult(List.of(
+ new 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.")
+ @RequiresPermission(ReadPermission.class)
+ String listColumns(ToolContext toolContext, @ToolParam(description = "Fully qualified table name as it would appear in SQL e.g. \"schema\".\"table\"") String tableName)
{
- var json = _listColumnsForTable(fullQuotedTableName);
- // can I just return a JSONObject
+ var json = _listColumns(tableName, toolContext);
return json.toString();
}
@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)
+ @RequiresPermission(ReadPermission.class)
+ String listTables(ToolContext toolContext, @ToolParam(description = "Fully qualified schema name as it would appear in SQL e.g. \"schema\"") String schemaName)
{
- var json = _listTablesForSchema(quotedSchemaName);
- // can I just return a JSONObject
+ var json = _listTables(schemaName, getContext(toolContext));
return json.toString();
}
@Tool(description = "Provide list of database schemas")
- String listSchemas()
+ @RequiresPermission(ReadPermission.class)
+ 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())
{
- 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();
}
@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)
+ @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 tableName)
{
- var json = _listTablesForSchema(fullQuotedTableName);
+ 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;
- }
-
-
- @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());
- }
+ 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