Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
1a3b9a2
sql experiment
labkey-matthewb Sep 15, 2025
deb64b1
pk/fk
labkey-matthewb Sep 15, 2025
ac07378
LabKeySql.md
labkey-matthewb Sep 15, 2025
67d21fb
Merge remote-tracking branch 'origin/develop' into fb_sql_experiment
labkey-matthewb Sep 16, 2025
1be5fd7
comment out console.log
labkey-matthewb Sep 16, 2025
61746b2
include saved queries
labkey-matthewb Sep 17, 2025
5dfe50c
Nested Schema
labkey-matthewb Sep 17, 2025
9351637
Merge remote-tracking branch 'origin/develop' into fb_sql_experiment
labkey-matthewb Oct 6, 2025
a6a383d
Merge remote-tracking branch 'origin/develop' into fb_sql_experiment
labkey-matthewb Oct 14, 2025
372671b
Merge remote-tracking branch 'origin/develop' into fb_sql_experiment
labkey-matthewb Nov 11, 2025
ccc467e
Merge remote-tracking branch 'origin/develop' into fb_sql_experiment
labkey-matthewb Dec 12, 2025
36ecdf7
Merge remote-tracking branch 'origin/develop' into fb_sql_experiment
labkey-matthewb Dec 18, 2025
c1f6a01
Merge remote-tracking branch 'origin/develop' into fb_sql_experiment
labkey-matthewb Dec 20, 2025
744459f
McpService checkpoint
labkey-matthewb Dec 24, 2025
b650056
Merge remote-tracking branch 'origin/develop' into fb_sql_experiment
labkey-matthewb Dec 29, 2025
099b3bd
AbstractAgentAction
labkey-matthewb Dec 31, 2025
78b2fc0
migration to spring-ai for chat client
labkey-matthewb Jan 1, 2026
05b8b3b
Merge branch 'develop' into fb_sql_experiment
labkey-jeckels Jan 2, 2026
5575dec
New files
labkey-matthewb Jan 2, 2026
d21d6d1
Merge remote-tracking branch 'origin/develop' into fb_sql_experiment
labkey-matthewb Jan 2, 2026
efd70b6
Merge remote-tracking branch 'origin/fb_sql_experiment' into fb_sql_e…
labkey-jeckels Jan 2, 2026
028df68
Move methods from querycontroller.java -> querymcp.java
labkey-matthewb Jan 2, 2026
db36682
Merge remote-tracking branch 'origin/fb_sql_experiment' into fb_sql_e…
labkey-jeckels Jan 2, 2026
c12bbea
Merge branch 'develop' into fb_sql_experiment
labkey-jeckels Jan 2, 2026
657dd27
McpService.sendMessageEx()
labkey-matthewb Jan 5, 2026
d54c910
delete commented code
labkey-matthewb Jan 5, 2026
c1b9ff5
McpService.getVectorStore()
labkey-matthewb Jan 6, 2026
1fd3ca7
delete commented code
labkey-matthewb Jan 6, 2026
4c00c82
springAiVersion=1.1.2
labkey-matthewb Jan 6, 2026
71c3323
Merge remote-tracking branch 'origin/develop' into fb_sql_experiment
labkey-matthewb Jan 6, 2026
4dc4014
QueryMCP.saveColumnDescription()
labkey-matthewb Jan 7, 2026
59a969b
isChatReady
labkey-matthewb Jan 7, 2026
f0efa3d
SearchMCP
labkey-matthewb Jan 7, 2026
7d166aa
SearchMCP, CoreMcp
labkey-matthewb Jan 8, 2026
687381d
Merge remote-tracking branch 'origin/develop' into fb_sql_experiment
labkey-matthewb Jan 14, 2026
17f0407
<% if (isChatReady) { %>
labkey-matthewb Jan 14, 2026
837e1ab
comment
labkey-matthewb Jan 14, 2026
9c1554c
Merge remote-tracking branch 'origin/develop' into fb_sql_experiment
labkey-matthewb Jan 16, 2026
8ac79eb
remove unused dependency (moved to api)
labkey-matthewb Jan 16, 2026
7669e93
CR cleanup
labkey-matthewb Jan 16, 2026
a7ce75f
comments
labkey-matthewb Jan 16, 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
78 changes: 78 additions & 0 deletions api/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -1029,6 +1029,84 @@ dependencies {
)
)

BuildUtils.addExternalDependency(
project,
new ExternalDependency(
"org.springframework.ai:spring-ai-starter-mcp-server-webmvc:${springAiVersion}",
"spring-ai-starter-mcp-server-webmvc",
"spring-ai",
"https://github.com/spring-projects/spring-ai",
ExternalDependency.APACHE_2_LICENSE_NAME,
ExternalDependency.APACHE_2_LICENSE_URL,
"MPC Servlet"
)
)

