33
44import io .modelcontextprotocol .server .McpServerFeatures ;
55import jakarta .servlet .http .HttpSession ;
6+ import org .apache .logging .log4j .Logger ;
67import org .jetbrains .annotations .NotNull ;
78import 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 ;
911import org .labkey .api .services .ServiceRegistry ;
12+ import org .labkey .api .settings .OptionalFeatureService ;
1013import 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 ;
1216import org .springframework .ai .chat .client .ChatClient ;
17+ import org .springframework .ai .chat .model .ToolContext ;
18+ import org .springframework .ai .mcp .annotation .provider .resource .SyncMcpResourceProvider ;
1319import org .springframework .ai .support .ToolCallbacks ;
1420import org .springframework .ai .tool .ToolCallback ;
1521import org .springframework .ai .tool .ToolCallbackProvider ;
1925import java .util .List ;
2026import 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<? 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+ ///
3182public 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 );
0 commit comments