Skip to content

Commit d35b630

Browse files
authored
MCP Server MVP (#7506) (#7548)
1 parent 295704c commit d35b630

12 files changed

Lines changed: 837 additions & 1138 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package org.labkey.api.mcp;
2+
3+
// A special exception that MCP endpoints can throw when they want to provide guidance to the client without making
4+
// it a big red error. The message will be extracted and sent as text to the client.
5+
public class McpException extends RuntimeException
6+
{
7+
public McpException(String message)
8+
{
9+
super(message);
10+
}
11+
}

api/src/org/labkey/api/mcp/McpService.java

Lines changed: 115 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,19 @@
33

44
import io.modelcontextprotocol.server.McpServerFeatures;
55
import jakarta.servlet.http.HttpSession;
6+
import org.apache.logging.log4j.Logger;
67
import org.jetbrains.annotations.NotNull;
78
import org.jspecify.annotations.NonNull;
8-
import org.labkey.api.module.McpProvider;
9+
import org.labkey.api.data.Container;
10+
import org.labkey.api.security.User;
911
import org.labkey.api.services.ServiceRegistry;
12+
import org.labkey.api.settings.OptionalFeatureService;
1013
import org.labkey.api.util.HtmlString;
11-
import org.springaicommunity.mcp.provider.resource.SyncMcpResourceProvider;
14+
import org.labkey.api.util.logging.LogHelper;
15+
import org.labkey.api.writer.ContainerUser;
1216
import org.springframework.ai.chat.client.ChatClient;
17+
import org.springframework.ai.chat.model.ToolContext;
18+
import org.springframework.ai.mcp.annotation.provider.resource.SyncMcpResourceProvider;
1319
import org.springframework.ai.support.ToolCallbacks;
1420
import org.springframework.ai.tool.ToolCallback;
1521
import org.springframework.ai.tool.ToolCallbackProvider;
@@ -19,23 +25,98 @@
1925
import java.util.List;
2026
import java.util.function.Supplier;
2127

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-
*/
28+
///
29+
/// ### MCP Development Guide
30+
/// `McpService` lets you expose functionality over the MCP protocol (only simple http for now). This allows external
31+
/// chat sessions to pull information from LabKey Server. Exposed functionality is also made available to chat sessions
32+
/// hosted by LabKey (see `AbstractAgentAction``).
33+
///
34+
/// ### Adding a new MCP class
35+
/// 1. Create a new class that implements `McpImpl` (see below) in the appropriate module
36+
/// 2. Register that class in your module `init()` method: `McpService.get().register(new MyMcp())`
37+
/// 3. Add tools and resources
38+
///
39+
/// ### Adding a new MCP tool
40+
/// 1. In your MCP class, create a new method that returns a String with the name you want to advertise
41+
/// 2. Annotate it with `@Tool` and provide a detailed description. This description is important since it instructs
42+
/// the LLM client (and the user) in the use of your tool.
43+
/// 3. Annotate it with `@RequiredPermission(Class&lt;? extends Permission>)` or `@RequiredNoPermission`. **A
44+
/// permission annotation is required, otherwise your tool will not be registered.**
45+
/// 4. Add `ToolContext` as the first parameter to the method
46+
/// 5. Add additional required or optional parameters to the method signature, as needed. Note that "required" is the
47+
/// default. Again here, the parameter descriptions are very important. Provide examples.
48+
/// 6. Use the helper method `getContext(ToolContext)` to retrieve the current `Container` and `User`
49+
/// 7. Use the helper method `getUser(ToolContext)` in the rare cases where you need just a `User`
50+
/// 8. Perform additional permissions checking (beyond what the annotations offer), where appropriate
51+
/// 9. Filter all results to the current container, of course
52+
/// 10. For any error conditions, throw exceptions with detailed information. These will get translated into appropriate
53+
/// failure responses and the LLM client will attempt to correct the problem.
54+
/// 11. For success cases, return a String with a message or JSON content, for example, `JSONObject.toString()`. Spring
55+
/// has some limited ability to convert other objects into JSON strings, but we haven't experimented with that. See
56+
/// `DefaultToolCallResultConverter` and the ability to provide a custom result converter via the `@Tool` annotation.
57+
///
58+
/// At registration time, the framework will:
59+
/// - Ensure all tools are annotated for permissions
60+
/// - Ensure there aren't multiple tools with the same name
61+
///
62+
/// On every tool request, before invoking any tool code, the framework will:
63+
/// - Authenticate the user or provide a guest user
64+
/// - Ensure a container has been set if the tool requires a container
65+
/// - Verify that the user has whatever permissions are required based on the tool's annotation(s)
66+
/// - Verify that every required parameter is non-null and every string parameter is non-blank
67+
/// - Push the container and user into the ToolContext to give the tool access
68+
/// - Increment a metrics counter for that tool
69+
///
70+
/// CoreMcp and QueryMcp have examples of tool declarations.
71+
///
72+
/// ### Adding a new MCP resource
73+
/// 1. In your MCP class, create a new method that returns `ReadResourceResult` with an appropriate name
74+
/// 2. Annotate it with `@McpResource` and provide a uri, mimeType, name, and description
75+
/// 3. Call `incrementResourceRequestCount()` with a short but unique name to increment its metrics count
76+
/// 4. Read the resource, construct a `ReadResourceResult`, and return it.
77+
///
78+
/// No permissions checking is performed on resources. All resources are public.
79+
///
80+
/// CoreMcp and QueryMcp have examples of resource declarations.
81+
///
3182
public interface McpService extends ToolCallbackProvider
3283
{
33-
// marker interface for classes that we will "ingest" using Spring annotations
34-
interface McpImpl {}
84+
Logger LOG = LogHelper.getLogger(McpService.class, "MCP registration exceptions");
85+
String ENABLE_MCP_SERVER_FLAG = "enableMcpServer";
86+
87+
// Interface for MCP classes that we will "ingest" using Spring annotations. Provides a few helper methods.
88+
interface McpImpl
89+
{
90+
default ContainerUser getContext(ToolContext toolContext)
91+
{
92+
User user = (User)toolContext.getContext().get("user");
93+
Container container = (Container)toolContext.getContext().get("container");
94+
if (container == null)
95+
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.");
96+
return ContainerUser.create(container, user);
97+
}
98+
99+
default User getUser(ToolContext toolContext)
100+
{
101+
return (User)toolContext.getContext().get("user");
102+
}
103+
104+
// Every MCP resource should call this on every invocation
105+
default void incrementResourceRequestCount(String resource)
106+
{
107+
if (!OptionalFeatureService.get().isFeatureEnabled(ENABLE_MCP_SERVER_FLAG))
108+
throw new RuntimeException("The MCP server is not enabled for external requests. Consider toggling the experimental feature flag.");
109+
110+
get().incrementResourceRequestCount(resource);
111+
}
112+
}
35113

36114
static @NotNull McpService get()
37115
{
38-
return ServiceRegistry.get().getService(McpService.class);
116+
McpService svc = ServiceRegistry.get().getService(McpService.class);
117+
if (svc == null)
118+
svc = NoopMcpService.get();
119+
return svc;
39120
}
40121

41122
static void setInstance(McpService service)
@@ -45,27 +126,25 @@ static void setInstance(McpService service)
45126

46127
boolean isReady();
47128

48-
49-
default void register(McpImpl obj)
129+
default void register(McpImpl mcp)
50130
{
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);
131+
try
132+
{
133+
ToolCallback[] tools = ToolCallbacks.from(mcp);
134+
if (tools.length > 0)
135+
registerTools(Arrays.asList(tools), mcp);
136+
137+
var resources = new SyncMcpResourceProvider(List.of(mcp)).getResourceSpecifications();
138+
if (null != resources && !resources.isEmpty())
139+
registerResources(resources);
140+
}
141+
catch (NoSuchMethodError t)
142+
{
143+
LOG.error("You likely need to do a clean build of API! Exception while registering an MCP implementation.", t);
144+
}
58145
}
59146

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);
147+
void registerTools(@NotNull List<ToolCallback> tools, McpImpl mcp);
69148

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