BuildUtils.addExternalDependency(
project,
new ExternalDependency(
"org.springframework.ai:spring-ai-bom:${springAiVersion}",
"spring-ai-bom",
"spring-ai",
"https://github.com/spring-projects/spring-ai",
ExternalDependency.APACHE_2_LICENSE_NAME,
ExternalDependency.APACHE_2_LICENSE_URL,
"MPC Servlet"
)
)

BuildUtils.addExternalDependency(
project,
new ExternalDependency(
"org.springframework.ai:spring-ai-starter-model-google-genai:${springAiVersion}",
"spring-ai-starter-google-genai",
"spring-ai",
"https://github.com/spring-projects/spring-ai",
ExternalDependency.APACHE_2_LICENSE_NAME,
ExternalDependency.APACHE_2_LICENSE_URL,
"LLM Chat Client integration for Gemini"
)
)

BuildUtils.addExternalDependency(
project,
new ExternalDependency(
"org.springframework.ai:spring-ai-client-chat:${springAiVersion}",
"spring-ai-client-chat",
"spring-ai",
"https://github.com/spring-projects/spring-ai",
ExternalDependency.APACHE_2_LICENSE_NAME,
ExternalDependency.APACHE_2_LICENSE_URL,
"LLM Chat Client"
)
)

BuildUtils.addExternalDependency(
project,
new ExternalDependency(
"org.springframework.ai:spring-ai-advisors-vector-store:${springAiVersion}",
"spring-ai-advisors-vector-store",
"spring-ai",
"https://github.com/spring-projects/spring-ai",
ExternalDependency.APACHE_2_LICENSE_NAME,
ExternalDependency.APACHE_2_LICENSE_URL,
"Vector store integration"
)
)

BuildUtils.addExternalDependency(
project,
new ExternalDependency(
"org.springframework.ai:spring-ai-starter-model-google-genai-embedding:${springAiVersion}",
"spring-ai-starter-model-google-genai-embedding",
"spring-ai",
"https://github.com/spring-projects/spring-ai",
ExternalDependency.APACHE_2_LICENSE_NAME,
ExternalDependency.APACHE_2_LICENSE_URL,
"Vector store integration"
)
)

jspImplementation files(project.tasks.jar)
jspImplementation apache, jackson, spring
}
Expand Down
83 changes: 83 additions & 0 deletions api/src/org/labkey/api/mcp/AbstractAgentAction.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package org.labkey.api.mcp;

import com.google.genai.errors.ClientException;
import com.google.genai.errors.ServerException;
import jakarta.servlet.http.HttpSession;
import org.json.JSONObject;
import org.labkey.api.action.ReadOnlyApiAction;
import org.labkey.api.util.HtmlString;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.validation.BindException;

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
* 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.
*/
public abstract class AbstractAgentAction<F extends PromptForm> extends ReadOnlyApiAction<F>
{
protected abstract String getAgentName();

protected abstract String getServicePrompt();

protected ChatClient getChat()
{
HttpSession session = getViewContext().getRequest().getSession(true);
ChatClient chatSession = McpService.get().getChat(session, getAgentName(), this::getServicePrompt);
return chatSession;
}

@Override
public Object execute(PromptForm form, BindException errors) throws Exception
{
try (var mcpPush = McpContext.withContext(getViewContext()))
{
ChatClient chatSession = getChat();
if (null == chatSession)
return new JSONObject(Map.of(
"contentType", "text/plain",
"response", "Service is not ready yet",
"success", Boolean.FALSE));

String prompt = form.getPrompt();
McpService.MessageResponse response = McpService.get().sendMessage(chatSession, prompt);
var ret = new JSONObject(Map.of("success", Boolean.TRUE));
if (!HtmlString.isBlank(response.html()))
{
ret.put("contentType", "text/html");
ret.put("response", response.html());
}
else if (isNotBlank(response.text()))
{
ret.put("contentType", response.contentType());
ret.put("response", response.text());
}
else
{
ret.put("contentType", "text/plain");
ret.put("response", "I got nothing");
}
return ret;
}
catch (ServerException x)
{
return new JSONObject(Map.of(
"error", x.getMessage(),
"text", "ERROR: " + x.getMessage(),
"success", Boolean.FALSE));
}
catch (ClientException ex)
{
var ret = new JSONObject(Map.of(
"text", ex.getMessage(),
"user", getViewContext().getUser().getName(),
"success", Boolean.FALSE));
return ret;
}
}
}
86 changes: 86 additions & 0 deletions api/src/org/labkey/api/mcp/McpContext.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package org.labkey.api.mcp;

import org.jetbrains.annotations.NotNull;
import org.labkey.api.data.Container;
import org.labkey.api.security.User;
import org.labkey.api.security.permissions.ReadPermission;
import org.labkey.api.view.UnauthorizedException;
import org.labkey.api.writer.ContainerUser;
import org.springframework.ai.chat.model.ToolContext;
import java.util.Map;

/**
* TODO MCP tool calling supports passing along a ToolContext. And most all
* interesting tools probably need a User and Container. This is not all hooked-up
* yet. This is an area for further investiation.
*/
public class McpContext implements ContainerUser
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A sentence or two of docs would be helpful here too.

