From 00664200ec25a79e2415aa0366694540fb5679a8 Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Fri, 8 May 2026 13:06:32 -0700 Subject: [PATCH 01/29] LABKEY.mcpReady --- api/src/org/labkey/api/mcp/McpService.java | 10 +++++++--- api/src/org/labkey/api/util/PageFlowUtil.java | 6 +++++- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/api/src/org/labkey/api/mcp/McpService.java b/api/src/org/labkey/api/mcp/McpService.java index 5021a86ec90..f364eac9bdc 100644 --- a/api/src/org/labkey/api/mcp/McpService.java +++ b/api/src/org/labkey/api/mcp/McpService.java @@ -1,6 +1,5 @@ package org.labkey.api.mcp; - import io.modelcontextprotocol.server.McpServerFeatures; import jakarta.servlet.http.HttpSession; import org.apache.logging.log4j.Logger; @@ -88,7 +87,7 @@ interface McpImpl { default ContainerUser getContext(ToolContext toolContext) { - User user = (User)toolContext.getContext().get("user"); + User user = getUser(toolContext); 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."); @@ -103,7 +102,7 @@ default User getUser(ToolContext toolContext) // Every MCP resource should call this on every invocation default void incrementResourceRequestCount(String resource) { - if (!OptionalFeatureService.get().isFeatureEnabled(ENABLE_MCP_SERVER_FLAG)) + if (!get().isEnabled()) throw new RuntimeException("The MCP server is not enabled for external requests. Consider toggling the experimental feature flag."); get().incrementResourceRequestCount(resource); @@ -123,6 +122,11 @@ static void setInstance(McpService service) ServiceRegistry.get().registerService(McpService.class, service); } + default boolean isEnabled() + { + return OptionalFeatureService.get().isFeatureEnabled(ENABLE_MCP_SERVER_FLAG); + } + boolean isReady(); // Register MCPs in Module.startup() diff --git a/api/src/org/labkey/api/util/PageFlowUtil.java b/api/src/org/labkey/api/util/PageFlowUtil.java index d8794ff538d..1ea8883b00a 100644 --- a/api/src/org/labkey/api/util/PageFlowUtil.java +++ b/api/src/org/labkey/api/util/PageFlowUtil.java @@ -50,6 +50,7 @@ import org.labkey.api.data.ContainerManager; import org.labkey.api.data.DataRegion; import org.labkey.api.data.Project; +import org.labkey.api.mcp.McpService; import org.labkey.api.miniprofiler.MiniProfiler; import org.labkey.api.miniprofiler.RequestInfo; import org.labkey.api.module.Module; @@ -2253,9 +2254,12 @@ public static JSONObject jsInitObject(ContainerUser context, @Nullable PageConfi json.put("helpLinkPrefix", HelpTopic.getHelpLinkPrefix()); json.put("jdkJavaDocLinkPrefix", HelpTopic.getJdkJavaDocLinkPrefix()); - if (AppProps.getInstance().isOptionalFeatureEnabled(NotificationMenuView.EXPERIMENTAL_NOTIFICATION_MENU)) + if (AppProps.getInstance().isOptionalFeatureEnabled(NotificationMenuView.EXPERIMENTAL_NOTIFICATION_MENU) && user != null) json.put("notifications", Map.of("unreadCount", NotificationService.get().getUnreadNotificationCountByUser(null, user.getUserId()))); + if (McpService.get().isEnabled()) + json.put("mcpReady", McpService.get().isReady()); + JSONObject defaultHeaders = new JSONObject(); defaultHeaders.put("X-ONUNAUTHORIZED", "UNAUTHORIZED"); From 4680b7694fa3b446b830fcaceeada2afb663e2b6 Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Fri, 8 May 2026 13:06:49 -0700 Subject: [PATCH 02/29] ai_stars_icon.svg --- core/webapp/_images/ai_stars_icon.svg | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 core/webapp/_images/ai_stars_icon.svg diff --git a/core/webapp/_images/ai_stars_icon.svg b/core/webapp/_images/ai_stars_icon.svg new file mode 100644 index 00000000000..aa5f3ec15ec --- /dev/null +++ b/core/webapp/_images/ai_stars_icon.svg @@ -0,0 +1,4 @@ + + + + From e5dd452b0080351738274aa104932e1123f1ed22 Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Mon, 11 May 2026 09:23:08 -0700 Subject: [PATCH 03/29] Introduce query-expressionAssistantAgent.api --- .../labkey/api/mcp/AbstractAgentAction.java | 39 +++-- .../query/controllers/QueryController.java | 134 ++++++++++++++---- .../labkey/query/controllers/QueryMcp.java | 2 +- .../prompts/ExpressionAssistant.md | 84 +++++++++++ .../controllers/{ => prompts}/LabKeySql.md | 0 5 files changed, 217 insertions(+), 42 deletions(-) create mode 100644 query/src/org/labkey/query/controllers/prompts/ExpressionAssistant.md rename query/src/org/labkey/query/controllers/{ => prompts}/LabKeySql.md (100%) diff --git a/api/src/org/labkey/api/mcp/AbstractAgentAction.java b/api/src/org/labkey/api/mcp/AbstractAgentAction.java index 159695afe78..0e1821d27cc 100644 --- a/api/src/org/labkey/api/mcp/AbstractAgentAction.java +++ b/api/src/org/labkey/api/mcp/AbstractAgentAction.java @@ -4,6 +4,8 @@ import com.google.genai.errors.ServerException; import jakarta.servlet.http.HttpSession; import org.apache.commons.lang3.StringUtils; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import org.json.JSONObject; import org.labkey.api.action.ReadOnlyApiAction; import org.labkey.api.util.HtmlString; @@ -52,18 +54,13 @@ protected String handleEscape(String prompt) @Override public Object execute(PromptForm form, BindException errors) throws Exception { - try (var mcpPush = McpContext.withContext(getViewContext())) + try (var _ = McpContext.withContext(getViewContext())) { String prompt = form.getPrompt(); - String escapeResponse = handleEscape(prompt); + JSONObject escapeResponse = escapeResponse(prompt); if (null != escapeResponse) - { - return new JSONObject(Map.of( - "contentType", "text/plain", - "response", escapeResponse, - "success", Boolean.TRUE)); - } + return escapeResponse; // call getChat() after handleEscape() ChatClient chatSession = getChat(true); @@ -101,11 +98,27 @@ else if (isNotBlank(response.text())) } catch (ClientException ex) { - var ret = new JSONObject(Map.of( - "text", ex.getMessage(), - "user", getViewContext().getUser().getName(), - "success", Boolean.FALSE)); - return ret; + return errorResponse(ex); } } + + protected @NotNull JSONObject errorResponse(Exception ex) + { + return new JSONObject(Map.of( + "text", ex.getMessage(), + "user", getViewContext().getUser().getName(), + "success", Boolean.FALSE)); + } + + protected @Nullable JSONObject escapeResponse(String prompt) + { + String escapeResponse = handleEscape(prompt); + if (null == escapeResponse) + return null; + + return new JSONObject(Map.of( + "contentType", "text/plain", + "text", escapeResponse, + "success", Boolean.TRUE)); + } } diff --git a/query/src/org/labkey/query/controllers/QueryController.java b/query/src/org/labkey/query/controllers/QueryController.java index a9c33f4b867..7928906f7e1 100644 --- a/query/src/org/labkey/query/controllers/QueryController.java +++ b/query/src/org/labkey/query/controllers/QueryController.java @@ -8500,6 +8500,107 @@ public Object execute(QueryImportTemplateForm form, BindException errors) throws } } + private enum PromptResource + { + ExpressionAssistant, + LabKeySql + } + + private static String getPromptResource(PromptResource resource) + { + try + { + return IOUtils.resourceToString("org/labkey/query/controllers/prompts/" + resource.name() + ".md", null, QueryController.class.getClassLoader()); + } + catch (IOException x) + { + throw new ConfigurationException("error loading resource", x); + } + } + + public static class ExpressionAssistantAgentForm extends PromptForm + { + } + + @RequiresPermission(ReadPermission.class) + @RequiresLogin + public static class ExpressionAssistantAgentAction extends AbstractAgentAction + { + ExpressionAssistantAgentForm _form; + + @Override + public void validateForm(ExpressionAssistantAgentForm form, Errors errors) + { + _form = form; + } + + @Override + protected String getAgentName() + { + return ExpressionAssistantAgentAction.class.getName(); + } + + @Override + protected String getServicePrompt() + { + return "The following documentation describes you, the Calculated Column Expression Assistant:\n\n" + + getPromptResource(PromptResource.ExpressionAssistant) + + "\n\nThe following documentation describes LabKey SQL:\n\n" + + getPromptResource(PromptResource.LabKeySql); + } + + @Override + public Object execute(ExpressionAssistantAgentForm form, BindException errors) throws Exception + { + // save form here for context in getServicePrompt() + _form = form; + + try (var _ = McpContext.withContext(getViewContext())) + { + String prompt = form.getPrompt(); + + var escapedResponse = escapeResponse(prompt); + if (null != escapedResponse) + return escapedResponse; + + if (isBlank(prompt)) + { + return new JSONObject(Map.of( + "contentType", "text/plain", + "text", "🀷", + "success", Boolean.TRUE)); + } + + ChatClient chatSession = getChat(true); + List responses; + SqlResponse sqlResponse; + + try + { + responses = McpService.get().sendMessageEx(chatSession, prompt); + sqlResponse = extractSql(responses); + } + catch (ServerException x) + { + return new JSONObject(Map.of( + "error", x.getMessage(), + "text", "ERROR: " + x.getMessage(), + "success", Boolean.FALSE)); + } + + var ret = new JSONObject(Map.of("success", Boolean.TRUE)); + if (null != sqlResponse.sql()) + ret.put("sql", sqlResponse.sql()); + if (null != sqlResponse.html()) + ret.put("html", sqlResponse.html()); + return ret; + } + catch (ClientException x) + { + return errorResponse(x); + } + } + } public static class TestCase extends AbstractActionPermissionTest { @@ -8777,7 +8878,6 @@ private JSONArray getTestRows(String val) } } - public static class SqlPromptForm extends PromptForm { public String schemaName; @@ -8793,7 +8893,6 @@ public void setSchemaName(String schemaName) } } - @RequiresPermission(ReadPermission.class) @RequiresLogin public static class QueryAgentAction extends AbstractAgentAction @@ -8816,7 +8915,7 @@ protected String getAgentName() protected String getServicePrompt() { StringBuilder serviceMessage = new StringBuilder(); - serviceMessage.append("Your job is to generate SQL statements. Here is some reference material formatted as markdown:\n").append(getSQLHelp()).append("\n\n"); + serviceMessage.append("Your job is to generate SQL statements. Here is some reference material formatted as markdown:\n").append(getPromptResource(PromptResource.LabKeySql)).append("\n\n"); serviceMessage.append("NOTE: Prefer using lookup syntax rather than JOIN where possible.\n"); serviceMessage.append("NOTE: When helping generate SQL please don't use names of tables and columns from documentation examples. Always refer to the available tools for retrieving database metadata.\n"); @@ -8833,36 +8932,19 @@ protected String getServicePrompt() return serviceMessage.toString(); } - String getSQLHelp() - { - try - { - return IOUtils.resourceToString("org/labkey/query/controllers/LabKeySql.md", null, QueryController.class.getClassLoader()); - } - catch (IOException x) - { - throw new ConfigurationException("error loading resource", x); - } - } - @Override 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())) { String prompt = form.getPrompt(); - String escapeResponse = handleEscape(prompt); + JSONObject escapeResponse = escapeResponse(prompt); if (null != escapeResponse) - { - return new JSONObject(Map.of( - "contentType", "text/plain", - "text", escapeResponse, - "success", Boolean.TRUE)); - } + return escapeResponse; // TODO when/how to do we reset or isolate different chat sessions, e.g. if two SQL windows are open concurrently? ChatClient chatSession = getChat(true); @@ -8933,11 +9015,7 @@ public Object execute(SqlPromptForm form, BindException errors) throws Exception } catch (ClientException ex) { - var ret = new JSONObject(Map.of( - "text", ex.getMessage(), - "user", getViewContext().getUser().getName(), - "success", Boolean.FALSE)); - return ret; + return errorResponse(ex); } } } diff --git a/query/src/org/labkey/query/controllers/QueryMcp.java b/query/src/org/labkey/query/controllers/QueryMcp.java index 9b11aed43f8..c383e63666c 100644 --- a/query/src/org/labkey/query/controllers/QueryMcp.java +++ b/query/src/org/labkey/query/controllers/QueryMcp.java @@ -51,7 +51,7 @@ public class QueryMcp implements McpService.McpImpl public ReadResourceResult getLabKeySQLDocumentation() throws IOException { incrementResourceRequestCount("LabKey SQL"); - String markdown = IOUtils.resourceToString("org/labkey/query/controllers/LabKeySql.md", null, QueryController.class.getClassLoader()); + String markdown = IOUtils.resourceToString("org/labkey/query/controllers/prompts/LabKeySql.md", null, QueryController.class.getClassLoader()); return new ReadResourceResult(List.of( new TextResourceContents( "resource://org/labkey/query/controllers/LabKeySql.md", diff --git a/query/src/org/labkey/query/controllers/prompts/ExpressionAssistant.md b/query/src/org/labkey/query/controllers/prompts/ExpressionAssistant.md new file mode 100644 index 00000000000..8892533e996 --- /dev/null +++ b/query/src/org/labkey/query/controllers/prompts/ExpressionAssistant.md @@ -0,0 +1,84 @@ +# Calculated Column Expression Assistant Documentation +You are a SQL assistant for LabKey. You have access to the schema metadata for this instance, including the table name, +column names, data types, lookup targets, field descriptions, labels and field aliases. When generating SQL for +calculated columns, always use LabKey SQL syntax, not standard ANSI SQL. Calculated columns in LabKey are defined as +SQL expressions that reference columns within the same query context. Never reference columns that do not exist in the +provided metadata. + +### Intent Classification + +#### Requirements +- You shall only operate within the current table context and access metadata required for that table. +- The system shall prevent cross-schema references. + +#### Guidelines +Before generating SQL, classify what the user is trying to do β€” helps route to the right pattern. Given the user's +request, identify which of the following patterns applies: + +1. arithmetic calculation on numeric fields +2. date/time calculation +3. conditional logic / flagging +4. string concatenation or formatting +5. lookup or join to another table +6. status derivation based on multiple fields. + +Return the pattern type before generating the expression. + +### SQL Generation and Integrity + +#### Requirements +- You shall only reference valid, existing columns from the current table. +- Generate valid LabKey SQL compatible with calculated column rules. +- The system shall prevent the use of disallowed functions and validate what's being used is valid LabKey SQL. +- The system shall prevent unsafe or unsupported SQL constructs. +- You shall not reference the calculated column being created (no circular references). +- You should proactively handle potential issues like empty data and dividing by zero. + +#### Guidelines +- Column references. Only reference columns that exist in the provided schema metadata for the current table. +Never reference the calculated column being defined β€” this creates a circular reference and will cause an error. +- Valid LabKey SQL. Generate expressions using only LabKey SQL syntax and supported functions. Do not use standard +ANSI SQL functions, subqueries, aggregate functions or any construct that is not valid in a LabKey calculated column +expression. If a user's request requires an unsupported construct, explain why it cannot be done and suggest the +closest valid alternative. +- Defensive expressions. Proactively guard against runtime errors in every expression you generate: + - Wrap any division operation in a NULL or zero check (e.g., use a CASE statement to avoid divide-by-zero errors. + Division examples should always include NULLIF(). + - Account for columns that may be empty or NULL by using COALESCE or conditional logic where appropriate. + - Do not assume data is always populated. +- What to do when something is invalid: If any part of the request cannot be fulfilled with valid LabKey SQL, do not +silently substitute or approximate. Stop, explain the issue clearly and ask the user how they would like to proceed. + +### Column Validation + +#### Requirements +- The system shall validate all referenced column names. +- You shall provide suggestions for likely matches and detect and flag typos when a column name is invalid. +- You shall not silently replace invalid column names without notifying the user. + +#### Guidelines +Before returning any SQL expression, perform the following validation checks against the provided schema metadata: + +1. Verify that every column name referenced in the expression exists exactly in the provided metadata. If any column +name is invalid, do not silently substitute or correct it. Instead, halt generation and notify the user of the invalid +reference. +2. For each invalid column name, suggest likely matches from the metadata by detecting possible typos or +near-matches (e.g., 'CollectionDte' β†’ did you mean 'CollectionDate'?). +3. Verify data type compatibility across all operations (e.g., not subtracting a string from a date). +4. Verify the expression is a single SELECT-able expression, not a full query. +5. Verify no LabKey-unsupported functions are used. +6. If checks (3), (4) or (5) fail, correct the expression and explain what was changed. +If check (1) fails, do not return SQL – return only the validation error and suggestions. + +### Ambiguity Handling and Assumptions + +#### Requirements +- You shall detect ambiguous prompts and ask clarifying questions when necessary, avoiding silent guessing when +ambiguity materially affects the result. +- The system shall explicitly state assumptions made in the generated SQL. +- You shall ask for clarification if it doesn’t know what a field is. + +#### Guidelines +If the user's request does not clearly identify which fields to use, ask one clarifying question before generating SQL. +For example, 'Which date field should be used as the start of the processing window – CollectionDate or ReceivedDate?' +Do not generate SQL based on assumptions about field names. \ No newline at end of file diff --git a/query/src/org/labkey/query/controllers/LabKeySql.md b/query/src/org/labkey/query/controllers/prompts/LabKeySql.md similarity index 100% rename from query/src/org/labkey/query/controllers/LabKeySql.md rename to query/src/org/labkey/query/controllers/prompts/LabKeySql.md From 58b13ede4a58e186b040b8a01551d3efb2310acc Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Mon, 11 May 2026 15:47:03 -0700 Subject: [PATCH 04/29] Validate generated query --- .../query/controllers/QueryController.java | 89 ++++++++++++------- .../prompts/ExpressionAssistant.md | 9 +- .../query/controllers/prompts/LabKeySql.md | 2 +- 3 files changed, 63 insertions(+), 37 deletions(-) diff --git a/query/src/org/labkey/query/controllers/QueryController.java b/query/src/org/labkey/query/controllers/QueryController.java index 7928906f7e1..7e52b9be7d8 100644 --- a/query/src/org/labkey/query/controllers/QueryController.java +++ b/query/src/org/labkey/query/controllers/QueryController.java @@ -8038,10 +8038,10 @@ public Object execute(GetQueryDetailsAction.Form form, BindException errors) thr } } - public static class ParseForm implements ApiJsonForm + public static class ParseForm extends PromptForm implements ApiJsonForm { String expression = ""; - Map columnMap = new HashMap<>(); + Map columnMap = new HashMap<>(); List phiColumns = new ArrayList<>(); Map getColumnMap() @@ -8091,9 +8091,36 @@ public void bindJson(JSONObject json) } } } + if (json.has("prompt")) + setPrompt(json.getString("prompt")); } } + private static Pair> parseCalculatedColumn(ViewContext context, ParseForm form) throws QueryException + { + var schema = DefaultSchema.get(context.getUser(), context.getContainer()).getUserSchema("core"); + var table = new VirtualTable<>(schema.getDbSchema(), "EXPR", schema){}; + ColumnInfo calculatedCol = QueryServiceImpl.get().createQueryExpressionColumn(table, new FieldKey(null, "expr"), form.getExpression(), null); + Map columns = new HashMap<>(); + + for (var entry : form.getColumnMap().entrySet()) + { + BaseColumnInfo entryCol = new BaseColumnInfo(entry.getKey(), entry.getValue()); + // bindQueryExpressionColumn has a check that restricts PHI columns from being used in expressions, + // so we need to set the PHI level to something other than NotPHI on these fake BaseColumnInfo objects + if (form.getPhiColumns().contains(entry.getKey())) + entryCol.setPHI(PHI.PHI); + columns.put(entry.getKey(), entryCol); + table.addColumn(entryCol); + } + + // TODO: calculating jdbcType still uses calculatedCol.getParentTable().getColumns() + var requiredColumns = new HashSet(); + QueryServiceImpl.get().bindQueryExpressionColumn(calculatedCol, columns, false, requiredColumns); + var jdbcType = calculatedCol.getJdbcType(); + + return Pair.of(jdbcType, requiredColumns); + } /** * Since this api purpose is to return parse errors, it does not generally return success:false. @@ -8137,27 +8164,13 @@ public Object execute(ParseForm form, BindException errors) throws Exception if (errors.hasErrors()) return errors; JSONObject result = new JSONObject(Map.of("success",true)); - var requiredColumns = new HashSet(); + Set requiredColumns = Collections.emptySet(); JdbcType jdbcType = JdbcType.OTHER; try { - var schema = DefaultSchema.get(getViewContext().getUser(), getViewContext().getContainer()).getUserSchema("core"); - var table = new VirtualTable<>(schema.getDbSchema(), "EXPR", schema){}; - ColumnInfo calculatedCol = QueryServiceImpl.get().createQueryExpressionColumn(table, new FieldKey(null, "expr"), form.getExpression(), null); - Map columns = new HashMap<>(); - for (var entry : form.getColumnMap().entrySet()) - { - BaseColumnInfo entryCol = new BaseColumnInfo(entry.getKey(), entry.getValue()); - // bindQueryExpressionColumn has a check that restricts PHI columns from being used in expressions - // so we need to set the PHI level to something other than NotPHI on these fake BaseColumnInfo objects - if (form.getPhiColumns().contains(entry.getKey())) - entryCol.setPHI(PHI.PHI); - columns.put(entry.getKey(), entryCol); - table.addColumn(entryCol); - } - // TODO: calculating jdbcType still uses calculatedCol.getParentTable().getColumns() - QueryServiceImpl.get().bindQueryExpressionColumn(calculatedCol, columns, false, requiredColumns); - jdbcType = calculatedCol.getJdbcType(); + var parsedResult = parseCalculatedColumn(getViewContext(), form); + jdbcType = parsedResult.first; + requiredColumns = parsedResult.second; } catch (QueryException x) { @@ -8518,7 +8531,7 @@ private static String getPromptResource(PromptResource resource) } } - public static class ExpressionAssistantAgentForm extends PromptForm + public static class ExpressionAssistantAgentForm extends ParseForm { } @@ -8526,14 +8539,6 @@ public static class ExpressionAssistantAgentForm extends PromptForm @RequiresLogin public static class ExpressionAssistantAgentAction extends AbstractAgentAction { - ExpressionAssistantAgentForm _form; - - @Override - public void validateForm(ExpressionAssistantAgentForm form, Errors errors) - { - _form = form; - } - @Override protected String getAgentName() { @@ -8552,9 +8557,6 @@ protected String getServicePrompt() @Override public Object execute(ExpressionAssistantAgentForm form, BindException errors) throws Exception { - // save form here for context in getServicePrompt() - _form = form; - try (var _ = McpContext.withContext(getViewContext())) { String prompt = form.getPrompt(); @@ -8579,6 +8581,7 @@ public Object execute(ExpressionAssistantAgentForm form, BindException errors) t { responses = McpService.get().sendMessageEx(chatSession, prompt); sqlResponse = extractSql(responses); + sqlResponse = validateExpressionWithRetry(chatSession, form, sqlResponse); } catch (ServerException x) { @@ -8600,6 +8603,28 @@ public Object execute(ExpressionAssistantAgentForm form, BindException errors) t return errorResponse(x); } } + + private SqlResponse validateExpressionWithRetry(ChatClient chatSession, ParseForm form, SqlResponse sqlResponse) + { + if (null == sqlResponse.sql() || form.getColumnMap().isEmpty()) + return sqlResponse; + + try + { + form.setExpression(sqlResponse.sql()); + parseCalculatedColumn(getViewContext(), form); + } + catch (QueryException x) + { + String validationPrompt = "That SQL caused the " + (x instanceof QueryParseWarning ? "warning" : "error") + " below, can you attempt to fix this?\n```" + x.getMessage() + "```"; + var responses = McpService.get().sendMessageEx(chatSession, validationPrompt); + var newSqlResponse = extractSql(responses); + if (isNotBlank(newSqlResponse.sql())) + return newSqlResponse; + } + + return sqlResponse; + } } public static class TestCase extends AbstractActionPermissionTest diff --git a/query/src/org/labkey/query/controllers/prompts/ExpressionAssistant.md b/query/src/org/labkey/query/controllers/prompts/ExpressionAssistant.md index 8892533e996..cb310d5aaee 100644 --- a/query/src/org/labkey/query/controllers/prompts/ExpressionAssistant.md +++ b/query/src/org/labkey/query/controllers/prompts/ExpressionAssistant.md @@ -5,6 +5,8 @@ calculated columns, always use LabKey SQL syntax, not standard ANSI SQL. Calcula SQL expressions that reference columns within the same query context. Never reference columns that do not exist in the provided metadata. +Be brief in your responses unless the user asks otherwise. + ### Intent Classification #### Requirements @@ -20,9 +22,7 @@ request, identify which of the following patterns applies: 3. conditional logic / flagging 4. string concatenation or formatting 5. lookup or join to another table -6. status derivation based on multiple fields. - -Return the pattern type before generating the expression. +6. status derivation based on multiple fields. ### SQL Generation and Integrity @@ -68,7 +68,8 @@ near-matches (e.g., 'CollectionDte' β†’ did you mean 'CollectionDate'?). 4. Verify the expression is a single SELECT-able expression, not a full query. 5. Verify no LabKey-unsupported functions are used. 6. If checks (3), (4) or (5) fail, correct the expression and explain what was changed. -If check (1) fails, do not return SQL – return only the validation error and suggestions. +If check (1) fails, do not return SQL – return only the validation error and suggestions. +If check (1) succeeds, return the SQL and do not return validation information unless explicitly requested. ### Ambiguity Handling and Assumptions diff --git a/query/src/org/labkey/query/controllers/prompts/LabKeySql.md b/query/src/org/labkey/query/controllers/prompts/LabKeySql.md index b02b11d1261..4cb61112291 100644 --- a/query/src/org/labkey/query/controllers/prompts/LabKeySql.md +++ b/query/src/org/labkey/query/controllers/prompts/LabKeySql.md @@ -204,7 +204,7 @@ Here is a summary of the available functions and methods in LabKey SQL. * `age_in_years(date1, date2)`: Returns age in years. * `curdate()`, `curtime()`: Returns the current date/time. * `dayofmonth(date)`: Returns the day of the month. -* `dayofweek(date)`: Returns the day of the week. +* `dayofweek(date)`: Returns the day of the week. Day of the week starts * `dayofyear(date)`: Returns the day of the year. * `hour(time)`, `minute(time)`, `second(time)`: Return time components. * `month(date)`, `monthname(date)`: Return month values. From 5dd6d4f069d57c87b6cad49d00ae07d5dba2a635 Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Mon, 11 May 2026 15:47:46 -0700 Subject: [PATCH 05/29] Remove check --- query/src/org/labkey/query/controllers/QueryController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/query/src/org/labkey/query/controllers/QueryController.java b/query/src/org/labkey/query/controllers/QueryController.java index 7e52b9be7d8..c51d0baf91a 100644 --- a/query/src/org/labkey/query/controllers/QueryController.java +++ b/query/src/org/labkey/query/controllers/QueryController.java @@ -8606,7 +8606,7 @@ public Object execute(ExpressionAssistantAgentForm form, BindException errors) t private SqlResponse validateExpressionWithRetry(ChatClient chatSession, ParseForm form, SqlResponse sqlResponse) { - if (null == sqlResponse.sql() || form.getColumnMap().isEmpty()) + if (null == sqlResponse.sql()) return sqlResponse; try From 377aa7cdef4d4d5f34efb325a0ef9ad2db7a621c Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Tue, 12 May 2026 12:10:50 -0700 Subject: [PATCH 06/29] conversationId --- .../labkey/api/mcp/AbstractAgentAction.java | 28 +++++++++++++++++-- api/src/org/labkey/api/mcp/PromptForm.java | 11 ++++++++ .../query/controllers/QueryController.java | 12 ++++---- .../src/org/labkey/query/view/sourceQuery.jsp | 10 +++++-- 4 files changed, 48 insertions(+), 13 deletions(-) diff --git a/api/src/org/labkey/api/mcp/AbstractAgentAction.java b/api/src/org/labkey/api/mcp/AbstractAgentAction.java index 0e1821d27cc..45ca3c0d648 100644 --- a/api/src/org/labkey/api/mcp/AbstractAgentAction.java +++ b/api/src/org/labkey/api/mcp/AbstractAgentAction.java @@ -8,30 +8,37 @@ import org.jetbrains.annotations.Nullable; import org.json.JSONObject; import org.labkey.api.action.ReadOnlyApiAction; +import org.labkey.api.util.GUID; import org.labkey.api.util.HtmlString; import org.springframework.ai.chat.client.ChatClient; import org.springframework.validation.BindException; +import org.springframework.validation.Errors; import java.util.Map; 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 + * "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. + * conversation to help your user get the right results. */ public abstract class AbstractAgentAction extends ReadOnlyApiAction { + protected GUID conversationId; + protected abstract String getAgentName(); protected abstract String getServicePrompt(); protected ChatClient getChat(boolean create) { + String conversationName = getAgentName() + ":" + getConversationId(); + HttpSession session = getViewContext().getRequest().getSession(true); - ChatClient chatSession = McpService.get().getChat(session, getAgentName(), this::getServicePrompt, create); + ChatClient chatSession = McpService.get().getChat(session, conversationName, this::getServicePrompt, create); + return chatSession; } @@ -51,6 +58,16 @@ protected String handleEscape(String prompt) return null; } + @Override + public void validateForm(F form, Errors errors) + { + // If the client provided a valid conversationId, use it. Otherwise, generate a conversationId. + if (form.getConversationId() != null) + conversationId = new GUID(form.getConversationId()); + else + conversationId = new GUID(); + } + @Override public Object execute(PromptForm form, BindException errors) throws Exception { @@ -121,4 +138,9 @@ else if (isNotBlank(response.text())) "text", escapeResponse, "success", Boolean.TRUE)); } + + protected GUID getConversationId() + { + return conversationId; + } } diff --git a/api/src/org/labkey/api/mcp/PromptForm.java b/api/src/org/labkey/api/mcp/PromptForm.java index a6017f4d266..61c58850ba7 100644 --- a/api/src/org/labkey/api/mcp/PromptForm.java +++ b/api/src/org/labkey/api/mcp/PromptForm.java @@ -2,8 +2,19 @@ public class PromptForm { + public String conversationId; public String prompt; + public String getConversationId() + { + return conversationId; + } + + public void setConversationId(String conversationId) + { + this.conversationId = conversationId; + } + public void setPrompt(String prompt) { this.prompt = prompt; diff --git a/query/src/org/labkey/query/controllers/QueryController.java b/query/src/org/labkey/query/controllers/QueryController.java index c51d0baf91a..951f724d569 100644 --- a/query/src/org/labkey/query/controllers/QueryController.java +++ b/query/src/org/labkey/query/controllers/QueryController.java @@ -8093,6 +8093,8 @@ public void bindJson(JSONObject json) } if (json.has("prompt")) setPrompt(json.getString("prompt")); + if (json.has("conversationId")) + setConversationId(json.getString("conversationId")); } } @@ -8531,13 +8533,9 @@ private static String getPromptResource(PromptResource resource) } } - public static class ExpressionAssistantAgentForm extends ParseForm - { - } - @RequiresPermission(ReadPermission.class) @RequiresLogin - public static class ExpressionAssistantAgentAction extends AbstractAgentAction + public static class ExpressionAssistantAgentAction extends AbstractAgentAction { @Override protected String getAgentName() @@ -8555,7 +8553,7 @@ protected String getServicePrompt() } @Override - public Object execute(ExpressionAssistantAgentForm form, BindException errors) throws Exception + public Object execute(ParseForm form, BindException errors) throws Exception { try (var _ = McpContext.withContext(getViewContext())) { @@ -8591,7 +8589,7 @@ public Object execute(ExpressionAssistantAgentForm form, BindException errors) t "success", Boolean.FALSE)); } - var ret = new JSONObject(Map.of("success", Boolean.TRUE)); + var ret = new JSONObject(Map.of("success", Boolean.TRUE, "conversationId", getConversationId())); if (null != sqlResponse.sql()) ret.put("sql", sqlResponse.sql()); if (null != sqlResponse.html()) diff --git a/query/src/org/labkey/query/view/sourceQuery.jsp b/query/src/org/labkey/query/view/sourceQuery.jsp index d875011525c..f308955f4f9 100644 --- a/query/src/org/labkey/query/view/sourceQuery.jsp +++ b/query/src/org/labkey/query/view/sourceQuery.jsp @@ -388,10 +388,15 @@ addChatItem(chatItem); } + let firstChat = true; + let conversationId; + async function queryAgent(prompt) { return new Promise((resolve, reject) => { LABKEY.Ajax.request({ - url: LABKEY.ActionURL.buildURL('query', 'queryagent.api', undefined, { prompt: prompt }), + url: LABKEY.ActionURL.buildURL('query', 'queryAgent.api'), + method: 'POST', + jsonData: { conversationId, prompt }, success: LABKEY.Utils.getCallbackWrapper(response => { resolve(response); }), @@ -402,8 +407,6 @@ }); } - let firstChat = true; - function initialPrompt() { if (!firstChat) return ''; @@ -445,6 +448,7 @@ startLoading(); const response = await queryAgent(initialPrompt() + prompt); + conversationId = response.conversationId; if (response.sql) { Ext4.getCmp('qep').getSourceEditor().setValue(response.sql); From ec4c72a0f43ea83232654288f45bd80e42e9df90 Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Tue, 12 May 2026 14:16:44 -0700 Subject: [PATCH 07/29] QueryMcp.validateCalculatedColumnExpression tool --- api/src/org/labkey/api/mcp/McpContext.java | 26 ++++++-- .../query/controllers/QueryController.java | 65 ++++++++----------- .../labkey/query/controllers/QueryMcp.java | 35 +++++++++- 3 files changed, 80 insertions(+), 46 deletions(-) diff --git a/api/src/org/labkey/api/mcp/McpContext.java b/api/src/org/labkey/api/mcp/McpContext.java index 6078214d965..8601f942e61 100644 --- a/api/src/org/labkey/api/mcp/McpContext.java +++ b/api/src/org/labkey/api/mcp/McpContext.java @@ -8,6 +8,8 @@ import org.labkey.api.view.UnauthorizedException; import org.labkey.api.writer.ContainerUser; import org.springframework.ai.chat.model.ToolContext; + +import java.util.HashMap; import java.util.Map; /** @@ -19,11 +21,11 @@ public class McpContext implements ContainerUser { final User user; final Container container; + final Map attributes = new HashMap<>(); public McpContext(ContainerUser ctx) { - this.container = ctx.getContainer(); - this.user = ctx.getUser(); + this(ctx.getContainer(), ctx.getUser()); } public McpContext(Container container, User user) @@ -36,9 +38,24 @@ public McpContext(Container container, User user) public ToolContext getToolContext() { - return new ToolContext(Map.of("container", getContainer(), "user", getUser())); + Map map = new HashMap<>(attributes); + map.put("container", getContainer()); + map.put("user", getUser()); + return new ToolContext(map); } + public McpContext put(String key, Object value) + { + if ("container".equals(key) || "user".equals(key)) + throw new IllegalArgumentException("Reserved key: " + key); + attributes.put(key, value); + return this; + } + + public Object get(String key) + { + return attributes.get(key); + } @Override public Container getContainer() @@ -52,10 +69,9 @@ public User getUser() return user; } - // // I'd like to get away from using ThreadLocal, but I haven't - // researched if there are other ways to pass context around to Tools registerd by McpService + // researched if there are other ways to pass context around to Tools registered by McpService // private static final ThreadLocal contexts = new ThreadLocal(); diff --git a/query/src/org/labkey/query/controllers/QueryController.java b/query/src/org/labkey/query/controllers/QueryController.java index 951f724d569..fbeb23dd021 100644 --- a/query/src/org/labkey/query/controllers/QueryController.java +++ b/query/src/org/labkey/query/controllers/QueryController.java @@ -154,7 +154,6 @@ import org.labkey.api.exp.property.PropertyService; import org.labkey.api.files.FileContentService; import org.labkey.api.gwt.client.AuditBehaviorType; -import org.labkey.api.gwt.client.model.GWTPropertyDescriptor; import org.labkey.api.mcp.AbstractAgentAction; import org.labkey.api.mcp.McpContext; import org.labkey.api.mcp.McpService; @@ -8098,19 +8097,27 @@ public void bindJson(JSONObject json) } } - private static Pair> parseCalculatedColumn(ViewContext context, ParseForm form) throws QueryException + record CalculatedColumnParseResult(JdbcType jdbcType, Set requiredColumns) { } + + static CalculatedColumnParseResult parseCalculatedColumn( + Container container, + User user, + String expression, + Map columnMap, + @Nullable List phiColumns + ) throws QueryException { - var schema = DefaultSchema.get(context.getUser(), context.getContainer()).getUserSchema("core"); + var schema = DefaultSchema.get(user, container).getUserSchema("core"); var table = new VirtualTable<>(schema.getDbSchema(), "EXPR", schema){}; - ColumnInfo calculatedCol = QueryServiceImpl.get().createQueryExpressionColumn(table, new FieldKey(null, "expr"), form.getExpression(), null); + ColumnInfo calculatedCol = QueryServiceImpl.get().createQueryExpressionColumn(table, new FieldKey(null, "expr"), expression, null); Map columns = new HashMap<>(); - for (var entry : form.getColumnMap().entrySet()) + for (var entry : columnMap.entrySet()) { BaseColumnInfo entryCol = new BaseColumnInfo(entry.getKey(), entry.getValue()); // bindQueryExpressionColumn has a check that restricts PHI columns from being used in expressions, // so we need to set the PHI level to something other than NotPHI on these fake BaseColumnInfo objects - if (form.getPhiColumns().contains(entry.getKey())) + if (phiColumns != null && phiColumns.contains(entry.getKey())) entryCol.setPHI(PHI.PHI); columns.put(entry.getKey(), entryCol); table.addColumn(entryCol); @@ -8121,7 +8128,7 @@ private static Pair> parseCalculatedColumn(ViewContext c QueryServiceImpl.get().bindQueryExpressionColumn(calculatedCol, columns, false, requiredColumns); var jdbcType = calculatedCol.getJdbcType(); - return Pair.of(jdbcType, requiredColumns); + return new CalculatedColumnParseResult(jdbcType, requiredColumns); } /** @@ -8166,13 +8173,10 @@ public Object execute(ParseForm form, BindException errors) throws Exception if (errors.hasErrors()) return errors; JSONObject result = new JSONObject(Map.of("success",true)); - Set requiredColumns = Collections.emptySet(); - JdbcType jdbcType = JdbcType.OTHER; + CalculatedColumnParseResult parsedResult = new CalculatedColumnParseResult(JdbcType.OTHER, Collections.emptySet()); try { - var parsedResult = parseCalculatedColumn(getViewContext(), form); - jdbcType = parsedResult.first; - requiredColumns = parsedResult.second; + parsedResult = parseCalculatedColumn(getViewContext().getContainer(), getViewContext().getUser(), form.getExpression(), form.getColumnMap(), form.getPhiColumns()); } catch (QueryException x) { @@ -8182,10 +8186,10 @@ public Object execute(ParseForm form, BindException errors) throws Exception } finally { - if (!requiredColumns.isEmpty()) + if (!parsedResult.requiredColumns().isEmpty()) { JSONObject columnMap = new JSONObject(); - for (FieldKey fk : requiredColumns) + for (FieldKey fk : parsedResult.requiredColumns()) { JdbcType type = Objects.requireNonNullElse(form.getColumnMap().get(fk), JdbcType.OTHER); columnMap.put(fk.toString(), type); @@ -8193,7 +8197,7 @@ public Object execute(ParseForm form, BindException errors) throws Exception result.put("columnMap", columnMap); } } - result.put("jdbcType", jdbcType.name()); + result.put("jdbcType", parsedResult.jdbcType().name()); return result; } } @@ -8549,7 +8553,8 @@ protected String getServicePrompt() return "The following documentation describes you, the Calculated Column Expression Assistant:\n\n" + getPromptResource(PromptResource.ExpressionAssistant) + "\n\nThe following documentation describes LabKey SQL:\n\n" + - getPromptResource(PromptResource.LabKeySql); + getPromptResource(PromptResource.LabKeySql) + + "\n\nWhen you produce a SQL expression for the calculated column you should use validate it using the validateCalculatedColumnExpression tool."; } @Override @@ -8577,9 +8582,13 @@ public Object execute(ParseForm form, BindException errors) throws Exception try { + // Context for the QueryMcp.validateCalculatedColumnExpression() tool + McpContext.get() + .put("columnMap", form.getColumnMap()) + .put("phiColumns", form.getPhiColumns()); + responses = McpService.get().sendMessageEx(chatSession, prompt); sqlResponse = extractSql(responses); - sqlResponse = validateExpressionWithRetry(chatSession, form, sqlResponse); } catch (ServerException x) { @@ -8601,28 +8610,6 @@ public Object execute(ParseForm form, BindException errors) throws Exception return errorResponse(x); } } - - private SqlResponse validateExpressionWithRetry(ChatClient chatSession, ParseForm form, SqlResponse sqlResponse) - { - if (null == sqlResponse.sql()) - return sqlResponse; - - try - { - form.setExpression(sqlResponse.sql()); - parseCalculatedColumn(getViewContext(), form); - } - catch (QueryException x) - { - String validationPrompt = "That SQL caused the " + (x instanceof QueryParseWarning ? "warning" : "error") + " below, can you attempt to fix this?\n```" + x.getMessage() + "```"; - var responses = McpService.get().sendMessageEx(chatSession, validationPrompt); - var newSqlResponse = extractSql(responses); - if (isNotBlank(newSqlResponse.sql())) - return newSqlResponse; - } - - return sqlResponse; - } } public static class TestCase extends AbstractActionPermissionTest diff --git a/query/src/org/labkey/query/controllers/QueryMcp.java b/query/src/org/labkey/query/controllers/QueryMcp.java index c383e63666c..bca112ac5ee 100644 --- a/query/src/org/labkey/query/controllers/QueryMcp.java +++ b/query/src/org/labkey/query/controllers/QueryMcp.java @@ -8,12 +8,16 @@ import org.json.JSONObject; import org.labkey.api.collections.CaseInsensitiveHashSet; import org.labkey.api.data.ColumnInfo; +import org.labkey.api.data.JdbcType; import org.labkey.api.data.PropertyManager; import org.labkey.api.data.TableDescription; import org.labkey.api.data.TableInfo; +import org.labkey.api.mcp.McpException; import org.labkey.api.mcp.McpService; import org.labkey.api.query.DefaultSchema; +import org.labkey.api.query.FieldKey; import org.labkey.api.query.QueryDefinition; +import org.labkey.api.query.QueryException; import org.labkey.api.query.QueryForeignKey; import org.labkey.api.query.QueryKey; import org.labkey.api.query.QueryParseException; @@ -44,7 +48,7 @@ public class QueryMcp implements McpService.McpImpl { @McpResource( - uri = "resource://org/labkey/query/controllers/LabKeySql.md", + uri = "resource://org/labkey/query/controllers/prompts/LabKeySql.md", mimeType = "application/markdown", name = "LabKey SQL", description = "Provide documentation for LabKey SQL specific syntax") @@ -54,7 +58,7 @@ public ReadResourceResult getLabKeySQLDocumentation() throws IOException String markdown = IOUtils.resourceToString("org/labkey/query/controllers/prompts/LabKeySql.md", null, QueryController.class.getClassLoader()); return new ReadResourceResult(List.of( new TextResourceContents( - "resource://org/labkey/query/controllers/LabKeySql.md", + "resource://org/labkey/query/controllers/prompts/LabKeySql.md", "application/markdown", markdown ) @@ -156,6 +160,33 @@ String validateSQL( return "success"; } + @Tool(description = "Validate a SQL expression for a calculated column. The set of available columns and their types β€” including any PHI-restricted columns β€” is supplied by the hosting endpoint, not by the caller; you only need to provide the expression itself.") + @RequiresPermission(ReadPermission.class) + String validateCalculatedColumnExpression( + ToolContext toolContext, + @ToolParam(description = "SQL expression for the calculated column") String expression + ) + { + var context = getContext(toolContext); + + @SuppressWarnings("unchecked") + Map columnMap = (Map) toolContext.getContext().get("columnMap"); + @SuppressWarnings("unchecked") + List phiColumns = (List) toolContext.getContext().get("phiColumns"); + + if (columnMap == null) + throw new McpException("validateCalculatedColumnExpression requires a columnMap supplied by the hosting endpoint; it cannot be invoked directly."); + + try + { + QueryController.parseCalculatedColumn(context.getContainer(), context.getUser(), expression, columnMap, phiColumns); + } + catch (QueryException x) + { + return "That SQL caused the " + (x instanceof QueryParseWarning ? "warning" : "error") + " below:\n```" + x.getMessage() + "```"; + } + return "success"; + } /* For now, list all schemas. CONSIDER support incremental querying. */ public static Map _listAllSchemas(DefaultSchema root) From 4d46dcefc022bd0e1271296a66cceb58a52b7495 Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Tue, 12 May 2026 17:03:56 -0700 Subject: [PATCH 08/29] McpInternal --- api/src/org/labkey/api/mcp/McpInternal.java | 22 +++++++++++++++++++ .../query/controllers/QueryController.java | 3 +-- .../labkey/query/controllers/QueryMcp.java | 7 ++++-- 3 files changed, 28 insertions(+), 4 deletions(-) create mode 100644 api/src/org/labkey/api/mcp/McpInternal.java diff --git a/api/src/org/labkey/api/mcp/McpInternal.java b/api/src/org/labkey/api/mcp/McpInternal.java new file mode 100644 index 00000000000..43696a4cce6 --- /dev/null +++ b/api/src/org/labkey/api/mcp/McpInternal.java @@ -0,0 +1,22 @@ +package org.labkey.api.mcp; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks an MCP tool as internal-only. Internal tools are available to in-process callers (e.g. + * {@link AbstractAgentAction}-backed agents that supply tool-specific context via {@link McpContext}) + * but are filtered out of the externally exposed MCP server: they do not appear in {@code listTools} + * and direct {@code callTool} requests for them are rejected. + * + * Use this for tools that require request-scoped context the LLM cannot reasonably supply on its own. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface McpInternal +{ + /** Optional reason surfaced in startup logs. */ + String value() default ""; +} diff --git a/query/src/org/labkey/query/controllers/QueryController.java b/query/src/org/labkey/query/controllers/QueryController.java index fbeb23dd021..21910fb0cd8 100644 --- a/query/src/org/labkey/query/controllers/QueryController.java +++ b/query/src/org/labkey/query/controllers/QueryController.java @@ -8552,8 +8552,7 @@ protected String getServicePrompt() { return "The following documentation describes you, the Calculated Column Expression Assistant:\n\n" + getPromptResource(PromptResource.ExpressionAssistant) + - "\n\nThe following documentation describes LabKey SQL:\n\n" + - getPromptResource(PromptResource.LabKeySql) + + "\n\nRefer to the \"LabKey SQL\" documentation resource for how to work with LabKey SQL.\n\n" + "\n\nWhen you produce a SQL expression for the calculated column you should use validate it using the validateCalculatedColumnExpression tool."; } diff --git a/query/src/org/labkey/query/controllers/QueryMcp.java b/query/src/org/labkey/query/controllers/QueryMcp.java index bca112ac5ee..a702f55402c 100644 --- a/query/src/org/labkey/query/controllers/QueryMcp.java +++ b/query/src/org/labkey/query/controllers/QueryMcp.java @@ -13,6 +13,7 @@ import org.labkey.api.data.TableDescription; import org.labkey.api.data.TableInfo; import org.labkey.api.mcp.McpException; +import org.labkey.api.mcp.McpInternal; import org.labkey.api.mcp.McpService; import org.labkey.api.query.DefaultSchema; import org.labkey.api.query.FieldKey; @@ -160,7 +161,8 @@ String validateSQL( return "success"; } - @Tool(description = "Validate a SQL expression for a calculated column. The set of available columns and their types β€” including any PHI-restricted columns β€” is supplied by the hosting endpoint, not by the caller; you only need to provide the expression itself.") + @Tool(description = "Validate a SQL expression for a calculated column. The set of available columns and their types, including any PHI-restricted columns, is supplied by the hosting endpoint, not by the caller; you only need to provide the expression itself.") + @McpInternal("Added for validation for the QueryController.ExpressionAssistantAgentAction endpoint.") @RequiresPermission(ReadPermission.class) String validateCalculatedColumnExpression( ToolContext toolContext, @@ -175,7 +177,7 @@ String validateCalculatedColumnExpression( List phiColumns = (List) toolContext.getContext().get("phiColumns"); if (columnMap == null) - throw new McpException("validateCalculatedColumnExpression requires a columnMap supplied by the hosting endpoint; it cannot be invoked directly."); + throw new IllegalArgumentException("validateCalculatedColumnExpression requires a columnMap supplied by the endpoint; it cannot be invoked directly."); try { @@ -185,6 +187,7 @@ String validateCalculatedColumnExpression( { return "That SQL caused the " + (x instanceof QueryParseWarning ? "warning" : "error") + " below:\n```" + x.getMessage() + "```"; } + return "success"; } From 27768fd7e9e02bf8c2c540d0cc5db9d5ffa54db1 Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Tue, 12 May 2026 17:41:47 -0700 Subject: [PATCH 09/29] Use segments --- query/src/org/labkey/query/QueryModule.java | 1 + .../query/controllers/QueryController.java | 260 +++++++++++++++++- 2 files changed, 252 insertions(+), 9 deletions(-) diff --git a/query/src/org/labkey/query/QueryModule.java b/query/src/org/labkey/query/QueryModule.java index 0e57baef4de..597782fc15f 100644 --- a/query/src/org/labkey/query/QueryModule.java +++ b/query/src/org/labkey/query/QueryModule.java @@ -428,6 +428,7 @@ public Set getSchemaNames() MemberSet.TestCase.class, MetadataElementBase.TestCase.class, Method.TestCase.class, + QueryController.ExpressionAssistantTestCase.class, QNode.TestCase.class, Query.TestCase.class, ReportsController.SerializationTest.class, diff --git a/query/src/org/labkey/query/controllers/QueryController.java b/query/src/org/labkey/query/controllers/QueryController.java index 21910fb0cd8..290d324e5fd 100644 --- a/query/src/org/labkey/query/controllers/QueryController.java +++ b/query/src/org/labkey/query/controllers/QueryController.java @@ -154,6 +154,7 @@ import org.labkey.api.exp.property.PropertyService; import org.labkey.api.files.FileContentService; import org.labkey.api.gwt.client.AuditBehaviorType; +import org.labkey.api.markdown.MarkdownService; import org.labkey.api.mcp.AbstractAgentAction; import org.labkey.api.mcp.McpContext; import org.labkey.api.mcp.McpService; @@ -8553,7 +8554,8 @@ protected String getServicePrompt() return "The following documentation describes you, the Calculated Column Expression Assistant:\n\n" + getPromptResource(PromptResource.ExpressionAssistant) + "\n\nRefer to the \"LabKey SQL\" documentation resource for how to work with LabKey SQL.\n\n" + - "\n\nWhen you produce a SQL expression for the calculated column you should use validate it using the validateCalculatedColumnExpression tool."; + "\n\nWhen you produce a SQL expression for the calculated column you should use validate it using the validateCalculatedColumnExpression tool." + + "\n\nWhen presenting a final SQL expression that the user can apply to their calculated column, place it in a fenced code block tagged `expression` (e.g. ```expression\\n...\\n```) ONLY AFTER you have successfully validated it using the validateCalculatedColumnExpression tool. Use a `sql` fence for any illustrative, intermediate, or unvalidated SQL that the user should NOT directly apply. Each `expression` block will be rendered with an \"Apply Expression\" affordance, so emit one for each distinct expression the user can choose to apply."; } @Override @@ -8577,7 +8579,6 @@ public Object execute(ParseForm form, BindException errors) throws Exception ChatClient chatSession = getChat(true); List responses; - SqlResponse sqlResponse; try { @@ -8587,7 +8588,6 @@ public Object execute(ParseForm form, BindException errors) throws Exception .put("phiColumns", form.getPhiColumns()); responses = McpService.get().sendMessageEx(chatSession, prompt); - sqlResponse = extractSql(responses); } catch (ServerException x) { @@ -8597,12 +8597,11 @@ public Object execute(ParseForm form, BindException errors) throws Exception "success", Boolean.FALSE)); } - var ret = new JSONObject(Map.of("success", Boolean.TRUE, "conversationId", getConversationId())); - if (null != sqlResponse.sql()) - ret.put("sql", sqlResponse.sql()); - if (null != sqlResponse.html()) - ret.put("html", sqlResponse.html()); - return ret; + JSONArray segments = buildSegments(responses); + return new JSONObject(Map.of( + "success", Boolean.TRUE, + "conversationId", getConversationId(), + "segments", segments)); } catch (ClientException x) { @@ -9033,6 +9032,93 @@ record SqlResponse(HtmlString html, String sql) { } + /** + * Walks the markdown of each MessageResponse and produces an ordered list of segments. + * Each segment is either a rendered-HTML span or a fenced SQL block. SQL blocks fenced as + * `expression` are tagged "expression" (the model's assertion that this SQL has been validated + * and is safe to apply); blocks fenced as `sql` are tagged "sql" (illustrative / unvalidated). + */ + static JSONArray buildSegments(List responses) + { + JSONArray segments = new JSONArray(); + MarkdownService md = MarkdownService.get(); + StringBuilder htmlBuf = new StringBuilder(); + + for (var response : responses) + { + String text = response.text(); + if (isBlank(text)) + continue; + + String[] lines = text.split("\n", -1); + int i = 0; + while (i < lines.length) + { + String tag = fenceTag(lines[i]); + if ("sql".equals(tag) || "expression".equals(tag)) + { + int j = i + 1; + StringBuilder code = new StringBuilder(); + while (j < lines.length && !"```".equals(lines[j].trim())) + { + if (!code.isEmpty()) code.append("\n"); + code.append(lines[j]); + j++; + } + if (j >= lines.length) + { + // Unterminated fence β€” treat the rest as markdown so we don't drop content. + if (!htmlBuf.isEmpty()) htmlBuf.append("\n"); + for (int k = i; k < lines.length; k++) + { + htmlBuf.append(lines[k]); + if (k < lines.length - 1) htmlBuf.append("\n"); + } + break; + } + flushHtmlSegment(segments, htmlBuf, md); + segments.put(new JSONObject(Map.of("type", tag, "sql", code.toString()))); + i = j + 1; + } + else + { + if (!htmlBuf.isEmpty()) htmlBuf.append("\n"); + htmlBuf.append(lines[i]); + i++; + } + } + } + flushHtmlSegment(segments, htmlBuf, md); + return segments; + } + + private static String fenceTag(String line) + { + String trimmed = line.trim(); + if (!trimmed.startsWith("```")) + return null; + String rest = trimmed.substring(3).trim(); + return rest.isEmpty() ? null : rest.toLowerCase(); + } + + private static void flushHtmlSegment(JSONArray segments, StringBuilder buf, MarkdownService md) + { + if (buf.isEmpty()) return; + String raw = buf.toString().strip(); + buf.setLength(0); + if (raw.isEmpty()) return; + String html; + try + { + html = md != null ? md.toHtml(raw) : raw; + } + catch (Exception x) + { + html = raw; + } + segments.put(new JSONObject(Map.of("type", "html", "html", html))); + } + static SqlResponse extractSql(List responses) { HtmlStringBuilder html = HtmlStringBuilder.of(); @@ -9055,4 +9141,160 @@ static SqlResponse extractSql(List responses) } return new SqlResponse(html.getHtmlString(), sql); } + + public static class ExpressionAssistantTestCase extends Assert + { + private static McpService.MessageResponse markdownResponse(String md) + { + return new McpService.MessageResponse("text/markdown", md, HtmlString.of(md)); + } + + private static JSONObject segment(JSONArray segments, int i) + { + return segments.getJSONObject(i); + } + + @Test + public void emptyResponseList() + { + JSONArray segments = buildSegments(List.of()); + assertEquals(0, segments.length()); + } + + @Test + public void blankMessageProducesNoSegments() + { + JSONArray segments = buildSegments(List.of(markdownResponse(""), markdownResponse(" "))); + assertEquals(0, segments.length()); + } + + @Test + public void proseOnlyProducesSingleHtmlSegment() + { + JSONArray segments = buildSegments(List.of(markdownResponse("Hello, here is some advice."))); + assertEquals(1, segments.length()); + assertEquals("html", segment(segments, 0).getString("type")); + assertTrue(segment(segments, 0).getString("html").contains("Hello, here is some advice.")); + } + + @Test + public void expressionFenceProducesExpressionSegment() + { + String md = "```expression\nSELECT 1\n```"; + JSONArray segments = buildSegments(List.of(markdownResponse(md))); + assertEquals(1, segments.length()); + assertEquals("expression", segment(segments, 0).getString("type")); + assertEquals("SELECT 1", segment(segments, 0).getString("sql")); + } + + @Test + public void sqlFenceProducesSqlSegment() + { + String md = "```sql\nSELECT 1\n```"; + JSONArray segments = buildSegments(List.of(markdownResponse(md))); + assertEquals(1, segments.length()); + assertEquals("sql", segment(segments, 0).getString("type")); + assertEquals("SELECT 1", segment(segments, 0).getString("sql")); + } + + @Test + public void interleavedProseAndFencesProduceOrderedSegments() + { + String md = String.join("\n", + "Here are two options.", + "", + "Option A (illustrative):", + "```sql", + "SELECT a FROM t", + "```", + "", + "Option B (ready to apply):", + "```expression", + "SELECT b FROM t", + "```", + "Pick whichever fits." + ); + JSONArray segments = buildSegments(List.of(markdownResponse(md))); + assertEquals(5, segments.length()); + assertEquals("html", segment(segments, 0).getString("type")); + assertEquals("sql", segment(segments, 1).getString("type")); + assertEquals("SELECT a FROM t", segment(segments, 1).getString("sql")); + assertEquals("html", segment(segments, 2).getString("type")); + assertEquals("expression", segment(segments, 3).getString("type")); + assertEquals("SELECT b FROM t", segment(segments, 3).getString("sql")); + assertEquals("html", segment(segments, 4).getString("type")); + } + + @Test + public void multipleExpressionFencesEachBecomeOwnSegment() + { + String md = String.join("\n", + "```expression", + "SELECT 1", + "```", + "```expression", + "SELECT 2", + "```" + ); + JSONArray segments = buildSegments(List.of(markdownResponse(md))); + assertEquals(2, segments.length()); + assertEquals("expression", segment(segments, 0).getString("type")); + assertEquals("SELECT 1", segment(segments, 0).getString("sql")); + assertEquals("expression", segment(segments, 1).getString("type")); + assertEquals("SELECT 2", segment(segments, 1).getString("sql")); + } + + @Test + public void unterminatedFenceFallsBackToHtml() + { + String md = "Here's an expression:\n```expression\nSELECT 1\n(no closing fence)"; + JSONArray segments = buildSegments(List.of(markdownResponse(md))); + // No expression segment is emitted; the unterminated portion is folded into html so + // content isn't dropped. + assertEquals(1, segments.length()); + assertEquals("html", segment(segments, 0).getString("type")); + assertTrue(segment(segments, 0).getString("html").contains("SELECT 1")); + } + + @Test + public void unknownFenceLanguageIsTreatedAsProse() + { + String md = "```python\nprint('hi')\n```"; + JSONArray segments = buildSegments(List.of(markdownResponse(md))); + // Only sql/expression are split out; other fenced blocks stay in the html segment. + assertEquals(1, segments.length()); + assertEquals("html", segment(segments, 0).getString("type")); + } + + @Test + public void fenceTagIsCaseInsensitive() + { + String md = "```EXPRESSION\nSELECT 1\n```"; + JSONArray segments = buildSegments(List.of(markdownResponse(md))); + assertEquals(1, segments.length()); + assertEquals("expression", segment(segments, 0).getString("type")); + assertEquals("SELECT 1", segment(segments, 0).getString("sql")); + } + + @Test + public void preservesMultilineSqlBody() + { + String md = "```expression\nSELECT a,\n b\nFROM t\n```"; + JSONArray segments = buildSegments(List.of(markdownResponse(md))); + assertEquals(1, segments.length()); + assertEquals("SELECT a,\n b\nFROM t", segment(segments, 0).getString("sql")); + } + + @Test + public void multipleMessageResponsesAreConcatenatedInOrder() + { + McpService.MessageResponse r1 = markdownResponse("First response prose."); + McpService.MessageResponse r2 = markdownResponse("```expression\nSELECT 1\n```"); + JSONArray segments = buildSegments(List.of(r1, r2)); + assertEquals(2, segments.length()); + assertEquals("html", segment(segments, 0).getString("type")); + assertEquals("expression", segment(segments, 1).getString("type")); + assertEquals("SELECT 1", segment(segments, 1).getString("sql")); + } + } } From a05c9b3580adb91c58a7e1186287de3594cf8341 Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Tue, 12 May 2026 22:09:28 -0700 Subject: [PATCH 10/29] nits --- .../query/controllers/QueryController.java | 11 +-------- .../prompts/ExpressionAssistant.md | 24 ++++++++++++++----- 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/query/src/org/labkey/query/controllers/QueryController.java b/query/src/org/labkey/query/controllers/QueryController.java index 290d324e5fd..32b7ea660d4 100644 --- a/query/src/org/labkey/query/controllers/QueryController.java +++ b/query/src/org/labkey/query/controllers/QueryController.java @@ -8551,11 +8551,7 @@ protected String getAgentName() @Override protected String getServicePrompt() { - return "The following documentation describes you, the Calculated Column Expression Assistant:\n\n" + - getPromptResource(PromptResource.ExpressionAssistant) + - "\n\nRefer to the \"LabKey SQL\" documentation resource for how to work with LabKey SQL.\n\n" + - "\n\nWhen you produce a SQL expression for the calculated column you should use validate it using the validateCalculatedColumnExpression tool." + - "\n\nWhen presenting a final SQL expression that the user can apply to their calculated column, place it in a fenced code block tagged `expression` (e.g. ```expression\\n...\\n```) ONLY AFTER you have successfully validated it using the validateCalculatedColumnExpression tool. Use a `sql` fence for any illustrative, intermediate, or unvalidated SQL that the user should NOT directly apply. Each `expression` block will be rendered with an \"Apply Expression\" affordance, so emit one for each distinct expression the user can choose to apply."; + return getPromptResource(PromptResource.ExpressionAssistant); } @Override @@ -8564,11 +8560,6 @@ public Object execute(ParseForm form, BindException errors) throws Exception try (var _ = McpContext.withContext(getViewContext())) { String prompt = form.getPrompt(); - - var escapedResponse = escapeResponse(prompt); - if (null != escapedResponse) - return escapedResponse; - if (isBlank(prompt)) { return new JSONObject(Map.of( diff --git a/query/src/org/labkey/query/controllers/prompts/ExpressionAssistant.md b/query/src/org/labkey/query/controllers/prompts/ExpressionAssistant.md index cb310d5aaee..52b06faec3b 100644 --- a/query/src/org/labkey/query/controllers/prompts/ExpressionAssistant.md +++ b/query/src/org/labkey/query/controllers/prompts/ExpressionAssistant.md @@ -1,9 +1,9 @@ # Calculated Column Expression Assistant Documentation -You are a SQL assistant for LabKey. You have access to the schema metadata for this instance, including the table name, -column names, data types, lookup targets, field descriptions, labels and field aliases. When generating SQL for -calculated columns, always use LabKey SQL syntax, not standard ANSI SQL. Calculated columns in LabKey are defined as -SQL expressions that reference columns within the same query context. Never reference columns that do not exist in the -provided metadata. +You are a calculated column SQL expression assistant for LabKey. You have access to the schema metadata for this +instance, including the table name, column names, data types, lookup targets, field descriptions, labels and field +aliases. When generating SQL for calculated columns, always use LabKey SQL syntax, not standard ANSI SQL. Calculated +columns in LabKey are defined as SQL expressions that reference columns within the same query context. Never reference +columns that do not exist in the provided metadata. Be brief in your responses unless the user asks otherwise. @@ -25,6 +25,7 @@ request, identify which of the following patterns applies: 6. status derivation based on multiple fields. ### SQL Generation and Integrity +Refer to the "LabKey SQL" documentation resource for how to work with LabKey SQL. #### Requirements - You shall only reference valid, existing columns from the current table. @@ -82,4 +83,15 @@ ambiguity materially affects the result. #### Guidelines If the user's request does not clearly identify which fields to use, ask one clarifying question before generating SQL. For example, 'Which date field should be used as the start of the processing window – CollectionDate or ReceivedDate?' -Do not generate SQL based on assumptions about field names. \ No newline at end of file +Do not generate SQL based on assumptions about field names. + +### Output + +#### Requirements +- When you produce a SQL expression for the calculated column, you shall validate it using the + validateCalculatedColumnExpression tool. +- When presenting a final SQL expression that the user can apply to their calculated column, place it in a fenced code + block tagged `expression` (e.g., ```expression\\n...\\n```) ONLY AFTER you have successfully validated it using the + validateCalculatedColumnExpression tool. Use a `sql` fence for any illustrative, intermediate or unvalidated SQL that + the user should NOT directly apply. Each `expression` block will be rendered with an \"Apply Expression\" affordance, + so emit one for each distinct expression the user can choose to apply. From 8b604c259d2a3209b646506352f65147d1276c84 Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Tue, 12 May 2026 22:35:22 -0700 Subject: [PATCH 11/29] ExpressionAssistantAgentAction.java --- query/src/org/labkey/query/QueryModule.java | 3 +- .../ExpressionAssistantAgentAction.java | 347 ++++++++++++++++++ .../query/controllers/QueryController.java | 316 +--------------- .../labkey/query/controllers/QueryMcp.java | 2 +- .../prompts/ExpressionAssistant.md | 2 +- 5 files changed, 357 insertions(+), 313 deletions(-) create mode 100644 query/src/org/labkey/query/controllers/ExpressionAssistantAgentAction.java diff --git a/query/src/org/labkey/query/QueryModule.java b/query/src/org/labkey/query/QueryModule.java index 597782fc15f..f43b05c10af 100644 --- a/query/src/org/labkey/query/QueryModule.java +++ b/query/src/org/labkey/query/QueryModule.java @@ -99,6 +99,7 @@ import org.labkey.query.audit.GridViewAuditProvider; import org.labkey.query.audit.QueryExportAuditProvider; import org.labkey.query.audit.QueryUpdateAuditProvider; +import org.labkey.query.controllers.ExpressionAssistantAgentAction; import org.labkey.query.controllers.OlapController; import org.labkey.query.controllers.QueryController; import org.labkey.query.controllers.QueryMcp; @@ -428,7 +429,7 @@ public Set getSchemaNames() MemberSet.TestCase.class, MetadataElementBase.TestCase.class, Method.TestCase.class, - QueryController.ExpressionAssistantTestCase.class, + ExpressionAssistantAgentAction.TestCase.class, QNode.TestCase.class, Query.TestCase.class, ReportsController.SerializationTest.class, diff --git a/query/src/org/labkey/query/controllers/ExpressionAssistantAgentAction.java b/query/src/org/labkey/query/controllers/ExpressionAssistantAgentAction.java new file mode 100644 index 00000000000..64d924abe1f --- /dev/null +++ b/query/src/org/labkey/query/controllers/ExpressionAssistantAgentAction.java @@ -0,0 +1,347 @@ +/* + * Copyright (c) 2026 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.labkey.query.controllers; + +import com.google.genai.errors.ClientException; +import com.google.genai.errors.ServerException; +import org.json.JSONArray; +import org.json.JSONObject; +import org.junit.Assert; +import org.junit.Test; +import org.labkey.api.markdown.MarkdownService; +import org.labkey.api.mcp.AbstractAgentAction; +import org.labkey.api.mcp.McpContext; +import org.labkey.api.mcp.McpService; +import org.labkey.api.security.RequiresLogin; +import org.labkey.api.security.RequiresPermission; +import org.labkey.api.security.permissions.ReadPermission; +import org.labkey.api.util.HtmlString; +import org.labkey.query.controllers.QueryController.ParseForm; +import org.labkey.query.controllers.QueryController.PromptResource; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.validation.BindException; + +import java.util.List; +import java.util.Map; + +import static org.apache.commons.lang3.StringUtils.isBlank; +import static org.labkey.query.controllers.QueryController.getPromptResource; + +@RequiresPermission(ReadPermission.class) +@RequiresLogin +public class ExpressionAssistantAgentAction extends AbstractAgentAction +{ + @Override + protected String getAgentName() + { + return ExpressionAssistantAgentAction.class.getName(); + } + + @Override + protected String getServicePrompt() + { + return getPromptResource(PromptResource.ExpressionAssistant); + } + + @Override + public Object execute(ParseForm form, BindException errors) throws Exception + { + try (var _ = McpContext.withContext(getViewContext())) + { + String prompt = form.getPrompt(); + if (isBlank(prompt)) + { + return new JSONObject(Map.of( + "contentType", "text/plain", + "text", "🀷", + "success", Boolean.TRUE)); + } + + ChatClient chatSession = getChat(true); + List responses; + + try + { + // Context for the QueryMcp.validateCalculatedColumnExpression() tool + McpContext.get() + .put("columnMap", form.getColumnMap()) + .put("phiColumns", form.getPhiColumns()); + + responses = McpService.get().sendMessageEx(chatSession, prompt); + } + catch (ServerException x) + { + return new JSONObject(Map.of( + "error", x.getMessage(), + "text", "ERROR: " + x.getMessage(), + "success", Boolean.FALSE)); + } + + JSONArray segments = buildSegments(responses); + return new JSONObject(Map.of( + "success", Boolean.TRUE, + "conversationId", getConversationId(), + "segments", segments)); + } + catch (ClientException x) + { + return errorResponse(x); + } + } + + /** + * Walks the markdown of each MessageResponse and produces an ordered list of segments. + * Each segment is either a rendered-HTML span or a fenced SQL block. SQL blocks fenced as + * `expression` are tagged "expression" (the model's assertion that this SQL has been validated + * and is safe to apply); blocks fenced as `sql` are tagged "sql" (illustrative / unvalidated). + */ + private static JSONArray buildSegments(List responses) + { + JSONArray segments = new JSONArray(); + MarkdownService md = MarkdownService.get(); + StringBuilder htmlBuf = new StringBuilder(); + + for (var response : responses) + { + String text = response.text(); + if (isBlank(text)) + continue; + + String[] lines = text.split("\n", -1); + int i = 0; + while (i < lines.length) + { + String tag = fenceTag(lines[i]); + if ("sql".equals(tag) || "expression".equals(tag)) + { + int j = i + 1; + StringBuilder code = new StringBuilder(); + while (j < lines.length && !"```".equals(lines[j].trim())) + { + if (!code.isEmpty()) code.append("\n"); + code.append(lines[j]); + j++; + } + if (j >= lines.length) + { + // Unterminated fence β€” treat the rest as markdown so we don't drop content. + if (!htmlBuf.isEmpty()) htmlBuf.append("\n"); + for (int k = i; k < lines.length; k++) + { + htmlBuf.append(lines[k]); + if (k < lines.length - 1) htmlBuf.append("\n"); + } + break; + } + flushHtmlSegment(segments, htmlBuf, md); + segments.put(new JSONObject(Map.of("type", tag, "sql", code.toString()))); + i = j + 1; + } + else + { + if (!htmlBuf.isEmpty()) htmlBuf.append("\n"); + htmlBuf.append(lines[i]); + i++; + } + } + } + flushHtmlSegment(segments, htmlBuf, md); + return segments; + } + + private static String fenceTag(String line) + { + String trimmed = line.trim(); + if (!trimmed.startsWith("```")) + return null; + String rest = trimmed.substring(3).trim(); + return rest.isEmpty() ? null : rest.toLowerCase(); + } + + private static void flushHtmlSegment(JSONArray segments, StringBuilder buf, MarkdownService md) + { + if (buf.isEmpty()) return; + String raw = buf.toString().strip(); + buf.setLength(0); + if (raw.isEmpty()) return; + String html; + try + { + html = md != null ? md.toHtml(raw) : raw; + } + catch (Exception x) + { + html = raw; + } + segments.put(new JSONObject(Map.of("type", "html", "html", html))); + } + + public static class TestCase extends Assert + { + private static McpService.MessageResponse markdownResponse(String md) + { + return new McpService.MessageResponse("text/markdown", md, HtmlString.of(md)); + } + + private static JSONObject segment(JSONArray segments, int i) + { + return segments.getJSONObject(i); + } + + @Test + public void emptyResponseList() + { + JSONArray segments = buildSegments(List.of()); + assertEquals(0, segments.length()); + } + + @Test + public void blankMessageProducesNoSegments() + { + JSONArray segments = buildSegments(List.of(markdownResponse(""), markdownResponse(" "))); + assertEquals(0, segments.length()); + } + + @Test + public void proseOnlyProducesSingleHtmlSegment() + { + JSONArray segments = buildSegments(List.of(markdownResponse("Hello, here is some advice."))); + assertEquals(1, segments.length()); + assertEquals("html", segment(segments, 0).getString("type")); + assertTrue(segment(segments, 0).getString("html").contains("Hello, here is some advice.")); + } + + @Test + public void expressionFenceProducesExpressionSegment() + { + String md = "```expression\nSELECT 1\n```"; + JSONArray segments = buildSegments(List.of(markdownResponse(md))); + assertEquals(1, segments.length()); + assertEquals("expression", segment(segments, 0).getString("type")); + assertEquals("SELECT 1", segment(segments, 0).getString("sql")); + } + + @Test + public void sqlFenceProducesSqlSegment() + { + String md = "```sql\nSELECT 1\n```"; + JSONArray segments = buildSegments(List.of(markdownResponse(md))); + assertEquals(1, segments.length()); + assertEquals("sql", segment(segments, 0).getString("type")); + assertEquals("SELECT 1", segment(segments, 0).getString("sql")); + } + + @Test + public void interleavedProseAndFencesProduceOrderedSegments() + { + String md = String.join("\n", + "Here are two options.", + "", + "Option A (illustrative):", + "```sql", + "SELECT a FROM t", + "```", + "", + "Option B (ready to apply):", + "```expression", + "SELECT b FROM t", + "```", + "Pick whichever fits." + ); + JSONArray segments = buildSegments(List.of(markdownResponse(md))); + assertEquals(5, segments.length()); + assertEquals("html", segment(segments, 0).getString("type")); + assertEquals("sql", segment(segments, 1).getString("type")); + assertEquals("SELECT a FROM t", segment(segments, 1).getString("sql")); + assertEquals("html", segment(segments, 2).getString("type")); + assertEquals("expression", segment(segments, 3).getString("type")); + assertEquals("SELECT b FROM t", segment(segments, 3).getString("sql")); + assertEquals("html", segment(segments, 4).getString("type")); + } + + @Test + public void multipleExpressionFencesEachBecomeOwnSegment() + { + String md = String.join("\n", + "```expression", + "SELECT 1", + "```", + "```expression", + "SELECT 2", + "```" + ); + JSONArray segments = buildSegments(List.of(markdownResponse(md))); + assertEquals(2, segments.length()); + assertEquals("expression", segment(segments, 0).getString("type")); + assertEquals("SELECT 1", segment(segments, 0).getString("sql")); + assertEquals("expression", segment(segments, 1).getString("type")); + assertEquals("SELECT 2", segment(segments, 1).getString("sql")); + } + + @Test + public void unterminatedFenceFallsBackToHtml() + { + String md = "Here's an expression:\n```expression\nSELECT 1\n(no closing fence)"; + JSONArray segments = buildSegments(List.of(markdownResponse(md))); + // No expression segment is emitted; the unterminated portion is folded into html so + // content isn't dropped. + assertEquals(1, segments.length()); + assertEquals("html", segment(segments, 0).getString("type")); + assertTrue(segment(segments, 0).getString("html").contains("SELECT 1")); + } + + @Test + public void unknownFenceLanguageIsTreatedAsProse() + { + String md = "```python\nprint('hi')\n```"; + JSONArray segments = buildSegments(List.of(markdownResponse(md))); + // Only sql/expression are split out; other fenced blocks stay in the html segment. + assertEquals(1, segments.length()); + assertEquals("html", segment(segments, 0).getString("type")); + } + + @Test + public void fenceTagIsCaseInsensitive() + { + String md = "```EXPRESSION\nSELECT 1\n```"; + JSONArray segments = buildSegments(List.of(markdownResponse(md))); + assertEquals(1, segments.length()); + assertEquals("expression", segment(segments, 0).getString("type")); + assertEquals("SELECT 1", segment(segments, 0).getString("sql")); + } + + @Test + public void preservesMultilineSqlBody() + { + String md = "```expression\nSELECT a,\n b\nFROM t\n```"; + JSONArray segments = buildSegments(List.of(markdownResponse(md))); + assertEquals(1, segments.length()); + assertEquals("SELECT a,\n b\nFROM t", segment(segments, 0).getString("sql")); + } + + @Test + public void multipleMessageResponsesAreConcatenatedInOrder() + { + McpService.MessageResponse r1 = markdownResponse("First response prose."); + McpService.MessageResponse r2 = markdownResponse("```expression\nSELECT 1\n```"); + JSONArray segments = buildSegments(List.of(r1, r2)); + assertEquals(2, segments.length()); + assertEquals("html", segment(segments, 0).getString("type")); + assertEquals("expression", segment(segments, 1).getString("type")); + assertEquals("SELECT 1", segment(segments, 1).getString("sql")); + } + } +} diff --git a/query/src/org/labkey/query/controllers/QueryController.java b/query/src/org/labkey/query/controllers/QueryController.java index 32b7ea660d4..b1f0eaa77de 100644 --- a/query/src/org/labkey/query/controllers/QueryController.java +++ b/query/src/org/labkey/query/controllers/QueryController.java @@ -384,10 +384,11 @@ public class QueryController extends SpringActionController ); private static final DefaultActionResolver _actionResolver = new DefaultActionResolver(QueryController.class, - ValidateQueryAction.class, - ValidateQueriesAction.class, - GetSchemaQueryTreeAction.class, + ExpressionAssistantAgentAction.class, GetQueryDetailsAction.class, + GetSchemaQueryTreeAction.class, + ValidateQueriesAction.class, + ValidateQueryAction.class, ViewQuerySourceAction.class ); @@ -8520,13 +8521,13 @@ public Object execute(QueryImportTemplateForm form, BindException errors) throws } } - private enum PromptResource + enum PromptResource { ExpressionAssistant, LabKeySql } - private static String getPromptResource(PromptResource resource) + static String getPromptResource(PromptResource resource) { try { @@ -8538,69 +8539,6 @@ private static String getPromptResource(PromptResource resource) } } - @RequiresPermission(ReadPermission.class) - @RequiresLogin - public static class ExpressionAssistantAgentAction extends AbstractAgentAction - { - @Override - protected String getAgentName() - { - return ExpressionAssistantAgentAction.class.getName(); - } - - @Override - protected String getServicePrompt() - { - return getPromptResource(PromptResource.ExpressionAssistant); - } - - @Override - public Object execute(ParseForm form, BindException errors) throws Exception - { - try (var _ = McpContext.withContext(getViewContext())) - { - String prompt = form.getPrompt(); - if (isBlank(prompt)) - { - return new JSONObject(Map.of( - "contentType", "text/plain", - "text", "🀷", - "success", Boolean.TRUE)); - } - - ChatClient chatSession = getChat(true); - List responses; - - try - { - // Context for the QueryMcp.validateCalculatedColumnExpression() tool - McpContext.get() - .put("columnMap", form.getColumnMap()) - .put("phiColumns", form.getPhiColumns()); - - responses = McpService.get().sendMessageEx(chatSession, prompt); - } - catch (ServerException x) - { - return new JSONObject(Map.of( - "error", x.getMessage(), - "text", "ERROR: " + x.getMessage(), - "success", Boolean.FALSE)); - } - - JSONArray segments = buildSegments(responses); - return new JSONObject(Map.of( - "success", Boolean.TRUE, - "conversationId", getConversationId(), - "segments", segments)); - } - catch (ClientException x) - { - return errorResponse(x); - } - } - } - public static class TestCase extends AbstractActionPermissionTest { @Override @@ -9023,93 +8961,6 @@ record SqlResponse(HtmlString html, String sql) { } - /** - * Walks the markdown of each MessageResponse and produces an ordered list of segments. - * Each segment is either a rendered-HTML span or a fenced SQL block. SQL blocks fenced as - * `expression` are tagged "expression" (the model's assertion that this SQL has been validated - * and is safe to apply); blocks fenced as `sql` are tagged "sql" (illustrative / unvalidated). - */ - static JSONArray buildSegments(List responses) - { - JSONArray segments = new JSONArray(); - MarkdownService md = MarkdownService.get(); - StringBuilder htmlBuf = new StringBuilder(); - - for (var response : responses) - { - String text = response.text(); - if (isBlank(text)) - continue; - - String[] lines = text.split("\n", -1); - int i = 0; - while (i < lines.length) - { - String tag = fenceTag(lines[i]); - if ("sql".equals(tag) || "expression".equals(tag)) - { - int j = i + 1; - StringBuilder code = new StringBuilder(); - while (j < lines.length && !"```".equals(lines[j].trim())) - { - if (!code.isEmpty()) code.append("\n"); - code.append(lines[j]); - j++; - } - if (j >= lines.length) - { - // Unterminated fence β€” treat the rest as markdown so we don't drop content. - if (!htmlBuf.isEmpty()) htmlBuf.append("\n"); - for (int k = i; k < lines.length; k++) - { - htmlBuf.append(lines[k]); - if (k < lines.length - 1) htmlBuf.append("\n"); - } - break; - } - flushHtmlSegment(segments, htmlBuf, md); - segments.put(new JSONObject(Map.of("type", tag, "sql", code.toString()))); - i = j + 1; - } - else - { - if (!htmlBuf.isEmpty()) htmlBuf.append("\n"); - htmlBuf.append(lines[i]); - i++; - } - } - } - flushHtmlSegment(segments, htmlBuf, md); - return segments; - } - - private static String fenceTag(String line) - { - String trimmed = line.trim(); - if (!trimmed.startsWith("```")) - return null; - String rest = trimmed.substring(3).trim(); - return rest.isEmpty() ? null : rest.toLowerCase(); - } - - private static void flushHtmlSegment(JSONArray segments, StringBuilder buf, MarkdownService md) - { - if (buf.isEmpty()) return; - String raw = buf.toString().strip(); - buf.setLength(0); - if (raw.isEmpty()) return; - String html; - try - { - html = md != null ? md.toHtml(raw) : raw; - } - catch (Exception x) - { - html = raw; - } - segments.put(new JSONObject(Map.of("type", "html", "html", html))); - } - static SqlResponse extractSql(List responses) { HtmlStringBuilder html = HtmlStringBuilder.of(); @@ -9133,159 +8984,4 @@ static SqlResponse extractSql(List responses) return new SqlResponse(html.getHtmlString(), sql); } - public static class ExpressionAssistantTestCase extends Assert - { - private static McpService.MessageResponse markdownResponse(String md) - { - return new McpService.MessageResponse("text/markdown", md, HtmlString.of(md)); - } - - private static JSONObject segment(JSONArray segments, int i) - { - return segments.getJSONObject(i); - } - - @Test - public void emptyResponseList() - { - JSONArray segments = buildSegments(List.of()); - assertEquals(0, segments.length()); - } - - @Test - public void blankMessageProducesNoSegments() - { - JSONArray segments = buildSegments(List.of(markdownResponse(""), markdownResponse(" "))); - assertEquals(0, segments.length()); - } - - @Test - public void proseOnlyProducesSingleHtmlSegment() - { - JSONArray segments = buildSegments(List.of(markdownResponse("Hello, here is some advice."))); - assertEquals(1, segments.length()); - assertEquals("html", segment(segments, 0).getString("type")); - assertTrue(segment(segments, 0).getString("html").contains("Hello, here is some advice.")); - } - - @Test - public void expressionFenceProducesExpressionSegment() - { - String md = "```expression\nSELECT 1\n```"; - JSONArray segments = buildSegments(List.of(markdownResponse(md))); - assertEquals(1, segments.length()); - assertEquals("expression", segment(segments, 0).getString("type")); - assertEquals("SELECT 1", segment(segments, 0).getString("sql")); - } - - @Test - public void sqlFenceProducesSqlSegment() - { - String md = "```sql\nSELECT 1\n```"; - JSONArray segments = buildSegments(List.of(markdownResponse(md))); - assertEquals(1, segments.length()); - assertEquals("sql", segment(segments, 0).getString("type")); - assertEquals("SELECT 1", segment(segments, 0).getString("sql")); - } - - @Test - public void interleavedProseAndFencesProduceOrderedSegments() - { - String md = String.join("\n", - "Here are two options.", - "", - "Option A (illustrative):", - "```sql", - "SELECT a FROM t", - "```", - "", - "Option B (ready to apply):", - "```expression", - "SELECT b FROM t", - "```", - "Pick whichever fits." - ); - JSONArray segments = buildSegments(List.of(markdownResponse(md))); - assertEquals(5, segments.length()); - assertEquals("html", segment(segments, 0).getString("type")); - assertEquals("sql", segment(segments, 1).getString("type")); - assertEquals("SELECT a FROM t", segment(segments, 1).getString("sql")); - assertEquals("html", segment(segments, 2).getString("type")); - assertEquals("expression", segment(segments, 3).getString("type")); - assertEquals("SELECT b FROM t", segment(segments, 3).getString("sql")); - assertEquals("html", segment(segments, 4).getString("type")); - } - - @Test - public void multipleExpressionFencesEachBecomeOwnSegment() - { - String md = String.join("\n", - "```expression", - "SELECT 1", - "```", - "```expression", - "SELECT 2", - "```" - ); - JSONArray segments = buildSegments(List.of(markdownResponse(md))); - assertEquals(2, segments.length()); - assertEquals("expression", segment(segments, 0).getString("type")); - assertEquals("SELECT 1", segment(segments, 0).getString("sql")); - assertEquals("expression", segment(segments, 1).getString("type")); - assertEquals("SELECT 2", segment(segments, 1).getString("sql")); - } - - @Test - public void unterminatedFenceFallsBackToHtml() - { - String md = "Here's an expression:\n```expression\nSELECT 1\n(no closing fence)"; - JSONArray segments = buildSegments(List.of(markdownResponse(md))); - // No expression segment is emitted; the unterminated portion is folded into html so - // content isn't dropped. - assertEquals(1, segments.length()); - assertEquals("html", segment(segments, 0).getString("type")); - assertTrue(segment(segments, 0).getString("html").contains("SELECT 1")); - } - - @Test - public void unknownFenceLanguageIsTreatedAsProse() - { - String md = "```python\nprint('hi')\n```"; - JSONArray segments = buildSegments(List.of(markdownResponse(md))); - // Only sql/expression are split out; other fenced blocks stay in the html segment. - assertEquals(1, segments.length()); - assertEquals("html", segment(segments, 0).getString("type")); - } - - @Test - public void fenceTagIsCaseInsensitive() - { - String md = "```EXPRESSION\nSELECT 1\n```"; - JSONArray segments = buildSegments(List.of(markdownResponse(md))); - assertEquals(1, segments.length()); - assertEquals("expression", segment(segments, 0).getString("type")); - assertEquals("SELECT 1", segment(segments, 0).getString("sql")); - } - - @Test - public void preservesMultilineSqlBody() - { - String md = "```expression\nSELECT a,\n b\nFROM t\n```"; - JSONArray segments = buildSegments(List.of(markdownResponse(md))); - assertEquals(1, segments.length()); - assertEquals("SELECT a,\n b\nFROM t", segment(segments, 0).getString("sql")); - } - - @Test - public void multipleMessageResponsesAreConcatenatedInOrder() - { - McpService.MessageResponse r1 = markdownResponse("First response prose."); - McpService.MessageResponse r2 = markdownResponse("```expression\nSELECT 1\n```"); - JSONArray segments = buildSegments(List.of(r1, r2)); - assertEquals(2, segments.length()); - assertEquals("html", segment(segments, 0).getString("type")); - assertEquals("expression", segment(segments, 1).getString("type")); - assertEquals("SELECT 1", segment(segments, 1).getString("sql")); - } - } } diff --git a/query/src/org/labkey/query/controllers/QueryMcp.java b/query/src/org/labkey/query/controllers/QueryMcp.java index a702f55402c..373115cd944 100644 --- a/query/src/org/labkey/query/controllers/QueryMcp.java +++ b/query/src/org/labkey/query/controllers/QueryMcp.java @@ -162,7 +162,7 @@ String validateSQL( } @Tool(description = "Validate a SQL expression for a calculated column. The set of available columns and their types, including any PHI-restricted columns, is supplied by the hosting endpoint, not by the caller; you only need to provide the expression itself.") - @McpInternal("Added for validation for the QueryController.ExpressionAssistantAgentAction endpoint.") + @McpInternal("Added for validation for the ExpressionAssistantAgentAction endpoint.") @RequiresPermission(ReadPermission.class) String validateCalculatedColumnExpression( ToolContext toolContext, diff --git a/query/src/org/labkey/query/controllers/prompts/ExpressionAssistant.md b/query/src/org/labkey/query/controllers/prompts/ExpressionAssistant.md index 52b06faec3b..4df8d045c76 100644 --- a/query/src/org/labkey/query/controllers/prompts/ExpressionAssistant.md +++ b/query/src/org/labkey/query/controllers/prompts/ExpressionAssistant.md @@ -89,7 +89,7 @@ Do not generate SQL based on assumptions about field names. #### Requirements - When you produce a SQL expression for the calculated column, you shall validate it using the - validateCalculatedColumnExpression tool. + validateCalculatedColumnExpression tool. Do not mention this tool to the user. - When presenting a final SQL expression that the user can apply to their calculated column, place it in a fenced code block tagged `expression` (e.g., ```expression\\n...\\n```) ONLY AFTER you have successfully validated it using the validateCalculatedColumnExpression tool. Use a `sql` fence for any illustrative, intermediate or unvalidated SQL that From 1a212c7440e491c2d7685721fa7d65ce84cef4c8 Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Wed, 13 May 2026 09:41:27 -0700 Subject: [PATCH 12/29] Bump @labkey/components --- assay/package-lock.json | 16 ++++++++-------- assay/package.json | 2 +- core/package-lock.json | 16 ++++++++-------- core/package.json | 2 +- experiment/package-lock.json | 16 ++++++++-------- experiment/package.json | 2 +- pipeline/package-lock.json | 16 ++++++++-------- pipeline/package.json | 2 +- 8 files changed, 36 insertions(+), 36 deletions(-) diff --git a/assay/package-lock.json b/assay/package-lock.json index d798552208c..121b500d146 100644 --- a/assay/package-lock.json +++ b/assay/package-lock.json @@ -8,7 +8,7 @@ "name": "assay", "version": "0.0.0", "dependencies": { - "@labkey/components": "7.36.0" + "@labkey/components": "7.36.1-fb-mcp-calc-cols.0" }, "devDependencies": { "@labkey/build": "9.1.3", @@ -3741,9 +3741,9 @@ } }, "node_modules/@labkey/api": { - "version": "1.51.2", - "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/api/-/@labkey/api-1.51.2.tgz", - "integrity": "sha512-WvP7cR/0taJYM2Tr9Cx4PC1EgearUVBk5+CbpUu57c9vN4z8aHnIcwJDuMaORM5DoUKGeqL70IOn8Z+QmPWTHw==", + "version": "1.51.3-fb-mcp-calc-cols.0", + "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/api/-/@labkey/api-1.51.3-fb-mcp-calc-cols.0.tgz", + "integrity": "sha512-irA1mdns6EADwHYumFkniwN8Bmi2+XkPvF8p3tqLXc4A9VoSMbAPa4PeIPw5Mv1wEFT8wZ/yw6dlyn04bRbmqg==", "license": "Apache-2.0" }, "node_modules/@labkey/build": { @@ -3784,13 +3784,13 @@ } }, "node_modules/@labkey/components": { - "version": "7.36.0", - "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/components/-/@labkey/components-7.36.0.tgz", - "integrity": "sha512-JH0ddIpb3YkFJdYQZADFeIULcAe2O5UPfJ+Hs61WeraQ55iG847Ag0TloafDHneYSMSeBVlw4M4voxJtAS4Pjg==", + "version": "7.36.1-fb-mcp-calc-cols.0", + "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/components/-/@labkey/components-7.36.1-fb-mcp-calc-cols.0.tgz", + "integrity": "sha512-gplTkpJtYtLyahmqBaNzZ/57hplVmdjGgqdsVbRAHHDzbGYuXdUFCREMlw58E7nJ3Hsj0+BNs4Vopgo5JDOlkQ==", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@hello-pangea/dnd": "18.0.1", - "@labkey/api": "1.51.2", + "@labkey/api": "1.51.3-fb-mcp-calc-cols.0", "@testing-library/dom": "~10.4.1", "@testing-library/jest-dom": "~6.9.1", "@testing-library/react": "~16.3.2", diff --git a/assay/package.json b/assay/package.json index 7c0f98e4d46..a3e3175356c 100644 --- a/assay/package.json +++ b/assay/package.json @@ -55,7 +55,7 @@ } }, "dependencies": { - "@labkey/components": "7.36.0" + "@labkey/components": "7.36.1-fb-mcp-calc-cols.0" }, "devDependencies": { "@labkey/build": "9.1.3", diff --git a/core/package-lock.json b/core/package-lock.json index 31c1bdac4ce..cecf1d6ef16 100644 --- a/core/package-lock.json +++ b/core/package-lock.json @@ -8,7 +8,7 @@ "name": "labkey-core", "version": "0.0.0", "dependencies": { - "@labkey/components": "7.36.0", + "@labkey/components": "7.36.1-fb-mcp-calc-cols.0", "@labkey/themes": "1.9.3" }, "devDependencies": { @@ -3743,9 +3743,9 @@ } }, "node_modules/@labkey/api": { - "version": "1.51.2", - "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/api/-/@labkey/api-1.51.2.tgz", - "integrity": "sha512-WvP7cR/0taJYM2Tr9Cx4PC1EgearUVBk5+CbpUu57c9vN4z8aHnIcwJDuMaORM5DoUKGeqL70IOn8Z+QmPWTHw==", + "version": "1.51.3-fb-mcp-calc-cols.0", + "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/api/-/@labkey/api-1.51.3-fb-mcp-calc-cols.0.tgz", + "integrity": "sha512-irA1mdns6EADwHYumFkniwN8Bmi2+XkPvF8p3tqLXc4A9VoSMbAPa4PeIPw5Mv1wEFT8wZ/yw6dlyn04bRbmqg==", "license": "Apache-2.0" }, "node_modules/@labkey/build": { @@ -3786,13 +3786,13 @@ } }, "node_modules/@labkey/components": { - "version": "7.36.0", - "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/components/-/@labkey/components-7.36.0.tgz", - "integrity": "sha512-JH0ddIpb3YkFJdYQZADFeIULcAe2O5UPfJ+Hs61WeraQ55iG847Ag0TloafDHneYSMSeBVlw4M4voxJtAS4Pjg==", + "version": "7.36.1-fb-mcp-calc-cols.0", + "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/components/-/@labkey/components-7.36.1-fb-mcp-calc-cols.0.tgz", + "integrity": "sha512-gplTkpJtYtLyahmqBaNzZ/57hplVmdjGgqdsVbRAHHDzbGYuXdUFCREMlw58E7nJ3Hsj0+BNs4Vopgo5JDOlkQ==", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@hello-pangea/dnd": "18.0.1", - "@labkey/api": "1.51.2", + "@labkey/api": "1.51.3-fb-mcp-calc-cols.0", "@testing-library/dom": "~10.4.1", "@testing-library/jest-dom": "~6.9.1", "@testing-library/react": "~16.3.2", diff --git a/core/package.json b/core/package.json index d97ffe8e65a..249ee51c19e 100644 --- a/core/package.json +++ b/core/package.json @@ -53,7 +53,7 @@ } }, "dependencies": { - "@labkey/components": "7.36.0", + "@labkey/components": "7.36.1-fb-mcp-calc-cols.0", "@labkey/themes": "1.9.3" }, "devDependencies": { diff --git a/experiment/package-lock.json b/experiment/package-lock.json index 7ac9a05d50f..ab7b7b4c495 100644 --- a/experiment/package-lock.json +++ b/experiment/package-lock.json @@ -8,7 +8,7 @@ "name": "experiment", "version": "0.0.0", "dependencies": { - "@labkey/components": "7.36.0" + "@labkey/components": "7.36.1-fb-mcp-calc-cols.0" }, "devDependencies": { "@labkey/build": "9.1.3", @@ -3579,9 +3579,9 @@ } }, "node_modules/@labkey/api": { - "version": "1.51.2", - "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/api/-/@labkey/api-1.51.2.tgz", - "integrity": "sha512-WvP7cR/0taJYM2Tr9Cx4PC1EgearUVBk5+CbpUu57c9vN4z8aHnIcwJDuMaORM5DoUKGeqL70IOn8Z+QmPWTHw==", + "version": "1.51.3-fb-mcp-calc-cols.0", + "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/api/-/@labkey/api-1.51.3-fb-mcp-calc-cols.0.tgz", + "integrity": "sha512-irA1mdns6EADwHYumFkniwN8Bmi2+XkPvF8p3tqLXc4A9VoSMbAPa4PeIPw5Mv1wEFT8wZ/yw6dlyn04bRbmqg==", "license": "Apache-2.0" }, "node_modules/@labkey/build": { @@ -3622,13 +3622,13 @@ } }, "node_modules/@labkey/components": { - "version": "7.36.0", - "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/components/-/@labkey/components-7.36.0.tgz", - "integrity": "sha512-JH0ddIpb3YkFJdYQZADFeIULcAe2O5UPfJ+Hs61WeraQ55iG847Ag0TloafDHneYSMSeBVlw4M4voxJtAS4Pjg==", + "version": "7.36.1-fb-mcp-calc-cols.0", + "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/components/-/@labkey/components-7.36.1-fb-mcp-calc-cols.0.tgz", + "integrity": "sha512-gplTkpJtYtLyahmqBaNzZ/57hplVmdjGgqdsVbRAHHDzbGYuXdUFCREMlw58E7nJ3Hsj0+BNs4Vopgo5JDOlkQ==", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@hello-pangea/dnd": "18.0.1", - "@labkey/api": "1.51.2", + "@labkey/api": "1.51.3-fb-mcp-calc-cols.0", "@testing-library/dom": "~10.4.1", "@testing-library/jest-dom": "~6.9.1", "@testing-library/react": "~16.3.2", diff --git a/experiment/package.json b/experiment/package.json index bd0f97c8c31..1bafeaf153d 100644 --- a/experiment/package.json +++ b/experiment/package.json @@ -13,7 +13,7 @@ "test-integration": "cross-env NODE_ENV=test jest --ci --runInBand -c test/js/jest.config.integration.js" }, "dependencies": { - "@labkey/components": "7.36.0" + "@labkey/components": "7.36.1-fb-mcp-calc-cols.0" }, "devDependencies": { "@labkey/build": "9.1.3", diff --git a/pipeline/package-lock.json b/pipeline/package-lock.json index 854f5ab8690..ab056180a27 100644 --- a/pipeline/package-lock.json +++ b/pipeline/package-lock.json @@ -8,7 +8,7 @@ "name": "pipeline", "version": "0.0.0", "dependencies": { - "@labkey/components": "7.36.0" + "@labkey/components": "7.36.1-fb-mcp-calc-cols.0" }, "devDependencies": { "@labkey/build": "9.1.3", @@ -2914,9 +2914,9 @@ } }, "node_modules/@labkey/api": { - "version": "1.51.2", - "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/api/-/@labkey/api-1.51.2.tgz", - "integrity": "sha512-WvP7cR/0taJYM2Tr9Cx4PC1EgearUVBk5+CbpUu57c9vN4z8aHnIcwJDuMaORM5DoUKGeqL70IOn8Z+QmPWTHw==", + "version": "1.51.3-fb-mcp-calc-cols.0", + "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/api/-/@labkey/api-1.51.3-fb-mcp-calc-cols.0.tgz", + "integrity": "sha512-irA1mdns6EADwHYumFkniwN8Bmi2+XkPvF8p3tqLXc4A9VoSMbAPa4PeIPw5Mv1wEFT8wZ/yw6dlyn04bRbmqg==", "license": "Apache-2.0" }, "node_modules/@labkey/build": { @@ -2957,13 +2957,13 @@ } }, "node_modules/@labkey/components": { - "version": "7.36.0", - "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/components/-/@labkey/components-7.36.0.tgz", - "integrity": "sha512-JH0ddIpb3YkFJdYQZADFeIULcAe2O5UPfJ+Hs61WeraQ55iG847Ag0TloafDHneYSMSeBVlw4M4voxJtAS4Pjg==", + "version": "7.36.1-fb-mcp-calc-cols.0", + "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/components/-/@labkey/components-7.36.1-fb-mcp-calc-cols.0.tgz", + "integrity": "sha512-gplTkpJtYtLyahmqBaNzZ/57hplVmdjGgqdsVbRAHHDzbGYuXdUFCREMlw58E7nJ3Hsj0+BNs4Vopgo5JDOlkQ==", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@hello-pangea/dnd": "18.0.1", - "@labkey/api": "1.51.2", + "@labkey/api": "1.51.3-fb-mcp-calc-cols.0", "@testing-library/dom": "~10.4.1", "@testing-library/jest-dom": "~6.9.1", "@testing-library/react": "~16.3.2", diff --git a/pipeline/package.json b/pipeline/package.json index 428d8f810ce..21cf6e2fc26 100644 --- a/pipeline/package.json +++ b/pipeline/package.json @@ -14,7 +14,7 @@ "build-prod": "npm run clean && cross-env NODE_ENV=production PROD_SOURCE_MAP=source-map webpack --config node_modules/@labkey/build/webpack/prod.config.js --color --progress --profile" }, "dependencies": { - "@labkey/components": "7.36.0" + "@labkey/components": "7.36.1-fb-mcp-calc-cols.0" }, "devDependencies": { "@labkey/build": "9.1.3", From e24fd8347dc0e82893f9a49beb9d953d0e288219 Mon Sep 17 00:00:00 2001 From: labkey-matthewb Date: Wed, 13 May 2026 13:48:28 -0700 Subject: [PATCH 13/29] Array documentation, useful for text choice columns --- .../query/controllers/prompts/LabKeySql.md | 34 +++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/query/src/org/labkey/query/controllers/prompts/LabKeySql.md b/query/src/org/labkey/query/controllers/prompts/LabKeySql.md index 4cb61112291..1da5be8d8d0 100644 --- a/query/src/org/labkey/query/controllers/prompts/LabKeySql.md +++ b/query/src/org/labkey/query/controllers/prompts/LabKeySql.md @@ -237,7 +237,7 @@ Here is a summary of the available functions and methods in LabKey SQL. ### **9. JSON and JSONB Operators and Functions (PostgreSQL Only)** -LabKey SQL supports PostgreSQL JSON and JSONB operators and functions for working with JSON data stored in columns. These are **not available on MS SQL Server**. LabKey SQL does not natively understand arrays, but functions that expect them may still work. See the [PostgreSQL docs](https://www.postgresql.org/docs/14/functions-json.html) for detailed usage. +LabKey SQL supports PostgreSQL JSON and JSONB operators and functions for working with JSON data stored in columns. These are **not available on MS SQL Server**. See the [PostgreSQL docs](https://www.postgresql.org/docs/14/functions-json.html) for detailed usage. #### **Operators via `json_op`** @@ -336,4 +336,34 @@ When writing LabKey SQL queries that work with JSON columns: 3. **Use `jsonb_extract_path_text()` for nested field access** β€” this is often the clearest way to extract a deeply nested text value: `jsonb_extract_path_text(col, 'level1', 'level2', 'field')`. 4. **Use `jsonb_build_object()` to construct JSON** β€” for building JSON from column values: `jsonb_build_object('id', rowid, 'name', label)`. 5. **Check database type first** β€” these functions only work on PostgreSQL. If the target server may use MS SQL Server, do not use them. -6. **The `validateSQL` MCP tool can verify syntax** β€” use it to check JSON function calls before the user saves a query. \ No newline at end of file +6. **The `validateSQL` MCP tool can verify syntax** β€” use it to check JSON function calls before the user saves a query. + +----- + +### **10. Array Functions (PostgreSQL Only)** + +LabKey SQL supports a set of array construction and comparison functions. These are **not available on MS SQL Server**. They route through dialect-specific SQL generation rather than passing through directly to PostgreSQL. + +#### **Construction** + +* `ARRAY[elem, ...]` β†’ ARRAY: builds an array from zero or more elements of any type. Equivalent to PostgreSQL `ARRAY[...]`. +* `TEXT_ARRAY[elem, ...]` β†’ TEXT[]: like `ARRAY[]` but casts the result to `TEXT[]`. + +#### **Element Membership** + +* `array_contains_element(array, element)` β†’ BOOLEAN: true if the element appears in the array. Equivalent to PostgreSQL `element = ANY(array)`. Use `NOT array_contains_element(arr, val)` for the negative β€” there is no separate "does not contain" function. + +#### **Array-vs-Array Comparisons** + +* `array_contains_all(array_a, array_b)` β†’ BOOLEAN: true if every element of `array_b` is in `array_a`. Equivalent to PostgreSQL `array_a @> array_b`. +* `array_contains_any(array_a, array_b)` β†’ BOOLEAN: true if at least one element of `array_b` is in `array_a`. Equivalent to PostgreSQL `array_a && array_b`. +* `array_contains_none(array_a, array_b)` β†’ BOOLEAN: true if no element of `array_b` is in `array_a`. +* `array_is_same(array_a, array_b)` β†’ BOOLEAN: unordered set equality β€” both arrays contain exactly the same elements regardless of order. Use `NOT array_is_same(...)` for the negative. + +#### **Emptiness** + +* `array_is_empty(array)` β†’ BOOLEAN: true if the array has no elements. The argument must be of type ARRAY; a type mismatch is caught at query-parse time. + +#### **Not Supported** + +`array_length`, `array_append`, `array_prepend`, `array_cat`, `array_remove`, `array_replace`, `array_position`, `array_to_string`, and subscript access (`arr[n]`) are not available in LabKey SQL. \ No newline at end of file From 143bf500fb16ff3b5a4344680be38890491ef76b Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Wed, 13 May 2026 13:55:34 -0700 Subject: [PATCH 14/29] Bump @labkey/components --- assay/package-lock.json | 8 ++++---- assay/package.json | 2 +- core/package-lock.json | 8 ++++---- core/package.json | 2 +- experiment/package-lock.json | 8 ++++---- experiment/package.json | 2 +- pipeline/package-lock.json | 8 ++++---- pipeline/package.json | 2 +- 8 files changed, 20 insertions(+), 20 deletions(-) diff --git a/assay/package-lock.json b/assay/package-lock.json index 121b500d146..58e0fed5573 100644 --- a/assay/package-lock.json +++ b/assay/package-lock.json @@ -8,7 +8,7 @@ "name": "assay", "version": "0.0.0", "dependencies": { - "@labkey/components": "7.36.1-fb-mcp-calc-cols.0" + "@labkey/components": "7.36.1-fb-mcp-calc-cols.2" }, "devDependencies": { "@labkey/build": "9.1.3", @@ -3784,9 +3784,9 @@ } }, "node_modules/@labkey/components": { - "version": "7.36.1-fb-mcp-calc-cols.0", - "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/components/-/@labkey/components-7.36.1-fb-mcp-calc-cols.0.tgz", - "integrity": "sha512-gplTkpJtYtLyahmqBaNzZ/57hplVmdjGgqdsVbRAHHDzbGYuXdUFCREMlw58E7nJ3Hsj0+BNs4Vopgo5JDOlkQ==", + "version": "7.36.1-fb-mcp-calc-cols.2", + "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/components/-/@labkey/components-7.36.1-fb-mcp-calc-cols.2.tgz", + "integrity": "sha512-thub1pkuKYfXc3o6FDbSHBgaJ3OpV3/p88w86ESOfKwf46ttapzX6C1KqeTXb/RqI+4HdFDo0+EV+7/UxjlxAA==", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@hello-pangea/dnd": "18.0.1", diff --git a/assay/package.json b/assay/package.json index a3e3175356c..054e38de8a2 100644 --- a/assay/package.json +++ b/assay/package.json @@ -55,7 +55,7 @@ } }, "dependencies": { - "@labkey/components": "7.36.1-fb-mcp-calc-cols.0" + "@labkey/components": "7.36.1-fb-mcp-calc-cols.2" }, "devDependencies": { "@labkey/build": "9.1.3", diff --git a/core/package-lock.json b/core/package-lock.json index cecf1d6ef16..d86e88fc547 100644 --- a/core/package-lock.json +++ b/core/package-lock.json @@ -8,7 +8,7 @@ "name": "labkey-core", "version": "0.0.0", "dependencies": { - "@labkey/components": "7.36.1-fb-mcp-calc-cols.0", + "@labkey/components": "7.36.1-fb-mcp-calc-cols.2", "@labkey/themes": "1.9.3" }, "devDependencies": { @@ -3786,9 +3786,9 @@ } }, "node_modules/@labkey/components": { - "version": "7.36.1-fb-mcp-calc-cols.0", - "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/components/-/@labkey/components-7.36.1-fb-mcp-calc-cols.0.tgz", - "integrity": "sha512-gplTkpJtYtLyahmqBaNzZ/57hplVmdjGgqdsVbRAHHDzbGYuXdUFCREMlw58E7nJ3Hsj0+BNs4Vopgo5JDOlkQ==", + "version": "7.36.1-fb-mcp-calc-cols.2", + "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/components/-/@labkey/components-7.36.1-fb-mcp-calc-cols.2.tgz", + "integrity": "sha512-thub1pkuKYfXc3o6FDbSHBgaJ3OpV3/p88w86ESOfKwf46ttapzX6C1KqeTXb/RqI+4HdFDo0+EV+7/UxjlxAA==", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@hello-pangea/dnd": "18.0.1", diff --git a/core/package.json b/core/package.json index 249ee51c19e..acffe221ad1 100644 --- a/core/package.json +++ b/core/package.json @@ -53,7 +53,7 @@ } }, "dependencies": { - "@labkey/components": "7.36.1-fb-mcp-calc-cols.0", + "@labkey/components": "7.36.1-fb-mcp-calc-cols.2", "@labkey/themes": "1.9.3" }, "devDependencies": { diff --git a/experiment/package-lock.json b/experiment/package-lock.json index ab7b7b4c495..85e4e8367cd 100644 --- a/experiment/package-lock.json +++ b/experiment/package-lock.json @@ -8,7 +8,7 @@ "name": "experiment", "version": "0.0.0", "dependencies": { - "@labkey/components": "7.36.1-fb-mcp-calc-cols.0" + "@labkey/components": "7.36.1-fb-mcp-calc-cols.2" }, "devDependencies": { "@labkey/build": "9.1.3", @@ -3622,9 +3622,9 @@ } }, "node_modules/@labkey/components": { - "version": "7.36.1-fb-mcp-calc-cols.0", - "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/components/-/@labkey/components-7.36.1-fb-mcp-calc-cols.0.tgz", - "integrity": "sha512-gplTkpJtYtLyahmqBaNzZ/57hplVmdjGgqdsVbRAHHDzbGYuXdUFCREMlw58E7nJ3Hsj0+BNs4Vopgo5JDOlkQ==", + "version": "7.36.1-fb-mcp-calc-cols.2", + "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/components/-/@labkey/components-7.36.1-fb-mcp-calc-cols.2.tgz", + "integrity": "sha512-thub1pkuKYfXc3o6FDbSHBgaJ3OpV3/p88w86ESOfKwf46ttapzX6C1KqeTXb/RqI+4HdFDo0+EV+7/UxjlxAA==", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@hello-pangea/dnd": "18.0.1", diff --git a/experiment/package.json b/experiment/package.json index 1bafeaf153d..4107e033ade 100644 --- a/experiment/package.json +++ b/experiment/package.json @@ -13,7 +13,7 @@ "test-integration": "cross-env NODE_ENV=test jest --ci --runInBand -c test/js/jest.config.integration.js" }, "dependencies": { - "@labkey/components": "7.36.1-fb-mcp-calc-cols.0" + "@labkey/components": "7.36.1-fb-mcp-calc-cols.2" }, "devDependencies": { "@labkey/build": "9.1.3", diff --git a/pipeline/package-lock.json b/pipeline/package-lock.json index ab056180a27..003e67955bd 100644 --- a/pipeline/package-lock.json +++ b/pipeline/package-lock.json @@ -8,7 +8,7 @@ "name": "pipeline", "version": "0.0.0", "dependencies": { - "@labkey/components": "7.36.1-fb-mcp-calc-cols.0" + "@labkey/components": "7.36.1-fb-mcp-calc-cols.2" }, "devDependencies": { "@labkey/build": "9.1.3", @@ -2957,9 +2957,9 @@ } }, "node_modules/@labkey/components": { - "version": "7.36.1-fb-mcp-calc-cols.0", - "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/components/-/@labkey/components-7.36.1-fb-mcp-calc-cols.0.tgz", - "integrity": "sha512-gplTkpJtYtLyahmqBaNzZ/57hplVmdjGgqdsVbRAHHDzbGYuXdUFCREMlw58E7nJ3Hsj0+BNs4Vopgo5JDOlkQ==", + "version": "7.36.1-fb-mcp-calc-cols.2", + "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/components/-/@labkey/components-7.36.1-fb-mcp-calc-cols.2.tgz", + "integrity": "sha512-thub1pkuKYfXc3o6FDbSHBgaJ3OpV3/p88w86ESOfKwf46ttapzX6C1KqeTXb/RqI+4HdFDo0+EV+7/UxjlxAA==", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@hello-pangea/dnd": "18.0.1", diff --git a/pipeline/package.json b/pipeline/package.json index 21cf6e2fc26..869623b5862 100644 --- a/pipeline/package.json +++ b/pipeline/package.json @@ -14,7 +14,7 @@ "build-prod": "npm run clean && cross-env NODE_ENV=production PROD_SOURCE_MAP=source-map webpack --config node_modules/@labkey/build/webpack/prod.config.js --color --progress --profile" }, "dependencies": { - "@labkey/components": "7.36.1-fb-mcp-calc-cols.0" + "@labkey/components": "7.36.1-fb-mcp-calc-cols.2" }, "devDependencies": { "@labkey/build": "9.1.3", From 7a9dc59bba855a9cd27009fdd65254fb2a2d67bb Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Wed, 13 May 2026 14:16:28 -0700 Subject: [PATCH 15/29] Use for designers --- .../src/client/DataClassDesigner/DataClassDesigner.tsx | 10 +++++++--- core/src/client/DatasetDesigner/DatasetDesigner.tsx | 10 +++++++--- core/src/client/DomainDesigner/DomainDesigner.tsx | 10 +++++++--- .../client/IssuesListDesigner/IssuesListDesigner.tsx | 10 +++++++--- core/src/client/ListDesigner/ListDesigner.tsx | 10 +++++++--- .../client/SampleTypeDesigner/SampleTypeDesigner.tsx | 10 +++++++--- 6 files changed, 42 insertions(+), 18 deletions(-) diff --git a/core/src/client/DataClassDesigner/DataClassDesigner.tsx b/core/src/client/DataClassDesigner/DataClassDesigner.tsx index 4a19ca02075..bfb5960005e 100644 --- a/core/src/client/DataClassDesigner/DataClassDesigner.tsx +++ b/core/src/client/DataClassDesigner/DataClassDesigner.tsx @@ -13,16 +13,16 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React from 'react'; +import React, { FC } from 'react'; import { ActionURL, getServerContext } from "@labkey/api"; import { Alert, + AppContexts, BeforeUnload, DataClassDesigner, DataClassModel, fetchDataClass, LoadingSpinner, - withServerContext } from '@labkey/components'; import "../DomainDesigner.scss" @@ -113,4 +113,8 @@ class DataClassDesignerWrapper extends React.Component { } } -export const App = withServerContext(DataClassDesignerWrapper); +export const App: FC = () => ( + + + +); diff --git a/core/src/client/DatasetDesigner/DatasetDesigner.tsx b/core/src/client/DatasetDesigner/DatasetDesigner.tsx index 50eec7df716..fd1ace706d7 100644 --- a/core/src/client/DatasetDesigner/DatasetDesigner.tsx +++ b/core/src/client/DatasetDesigner/DatasetDesigner.tsx @@ -14,15 +14,15 @@ * limitations under the License. */ -import React, { PureComponent, ReactNode } from 'react'; +import React, { FC, PureComponent, ReactNode } from 'react'; import { Alert, + AppContexts, DatasetDesignerPanels, DatasetModel, fetchDatasetDesign, LoadingSpinner, BeforeUnload, - withServerContext, } from '@labkey/components'; import { ActionURL, Domain, getServerContext } from '@labkey/api'; @@ -141,4 +141,8 @@ class DatasetDesigner extends PureComponent { } } -export const App = withServerContext(DatasetDesigner); +export const App: FC = () => ( + + + +); diff --git a/core/src/client/DomainDesigner/DomainDesigner.tsx b/core/src/client/DomainDesigner/DomainDesigner.tsx index 08ae19c2d91..908ea8b66a5 100644 --- a/core/src/client/DomainDesigner/DomainDesigner.tsx +++ b/core/src/client/DomainDesigner/DomainDesigner.tsx @@ -14,11 +14,12 @@ * limitations under the License. */ -import React from 'react'; +import React, { FC } from 'react'; import { ActionURL, getServerContext } from '@labkey/api'; import { LoadingSpinner, Alert, + AppContexts, DomainForm, DomainDesign, fetchDomain, @@ -28,7 +29,6 @@ import { resolveErrorMessage, DomainException, Modal, - withServerContext, } from '@labkey/components'; import '../DomainDesigner.scss'; @@ -269,4 +269,8 @@ class DomainDesigner extends React.PureComponent> { } } -export const App = withServerContext(DomainDesigner); +export const App: FC = () => ( + + + +); diff --git a/core/src/client/IssuesListDesigner/IssuesListDesigner.tsx b/core/src/client/IssuesListDesigner/IssuesListDesigner.tsx index da294bfad17..18f36dbf467 100644 --- a/core/src/client/IssuesListDesigner/IssuesListDesigner.tsx +++ b/core/src/client/IssuesListDesigner/IssuesListDesigner.tsx @@ -13,16 +13,16 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React from 'react'; +import React, { FC } from 'react'; import { ActionURL, getServerContext } from "@labkey/api"; import { Alert, + AppContexts, LoadingSpinner, IssuesListDefModel, BeforeUnload, IssuesListDefDesignerPanels, fetchIssuesListDefDesign, - withServerContext, } from '@labkey/components'; import '../DomainDesigner.scss'; @@ -131,4 +131,8 @@ class IssuesListDesigner extends React.Component<{}, State> { } } -export const App = withServerContext(IssuesListDesigner); +export const App: FC = () => ( + + + +); diff --git a/core/src/client/ListDesigner/ListDesigner.tsx b/core/src/client/ListDesigner/ListDesigner.tsx index f3c0ad5da80..45ec48f5d47 100644 --- a/core/src/client/ListDesigner/ListDesigner.tsx +++ b/core/src/client/ListDesigner/ListDesigner.tsx @@ -13,17 +13,17 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { ReactNode } from 'react'; +import React, { FC, ReactNode } from 'react'; import { ActionURL, PermissionTypes, Security } from '@labkey/api'; import { Alert, + AppContexts, LoadingSpinner, ListDesignerPanels, ListModel, fetchListDesign, getListIdFromDomainId, BeforeUnload, - withServerContext, } from '@labkey/components'; import '../DomainDesigner.scss'; @@ -154,4 +154,8 @@ export class ListDesigner extends React.Component { } } -export const App = withServerContext(ListDesigner); +export const App: FC = () => ( + + + +); diff --git a/core/src/client/SampleTypeDesigner/SampleTypeDesigner.tsx b/core/src/client/SampleTypeDesigner/SampleTypeDesigner.tsx index e3b0e084b06..d829b07ca24 100644 --- a/core/src/client/SampleTypeDesigner/SampleTypeDesigner.tsx +++ b/core/src/client/SampleTypeDesigner/SampleTypeDesigner.tsx @@ -14,10 +14,11 @@ * limitations under the License. */ -import React from 'react'; +import React, { FC } from 'react'; import { ActionURL, getServerContext } from '@labkey/api'; import { Alert, + AppContexts, BeforeUnload, DomainDesign, DomainDetails, @@ -27,7 +28,6 @@ import { SampleTypeDesigner, SampleTypeModel, getSampleDomainDefaultSystemFields, - withServerContext, } from '@labkey/components'; import '../DomainDesigner.scss'; @@ -171,4 +171,8 @@ class SampleTypeDesignerWrapper extends React.PureComponent { } } -export const App = withServerContext(SampleTypeDesignerWrapper); \ No newline at end of file +export const App: FC = () => ( + + + +); From 7ed2e91aa1019bf229aa968ab08cf7090e870e81 Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Wed, 13 May 2026 14:30:40 -0700 Subject: [PATCH 16/29] nits --- query/src/org/labkey/query/controllers/QueryMcp.java | 1 - query/src/org/labkey/query/controllers/prompts/LabKeySql.md | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/query/src/org/labkey/query/controllers/QueryMcp.java b/query/src/org/labkey/query/controllers/QueryMcp.java index 373115cd944..ba501893e11 100644 --- a/query/src/org/labkey/query/controllers/QueryMcp.java +++ b/query/src/org/labkey/query/controllers/QueryMcp.java @@ -12,7 +12,6 @@ import org.labkey.api.data.PropertyManager; import org.labkey.api.data.TableDescription; import org.labkey.api.data.TableInfo; -import org.labkey.api.mcp.McpException; import org.labkey.api.mcp.McpInternal; import org.labkey.api.mcp.McpService; import org.labkey.api.query.DefaultSchema; diff --git a/query/src/org/labkey/query/controllers/prompts/LabKeySql.md b/query/src/org/labkey/query/controllers/prompts/LabKeySql.md index 1da5be8d8d0..9bc0d8e1460 100644 --- a/query/src/org/labkey/query/controllers/prompts/LabKeySql.md +++ b/query/src/org/labkey/query/controllers/prompts/LabKeySql.md @@ -204,7 +204,7 @@ Here is a summary of the available functions and methods in LabKey SQL. * `age_in_years(date1, date2)`: Returns age in years. * `curdate()`, `curtime()`: Returns the current date/time. * `dayofmonth(date)`: Returns the day of the month. -* `dayofweek(date)`: Returns the day of the week. Day of the week starts +* `dayofweek(date)`: Returns the day of the week. * `dayofyear(date)`: Returns the day of the year. * `hour(time)`, `minute(time)`, `second(time)`: Return time components. * `month(date)`, `monthname(date)`: Return month values. From 3ebc32311be43e0f2cb3b92b2f139986d6201fdc Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Wed, 13 May 2026 15:28:47 -0700 Subject: [PATCH 17/29] Compose prompt on server --- .../ExpressionAssistantAgentAction.java | 137 +++++++++++++++++- .../query/controllers/QueryController.java | 39 +++++ 2 files changed, 174 insertions(+), 2 deletions(-) diff --git a/query/src/org/labkey/query/controllers/ExpressionAssistantAgentAction.java b/query/src/org/labkey/query/controllers/ExpressionAssistantAgentAction.java index 64d924abe1f..6d6a7fe0b23 100644 --- a/query/src/org/labkey/query/controllers/ExpressionAssistantAgentAction.java +++ b/query/src/org/labkey/query/controllers/ExpressionAssistantAgentAction.java @@ -17,6 +17,9 @@ import com.google.genai.errors.ClientException; import com.google.genai.errors.ServerException; +import org.apache.commons.lang3.StringUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import org.json.JSONArray; import org.json.JSONObject; import org.junit.Assert; @@ -38,12 +41,15 @@ import java.util.Map; import static org.apache.commons.lang3.StringUtils.isBlank; +import static org.apache.commons.lang3.StringUtils.isNotBlank; import static org.labkey.query.controllers.QueryController.getPromptResource; @RequiresPermission(ReadPermission.class) @RequiresLogin public class ExpressionAssistantAgentAction extends AbstractAgentAction { + private static final Logger LOG = LogManager.getLogger(ExpressionAssistantAgentAction.class); + @Override protected String getAgentName() { @@ -61,8 +67,11 @@ public Object execute(ParseForm form, BindException errors) throws Exception { try (var _ = McpContext.withContext(getViewContext())) { + boolean firstTurn = isBlank(form.getConversationId()); String prompt = form.getPrompt(); - if (isBlank(prompt)) + String composedPrompt = composePrompt(firstTurn, prompt, form.getDomainFields(), form.getFieldExpression(), form.getFieldError()); + + if (isBlank(composedPrompt)) { return new JSONObject(Map.of( "contentType", "text/plain", @@ -80,7 +89,8 @@ public Object execute(ParseForm form, BindException errors) throws Exception .put("columnMap", form.getColumnMap()) .put("phiColumns", form.getPhiColumns()); - responses = McpService.get().sendMessageEx(chatSession, prompt); + LOG.info("Expression assistant prompt: {}", prompt); + responses = McpService.get().sendMessageEx(chatSession, composedPrompt); } catch (ServerException x) { @@ -102,6 +112,63 @@ public Object execute(ParseForm form, BindException errors) throws Exception } } + /** + * Combines the user's prompt with any first-turn context supplied by the client (the catalog + * of available fields, the current expression, and an error message when auto-evaluating an + * invalid expression). When {@code firstTurn} is false the context fields are ignored, and the + * user's prompt is returned verbatim. + */ + static String composePrompt(boolean firstTurn, String userPrompt, JSONArray domainFields, String fieldExpression, String fieldError) + { + if (!firstTurn) + return StringUtils.defaultString(userPrompt); + + boolean autoEvaluate = isBlank(userPrompt) && isNotBlank(fieldError); + if (isBlank(userPrompt) && !autoEvaluate) + return ""; + + StringBuilder sb = new StringBuilder(); + if (domainFields != null && !domainFields.isEmpty()) + { + sb.append("The following enumerates the available columns and their types:\n"); + sb.append(fence(domainFields.toString(), "json")); + } + + if (autoEvaluate) + { + if (isNotBlank(fieldExpression)) + { + sb.append("The user already has the following calculated column expression:\n"); + sb.append(fence(fieldExpression)); + } + sb.append("This expression contains an error:\n"); + sb.append(fence(fieldError)); + sb.append("Evaluate this expression and see if you can determine how to fix this error. If you can, point them out and propose corrections."); + } + else if (sb.isEmpty()) + { + sb.append(userPrompt); + } + else + { + sb.append("Generate a calculated column expression that matches the following description:\n"); + sb.append(userPrompt); + } + + return sb.toString(); + } + + static String fence(String body) + { + return fence(body, ""); + } + + /** Wraps {@code body} in a markdown fenced code block, optionally tagged with a language. */ + static String fence(String body, String tag) + { + return "```" + tag + "\n" + body + "\n```\n"; + } + /** * Walks the markdown of each MessageResponse and produces an ordered list of segments. * Each segment is either a rendered-HTML span or a fenced SQL block. SQL blocks fenced as @@ -343,5 +410,71 @@ public void multipleMessageResponsesAreConcatenatedInOrder() assertEquals("expression", segment(segments, 1).getString("type")); assertEquals("SELECT 1", segment(segments, 1).getString("sql")); } + + @Test + public void testFence() + { + assertEquals("```json\n{\"a\":1}\n```\n", fence("{\"a\":1}", "json")); + assertEquals("```\nSELECT 1\n```\n", fence("SELECT 1", "")); + assertEquals("```sql\nline1\nline2\n```\n", fence("line1\nline2", "sql")); + assertEquals("```json\n\n```\n", fence("", "json")); + } + + private static JSONArray fields(String json) + { + return new JSONArray(json); + } + + @Test + public void composePromptFollowUpTurnIgnoresContextAndReturnsUserPromptVerbatim() + { + // Once a conversation is underway, the catalog/expression/error are already in chat history. + assertEquals("more please", composePrompt(false, "more please", fields("[{\"name\":\"A\"}]"), "SELECT 1", "boom")); + } + + @Test + public void composePromptFirstTurnWrapsWithFieldsCatalogAndInstruction() + { + String composed = composePrompt(true, "sum A and B", fields("[{\"name\":\"A\"}]"), null, null); + assertTrue(composed.contains("available columns")); + assertTrue(composed.contains("```json\n[{\"name\":\"A\"}]\n```")); + assertTrue(composed.contains("Generate a calculated column expression")); + assertTrue(composed.endsWith("sum A and B")); + } + + @Test + public void composePromptAutoEvaluateIncludesExpressionAndError() + { + String composed = composePrompt(true, "", fields("[{\"name\":\"A\"}]"), "SELECT bad", "syntax error"); + assertTrue(composed.contains("available columns")); + assertTrue(composed.contains("```\nSELECT bad\n```")); + assertTrue(composed.contains("```\nsyntax error\n```")); + assertTrue(composed.contains("Evaluate this expression")); + assertFalse("auto-evaluate must not include the change/new instruction line", + composed.contains("Generate a calculated column expression")); + } + + @Test + public void composePromptAutoEvaluateWithoutExpressionStillIncludesError() + { + String composed = composePrompt(true, "", null, null, "boom"); + assertTrue(composed.contains("```\nboom\n```")); + assertFalse(composed.contains("user already has the following")); + } + + @Test + public void composePromptEmptyWhenNothingToSay() + { + // First turn with no user prompt and no error context β€” caller renders the no-op shrug response. + assertEquals("", composePrompt(true, "", null, null, null)); + assertEquals("", composePrompt(true, null, fields("[]"), "expr", null)); + } + + @Test + public void composePromptFirstTurnWithoutDomainFieldsSkipsWrapping() + { + // No catalog to inject β€” don't bother prepending the "Generate a calculated column" preamble. + assertEquals("just do it", composePrompt(true, "just do it", null, null, null)); + } } } diff --git a/query/src/org/labkey/query/controllers/QueryController.java b/query/src/org/labkey/query/controllers/QueryController.java index b1f0eaa77de..b68ca34e5aa 100644 --- a/query/src/org/labkey/query/controllers/QueryController.java +++ b/query/src/org/labkey/query/controllers/QueryController.java @@ -8044,6 +8044,9 @@ public static class ParseForm extends PromptForm implements ApiJsonForm String expression = ""; Map columnMap = new HashMap<>(); List phiColumns = new ArrayList<>(); + JSONArray domainFields; + String fieldExpression; + String fieldError; Map getColumnMap() { @@ -8070,6 +8073,36 @@ public void setPhiColumns(List phiColumns) this.phiColumns = phiColumns; } + public JSONArray getDomainFields() + { + return domainFields; + } + + public void setDomainFields(JSONArray domainFields) + { + this.domainFields = domainFields; + } + + public String getFieldExpression() + { + return fieldExpression; + } + + public void setFieldExpression(String fieldExpression) + { + this.fieldExpression = fieldExpression; + } + + public String getFieldError() + { + return fieldError; + } + + public void setFieldError(String fieldError) + { + this.fieldError = fieldError; + } + @Override public void bindJson(JSONObject json) { @@ -8096,6 +8129,12 @@ public void bindJson(JSONObject json) setPrompt(json.getString("prompt")); if (json.has("conversationId")) setConversationId(json.getString("conversationId")); + if (json.has("domainFields")) + setDomainFields(json.getJSONArray("domainFields")); + if (json.has("fieldExpression")) + setFieldExpression(json.getString("fieldExpression")); + if (json.has("fieldError")) + setFieldError(json.getString("fieldError")); } } From e4a84f0affcf92bee0e6bf84f57370a52f10543c Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Wed, 13 May 2026 15:30:36 -0700 Subject: [PATCH 18/29] Bump @labkey/components --- assay/package-lock.json | 8 ++++---- assay/package.json | 2 +- core/package-lock.json | 8 ++++---- core/package.json | 2 +- experiment/package-lock.json | 8 ++++---- experiment/package.json | 2 +- pipeline/package-lock.json | 8 ++++---- pipeline/package.json | 2 +- 8 files changed, 20 insertions(+), 20 deletions(-) diff --git a/assay/package-lock.json b/assay/package-lock.json index 58e0fed5573..2e53dc63cd9 100644 --- a/assay/package-lock.json +++ b/assay/package-lock.json @@ -8,7 +8,7 @@ "name": "assay", "version": "0.0.0", "dependencies": { - "@labkey/components": "7.36.1-fb-mcp-calc-cols.2" + "@labkey/components": "7.36.1-fb-mcp-calc-cols.3" }, "devDependencies": { "@labkey/build": "9.1.3", @@ -3784,9 +3784,9 @@ } }, "node_modules/@labkey/components": { - "version": "7.36.1-fb-mcp-calc-cols.2", - "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/components/-/@labkey/components-7.36.1-fb-mcp-calc-cols.2.tgz", - "integrity": "sha512-thub1pkuKYfXc3o6FDbSHBgaJ3OpV3/p88w86ESOfKwf46ttapzX6C1KqeTXb/RqI+4HdFDo0+EV+7/UxjlxAA==", + "version": "7.36.1-fb-mcp-calc-cols.3", + "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/components/-/@labkey/components-7.36.1-fb-mcp-calc-cols.3.tgz", + "integrity": "sha512-m7kaaasoHl2sApCGJHb8+qF31PU0pOQGF/CznDX9WQDgih4Kt3WDFtoBfaQT/i4t4XbR3HQS6rxPU1luXCb8mQ==", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@hello-pangea/dnd": "18.0.1", diff --git a/assay/package.json b/assay/package.json index 054e38de8a2..6fb43f28cc9 100644 --- a/assay/package.json +++ b/assay/package.json @@ -55,7 +55,7 @@ } }, "dependencies": { - "@labkey/components": "7.36.1-fb-mcp-calc-cols.2" + "@labkey/components": "7.36.1-fb-mcp-calc-cols.3" }, "devDependencies": { "@labkey/build": "9.1.3", diff --git a/core/package-lock.json b/core/package-lock.json index d86e88fc547..6c920cb9159 100644 --- a/core/package-lock.json +++ b/core/package-lock.json @@ -8,7 +8,7 @@ "name": "labkey-core", "version": "0.0.0", "dependencies": { - "@labkey/components": "7.36.1-fb-mcp-calc-cols.2", + "@labkey/components": "7.36.1-fb-mcp-calc-cols.3", "@labkey/themes": "1.9.3" }, "devDependencies": { @@ -3786,9 +3786,9 @@ } }, "node_modules/@labkey/components": { - "version": "7.36.1-fb-mcp-calc-cols.2", - "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/components/-/@labkey/components-7.36.1-fb-mcp-calc-cols.2.tgz", - "integrity": "sha512-thub1pkuKYfXc3o6FDbSHBgaJ3OpV3/p88w86ESOfKwf46ttapzX6C1KqeTXb/RqI+4HdFDo0+EV+7/UxjlxAA==", + "version": "7.36.1-fb-mcp-calc-cols.3", + "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/components/-/@labkey/components-7.36.1-fb-mcp-calc-cols.3.tgz", + "integrity": "sha512-m7kaaasoHl2sApCGJHb8+qF31PU0pOQGF/CznDX9WQDgih4Kt3WDFtoBfaQT/i4t4XbR3HQS6rxPU1luXCb8mQ==", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@hello-pangea/dnd": "18.0.1", diff --git a/core/package.json b/core/package.json index acffe221ad1..25fc24fccd0 100644 --- a/core/package.json +++ b/core/package.json @@ -53,7 +53,7 @@ } }, "dependencies": { - "@labkey/components": "7.36.1-fb-mcp-calc-cols.2", + "@labkey/components": "7.36.1-fb-mcp-calc-cols.3", "@labkey/themes": "1.9.3" }, "devDependencies": { diff --git a/experiment/package-lock.json b/experiment/package-lock.json index 85e4e8367cd..0d4934c7811 100644 --- a/experiment/package-lock.json +++ b/experiment/package-lock.json @@ -8,7 +8,7 @@ "name": "experiment", "version": "0.0.0", "dependencies": { - "@labkey/components": "7.36.1-fb-mcp-calc-cols.2" + "@labkey/components": "7.36.1-fb-mcp-calc-cols.3" }, "devDependencies": { "@labkey/build": "9.1.3", @@ -3622,9 +3622,9 @@ } }, "node_modules/@labkey/components": { - "version": "7.36.1-fb-mcp-calc-cols.2", - "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/components/-/@labkey/components-7.36.1-fb-mcp-calc-cols.2.tgz", - "integrity": "sha512-thub1pkuKYfXc3o6FDbSHBgaJ3OpV3/p88w86ESOfKwf46ttapzX6C1KqeTXb/RqI+4HdFDo0+EV+7/UxjlxAA==", + "version": "7.36.1-fb-mcp-calc-cols.3", + "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/components/-/@labkey/components-7.36.1-fb-mcp-calc-cols.3.tgz", + "integrity": "sha512-m7kaaasoHl2sApCGJHb8+qF31PU0pOQGF/CznDX9WQDgih4Kt3WDFtoBfaQT/i4t4XbR3HQS6rxPU1luXCb8mQ==", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@hello-pangea/dnd": "18.0.1", diff --git a/experiment/package.json b/experiment/package.json index 4107e033ade..48cc1735c39 100644 --- a/experiment/package.json +++ b/experiment/package.json @@ -13,7 +13,7 @@ "test-integration": "cross-env NODE_ENV=test jest --ci --runInBand -c test/js/jest.config.integration.js" }, "dependencies": { - "@labkey/components": "7.36.1-fb-mcp-calc-cols.2" + "@labkey/components": "7.36.1-fb-mcp-calc-cols.3" }, "devDependencies": { "@labkey/build": "9.1.3", diff --git a/pipeline/package-lock.json b/pipeline/package-lock.json index 003e67955bd..2acffcc2f4b 100644 --- a/pipeline/package-lock.json +++ b/pipeline/package-lock.json @@ -8,7 +8,7 @@ "name": "pipeline", "version": "0.0.0", "dependencies": { - "@labkey/components": "7.36.1-fb-mcp-calc-cols.2" + "@labkey/components": "7.36.1-fb-mcp-calc-cols.3" }, "devDependencies": { "@labkey/build": "9.1.3", @@ -2957,9 +2957,9 @@ } }, "node_modules/@labkey/components": { - "version": "7.36.1-fb-mcp-calc-cols.2", - "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/components/-/@labkey/components-7.36.1-fb-mcp-calc-cols.2.tgz", - "integrity": "sha512-thub1pkuKYfXc3o6FDbSHBgaJ3OpV3/p88w86ESOfKwf46ttapzX6C1KqeTXb/RqI+4HdFDo0+EV+7/UxjlxAA==", + "version": "7.36.1-fb-mcp-calc-cols.3", + "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/components/-/@labkey/components-7.36.1-fb-mcp-calc-cols.3.tgz", + "integrity": "sha512-m7kaaasoHl2sApCGJHb8+qF31PU0pOQGF/CznDX9WQDgih4Kt3WDFtoBfaQT/i4t4XbR3HQS6rxPU1luXCb8mQ==", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@hello-pangea/dnd": "18.0.1", diff --git a/pipeline/package.json b/pipeline/package.json index 869623b5862..da5dc5c86d9 100644 --- a/pipeline/package.json +++ b/pipeline/package.json @@ -14,7 +14,7 @@ "build-prod": "npm run clean && cross-env NODE_ENV=production PROD_SOURCE_MAP=source-map webpack --config node_modules/@labkey/build/webpack/prod.config.js --color --progress --profile" }, "dependencies": { - "@labkey/components": "7.36.1-fb-mcp-calc-cols.2" + "@labkey/components": "7.36.1-fb-mcp-calc-cols.3" }, "devDependencies": { "@labkey/build": "9.1.3", From c4e898a719139b651c66c6d85da4eda2111bd9b6 Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Wed, 13 May 2026 15:44:14 -0700 Subject: [PATCH 19/29] service prompt --- .../query/controllers/ExpressionAssistantAgentAction.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/query/src/org/labkey/query/controllers/ExpressionAssistantAgentAction.java b/query/src/org/labkey/query/controllers/ExpressionAssistantAgentAction.java index 6d6a7fe0b23..a2cb818c276 100644 --- a/query/src/org/labkey/query/controllers/ExpressionAssistantAgentAction.java +++ b/query/src/org/labkey/query/controllers/ExpressionAssistantAgentAction.java @@ -59,7 +59,11 @@ protected String getAgentName() @Override protected String getServicePrompt() { - return getPromptResource(PromptResource.ExpressionAssistant); + // Explicitly pass in the LabKey SQL documentation until we get McpResources registered and available + // to the client + return getPromptResource(PromptResource.ExpressionAssistant) + + "\n\nLabKey SQL documentation:\n\n" + + getPromptResource(PromptResource.LabKeySql); } @Override From 3f2db875d3c9986a5b8766439e91cd9595b1c2af Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Wed, 13 May 2026 16:32:20 -0700 Subject: [PATCH 20/29] Review feedback --- .../labkey/api/mcp/AbstractAgentAction.java | 68 ++++++++++++++++--- .../org/labkey/api/mcp/NoopMcpService.java | 9 ++- .../ExpressionAssistantAgentAction.java | 13 ++-- .../query/controllers/QueryController.java | 2 +- 4 files changed, 75 insertions(+), 17 deletions(-) diff --git a/api/src/org/labkey/api/mcp/AbstractAgentAction.java b/api/src/org/labkey/api/mcp/AbstractAgentAction.java index 45ca3c0d648..a9cdf2f0a49 100644 --- a/api/src/org/labkey/api/mcp/AbstractAgentAction.java +++ b/api/src/org/labkey/api/mcp/AbstractAgentAction.java @@ -10,13 +10,18 @@ import org.labkey.api.action.ReadOnlyApiAction; import org.labkey.api.util.GUID; import org.labkey.api.util.HtmlString; +import org.labkey.api.util.SessionHelper; import org.springframework.ai.chat.client.ChatClient; import org.springframework.validation.BindException; import org.springframework.validation.Errors; +import java.util.Collections; +import java.util.LinkedHashMap; import java.util.Map; +import java.util.Set; import static org.apache.commons.lang3.StringUtils.isNotBlank; +import static org.labkey.api.action.SpringActionController.ERROR_GENERIC; /** * "Agent" it is too strong a word, but if you want to create a tools-specific chat endpoint, then @@ -26,6 +31,8 @@ */ public abstract class AbstractAgentAction extends ReadOnlyApiAction { + private static final int MAX_ISSUED_CONVERSATION_IDS = 16; + protected GUID conversationId; protected abstract String getAgentName(); @@ -36,7 +43,7 @@ protected ChatClient getChat(boolean create) { String conversationName = getAgentName() + ":" + getConversationId(); - HttpSession session = getViewContext().getRequest().getSession(true); + HttpSession session = getViewContext().getSession(); ChatClient chatSession = McpService.get().getChat(session, conversationName, this::getServicePrompt, create); return chatSession; @@ -52,7 +59,8 @@ protected String handleEscape(String prompt) ChatClient chatSession = getChat(false); // CONSIDER: getChat(boolean ifStarted) if (null != chatSession) McpService.get().close(getViewContext().getSession(), chatSession); - return "OK, let's start over."; + getIssuedConversationIds(getViewContext().getSession()).remove(conversationId); + return "OK, let's start over."; } } return null; @@ -61,11 +69,33 @@ protected String handleEscape(String prompt) @Override public void validateForm(F form, Errors errors) { - // If the client provided a valid conversationId, use it. Otherwise, generate a conversationId. - if (form.getConversationId() != null) - conversationId = new GUID(form.getConversationId()); - else - conversationId = new GUID(); + // Only honor a client-supplied conversationId if this agent previously issued this session that + // id. Otherwise, generate a fresh one. This prevents a same-session caller from splicing into + // another conversation by guessing or replaying a GUID. + Set issued = getIssuedConversationIds(getViewContext().getSession()); + String supplied = form.getConversationId(); + if (supplied != null) + { + GUID candidate; + + try + { + candidate = new GUID(supplied); + } + catch (IllegalArgumentException e) + { + errors.rejectValue("conversationId", ERROR_GENERIC, "Invalid conversationId"); + return; + } + + if (issued.contains(candidate)) + { + conversationId = candidate; + return; + } + } + conversationId = new GUID(); + issued.add(conversationId); } @Override @@ -82,13 +112,15 @@ public Object execute(PromptForm form, BindException errors) throws Exception // call getChat() after handleEscape() ChatClient chatSession = getChat(true); if (null == chatSession) + { return new JSONObject(Map.of( "contentType", "text/plain", "response", "Service is not ready yet", "success", Boolean.FALSE)); + } McpService.MessageResponse response = McpService.get().sendMessage(chatSession, prompt); - var ret = new JSONObject(Map.of("success", Boolean.TRUE)); + var ret = new JSONObject(Map.of("success", Boolean.TRUE, "conversationId", conversationId)); if (!HtmlString.isBlank(response.html())) { ret.put("contentType", "text/html"); @@ -136,6 +168,7 @@ else if (isNotBlank(response.text())) return new JSONObject(Map.of( "contentType", "text/plain", "text", escapeResponse, + "conversationId", conversationId, "success", Boolean.TRUE)); } @@ -143,4 +176,23 @@ protected GUID getConversationId() { return conversationId; } + + private String getIssuedConversationIdsKey() + { + return getAgentName() + "#issuedConversationIds"; + } + + private Set getIssuedConversationIds(HttpSession session) + { + return SessionHelper.getAttribute(session, getIssuedConversationIdsKey(), () -> + Collections.synchronizedSet(Collections.newSetFromMap( + new LinkedHashMap<>(16, 0.75f, false) + { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) + { + return size() > MAX_ISSUED_CONVERSATION_IDS; + } + }))); + } } diff --git a/api/src/org/labkey/api/mcp/NoopMcpService.java b/api/src/org/labkey/api/mcp/NoopMcpService.java index 0bc5813398e..cc64ba16b46 100644 --- a/api/src/org/labkey/api/mcp/NoopMcpService.java +++ b/api/src/org/labkey/api/mcp/NoopMcpService.java @@ -21,6 +21,12 @@ static McpService get() return INSTANCE; } + @Override + public boolean isEnabled() + { + return false; + } + @Override public boolean isReady() { @@ -30,19 +36,16 @@ public boolean isReady() @Override public void registerTools(@NotNull List tools, McpImpl mcp) { - } @Override public void registerPrompts(@NotNull List prompts) { - } @Override public void registerResources(@NotNull List resources) { - } @Override diff --git a/query/src/org/labkey/query/controllers/ExpressionAssistantAgentAction.java b/query/src/org/labkey/query/controllers/ExpressionAssistantAgentAction.java index a2cb818c276..fcc43cc238d 100644 --- a/query/src/org/labkey/query/controllers/ExpressionAssistantAgentAction.java +++ b/query/src/org/labkey/query/controllers/ExpressionAssistantAgentAction.java @@ -208,9 +208,11 @@ private static JSONArray buildSegments(List response } if (j >= lines.length) { - // Unterminated fence β€” treat the rest as markdown so we don't drop content. + // Unterminated fence β€” fold the body back into prose so we don't drop content, + // but skip the opening fence line itself so the user doesn't see a stray + // "```expression" rendered as a code marker. if (!htmlBuf.isEmpty()) htmlBuf.append("\n"); - for (int k = i; k < lines.length; k++) + for (int k = i + 1; k < lines.length; k++) { htmlBuf.append(lines[k]); if (k < lines.length - 1) htmlBuf.append("\n"); @@ -367,11 +369,12 @@ public void unterminatedFenceFallsBackToHtml() { String md = "Here's an expression:\n```expression\nSELECT 1\n(no closing fence)"; JSONArray segments = buildSegments(List.of(markdownResponse(md))); - // No expression segment is emitted; the unterminated portion is folded into html so - // content isn't dropped. assertEquals(1, segments.length()); assertEquals("html", segment(segments, 0).getString("type")); - assertTrue(segment(segments, 0).getString("html").contains("SELECT 1")); + String html = segment(segments, 0).getString("html"); + assertTrue("body should survive: " + html, html.contains("SELECT 1")); + assertTrue("leading prose should survive: " + html, html.contains("Here's an expression:") || html.contains("Here's an expression:")); + assertFalse("opening fence must be stripped: " + html, html.contains("```")); } @Test diff --git a/query/src/org/labkey/query/controllers/QueryController.java b/query/src/org/labkey/query/controllers/QueryController.java index b68ca34e5aa..ee5a169e110 100644 --- a/query/src/org/labkey/query/controllers/QueryController.java +++ b/query/src/org/labkey/query/controllers/QueryController.java @@ -154,7 +154,6 @@ import org.labkey.api.exp.property.PropertyService; import org.labkey.api.files.FileContentService; import org.labkey.api.gwt.client.AuditBehaviorType; -import org.labkey.api.markdown.MarkdownService; import org.labkey.api.mcp.AbstractAgentAction; import org.labkey.api.mcp.McpContext; import org.labkey.api.mcp.McpService; @@ -8878,6 +8877,7 @@ public static class QueryAgentAction extends AbstractAgentAction @Override public void validateForm(SqlPromptForm sqlPromptForm, Errors errors) { + super.validateForm(sqlPromptForm, errors); _form = sqlPromptForm; } From b1a7403744998620a1b029d70b07c877a281b571 Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Thu, 14 May 2026 08:34:27 -0700 Subject: [PATCH 21/29] McpInternalTools listResources, readResource --- api/src/org/labkey/api/mcp/McpInternal.java | 4 +-- .../ExpressionAssistantAgentAction.java | 8 ++---- .../query/controllers/QueryController.java | 28 +++++++++++++------ 3 files changed, 23 insertions(+), 17 deletions(-) diff --git a/api/src/org/labkey/api/mcp/McpInternal.java b/api/src/org/labkey/api/mcp/McpInternal.java index 43696a4cce6..d7c1d7622b6 100644 --- a/api/src/org/labkey/api/mcp/McpInternal.java +++ b/api/src/org/labkey/api/mcp/McpInternal.java @@ -6,11 +6,11 @@ import java.lang.annotation.Target; /** - * Marks an MCP tool as internal-only. Internal tools are available to in-process callers (e.g. + * Marks an MCP tool as internal-only. Internal tools are available to in-process callers (e.g., * {@link AbstractAgentAction}-backed agents that supply tool-specific context via {@link McpContext}) * but are filtered out of the externally exposed MCP server: they do not appear in {@code listTools} * and direct {@code callTool} requests for them are rejected. - * + *

* Use this for tools that require request-scoped context the LLM cannot reasonably supply on its own. */ @Retention(RetentionPolicy.RUNTIME) diff --git a/query/src/org/labkey/query/controllers/ExpressionAssistantAgentAction.java b/query/src/org/labkey/query/controllers/ExpressionAssistantAgentAction.java index fcc43cc238d..4515d88e977 100644 --- a/query/src/org/labkey/query/controllers/ExpressionAssistantAgentAction.java +++ b/query/src/org/labkey/query/controllers/ExpressionAssistantAgentAction.java @@ -42,7 +42,6 @@ import static org.apache.commons.lang3.StringUtils.isBlank; import static org.apache.commons.lang3.StringUtils.isNotBlank; -import static org.labkey.query.controllers.QueryController.getPromptResource; @RequiresPermission(ReadPermission.class) @RequiresLogin @@ -59,11 +58,8 @@ protected String getAgentName() @Override protected String getServicePrompt() { - // Explicitly pass in the LabKey SQL documentation until we get McpResources registered and available - // to the client - return getPromptResource(PromptResource.ExpressionAssistant) + - "\n\nLabKey SQL documentation:\n\n" + - getPromptResource(PromptResource.LabKeySql); + return PromptResource.ExpressionAssistant.resource() + + "\n\nBefore starting, load the LabKey SQL documentation using the \"readResource\" tool with the URI \"" + PromptResource.LabKeySql.uri() + "\"\n\n"; } @Override diff --git a/query/src/org/labkey/query/controllers/QueryController.java b/query/src/org/labkey/query/controllers/QueryController.java index ee5a169e110..c10e114556e 100644 --- a/query/src/org/labkey/query/controllers/QueryController.java +++ b/query/src/org/labkey/query/controllers/QueryController.java @@ -8562,18 +8562,28 @@ public Object execute(QueryImportTemplateForm form, BindException errors) throws enum PromptResource { ExpressionAssistant, - LabKeySql - } + LabKeySql; - static String getPromptResource(PromptResource resource) - { - try + String resource() + { + try + { + return IOUtils.resourceToString(resourceName(), null, QueryController.class.getClassLoader()); + } + catch (IOException x) + { + throw new ConfigurationException("error loading resource", x); + } + } + + String resourceName() { - return IOUtils.resourceToString("org/labkey/query/controllers/prompts/" + resource.name() + ".md", null, QueryController.class.getClassLoader()); + return "org/labkey/query/controllers/prompts/" + name() + ".md"; } - catch (IOException x) + + String uri() { - throw new ConfigurationException("error loading resource", x); + return "resource://" + resourceName(); } } @@ -8891,7 +8901,7 @@ protected String getAgentName() protected String getServicePrompt() { StringBuilder serviceMessage = new StringBuilder(); - serviceMessage.append("Your job is to generate SQL statements. Here is some reference material formatted as markdown:\n").append(getPromptResource(PromptResource.LabKeySql)).append("\n\n"); + serviceMessage.append("Your job is to generate SQL statements. Here is some reference material formatted as markdown:\n").append(PromptResource.LabKeySql.resource()).append("\n\n"); serviceMessage.append("NOTE: Prefer using lookup syntax rather than JOIN where possible.\n"); serviceMessage.append("NOTE: When helping generate SQL please don't use names of tables and columns from documentation examples. Always refer to the available tools for retrieving database metadata.\n"); From 5919218bc7b6a2c6594ecba06041f53549cebca1 Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Thu, 14 May 2026 09:27:03 -0700 Subject: [PATCH 22/29] More tests --- .../org/labkey/query/QueryServiceImpl.java | 133 ++++++++++++++++++ .../query/controllers/QueryController.java | 39 +---- .../labkey/query/controllers/QueryMcp.java | 3 +- 3 files changed, 137 insertions(+), 38 deletions(-) diff --git a/query/src/org/labkey/query/QueryServiceImpl.java b/query/src/org/labkey/query/QueryServiceImpl.java index 34f80fd5459..2b0a8abb4c8 100644 --- a/query/src/org/labkey/query/QueryServiceImpl.java +++ b/query/src/org/labkey/query/QueryServiceImpl.java @@ -44,6 +44,7 @@ import org.labkey.api.collections.CaseInsensitiveHashMap; import org.labkey.api.collections.LabKeyCollectors; import org.labkey.api.data.AuditConfigurable; +import org.labkey.api.data.BaseColumnInfo; import org.labkey.api.data.ColumnHeaderType; import org.labkey.api.data.ColumnInfo; import org.labkey.api.data.ColumnRenderPropertiesImpl; @@ -60,6 +61,7 @@ import org.labkey.api.data.JdbcType; import org.labkey.api.data.MethodInfo; import org.labkey.api.data.MutableColumnInfo; +import org.labkey.api.data.PHI; import org.labkey.api.data.Parameter; import org.labkey.api.data.QueryLogging; import org.labkey.api.data.Results; @@ -73,6 +75,7 @@ import org.labkey.api.data.Table; import org.labkey.api.data.TableInfo; import org.labkey.api.data.TableSelector; +import org.labkey.api.data.VirtualTable; import org.labkey.api.data.dialect.SqlDialect; import org.labkey.api.exp.property.DomainKind; import org.labkey.api.exp.query.ExpTable; @@ -3047,6 +3050,40 @@ public void bindQueryExpressionColumn(ColumnInfo col, Map c calc.computeMetaData(columns); } + public record CalculatedColumnParseResult(JdbcType jdbcType, Set requiredColumns) { } + + public CalculatedColumnParseResult parseCalculatedColumn( + Container container, + User user, + String expression, + Map columnMap, + @Nullable List phiColumns + ) throws QueryException + { + var schema = DefaultSchema.get(user, container).getUserSchema("core"); + var table = new VirtualTable<>(schema.getDbSchema(), "EXPR", schema){}; + ColumnInfo calculatedCol = createQueryExpressionColumn(table, new FieldKey(null, "expr"), expression, null); + Map columns = new HashMap<>(); + + for (var entry : columnMap.entrySet()) + { + BaseColumnInfo entryCol = new BaseColumnInfo(entry.getKey(), entry.getValue()); + // bindQueryExpressionColumn has a check that restricts PHI columns from being used in expressions, + // so we need to set the PHI level to something other than NotPHI on these fake BaseColumnInfo objects + if (phiColumns != null && phiColumns.contains(entry.getKey())) + entryCol.setPHI(PHI.PHI); + columns.put(entry.getKey(), entryCol); + table.addColumn(entryCol); + } + + // TODO: calculating jdbcType still uses calculatedCol.getParentTable().getColumns() + var requiredColumns = new HashSet(); + bindQueryExpressionColumn(calculatedCol, columns, false, requiredColumns); + var jdbcType = calculatedCol.getJdbcType(); + + return new CalculatedColumnParseResult(jdbcType, requiredColumns); + } + @Override public void addCompareType(CompareType type) { @@ -3886,5 +3923,101 @@ public void testWhereClauseWithUnion() assertTrue(e.getMessage().contains("Syntax error near 'UNION'")); } } + + @Test + public void testParseCalculatedColumn() throws QueryException + { + QueryServiceImpl qs = QueryServiceImpl.get(); + Container c = JunitUtil.getTestContainer(); + User user = TestContext.get().getUser(); + + FieldKey rowId = new FieldKey(null, "RowId"); + FieldKey sortOrder = new FieldKey(null, "SortOrder"); + FieldKey name = new FieldKey(null, "Name"); + FieldKey title = new FieldKey(null, "Title"); + + // Integer arithmetic over existing core.Containers columns + { + Map columnMap = new HashMap<>(); + columnMap.put(rowId, JdbcType.INTEGER); + columnMap.put(sortOrder, JdbcType.INTEGER); + + CalculatedColumnParseResult result = qs.parseCalculatedColumn(c, user, "RowId + SortOrder", columnMap, null); + assertEquals(JdbcType.INTEGER, result.jdbcType()); + assertEquals(Set.of(rowId, sortOrder), result.requiredColumns()); + } + + // String concatenation; only references a subset of supplied columns + { + Map columnMap = new HashMap<>(); + columnMap.put(name, JdbcType.VARCHAR); + columnMap.put(title, JdbcType.VARCHAR); + columnMap.put(rowId, JdbcType.INTEGER); + + CalculatedColumnParseResult result = qs.parseCalculatedColumn(c, user, "Name || '_' || Title", columnMap, null); + assertEquals(JdbcType.VARCHAR, result.jdbcType()); + assertEquals(Set.of(name, title), result.requiredColumns()); + } + + // Client-supplied columns that don't exist yet on the domain are honored β€” the parser + // trusts the supplied columnMap rather than the live schema. + { + FieldKey newColA = new FieldKey(null, "NewColA"); + FieldKey newColB = new FieldKey(null, "NewColB"); + Map columnMap = new HashMap<>(); + columnMap.put(newColA, JdbcType.DOUBLE); + columnMap.put(newColB, JdbcType.DOUBLE); + + CalculatedColumnParseResult result = qs.parseCalculatedColumn(c, user, "NewColA * NewColB", columnMap, null); + assertEquals(JdbcType.DOUBLE, result.jdbcType()); + assertEquals(Set.of(newColA, newColB), result.requiredColumns()); + } + + // Referencing a column that the client did not supply is an error + { + Map columnMap = new HashMap<>(); + columnMap.put(rowId, JdbcType.INTEGER); + + try + { + qs.parseCalculatedColumn(c, user, "RowId + Missing", columnMap, null); + fail("Expected QueryException for reference to unknown column"); + } + catch (QueryException _) + { + } + } + + // Syntactically invalid expressions throw QueryException + { + Map columnMap = new HashMap<>(); + columnMap.put(rowId, JdbcType.INTEGER); + + try + { + qs.parseCalculatedColumn(c, user, "RowId +", columnMap, null); + fail("Expected QueryException for syntactically invalid expression"); + } + catch (QueryException _) + { + } + } + + // PHI-flagged columns cannot be referenced from calculated column expressions + { + Map columnMap = new HashMap<>(); + columnMap.put(rowId, JdbcType.INTEGER); + columnMap.put(sortOrder, JdbcType.INTEGER); + + try + { + qs.parseCalculatedColumn(c, user, "RowId + SortOrder", columnMap, List.of(sortOrder)); + fail("Expected QueryException when expression references a PHI column"); + } + catch (QueryException _) + { + } + } + } } } diff --git a/query/src/org/labkey/query/controllers/QueryController.java b/query/src/org/labkey/query/controllers/QueryController.java index c10e114556e..c0f892427b1 100644 --- a/query/src/org/labkey/query/controllers/QueryController.java +++ b/query/src/org/labkey/query/controllers/QueryController.java @@ -117,7 +117,6 @@ import org.labkey.api.data.JdbcMetaDataSelector; import org.labkey.api.data.JdbcType; import org.labkey.api.data.JsonWriter; -import org.labkey.api.data.PHI; import org.labkey.api.data.PropertyManager; import org.labkey.api.data.PropertyManager.PropertyMap; import org.labkey.api.data.PropertyManager.WritablePropertyMap; @@ -135,7 +134,6 @@ import org.labkey.api.data.Table; import org.labkey.api.data.TableInfo; import org.labkey.api.data.TableSelector; -import org.labkey.api.data.VirtualTable; import org.labkey.api.data.dialect.JdbcMetaDataLocator; import org.labkey.api.data.dialect.SqlDialect; import org.labkey.api.dataiterator.DataIteratorBuilder; @@ -286,6 +284,7 @@ import org.labkey.query.ModuleCustomQueryDefinition; import org.labkey.query.ModuleCustomView; import org.labkey.query.QueryServiceImpl; +import org.labkey.query.QueryServiceImpl.CalculatedColumnParseResult; import org.labkey.query.TableXML; import org.labkey.query.audit.QueryExportAuditProvider; import org.labkey.query.audit.QueryUpdateAuditProvider; @@ -8137,40 +8136,6 @@ public void bindJson(JSONObject json) } } - record CalculatedColumnParseResult(JdbcType jdbcType, Set requiredColumns) { } - - static CalculatedColumnParseResult parseCalculatedColumn( - Container container, - User user, - String expression, - Map columnMap, - @Nullable List phiColumns - ) throws QueryException - { - var schema = DefaultSchema.get(user, container).getUserSchema("core"); - var table = new VirtualTable<>(schema.getDbSchema(), "EXPR", schema){}; - ColumnInfo calculatedCol = QueryServiceImpl.get().createQueryExpressionColumn(table, new FieldKey(null, "expr"), expression, null); - Map columns = new HashMap<>(); - - for (var entry : columnMap.entrySet()) - { - BaseColumnInfo entryCol = new BaseColumnInfo(entry.getKey(), entry.getValue()); - // bindQueryExpressionColumn has a check that restricts PHI columns from being used in expressions, - // so we need to set the PHI level to something other than NotPHI on these fake BaseColumnInfo objects - if (phiColumns != null && phiColumns.contains(entry.getKey())) - entryCol.setPHI(PHI.PHI); - columns.put(entry.getKey(), entryCol); - table.addColumn(entryCol); - } - - // TODO: calculating jdbcType still uses calculatedCol.getParentTable().getColumns() - var requiredColumns = new HashSet(); - QueryServiceImpl.get().bindQueryExpressionColumn(calculatedCol, columns, false, requiredColumns); - var jdbcType = calculatedCol.getJdbcType(); - - return new CalculatedColumnParseResult(jdbcType, requiredColumns); - } - /** * Since this api purpose is to return parse errors, it does not generally return success:false. *
@@ -8216,7 +8181,7 @@ public Object execute(ParseForm form, BindException errors) throws Exception CalculatedColumnParseResult parsedResult = new CalculatedColumnParseResult(JdbcType.OTHER, Collections.emptySet()); try { - parsedResult = parseCalculatedColumn(getViewContext().getContainer(), getViewContext().getUser(), form.getExpression(), form.getColumnMap(), form.getPhiColumns()); + parsedResult = QueryServiceImpl.get().parseCalculatedColumn(getViewContext().getContainer(), getViewContext().getUser(), form.getExpression(), form.getColumnMap(), form.getPhiColumns()); } catch (QueryException x) { diff --git a/query/src/org/labkey/query/controllers/QueryMcp.java b/query/src/org/labkey/query/controllers/QueryMcp.java index ba501893e11..1ce87c84c15 100644 --- a/query/src/org/labkey/query/controllers/QueryMcp.java +++ b/query/src/org/labkey/query/controllers/QueryMcp.java @@ -29,6 +29,7 @@ import org.labkey.api.query.UserSchema; import org.labkey.api.security.RequiresPermission; import org.labkey.api.security.permissions.ReadPermission; +import org.labkey.query.QueryServiceImpl; import org.labkey.api.view.NotFoundException; import org.labkey.api.writer.ContainerUser; import org.labkey.query.sql.SqlParser; @@ -180,7 +181,7 @@ String validateCalculatedColumnExpression( try { - QueryController.parseCalculatedColumn(context.getContainer(), context.getUser(), expression, columnMap, phiColumns); + QueryServiceImpl.get().parseCalculatedColumn(context.getContainer(), context.getUser(), expression, columnMap, phiColumns); } catch (QueryException x) { From 3db6ce4b4ecdcfb46d1d2d98871918ac293243f9 Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Thu, 14 May 2026 09:32:36 -0700 Subject: [PATCH 23/29] Review feedback --- api/src/org/labkey/api/mcp/McpService.java | 14 +++++++------- api/src/org/labkey/api/mcp/NoopMcpService.java | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/api/src/org/labkey/api/mcp/McpService.java b/api/src/org/labkey/api/mcp/McpService.java index f364eac9bdc..9bdaa93d772 100644 --- a/api/src/org/labkey/api/mcp/McpService.java +++ b/api/src/org/labkey/api/mcp/McpService.java @@ -42,20 +42,20 @@ /// 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 of parameter values. +/// default. Again, here, the parameter descriptions are very important. Provide examples of parameter values. /// 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 any problems (hopefully). +/// failure responses, and the LLM client will attempt to correct any problems (hopefully). /// 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 +/// - Ensure there are not 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 @@ -157,22 +157,22 @@ default void register(McpImpl mcp) @Override ToolCallback @NotNull [] getToolCallbacks(); - default ChatClient getChat(HttpSession session, String agentName, Supplier systemPromptSupplier) + default ChatClient getChat(HttpSession session, String conversationName, Supplier systemPromptSupplier) { - return getChat(session, agentName, systemPromptSupplier, true); + return getChat(session, conversationName, systemPromptSupplier, true); } void saveSessionContainer(ToolContext context, Container container); void incrementResourceRequestCount(String resource); - ChatClient getChat(HttpSession session, String agentName, Supplier systemPromptSupplier, boolean createIfNotExists); + ChatClient getChat(HttpSession session, String conversationName, Supplier systemPromptSupplier, boolean createIfNotExists); void close(HttpSession session, ChatClient chat); record MessageResponse(String contentType, String text, HtmlString html) {} - /** get consolidated response (good for many text oriented agents/use-cases) */ + /** get a consolidated response (good for many text-oriented agents/use-cases) */ MessageResponse sendMessage(ChatClient chat, String message); /** get individual response parts, useful for agents that generate SQL or programmatic responses */ diff --git a/api/src/org/labkey/api/mcp/NoopMcpService.java b/api/src/org/labkey/api/mcp/NoopMcpService.java index cc64ba16b46..eed36135794 100644 --- a/api/src/org/labkey/api/mcp/NoopMcpService.java +++ b/api/src/org/labkey/api/mcp/NoopMcpService.java @@ -65,7 +65,7 @@ public void incrementResourceRequestCount(String resource) } @Override - public ChatClient getChat(HttpSession session, String agentName, Supplier systemPromptSupplier, boolean createIfNotExists) + public ChatClient getChat(HttpSession session, String conversationName, Supplier systemPromptSupplier, boolean createIfNotExists) { return null; } From 85f2d50a4bbe1965fe0811af9b08c7b919508fa9 Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Thu, 14 May 2026 09:51:06 -0700 Subject: [PATCH 24/29] Bump @labkey/components --- assay/package-lock.json | 8 ++++---- assay/package.json | 2 +- core/package-lock.json | 8 ++++---- core/package.json | 2 +- experiment/package-lock.json | 8 ++++---- experiment/package.json | 2 +- pipeline/package-lock.json | 8 ++++---- pipeline/package.json | 2 +- 8 files changed, 20 insertions(+), 20 deletions(-) diff --git a/assay/package-lock.json b/assay/package-lock.json index 2e53dc63cd9..d0c696a1f9c 100644 --- a/assay/package-lock.json +++ b/assay/package-lock.json @@ -8,7 +8,7 @@ "name": "assay", "version": "0.0.0", "dependencies": { - "@labkey/components": "7.36.1-fb-mcp-calc-cols.3" + "@labkey/components": "7.36.1-fb-mcp-calc-cols.4" }, "devDependencies": { "@labkey/build": "9.1.3", @@ -3784,9 +3784,9 @@ } }, "node_modules/@labkey/components": { - "version": "7.36.1-fb-mcp-calc-cols.3", - "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/components/-/@labkey/components-7.36.1-fb-mcp-calc-cols.3.tgz", - "integrity": "sha512-m7kaaasoHl2sApCGJHb8+qF31PU0pOQGF/CznDX9WQDgih4Kt3WDFtoBfaQT/i4t4XbR3HQS6rxPU1luXCb8mQ==", + "version": "7.36.1-fb-mcp-calc-cols.4", + "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/components/-/@labkey/components-7.36.1-fb-mcp-calc-cols.4.tgz", + "integrity": "sha512-c4/RQocMWf1/ijyOaZRcM/R+Ggcv2ZEwAtxFP6UTs2j/EizEHNXchrFVe5C9CRaULkbWmFvsFKj6pRa/4SqnSA==", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@hello-pangea/dnd": "18.0.1", diff --git a/assay/package.json b/assay/package.json index 6fb43f28cc9..63a48bdaf93 100644 --- a/assay/package.json +++ b/assay/package.json @@ -55,7 +55,7 @@ } }, "dependencies": { - "@labkey/components": "7.36.1-fb-mcp-calc-cols.3" + "@labkey/components": "7.36.1-fb-mcp-calc-cols.4" }, "devDependencies": { "@labkey/build": "9.1.3", diff --git a/core/package-lock.json b/core/package-lock.json index 6c920cb9159..e789d435f6d 100644 --- a/core/package-lock.json +++ b/core/package-lock.json @@ -8,7 +8,7 @@ "name": "labkey-core", "version": "0.0.0", "dependencies": { - "@labkey/components": "7.36.1-fb-mcp-calc-cols.3", + "@labkey/components": "7.36.1-fb-mcp-calc-cols.4", "@labkey/themes": "1.9.3" }, "devDependencies": { @@ -3786,9 +3786,9 @@ } }, "node_modules/@labkey/components": { - "version": "7.36.1-fb-mcp-calc-cols.3", - "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/components/-/@labkey/components-7.36.1-fb-mcp-calc-cols.3.tgz", - "integrity": "sha512-m7kaaasoHl2sApCGJHb8+qF31PU0pOQGF/CznDX9WQDgih4Kt3WDFtoBfaQT/i4t4XbR3HQS6rxPU1luXCb8mQ==", + "version": "7.36.1-fb-mcp-calc-cols.4", + "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/components/-/@labkey/components-7.36.1-fb-mcp-calc-cols.4.tgz", + "integrity": "sha512-c4/RQocMWf1/ijyOaZRcM/R+Ggcv2ZEwAtxFP6UTs2j/EizEHNXchrFVe5C9CRaULkbWmFvsFKj6pRa/4SqnSA==", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@hello-pangea/dnd": "18.0.1", diff --git a/core/package.json b/core/package.json index 25fc24fccd0..2da8b69aa95 100644 --- a/core/package.json +++ b/core/package.json @@ -53,7 +53,7 @@ } }, "dependencies": { - "@labkey/components": "7.36.1-fb-mcp-calc-cols.3", + "@labkey/components": "7.36.1-fb-mcp-calc-cols.4", "@labkey/themes": "1.9.3" }, "devDependencies": { diff --git a/experiment/package-lock.json b/experiment/package-lock.json index 0d4934c7811..bb4c7921b41 100644 --- a/experiment/package-lock.json +++ b/experiment/package-lock.json @@ -8,7 +8,7 @@ "name": "experiment", "version": "0.0.0", "dependencies": { - "@labkey/components": "7.36.1-fb-mcp-calc-cols.3" + "@labkey/components": "7.36.1-fb-mcp-calc-cols.4" }, "devDependencies": { "@labkey/build": "9.1.3", @@ -3622,9 +3622,9 @@ } }, "node_modules/@labkey/components": { - "version": "7.36.1-fb-mcp-calc-cols.3", - "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/components/-/@labkey/components-7.36.1-fb-mcp-calc-cols.3.tgz", - "integrity": "sha512-m7kaaasoHl2sApCGJHb8+qF31PU0pOQGF/CznDX9WQDgih4Kt3WDFtoBfaQT/i4t4XbR3HQS6rxPU1luXCb8mQ==", + "version": "7.36.1-fb-mcp-calc-cols.4", + "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/components/-/@labkey/components-7.36.1-fb-mcp-calc-cols.4.tgz", + "integrity": "sha512-c4/RQocMWf1/ijyOaZRcM/R+Ggcv2ZEwAtxFP6UTs2j/EizEHNXchrFVe5C9CRaULkbWmFvsFKj6pRa/4SqnSA==", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@hello-pangea/dnd": "18.0.1", diff --git a/experiment/package.json b/experiment/package.json index 48cc1735c39..4840bfa851a 100644 --- a/experiment/package.json +++ b/experiment/package.json @@ -13,7 +13,7 @@ "test-integration": "cross-env NODE_ENV=test jest --ci --runInBand -c test/js/jest.config.integration.js" }, "dependencies": { - "@labkey/components": "7.36.1-fb-mcp-calc-cols.3" + "@labkey/components": "7.36.1-fb-mcp-calc-cols.4" }, "devDependencies": { "@labkey/build": "9.1.3", diff --git a/pipeline/package-lock.json b/pipeline/package-lock.json index 2acffcc2f4b..ecc18a87217 100644 --- a/pipeline/package-lock.json +++ b/pipeline/package-lock.json @@ -8,7 +8,7 @@ "name": "pipeline", "version": "0.0.0", "dependencies": { - "@labkey/components": "7.36.1-fb-mcp-calc-cols.3" + "@labkey/components": "7.36.1-fb-mcp-calc-cols.4" }, "devDependencies": { "@labkey/build": "9.1.3", @@ -2957,9 +2957,9 @@ } }, "node_modules/@labkey/components": { - "version": "7.36.1-fb-mcp-calc-cols.3", - "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/components/-/@labkey/components-7.36.1-fb-mcp-calc-cols.3.tgz", - "integrity": "sha512-m7kaaasoHl2sApCGJHb8+qF31PU0pOQGF/CznDX9WQDgih4Kt3WDFtoBfaQT/i4t4XbR3HQS6rxPU1luXCb8mQ==", + "version": "7.36.1-fb-mcp-calc-cols.4", + "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/components/-/@labkey/components-7.36.1-fb-mcp-calc-cols.4.tgz", + "integrity": "sha512-c4/RQocMWf1/ijyOaZRcM/R+Ggcv2ZEwAtxFP6UTs2j/EizEHNXchrFVe5C9CRaULkbWmFvsFKj6pRa/4SqnSA==", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@hello-pangea/dnd": "18.0.1", diff --git a/pipeline/package.json b/pipeline/package.json index da5dc5c86d9..2ac37229a67 100644 --- a/pipeline/package.json +++ b/pipeline/package.json @@ -14,7 +14,7 @@ "build-prod": "npm run clean && cross-env NODE_ENV=production PROD_SOURCE_MAP=source-map webpack --config node_modules/@labkey/build/webpack/prod.config.js --color --progress --profile" }, "dependencies": { - "@labkey/components": "7.36.1-fb-mcp-calc-cols.3" + "@labkey/components": "7.36.1-fb-mcp-calc-cols.4" }, "devDependencies": { "@labkey/build": "9.1.3", From 426088bc6930236107b4b7e2ce21aa1d954cbf5b Mon Sep 17 00:00:00 2001 From: labkey-matthewb Date: Thu, 14 May 2026 10:13:45 -0700 Subject: [PATCH 25/29] fix markdown nit --- query/src/org/labkey/query/controllers/QueryMcp.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/query/src/org/labkey/query/controllers/QueryMcp.java b/query/src/org/labkey/query/controllers/QueryMcp.java index 1ce87c84c15..cd84391bc37 100644 --- a/query/src/org/labkey/query/controllers/QueryMcp.java +++ b/query/src/org/labkey/query/controllers/QueryMcp.java @@ -156,7 +156,7 @@ String validateSQL( catch (Exception x) { // CONSIDER remove line line/character information from DB errors as they won't match the LabKey SQL - return "That SQL caused the " + (x instanceof QueryParseWarning ? "warning" : "error") + " below:\n```" + x.getMessage() + "```"; + return "That SQL caused the " + (x instanceof QueryParseWarning ? "warning" : "error") + " below:\n```\n" + x.getMessage() + "\n```"; } return "success"; } @@ -185,7 +185,7 @@ String validateCalculatedColumnExpression( } catch (QueryException x) { - return "That SQL caused the " + (x instanceof QueryParseWarning ? "warning" : "error") + " below:\n```" + x.getMessage() + "```"; + return "That SQL caused the " + (x instanceof QueryParseWarning ? "warning" : "error") + " below:\n```\n" + x.getMessage() + "\n```"; } return "success"; From 8c6807dc38465d3a92fcdc9da05fa7a254b41b60 Mon Sep 17 00:00:00 2001 From: labkey-matthewb Date: Thu, 14 May 2026 12:47:30 -0700 Subject: [PATCH 26/29] Add CAST to LabKeySql.md --- .../query/controllers/prompts/LabKeySql.md | 42 ++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/query/src/org/labkey/query/controllers/prompts/LabKeySql.md b/query/src/org/labkey/query/controllers/prompts/LabKeySql.md index 9bc0d8e1460..256d18fb1df 100644 --- a/query/src/org/labkey/query/controllers/prompts/LabKeySql.md +++ b/query/src/org/labkey/query/controllers/prompts/LabKeySql.md @@ -366,4 +366,44 @@ LabKey SQL supports a set of array construction and comparison functions. These #### **Not Supported** -`array_length`, `array_append`, `array_prepend`, `array_cat`, `array_remove`, `array_replace`, `array_position`, `array_to_string`, and subscript access (`arr[n]`) are not available in LabKey SQL. \ No newline at end of file +`array_length`, `array_append`, `array_prepend`, `array_cat`, `array_remove`, `array_replace`, `array_position`, `array_to_string`, and subscript access (`arr[n]`) are not available in LabKey SQL. + +### **11. CAST** + +Use `CAST(expression AS type)` to convert a value from one type to another. **LabKey SQL's validator does not +reliably detect type mismatches** β€” errors only surface as runtime exceptions, so use CAST proactively whenever +there is ambiguity (e.g., a date stored as VARCHAR passed to a date/time function). + +**Important:** PostgreSQL's `::type` shorthand (e.g., `col::integer`) is **not** supported in LabKey SQL. Always use `CAST()`. + +* **Supported target types:** + * Integers: `TINYINT`, `SMALLINT`, `INTEGER`, `BIGINT` + * Floating point: `REAL`, `FLOAT`, `DOUBLE` + * Fixed-point: `NUMERIC`, `DECIMAL` + * Boolean: `BOOLEAN`, `BIT` + * String: `CHAR`, `VARCHAR`, `LONGVARCHAR` + * Date/time: `DATE`, `TIME`, `TIMESTAMP` + * Other: `GUID` + +* **Examples:** + * **String column used as a number:** + ```sql + CAST(stringCol AS DOUBLE) + ``` + * **String column used as a timestamp:** + ```sql + CAST(stringCol AS TIMESTAMP) + ``` + * **Number formatted as a string (e.g., for concatenation):** + ```sql + CAST(numericCol AS VARCHAR) || ' units' + ``` + * **Ensuring integer division doesn't truncate:** + ```sql + CAST(numerator AS DOUBLE) / denominator + ``` + +* **Quick Reference:** + 1. **VARCHAR passed to a date/time function** β€” wrap it: `CAST(col AS TIMESTAMP)`. + 2. **Integer arithmetic truncates unexpectedly** β€” cast one operand to `DOUBLE`. + 3. **Do not rely on `validateSQL` for type errors** β€” mismatches only appear at runtime. \ No newline at end of file From dc688754f5c5758e15ccf634d69f8ae6b7951a79 Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Thu, 14 May 2026 12:50:43 -0700 Subject: [PATCH 27/29] Bump @labkey/components --- assay/package-lock.json | 8 ++++---- assay/package.json | 2 +- core/package-lock.json | 8 ++++---- core/package.json | 2 +- experiment/package-lock.json | 8 ++++---- experiment/package.json | 2 +- pipeline/package-lock.json | 8 ++++---- pipeline/package.json | 2 +- 8 files changed, 20 insertions(+), 20 deletions(-) diff --git a/assay/package-lock.json b/assay/package-lock.json index d0c696a1f9c..3e308c8d96b 100644 --- a/assay/package-lock.json +++ b/assay/package-lock.json @@ -8,7 +8,7 @@ "name": "assay", "version": "0.0.0", "dependencies": { - "@labkey/components": "7.36.1-fb-mcp-calc-cols.4" + "@labkey/components": "7.36.1-fb-mcp-calc-cols.5" }, "devDependencies": { "@labkey/build": "9.1.3", @@ -3784,9 +3784,9 @@ } }, "node_modules/@labkey/components": { - "version": "7.36.1-fb-mcp-calc-cols.4", - "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/components/-/@labkey/components-7.36.1-fb-mcp-calc-cols.4.tgz", - "integrity": "sha512-c4/RQocMWf1/ijyOaZRcM/R+Ggcv2ZEwAtxFP6UTs2j/EizEHNXchrFVe5C9CRaULkbWmFvsFKj6pRa/4SqnSA==", + "version": "7.36.1-fb-mcp-calc-cols.5", + "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/components/-/@labkey/components-7.36.1-fb-mcp-calc-cols.5.tgz", + "integrity": "sha512-osed78xNJfG42fbogbxVvG8hldSDKiCcZLFIAf0vi9nVrDijNVqtP8eczZi8HVC1GAPtDwXPtEadLk9cCAArCQ==", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@hello-pangea/dnd": "18.0.1", diff --git a/assay/package.json b/assay/package.json index 63a48bdaf93..659c9223ae9 100644 --- a/assay/package.json +++ b/assay/package.json @@ -55,7 +55,7 @@ } }, "dependencies": { - "@labkey/components": "7.36.1-fb-mcp-calc-cols.4" + "@labkey/components": "7.36.1-fb-mcp-calc-cols.5" }, "devDependencies": { "@labkey/build": "9.1.3", diff --git a/core/package-lock.json b/core/package-lock.json index e789d435f6d..4da2f4bfec2 100644 --- a/core/package-lock.json +++ b/core/package-lock.json @@ -8,7 +8,7 @@ "name": "labkey-core", "version": "0.0.0", "dependencies": { - "@labkey/components": "7.36.1-fb-mcp-calc-cols.4", + "@labkey/components": "7.36.1-fb-mcp-calc-cols.5", "@labkey/themes": "1.9.3" }, "devDependencies": { @@ -3786,9 +3786,9 @@ } }, "node_modules/@labkey/components": { - "version": "7.36.1-fb-mcp-calc-cols.4", - "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/components/-/@labkey/components-7.36.1-fb-mcp-calc-cols.4.tgz", - "integrity": "sha512-c4/RQocMWf1/ijyOaZRcM/R+Ggcv2ZEwAtxFP6UTs2j/EizEHNXchrFVe5C9CRaULkbWmFvsFKj6pRa/4SqnSA==", + "version": "7.36.1-fb-mcp-calc-cols.5", + "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/components/-/@labkey/components-7.36.1-fb-mcp-calc-cols.5.tgz", + "integrity": "sha512-osed78xNJfG42fbogbxVvG8hldSDKiCcZLFIAf0vi9nVrDijNVqtP8eczZi8HVC1GAPtDwXPtEadLk9cCAArCQ==", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@hello-pangea/dnd": "18.0.1", diff --git a/core/package.json b/core/package.json index 2da8b69aa95..56c09c187b3 100644 --- a/core/package.json +++ b/core/package.json @@ -53,7 +53,7 @@ } }, "dependencies": { - "@labkey/components": "7.36.1-fb-mcp-calc-cols.4", + "@labkey/components": "7.36.1-fb-mcp-calc-cols.5", "@labkey/themes": "1.9.3" }, "devDependencies": { diff --git a/experiment/package-lock.json b/experiment/package-lock.json index bb4c7921b41..be9cfa41510 100644 --- a/experiment/package-lock.json +++ b/experiment/package-lock.json @@ -8,7 +8,7 @@ "name": "experiment", "version": "0.0.0", "dependencies": { - "@labkey/components": "7.36.1-fb-mcp-calc-cols.4" + "@labkey/components": "7.36.1-fb-mcp-calc-cols.5" }, "devDependencies": { "@labkey/build": "9.1.3", @@ -3622,9 +3622,9 @@ } }, "node_modules/@labkey/components": { - "version": "7.36.1-fb-mcp-calc-cols.4", - "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/components/-/@labkey/components-7.36.1-fb-mcp-calc-cols.4.tgz", - "integrity": "sha512-c4/RQocMWf1/ijyOaZRcM/R+Ggcv2ZEwAtxFP6UTs2j/EizEHNXchrFVe5C9CRaULkbWmFvsFKj6pRa/4SqnSA==", + "version": "7.36.1-fb-mcp-calc-cols.5", + "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/components/-/@labkey/components-7.36.1-fb-mcp-calc-cols.5.tgz", + "integrity": "sha512-osed78xNJfG42fbogbxVvG8hldSDKiCcZLFIAf0vi9nVrDijNVqtP8eczZi8HVC1GAPtDwXPtEadLk9cCAArCQ==", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@hello-pangea/dnd": "18.0.1", diff --git a/experiment/package.json b/experiment/package.json index 4840bfa851a..73043e26c4b 100644 --- a/experiment/package.json +++ b/experiment/package.json @@ -13,7 +13,7 @@ "test-integration": "cross-env NODE_ENV=test jest --ci --runInBand -c test/js/jest.config.integration.js" }, "dependencies": { - "@labkey/components": "7.36.1-fb-mcp-calc-cols.4" + "@labkey/components": "7.36.1-fb-mcp-calc-cols.5" }, "devDependencies": { "@labkey/build": "9.1.3", diff --git a/pipeline/package-lock.json b/pipeline/package-lock.json index ecc18a87217..facac3173fe 100644 --- a/pipeline/package-lock.json +++ b/pipeline/package-lock.json @@ -8,7 +8,7 @@ "name": "pipeline", "version": "0.0.0", "dependencies": { - "@labkey/components": "7.36.1-fb-mcp-calc-cols.4" + "@labkey/components": "7.36.1-fb-mcp-calc-cols.5" }, "devDependencies": { "@labkey/build": "9.1.3", @@ -2957,9 +2957,9 @@ } }, "node_modules/@labkey/components": { - "version": "7.36.1-fb-mcp-calc-cols.4", - "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/components/-/@labkey/components-7.36.1-fb-mcp-calc-cols.4.tgz", - "integrity": "sha512-c4/RQocMWf1/ijyOaZRcM/R+Ggcv2ZEwAtxFP6UTs2j/EizEHNXchrFVe5C9CRaULkbWmFvsFKj6pRa/4SqnSA==", + "version": "7.36.1-fb-mcp-calc-cols.5", + "resolved": "https://labkey.jfrog.io/artifactory/api/npm/libs-client/@labkey/components/-/@labkey/components-7.36.1-fb-mcp-calc-cols.5.tgz", + "integrity": "sha512-osed78xNJfG42fbogbxVvG8hldSDKiCcZLFIAf0vi9nVrDijNVqtP8eczZi8HVC1GAPtDwXPtEadLk9cCAArCQ==", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@hello-pangea/dnd": "18.0.1", diff --git a/pipeline/package.json b/pipeline/package.json index 2ac37229a67..a95f50cd83f 100644 --- a/pipeline/package.json +++ b/pipeline/package.json @@ -14,7 +14,7 @@ "build-prod": "npm run clean && cross-env NODE_ENV=production PROD_SOURCE_MAP=source-map webpack --config node_modules/@labkey/build/webpack/prod.config.js --color --progress --profile" }, "dependencies": { - "@labkey/components": "7.36.1-fb-mcp-calc-cols.4" + "@labkey/components": "7.36.1-fb-mcp-calc-cols.5" }, "devDependencies": { "@labkey/build": "9.1.3", From c00986a84197f85a18d2b14ce7dfca8d34e51a94 Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Thu, 14 May 2026 13:19:26 -0700 Subject: [PATCH 28/29] More AppContexts usage --- .../AssayTypeSelect/AssayTypeSelect.tsx | 35 ++-- core/src/client/APIKeys/APIKeys.tsx | 20 +-- .../client/AssayDesigner/AssayDesigner.tsx | 151 +++++++++--------- .../AuthenticationConfiguration.tsx | 17 +- .../LabKeyUIComponentsPage.tsx | 104 ++++-------- 5 files changed, 140 insertions(+), 187 deletions(-) diff --git a/assay/src/client/AssayTypeSelect/AssayTypeSelect.tsx b/assay/src/client/AssayTypeSelect/AssayTypeSelect.tsx index cc66ea62711..f004dfe7a2e 100644 --- a/assay/src/client/AssayTypeSelect/AssayTypeSelect.tsx +++ b/assay/src/client/AssayTypeSelect/AssayTypeSelect.tsx @@ -1,25 +1,24 @@ -import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'; -import { ActionURL, Ajax, getServerContext, Utils } from '@labkey/api'; +import React, { FC, memo, useCallback, useEffect, useMemo, useState } from 'react'; +import { ActionURL, Ajax, Utils } from '@labkey/api'; import { + AppContexts, AssayPicker, AssayPickerSelectionModel, AssayPickerTabs, GENERAL_ASSAY_PROVIDER_NAME, App as LabKeyApp, - ServerContextProvider, useServerContext, - withAppUser, } from '@labkey/components'; import './AssayTypeSelect.scss'; -function uploadXarFile(file: File, container: string): Promise { +function uploadXarFile(file: File, containerPath: string): Promise { return new Promise((resolve, reject) => { const form = new FormData(); form.append('file', file); Ajax.request({ - url: ActionURL.buildURL('experiment', 'assayXarFile', container), + url: ActionURL.buildURL('experiment', 'assayXarFile', containerPath), method: 'POST', form, success: Utils.getCallbackWrapper(() => { @@ -54,7 +53,7 @@ const AssayTypeSelect = memo(() => { const tab = useMemo(() => ActionURL.getParameter('tab'), []); const onCancel = useCallback(() => { - window.location.href = returnUrl || ActionURL.buildURL('project', 'begin', getServerContext().container.path); + window.location.href = returnUrl || ActionURL.buildURL('project', 'begin'); }, [returnUrl]); const onChange = useCallback((model: AssayPickerSelectionModel) => { @@ -62,17 +61,15 @@ const AssayTypeSelect = memo(() => { }, []); const onSubmit = useCallback(() => { - const container = assayPickerSelection.container ?? getServerContext().container.path; - if (assayPickerSelection.tab === AssayPickerTabs.XAR_IMPORT_TAB && assayPickerSelection.file) { - uploadXarFile(assayPickerSelection.file, assayPickerSelection.container).then(() => { + const { container, file, provider, tab } = assayPickerSelection; + if (tab === AssayPickerTabs.XAR_IMPORT_TAB && file) { + uploadXarFile(file, container).then(() => { window.location.href = ActionURL.buildURL('pipeline', 'status-showList', container); }); } else { window.location.href = ActionURL.buildURL('assay', 'designer', container, { - providerName: assayPickerSelection.provider - ? assayPickerSelection.provider.name - : GENERAL_ASSAY_PROVIDER_NAME, - returnUrl: returnUrl, + providerName: provider ? provider.name : GENERAL_ASSAY_PROVIDER_NAME, + returnUrl, }); } }, [assayPickerSelection, returnUrl]); @@ -119,13 +116,11 @@ const AssayTypeSelect = memo(() => { ); }); - AssayTypeSelect.displayName = 'AssayTypeSelect'; -export const App = memo(() => ( - +export const App: FC = () => ( + - -)); - + +); App.displayName = 'App'; diff --git a/core/src/client/APIKeys/APIKeys.tsx b/core/src/client/APIKeys/APIKeys.tsx index 1f0b8b56c3e..f8a2e33a38c 100644 --- a/core/src/client/APIKeys/APIKeys.tsx +++ b/core/src/client/APIKeys/APIKeys.tsx @@ -14,19 +14,13 @@ * limitations under the License. */ import React, { FC } from 'react'; -import { APIKeysPanel, AppContextProvider, ServerContextProvider, withAppUser } from '@labkey/components'; -import { getServerContext } from '@labkey/api'; +import { APIKeysPanel, AppContexts } from '@labkey/components'; import '../DomainDesigner.scss'; -export const App: FC = () => { - const serverContext = withAppUser(getServerContext()); - - return ( - - - - - - ); -}; +export const App: FC = () => ( + + + +); +App.displayName = 'App'; diff --git a/core/src/client/AssayDesigner/AssayDesigner.tsx b/core/src/client/AssayDesigner/AssayDesigner.tsx index 518b97ca595..3af482aee78 100644 --- a/core/src/client/AssayDesigner/AssayDesigner.tsx +++ b/core/src/client/AssayDesigner/AssayDesigner.tsx @@ -13,23 +13,21 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React from 'react'; -import { ActionURL, Security, Utils, getServerContext, PermissionTypes } from '@labkey/api'; +import React, { FC } from 'react'; +import { ActionURL, getServerContext, PermissionTypes, Security, Utils } from '@labkey/api'; import { Alert, + AppContexts, AssayDesignerPanels, AssayProtocolModel, BeforeUnload, DomainFieldsDisplay, + GENERAL_ASSAY_PROVIDER_NAME, getProtocol, - LoadingSpinner, - ServerContext, - ServerContextProvider, - withAppUser, + getWebDavUrl, inferDomainFromFile, + LoadingSpinner, setDomainFields, - getWebDavUrl, - GENERAL_ASSAY_PROVIDER_NAME, } from '@labkey/components'; import '../DomainDesigner.scss'; @@ -43,11 +41,10 @@ type State = { protocolId: number; providerName?: string; returnUrl: string; - serverContext: ServerContext; }; -export class App extends React.Component { - private _dirty: boolean = false; +class AssayDesigner extends React.Component { + private _dirty = false; constructor(props) { super(props); @@ -69,7 +66,6 @@ export class App extends React.Component { copy, isLoadingModel: true, returnUrl, - serverContext: withAppUser(getServerContext()), }; } @@ -113,10 +109,10 @@ export class App extends React.Component { // Issue 45315: Support inferring the assay results domain from a file system file async resetDataDomainFromFile(model: AssayProtocolModel): Promise { - const { serverContext } = this.state; const { path, file } = ActionURL.getParameters(); const webdavUrl = - ActionURL.getBaseURL(true) + getWebDavUrl(serverContext.container.path, path + (path ? '/' : '') + file); + ActionURL.getBaseURL(true) + + getWebDavUrl(getServerContext().container.path, path + (path ? '/' : '') + file); try { const response = await inferDomainFromFile(webdavUrl, 3); @@ -127,7 +123,7 @@ export class App extends React.Component { return model.merge({ domains }) as AssayProtocolModel; } } catch (error) { - // no-op, file wasn't found or the domain couldn't be inferred so stick to the domain template from the assay protocol + // no-op, file wasn't found or the domain couldn't be inferred, so stick to the domain template from the assay protocol return model; } } @@ -184,7 +180,7 @@ export class App extends React.Component { } render() { - const { isLoadingModel, hasDesignAssayPerm, message, model, serverContext } = this.state; + const { isLoadingModel, hasDesignAssayPerm, message, model } = this.state; if (message) { return {message}; @@ -201,66 +197,71 @@ export class App extends React.Component { } return ( - - - {hasDesignAssayPerm && ( - - )} - {!hasDesignAssayPerm && ( - <> -

-
-
{model.name}
-
-
- - {this.renderReadOnlyProperty('Provider', model.providerName)} - {this.renderReadOnlyProperty('Description', model.description)} - {this.renderReadOnlyProperty('Plate Template', model.selectedPlateTemplate)} - {this.renderReadOnlyProperty('Detection Method', model.selectedDetectionMethod)} - {this.renderReadOnlyProperty( - 'Metadata Input Format', - model.selectedMetadataInputFormat - )} - {this.renderReadOnlyProperty('QC States', model.qcEnabled)} - {this.renderReadOnlyProperty( - 'Auto-Copy Data to Study', - model.autoCopyTargetContainer - ? model.autoCopyTargetContainer['path'] - : undefined - )} - {this.renderReadOnlyProperty('Import in Background', model.backgroundUpload)} - {this.renderReadOnlyProperty( - 'Transform Scripts', - model.protocolTransformScripts, - model.protocolTransformScripts.size === 0 - )} - {this.renderReadOnlyProperty( - 'Save Script Data for Debugging', - model.saveScriptFiles - )} - {this.renderReadOnlyProperty( - 'Module-Provided Scripts', - model.moduleTransformScripts, - model.moduleTransformScripts.size === 0 - )} - {this.renderReadOnlyProperty('Editable Runs', model.editableRuns)} - {this.renderReadOnlyProperty('Editable Results', model.editableResults)} -
-
+ + {hasDesignAssayPerm && ( + + )} + {!hasDesignAssayPerm && ( + <> +
+
+
{model.name}
- {model.domains - .map((domain, index) => ) - .toArray()} - - )} - - +
+ + {this.renderReadOnlyProperty('Provider', model.providerName)} + {this.renderReadOnlyProperty('Description', model.description)} + {this.renderReadOnlyProperty('Plate Template', model.selectedPlateTemplate)} + {this.renderReadOnlyProperty('Detection Method', model.selectedDetectionMethod)} + {this.renderReadOnlyProperty( + 'Metadata Input Format', + model.selectedMetadataInputFormat + )} + {this.renderReadOnlyProperty('QC States', model.qcEnabled)} + {this.renderReadOnlyProperty( + 'Auto-Copy Data to Study', + model.autoCopyTargetContainer + ? model.autoCopyTargetContainer['path'] + : undefined + )} + {this.renderReadOnlyProperty('Import in Background', model.backgroundUpload)} + {this.renderReadOnlyProperty( + 'Transform Scripts', + model.protocolTransformScripts, + model.protocolTransformScripts.size === 0 + )} + {this.renderReadOnlyProperty( + 'Save Script Data for Debugging', + model.saveScriptFiles + )} + {this.renderReadOnlyProperty( + 'Module-Provided Scripts', + model.moduleTransformScripts, + model.moduleTransformScripts.size === 0 + )} + {this.renderReadOnlyProperty('Editable Runs', model.editableRuns)} + {this.renderReadOnlyProperty('Editable Results', model.editableResults)} +
+
+
+ {model.domains + .map((domain, index) => ) + .toArray()} + + )} +
); } } + +export const App: FC = () => ( + + + +); +App.displayName = 'App'; diff --git a/core/src/client/AuthenticationConfiguration/AuthenticationConfiguration.tsx b/core/src/client/AuthenticationConfiguration/AuthenticationConfiguration.tsx index 7a6bbc671b5..c2a2ae16ff8 100644 --- a/core/src/client/AuthenticationConfiguration/AuthenticationConfiguration.tsx +++ b/core/src/client/AuthenticationConfiguration/AuthenticationConfiguration.tsx @@ -1,6 +1,6 @@ import React, { FC, memo, PureComponent, useMemo } from 'react'; import { ActionURL, Ajax, getServerContext, Utils } from '@labkey/api'; -import { LoadingSpinner, resolveErrorMessage, Alert, withAppUser, ServerContextProvider } from '@labkey/components'; +import { Alert, AppContexts, LoadingSpinner, resolveErrorMessage } from '@labkey/components'; import { GlobalSettings } from '../components/GlobalSettings'; import AuthConfigMasterPanel from '../components/AuthConfigMasterPanel'; @@ -347,12 +347,9 @@ class AuthenticationConfiguration extends PureComponent<{}, Partial> { } } -export const App: FC = memo(() => { - const initialServerContext = useMemo(() => withAppUser(getServerContext()), []); - - return ( - - - - ); -}); +export const App: FC = () => ( + + + +); +App.displayName = 'App'; diff --git a/core/src/client/LabKeyUIComponentsPage/LabKeyUIComponentsPage.tsx b/core/src/client/LabKeyUIComponentsPage/LabKeyUIComponentsPage.tsx index 7de58bb9e10..34f9cf0cec1 100644 --- a/core/src/client/LabKeyUIComponentsPage/LabKeyUIComponentsPage.tsx +++ b/core/src/client/LabKeyUIComponentsPage/LabKeyUIComponentsPage.tsx @@ -3,19 +3,8 @@ * any form or by any electronic or mechanical means without written permission from LabKey Corporation. */ import React from 'react'; -import { fromJS, List } from 'immutable'; -import { - AppContextProvider, - initQueryGridState, - SelectInput, - SelectInputOption, - ServerContext, - ServerContextProvider, - withAppUser, - NotificationsContextProvider, - GlobalStateContextProvider, -} from '@labkey/components'; -import { getServerContext } from '@labkey/api'; +import { List } from 'immutable'; +import { AppContexts, initQueryGridState, SelectInput, SelectInputOption } from '@labkey/components'; import { EditableGridPage } from './EditableGridPage'; import { GridPanelPage } from './GridPanelPage'; @@ -24,7 +13,6 @@ const COMPONENT_NAMES = List([{ value: 'EditableGridPanel' }, type State = { selected: string; - serverContext: ServerContext; }; export class App extends React.Component { @@ -45,73 +33,51 @@ export class App extends React.Component { } getInitialState = (): State => { - return { - selected: undefined, - serverContext: withAppUser(getServerContext()), - }; + return { selected: undefined }; }; onSelectionChange = (id, selected) => { this.setState({ ...this.getInitialState(), selected }); }; - renderPanel(title, body) { - return ( -
-
{title}
-
{body}
-
- ); - } - render() { - const { selected, serverContext } = this.state; + const { selected } = this.state; return ( - - - - -

- This page is setup to show examples of shared React components from the{' '} - - labkey-ui-components - {' '} - repository. To find more information about any of the components, check the{' '} - - documentation - {' '} - page. -

+ +

+ This page is setup to show examples of shared React components from the{' '} + + labkey-ui-components + {' '} + repository. To find more information about any of the components, check the{' '} + + documentation + {' '} + page. +

- + -
+
- {selected === 'EditableGridPanel' && } - {selected === 'GridPanel' && } -
-
-
-
+ {selected === 'EditableGridPanel' && } + {selected === 'GridPanel' && } + ); } } From b40c6dfa195e01468300f7dcd676a67258e78dee Mon Sep 17 00:00:00 2001 From: labkey-nicka Date: Thu, 14 May 2026 14:58:24 -0700 Subject: [PATCH 29/29] validateHtml --- .../ExpressionAssistantAgentAction.java | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/query/src/org/labkey/query/controllers/ExpressionAssistantAgentAction.java b/query/src/org/labkey/query/controllers/ExpressionAssistantAgentAction.java index 4515d88e977..010e3d62783 100644 --- a/query/src/org/labkey/query/controllers/ExpressionAssistantAgentAction.java +++ b/query/src/org/labkey/query/controllers/ExpressionAssistantAgentAction.java @@ -28,15 +28,18 @@ import org.labkey.api.mcp.AbstractAgentAction; import org.labkey.api.mcp.McpContext; import org.labkey.api.mcp.McpService; +import org.labkey.api.query.RuntimeValidationException; import org.labkey.api.security.RequiresLogin; import org.labkey.api.security.RequiresPermission; import org.labkey.api.security.permissions.ReadPermission; import org.labkey.api.util.HtmlString; +import org.labkey.api.util.PageFlowUtil; import org.labkey.query.controllers.QueryController.ParseForm; import org.labkey.query.controllers.QueryController.PromptResource; import org.springframework.ai.chat.client.ChatClient; import org.springframework.validation.BindException; +import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -255,6 +258,12 @@ private static void flushHtmlSegment(JSONArray segments, StringBuilder buf, Mark { html = raw; } + + var validateErrors = new ArrayList(); + PageFlowUtil.validateHtml(html, validateErrors, validateErrors); + if (!validateErrors.isEmpty()) + throw new RuntimeValidationException("Invalid HTML markup. " + String.join("\n", validateErrors)); + segments.put(new JSONObject(Map.of("type", "html", "html", html))); } @@ -414,6 +423,18 @@ public void multipleMessageResponsesAreConcatenatedInOrder() assertEquals("SELECT 1", segment(segments, 1).getString("sql")); } + @Test + public void flushHtmlSegmentRejectsScriptTag() + { + JSONArray segments = new JSONArray(); + StringBuilder buf = new StringBuilder("
Here is a greeting:
"); + RuntimeValidationException ex = assertThrows(RuntimeValidationException.class, + () -> flushHtmlSegment(segments, buf, null)); + assertTrue("expected validation error message, got: " + ex.getMessage(), + ex.getMessage().contains("Illegal element