diff --git a/api/src/org/labkey/api/mcp/McpException.java b/api/src/org/labkey/api/mcp/McpException.java new file mode 100644 index 00000000000..e9270a8f88f --- /dev/null +++ b/api/src/org/labkey/api/mcp/McpException.java @@ -0,0 +1,11 @@ +package org.labkey.api.mcp; + +// A special exception that MCP endpoints can throw when they want to provide guidance to the client without making +// it a big red error. The message will be extracted and sent as text to the client. +public class McpException extends RuntimeException +{ + public McpException(String message) + { + super(message); + } +} diff --git a/api/src/org/labkey/api/mcp/McpService.java b/api/src/org/labkey/api/mcp/McpService.java index 67854a58c61..6112a729f9c 100644 --- a/api/src/org/labkey/api/mcp/McpService.java +++ b/api/src/org/labkey/api/mcp/McpService.java @@ -5,11 +5,16 @@ import jakarta.servlet.http.HttpSession; import org.jetbrains.annotations.NotNull; import org.jspecify.annotations.NonNull; +import org.labkey.api.data.Container; import org.labkey.api.module.McpProvider; +import org.labkey.api.security.User; import org.labkey.api.services.ServiceRegistry; +import org.labkey.api.usageMetrics.SimpleMetricsService; import org.labkey.api.util.HtmlString; -import org.springaicommunity.mcp.provider.resource.SyncMcpResourceProvider; +import org.labkey.api.writer.ContainerUser; import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.model.ToolContext; +import org.springframework.ai.mcp.annotation.provider.resource.SyncMcpResourceProvider; import org.springframework.ai.support.ToolCallbacks; import org.springframework.ai.tool.ToolCallback; import org.springframework.ai.tool.ToolCallbackProvider; @@ -31,7 +36,28 @@ public interface McpService extends ToolCallbackProvider { // marker interface for classes that we will "ingest" using Spring annotations - interface McpImpl {} + interface McpImpl + { + default ContainerUser getContext(ToolContext toolContext) + { + User user = (User)toolContext.getContext().get("user"); + Container container = (Container)toolContext.getContext().get("container"); + if (container == null) + throw new McpException("You need to set a container path before invoking this tool"); + return ContainerUser.create(container, user); + } + + default User getUser(ToolContext toolContext) + { + return (User)toolContext.getContext().get("user"); + } + + // Every MCP resource should call this on every invocation + default void incrementResourceReadCount(String resource) + { + SimpleMetricsService.get().increment("core", "mcpResourceReads", resource); + } + } static @NotNull McpService get() { diff --git a/core/src/org/labkey/core/CoreMcp.java b/core/src/org/labkey/core/CoreMcp.java index 06482fe5627..55ef74486da 100644 --- a/core/src/org/labkey/core/CoreMcp.java +++ b/core/src/org/labkey/core/CoreMcp.java @@ -1,31 +1,35 @@ package org.labkey.core; import org.json.JSONObject; +import org.labkey.api.collections.LabKeyCollectors; import org.labkey.api.data.Container; -import org.labkey.api.mcp.McpContext; +import org.labkey.api.data.ContainerManager; import org.labkey.api.mcp.McpService; import org.labkey.api.security.User; +import org.labkey.api.security.permissions.ReadPermission; import org.labkey.api.settings.AppProps; import org.labkey.api.settings.LookAndFeelProperties; import org.labkey.api.study.Study; import org.labkey.api.study.StudyService; import org.labkey.api.util.HtmlString; +import org.springframework.ai.chat.model.ToolContext; import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.annotation.ToolParam; import java.util.Map; import java.util.Objects; import static org.apache.commons.lang3.StringUtils.isNotBlank; +import static org.labkey.core.mcp.McpServiceImpl.PATH_CACHE; public class CoreMcp implements McpService.McpImpl { - // TODO ChatSessions are currently per session. The McpService should detect change of folder. - @Tool(description = "Call this tool before answering any prompts! This tool provides useful context information about the current user (name, userid), webserver (name, url, description), and current folder (name, path, url, description).") - String whereAmIWhoAmITalkingTo() + @Tool(description = "Call this tool before answering any prompts! This tool provides useful context information about the current user (name, userid), webserver (name, url, description), and current folder (name, path, url, description).") + String whereAmIWhoAmITalkingTo(ToolContext context) { - McpContext context = McpContext.get(); - User user = context.getUser(); - Container folder = context.getContainer(); + var cu = getContext(context); + User user = cu.getUser(); + Container folder = cu.getContainer(); AppProps appProps = AppProps.getInstance(); Study study = null != StudyService.get() ? Objects.requireNonNull(StudyService.get()).getStudy(folder) : null; LookAndFeelProperties laf = LookAndFeelProperties.getInstance(folder); @@ -63,4 +67,27 @@ String whereAmIWhoAmITalkingTo() "site", siteObj )).toString(); } + + @Tool(description = "List the hierarchical path for every container in the server where the user has read permissions.") + String listContainers(ToolContext toolContext) + { + return ContainerManager.getAllChildren(ContainerManager.getRoot(), getUser(toolContext), ReadPermission.class) + .stream() + .map(Container::getPath) + .collect(LabKeyCollectors.toJSONArray()) + .toString(); + } + + @Tool(description = "Every tool in this MCP requires a container path, e.g. /MyProject/MyFolder. A container is also called a folder or project. Please prompt the user for a container path and use this tool to save the path for this session.") + String setContainer(ToolContext context, @ToolParam(description = "Container path, e.g. /MyProject/MyFolder", required = true) String containerPath) + { + Container container = ContainerManager.getForPath(containerPath); + if (container != null) + { + PATH_CACHE.put((String) context.getContext().get("sessionId"), containerPath); + return "OK!"; + } + + return "That's not a valid container path. Try using listContainers to see them."; + } } diff --git a/core/src/org/labkey/core/mcp/McpServiceImpl.java b/core/src/org/labkey/core/mcp/McpServiceImpl.java index 0937f308a20..6c74fe02fc4 100644 --- a/core/src/org/labkey/core/mcp/McpServiceImpl.java +++ b/core/src/org/labkey/core/mcp/McpServiceImpl.java @@ -1,9 +1,11 @@ package org.labkey.core.mcp; +import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.google.genai.Client; import com.google.genai.types.ClientOptions; -import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.common.McpTransportContext; +import io.modelcontextprotocol.json.McpJsonDefaults; import io.modelcontextprotocol.server.McpServer; import io.modelcontextprotocol.server.McpServerFeatures; import io.modelcontextprotocol.server.McpSyncServer; @@ -15,17 +17,22 @@ import jakarta.servlet.ServletResponse; import jakarta.servlet.http.HttpServlet; import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletRequestWrapper; import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpSession; -import org.apache.commons.lang3.StringUtils; import org.apache.logging.log4j.Logger; import org.jetbrains.annotations.NotNull; import org.jspecify.annotations.NonNull; +import org.labkey.api.cache.Cache; +import org.labkey.api.cache.CacheManager; import org.labkey.api.collections.CopyOnWriteHashMap; +import org.labkey.api.data.Container; +import org.labkey.api.data.ContainerManager; import org.labkey.api.markdown.MarkdownService; import org.labkey.api.mcp.McpContext; +import org.labkey.api.mcp.McpException; import org.labkey.api.mcp.McpService; +import org.labkey.api.security.User; +import org.labkey.api.usageMetrics.SimpleMetricsService; import org.labkey.api.util.ContextListener; import org.labkey.api.util.FileUtil; import org.labkey.api.util.HtmlString; @@ -35,14 +42,6 @@ import org.labkey.api.util.logging.LogHelper; import org.springframework.ai.anthropic.AnthropicChatModel; import org.springframework.ai.anthropic.AnthropicChatOptions; -import org.springframework.ai.google.genai.common.GoogleGenAiThinkingLevel; -import org.springframework.ai.openai.OpenAiChatModel; -import org.springframework.ai.openai.OpenAiChatOptions; -import org.springframework.ai.openai.OpenAiEmbeddingModel; -import org.springframework.ai.openai.OpenAiEmbeddingOptions; -import org.springframework.ai.openai.api.OpenAiApi; -import org.springframework.ai.document.MetadataMode; -import org.springframework.ai.anthropic.api.AnthropicApi; import org.springframework.ai.chat.client.ChatClient; import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor; import org.springframework.ai.chat.client.advisor.api.Advisor; @@ -53,37 +52,41 @@ import org.springframework.ai.chat.memory.MessageWindowChatMemory; import org.springframework.ai.chat.model.ChatModel; import org.springframework.ai.chat.model.Generation; +import org.springframework.ai.chat.model.ToolContext; import org.springframework.ai.chat.prompt.ChatOptions; import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.document.Document; +import org.springframework.ai.document.MetadataMode; import org.springframework.ai.embedding.EmbeddingModel; import org.springframework.ai.google.genai.GoogleGenAiChatModel; import org.springframework.ai.google.genai.GoogleGenAiChatOptions; import org.springframework.ai.google.genai.GoogleGenAiEmbeddingConnectionDetails; import org.springframework.ai.google.genai.text.GoogleGenAiTextEmbeddingModel; import org.springframework.ai.google.genai.text.GoogleGenAiTextEmbeddingOptions; -import org.springframework.ai.mcp.McpToolUtils; -import org.springframework.ai.chat.model.ToolContext; +import org.springframework.ai.openai.OpenAiChatModel; +import org.springframework.ai.openai.OpenAiChatOptions; +import org.springframework.ai.openai.OpenAiEmbeddingModel; +import org.springframework.ai.openai.OpenAiEmbeddingOptions; +import org.springframework.ai.openai.api.OpenAiApi; import org.springframework.ai.tool.ToolCallback; import org.springframework.ai.tool.definition.ToolDefinition; +import org.springframework.ai.tool.execution.ToolExecutionException; import org.springframework.ai.tool.metadata.ToolMetadata; -import org.springframework.ai.document.Document; import org.springframework.ai.vectorstore.SearchRequest; import org.springframework.ai.vectorstore.SimpleVectorStore; import org.springframework.ai.vectorstore.VectorStore; import org.springframework.ai.vectorstore.filter.Filter; -import org.springframework.mock.web.MockHttpServletRequest; -import org.springframework.mock.web.MockHttpServletResponse; import reactor.core.publisher.Mono; import java.io.IOException; -import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.ArrayList; import java.util.Arrays; import java.util.ConcurrentModificationException; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.NoSuchElementException; -import java.util.Objects; import java.util.function.Supplier; import static org.apache.commons.lang3.StringUtils.isBlank; @@ -204,16 +207,17 @@ public void registerResources(@NotNull List tools() { - McpJsonMapper mapper = McpJsonMapper.getDefault(); return toolMap.values().stream().map(ToolCallback::getToolDefinition).map(td -> - McpSchema.Tool.builder() - .name(td.name()) - .description(td.description()) - .inputSchema(mapper, td.inputSchema()) - .build() + McpSchema.Tool.builder() + .name(td.name()) + .description(td.description()) + .inputSchema(McpJsonDefaults.getMapper(), td.inputSchema()) + .build() ).toList(); } + public final static Cache PATH_CACHE = CacheManager.getCache(1000, CacheManager.DAY, "MCP container paths"); + private class _McpServlet extends HttpServlet // wraps HttpServletSseServerTransportProvider { HttpServletStreamableServerTransportProvider transportProvider; @@ -222,24 +226,102 @@ private class _McpServlet extends HttpServlet // wraps HttpServletSseServerTrans _McpServlet(ObjectMapper objectMapper, String messageEndpoint, String sseEndpoint) { transportProvider = HttpServletStreamableServerTransportProvider.builder() - .jsonMapper(McpJsonMapper.getDefault()) - .mcpEndpoint(messageEndpoint) - .build(); + .jsonMapper(McpJsonDefaults.getMapper()) + .mcpEndpoint(messageEndpoint) + .contextExtractor(req -> { + User user = (User) req.getUserPrincipal(); + return McpTransportContext.create(Map.of( + "user", user + )); + }) + .build(); } void startMcpServer() { - List tools = Arrays.stream(getToolCallbacks()).map(McpToolUtils::toSyncToolSpecification).toList(); + List tools = Arrays.stream(getToolCallbacks()) + .map(this::toSyncToolSpecification) + .toList(); + List resources = new ArrayList<>(resourceMap.values()); mcpServer = McpServer.sync(transportProvider) - .tools(tools) - .resources(resources) + .tools(tools) + .resources(resources) // .capabilities(new McpSchema.ServerCapabilities()) - .build(); + .build(); ContextListener.addShutdownListener(new _ShutdownListener()); } + private McpServerFeatures.SyncToolSpecification toSyncToolSpecification(ToolCallback toolCallback) + { + var toolDef = toolCallback.getToolDefinition(); + var schema = McpSchema.Tool.builder() + .name(toolDef.name()) + .description(toolDef.description()) + .inputSchema(McpJsonDefaults.getMapper(), toolDef.inputSchema()) + .build(); + + return new McpServerFeatures.SyncToolSpecification(schema, (exchange, request) -> { + var transportCtx = exchange.transportContext(); + var user = (User) transportCtx.get("user"); + var sessionId = exchange.sessionId(); + + Map map = new HashMap<>(); + map.put("user", user); + map.put("sessionId", sessionId); + + String containerPath = PATH_CACHE.get(exchange.sessionId()); // TODO: Cache Container IDs instead of paths? + if (containerPath != null) + { + Container container = ContainerManager.getForPath(containerPath); + map.put("container", container); + map.put("containerPath", containerPath); + } + + var toolContext = new ToolContext(map); + + String toolInput = /* serialize args to JSON */ null; + try + { + toolInput = JsonUtil.DEFAULT_MAPPER.writeValueAsString(request.arguments()); + } + catch (JsonProcessingException e) + { + throw new RuntimeException(e); + } + + String result; + + try + { + SimpleMetricsService.get().increment("core", "mcpToolInvocations", request.name()); + result = toolCallback.call(toolInput, toolContext); + } + catch (ToolExecutionException e) + { + // If a tool threw McpException then just send back the message, not as an error + if (e.getCause() instanceof McpException) + result = e.getCause().getMessage(); + else + throw e; + } + catch (Throwable t) + { + // Set a breakpoint below to inspect exceptions during development + throw t; + } + + // Responses and McpExceptions are treated as "success". Tools should throw for true error conditions. + return new McpSchema.CallToolResult( + List.of(new McpSchema.TextContent(result)), + false, + null, + null + ); + }); + } + @Override public void service(ServletRequest sreq, ServletResponse sres) throws ServletException, IOException { @@ -255,34 +337,6 @@ public void service(ServletRequest sreq, ServletResponse sres) throws ServletExc return; } - if ("POST".equals(req.getMethod())) - { - if (null == req.getParameter("sessionId") && null == req.getSession(true).getAttribute("McpServiceImpl#mcpSessionId")) - { - // USE SSE endpoint to get a sessionId - MockHttpServletRequest mockRequest = new MockHttpServletRequest(req.getServletContext(), "GET", SSE_ENDPOINT); - mockRequest.setAsyncSupported(true); - MockHttpServletResponse mockResponse = new MockHttpServletResponse(); - transportProvider.service(mockRequest, mockResponse); - String body = new String(mockResponse.getContentAsByteArray(), StandardCharsets.UTF_8); - String mcpSessionId = StringUtils.substringBetween(body, "sessionId=", "\n"); - req.getSession(true).setAttribute("McpServiceImpl#mcpSessionId", mcpSessionId); - mockRequest.close(); - mockResponse.getOutputStream().close(); - } - - req = new HttpServletRequestWrapper(req) - { - @Override - public String getParameter(String name) - { - var ret = super.getParameter(name); - if (null == ret && "sessionId".equals(name)) - return String.valueOf(Objects.requireNonNull(((HttpServletRequest) getRequest()).getSession(true).getAttribute("McpServiceImpl#mcpSessionId"))); - return ret; - } - }; - } transportProvider.service(req, res); } @@ -336,25 +390,25 @@ private static class _LoggingVectorStore implements VectorStore } @Override - public void add(List documents) + public void add(@NotNull List documents) { delegate.add(documents); } @Override - public void delete(Filter.Expression filterExpression) + public void delete(@NotNull Filter.Expression filterExpression) { delegate.delete(filterExpression); } @Override - public void delete(List idList) + public void delete(@NotNull List idList) { delegate.delete(idList); } @Override - public List similaritySearch(SearchRequest request) + public @NotNull List similaritySearch(SearchRequest request) { LOG.info("Vector store search: query=\"{}\"", request.getQuery()); List results = delegate.similaritySearch(request); @@ -376,7 +430,7 @@ public List similaritySearch(SearchRequest request) } @Override - public String getName() + public @NotNull String getName() { return delegate.getName(); } @@ -740,6 +794,7 @@ Client getLlmClient() │ labels │ Map │ Custom labels attached to requests │ └───────────────────────┴────────────────────────────────┴────────────────────────────────────┘ */ + @Override public GoogleGenAiChatOptions getChatOptions() { GoogleGenAiChatOptions chatOptions = GoogleGenAiChatOptions.builder() @@ -749,6 +804,7 @@ public GoogleGenAiChatOptions getChatOptions() return chatOptions; } + @Override public ChatModel getChatModel() { Client genAiClient = getLlmClient(); @@ -835,25 +891,22 @@ public String getEmbeddingModel() return null; } + @Override public AnthropicChatOptions getChatOptions() { - AnthropicChatOptions chatOptions = AnthropicChatOptions.builder() - .model(getModel()) - .toolCallbacks(getToolCallbacks()) - .build(); - return chatOptions; + return AnthropicChatOptions.builder() + .model(getModel()) + .apiKey(System.getenv("CLAUDE_API_KEY")) + .toolCallbacks(getToolCallbacks()) + .build(); } + @Override public AnthropicChatModel getChatModel() { - AnthropicChatOptions chatOptions = getChatOptions(); - AnthropicApi api = AnthropicApi.builder() - .apiKey(System.getenv("CLAUDE_API_KEY")) - .build(); - AnthropicChatModel chatModel = AnthropicChatModel.builder() - .anthropicApi(api) - .build(); - return chatModel; + return AnthropicChatModel.builder() + .options(getChatOptions()) + .build(); } @Override diff --git a/query/src/org/labkey/query/controllers/QueryMcp.java b/query/src/org/labkey/query/controllers/QueryMcp.java index 99bb2df99c0..c551bc534c2 100644 --- a/query/src/org/labkey/query/controllers/QueryMcp.java +++ b/query/src/org/labkey/query/controllers/QueryMcp.java @@ -5,14 +5,11 @@ import org.apache.commons.lang3.StringUtils; import org.json.JSONArray; import org.json.JSONObject; -import org.labkey.api.action.SpringActionController; import org.labkey.api.collections.CaseInsensitiveHashSet; import org.labkey.api.data.ColumnInfo; -import org.labkey.api.data.ContainerManager; import org.labkey.api.data.PropertyManager; import org.labkey.api.data.TableDescription; import org.labkey.api.data.TableInfo; -import org.labkey.api.mcp.McpContext; import org.labkey.api.mcp.McpService; import org.labkey.api.query.DefaultSchema; import org.labkey.api.query.QueryDefinition; @@ -22,9 +19,10 @@ import org.labkey.api.query.SchemaKey; import org.labkey.api.query.SimpleSchemaTreeVisitor; import org.labkey.api.query.UserSchema; -import org.labkey.api.security.UserManager; +import org.labkey.api.writer.ContainerUser; import org.labkey.query.sql.SqlParser; -import org.springaicommunity.mcp.annotation.McpResource; +import org.springframework.ai.chat.model.ToolContext; +import org.springframework.ai.mcp.annotation.McpResource; import org.springframework.ai.tool.annotation.Tool; import org.springframework.ai.tool.annotation.ToolParam; @@ -36,8 +34,6 @@ import static org.apache.commons.lang3.StringUtils.isNotBlank; -/* TODO: integrate ToolContext support */ - public class QueryMcp implements McpService.McpImpl { @McpResource( @@ -47,99 +43,61 @@ public class QueryMcp implements McpService.McpImpl description = "Provide documentation for LabKey SQL specific syntax") public McpSchema.ReadResourceResult getLabKeySQLDocumentation() throws IOException { + incrementResourceReadCount("LabKey SQL"); String markdown = IOUtils.resourceToString("org/labkey/query/controllers/LabKeySql.md", null, QueryController.class.getClassLoader()); return new McpSchema.ReadResourceResult(List.of( - new McpSchema.TextResourceContents( - "resource://org/labkey/query/controllers/LabKeySql.md", - "application/markdown", - markdown) + new McpSchema.TextResourceContents( + "resource://org/labkey/query/controllers/LabKeySql.md", + "application/markdown", + markdown + ) )); } - - @Tool(description = "Provide column metadata for a sql table. This tool will also return SQL source for saved queries.") - String listColumnMetaData(@ToolParam(description = "Fully qualified table name as it would appear in SQL e.g. \"schema\".\"table\"") String fullQuotedTableName) + @Tool(description = "Provide column metadata for a sql table. This tool will also return SQL source for saved queries.") + String listColumns(ToolContext toolContext, @ToolParam(description = "Fully qualified table name as it would appear in SQL e.g. \"schema\".\"table\"") String fullQuotedTableName) { - var json = _listColumnsForTable(fullQuotedTableName); + var json = _listColumns(fullQuotedTableName, toolContext); // can I just return a JSONObject return json.toString(); } @Tool(description = "Provide list of tables within the provided schema.") - String listTablesForSchema(@ToolParam(description = "Fully qualified schema name as it would appear in SQL e.g. \"schema\"") String quotedSchemaName) + String listTables(ToolContext toolContext, @ToolParam(description = "Fully qualified schema name as it would appear in SQL e.g. \"schema\"") String quotedSchemaName) { - var json = _listTablesForSchema(quotedSchemaName); + var json = _listTables(quotedSchemaName, getContext(toolContext)); // can I just return a JSONObject return json.toString(); } @Tool(description = "Provide list of database schemas") - String listSchemas() + String listSchemas(ToolContext toolContext) { - McpContext context = getContext(); - var map = _listAllSchemas(DefaultSchema.get(context.getUser(), context.getContainer())); + ContainerUser cu = getContext(toolContext); + var map = _listAllSchemas(DefaultSchema.get(cu.getUser(), cu.getContainer())); var array = new JSONArray(); for (var entry : map.entrySet()) { - array.put(new JSONObject(Map.of( - "name", entry.getKey().getName(), - "quotedName", entry.getKey().toSQLString(), - "description", StringUtils.trimToEmpty(entry.getValue().getDescription()) - ))); + array.put(new JSONObject(Map.of( + "name", entry.getKey().getName(), + "quotedName", entry.getKey().toSQLString(), + "description", StringUtils.trimToEmpty(entry.getValue().getDescription()) + ))); } return new JSONObject(Map.of("success", "true", "schemas", array)).toString(); } @Tool(description = "Provide the SQL source for a saved query.") - String getSourceForSavedQuery(@ToolParam(description = "Fully qualified query name as it would appear in SQL e.g. \"schema\".\"table or query\"") String fullQuotedTableName) + String getSourceForSavedQuery(ToolContext toolContext, @ToolParam(description = "Fully qualified query name as it would appear in SQL e.g. \"schema\".\"table or query\"") String fullQuotedTableName) { - var json = _listTablesForSchema(fullQuotedTableName); + var json = _listTables(fullQuotedTableName, getContext(toolContext)); if (json.has("sql")) return "```sql\n" + json.getString("sql") + "\n```\n"; else return "I could not find the source for " + fullQuotedTableName; } - - @Tool(description = """ - Save addition information for database columns. If additional metadata is gathered via - chat, it can be saved to improve further interactions. - """) - String saveColumnDescription( - @ToolParam(description = "Fully qualified table or query name as it would appear in SQL e.g. \"schema\".\"table or query\"") - String fullQuotedTableName, - @ToolParam(description = "Quoted column name as it would appear in SQL e.g. \"column name\"") - String quotedColumnName, - @ToolParam(description = "Additional metadata to remember for future use. This will replace any currently saved value") - String columnMetadata - ) - { - McpContext context = McpContext.get(); - var map = PropertyManager.getWritableProperties(context.getContainer(), "QueryMCP.annotations", true); - String fullPath = normalizeIdentifier(fullQuotedTableName + "." + quotedColumnName); - map.put(fullPath, columnMetadata); - try (var ignore = SpringActionController.ignoreSqlUpdates()) - { - map.save(); - } - return new JSONObject(Map.of("success",Boolean.TRUE)).toString(); - } - - /* TODO McpContext setup */ - - static McpContext getContext() - { - try - { - return McpContext.get(); - } - catch (Exception x) - { - return new McpContext(ContainerManager.getHomeContainer(), UserManager.getGuestUser()); - } - } - /* For now, list all schemas. CONSIDER support incremental querying. */ public static Map _listAllSchemas(DefaultSchema root) { @@ -179,7 +137,7 @@ public Map reduce(Map r1, Map