@@ -79,6 +158,10 @@ default ChatClient getChat(HttpSession session, String agentName, Supplier<Strin
79158
return getChat(session, agentName, systemPromptSupplier, true);
80159
}
81160

161+
void saveSessionContainer(ToolContext context, Container container);
162+
163+
void incrementResourceRequestCount(String resource);
164+
82165
ChatClient getChat(HttpSession session, String agentName, Supplier<String> systemPromptSupplier, boolean createIfNotExists);
83166

84167
void close(HttpSession session, ChatClient chat);
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
package org.labkey.api.mcp;
2+
3+
import io.modelcontextprotocol.server.McpServerFeatures;
4+
import jakarta.servlet.http.HttpSession;
5+
import org.jetbrains.annotations.NotNull;
6+
import org.jspecify.annotations.NonNull;
7+
import org.labkey.api.data.Container;
8+
import org.springframework.ai.chat.client.ChatClient;
9+
import org.springframework.ai.chat.model.ToolContext;
10+
import org.springframework.ai.tool.ToolCallback;
11+
import org.springframework.ai.vectorstore.VectorStore;
12+
13+
import java.util.List;
14+
import java.util.function.Supplier;
15+
16+
class NoopMcpService implements McpService
17+
{
18+
private static final McpService INSTANCE = new NoopMcpService();
19+
20+
static McpService get()
21+
{
22+
return INSTANCE;
23+
}
24+
25+
@Override
26+
public boolean isReady()
27+
{
28+
return false;
29+
}
30+
31+
@Override
32+
public void registerTools(@NotNull List<ToolCallback> tools, McpImpl mcp)
33+
{
34+
35+
}
36+
37+
@Override
38+
public void registerPrompts(@NotNull List<McpServerFeatures.SyncPromptSpecification> prompts)
39+
{
40+
41+
}
42+
43+
@Override
44+
public void registerResources(@NotNull List<McpServerFeatures.SyncResourceSpecification> resources)
45+
{
46+
47+
}
48+
49+
@Override
50+
public ToolCallback @NonNull [] getToolCallbacks()
51+
{
52+
return new ToolCallback[0];
53+
}
54+
55+
@Override
56+
public void saveSessionContainer(ToolContext context, Container container)
57+
{
58+
}
59+
60+
@Override
61+
public void incrementResourceRequestCount(String resource)
62+
{
63+
}
64+
65+
@Override
66+
public ChatClient getChat(HttpSession session, String agentName, Supplier<String> systemPromptSupplier, boolean createIfNotExists)
67+
{
68+
return null;
69+
}
70+
71+
@Override
72+
public void close(HttpSession session, ChatClient chat)
73+
{
74+
}
75+
76+
@Override
77+
public MessageResponse sendMessage(ChatClient chat, String message)
78+
{
79+
return null;
80+
}
81+
82+
@Override
83+
public VectorStore getVectorStore()
84+
{
85+
return null;
86+
}
87+
}