{
final User user;
final Container container;

public McpContext(ContainerUser ctx)
{
this.container = ctx.getContainer();
this.user = ctx.getUser();
}

public McpContext(Container container, User user)
{
if (!container.hasPermission(user, ReadPermission.class))
throw new UnauthorizedException();
this.container = container;
this.user = user;
}

public ToolContext getToolContext()
{
return new ToolContext(Map.of("container", getContainer(), "user", getUser()));
}


@Override
public Container getContainer()
{
return container;
}

@Override
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
//

private static final ThreadLocal<McpContext> contexts = new ThreadLocal();

public static @NotNull McpContext get()
{
var ret = contexts.get();
if (null == ret)
throw new IllegalStateException("McpContext is not set");
return ret;
}

public static AutoCloseable withContext(ContainerUser ctx)
{
return with(new McpContext(ctx));
}

public static AutoCloseable withContext(Container container, User user)
{
return with(new McpContext(container, user));
}

private static AutoCloseable with(McpContext ctx)
{
final McpContext prev = contexts.get();
contexts.set(ctx);
return () -> contexts.set(prev);
}
}
96 changes: 96 additions & 0 deletions api/src/org/labkey/api/mcp/McpService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package org.labkey.api.mcp;


import io.modelcontextprotocol.server.McpServerFeatures;
import jakarta.servlet.http.HttpSession;
import org.jetbrains.annotations.NotNull;
import org.jspecify.annotations.NonNull;
import org.labkey.api.module.McpProvider;
import org.labkey.api.services.ServiceRegistry;
import org.labkey.api.util.HtmlString;
import org.springaicommunity.mcp.provider.resource.SyncMcpResourceProvider;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.support.ToolCallbacks;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.ToolCallbackProvider;
import org.springframework.ai.vectorstore.VectorStore;

import java.util.Arrays;
import java.util.List;
import java.util.function.Supplier;

/**
* This service lets you expose functionality over the MCP protocol (only simple http for now). This allows
* external chat sessions to pull information from LabKey Server. These methods are also made available
* to chat session shosted by LabKey (see AbstractAgentAction).
* <p></p>
* These calls are not security checked. Any tools registered here must check user permissions. Maybe that
* will come as we get further along. Note that the LLM may make callbacks concerning containers other than the
* current container. This is an area for investigation.
*/
public interface McpService extends ToolCallbackProvider
{
// marker interface for classes that we will "ingest" using Spring annotations
interface McpImpl {};

static @NotNull McpService get()
{
return ServiceRegistry.get().getService(McpService.class);
}

static void setInstance(McpService service)
{
ServiceRegistry.get().registerService(McpService.class, service);
}

boolean isReady();


default void register(McpImpl obj)
{
ToolCallback[] tools = ToolCallbacks.from(obj);
if (null != tools && tools.length > 0)
registerTools(Arrays.asList(tools));

List<McpServerFeatures.SyncResourceSpecification> ret;
var resources = new SyncMcpResourceProvider(List.of(obj)).getResourceSpecifications();
if (null != resources && !resources.isEmpty())
registerResources(resources);
}


default void register(McpProvider mcp)
{
registerTools(mcp.getMcpTools());
registerPrompts(mcp.getMcpPrompts());
registerResources(mcp.getMcpResources());
}

void registerTools(@NotNull List<ToolCallback> tools);

void registerPrompts(@NotNull List<McpServerFeatures.SyncPromptSpecification> prompts);

void registerResources(@NotNull List<McpServerFeatures.SyncResourceSpecification> resources);

@Override
ToolCallback @NonNull [] getToolCallbacks();

ChatClient getChat(HttpSession session, String agentName, Supplier<String> systemPromptSupplier);

record MessageResponse(String contentType, String text, HtmlString html) {}

/** get 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 */
default List<MessageResponse> sendMessageEx(ChatClient chat, String message)
{
return List.of(sendMessage(chat, message));
}

/**
* return an in-memory Vector store for prototyping RAG features
* CONSIDER: Is it possible to implement VectorStoreRetriever wrapper for SearchService???
*/
VectorStore getVectorStore();
}
16 changes: 16 additions & 0 deletions api/src/org/labkey/api/mcp/PromptForm.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package org.labkey.api.mcp;

public class PromptForm
{
public String prompt;

public void setPrompt(String prompt)
{
this.prompt = prompt;
}

public String getPrompt()
{
return this.prompt;
}
}
24 changes: 24 additions & 0 deletions api/src/org/labkey/api/module/McpProvider.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package org.labkey.api.module;

import io.modelcontextprotocol.server.McpServerFeatures;
import org.springframework.ai.tool.ToolCallback;

import java.util.List;

public interface McpProvider
{
default List<ToolCallback> getMcpTools()
{
return List.of();
}

default List<McpServerFeatures.SyncPromptSpecification> getMcpPrompts()
{
return List.of();
}

default List<McpServerFeatures.SyncResourceSpecification> getMcpResources()
{
return List.of();
}
}
Loading