From eac0170f89a357d89c4eda84596a19205290e7a8 Mon Sep 17 00:00:00 2001 From: labkey-tchad Date: Wed, 11 Feb 2026 10:33:25 -0800 Subject: [PATCH 1/4] Minor improvements to AbstractAgentAction --- .../labkey/api/mcp/AbstractAgentAction.java | 25 +++++++++++-------- api/src/org/labkey/api/mcp/McpContext.java | 5 ++-- .../org/labkey/devtools/TestController.java | 3 ++- .../src/org/labkey/devtools/view/chat.jsp | 18 ++++++------- .../query/controllers/QueryController.java | 7 ++---- 5 files changed, 29 insertions(+), 29 deletions(-) diff --git a/api/src/org/labkey/api/mcp/AbstractAgentAction.java b/api/src/org/labkey/api/mcp/AbstractAgentAction.java index 91dddcc7eaf..f5083a4b4c4 100644 --- a/api/src/org/labkey/api/mcp/AbstractAgentAction.java +++ b/api/src/org/labkey/api/mcp/AbstractAgentAction.java @@ -2,9 +2,11 @@ import com.google.genai.errors.ClientException; import com.google.genai.errors.ServerException; +import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpSession; import org.json.JSONObject; import org.labkey.api.action.ReadOnlyApiAction; +import org.labkey.api.security.CSRF; import org.labkey.api.util.HtmlString; import org.springframework.ai.chat.client.ChatClient; import org.springframework.validation.BindException; @@ -14,11 +16,11 @@ import static org.apache.commons.lang3.StringUtils.isNotBlank; /** - * "agent" it is too strong a word, but if you want to create a tools specific chat endpoint then - * start here. - * First implement getServicePrompt() to tell your "agent its mission. You can also listen in on the - * conversation to help you user get the right results. + * If you want to create a tools specific chat endpoint then start here. + * First implement getServicePrompt() to tell your "agent" its mission. You can also listen in on the + * conversation to help the user get the right results. */ +@CSRF(CSRF.Method.ALL) public abstract class AbstractAgentAction extends ReadOnlyApiAction { protected abstract String getAgentName(); @@ -27,15 +29,17 @@ public abstract class AbstractAgentAction extends ReadOnly protected ChatClient getChat() { - HttpSession session = getViewContext().getRequest().getSession(true); - ChatClient chatSession = McpService.get().getChat(session, getAgentName(), this::getServicePrompt); - return chatSession; + HttpServletRequest request = getViewContext().getRequest(); + if (request == null) + throw new IllegalStateException("No request"); + HttpSession session = request.getSession(true); + return McpService.get().getChat(session, getAgentName(), this::getServicePrompt); } @Override - public Object execute(PromptForm form, BindException errors) throws Exception + public Object execute(F form, BindException errors) throws Exception { - try (var mcpPush = McpContext.withContext(getViewContext())) + try (var _ = McpContext.withContext(getViewContext())) { ChatClient chatSession = getChat(); if (null == chatSession) @@ -73,11 +77,10 @@ else if (isNotBlank(response.text())) } catch (ClientException ex) { - var ret = new JSONObject(Map.of( + return new JSONObject(Map.of( "text", ex.getMessage(), "user", getViewContext().getUser().getName(), "success", Boolean.FALSE)); - return ret; } } } diff --git a/api/src/org/labkey/api/mcp/McpContext.java b/api/src/org/labkey/api/mcp/McpContext.java index d1b0975f46f..8e10e2a2c2d 100644 --- a/api/src/org/labkey/api/mcp/McpContext.java +++ b/api/src/org/labkey/api/mcp/McpContext.java @@ -7,6 +7,7 @@ import org.labkey.api.view.UnauthorizedException; import org.labkey.api.writer.ContainerUser; import org.springframework.ai.chat.model.ToolContext; + import java.util.Map; /** @@ -57,7 +58,7 @@ public User getUser() // researched if there are other ways to pass context around to Tools registerd by McpService // - private static final ThreadLocal contexts = new ThreadLocal(); + private static final ThreadLocal contexts = new ThreadLocal<>(); public static @NotNull McpContext get() { @@ -67,7 +68,7 @@ public User getUser() return ret; } - public static AutoCloseable withContext(ContainerUser ctx) + public static AutoCloseable withContext(ContainerUser ctx) { return with(new McpContext(ctx)); } diff --git a/devtools/src/org/labkey/devtools/TestController.java b/devtools/src/org/labkey/devtools/TestController.java index 5c8cf796240..fac7e74224f 100644 --- a/devtools/src/org/labkey/devtools/TestController.java +++ b/devtools/src/org/labkey/devtools/TestController.java @@ -32,6 +32,7 @@ import org.labkey.api.data.ContainerManager; import org.labkey.api.mcp.AbstractAgentAction; import org.labkey.api.mcp.McpService; +import org.labkey.api.mcp.PromptForm; import org.labkey.api.security.CSRF; import org.labkey.api.security.MethodsAllowed; import org.labkey.api.security.RequiresLogin; @@ -1308,7 +1309,7 @@ public void addNavTrail(NavTree root) @RequiresLogin - public static class ChatEndpointAction extends AbstractAgentAction + public static class ChatEndpointAction extends AbstractAgentAction { @Override protected String getAgentName() diff --git a/devtools/src/org/labkey/devtools/view/chat.jsp b/devtools/src/org/labkey/devtools/view/chat.jsp index 50772d33c8c..765d724583f 100644 --- a/devtools/src/org/labkey/devtools/view/chat.jsp +++ b/devtools/src/org/labkey/devtools/view/chat.jsp @@ -1,5 +1,3 @@ -<%@ page import="org.labkey.api.util.DOM" %> -<%@ page import="java.util.stream.Stream" %> <%@ page import="static org.labkey.api.util.DOM.*" %> <%@ page import="static org.labkey.api.util.DOM.Attribute.*" %> <%@ page extends="org.labkey.api.jsp.JspBase" %> @@ -75,9 +73,8 @@ function startChatting(chatEndpoint) scrollToBottom(); } - function handleChatResponse(event) + function handleChatResponse(req) { - const req = event.target; if (req.readyState === 4) { if (req.status >= 200 && req.status < 300) { @@ -95,12 +92,13 @@ function startChatting(chatEndpoint) function sendMessage(prompt) { - var url = new URL(chatEndpoint); - url.searchParams.set('prompt', prompt); - var req = new XMLHttpRequest(); - req.open('GET', url.toString(), true); - req.onreadystatechange = handleChatResponse; - req.send(); + LABKEY.Ajax.request({ + url: chatEndpoint, + method: 'POST', + params: {prompt: prompt}, + success: handleChatResponse, + failure: handleChatResponse + }); const loadingSpinner = document.querySelector('.loading-spinner'); loadingSpinner.classList.remove('loading-spinner--hidden'); } diff --git a/query/src/org/labkey/query/controllers/QueryController.java b/query/src/org/labkey/query/controllers/QueryController.java index 0d14f2a7b27..bef3580e845 100644 --- a/query/src/org/labkey/query/controllers/QueryController.java +++ b/query/src/org/labkey/query/controllers/QueryController.java @@ -19,7 +19,6 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; -import com.google.genai.Chat; import com.google.genai.errors.ClientException; import com.google.genai.errors.ServerException; import jakarta.servlet.ServletException; @@ -28,7 +27,6 @@ import jakarta.servlet.http.HttpSession; import org.antlr.runtime.tree.Tree; import org.apache.commons.beanutils.ConversionException; -import org.apache.commons.beanutils.ConvertUtils; import org.apache.commons.collections4.MultiValuedMap; import org.apache.commons.collections4.multimap.ArrayListValuedHashMap; import org.apache.commons.collections4.multimap.HashSetValuedHashMap; @@ -8878,7 +8876,7 @@ public Object execute(SqlPromptForm form, BindException errors) throws Exception // save form here for context in getServicePrompt() _form = form; - try (var mcpPush = McpContext.withContext(getViewContext())) + try (var _ = McpContext.withContext(getViewContext())) { // TODO when/how to do we reset or isolate different chat sessions, e.g. if two SQL windows are open concurrently? ChatClient chatSession = getChat(); @@ -8941,11 +8939,10 @@ public Object execute(SqlPromptForm form, BindException errors) throws Exception } catch (ClientException ex) { - var ret = new JSONObject(Map.of( + return new JSONObject(Map.of( "text", ex.getMessage(), "user", getViewContext().getUser().getName(), "success", Boolean.FALSE)); - return ret; } } } From f0f791bf746bd1df74a8ee904e9e66c73f25d5f0 Mon Sep 17 00:00:00 2001 From: labkey-tchad Date: Fri, 13 Feb 2026 09:52:35 -0800 Subject: [PATCH 2/4] Require POST for agent actions --- .../labkey/api/mcp/AbstractAgentAction.java | 8 ++-- .../src/org/labkey/query/view/sourceQuery.jsp | 38 ++++++++++--------- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/api/src/org/labkey/api/mcp/AbstractAgentAction.java b/api/src/org/labkey/api/mcp/AbstractAgentAction.java index 71e9e7de980..08c169256f1 100644 --- a/api/src/org/labkey/api/mcp/AbstractAgentAction.java +++ b/api/src/org/labkey/api/mcp/AbstractAgentAction.java @@ -6,8 +6,7 @@ import jakarta.servlet.http.HttpSession; import org.apache.commons.lang3.StringUtils; import org.json.JSONObject; -import org.labkey.api.action.ReadOnlyApiAction; -import org.labkey.api.security.CSRF; +import org.labkey.api.action.MutatingApiAction; import org.labkey.api.util.HtmlString; import org.springframework.ai.chat.client.ChatClient; import org.springframework.validation.BindException; @@ -17,12 +16,11 @@ import static org.apache.commons.lang3.StringUtils.isNotBlank; /** - * If you want to create a tools specific chat endpoint then start here. + * If you want to create a tools-specific chat endpoint, then start here. * First implement getServicePrompt() to tell your "agent" its mission. You can also listen in on the * conversation to help the user get the right results. */ -@CSRF(CSRF.Method.ALL) -public abstract class AbstractAgentAction extends ReadOnlyApiAction +public abstract class AbstractAgentAction extends MutatingApiAction { protected abstract String getAgentName(); diff --git a/query/src/org/labkey/query/view/sourceQuery.jsp b/query/src/org/labkey/query/view/sourceQuery.jsp index c12fa995bcd..05dd78c5ed8 100644 --- a/query/src/org/labkey/query/view/sourceQuery.jsp +++ b/query/src/org/labkey/query/view/sourceQuery.jsp @@ -15,15 +15,15 @@ * limitations under the License. */ %> +<%@ page import="org.labkey.api.mcp.McpService"%> <%@ page import="org.labkey.api.query.QueryAction"%> -<%@ page import="org.labkey.api.query.QueryDefinition"%> +<%@ page import="org.labkey.api.query.QueryDefinition" %> <%@ page import="org.labkey.api.util.HelpTopic" %> +<%@ page import="org.labkey.api.util.JavaScriptFragment" %> <%@ page import="org.labkey.api.view.ActionURL" %> <%@ page import="org.labkey.api.view.HttpView" %> <%@ page import="org.labkey.api.view.template.ClientDependencies" %> <%@ page import="org.labkey.query.controllers.QueryController" %> -<%@ page import="org.labkey.api.mcp.McpService" %> -<%@ page import="org.labkey.api.util.JavaScriptFragment" %> <%@ taglib prefix="labkey" uri="http://www.labkey.org/taglib" %> <%@ page extends="org.labkey.api.jsp.JspBase" %> <%! @@ -363,11 +363,14 @@ if (initPrompt) { var url = new URL('./query-queryagent.api', window.location.href); - url.searchParams.set('schemaName', schemaName || ''); - url.searchParams.set('prompt', initPrompt); - var req = new XMLHttpRequest(); - req.open('GET', url.toString(), true); - req.send(); + LABKEY.Ajax.request({ + url: url, + method: 'POST', + params: { + prompt: initPrompt, + schemaName: schemaName || '' + } + }); } } @@ -392,13 +395,13 @@ // TODO waiting/thinking UI // Build URL with same base as current document, endpoint /query-queryagent.api and prompt parameter var url = new URL('./query-queryagent.api', window.location.href); - url.searchParams.set('prompt', prompt); - var req = new XMLHttpRequest(); - req.open('GET', url.toString(), true); - req.onreadystatechange = function () { - if (req.readyState === 4) { - if (req.status >= 200 && req.status < 300) { - var responseJson = JSON.parse(req.responseText); + LABKEY.Ajax.request({ + url: url, + method: 'POST', + params: {prompt: prompt}, + callback: function (config, success, xhr) { + if (success) { + var responseJson = JSON.parse(xhr.responseText); var responseText = responseJson['text']; var responseHtml = responseJson['html']; var responseSql = responseJson['sql']; @@ -413,11 +416,10 @@ appendTextResponse(responseText); } } else { - appendTextResponse('Request failed: ' + req.status + ' ' + (req.statusText || '')); + appendTextResponse('Request failed: ' + xhr.status + ' ' + (xhr.statusText || '')); } } - }; - req.send(); + }); ev.preventDefault(); ev.stopPropagation(); return false; From bcd26018b76c17e900e299131355d6f0635cdc0c Mon Sep 17 00:00:00 2001 From: labkey-tchad Date: Fri, 13 Feb 2026 10:55:34 -0800 Subject: [PATCH 3/4] minimize change --- query/src/org/labkey/query/view/sourceQuery.jsp | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/query/src/org/labkey/query/view/sourceQuery.jsp b/query/src/org/labkey/query/view/sourceQuery.jsp index 05dd78c5ed8..3c31a8fbf4e 100644 --- a/query/src/org/labkey/query/view/sourceQuery.jsp +++ b/query/src/org/labkey/query/view/sourceQuery.jsp @@ -24,6 +24,9 @@ <%@ page import="org.labkey.api.view.HttpView" %> <%@ page import="org.labkey.api.view.template.ClientDependencies" %> <%@ page import="org.labkey.query.controllers.QueryController" %> +<%@ page import="java.lang.Exception" %> +<%@ page import="java.lang.Override" %> +<%@ page import="java.lang.String" %> <%@ taglib prefix="labkey" uri="http://www.labkey.org/taglib" %> <%@ page extends="org.labkey.api.jsp.JspBase" %> <%! @@ -399,9 +402,9 @@ url: url, method: 'POST', params: {prompt: prompt}, - callback: function (config, success, xhr) { + callback: function (config, success, req) { if (success) { - var responseJson = JSON.parse(xhr.responseText); + var responseJson = JSON.parse(req.responseText); var responseText = responseJson['text']; var responseHtml = responseJson['html']; var responseSql = responseJson['sql']; @@ -416,7 +419,7 @@ appendTextResponse(responseText); } } else { - appendTextResponse('Request failed: ' + xhr.status + ' ' + (xhr.statusText || '')); + appendTextResponse('Request failed: ' + req.status + ' ' + (req.statusText || '')); } } }); From d7e4ce9fa2e55ecf3b15290b13c2566e1f3bcc74 Mon Sep 17 00:00:00 2001 From: labkey-tchad Date: Fri, 13 Feb 2026 12:46:33 -0800 Subject: [PATCH 4/4] Not actually mutating, just require POST --- api/src/org/labkey/api/mcp/AbstractAgentAction.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/api/src/org/labkey/api/mcp/AbstractAgentAction.java b/api/src/org/labkey/api/mcp/AbstractAgentAction.java index 08c169256f1..a67a00fa04e 100644 --- a/api/src/org/labkey/api/mcp/AbstractAgentAction.java +++ b/api/src/org/labkey/api/mcp/AbstractAgentAction.java @@ -6,8 +6,10 @@ import jakarta.servlet.http.HttpSession; import org.apache.commons.lang3.StringUtils; import org.json.JSONObject; -import org.labkey.api.action.MutatingApiAction; +import org.labkey.api.action.ReadOnlyApiAction; +import org.labkey.api.security.MethodsAllowed; import org.labkey.api.util.HtmlString; +import org.labkey.api.util.HttpUtil.Method; import org.springframework.ai.chat.client.ChatClient; import org.springframework.validation.BindException; @@ -20,7 +22,8 @@ * First implement getServicePrompt() to tell your "agent" its mission. You can also listen in on the * conversation to help the user get the right results. */ -public abstract class AbstractAgentAction extends MutatingApiAction +@MethodsAllowed({Method.POST}) +public abstract class AbstractAgentAction extends ReadOnlyApiAction { protected abstract String getAgentName();