Skip to content
6 changes: 6 additions & 0 deletions api/src/org/labkey/api/data/DataColumn.java
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,12 @@ protected ColumnInfo getDisplayField(@NotNull ColumnInfo col, boolean withLookup
return null==display ? col : display;
}

@Override
public void setWithLookup(boolean withLookup)
{
_displayColumn = withLookup ? getDisplayField(_boundColumn, true) : _boundColumn;
}

@Override
public String toString()
{
Expand Down
5 changes: 5 additions & 0 deletions api/src/org/labkey/api/data/DisplayColumn.java
Original file line number Diff line number Diff line change
Expand Up @@ -1232,6 +1232,11 @@ public void setRequiresHtmlFiltering(boolean requiresHtmlFiltering)
_requiresHtmlFiltering = requiresHtmlFiltering;
}

public void setWithLookup(boolean withLookup)
{
// subclasses override as needed
}

public void setLinkTarget(String linkTarget)
{
_linkTarget = linkTarget;
Expand Down
10 changes: 10 additions & 0 deletions api/src/org/labkey/api/util/PageFlowUtil.java
Original file line number Diff line number Diff line change
Expand Up @@ -2697,6 +2697,16 @@ private static boolean shouldEscapeForExport(@NotNull String value)
return StringUtils.containsAny(value,",\"");
}

/// Generate one row of tab-delimited output using RFC 4180 quoting rules.
/// Fields containing tabs, newlines, or double quotes are enclosed in double quotes,
/// with embedded double quotes escaped by doubling.
public static String joinValuesWithTabs4180(@NotNull List<String> values)
{
return values.stream()
.map(value -> null == value ? "" : StringUtils.containsAny(value, "\t\n\r\"") ? "\"" + Strings.CS.replace(value, "\"", "\"\"") + "\"" : value)
.collect(Collectors.joining("\t"));
}


static final String FIELD_ENCODED_PREFIX = "%_";

Expand Down
3 changes: 2 additions & 1 deletion query/src/org/labkey/query/QueryModule.java
Original file line number Diff line number Diff line change
Expand Up @@ -394,7 +394,8 @@ public Set<String> getSchemaNames()
RolapReader.RolapTest.class,
RolapTestCase.class,
SelectRowsStreamHack.TestCase.class,
ServerManager.TestCase.class
ServerManager.TestCase.class,
SqlController.TestCase.class
);
}

Expand Down
52 changes: 51 additions & 1 deletion query/src/org/labkey/query/controllers/QueryMcp.java
Original file line number Diff line number Diff line change
Expand Up @@ -44,22 +44,24 @@
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.QueryServiceImpl;
import org.labkey.query.sql.SqlParser;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.mcp.annotation.McpResource;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;

import java.io.IOException;
import java.io.StringWriter;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.TreeMap;

import static org.apache.commons.lang3.StringUtils.isNotBlank;
import static org.labkey.api.util.StringUtilsLabKey.pluralize;

public class QueryMcp implements McpService.McpImpl
{
Expand Down Expand Up @@ -210,6 +212,54 @@ String validateCalculatedColumnExpression(
}
}


@Tool(description =
"Execute a LabKey SQL query and return results as tab-separated values (RFC 4180 TSV). " +
"Use this to inspect actual query results while writing or debugging SQL. " +
"Prefer validateSQL when you only need to check syntax without running the query. " +
"Returns up to 100 rows by default, or up to 1,000 rows when using limit; use offset and limit to page through larger result sets. " +
"Response format: a header row of column names, then one data row per newline, fields tab-separated. " +
"Fields containing tabs, newlines, or double-quotes are RFC 4180 quoted. " +
"On SQL error, the error message is returned as plain text rather than throwing. " +
"For data analysis or bulk retrieval, use the LabKey Python or R client APIs instead of this tool. " +
"**Important** This tool does not yet support queries with named parameters.")
@RequiresPermission(ReadPermission.class)
String executeSQL(
ToolContext toolContext,
@ToolParam(description = "Fully qualified schema name as it would appear in SQL e.g. Study or \"Study\".\"Datasets\"") String schemaName,
@ToolParam(description = "LabKey SQL to execute") String sql,
@ToolParam(description = "Rows to skip before returning results.", required=false) Integer offset,
@ToolParam(description = "Number of rows to return (limit <= 1000, default=100)", required=false) Integer limit
)
{
var cu = getContext(toolContext);
var schema = DefaultSchema.get(cu.getUser(), cu.getContainer(), getSchemaKey(schemaName));
if (!(schema instanceof UserSchema userSchema))
return "Could not find schema " + schemaName;

offset = null==offset ? 0 : offset < 0 ? 0 : offset;
limit = (limit == null || limit < 0) ? 100 : Math.min(1000, limit);
var execute = new SqlController.SqlExecute(cu, userSchema, sql)
.page(offset, limit)
.truncation(500, "…[truncated]");

try
{
StringWriter sw = new StringWriter(2000);
SqlController.SqlExecute.ExecuteResult result = execute.execute(sw);
String message = "\n-- " + pluralize(result.rows(), "row", "rows") + " returned";
if (result.complete())
message += ".";
else
message += ", more may be available (use offset and limit to page).";
return sw + message;
}
catch (Exception x)
{
return x.getMessage() != null ? x.getMessage() : x.getClass().getSimpleName();
}
}

/* For now, list all schemas. CONSIDER support incremental querying. */
public static Map<SchemaKey, UserSchema> _listAllSchemas(DefaultSchema root)
{
Expand Down
Loading