Skip to content

Commit dc31b77

Browse files
Merge branch 'develop' into fb_dataclassLock
2 parents 8aed97f + f3972f4 commit dc31b77

73 files changed

Lines changed: 3215 additions & 537 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

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
}

api/src/org/labkey/api/data/DbScope.java

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,6 @@
5050
import org.labkey.api.util.GUID;
5151
import org.labkey.api.util.LoggerWriter;
5252
import org.labkey.api.util.MemTracker;
53-
import org.labkey.api.util.Pair;
5453
import org.labkey.api.util.ResultSetUtil;
5554
import org.labkey.api.util.SimpleLoggerWriter;
5655
import org.labkey.api.util.SkipMothershipLogging;
@@ -3271,9 +3270,8 @@ public void testLockTimeout()
32713270
{
32723271
ReentrantLock lock1 = new ReentrantLock();
32733272
ReentrantLock lock2 = new ReentrantLock();
3274-
Pair<Throwable, Throwable> throwables = attemptToDeadlock(lock1, lock2, (x) -> ((TransactionImpl)x).setLockTimeout(5, TimeUnit.SECONDS));
3273+
attemptToDeadlock(lock1, lock2, (x) -> ((TransactionImpl)x).setLockTimeout(5, TimeUnit.SECONDS), DeadlockPreventingException.class);
32753274

3276-
assertTrue(throwables.first instanceof DeadlockPreventingException || throwables.second instanceof DeadlockPreventingException);
32773275
assertFalse("Lock 1 is still locked", lock1.isLocked());
32783276
assertFalse("Lock 2 is still locked", lock2.isLocked());
32793277
}
@@ -3394,18 +3392,13 @@ public void testServerRowLock()
33943392
Lock lockUser = new ServerPrimaryKeyLock(true, CoreSchema.getInstance().getTableInfoUsersData(), user.getUserId());
33953393
Lock lockHome = new ServerPrimaryKeyLock(true, CoreSchema.getInstance().getTableInfoContainers(), ContainerManager.getHomeContainer().getId());
33963394

3397-
Pair<Throwable, Throwable> throwables = attemptToDeadlock(lockUser, lockHome, (x) -> {});
3398-
3399-
assertTrue("Unexpected exceptions: " + throwables.first + "\n" + throwables.second, throwables.first instanceof PessimisticLockingFailureException || throwables.second instanceof PessimisticLockingFailureException );
3395+
attemptToDeadlock(lockUser, lockHome, (x) -> {}, PessimisticLockingFailureException.class);
34003396
}
34013397

3402-
/**
3403-
* @return foreground and background thread exceptions
3404-
*/
3405-
private Pair<Throwable, Throwable> attemptToDeadlock(Lock lock1, Lock lock2, @NotNull Consumer<Transaction> transactionModifier)
3398+
private void attemptToDeadlock(Lock lock1, Lock lock2, @NotNull Consumer<Transaction> transactionModifier, Class<? extends Throwable> expectedException)
34063399
{
34073400
final Object notifier = new Object();
3408-
final Pair<Throwable, Throwable> result = new Pair<>(null, null);
3401+
final List<Throwable> result = new ArrayList<>();
34093402

34103403
// let's try to intentionally cause a deadlock
34113404
Thread bkg = new Thread(() -> {
@@ -3431,7 +3424,7 @@ private Pair<Throwable, Throwable> attemptToDeadlock(Lock lock1, Lock lock2, @No
34313424
}
34323425
catch (Throwable x)
34333426
{
3434-
result.second = x;
3427+
result.add(x);
34353428
}
34363429
}
34373430
});
@@ -3465,7 +3458,7 @@ private Pair<Throwable, Throwable> attemptToDeadlock(Lock lock1, Lock lock2, @No
34653458
}
34663459
catch (Throwable x)
34673460
{
3468-
result.first = x;
3461+
result.add(x);
34693462
}
34703463
finally
34713464
{
@@ -3479,7 +3472,13 @@ private Pair<Throwable, Throwable> attemptToDeadlock(Lock lock1, Lock lock2, @No
34793472
}
34803473
}
34813474
}
3482-
return result;
3475+
3476+
assertFalse("No exceptions from attempted deadlock.", result.isEmpty());
3477+
result.stream().filter(t -> !expectedException.isAssignableFrom(t.getClass()))
3478+
.findAny().ifPresent(t -> {
3479+
throw new AssertionError("Wrong error from deadlock. expected: <" +
3480+
expectedException.getSimpleName() + ">, but was <" + t.getClass().getSimpleName() + ">", t);
3481+
});
34833482
}
34843483

34853484
@Test
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+
}

0 commit comments

Comments
 (0)