Skip to content

Commit a5f2ed5

Browse files
McpService - prototype (#7329)
1 parent 4bafbb1 commit a5f2ed5

28 files changed

Lines changed: 2739 additions & 144 deletions

File tree

api/build.gradle

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1029,6 +1029,97 @@ dependencies {
10291029
)
10301030
)
10311031

1032+
BuildUtils.addExternalDependency(
1033+
project,
1034+
new ExternalDependency(
1035+
"org.springframework.ai:spring-ai-starter-mcp-server-webmvc:${springAiVersion}",
1036+
"spring-ai-starter-mcp-server-webmvc",
1037+
"spring-ai",
1038+
"https://github.com/spring-projects/spring-ai",
1039+
ExternalDependency.APACHE_2_LICENSE_NAME,
1040+
ExternalDependency.APACHE_2_LICENSE_URL,
1041+
"MPC Servlet"
1042+
)
1043+
)
1044+
1045+
BuildUtils.addExternalDependency(
1046+
project,
1047+
new ExternalDependency(
1048+
"org.springframework.ai:spring-ai-bom:${springAiVersion}",
1049+
"spring-ai-bom",
1050+
"spring-ai",
1051+
"https://github.com/spring-projects/spring-ai",
1052+
ExternalDependency.APACHE_2_LICENSE_NAME,
1053+
ExternalDependency.APACHE_2_LICENSE_URL,
1054+
"MPC Servlet"
1055+
)
1056+
)
1057+
1058+
BuildUtils.addExternalDependency(
1059+
project,
1060+
new ExternalDependency(
1061+
"org.springframework.ai:spring-ai-starter-model-google-genai:${springAiVersion}",
1062+
"spring-ai-starter-google-genai",
1063+
"spring-ai",
1064+
"https://github.com/spring-projects/spring-ai",
1065+
ExternalDependency.APACHE_2_LICENSE_NAME,
1066+
ExternalDependency.APACHE_2_LICENSE_URL,
1067+
"LLM Chat Client integration for Gemini"
1068+
)
1069+
)
1070+
1071+
BuildUtils.addExternalDependency(
1072+
project,
1073+
new ExternalDependency(
1074+
"org.springframework.ai:spring-ai-anthropic:${springAiVersion}",
1075+
"spring-ai-anthropic",
1076+
"spring-ai",
1077+
"https://github.com/spring-projects/spring-ai",
1078+
ExternalDependency.APACHE_2_LICENSE_NAME,
1079+
ExternalDependency.APACHE_2_LICENSE_URL,
1080+
"LLM Chat Client integration for Claude"
1081+
)
1082+
)
1083+
1084+
BuildUtils.addExternalDependency(
1085+
project,
1086+
new ExternalDependency(
1087+
"org.springframework.ai:spring-ai-client-chat:${springAiVersion}",
1088+
"spring-ai-client-chat",
1089+
"spring-ai",
1090+
"https://github.com/spring-projects/spring-ai",
1091+
ExternalDependency.APACHE_2_LICENSE_NAME,
1092+
ExternalDependency.APACHE_2_LICENSE_URL,
1093+
"LLM Chat Client"
1094+
)
1095+
)
1096+
1097+
BuildUtils.addExternalDependency(
1098+
project,
1099+
new ExternalDependency(
1100+
"org.springframework.ai:spring-ai-advisors-vector-store:${springAiVersion}",
1101+
"spring-ai-advisors-vector-store",
1102+
"spring-ai",
1103+
"https://github.com/spring-projects/spring-ai",
1104+
ExternalDependency.APACHE_2_LICENSE_NAME,
1105+
ExternalDependency.APACHE_2_LICENSE_URL,
1106+
"Vector store integration"
1107+
)
1108+
)
1109+
1110+
BuildUtils.addExternalDependency(
1111+
project,
1112+
new ExternalDependency(
1113+
"org.springframework.ai:spring-ai-starter-model-google-genai-embedding:${springAiVersion}",
1114+
"spring-ai-starter-model-google-genai-embedding",
1115+
"spring-ai",
1116+
"https://github.com/spring-projects/spring-ai",
1117+
ExternalDependency.APACHE_2_LICENSE_NAME,
1118+
ExternalDependency.APACHE_2_LICENSE_URL,
1119+
"Vector store integration"
1120+
)
1121+
)
1122+
10321123
jspImplementation files(project.tasks.jar)
10331124
jspImplementation apache, jackson, spring
10341125
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package org.labkey.api.mcp;
2+
3+
import com.google.genai.errors.ClientException;
4+
import com.google.genai.errors.ServerException;
5+
import jakarta.servlet.http.HttpSession;
6+
import org.json.JSONObject;
7+
import org.labkey.api.action.ReadOnlyApiAction;
8+
import org.labkey.api.util.HtmlString;
9+
import org.springframework.ai.chat.client.ChatClient;
10+
import org.springframework.validation.BindException;
11+
12+
import java.util.Map;
13+
14+
import static org.apache.commons.lang3.StringUtils.isNotBlank;
15+
16+
/**
17+
* "agent" it is too strong a word, but if you want to create a tools specific chat endpoint then
18+
* start here.
19+
* First implement getServicePrompt() to tell your "agent its mission. You can also listen in on the
20+
* conversation to help you user get the right results.
21+
*/
22+
public abstract class AbstractAgentAction<F extends PromptForm> extends ReadOnlyApiAction<F>
23+
{
24+
protected abstract String getAgentName();
25+
26+
protected abstract String getServicePrompt();
27+
28+
protected ChatClient getChat()
29+
{
30+
HttpSession session = getViewContext().getRequest().getSession(true);
31+
ChatClient chatSession = McpService.get().getChat(session, getAgentName(), this::getServicePrompt);
32+
return chatSession;
33+
}
34+
35+
@Override
36+
public Object execute(PromptForm form, BindException errors) throws Exception
37+
{
38+
try (var mcpPush = McpContext.withContext(getViewContext()))
39+
{
40+
ChatClient chatSession = getChat();
41+
if (null == chatSession)
42+
return new JSONObject(Map.of(
43+
"contentType", "text/plain",
44+
"response", "Service is not ready yet",
45+
"success", Boolean.FALSE));
46+
47+
String prompt = form.getPrompt();
48+
McpService.MessageResponse response = McpService.get().sendMessage(chatSession, prompt);
49+
var ret = new JSONObject(Map.of("success", Boolean.TRUE));
50+
if (!HtmlString.isBlank(response.html()))
51+
{
52+
ret.put("contentType", "text/html");
53+
ret.put("response", response.html());
54+
}
55+
else if (isNotBlank(response.text()))
56+
{
57+
ret.put("contentType", response.contentType());
58+
ret.put("response", response.text());
59+
}
60+
else
61+
{
62+
ret.put("contentType", "text/plain");
63+
ret.put("response", "I got nothing");
64+
}
65+
return ret;
66+
}
67+
catch (ServerException x)
68+
{
69+
return new JSONObject(Map.of(
70+
"error", x.getMessage(),
71+
"text", "ERROR: " + x.getMessage(),
72+
"success", Boolean.FALSE));
73+
}
74+
catch (ClientException ex)
75+
{
76+
var ret = new JSONObject(Map.of(
77+
"text", ex.getMessage(),
78+
"user", getViewContext().getUser().getName(),
79+
"success", Boolean.FALSE));
80+
return ret;
81+
}
82+
}
83+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package org.labkey.api.mcp;
2+
3+
import org.jetbrains.annotations.NotNull;
4+
import org.labkey.api.data.Container;
5+
import org.labkey.api.security.User;
6+
import org.labkey.api.security.permissions.ReadPermission;
7+
import org.labkey.api.view.UnauthorizedException;
8+
import org.labkey.api.writer.ContainerUser;
9+
import org.springframework.ai.chat.model.ToolContext;
10+
import java.util.Map;
11+
12+
/**
13+
* TODO MCP tool calling supports passing along a ToolContext. And most all
14+
* interesting tools probably need a User and Container. This is not all hooked-up
15+
* yet. This is an area for further investiation.
16+
*/
17+
public class McpContext implements ContainerUser
18+
{
19+
final User user;
20+
final Container container;
21+
22+
public McpContext(ContainerUser ctx)
23+
{
24+
this.container = ctx.getContainer();
25+
this.user = ctx.getUser();
26+
}
27+
28+
public McpContext(Container container, User user)
29+
{
30+
if (!container.hasPermission(user, ReadPermission.class))
31+
throw new UnauthorizedException();
32+
this.container = container;
33+
this.user = user;
34+
}
35+
36+
public ToolContext getToolContext()
37+
{
38+
return new ToolContext(Map.of("container", getContainer(), "user", getUser()));
39+
}
40+
41+
42+
@Override
43+
public Container getContainer()
44+
{
45+
return container;
46+
}
47+
48+
@Override
49+
public User getUser()
50+
{
51+
return user;
52+
}
53+
54+
55+
//
56+
// I'd like to get away from using ThreadLocal, but I haven't
57+
// researched if there are other ways to pass context around to Tools registerd by McpService
58+
//
59+
60+
private static final ThreadLocal<McpContext> contexts = new ThreadLocal();
61+
62+
public static @NotNull McpContext get()
63+
{
64+
var ret = contexts.get();
65+
if (null == ret)
66+
throw new IllegalStateException("McpContext is not set");
67+
return ret;
68+
}
69+
70+
public static AutoCloseable withContext(ContainerUser ctx)
71+
{
72+
return with(new McpContext(ctx));
73+
}
74+
75+
public static AutoCloseable withContext(Container container, User user)
76+
{
77+
return with(new McpContext(container, user));
78+
}
79+
80+
private static AutoCloseable with(McpContext ctx)
81+
{
82+
final McpContext prev = contexts.get();
83+
contexts.set(ctx);
84+
return () -> contexts.set(prev);
85+
}
86+
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package org.labkey.api.mcp;
2+
3+
4+
import io.modelcontextprotocol.server.McpServerFeatures;
5+
import jakarta.servlet.http.HttpSession;
6+
import org.jetbrains.annotations.NotNull;
7+
import org.jspecify.annotations.NonNull;
8+
import org.labkey.api.module.McpProvider;
9+
import org.labkey.api.services.ServiceRegistry;
10+
import org.labkey.api.util.HtmlString;
11+
import org.springaicommunity.mcp.provider.resource.SyncMcpResourceProvider;
12+
import org.springframework.ai.chat.client.ChatClient;
13+
import org.springframework.ai.support.ToolCallbacks;
14+
import org.springframework.ai.tool.ToolCallback;
15+
import org.springframework.ai.tool.ToolCallbackProvider;
16+
import org.springframework.ai.vectorstore.VectorStore;
17+
18+
import java.util.Arrays;
19+
import java.util.List;
20+
import java.util.function.Supplier;
21+
22+
/**
23+
* This service lets you expose functionality over the MCP protocol (only simple http for now). This allows
24+
* external chat sessions to pull information from LabKey Server. These methods are also made available
25+
* to chat session hosted by LabKey (see AbstractAgentAction).
26+
* <p></p>
27+
* These calls are not security checked. Any tools registered here must check user permissions. Maybe that
28+
* will come as we get further along. Note that the LLM may make callbacks concerning containers other than the
29+
* current container. This is an area for investigation.
30+
*/
31+
public interface McpService extends ToolCallbackProvider
32+
{
33+
// marker interface for classes that we will "ingest" using Spring annotations
34+
interface McpImpl {}
35+
36+
static @NotNull McpService get()
37+
{
38+
return ServiceRegistry.get().getService(McpService.class);
39+
}
40+
41+
static void setInstance(McpService service)
42+
{
43+
ServiceRegistry.get().registerService(McpService.class, service);
44+
}
45+
46+
boolean isReady();
47+
48+
49+
default void register(McpImpl obj)
50+
{
51+
ToolCallback[] tools = ToolCallbacks.from(obj);
52+
if (null != tools && tools.length > 0)
53+
registerTools(Arrays.asList(tools));
54+
55+
var resources = new SyncMcpResourceProvider(List.of(obj)).getResourceSpecifications();
56+
if (null != resources && !resources.isEmpty())
57+
registerResources(resources);
58+
}
59+
60+
61+
default void register(McpProvider mcp)
62+
{
63+
registerTools(mcp.getMcpTools());
64+
registerPrompts(mcp.getMcpPrompts());
65+
registerResources(mcp.getMcpResources());
66+
}
67+
68+
void registerTools(@NotNull List<ToolCallback> tools);
69+
70+
void registerPrompts(@NotNull List<McpServerFeatures.SyncPromptSpecification> prompts);
71+
72+
void registerResources(@NotNull List<McpServerFeatures.SyncResourceSpecification> resources);
73+
74+
@Override
75+
ToolCallback @NonNull [] getToolCallbacks();
76+
77+
ChatClient getChat(HttpSession session, String agentName, Supplier<String> systemPromptSupplier);
78+
79+
record MessageResponse(String contentType, String text, HtmlString html) {}
80+
81+
/** get consolidated response (good for many text oriented agents/use-cases) */
82+
MessageResponse sendMessage(ChatClient chat, String message);
83+
84+
/** get individual response parts, useful for agents that generate SQL or programmatic responses */
85+
default List<MessageResponse> sendMessageEx(ChatClient chat, String message)
86+
{
87+
return List.of(sendMessage(chat, message));
88+
}
89+
90+
/**
91+
* return an in-memory Vector store for prototyping RAG features
92+
* CONSIDER: Is it possible to implement VectorStoreRetriever wrapper for SearchService???
93+
*/
94+
VectorStore getVectorStore();
95+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package org.labkey.api.mcp;
2+
3+
public class PromptForm
4+
{
5+
public String prompt;
6+
7+
public void setPrompt(String prompt)
8+
{
9+
this.prompt = prompt;
10+
}
11+
12+
public String getPrompt()
13+
{
14+
return this.prompt;
15+
}
16+
}

0 commit comments

Comments
 (0)