Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
0066420
LABKEY.mcpReady
labkey-nicka May 8, 2026
4680b76
ai_stars_icon.svg
labkey-nicka May 8, 2026
e5dd452
Introduce query-expressionAssistantAgent.api
labkey-nicka May 11, 2026
58b13ed
Validate generated query
labkey-nicka May 11, 2026
5dd6d4f
Remove check
labkey-nicka May 11, 2026
377aa7c
conversationId
labkey-nicka May 12, 2026
ec4c72a
QueryMcp.validateCalculatedColumnExpression tool
labkey-nicka May 12, 2026
4d46dce
McpInternal
labkey-nicka May 13, 2026
27768fd
Use segments
labkey-nicka May 13, 2026
a05c9b3
nits
labkey-nicka May 13, 2026
8b604c2
ExpressionAssistantAgentAction.java
labkey-nicka May 13, 2026
1a212c7
Bump @labkey/components
labkey-nicka May 13, 2026
e24fd83
Array documentation, useful for text choice columns
labkey-matthewb May 13, 2026
143bf50
Bump @labkey/components
labkey-nicka May 13, 2026
7a9dc59
Use <AppContexts/> for designers
labkey-nicka May 13, 2026
7ed2e91
nits
labkey-nicka May 13, 2026
3ebc323
Compose prompt on server
labkey-nicka May 13, 2026
e4a84f0
Bump @labkey/components
labkey-nicka May 13, 2026
c4e898a
service prompt
labkey-nicka May 13, 2026
3f2db87
Review feedback
labkey-nicka May 13, 2026
b1a7403
McpInternalTools listResources, readResource
labkey-nicka May 14, 2026
5919218
More tests
labkey-nicka May 14, 2026
3db6ce4
Review feedback
labkey-nicka May 14, 2026
85f2d50
Bump @labkey/components
labkey-nicka May 14, 2026
426088b
fix markdown nit
labkey-matthewb May 14, 2026
8c6807d
Add CAST to LabKeySql.md
labkey-matthewb May 14, 2026
8f29555
Merge branch 'develop' into fb_mcp_calc_cols
labkey-nicka May 14, 2026
dc68875
Bump @labkey/components
labkey-nicka May 14, 2026
c00986a
More AppContexts usage
labkey-nicka May 14, 2026
687236e
Merge branch 'fb_mcp_calc_cols' of github.com:LabKey/platform into fb…
labkey-nicka May 14, 2026
b40c6df
validateHtml
labkey-nicka May 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 106 additions & 19 deletions api/src/org/labkey/api/mcp/AbstractAgentAction.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,32 +4,48 @@
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.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
* "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<F extends PromptForm> extends ReadOnlyApiAction<F>
{
private static final int MAX_ISSUED_CONVERSATION_IDS = 16;

protected GUID conversationId;

protected abstract String getAgentName();

protected abstract String getServicePrompt();

protected ChatClient getChat(boolean create)
{
HttpSession session = getViewContext().getRequest().getSession(true);
ChatClient chatSession = McpService.get().getChat(session, getAgentName(), this::getServicePrompt, create);
String conversationName = getAgentName() + ":" + getConversationId();

HttpSession session = getViewContext().getSession();
ChatClient chatSession = McpService.get().getChat(session, conversationName, this::getServicePrompt, create);
Comment thread
labkey-nicka marked this conversation as resolved.

return chatSession;
}

Expand All @@ -43,38 +59,68 @@ 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;
}

@Override
public void validateForm(F form, Errors errors)
{
// 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<GUID> 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
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);
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");
Expand All @@ -101,11 +147,52 @@ 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,
"conversationId", conversationId,
"success", Boolean.TRUE));
}

protected GUID getConversationId()
{
return conversationId;
}

private String getIssuedConversationIdsKey()
{
return getAgentName() + "#issuedConversationIds";
}

private Set<GUID> getIssuedConversationIds(HttpSession session)
{
return SessionHelper.getAttribute(session, getIssuedConversationIdsKey(), () ->
Collections.synchronizedSet(Collections.newSetFromMap(
new LinkedHashMap<>(16, 0.75f, false)
{
@Override
protected boolean removeEldestEntry(Map.Entry<GUID, Boolean> eldest)
{
return size() > MAX_ISSUED_CONVERSATION_IDS;
}
})));
}
}
26 changes: 21 additions & 5 deletions api/src/org/labkey/api/mcp/McpContext.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand All @@ -19,11 +21,11 @@ public class McpContext implements ContainerUser
{
final User user;
final Container container;
final Map<String, Object> 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)
Expand All @@ -36,9 +38,24 @@ public McpContext(Container container, User user)

public ToolContext getToolContext()
{
return new ToolContext(Map.of("container", getContainer(), "user", getUser()));
Map<String, Object> 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()
Expand All @@ -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<McpContext> contexts = new ThreadLocal();
Expand Down
22 changes: 22 additions & 0 deletions api/src/org/labkey/api/mcp/McpInternal.java
Original file line number Diff line number Diff line change
@@ -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.
* <p>
* 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 "";
}
24 changes: 14 additions & 10 deletions api/src/org/labkey/api/mcp/McpService.java
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -43,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
Expand Down Expand Up @@ -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.");
Expand All @@ -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);
Expand All @@ -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()
Expand Down Expand Up @@ -153,22 +157,22 @@ default void register(McpImpl mcp)
@Override
ToolCallback @NotNull [] getToolCallbacks();

default ChatClient getChat(HttpSession session, String agentName, Supplier<String> systemPromptSupplier)
default ChatClient getChat(HttpSession session, String conversationName, Supplier<String> 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<String> systemPromptSupplier, boolean createIfNotExists);
ChatClient getChat(HttpSession session, String conversationName, Supplier<String> 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 */
Expand Down
Loading