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 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, Void> visitor = new SimpleSchemaTreeVisitor<>(false) @@ -179,7 +143,7 @@ public Map reduce(Map r1, Map