api/src/org/labkey/api/module/McpProvider.java

Lines changed: 0 additions & 24 deletions
This file was deleted.

api/src/org/labkey/api/security/RequiresNoPermission.java

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,12 @@
2121
import java.lang.annotation.Target;
2222

2323
/**
24-
* Indicates that an action does not require any kind of authentication or permission to invoke. Use with extreme
25-
* caution. Typically, actions marked with this annotation will handle their own permission checks in their own code path.
26-
* User: adam
27-
* Date: Dec 22, 2009
28-
*/
29-
public @Retention(java.lang.annotation.RetentionPolicy.RUNTIME) @Target(ElementType.TYPE)
24+
* Indicates that an action class or an MCP tool method does not require any kind of authentication or permission to
25+
* invoke. Use with extreme caution. Typically, actions marked with this annotation will handle their own permission
26+
* checks in their own code path. Note that this is the lowest priority permission annotation; all other @Requires*
27+
* annotations effectively override this annotation.
28+
*/
29+
public @Retention(java.lang.annotation.RetentionPolicy.RUNTIME) @Target({ElementType.TYPE, ElementType.METHOD})
3030
@interface RequiresNoPermission
3131
{
3232
}

api/src/org/labkey/api/security/RequiresPermission.java

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,10 @@
2222
import java.lang.annotation.Target;
2323

2424
/**
25-
* Specifies the required permission for an action. It does not imply that the user needs to be logged in or otherwise
26-
* authenticated.
25+
* Specifies the required permission for an action class or an MCP tool method. It does not imply that the user needs
26+
* to be logged in or otherwise authenticated.
2727
*/
28-
@Retention(java.lang.annotation.RetentionPolicy.RUNTIME) @Target(ElementType.TYPE)
28+
@Retention(java.lang.annotation.RetentionPolicy.RUNTIME) @Target({ElementType.TYPE, ElementType.METHOD})
2929
public @interface RequiresPermission
3030
{
3131
Class<? extends Permission> value();

0 commit comments

Comments
 (0)