chore: import upstream snapshot with attribution
Integration Tests - MySQL + Elasticsearch / Detect Changes (push) Has been cancelled
Integration Tests - MySQL + Elasticsearch / integration-tests-mysql-elasticsearch (push) Has been cancelled
Integration Tests - PostgreSQL + Elasticsearch + Redis / Detect Changes (push) Has been cancelled
Integration Tests - PostgreSQL + Elasticsearch + Redis / integration-tests-postgres-elasticsearch-redis (push) Has been cancelled
Integration Tests - PostgreSQL + OpenSearch / Detect Changes (push) Has been cancelled
Integration Tests - PostgreSQL + OpenSearch / integration-tests-postgres-opensearch (push) Has been cancelled
Java Checkstyle / java-checkstyle (push) Has been cancelled
Maven Collate Tests / maven-collate-ci (push) Has been cancelled
OpenMetadata Service Unit Tests / openmetadata-service-unit-tests-status (push) Has been cancelled
Publish Package to Maven Central Repository / publish-maven-packages (push) Has been cancelled
OpenMetadata Service Unit Tests / Detect Changes (push) Has been cancelled
OpenMetadata Service Unit Tests / openmetadata-service-unit-tests (push) Has been cancelled
OpenMetadata Service Unit Tests / k8s_operator-unit-tests (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:35:45 +08:00
commit bf2343b7e4
16049 changed files with 3531137 additions and 0 deletions
@@ -0,0 +1,131 @@
package org.openmetadata.mcp;
import io.modelcontextprotocol.common.McpTransportContext;
import io.modelcontextprotocol.server.McpTransportContextExtractor;
import jakarta.servlet.http.HttpServletRequest;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.function.Predicate;
import org.openmetadata.service.security.JwtFilter;
public class AuthEnrichedMcpContextExtractor
implements McpTransportContextExtractor<HttpServletRequest> {
public static final String AUTHORIZATION_HEADER = "Authorization";
/**
* Context key carrying the resolved client name (Claude Desktop / Cursor / VS Code / etc.)
* derived from the User-Agent header. Stamped on every {@link
* org.openmetadata.schema.entity.app.mcp.McpToolCallUsage} row so the Billing > MCP page can
* group by client. Kept as a constant on this class so producer + consumer share the spelling.
*/
public static final String CLIENT_NAME = "Mcp-Client-Name";
private static final String USER_AGENT_HEADER = "User-Agent";
/**
* Upper bound on the persisted client name. The value comes from a user-controlled
* {@code User-Agent} header, so we cap it to prevent oversized strings or junk characters from
* leaking into the {@code McpToolCallUsage} rows. Mirrors {@code maxLength} on the
* {@code clientName} property in {@code mcpToolCallUsage.json}.
*/
static final int MAX_CLIENT_NAME_LENGTH = 64;
/**
* Ordered list of (predicate, label) pairs used to classify a lower-cased User-Agent into a
* human-readable client name. Order matters — VS Code is checked before Claude CLI so a UA
* containing both {@code claude} and {@code code} substrings (e.g. a Claude extension hosted in
* VS Code) is attributed to the host process rather than the standalone CLI.
*/
private static final List<UaMatcher> UA_MATCHERS =
List.of(
new UaMatcher(ua -> ua.contains("claude") && ua.contains("desktop"), "Claude Desktop"),
new UaMatcher(
ua ->
ua.contains("vscode")
|| ua.contains("vs code")
|| ua.contains("visual studio code"),
"VS Code"),
new UaMatcher(
ua ->
ua.contains("claude-cli")
|| ua.contains("claude-code")
|| ua.contains("claude cli")
|| ua.contains("claude code"),
"Claude CLI"),
new UaMatcher(ua -> ua.contains("cursor"), "Cursor"),
new UaMatcher(ua -> ua.contains("zed"), "Zed"),
new UaMatcher(ua -> ua.contains("windsurf"), "Windsurf"));
/** Pairing of a User-Agent substring predicate with the label shown in the dashboard. */
private record UaMatcher(Predicate<String> matches, String label) {}
@Override
public McpTransportContext extract(HttpServletRequest request) {
String token = JwtFilter.extractToken(request.getHeader(AUTHORIZATION_HEADER));
String clientName = resolveClientName(request.getHeader(USER_AGENT_HEADER));
Map<String, Object> values = new HashMap<>();
values.put(AUTHORIZATION_HEADER, token != null ? token : "");
if (clientName != null) {
values.put(CLIENT_NAME, clientName);
}
return McpTransportContext.create(values);
}
/**
* Heuristic: classify the {@code User-Agent} into the labels the dashboard uses (Claude Desktop,
* Cursor, VS Code, Claude CLI, etc.). MCP clients all set distinctive UAs — the table at
* {@link #UA_MATCHERS} covers the common ones explicitly and we fall back to the raw product
* token so a new client still surfaces in the breakdown without a code change. The fallback
* value (and every label) is sanitised before return so the persisted value is bounded.
*/
static String resolveClientName(String userAgent) {
String resolved = null;
if (userAgent != null && !userAgent.isBlank()) {
String ua = userAgent.toLowerCase(Locale.ROOT);
resolved =
UA_MATCHERS.stream()
.filter(matcher -> matcher.matches().test(ua))
.map(UaMatcher::label)
.findFirst()
.orElseGet(() -> fallbackProductToken(userAgent));
}
return sanitize(resolved);
}
/**
* First product token of the User-Agent (the bit before the first slash or space), capitalised.
* Avoids leaking version strings into the dashboard while still surfacing unknown clients with a
* human-readable label. Returns {@code null} when no usable token is present.
*/
private static String fallbackProductToken(String userAgent) {
String head = userAgent.split("[\\s/]", 2)[0];
String result = null;
if (!head.isBlank()) {
result = head.substring(0, 1).toUpperCase(Locale.ROOT) + head.substring(1);
}
return result;
}
/**
* Trims whitespace, strips ISO control characters, and caps the value to
* {@link #MAX_CLIENT_NAME_LENGTH} characters. The User-Agent header is attacker-controlled so
* the persisted value must be sanitised before it reaches the database or the dashboard.
*/
private static String sanitize(String value) {
String result = null;
if (value != null) {
StringBuilder sb = new StringBuilder(value.length());
value.codePoints().filter(cp -> !Character.isISOControl(cp)).forEach(sb::appendCodePoint);
String stripped = sb.toString().trim();
if (!stripped.isEmpty()) {
result =
stripped.length() > MAX_CLIENT_NAME_LENGTH
? stripped.substring(0, MAX_CLIENT_NAME_LENGTH)
: stripped;
}
}
return result;
}
}
@@ -0,0 +1,410 @@
package org.openmetadata.mcp;
import io.dropwizard.core.setup.Environment;
import io.dropwizard.jetty.MutableServletContextHandler;
import io.modelcontextprotocol.server.McpStatelessServerFeatures;
import io.modelcontextprotocol.server.McpStatelessSyncServer;
import io.modelcontextprotocol.spec.McpSchema;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.jetty.ee10.servlet.ServletHolder;
import org.openmetadata.mcp.prompts.DefaultPromptsContext;
import org.openmetadata.mcp.server.auth.jobs.OAuthTokenCleanupScheduler;
import org.openmetadata.mcp.server.transport.OAuthHttpStatelessServerTransportProvider;
import org.openmetadata.mcp.tools.DefaultToolContext;
import org.openmetadata.mcp.usage.McpUsageRecorder;
import org.openmetadata.schema.utils.JsonUtils;
import org.openmetadata.service.Entity;
import org.openmetadata.service.OpenMetadataApplicationConfig;
import org.openmetadata.service.apps.AbstractNativeApplication;
import org.openmetadata.service.apps.ApplicationContext;
import org.openmetadata.service.apps.McpServerProvider;
import org.openmetadata.service.apps.bundles.mcp.McpAppConstants;
import org.openmetadata.service.limits.Limits;
import org.openmetadata.service.resources.mcpclient.McpClientResource;
import org.openmetadata.service.security.Authorizer;
import org.openmetadata.service.security.ImpersonationContext;
import org.openmetadata.service.security.JwtFilter;
import org.openmetadata.service.security.auth.CatalogSecurityContext;
import org.openmetadata.service.security.auth.SecurityConfigurationManager;
@Slf4j
public class McpServer implements McpServerProvider {
private static final String DEFAULT_MCP_BOT_NAME = McpAppConstants.MCP_APP_NAME + "Bot";
protected JwtFilter jwtFilter;
protected Authorizer authorizer;
protected Limits limits;
protected DefaultToolContext toolContext;
protected DefaultPromptsContext promptsContext;
private Environment environment;
private volatile String mcpBotName;
// Default constructor for dynamic loading
public McpServer() {
this.toolContext = new DefaultToolContext();
this.promptsContext = new DefaultPromptsContext();
}
public McpServer(DefaultToolContext toolContext, DefaultPromptsContext promptsContext) {
this.toolContext = toolContext;
this.promptsContext = promptsContext;
}
@Override
public void initializeMcpServer(
Environment environment,
Authorizer authorizer,
Limits limits,
OpenMetadataApplicationConfig config) {
this.jwtFilter =
new JwtFilter(
SecurityConfigurationManager.getCurrentAuthConfig(),
SecurityConfigurationManager.getCurrentAuthzConfig());
this.authorizer = authorizer;
this.limits = limits;
this.environment = environment;
MutableServletContextHandler contextHandler = environment.getApplicationContext();
List<McpSchema.Tool> tools = getTools();
List<McpSchema.Prompt> prompts = getPrompts();
addStatelessTransport(contextHandler, tools, prompts, config);
registerMcpClientToolExecutor();
}
@SuppressWarnings("unchecked")
private void registerMcpClientToolExecutor() {
try {
McpClientResource mcpClientResource = McpClientResource.getInstance();
if (mcpClientResource == null) {
LOG.warn("McpClientResource not found — MCP Client chat will not have tool access.");
return;
}
String json = McpUtils.getJsonFromFile("json/data/mcp/tools.json");
List<Map<String, Object>> rawTools = McpUtils.loadDefinitionsFromJson(json);
List<Map<String, Object>> llmToolDefs = new ArrayList<>();
for (Map<String, Object> rawTool : rawTools) {
Map<String, Object> functionDef = new HashMap<>();
functionDef.put("name", rawTool.get("name"));
functionDef.put("description", rawTool.get("description"));
if (rawTool.containsKey("parameters")) {
Map<String, Object> params = (Map<String, Object>) rawTool.get("parameters");
Map<String, Object> cleanParams = new HashMap<>(params);
cleanParams.remove("examples");
functionDef.put("parameters", cleanParams);
}
Map<String, Object> tool = new HashMap<>();
tool.put("type", "function");
tool.put("function", functionDef);
llmToolDefs.add(tool);
}
mcpClientResource.registerToolExecutor(
(secCtx, toolName, arguments) -> {
Map<String, Object> args =
(arguments != null && !arguments.isBlank())
? JsonUtils.readValue(arguments, Map.class)
: new HashMap<>();
McpSchema.CallToolRequest request = new McpSchema.CallToolRequest(toolName, args, null);
McpSchema.CallToolResult callResult =
toolContext.callTool(authorizer, limits, toolName, secCtx, request);
return extractToolResultContent(callResult);
},
llmToolDefs);
LOG.info("Registered MCP tool executor with McpClientResource.");
} catch (Exception e) {
LOG.warn("Failed to register MCP Client tool executor: {}", e.getMessage());
}
}
private static String extractToolResultContent(McpSchema.CallToolResult result) {
if (result.content() == null || result.content().isEmpty()) {
return "{}";
}
StringBuilder sb = new StringBuilder();
for (McpSchema.Content content : result.content()) {
if (content instanceof McpSchema.TextContent textContent) {
sb.append(textContent.text());
}
}
return sb.length() > 0 ? sb.toString() : "{}";
}
protected List<McpSchema.Tool> getTools() {
return toolContext.loadToolsDefinitionsFromJson("json/data/mcp/tools.json");
}
protected List<McpSchema.Prompt> getPrompts() {
return promptsContext.loadPromptsDefinitionsFromJson("json/data/mcp/prompts.json");
}
private void addStatelessTransport(
MutableServletContextHandler contextHandler,
List<McpSchema.Tool> tools,
List<McpSchema.Prompt> prompts,
OpenMetadataApplicationConfig config) {
try {
McpSchema.ServerCapabilities serverCapabilities =
McpSchema.ServerCapabilities.builder()
.tools(true)
.prompts(true)
.resources(true, true)
.build();
// Create unified OAuth provider for MCP authentication (supports both SSO and Basic Auth)
// Get base URL from MCP configuration or system settings
String baseUrl = getBaseUrlFromConfig();
LOG.info("MCP OAuth initialized with base URL: {}", baseUrl);
org.openmetadata.service.security.jwt.JWTTokenGenerator jwtGenerator =
org.openmetadata.service.security.jwt.JWTTokenGenerator.getInstance();
// Create the appropriate authenticator based on the current auth provider.
// LDAP uses LdapAuthenticator for credential validation via LDAP bind;
// all other providers use BasicAuthenticator for DB-based validation.
org.openmetadata.service.security.auth.AuthenticatorHandler credentialAuthenticator;
org.openmetadata.schema.api.security.AuthenticationConfiguration currentAuthConfig =
SecurityConfigurationManager.getCurrentAuthConfig();
if (currentAuthConfig != null
&& currentAuthConfig.getProvider()
== org.openmetadata.schema.services.connections.metadata.AuthProvider.LDAP) {
credentialAuthenticator = new org.openmetadata.service.security.auth.LdapAuthenticator();
credentialAuthenticator.init(config);
LOG.info("LdapAuthenticator initialized for MCP OAuth credential validation");
} else {
credentialAuthenticator = new org.openmetadata.service.security.auth.BasicAuthenticator();
credentialAuthenticator.init(config);
LOG.info("BasicAuthenticator initialized for MCP OAuth credential validation");
}
org.openmetadata.mcp.server.auth.provider.UserSSOOAuthProvider authProvider =
new org.openmetadata.mcp.server.auth.provider.UserSSOOAuthProvider(
jwtGenerator, credentialAuthenticator);
// Get allowed origins from MCP configuration (database-backed)
List<String> allowedOrigins = getAllowedOriginsFromConfig();
LOG.info("MCP OAuth CORS: Using allowed origins from configuration: {}", allowedOrigins);
// Initialize OAuth token cleanup scheduler (runs hourly to delete expired tokens)
OAuthTokenCleanupScheduler.initialize();
environment
.lifecycle()
.manage(
new io.dropwizard.lifecycle.Managed() {
@Override
public void start() {}
@Override
public void stop() {
OAuthTokenCleanupScheduler.shutdown();
}
});
OAuthHttpStatelessServerTransportProvider statelessOauthTransport =
new OAuthHttpStatelessServerTransportProvider(
JsonUtils.getObjectMapper(),
baseUrl,
"/mcp",
new AuthEnrichedMcpContextExtractor(),
authProvider,
allowedOrigins);
McpStatelessSyncServer server =
io.modelcontextprotocol.server.McpServer.sync(statelessOauthTransport)
.serverInfo("openmetadata-mcp-stateless", "1.1.0")
.capabilities(serverCapabilities)
.build();
addToolsToServer(server, tools);
addPromptsToServer(server, prompts);
// SSE transport for MCP
ServletHolder servletHolderSSE = new ServletHolder(statelessOauthTransport);
contextHandler.addServlet(servletHolderSSE, "/mcp/*");
// Register Basic Auth / LDAP login handler with the transport provider.
// The /mcp/* wildcard servlet intercepts all /mcp/ paths, so dedicated servlets at
// /mcp/login can't be reached. Instead, the transport provider delegates internally.
org.openmetadata.mcp.server.auth.handlers.BasicAuthLoginServlet basicAuthLoginServlet =
new org.openmetadata.mcp.server.auth.handlers.BasicAuthLoginServlet(
authProvider, credentialAuthenticator);
statelessOauthTransport.setBasicAuthLoginServlet(basicAuthLoginServlet);
LOG.info("Registered Basic Auth login handler in transport provider");
// Register well-known filter via Dropwizard's environment.servlets() API.
// This must use addFilter (not servlet registration) because the OpenMetadataAssetServlet
// at "/*" treats all extension-less paths as SPA routes and serves index.html,
// intercepting /.well-known/* before any exact-path servlet can handle it.
// Filters registered via environment.servlets() run BEFORE any servlet processing.
jakarta.servlet.FilterRegistration.Dynamic wellKnownFilter =
environment
.servlets()
.addFilter("oauth-well-known", new OAuthWellKnownFilter(statelessOauthTransport));
wellKnownFilter.addMappingForUrlPatterns(
java.util.EnumSet.of(jakarta.servlet.DispatcherType.REQUEST), false, "/.well-known/*");
LOG.info("OAuth well-known filter registered for /.well-known/* discovery paths");
// Register MCP state checker so AuthCallbackServlet can forward MCP callbacks.
// SSO providers redirect to /callback (the registered URI), not /mcp/callback.
// This checker lets /callback detect MCP flow and forward to /mcp/callback.
org.openmetadata.mcp.server.auth.repository.McpPendingAuthRequestRepository pendingAuthRepo =
new org.openmetadata.mcp.server.auth.repository.McpPendingAuthRequestRepository();
org.openmetadata.service.security.AuthenticationCodeFlowHandler.setMcpStateChecker(
state -> pendingAuthRepo.findByPac4jState(state) != null);
LOG.info("Registered MCP state checker for SSO callback forwarding");
// Register the SAML MCP bridge so the SAML ACS callback (service module) can hand the
// authenticated identity back to the MCP OAuth flow. SAML carries the MCP authorization
// request id in RelayState ("mcp:{authRequestId}"); SamlAuthServletHandler detects it and
// invokes this handler, which mints the MCP authorization code and redirects to the client.
org.openmetadata.service.security.auth.SamlAuthServletHandler.setMcpSamlCallbackHandler(
(req, resp, username, email, relayState) ->
authProvider.handleSSOCallbackWithDbState(req, resp, username, email, relayState));
LOG.info("Registered MCP SAML callback handler for SAML SSO support");
// Register MCP callback servlet unconditionally — SSO availability is checked at
// request time, not startup time. This follows the same pattern as the regular auth
// servlets (AuthCallbackServlet, AuthLoginServlet) which are always registered and
// dispatch to the appropriate handler at runtime.
contextHandler.addServlet(
new ServletHolder(
new org.openmetadata.mcp.server.auth.handlers.McpCallbackServlet(authProvider)),
"/mcp/callback");
LOG.info("Registered /mcp/callback servlet (runtime SSO dispatch)");
} catch (Exception ex) {
LOG.error(
"MCP server initialization failed — MCP OAuth will be non-functional. "
+ "Check the configuration and restart.",
ex);
}
}
public void addToolsToServer(McpStatelessSyncServer server, List<McpSchema.Tool> tools) {
for (McpSchema.Tool tool : tools) {
server.addTool(getTool(tool));
}
}
public void addPromptsToServer(McpStatelessSyncServer server, List<McpSchema.Prompt> tools) {
for (McpSchema.Prompt pm : tools) {
server.addPrompt(getPrompt(pm));
}
}
private String getMcpBotName() {
if (mcpBotName == null) {
try {
AbstractNativeApplication mcpApp =
ApplicationContext.getInstance().getAppIfExists(McpAppConstants.MCP_APP_NAME);
if (mcpApp != null && mcpApp.getApp().getBot() != null) {
mcpBotName = mcpApp.getApp().getBot().getName();
}
// Don't cache the default — if the app isn't registered yet, retry on next call
} catch (Exception e) {
LOG.debug("Could not resolve MCP bot name from app registry, will retry on next call", e);
}
}
return mcpBotName != null ? mcpBotName : DEFAULT_MCP_BOT_NAME;
}
protected McpStatelessServerFeatures.SyncToolSpecification getTool(McpSchema.Tool tool) {
return new McpStatelessServerFeatures.SyncToolSpecification(
tool,
(context, req) -> {
CatalogSecurityContext securityContext =
jwtFilter.getCatalogSecurityContext((String) context.get("Authorization"));
String userName = securityContext.getUserPrincipal().getName();
String clientName =
(String)
context.get(org.openmetadata.mcp.AuthEnrichedMcpContextExtractor.CLIENT_NAME);
org.openmetadata.mcp.tools.DefaultToolContext.CallToolOutcome outcome = null;
try {
ImpersonationContext.setImpersonatedBy(getMcpBotName());
outcome =
toolContext.callToolWithMetadata(
authorizer, limits, tool.name(), securityContext, req);
return outcome.result();
} finally {
boolean success = outcome != null && !Boolean.TRUE.equals(outcome.result().isError());
Long latencyMs = outcome != null ? outcome.latencyMs() : null;
org.openmetadata.schema.entity.app.mcp.McpToolCallUsage.ErrorCategory category =
outcome != null ? outcome.errorCategory() : null;
McpUsageRecorder.record(
tool.name(), userName, success, latencyMs, category, clientName);
ImpersonationContext.clear();
}
});
}
private McpStatelessServerFeatures.SyncPromptSpecification getPrompt(McpSchema.Prompt prompt) {
return new McpStatelessServerFeatures.SyncPromptSpecification(
prompt,
(exchange, arguments) -> promptsContext.callPrompt(jwtFilter, prompt.name(), arguments));
}
private String getBaseUrlFromConfig() {
try {
org.openmetadata.schema.api.configuration.MCPConfiguration mcpConfig =
SecurityConfigurationManager.getCurrentMcpConfig();
if (mcpConfig != null && mcpConfig.getBaseUrl() != null) {
LOG.info("Base URL retrieved from MCP configuration: {}", mcpConfig.getBaseUrl());
return mcpConfig.getBaseUrl();
}
} catch (Exception e) {
LOG.warn("Failed to get base URL from MCP config: {}", e.getMessage());
}
return getBaseUrlFromSettings();
}
private List<String> getAllowedOriginsFromConfig() {
try {
org.openmetadata.schema.api.configuration.MCPConfiguration mcpConfig =
SecurityConfigurationManager.getCurrentMcpConfig();
if (mcpConfig != null && mcpConfig.getAllowedOrigins() != null) {
return mcpConfig.getAllowedOrigins();
}
} catch (Exception e) {
LOG.error(
"Failed to get allowed origins from MCP config, CORS will reject all origins: {}",
e.getMessage());
}
LOG.error(
"MCP configuration not available. CORS will reject all origins. "
+ "Configure MCP settings via the API to enable cross-origin access.");
return List.of();
}
/**
* Get base URL from system settings, with fallback to localhost for development.
*/
private String getBaseUrlFromSettings() {
try {
org.openmetadata.service.jdbi3.SystemRepository systemRepository =
Entity.getSystemRepository();
if (systemRepository != null) {
org.openmetadata.schema.settings.Settings settings =
systemRepository.getOMBaseUrlConfigInternal();
if (settings != null && settings.getConfigValue() != null) {
org.openmetadata.schema.api.configuration.OpenMetadataBaseUrlConfiguration urlConfig =
(org.openmetadata.schema.api.configuration.OpenMetadataBaseUrlConfiguration)
settings.getConfigValue();
if (urlConfig != null && urlConfig.getOpenMetadataUrl() != null) {
String url = urlConfig.getOpenMetadataUrl();
LOG.info("Base URL retrieved from system settings: {}", url);
return url;
}
}
} else {
LOG.warn("SystemRepository is null during MCP initialization");
}
} catch (Exception e) {
LOG.warn("Could not get instance URL from SystemSettings, using fallback", e);
}
LOG.error(
"No base URL configured in MCP settings or system settings. "
+ "Falling back to http://localhost:8585 — this is only suitable for local development. "
+ "Configure a proper base URL for production deployments.");
return "http://localhost:8585";
}
}
@@ -0,0 +1,140 @@
package org.openmetadata.mcp;
import com.fasterxml.jackson.databind.JsonNode;
import io.modelcontextprotocol.json.McpJsonMapper;
import io.modelcontextprotocol.json.jackson2.JacksonMcpJsonMapper;
import io.modelcontextprotocol.spec.McpSchema;
import jakarta.servlet.http.HttpServletRequest;
import java.io.BufferedReader;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import lombok.extern.slf4j.Slf4j;
import org.openmetadata.common.utils.CommonUtil;
import org.openmetadata.schema.utils.JsonUtils;
import org.openmetadata.service.security.JwtFilter;
@Slf4j
public class McpUtils {
private static final McpJsonMapper JSON_MAPPER =
new JacksonMcpJsonMapper(JsonUtils.getObjectMapper());
@SuppressWarnings("unchecked")
public static McpSchema.JSONRPCMessage getJsonRpcMessageWithAuthorizationParam(
McpJsonMapper jsonMapper, HttpServletRequest request, String body) throws IOException {
Map<String, Object> requestMessage = JsonUtils.getMap(JsonUtils.readTree(body));
Map<String, Object> params = (Map<String, Object>) requestMessage.get("params");
if (params != null) {
Map<String, Object> arguments = (Map<String, Object>) params.get("arguments");
if (arguments != null) {
arguments.put("Authorization", JwtFilter.extractToken(request.getHeader("Authorization")));
}
}
return McpSchema.deserializeJsonRpcMessage(jsonMapper, JsonUtils.pojoToJson(requestMessage));
}
@SuppressWarnings("unchecked")
public static List<Map<String, Object>> loadDefinitionsFromJson(String json) {
try {
LOG.info("Loaded definitions, content length: {}", json.length());
JsonNode jsonNode = JsonUtils.readTree(json);
JsonNode jsonArray = jsonNode.get("tools");
if (jsonArray == null || !jsonArray.isArray()) {
LOG.error("Invalid MCP tools file format. Expected 'tools' array.");
return new ArrayList<>();
}
List<Map<String, Object>> toolOrPrompt = new ArrayList<>();
for (JsonNode toolNode : jsonArray) {
String name = toolNode.get("name").asText();
Map<String, Object> toolDef = JsonUtils.convertValue(toolNode, Map.class);
toolOrPrompt.add(toolDef);
LOG.info("Tool/Prompt found: {} with definition: {}", name, toolDef);
}
LOG.info("Found {} tool/prompts definitions", toolOrPrompt.size());
return toolOrPrompt;
} catch (Exception e) {
LOG.error("Error loading tool definitions: {}", e.getMessage(), e);
throw e;
}
}
public static String getJsonFromFile(String path) {
try {
return CommonUtil.getResourceAsStream(McpServer.class.getClassLoader(), path);
} catch (Exception ex) {
LOG.error("Error loading JSON file: {}", path, ex);
return null;
}
}
public static List<McpSchema.Tool> getToolProperties(String jsonFilePath) {
try {
List<McpSchema.Tool> result = new ArrayList<>();
String json = getJsonFromFile(jsonFilePath);
List<Map<String, Object>> cachedTools = loadDefinitionsFromJson(json);
if (cachedTools == null || cachedTools.isEmpty()) {
LOG.error("No tool definitions were loaded!");
throw new RuntimeException("Failed to load tool definitions");
}
LOG.debug("Successfully loaded {} tool definitions", cachedTools.size());
for (int i = 0; i < cachedTools.size(); i++) {
Map<String, Object> toolDef = cachedTools.get(i);
String name = (String) toolDef.get("name");
String description = (String) toolDef.get("description");
Map<String, Object> schema = JsonUtils.getMap(toolDef.get("parameters"));
result.add(
McpSchema.Tool.builder()
.name(name)
.description(description)
.inputSchema(JSON_MAPPER, JsonUtils.pojoToJson(schema))
.build());
}
return result;
} catch (Exception e) {
LOG.error("Error during server startup", e);
throw new RuntimeException("Failed to start MCP server", e);
}
}
public static List<McpSchema.Prompt> getPrompts(String jsonFilePath) {
try {
String json = getJsonFromFile(jsonFilePath);
if (json == null || json.isEmpty()) {
LOG.error("No prompts definitions were loaded from file: {}", jsonFilePath);
}
List<Map<String, Object>> cachedPrompts = loadDefinitionsFromJson(json);
return cachedPrompts.stream()
.map(
promptDef -> {
String name = (String) promptDef.get("name");
String description = (String) promptDef.get("description");
List<McpSchema.PromptArgument> arguments =
JsonUtils.readOrConvertValues(
promptDef.get("arguments"), McpSchema.PromptArgument.class);
return new McpSchema.Prompt(name, description, arguments);
})
.toList();
} catch (Exception e) {
LOG.error("Error during server startup", e);
throw new RuntimeException("Failed to start MCP server", e);
}
}
public static String readRequestBody(HttpServletRequest request) throws IOException {
StringBuilder body = new StringBuilder();
try (BufferedReader reader = request.getReader()) {
String line;
while ((line = reader.readLine()) != null) {
body.append(line);
}
}
return body.toString();
}
}
@@ -0,0 +1,55 @@
package org.openmetadata.mcp;
import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import lombok.extern.slf4j.Slf4j;
import org.openmetadata.mcp.server.transport.OAuthHttpStatelessServerTransportProvider;
/**
* Filter to handle OAuth well-known endpoints at the root level. Directly delegates to the MCP
* transport provider's metadata handlers instead of forwarding, because the asset servlet's SPA
* routing intercepts forwarded requests.
*/
@Slf4j
public class OAuthWellKnownFilter implements Filter {
private final OAuthHttpStatelessServerTransportProvider transport;
public OAuthWellKnownFilter(OAuthHttpStatelessServerTransportProvider transport) {
this.transport = transport;
}
@Override
public void doFilter(
ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain)
throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse) servletResponse;
String path = request.getRequestURI();
if (path.equals("/.well-known/oauth-authorization-server")
|| path.equals("/.well-known/oauth-authorization-server/mcp")
|| path.equals("/.well-known/openid-configuration")
|| path.equals("/.well-known/openid-configuration/mcp")) {
LOG.debug("Serving OAuth authorization server metadata for: {}", path);
transport.handleMetadata(request, response);
return;
}
if (path.equals("/.well-known/oauth-protected-resource")
|| path.equals("/.well-known/oauth-protected-resource/mcp")) {
LOG.debug("Serving OAuth protected resource metadata for: {}", path);
transport.handleProtectedResourceMetadata(request, response);
return;
}
filterChain.doFilter(servletRequest, servletResponse);
}
}
@@ -0,0 +1,77 @@
package org.openmetadata.mcp.auth;
import java.util.List;
/**
* Represents an OAuth access token.
*/
public class AccessToken {
private String token;
private String clientId;
private List<String> scopes;
private Long expiresAt;
private List<String> audience;
public AccessToken() {}
public AccessToken(String token, String clientId, List<String> scopes, Long expiresAt) {
this.token = token;
this.clientId = clientId;
this.scopes = scopes;
this.expiresAt = expiresAt;
}
public AccessToken(
String token, String clientId, List<String> scopes, Long expiresAt, List<String> audience) {
this.token = token;
this.clientId = clientId;
this.scopes = scopes;
this.expiresAt = expiresAt;
this.audience = audience;
}
public String getToken() {
return token;
}
public void setToken(String token) {
this.token = token;
}
public String getClientId() {
return clientId;
}
public void setClientId(String clientId) {
this.clientId = clientId;
}
public List<String> getScopes() {
return scopes;
}
public void setScopes(List<String> scopes) {
this.scopes = scopes;
}
public Long getExpiresAt() {
return expiresAt;
}
public void setExpiresAt(Long expiresAt) {
this.expiresAt = expiresAt;
}
public List<String> getAudience() {
return audience;
}
public void setAudience(List<String> audience) {
this.audience = audience;
}
}
@@ -0,0 +1,109 @@
package org.openmetadata.mcp.auth;
import java.net.URI;
import java.util.List;
/**
* Represents an OAuth authorization code.
*/
public class AuthorizationCode {
private String code;
private List<String> scopes;
private long expiresAt;
private String clientId;
private String codeChallenge;
private String codeVerifier;
private URI redirectUri;
private boolean redirectUriProvidedExplicitly;
public AuthorizationCode() {}
public AuthorizationCode(
String code,
List<String> scopes,
long expiresAt,
String clientId,
String codeChallenge,
URI redirectUri,
boolean redirectUriProvidedExplicitly) {
this.code = code;
this.scopes = scopes;
this.expiresAt = expiresAt;
this.clientId = clientId;
this.codeChallenge = codeChallenge;
this.redirectUri = redirectUri;
this.redirectUriProvidedExplicitly = redirectUriProvidedExplicitly;
}
public String getCode() {
return code;
}
public void setCode(String code) {
this.code = code;
}
public List<String> getScopes() {
return scopes;
}
public void setScopes(List<String> scopes) {
this.scopes = scopes;
}
public long getExpiresAt() {
return expiresAt;
}
public void setExpiresAt(long expiresAt) {
this.expiresAt = expiresAt;
}
public String getClientId() {
return clientId;
}
public void setClientId(String clientId) {
this.clientId = clientId;
}
public String getCodeChallenge() {
return codeChallenge;
}
public void setCodeChallenge(String codeChallenge) {
this.codeChallenge = codeChallenge;
}
public String getCodeVerifier() {
return codeVerifier;
}
public void setCodeVerifier(String codeVerifier) {
this.codeVerifier = codeVerifier;
}
public URI getRedirectUri() {
return redirectUri;
}
public void setRedirectUri(URI redirectUri) {
this.redirectUri = redirectUri;
}
public boolean isRedirectUriProvidedExplicitly() {
return redirectUriProvidedExplicitly;
}
public void setRedirectUriProvidedExplicitly(boolean redirectUriProvidedExplicitly) {
this.redirectUriProvidedExplicitly = redirectUriProvidedExplicitly;
}
}
@@ -0,0 +1,75 @@
package org.openmetadata.mcp.auth;
import java.net.URI;
import java.util.List;
/**
* Parameters for an authorization request.
*/
public class AuthorizationParams {
private String state;
private List<String> scopes;
private String codeChallenge;
private URI redirectUri;
private boolean redirectUriProvidedExplicitly;
public AuthorizationParams() {}
public AuthorizationParams(
String state,
List<String> scopes,
String codeChallenge,
URI redirectUri,
boolean redirectUriProvidedExplicitly) {
this.state = state;
this.scopes = scopes;
this.codeChallenge = codeChallenge;
this.redirectUri = redirectUri;
this.redirectUriProvidedExplicitly = redirectUriProvidedExplicitly;
}
public String getState() {
return state;
}
public void setState(String state) {
this.state = state;
}
public List<String> getScopes() {
return scopes;
}
public void setScopes(List<String> scopes) {
this.scopes = scopes;
}
public String getCodeChallenge() {
return codeChallenge;
}
public void setCodeChallenge(String codeChallenge) {
this.codeChallenge = codeChallenge;
}
public URI getRedirectUri() {
return redirectUri;
}
public void setRedirectUri(URI redirectUri) {
this.redirectUri = redirectUri;
}
public boolean isRedirectUriProvidedExplicitly() {
return redirectUriProvidedExplicitly;
}
public void setRedirectUriProvidedExplicitly(boolean redirectUriProvidedExplicitly) {
this.redirectUriProvidedExplicitly = redirectUriProvidedExplicitly;
}
}
@@ -0,0 +1,11 @@
package org.openmetadata.mcp.auth;
/**
* Exception thrown when a redirect URI is invalid.
*/
public class InvalidRedirectUriException extends Exception {
public InvalidRedirectUriException(String message) {
super(message);
}
}
@@ -0,0 +1,11 @@
package org.openmetadata.mcp.auth;
/**
* Exception thrown when a requested scope is invalid.
*/
public class InvalidScopeException extends Exception {
public InvalidScopeException(String message) {
super(message);
}
}
@@ -0,0 +1,101 @@
package org.openmetadata.mcp.auth;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import org.openmetadata.mcp.auth.exception.AuthorizeException;
import org.openmetadata.mcp.auth.exception.RegistrationException;
import org.openmetadata.mcp.auth.exception.TokenException;
/**
* Interface for OAuth authorization server providers.
*/
public interface OAuthAuthorizationServerProvider {
/**
* Retrieves client information by client ID.
* @param clientId The ID of the client to retrieve.
* @return A CompletableFuture that resolves to the client information, or null if the
* client does not exist.
*/
CompletableFuture<OAuthClientInformation> getClient(String clientId);
/**
* Saves client information as part of registering it.
* @param clientInfo The client metadata to register.
* @return A CompletableFuture that completes when the registration is done.
* @throws RegistrationException If the client metadata is invalid.
*/
CompletableFuture<Void> registerClient(OAuthClientInformation clientInfo)
throws RegistrationException;
/**
* Called as part of the /authorize endpoint, and returns a URL that the client will
* be redirected to.
* @param client The client requesting authorization.
* @param params The parameters of the authorization request.
* @return A CompletableFuture that resolves to a URL to redirect the client to for
* authorization.
* @throws AuthorizeException If the authorization request is invalid.
*/
CompletableFuture<String> authorize(OAuthClientInformation client, AuthorizationParams params)
throws AuthorizeException;
/**
* Loads an AuthorizationCode by its code.
* @param client The client that requested the authorization code.
* @param authorizationCode The authorization code to get the challenge for.
* @return A CompletableFuture that resolves to the AuthorizationCode, or null if not
* found.
*/
CompletableFuture<AuthorizationCode> loadAuthorizationCode(
OAuthClientInformation client, String authorizationCode);
/**
* Exchanges an authorization code for an access token and refresh token.
* @param client The client exchanging the authorization code.
* @param authorizationCode The authorization code to exchange.
* @return A CompletableFuture that resolves to the OAuth token, containing access and
* refresh tokens.
* @throws TokenException If the request is invalid.
*/
CompletableFuture<OAuthToken> exchangeAuthorizationCode(
OAuthClientInformation client, AuthorizationCode authorizationCode) throws TokenException;
/**
* Loads a RefreshToken by its token string.
* @param client The client that is requesting to load the refresh token.
* @param refreshToken The refresh token string to load.
* @return A CompletableFuture that resolves to the RefreshToken object if found, or
* null if not found.
*/
CompletableFuture<RefreshToken> loadRefreshToken(
OAuthClientInformation client, String refreshToken);
/**
* Exchanges a refresh token for an access token and refresh token.
* @param client The client exchanging the refresh token.
* @param refreshToken The refresh token to exchange.
* @param scopes Optional scopes to request with the new access token.
* @return A CompletableFuture that resolves to the OAuth token, containing access and
* refresh tokens.
* @throws TokenException If the request is invalid.
*/
CompletableFuture<OAuthToken> exchangeRefreshToken(
OAuthClientInformation client, RefreshToken refreshToken, List<String> scopes)
throws TokenException;
/**
* Loads an access token by its token.
* @param token The access token to verify.
* @return A CompletableFuture that resolves to the AccessToken, or null if the token
* is invalid.
*/
CompletableFuture<AccessToken> loadAccessToken(String token);
/**
* Revokes an access or refresh token.
* @param token The token to revoke.
* @return A CompletableFuture that completes when the token is revoked.
*/
CompletableFuture<Void> revokeToken(Object token);
}
@@ -0,0 +1,60 @@
package org.openmetadata.mcp.auth;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* RFC 7591 OAuth 2.0 Dynamic Client Registration full response (client information plus
* metadata).
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public class OAuthClientInformation extends OAuthClientMetadata {
@JsonProperty("client_id")
private String clientId;
@JsonProperty("client_secret")
private String clientSecret;
@JsonProperty("client_id_issued_at")
private Long clientIdIssuedAt;
@JsonProperty("client_secret_expires_at")
private Long clientSecretExpiresAt;
public OAuthClientInformation() {
super();
}
public String getClientId() {
return clientId;
}
public void setClientId(String clientId) {
this.clientId = clientId;
}
public String getClientSecret() {
return clientSecret;
}
public void setClientSecret(String clientSecret) {
this.clientSecret = clientSecret;
}
public Long getClientIdIssuedAt() {
return clientIdIssuedAt;
}
public void setClientIdIssuedAt(Long clientIdIssuedAt) {
this.clientIdIssuedAt = clientIdIssuedAt;
}
public Long getClientSecretExpiresAt() {
return clientSecretExpiresAt;
}
public void setClientSecretExpiresAt(Long clientSecretExpiresAt) {
this.clientSecretExpiresAt = clientSecretExpiresAt;
}
}
@@ -0,0 +1,246 @@
package org.openmetadata.mcp.auth;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.net.URI;
import java.util.Arrays;
import java.util.List;
/**
* RFC 7591 OAuth 2.0 Dynamic Client Registration metadata. See
* https://datatracker.ietf.org/doc/html/rfc7591#section-2 for the full specification.
*/
@JsonIgnoreProperties(ignoreUnknown = true)
@JsonInclude(JsonInclude.Include.NON_NULL)
public class OAuthClientMetadata {
@JsonProperty("redirect_uris")
private List<URI> redirectUris;
@JsonProperty("token_endpoint_auth_method")
private String tokenEndpointAuthMethod;
@JsonProperty("grant_types")
private List<String> grantTypes;
@JsonProperty("response_types")
private List<String> responseTypes;
@JsonProperty("scope")
private String scope;
// Optional metadata fields
@JsonProperty("client_name")
private String clientName;
@JsonProperty("client_uri")
private URI clientUri;
@JsonProperty("logo_uri")
private URI logoUri;
@JsonProperty("contacts")
private List<String> contacts;
@JsonProperty("tos_uri")
private URI tosUri;
@JsonProperty("policy_uri")
private URI policyUri;
@JsonProperty("jwks_uri")
private URI jwksUri;
@JsonProperty("jwks")
private Object jwks;
@JsonProperty("software_id")
private String softwareId;
@JsonProperty("software_version")
private String softwareVersion;
public OAuthClientMetadata() {
this.tokenEndpointAuthMethod = "client_secret_post";
this.grantTypes = Arrays.asList("authorization_code", "refresh_token");
this.responseTypes = Arrays.asList("code");
}
/**
* Validates the requested scope against the client's allowed scopes.
* If no scopes were registered with the client (scope is null or empty),
* all requested scopes are allowed.
* @param requestedScope The scope requested by the client
* @return List of validated scopes or null if no scope was requested
* @throws InvalidScopeException if the requested scope is not allowed
*/
public List<String> validateScope(String requestedScope) throws InvalidScopeException {
if (requestedScope == null) {
return null;
}
List<String> requestedScopes = Arrays.asList(requestedScope.split(" "));
// If client didn't register specific scopes, allow any scope
if (scope == null || scope.trim().isEmpty()) {
return requestedScopes;
}
List<String> allowedScopes = Arrays.asList(scope.split(" "));
for (String requestedScopeItem : requestedScopes) {
if (!allowedScopes.contains(requestedScopeItem)) {
throw new InvalidScopeException(
"Client was not registered with scope " + requestedScopeItem);
}
}
return requestedScopes;
}
/**
* Validates the redirect URI against the client's registered redirect URIs.
* @param redirectUri The redirect URI to validate
* @return The validated redirect URI
* @throws InvalidRedirectUriException if the redirect URI is invalid
*/
public URI validateRedirectUri(URI redirectUri) throws InvalidRedirectUriException {
if (redirectUris == null || redirectUris.isEmpty()) {
throw new InvalidRedirectUriException("No redirect URIs registered for client");
}
if (redirectUri != null) {
if (!redirectUris.contains(redirectUri)) {
throw new InvalidRedirectUriException(
"Redirect URI '" + redirectUri + "' not registered for client");
}
return redirectUri;
} else if (redirectUris.size() == 1) {
return redirectUris.get(0);
} else {
throw new InvalidRedirectUriException(
"redirect_uri must be specified when client has multiple registered URIs");
}
}
// Getters and setters
public List<URI> getRedirectUris() {
return redirectUris;
}
public void setRedirectUris(List<URI> redirectUris) {
this.redirectUris = redirectUris;
}
public String getTokenEndpointAuthMethod() {
return tokenEndpointAuthMethod;
}
public void setTokenEndpointAuthMethod(String tokenEndpointAuthMethod) {
this.tokenEndpointAuthMethod = tokenEndpointAuthMethod;
}
public List<String> getGrantTypes() {
return grantTypes;
}
public void setGrantTypes(List<String> grantTypes) {
this.grantTypes = grantTypes;
}
public List<String> getResponseTypes() {
return responseTypes;
}
public void setResponseTypes(List<String> responseTypes) {
this.responseTypes = responseTypes;
}
public String getScope() {
return scope;
}
public void setScope(String scope) {
this.scope = scope;
}
public String getClientName() {
return clientName;
}
public void setClientName(String clientName) {
this.clientName = clientName;
}
public URI getClientUri() {
return clientUri;
}
public void setClientUri(URI clientUri) {
this.clientUri = clientUri;
}
public URI getLogoUri() {
return logoUri;
}
public void setLogoUri(URI logoUri) {
this.logoUri = logoUri;
}
public List<String> getContacts() {
return contacts;
}
public void setContacts(List<String> contacts) {
this.contacts = contacts;
}
public URI getTosUri() {
return tosUri;
}
public void setTosUri(URI tosUri) {
this.tosUri = tosUri;
}
public URI getPolicyUri() {
return policyUri;
}
public void setPolicyUri(URI policyUri) {
this.policyUri = policyUri;
}
public URI getJwksUri() {
return jwksUri;
}
public void setJwksUri(URI jwksUri) {
this.jwksUri = jwksUri;
}
public Object getJwks() {
return jwks;
}
public void setJwks(Object jwks) {
this.jwks = jwks;
}
public String getSoftwareId() {
return softwareId;
}
public void setSoftwareId(String softwareId) {
this.softwareId = softwareId;
}
public String getSoftwareVersion() {
return softwareVersion;
}
public void setSoftwareVersion(String softwareVersion) {
this.softwareVersion = softwareVersion;
}
}
@@ -0,0 +1,258 @@
package org.openmetadata.mcp.auth;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.net.URI;
import java.util.Arrays;
import java.util.List;
/**
* RFC 8414 OAuth 2.0 Authorization Server Metadata. See
* https://datatracker.ietf.org/doc/html/rfc8414#section-2
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public class OAuthMetadata {
@JsonProperty("issuer")
private URI issuer;
@JsonProperty("authorization_endpoint")
private URI authorizationEndpoint;
@JsonProperty("token_endpoint")
private URI tokenEndpoint;
@JsonProperty("registration_endpoint")
private URI registrationEndpoint;
@JsonProperty("scopes_supported")
private List<String> scopesSupported;
@JsonProperty("response_types_supported")
private List<String> responseTypesSupported;
@JsonProperty("response_modes_supported")
private List<String> responseModesSupported;
@JsonProperty("grant_types_supported")
private List<String> grantTypesSupported;
@JsonProperty("token_endpoint_auth_methods_supported")
private List<String> tokenEndpointAuthMethodsSupported;
@JsonProperty("token_endpoint_auth_signing_alg_values_supported")
private List<String> tokenEndpointAuthSigningAlgValuesSupported;
@JsonProperty("service_documentation")
private URI serviceDocumentation;
@JsonProperty("ui_locales_supported")
private List<String> uiLocalesSupported;
@JsonProperty("op_policy_uri")
private URI opPolicyUri;
@JsonProperty("op_tos_uri")
private URI opTosUri;
@JsonProperty("revocation_endpoint")
private URI revocationEndpoint;
@JsonProperty("revocation_endpoint_auth_methods_supported")
private List<String> revocationEndpointAuthMethodsSupported;
@JsonProperty("revocation_endpoint_auth_signing_alg_values_supported")
private List<String> revocationEndpointAuthSigningAlgValuesSupported;
@JsonProperty("introspection_endpoint")
private URI introspectionEndpoint;
@JsonProperty("introspection_endpoint_auth_methods_supported")
private List<String> introspectionEndpointAuthMethodsSupported;
@JsonProperty("introspection_endpoint_auth_signing_alg_values_supported")
private List<String> introspectionEndpointAuthSigningAlgValuesSupported;
@JsonProperty("code_challenge_methods_supported")
private List<String> codeChallengeMethodsSupported;
public OAuthMetadata() {
this.responseTypesSupported = Arrays.asList("code");
}
// Getters and setters
public URI getIssuer() {
return issuer;
}
public void setIssuer(URI issuer) {
this.issuer = issuer;
}
public URI getAuthorizationEndpoint() {
return authorizationEndpoint;
}
public void setAuthorizationEndpoint(URI authorizationEndpoint) {
this.authorizationEndpoint = authorizationEndpoint;
}
public URI getTokenEndpoint() {
return tokenEndpoint;
}
public void setTokenEndpoint(URI tokenEndpoint) {
this.tokenEndpoint = tokenEndpoint;
}
public URI getRegistrationEndpoint() {
return registrationEndpoint;
}
public void setRegistrationEndpoint(URI registrationEndpoint) {
this.registrationEndpoint = registrationEndpoint;
}
public List<String> getScopesSupported() {
return scopesSupported;
}
public void setScopesSupported(List<String> scopesSupported) {
this.scopesSupported = scopesSupported;
}
public List<String> getResponseTypesSupported() {
return responseTypesSupported;
}
public void setResponseTypesSupported(List<String> responseTypesSupported) {
this.responseTypesSupported = responseTypesSupported;
}
public List<String> getResponseModesSupported() {
return responseModesSupported;
}
public void setResponseModesSupported(List<String> responseModesSupported) {
this.responseModesSupported = responseModesSupported;
}
public List<String> getGrantTypesSupported() {
return grantTypesSupported;
}
public void setGrantTypesSupported(List<String> grantTypesSupported) {
this.grantTypesSupported = grantTypesSupported;
}
public List<String> getTokenEndpointAuthMethodsSupported() {
return tokenEndpointAuthMethodsSupported;
}
public void setTokenEndpointAuthMethodsSupported(List<String> tokenEndpointAuthMethodsSupported) {
this.tokenEndpointAuthMethodsSupported = tokenEndpointAuthMethodsSupported;
}
public List<String> getTokenEndpointAuthSigningAlgValuesSupported() {
return tokenEndpointAuthSigningAlgValuesSupported;
}
public void setTokenEndpointAuthSigningAlgValuesSupported(
List<String> tokenEndpointAuthSigningAlgValuesSupported) {
this.tokenEndpointAuthSigningAlgValuesSupported = tokenEndpointAuthSigningAlgValuesSupported;
}
public URI getServiceDocumentation() {
return serviceDocumentation;
}
public void setServiceDocumentation(URI serviceDocumentation) {
this.serviceDocumentation = serviceDocumentation;
}
public List<String> getUiLocalesSupported() {
return uiLocalesSupported;
}
public void setUiLocalesSupported(List<String> uiLocalesSupported) {
this.uiLocalesSupported = uiLocalesSupported;
}
public URI getOpPolicyUri() {
return opPolicyUri;
}
public void setOpPolicyUri(URI opPolicyUri) {
this.opPolicyUri = opPolicyUri;
}
public URI getOpTosUri() {
return opTosUri;
}
public void setOpTosUri(URI opTosUri) {
this.opTosUri = opTosUri;
}
public URI getRevocationEndpoint() {
return revocationEndpoint;
}
public void setRevocationEndpoint(URI revocationEndpoint) {
this.revocationEndpoint = revocationEndpoint;
}
public List<String> getRevocationEndpointAuthMethodsSupported() {
return revocationEndpointAuthMethodsSupported;
}
public void setRevocationEndpointAuthMethodsSupported(
List<String> revocationEndpointAuthMethodsSupported) {
this.revocationEndpointAuthMethodsSupported = revocationEndpointAuthMethodsSupported;
}
public List<String> getRevocationEndpointAuthSigningAlgValuesSupported() {
return revocationEndpointAuthSigningAlgValuesSupported;
}
public void setRevocationEndpointAuthSigningAlgValuesSupported(
List<String> revocationEndpointAuthSigningAlgValuesSupported) {
this.revocationEndpointAuthSigningAlgValuesSupported =
revocationEndpointAuthSigningAlgValuesSupported;
}
public URI getIntrospectionEndpoint() {
return introspectionEndpoint;
}
public void setIntrospectionEndpoint(URI introspectionEndpoint) {
this.introspectionEndpoint = introspectionEndpoint;
}
public List<String> getIntrospectionEndpointAuthMethodsSupported() {
return introspectionEndpointAuthMethodsSupported;
}
public void setIntrospectionEndpointAuthMethodsSupported(
List<String> introspectionEndpointAuthMethodsSupported) {
this.introspectionEndpointAuthMethodsSupported = introspectionEndpointAuthMethodsSupported;
}
public List<String> getIntrospectionEndpointAuthSigningAlgValuesSupported() {
return introspectionEndpointAuthSigningAlgValuesSupported;
}
public void setIntrospectionEndpointAuthSigningAlgValuesSupported(
List<String> introspectionEndpointAuthSigningAlgValuesSupported) {
this.introspectionEndpointAuthSigningAlgValuesSupported =
introspectionEndpointAuthSigningAlgValuesSupported;
}
public List<String> getCodeChallengeMethodsSupported() {
return codeChallengeMethodsSupported;
}
public void setCodeChallengeMethodsSupported(List<String> codeChallengeMethodsSupported) {
this.codeChallengeMethodsSupported = codeChallengeMethodsSupported;
}
}
@@ -0,0 +1,77 @@
package org.openmetadata.mcp.auth;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* OAuth token as defined in RFC 6749 section 5.1
* https://datatracker.ietf.org/doc/html/rfc6749#section-5.1
*/
public class OAuthToken {
@JsonProperty("access_token")
private String accessToken;
@JsonProperty("token_type")
private String tokenType;
@JsonProperty("expires_in")
private Integer expiresIn;
@JsonProperty("scope")
private String scope;
@JsonProperty("refresh_token")
private String refreshToken;
public OAuthToken() {
this.tokenType = "bearer";
}
public OAuthToken(String accessToken, Integer expiresIn, String scope, String refreshToken) {
this.accessToken = accessToken;
this.tokenType = "bearer";
this.expiresIn = expiresIn;
this.scope = scope;
this.refreshToken = refreshToken;
}
public String getAccessToken() {
return accessToken;
}
public void setAccessToken(String accessToken) {
this.accessToken = accessToken;
}
public String getTokenType() {
return tokenType;
}
public void setTokenType(String tokenType) {
this.tokenType = tokenType;
}
public Integer getExpiresIn() {
return expiresIn;
}
public void setExpiresIn(Integer expiresIn) {
this.expiresIn = expiresIn;
}
public String getScope() {
return scope;
}
public void setScope(String scope) {
this.scope = scope;
}
public String getRefreshToken() {
return refreshToken;
}
public void setRefreshToken(String refreshToken) {
this.refreshToken = refreshToken;
}
}
@@ -0,0 +1,85 @@
package org.openmetadata.mcp.auth;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.net.URI;
import java.util.List;
/**
* RFC 9728 OAuth 2.0 Protected Resource Metadata.
* See https://datatracker.ietf.org/doc/html/rfc9728
*
* This metadata describes the OAuth 2.0 protected resource (MCP server)
* and points to the authorization servers that issue tokens for it.
*/
public class ProtectedResourceMetadata {
@JsonProperty("resource")
private URI resource;
@JsonProperty("authorization_servers")
private List<URI> authorizationServers;
@JsonProperty("bearer_methods_supported")
private List<String> bearerMethodsSupported;
@JsonProperty("resource_signing_alg_values_supported")
private List<String> resourceSigningAlgValuesSupported;
@JsonProperty("resource_documentation")
private URI resourceDocumentation;
@JsonProperty("scopes_supported")
private List<String> scopesSupported;
public ProtectedResourceMetadata() {
this.bearerMethodsSupported = List.of("header");
}
public URI getResource() {
return resource;
}
public void setResource(URI resource) {
this.resource = resource;
}
public List<URI> getAuthorizationServers() {
return authorizationServers;
}
public void setAuthorizationServers(List<URI> authorizationServers) {
this.authorizationServers = authorizationServers;
}
public List<String> getBearerMethodsSupported() {
return bearerMethodsSupported;
}
public void setBearerMethodsSupported(List<String> bearerMethodsSupported) {
this.bearerMethodsSupported = bearerMethodsSupported;
}
public List<String> getResourceSigningAlgValuesSupported() {
return resourceSigningAlgValuesSupported;
}
public void setResourceSigningAlgValuesSupported(List<String> resourceSigningAlgValuesSupported) {
this.resourceSigningAlgValuesSupported = resourceSigningAlgValuesSupported;
}
public URI getResourceDocumentation() {
return resourceDocumentation;
}
public void setResourceDocumentation(URI resourceDocumentation) {
this.resourceDocumentation = resourceDocumentation;
}
public List<String> getScopesSupported() {
return scopesSupported;
}
public void setScopesSupported(List<String> scopesSupported) {
this.scopesSupported = scopesSupported;
}
}
@@ -0,0 +1,77 @@
package org.openmetadata.mcp.auth;
import java.util.List;
/**
* Represents an OAuth refresh token.
*/
public class RefreshToken {
private String token;
private String clientId;
private String userName;
private List<String> scopes;
private Long expiresAt;
public RefreshToken() {}
public RefreshToken(String token, String clientId, List<String> scopes, Long expiresAt) {
this.token = token;
this.clientId = clientId;
this.scopes = scopes;
this.expiresAt = expiresAt;
}
public RefreshToken(
String token, String clientId, String userName, List<String> scopes, Long expiresAt) {
this.token = token;
this.clientId = clientId;
this.userName = userName;
this.scopes = scopes;
this.expiresAt = expiresAt;
}
public String getToken() {
return token;
}
public void setToken(String token) {
this.token = token;
}
public String getClientId() {
return clientId;
}
public void setClientId(String clientId) {
this.clientId = clientId;
}
public String getUserName() {
return userName;
}
public void setUserName(String userName) {
this.userName = userName;
}
public List<String> getScopes() {
return scopes;
}
public void setScopes(List<String> scopes) {
this.scopes = scopes;
}
public Long getExpiresAt() {
return expiresAt;
}
public void setExpiresAt(Long expiresAt) {
this.expiresAt = expiresAt;
}
}
@@ -0,0 +1,25 @@
package org.openmetadata.mcp.auth.exception;
/**
* Exception thrown during authorization.
*/
public class AuthorizeException extends Exception {
private final String error;
private final String errorDescription;
public AuthorizeException(String error, String errorDescription) {
super(errorDescription != null ? errorDescription : error);
this.error = error;
this.errorDescription = errorDescription;
}
public String getError() {
return error;
}
public String getErrorDescription() {
return errorDescription;
}
}
@@ -0,0 +1,25 @@
package org.openmetadata.mcp.auth.exception;
/**
* Exception thrown during client registration.
*/
public class RegistrationException extends Exception {
private final String error;
private final String errorDescription;
public RegistrationException(String error, String errorDescription) {
super(errorDescription != null ? errorDescription : error);
this.error = error;
this.errorDescription = errorDescription;
}
public String getError() {
return error;
}
public String getErrorDescription() {
return errorDescription;
}
}
@@ -0,0 +1,25 @@
package org.openmetadata.mcp.auth.exception;
/**
* Exception thrown during token operations.
*/
public class TokenException extends Exception {
private final String error;
private final String errorDescription;
public TokenException(String error, String errorDescription) {
super(errorDescription != null ? errorDescription : error);
this.error = error;
this.errorDescription = errorDescription;
}
public String getError() {
return error;
}
public String getErrorDescription() {
return errorDescription;
}
}
@@ -0,0 +1,45 @@
package org.openmetadata.mcp.prompts;
import static org.openmetadata.mcp.McpUtils.getPrompts;
import io.modelcontextprotocol.spec.McpSchema;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import lombok.extern.slf4j.Slf4j;
import org.openmetadata.service.security.JwtFilter;
import org.openmetadata.service.security.auth.CatalogSecurityContext;
@Slf4j
public class DefaultPromptsContext {
public List<McpSchema.Prompt> loadPromptsDefinitionsFromJson(String promptFilePath) {
return getPrompts(promptFilePath);
}
public McpSchema.GetPromptResult callPrompt(
JwtFilter jwtFilter, String promptName, McpSchema.GetPromptRequest promptRequest) {
Map<String, Object> params = promptRequest.arguments();
CatalogSecurityContext securityContext =
jwtFilter.getCatalogSecurityContext((String) params.get("Authorization"));
LOG.info(
"Catalog Principal: {} is trying to call the prompt: {}",
securityContext.getUserPrincipal().getName(),
promptName);
McpSchema.GetPromptResult result;
try {
switch (promptName) {
case "search_metadata":
result = new SearchPrompt().callPrompt(promptRequest);
break;
default:
return new McpSchema.GetPromptResult("error", new ArrayList<>());
}
return result;
} catch (Exception ex) {
LOG.error("Error executing tool: {}", ex.getMessage());
return new McpSchema.GetPromptResult(
String.format("Error executing tool: %s", ex.getMessage()), new ArrayList<>());
}
}
}
@@ -0,0 +1,7 @@
package org.openmetadata.mcp.prompts;
import io.modelcontextprotocol.spec.McpSchema;
public interface McpPrompt {
McpSchema.GetPromptResult callPrompt(McpSchema.GetPromptRequest promptRequest);
}
@@ -0,0 +1,32 @@
package org.openmetadata.mcp.prompts;
import io.modelcontextprotocol.spec.McpSchema;
import java.util.List;
import java.util.Map;
public class SearchPrompt implements McpPrompt {
@Override
public McpSchema.GetPromptResult callPrompt(McpSchema.GetPromptRequest promptRequest) {
Map<String, Object> params = promptRequest.arguments();
String query = (String) params.get("query");
int limit = 10;
if (params.containsKey("limit")) {
Object limitObj = params.get("limit");
if (limitObj instanceof Number) {
limit = ((Number) limitObj).intValue();
} else if (limitObj instanceof String) {
limit = Integer.parseInt((String) limitObj);
}
}
String entityType = (String) params.get("entityType");
return new McpSchema.GetPromptResult(
"Message can be used to get search results",
List.of(
new McpSchema.PromptMessage(
McpSchema.Role.ASSISTANT,
new McpSchema.TextContent(
String.format(
"Search for `%s` in OpenMetadata where entity type is `%s` and with a limit of `%s` . Summarise the information for all the results properly and also make sure to provide clickable links using the href field from the results.",
query, entityType, limit)))));
}
}
@@ -0,0 +1,215 @@
package org.openmetadata.mcp.server.auth.handlers;
import java.net.URI;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import lombok.extern.slf4j.Slf4j;
import org.openmetadata.mcp.auth.AuthorizationParams;
import org.openmetadata.mcp.auth.InvalidRedirectUriException;
import org.openmetadata.mcp.auth.InvalidScopeException;
import org.openmetadata.mcp.auth.OAuthAuthorizationServerProvider;
import org.openmetadata.mcp.auth.exception.AuthorizeException;
import org.openmetadata.mcp.server.auth.model.AuthorizationErrorResponse;
import org.openmetadata.mcp.server.auth.util.UriUtils;
@Slf4j
public class AuthorizationHandler {
private final OAuthAuthorizationServerProvider provider;
public AuthorizationHandler(OAuthAuthorizationServerProvider provider) {
this.provider = provider;
}
/**
* Handle an authorization request.
* @param params The request parameters
* @return A CompletableFuture that resolves to a response object containing either a
* redirect URL or an error
*/
public CompletableFuture<AuthorizationResponse> handle(Map<String, String> params) {
String clientId = params.get("client_id");
String redirectUriStr = params.get("redirect_uri");
String responseType = params.get("response_type");
String codeChallenge = params.get("code_challenge");
String codeChallengeMethod = params.get("code_challenge_method");
String state = params.get("state");
String scope = params.get("scope");
// Validate required parameters
if (clientId == null || responseType == null || codeChallenge == null) {
return CompletableFuture.completedFuture(
createErrorResponse("invalid_request", "Missing required parameters", state, null));
}
// Validate response type
if (!"code".equals(responseType)) {
return CompletableFuture.completedFuture(
createErrorResponse(
"unsupported_response_type", "Only 'code' response type is supported", state, null));
}
if (!"S256".equals(codeChallengeMethod)) {
return CompletableFuture.completedFuture(
createErrorResponse(
"invalid_request", "code_challenge_method must be 'S256'", state, null));
}
// Get client information
return provider
.getClient(clientId)
.thenCompose(
client -> {
if (client == null) {
return CompletableFuture.completedFuture(
createErrorResponse("invalid_request", "Client ID not found", state, null));
}
// Validate redirect URI against client's registered redirect URIs.
// Open redirect is mitigated at registration: HTTP URIs are restricted to
// loopback addresses per RFC 8252, and the redirect URI must match a
// registered URI for this client.
URI redirectUri;
try {
URI tempUri = redirectUriStr != null ? URI.create(redirectUriStr) : null;
redirectUri = client.validateRedirectUri(tempUri);
} catch (InvalidRedirectUriException e) {
return CompletableFuture.completedFuture(
createErrorResponse("invalid_request", e.getMessage(), state, null));
}
// Validate scope
List<String> scopes;
try {
scopes = client.validateScope(scope);
} catch (InvalidScopeException e) {
return CompletableFuture.completedFuture(
createErrorResponse("invalid_scope", e.getMessage(), state, redirectUri));
}
// Setup authorization parameters
AuthorizationParams authParams = new AuthorizationParams();
authParams.setState(state);
authParams.setScopes(scopes);
authParams.setCodeChallenge(codeChallenge);
authParams.setRedirectUri(redirectUri);
authParams.setRedirectUriProvidedExplicitly(redirectUriStr != null);
// Let the provider handle the authorization
try {
return provider
.authorize(client, authParams)
.thenApply(
result -> {
// Check if result is a full redirect URL (SSO flow) or an auth code
if (result.startsWith("http://") || result.startsWith("https://")) {
// SSO redirect URL - use directly
return new AuthorizationResponse(result, true, null);
} else if ("SSO_REDIRECT_INITIATED".equals(result)) {
// SSO redirect was already sent by provider - return empty response
return new AuthorizationResponse(null, false, null);
} else {
// Authorization code - construct callback URL
Map<String, String> queryParams = new HashMap<>();
queryParams.put("code", result);
if (authParams.getState() != null) {
queryParams.put("state", authParams.getState());
}
String callbackUrl =
UriUtils.constructRedirectUri(
authParams.getRedirectUri().toString(), queryParams);
return new AuthorizationResponse(callbackUrl, true, null);
}
})
.exceptionally(
ex -> {
Throwable cause = ex.getCause() != null ? ex.getCause() : ex;
if (cause instanceof AuthorizeException authEx) {
LOG.warn(
"Authorization failed: {} - {}",
authEx.getError(),
authEx.getErrorDescription());
return createErrorResponse(
authEx.getError(),
authEx.getErrorDescription(),
state,
redirectUri);
} else {
LOG.error(
"Unexpected error during authorization for client: {}",
clientId,
cause);
return createErrorResponse(
"server_error", "An unexpected error occurred", state, redirectUri);
}
});
} catch (AuthorizeException e) {
return CompletableFuture.completedFuture(
createErrorResponse(e.getError(), e.getErrorDescription(), state, redirectUri));
}
});
}
/**
* Create an error response.
* @param error The error code
* @param errorDescription The error description
* @param state The state parameter
* @param redirectUri The redirect URI, or null if not available
* @return An AuthorizationResponse containing the error
*/
private AuthorizationResponse createErrorResponse(
String error, String errorDescription, String state, URI redirectUri) {
AuthorizationErrorResponse errorResponse =
new AuthorizationErrorResponse(error, errorDescription, state);
if (redirectUri != null) {
// Redirect with error parameters
String redirectUrl =
UriUtils.constructRedirectUri(redirectUri.toString(), errorResponse.toQueryParams());
return new AuthorizationResponse(redirectUrl, true, errorResponse);
} else {
// Direct error response
return new AuthorizationResponse(null, false, errorResponse);
}
}
/**
* Response object for authorization requests.
*/
public static class AuthorizationResponse {
private final String redirectUrl;
private final boolean isRedirect;
private final AuthorizationErrorResponse error;
public AuthorizationResponse(
String redirectUrl, boolean isRedirect, AuthorizationErrorResponse error) {
this.redirectUrl = redirectUrl;
this.isRedirect = isRedirect;
this.error = error;
}
public String getRedirectUrl() {
return redirectUrl;
}
public boolean isRedirect() {
return isRedirect;
}
public AuthorizationErrorResponse getError() {
return error;
}
public boolean isSuccess() {
return error == null;
}
}
}
@@ -0,0 +1,285 @@
package org.openmetadata.mcp.server.auth.handlers;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import java.io.IOException;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Base64;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import lombok.extern.slf4j.Slf4j;
import org.openmetadata.mcp.server.auth.html.HtmlTemplates;
import org.openmetadata.mcp.server.auth.provider.UserSSOOAuthProvider;
import org.openmetadata.mcp.server.auth.repository.McpPendingAuthRequestRepository;
import org.openmetadata.mcp.server.auth.repository.OAuthClientRepository;
import org.openmetadata.mcp.server.auth.util.UriUtils;
import org.openmetadata.schema.entity.teams.User;
import org.openmetadata.schema.type.Include;
import org.openmetadata.service.Entity;
import org.openmetadata.service.exception.AuthenticationException;
import org.openmetadata.service.jdbi3.oauth.OAuthRecords.McpPendingAuthRequest;
import org.openmetadata.service.security.auth.AuthenticatorHandler;
import org.openmetadata.service.security.auth.LoginAttemptCache;
import org.openmetadata.service.security.auth.SecurityConfigurationManager;
/**
* Servlet that handles the Basic Auth login flow for MCP OAuth.
*
* <p>MCP clients (Claude Desktop, Cursor) proxy the /mcp/authorize request and expect a 302
* redirect to a browser-openable URL. This servlet serves that browser-facing login page.
*
* <p><b>Flow:</b>
*
* <ol>
* <li>/mcp/authorize stores PKCE params in DB and returns redirect to /mcp/login?auth_request_id=XXX
* <li>GET /mcp/login — renders the login form in the user's browser
* <li>POST /mcp/login — validates credentials, generates auth code, redirects to client callback
* </ol>
*/
@Slf4j
public class BasicAuthLoginServlet extends HttpServlet {
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
private static final String SESSION_CSRF_TOKEN = "mcp.login.csrf";
private static final String SESSION_AUTH_REQUEST_ID = "mcp.login.auth_request_id";
private final UserSSOOAuthProvider authProvider;
private final AuthenticatorHandler authenticator;
private final McpPendingAuthRequestRepository pendingAuthRepository;
private final OAuthClientRepository clientRepository;
public BasicAuthLoginServlet(
UserSSOOAuthProvider authProvider, AuthenticatorHandler authenticator) {
this.authProvider = authProvider;
this.authenticator = authenticator;
this.pendingAuthRepository = new McpPendingAuthRequestRepository();
this.clientRepository = new OAuthClientRepository();
}
@Override
public void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException {
try {
String authRequestId = request.getParameter("auth_request_id");
if (authRequestId == null || authRequestId.isEmpty()) {
response.sendError(HttpServletResponse.SC_BAD_REQUEST, "Missing auth_request_id");
return;
}
McpPendingAuthRequest pending = pendingAuthRepository.findByAuthRequestId(authRequestId);
if (pending == null) {
response.sendError(
HttpServletResponse.SC_BAD_REQUEST,
"Invalid or expired auth request. Please restart the authorization flow.");
return;
}
// Generate CSRF token and store in session along with auth_request_id
String csrfToken = generateSecureToken(32);
HttpSession session = request.getSession(true);
session.setAttribute(SESSION_CSRF_TOKEN, csrfToken);
session.setAttribute(SESSION_AUTH_REQUEST_ID, authRequestId);
// Look up client name for display
String clientName = "An application";
var client = clientRepository.findByClientId(pending.clientId());
if (client != null && client.getClientName() != null) {
clientName = client.getClientName();
}
String html = HtmlTemplates.generateLoginForm(clientName, null, csrfToken);
response.setContentType("text/html; charset=UTF-8");
response.setStatus(HttpServletResponse.SC_OK);
response.getWriter().write(html);
} catch (Exception e) {
LOG.error("Failed to render login form", e);
response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "Failed to load login page");
}
}
@Override
public void doPost(HttpServletRequest request, HttpServletResponse response) throws IOException {
String password = null;
try {
HttpSession session = request.getSession(false);
if (session == null) {
response.sendError(
HttpServletResponse.SC_BAD_REQUEST,
"No session found. Please restart the authorization flow.");
return;
}
// Validate CSRF token (timing-safe)
String submittedCsrf = request.getParameter("csrf_token");
String sessionCsrf = (String) session.getAttribute(SESSION_CSRF_TOKEN);
session.removeAttribute(SESSION_CSRF_TOKEN);
byte[] expectedBytes =
sessionCsrf != null ? sessionCsrf.getBytes(StandardCharsets.UTF_8) : new byte[0];
byte[] providedBytes =
submittedCsrf != null ? submittedCsrf.getBytes(StandardCharsets.UTF_8) : new byte[0];
if (!MessageDigest.isEqual(expectedBytes, providedBytes)) {
LOG.warn("CSRF token mismatch during Basic Auth login");
response.sendError(HttpServletResponse.SC_FORBIDDEN, "CSRF validation failed");
return;
}
// Get auth_request_id from session
String authRequestId = (String) session.getAttribute(SESSION_AUTH_REQUEST_ID);
if (authRequestId == null) {
response.sendError(
HttpServletResponse.SC_BAD_REQUEST,
"Session expired. Please restart the authorization flow.");
return;
}
// Lookup pending request from DB
McpPendingAuthRequest pending = pendingAuthRepository.findByAuthRequestId(authRequestId);
if (pending == null) {
response.sendError(
HttpServletResponse.SC_BAD_REQUEST,
"Authorization request expired. Please restart the flow.");
return;
}
String usernameOrEmail = request.getParameter("username");
password = request.getParameter("password");
if (usernameOrEmail == null
|| usernameOrEmail.isEmpty()
|| password == null
|| password.isEmpty()) {
renderLoginFormWithError(request, response, pending, "Username and password are required");
return;
}
String email = usernameOrEmail;
String userName = usernameOrEmail;
if (!usernameOrEmail.contains("@")) {
try {
User userByName =
Entity.getEntityByName(Entity.USER, usernameOrEmail, "", Include.NON_DELETED);
if (userByName != null) {
email = userByName.getEmail();
userName = userByName.getName();
}
} catch (Exception e) {
LOG.debug("Could not find user by username: {}", usernameOrEmail);
}
}
try {
authenticator.checkIfLoginBlocked(email);
} catch (AuthenticationException e) {
LOG.warn("Login blocked for user", e);
renderLoginFormWithError(request, response, pending, "Login blocked");
return;
}
try {
// lookUserInProvider validates credentials for LDAP (via LDAP bind) but NOT for
// BasicAuth (just finds user). Call validatePassword separately for non-LDAP providers.
User user = authenticator.lookUserInProvider(email, password);
org.openmetadata.schema.services.connections.metadata.AuthProvider provider =
SecurityConfigurationManager.getCurrentAuthConfig().getProvider();
if (provider != org.openmetadata.schema.services.connections.metadata.AuthProvider.LDAP) {
authenticator.validatePassword(email, password, user);
}
LoginAttemptCache.getInstance().recordSuccessfulLogin(email);
LOG.debug("Successful basic auth login for MCP");
// Regenerate session to prevent fixation
request.changeSessionId();
// Generate authorization code
List<String> scopes = pending.scopes();
if (scopes == null || scopes.isEmpty()) {
scopes = List.of("openid", "profile", "email");
}
String authCode =
authProvider.createAuthorizationCode(
user.getName(),
pending.clientId(),
pending.codeChallenge(),
URI.create(pending.redirectUri()),
scopes);
// Redirect to client callback with code + state
// Redirect BEFORE deleting pending request — if delete throws, the user still
// gets their auth code. The pending request will be cleaned up by the cleanup job.
Map<String, String> queryParams = new HashMap<>();
queryParams.put("code", authCode);
if (pending.mcpState() != null) {
queryParams.put("state", pending.mcpState());
}
String redirectUrl = UriUtils.constructRedirectUri(pending.redirectUri(), queryParams);
LOG.info("Basic Auth login successful, redirecting to client callback");
response.sendRedirect(redirectUrl);
// Best-effort cleanup — failure here doesn't affect the user
try {
pendingAuthRepository.delete(authRequestId);
} catch (Exception deleteEx) {
LOG.warn(
"Failed to clean up pending auth request {}, will be removed by cleanup job: {}",
authRequestId,
deleteEx.getMessage());
}
} catch (AuthenticationException e) {
LOG.warn("Basic Auth login failed");
try {
authenticator.recordFailedLoginAttempt(email, email);
} catch (Exception recordEx) {
LOG.error("Failed to record login attempt for security tracking", recordEx);
}
renderLoginFormWithError(request, response, pending, "Invalid username or password");
}
} catch (Exception e) {
LOG.error("Basic Auth login processing failed", e);
response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "Login processing failed");
} finally {
password = null;
}
}
private void renderLoginFormWithError(
HttpServletRequest request,
HttpServletResponse response,
McpPendingAuthRequest pending,
String errorMessage)
throws IOException {
// Generate new CSRF token for the re-rendered form
String csrfToken = generateSecureToken(32);
HttpSession session = request.getSession(true);
session.setAttribute(SESSION_CSRF_TOKEN, csrfToken);
String clientName = "An application";
var client = clientRepository.findByClientId(pending.clientId());
if (client != null && client.getClientName() != null) {
clientName = client.getClientName();
}
String html = HtmlTemplates.generateLoginForm(clientName, errorMessage, csrfToken);
response.setContentType("text/html; charset=UTF-8");
response.setStatus(HttpServletResponse.SC_OK);
response.getWriter().write(html);
}
private static String generateSecureToken(int numBytes) {
byte[] tokenBytes = new byte[numBytes];
SECURE_RANDOM.nextBytes(tokenBytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(tokenBytes);
}
}
@@ -0,0 +1,828 @@
package org.openmetadata.mcp.server.auth.handlers;
import static org.openmetadata.service.security.AuthenticationCodeFlowHandler.OIDC_CREDENTIAL_PROFILE;
import static org.openmetadata.service.security.SecurityUtil.findEmailFromClaims;
import static org.openmetadata.service.security.SecurityUtil.findUserNameFromClaims;
import com.nimbusds.jwt.JWT;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.oauth2.sdk.id.State;
import com.nimbusds.oauth2.sdk.pkce.CodeVerifier;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletOutputStream;
import jakarta.servlet.WriteListener;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpServletResponseWrapper;
import jakarta.servlet.http.HttpSession;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.net.URI;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import lombok.extern.slf4j.Slf4j;
import org.openmetadata.mcp.server.auth.provider.UserSSOOAuthProvider;
import org.openmetadata.mcp.server.auth.repository.McpPendingAuthRequestRepository;
import org.openmetadata.mcp.server.auth.validators.IdTokenValidator;
import org.openmetadata.schema.api.configuration.MCPConfiguration;
import org.openmetadata.schema.api.configuration.OpenMetadataBaseUrlConfiguration;
import org.openmetadata.schema.services.connections.metadata.AuthProvider;
import org.openmetadata.schema.settings.Settings;
import org.openmetadata.service.Entity;
import org.openmetadata.service.jdbi3.SystemRepository;
import org.openmetadata.service.jdbi3.oauth.OAuthRecords.McpPendingAuthRequest;
import org.openmetadata.service.security.AuthenticationCodeFlowHandler;
import org.openmetadata.service.security.auth.SecurityConfigurationManager;
import org.pac4j.oidc.client.OidcClient;
import org.pac4j.oidc.credentials.OidcCredentials;
/**
* Unified MCP callback servlet that handles SSO provider callbacks for MCP OAuth flow.
*
* <p>Always registered at /mcp/callback regardless of the authentication provider configured at
* startup. At request time, checks whether SSO is available and either processes the SSO callback or
* returns a clear error message. This follows the same pattern as the regular auth servlets (e.g.,
* AuthCallbackServlet) which are always registered and dispatch at runtime.
*
* <p><b>IMPORTANT: Two Different OAuth Flows with Two Different State Parameters</b>
*
* <p>There are TWO separate OAuth flows happening:
*
* <ol>
* <li><b>Flow 1: Claude Desktop ↔ OpenMetadata (MCP OAuth)</b>
* <ul>
* <li>Client sends state parameter (e.g., random_state_123)
* <li>Stored as "mcp.state" in session
* <li>Used when redirecting BACK to client with authorization code
* </ul>
* <li><b>Flow 2: OpenMetadata ↔ Google (SSO OAuth)</b>
* <ul>
* <li>Pac4j generates NEW state parameter (e.g., 9880e10e42)
* <li>Google returns this state in callback
* <li>Validated by AuthenticationCodeFlowHandler.handleCallback()
* </ul>
* </ol>
*/
@Slf4j
public class McpCallbackServlet extends HttpServlet {
// Error messages used in sendError() calls — kept as constants so callers that
// match on the message text see a stable contract and typos are caught at compile time.
static final String ERR_SSO_UNAVAILABLE = "MCP SSO not available. Please restart the server.";
static final String ERR_CSRF_ORIGIN_MISMATCH =
"CSRF protection: request origin does not match server origin";
static final String ERR_MISSING_ID_TOKEN = "Invalid MCP OAuth callback - missing id_token";
static final String ERR_CALLBACK_FAILED = "MCP OAuth callback processing failed";
static final String ERR_MISSING_STATE = "Invalid MCP OAuth callback - missing state";
static final String ERR_STATE_NOT_FOUND =
"Invalid MCP OAuth callback - state not found or expired";
private final UserSSOOAuthProvider userSSOProvider;
private final McpPendingAuthRequestRepository pendingAuthRepository;
private volatile IdTokenValidator idTokenValidator;
private volatile AuthenticationCodeFlowHandler validatorBuiltFrom;
// Cached server origin for CSRF validation — resolved lazily on first POST and held
// for the server lifetime (restart required if the base URL is reconfigured via UI).
private volatile String cachedServerOrigin;
public McpCallbackServlet(UserSSOOAuthProvider userSSOProvider) {
this.userSSOProvider = userSSOProvider;
this.pendingAuthRepository = new McpPendingAuthRequestRepository();
LOG.info("Initialized McpCallbackServlet (runtime SSO dispatch)");
}
McpCallbackServlet(
UserSSOOAuthProvider userSSOProvider, McpPendingAuthRequestRepository pendingAuthRepository) {
this.userSSOProvider = userSSOProvider;
this.pendingAuthRepository = pendingAuthRepository;
}
protected AuthenticationCodeFlowHandler resolveSsoHandler() {
try {
var authConfig = SecurityConfigurationManager.getCurrentAuthConfig();
if (authConfig == null
|| authConfig.getProvider() == null
|| authConfig.getProvider() == AuthProvider.BASIC
|| authConfig.getProvider() == AuthProvider.LDAP) {
return null;
}
return AuthenticationCodeFlowHandler.getInstance();
} catch (Exception e) {
LOG.warn("SSO handler not available: {}", e.getMessage());
return null;
}
}
private String resolveBaseUrl() {
try {
MCPConfiguration mcpConfig = SecurityConfigurationManager.getCurrentMcpConfig();
if (mcpConfig != null && mcpConfig.getBaseUrl() != null) {
return mcpConfig.getBaseUrl();
}
} catch (Exception e) {
LOG.warn("Failed to get base URL from MCP config", e);
}
try {
SystemRepository systemRepository = Entity.getSystemRepository();
if (systemRepository != null) {
Settings settings = systemRepository.getOMBaseUrlConfigInternal();
if (settings != null && settings.getConfigValue() != null) {
OpenMetadataBaseUrlConfiguration urlConfig =
(OpenMetadataBaseUrlConfiguration) settings.getConfigValue();
if (urlConfig != null && urlConfig.getOpenMetadataUrl() != null) {
return urlConfig.getOpenMetadataUrl();
}
}
}
} catch (Exception e) {
LOG.warn("Could not get base URL from system settings", e);
}
LOG.error(
"No base URL configured in MCP settings or system settings. "
+ "Falling back to http://localhost:8585 — this is only suitable for local development. "
+ "Configure a proper base URL for production deployments.");
return "http://localhost:8585";
}
private Map<String, String> getClaimsMapping() {
try {
var authConfig = SecurityConfigurationManager.getCurrentAuthConfig();
if (authConfig != null && authConfig.getJwtPrincipalClaimsMapping() != null) {
Map<String, String> mapping = new TreeMap<>();
for (String claimPair : authConfig.getJwtPrincipalClaimsMapping()) {
String[] parts = claimPair.split(":");
if (parts.length == 2) {
mapping.put(parts[0], parts[1]);
}
}
return mapping;
}
} catch (Exception e) {
LOG.warn("Failed to get claims mapping from config: {}", e.getMessage());
}
return Map.of();
}
private String[] getClaimsOrder() {
try {
var authConfig = SecurityConfigurationManager.getCurrentAuthConfig();
if (authConfig != null && authConfig.getJwtPrincipalClaims() != null) {
return authConfig.getJwtPrincipalClaims().toArray(new String[0]);
}
} catch (Exception e) {
LOG.warn("Failed to get claims order from config: {}", e.getMessage());
}
return new String[] {"email", "preferred_username", "sub"};
}
private String getPrincipalDomain() {
try {
var authzConfig = SecurityConfigurationManager.getCurrentAuthzConfig();
if (authzConfig != null && authzConfig.getPrincipalDomain() != null) {
return authzConfig.getPrincipalDomain();
}
LOG.debug("Principal domain not configured, using empty string");
} catch (Exception e) {
LOG.warn("Failed to get principal domain from config: {}", e.getMessage());
}
return "";
}
private IdTokenValidator getIdTokenValidator(AuthenticationCodeFlowHandler ssoHandler) {
if (idTokenValidator != null && validatorBuiltFrom == ssoHandler) {
return idTokenValidator;
}
synchronized (this) {
if (idTokenValidator == null || validatorBuiltFrom != ssoHandler) {
idTokenValidator = createIdTokenValidator(ssoHandler);
validatorBuiltFrom = ssoHandler;
}
return idTokenValidator;
}
}
private IdTokenValidator createIdTokenValidator(AuthenticationCodeFlowHandler ssoHandler) {
var authConfig = SecurityConfigurationManager.getCurrentAuthConfig();
if (authConfig == null) {
throw new IllegalStateException(
"Authentication configuration not initialized. Cannot validate ID tokens.");
}
String expectedIssuer;
try {
expectedIssuer =
ssoHandler.getClient().getConfiguration().getProviderMetadata().getIssuer().getValue();
} catch (Exception e) {
LOG.warn(
"Could not extract issuer from OIDC provider metadata, will use default: {}",
e.getMessage());
expectedIssuer = authConfig.getAuthority();
}
String expectedAudience = null;
try {
expectedAudience = ssoHandler.getClient().getConfiguration().getClientId();
LOG.debug("ID token audience validation enabled for client ID: {}", expectedAudience);
} catch (Exception e) {
LOG.warn(
"Could not extract SSO client ID for audience validation, skipping: {}", e.getMessage());
}
return new IdTokenValidator(authConfig.getPublicKeyUrls(), expectedIssuer, expectedAudience);
}
@Override
protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
// Handles the form POST from serveFragmentExtractionPage() — the JS page extracts the
// id_token from window.location.hash and submits it here via a hidden form so the token
// never appears in a URL, browser history, access logs, or Referer headers.
AuthenticationCodeFlowHandler ssoHandler = resolveSsoHandler();
if (ssoHandler == null) {
response.sendError(HttpServletResponse.SC_SERVICE_UNAVAILABLE, ERR_SSO_UNAVAILABLE);
} else if (!isOriginAllowed(request)) {
// CSRF protection: the form is always submitted from the same origin as this server.
// Reject any POST whose Origin header does not match the server's own base URL so a
// malicious cross-origin page cannot submit an attacker-controlled id_token to hijack
// a victim's pending MCP auth session.
LOG.warn(
"MCP OAuth doPost rejected: Origin '{}' does not match server origin",
request.getHeader("Origin"));
response.sendError(HttpServletResponse.SC_FORBIDDEN, ERR_CSRF_ORIGIN_MISMATCH);
} else {
String idTokenParam = request.getParameter("id_token");
if (idTokenParam == null || idTokenParam.isEmpty()) {
response.sendError(HttpServletResponse.SC_BAD_REQUEST, ERR_MISSING_ID_TOKEN);
} else {
try {
LOG.info("Handling MCP OAuth fragment POST callback (id_token extracted from hash)");
handleDirectIdTokenFlow(request, response, idTokenParam, ssoHandler);
} catch (Exception e) {
LOG.error("MCP OAuth fragment POST callback failed", e);
response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, ERR_CALLBACK_FAILED);
}
}
}
}
/**
* Returns {@code true} when the request {@code Origin} is absent (same-origin, allowed) or
* matches this server's own origin. A present, non-matching {@code Origin} is cross-origin and
* must be rejected. When the server origin cannot be resolved (misconfiguration, startup race,
* DB error) and a non-null {@code Origin} is present, the request is rejected rather than
* silently bypassing CSRF protection.
*/
boolean isOriginAllowed(HttpServletRequest request) {
String origin = request.getHeader("Origin");
if (origin == null) {
return true;
}
String serverOrigin = getServerOrigin();
if (serverOrigin == null) {
LOG.warn(
"MCP OAuth CSRF check: server origin unknown; rejecting cross-origin POST from '{}'",
origin);
return false;
}
return origin.equals(serverOrigin);
}
private String getServerOrigin() {
String cached = cachedServerOrigin;
if (cached != null) {
return cached;
}
String resolved = resolveServerOrigin();
if (resolved != null) {
cachedServerOrigin = resolved;
}
return resolved;
}
String resolveServerOrigin() {
try {
MCPConfiguration mcpConfig = SecurityConfigurationManager.getCurrentMcpConfig();
String baseUrl = mcpConfig != null ? mcpConfig.getBaseUrl() : null;
if (baseUrl == null) {
SystemRepository systemRepo = Entity.getSystemRepository();
Settings settings = systemRepo != null ? systemRepo.getOMBaseUrlConfigInternal() : null;
if (settings != null) {
OpenMetadataBaseUrlConfiguration urlConfig =
(OpenMetadataBaseUrlConfiguration) settings.getConfigValue();
baseUrl = urlConfig != null ? urlConfig.getOpenMetadataUrl() : null;
}
}
if (baseUrl == null) {
return null;
}
URI uri = URI.create(baseUrl);
int port = uri.getPort();
// Browsers omit default ports from the Origin header (443 for https, 80 for http).
// Strip them from the reconstructed origin so the comparison cannot fail on
// a baseUrl written with an explicit default port (e.g. https://example.com:443).
boolean isDefaultPort =
("https".equals(uri.getScheme()) && port == 443)
|| ("http".equals(uri.getScheme()) && port == 80);
String portPart = (port != -1 && !isDefaultPort) ? ":" + port : "";
return uri.getScheme() + "://" + uri.getHost() + portPart;
} catch (Exception e) {
LOG.warn("Could not resolve server origin for CSRF check: {}", e.getMessage());
return null;
}
}
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
AuthenticationCodeFlowHandler ssoHandler = resolveSsoHandler();
if (ssoHandler == null) {
LOG.warn(
"MCP SSO callback hit but SSO was not configured at server startup. "
+ "The authentication provider may have been changed after the server started.");
response.setStatus(HttpServletResponse.SC_SERVICE_UNAVAILABLE);
response.setContentType("text/html; charset=UTF-8");
response
.getWriter()
.write(
"<html><body>"
+ "<h1>MCP SSO Not Available</h1>"
+ "<p>The authentication provider was changed after the server started. "
+ "Please restart the server for MCP SSO authentication to take effect.</p>"
+ "</body></html>");
return;
}
try {
LOG.info("Handling SSO callback for MCP OAuth");
String pac4jState = request.getParameter("state");
String idTokenParam = request.getParameter("id_token");
LOG.debug(
"MCP callback request: method={}, queryString={}, refererPresent={}",
request.getMethod(),
request.getQueryString() != null
? request.getQueryString().replaceAll("(id_token|code|token)=[^&]*", "$1=[REDACTED]")
: "none",
request.getHeader("Referer") != null);
LOG.debug(
"Received SSO callback with pac4j state: {}, id_token present: {}",
pac4jState,
idTokenParam != null);
if (pac4jState == null && idTokenParam == null) {
LOG.debug(
"MCP callback has neither state nor id_token in query params. "
+ "Likely an implicit-flow redirect with id_token in URL fragment.");
}
if ((pac4jState == null || pac4jState.isEmpty()) && idTokenParam != null) {
LOG.info("Handling direct ID token flow (user already authenticated)");
handleDirectIdTokenFlow(request, response, idTokenParam, ssoHandler);
return;
}
if (pac4jState == null || pac4jState.isEmpty()) {
// The id_token may be in the URL fragment (e.g., Google/Azure implicit flow delivers
// the token as #id_token=... after an active-session shortcut). Fragments are
// client-side only — the server never receives them. Serve a tiny JS page that
// reads window.location.hash, extracts the id_token, and retries this endpoint as
// a query param so the server can process it via handleDirectIdTokenFlow().
LOG.info(
"MCP OAuth callback arrived with no state/id_token query params; "
+ "serving fragment-extraction page to handle implicit-flow redirect");
serveFragmentExtractionPage(response);
return;
}
McpPendingAuthRequest pendingRequest = pendingAuthRepository.findByPac4jState(pac4jState);
if (pendingRequest == null) {
LOG.warn(
"No pending auth request found for pac4j state (hash={})",
Integer.toHexString(pac4jState.hashCode()));
response.sendError(HttpServletResponse.SC_BAD_REQUEST, ERR_STATE_NOT_FOUND);
return;
}
LOG.debug("Found pending auth request: {}", pendingRequest.authRequestId());
HttpSession session = request.getSession(true);
String clientName = ssoHandler.getClient().getName();
LOG.debug("Restoring pac4j session attributes for client: {}", clientName);
OidcClient oidcClient = ssoHandler.getClient();
if (pendingRequest.pac4jState() != null) {
State stateObj = new State(pendingRequest.pac4jState());
session.setAttribute(oidcClient.getStateSessionAttributeName(), stateObj);
LOG.debug("Restored pac4j state for client {}", clientName);
}
if (pendingRequest.pac4jNonce() != null) {
session.setAttribute(
oidcClient.getNonceSessionAttributeName(), pendingRequest.pac4jNonce());
LOG.debug("Restored pac4j nonce for client {}", clientName);
}
if (pendingRequest.pac4jCodeVerifier() != null) {
CodeVerifier verifierObj = new CodeVerifier(pendingRequest.pac4jCodeVerifier());
session.setAttribute(oidcClient.getCodeVerifierSessionAttributeName(), verifierObj);
LOG.debug("Restored pac4j code verifier for client {}", clientName);
}
String ssoCallbackUrl = ssoHandler.getClient().getCallbackUrl();
session.setAttribute(AuthenticationCodeFlowHandler.SESSION_SSO_CALLBACK_URL, ssoCallbackUrl);
String baseUrl = resolveBaseUrl();
session.setAttribute(
AuthenticationCodeFlowHandler.SESSION_REDIRECT_URI, baseUrl + "/mcp/callback");
LOG.debug("Set session SSO callback URL to: {}", ssoCallbackUrl);
BufferedServletResponseWrapper callbackResponse =
new BufferedServletResponseWrapper(response);
ssoHandler.handleCallback(request, callbackResponse);
if (callbackResponse.getStatusCode() >= HttpServletResponse.SC_BAD_REQUEST) {
String errorBody = callbackResponse.getCapturedBody();
LOG.error(
"SSO token exchange failed with HTTP {}: {}",
callbackResponse.getStatusCode(),
errorBody);
throw new IllegalStateException(
"SSO provider token exchange failed (HTTP "
+ callbackResponse.getStatusCode()
+ "): "
+ errorBody);
}
OidcCredentials credentials = (OidcCredentials) session.getAttribute(OIDC_CREDENTIAL_PROFILE);
if (credentials == null || credentials.getIdToken() == null) {
throw new IllegalStateException("No OIDC credentials found in session after SSO callback");
}
JWT idToken = credentials.getIdToken();
JWTClaimsSet claimsSet;
try {
claimsSet = getIdTokenValidator(ssoHandler).validateAndDecode(idToken.serialize());
LOG.debug("ID token signature validated successfully in standard SSO flow");
} catch (IdTokenValidator.IdTokenValidationException e) {
LOG.error(
"SECURITY ALERT: ID token validation failed in standard SSO flow. Reason: {}. IP: {}",
e.getMessage(),
request.getRemoteAddr());
throw new IllegalStateException(
"ID token validation failed: " + e.getMessage() + ". Please restart authentication.",
e);
}
Map<String, Object> claims = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
claims.putAll(claimsSet.getClaims());
String userName =
findUserNameFromClaims(getClaimsMapping(), List.of(getClaimsOrder()), claims);
String email =
findEmailFromClaims(
getClaimsMapping(), List.of(getClaimsOrder()), claims, getPrincipalDomain());
if (userName == null || userName.trim().isEmpty()) {
LOG.error(
"Could not extract username from SSO claims. Available claims: {}", claims.keySet());
throw new IllegalStateException(
"SSO provider did not provide required user information (username). "
+ "Please contact your administrator.");
}
LOG.debug("Extracted user identity from SSO callback");
processBufferedCallbackResponse(
response,
wrappedResponse ->
userSSOProvider.handleSSOCallbackWithDbState(
request,
wrappedResponse,
userName,
email,
"mcp:" + pendingRequest.authRequestId()));
LOG.info("MCP OAuth SSO callback completed successfully");
} catch (Exception e) {
LOG.error("SSO callback handling failed", e);
if (!response.isCommitted()) {
try {
response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
response.setContentType("text/html; charset=UTF-8");
response
.getWriter()
.write(
"<html><body><h1>Authentication Failed</h1>"
+ "<p>An internal error occurred while processing authentication. "
+ "Please try again or contact your administrator.</p></body></html>");
} catch (Exception writeEx) {
LOG.error("Failed to write error response", writeEx);
}
}
}
}
private void handleDirectIdTokenFlow(
HttpServletRequest request,
HttpServletResponse response,
String idTokenString,
AuthenticationCodeFlowHandler ssoHandler)
throws Exception {
HttpSession session = request.getSession(false);
if (session == null) {
throw new IllegalStateException("No session found for direct ID token flow");
}
String authRequestId = (String) session.getAttribute("mcp.auth.request.id");
if (authRequestId == null) {
throw new IllegalStateException("No auth request ID found in session");
}
LOG.debug("Found auth request ID in session: {}", authRequestId);
McpPendingAuthRequest pendingRequest = pendingAuthRepository.findByAuthRequestId(authRequestId);
if (pendingRequest == null) {
throw new IllegalStateException(
"Pending auth request not found or expired: " + authRequestId);
}
JWTClaimsSet claimsSet;
try {
claimsSet = getIdTokenValidator(ssoHandler).validateAndDecode(idTokenString);
LOG.debug("ID token signature validated successfully for auth request: {}", authRequestId);
} catch (IdTokenValidator.IdTokenValidationException e) {
LOG.error(
"SECURITY ALERT: ID token validation failed for auth request: {}. Reason: {}. IP: {}",
authRequestId,
e.getMessage(),
request.getRemoteAddr());
throw new IllegalStateException(
"ID token validation failed: " + e.getMessage() + ". Please restart authentication.", e);
}
Map<String, Object> claims = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
claims.putAll(claimsSet.getClaims());
String userName = findUserNameFromClaims(getClaimsMapping(), List.of(getClaimsOrder()), claims);
String email =
findEmailFromClaims(
getClaimsMapping(), List.of(getClaimsOrder()), claims, getPrincipalDomain());
if (userName == null || userName.trim().isEmpty()) {
LOG.error(
"Could not extract username from SSO claims. Available claims: {}", claims.keySet());
throw new IllegalStateException(
"SSO provider did not provide required user information (username). "
+ "Please contact your administrator.");
}
if (email == null || email.trim().isEmpty()) {
LOG.warn("Could not extract email from SSO claims. Available claims: {}", claims.keySet());
}
LOG.debug("Extracted user identity from direct ID token flow");
session.removeAttribute("mcp.auth.request.id");
processBufferedCallbackResponse(
response,
wrappedResponse ->
userSSOProvider.handleSSOCallbackWithDbState(
request, wrappedResponse, userName, email, "mcp:" + authRequestId));
LOG.info("MCP OAuth direct ID token flow completed successfully");
}
/**
* Serves a minimal HTML page with JavaScript that extracts the {@code id_token} from the URL
* fragment ({@code window.location.hash}) and retries {@code /mcp/callback} with the token as a
* real query parameter. This is needed because SSO providers using the implicit or hybrid flow
* return the id_token in the URL fragment (e.g., {@code /mcp/callback#id_token=eyJ...}), which
* the server never receives — only client-side JavaScript can read {@code window.location.hash}.
*/
private void serveFragmentExtractionPage(HttpServletResponse response) throws IOException {
response.setContentType("text/html;charset=UTF-8");
response.setStatus(HttpServletResponse.SC_OK);
// POST the token in the form body so it never appears in a URL, browser history,
// access logs, or Referer headers (cf. RFC 6819 §5.3.5).
response
.getWriter()
.write(
"<!DOCTYPE html><html><head>"
+ "<meta charset=\"UTF-8\">"
+ "<title>MCP OAuth Completing authentication...</title>"
+ "</head><body>"
+ "<p>Completing authentication, please wait...</p>"
+ "<script>"
+ "try {"
+ " var hash = window.location.hash.slice(1);"
+ " var params = new URLSearchParams(hash);"
+ " var idToken = params.get('id_token');"
+ " if (idToken) {"
+ " var f = document.createElement('form');"
+ " f.method = 'POST';"
+ " f.action = window.location.pathname;"
+ " var i = document.createElement('input');"
+ " i.type = 'hidden'; i.name = 'id_token'; i.value = idToken;"
+ " f.appendChild(i);"
+ " document.body.appendChild(f);"
+ " f.submit();"
+ " } else {"
+ " document.body.textContent = 'MCP OAuth Error: Authentication failed — the SSO provider did not return an id_token. Please close this tab and retry.';"
+ " }"
+ "} catch(e) {"
+ " document.body.textContent = 'MCP OAuth Error: Fragment extraction failed. Please retry.';"
+ "}"
+ "</script></body></html>");
}
private void processBufferedCallbackResponse(
HttpServletResponse response, BufferedResponseAction action) throws Exception {
BufferedServletResponseWrapper bufferedResponse = new BufferedServletResponseWrapper(response);
action.execute(bufferedResponse);
bufferedResponse.commitTo(response);
}
@FunctionalInterface
private interface BufferedResponseAction {
void execute(HttpServletResponse response) throws Exception;
}
private static final class BufferedServletResponseWrapper extends HttpServletResponseWrapper {
private final ByteArrayOutputStream buffer = new ByteArrayOutputStream();
private final ServletOutputStream outputStream = new BufferedServletOutputStream(buffer);
private PrintWriter writer;
private Integer statusCode = HttpServletResponse.SC_OK;
private String contentType;
private String characterEncoding = StandardCharsets.UTF_8.name();
private String redirectLocation;
private boolean committed;
private boolean outputStreamRequested;
private boolean writerRequested;
BufferedServletResponseWrapper(HttpServletResponse response) {
super(response);
}
int getStatusCode() {
return statusCode;
}
String getCapturedBody() throws IOException {
flushBuffer();
return buffer.toString(StandardCharsets.UTF_8);
}
void commitTo(HttpServletResponse response) throws IOException {
flushBuffer();
if (redirectLocation != null) {
response.sendRedirect(redirectLocation);
return;
}
response.setStatus(statusCode);
if (characterEncoding != null) {
response.setCharacterEncoding(characterEncoding);
}
if (contentType != null) {
response.setContentType(contentType);
}
if (buffer.size() > 0) {
response.getOutputStream().write(buffer.toByteArray());
}
response.flushBuffer();
}
@Override
public void setStatus(int sc) {
statusCode = sc;
}
@Override
public void sendError(int sc) {
statusCode = sc;
committed = true;
}
@Override
public void sendError(int sc, String msg) throws IOException {
statusCode = sc;
committed = true;
if (msg != null) {
writeToBuffer(msg);
}
}
@Override
public void sendRedirect(String location) {
redirectLocation = location;
committed = true;
}
@Override
public void setContentType(String type) {
contentType = type;
}
@Override
public String getContentType() {
return contentType;
}
@Override
public void setCharacterEncoding(String charset) {
if (!writerRequested) {
characterEncoding = charset;
}
}
@Override
public String getCharacterEncoding() {
return characterEncoding;
}
@Override
public ServletOutputStream getOutputStream() {
if (writerRequested) {
throw new IllegalStateException("getWriter() has already been called on this response");
}
outputStreamRequested = true;
return outputStream;
}
@Override
public PrintWriter getWriter() {
if (outputStreamRequested) {
throw new IllegalStateException(
"getOutputStream() has already been called on this response");
}
if (writer == null) {
writer =
new PrintWriter(
new OutputStreamWriter(
buffer,
Charset.forName(
characterEncoding != null
? characterEncoding
: StandardCharsets.UTF_8.name())),
true);
}
writerRequested = true;
return writer;
}
@Override
public void flushBuffer() throws IOException {
if (writer != null) {
writer.flush();
}
if (outputStreamRequested) {
outputStream.flush();
}
committed = true;
}
@Override
public boolean isCommitted() {
return committed;
}
private void writeToBuffer(String value) throws IOException {
if (writer != null) {
writer.flush();
}
buffer.write(
value.getBytes(
Charset.forName(
characterEncoding != null ? characterEncoding : StandardCharsets.UTF_8.name())));
}
}
private static final class BufferedServletOutputStream extends ServletOutputStream {
private final ByteArrayOutputStream buffer;
private BufferedServletOutputStream(ByteArrayOutputStream buffer) {
this.buffer = buffer;
}
@Override
public void write(int b) {
buffer.write(b);
}
@Override
public boolean isReady() {
return true;
}
@Override
public void setWriteListener(WriteListener writeListener) {}
}
}
@@ -0,0 +1,282 @@
package org.openmetadata.mcp.server.auth.handlers;
import java.net.URI;
import java.security.SecureRandom;
import java.util.Base64;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import lombok.extern.slf4j.Slf4j;
import org.openmetadata.mcp.auth.OAuthClientInformation;
import org.openmetadata.mcp.auth.OAuthClientMetadata;
import org.openmetadata.mcp.auth.exception.RegistrationException;
import org.openmetadata.mcp.server.auth.repository.OAuthClientRepository;
/**
* Handler for OAuth 2.0 Dynamic Client Registration (RFC 7591).
*
* <p>Processes client registration requests and generates client credentials.
*
* <p>TODO: The registration endpoint is currently open (unauthenticated) per RFC 7591 §3 SHOULD
* and MCP spec requirements. Future improvements:
* <ul>
* <li>Add configurable registration policy (open vs admin-only) via MCPConfiguration
* <li>Add client expiry and automated cleanup of orphaned registrations
* <li>Consider static client initialization during server configuration
* <li>Add admin API for listing and pruning registered clients
* </ul>
*/
@Slf4j
public class RegistrationHandler {
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
private static final int CLIENT_SECRET_BYTES = 32;
// Token endpoint auth methods (RFC 7591). "none" denotes a public client (PKCE only, no secret).
private static final String AUTH_METHOD_NONE = "none";
private static final String AUTH_METHOD_CLIENT_SECRET_POST = "client_secret_post";
private static final String AUTH_METHOD_CLIENT_SECRET_BASIC = "client_secret_basic";
// Block dangerous schemes; allow http, https, and private-use URI schemes
// per RFC 8252 Section 7.1 (e.g. cursor://, vscode://, claude-desktop://)
private static final Set<String> BLOCKED_REDIRECT_SCHEMES =
Set.of("javascript", "data", "file", "blob", "vbscript");
private static final Set<String> LOOPBACK_HOSTS = Set.of("localhost", "127.0.0.1", "::1");
private final OAuthClientRepository clientRepository;
public RegistrationHandler(OAuthClientRepository clientRepository) {
this.clientRepository = clientRepository;
}
/**
* Handle a client registration request.
*
* <p>Validates the registration metadata and generates client credentials.
*
* @param metadata The client registration metadata
* @return CompletableFuture resolving to registered client information
*/
public CompletableFuture<OAuthClientInformation> handle(OAuthClientMetadata metadata) {
return CompletableFuture.supplyAsync(
() -> {
try {
validateRegistrationRequest(metadata);
OAuthClientInformation clientInfo = new OAuthClientInformation();
// Generate client credentials. The token_endpoint_auth_method decides whether this is a
// confidential client (issued a secret) or a public client (PKCE only).
String clientId = generateClientId();
long issuedAt = System.currentTimeMillis() / 1000;
String authMethod =
metadata.getTokenEndpointAuthMethod() != null
? metadata.getTokenEndpointAuthMethod()
: AUTH_METHOD_CLIENT_SECRET_POST;
clientInfo.setClientId(clientId);
clientInfo.setClientIdIssuedAt(issuedAt);
clientInfo.setTokenEndpointAuthMethod(authMethod);
// Public clients (token_endpoint_auth_method=none) must NOT be issued a client secret
// (RFC 7591 §3.2.1 / RFC 8252) — they are secured by PKCE. Issuing one anyway makes the
// token endpoint demand a secret the client never has, breaking public clients such as
// Cursor and ChatGPT. Only confidential clients receive a secret.
if (!AUTH_METHOD_NONE.equals(authMethod)) {
clientInfo.setClientSecret(generateClientSecret());
clientInfo.setClientSecretExpiresAt(0L); // 0 means never expires
}
// Copy metadata
clientInfo.setClientName(metadata.getClientName());
clientInfo.setRedirectUris(metadata.getRedirectUris());
clientInfo.setGrantTypes(
metadata.getGrantTypes() != null && !metadata.getGrantTypes().isEmpty()
? metadata.getGrantTypes()
: List.of("authorization_code", "refresh_token"));
clientInfo.setResponseTypes(
metadata.getResponseTypes() != null && !metadata.getResponseTypes().isEmpty()
? metadata.getResponseTypes()
: List.of("code"));
clientInfo.setScope(metadata.getScope());
// Optional metadata
clientInfo.setClientUri(metadata.getClientUri());
clientInfo.setLogoUri(metadata.getLogoUri());
clientInfo.setContacts(metadata.getContacts());
clientInfo.setTosUri(metadata.getTosUri());
clientInfo.setPolicyUri(metadata.getPolicyUri());
clientInfo.setSoftwareId(metadata.getSoftwareId());
clientInfo.setSoftwareVersion(metadata.getSoftwareVersion());
// Register in database
clientRepository.register(clientInfo);
LOG.info("Successfully registered OAuth client: {}", clientId);
return clientInfo;
} catch (RegistrationException e) {
LOG.warn("Client registration rejected: {}", e.getMessage());
throw new RuntimeException(e);
} catch (Exception e) {
LOG.error("Client registration failed due to server error", e);
throw new RuntimeException(
new RegistrationException("server_error", "Client registration failed"));
}
});
}
/**
* Validate registration request per RFC 7591.
*
* @param metadata The client registration metadata
* @throws RegistrationException if validation fails
*/
private static final int MAX_STRING_FIELD_LENGTH = 255;
private static final int MAX_URI_FIELD_LENGTH = 2048;
private static final int MAX_REDIRECT_URIS = 10;
private static final int MAX_CONTACTS = 5;
private static final int MAX_GRANT_TYPES = 5;
private static final int MAX_RESPONSE_TYPES = 5;
private void validateRegistrationRequest(OAuthClientMetadata metadata)
throws RegistrationException {
// Validate string field lengths to prevent storage exhaustion
validateFieldLength(metadata.getClientName(), "client_name", MAX_STRING_FIELD_LENGTH);
validateFieldLength(metadata.getSoftwareId(), "software_id", MAX_STRING_FIELD_LENGTH);
validateFieldLength(metadata.getSoftwareVersion(), "software_version", MAX_STRING_FIELD_LENGTH);
validateFieldLength(metadata.getScope(), "scope", MAX_STRING_FIELD_LENGTH);
validateUriFieldLength(metadata.getClientUri(), "client_uri");
validateUriFieldLength(metadata.getLogoUri(), "logo_uri");
validateUriFieldLength(metadata.getTosUri(), "tos_uri");
validateUriFieldLength(metadata.getPolicyUri(), "policy_uri");
if (metadata.getContacts() != null) {
validateListSize(metadata.getContacts(), "contacts", MAX_CONTACTS);
for (String contact : metadata.getContacts()) {
validateFieldLength(contact, "contacts entry", MAX_STRING_FIELD_LENGTH);
}
}
// redirect_uris is REQUIRED per RFC 7591 Section 2
if (metadata.getRedirectUris() == null || metadata.getRedirectUris().isEmpty()) {
throw new RegistrationException(
"invalid_redirect_uri", "At least one redirect_uri must be provided");
}
validateListSize(metadata.getRedirectUris(), "redirect_uris", MAX_REDIRECT_URIS);
// Validate redirect URI schemes and hosts
// RFC 8252 Section 7.1: native apps may use private-use URI schemes (e.g. cursor://, vscode://)
// RFC 8252 Section 7.3: http redirect URIs MUST use loopback addresses only
for (URI uri : metadata.getRedirectUris()) {
String scheme = uri.getScheme();
if (scheme == null || BLOCKED_REDIRECT_SCHEMES.contains(scheme.toLowerCase())) {
throw new RegistrationException(
"invalid_redirect_uri", "redirect_uri uses a disallowed scheme: " + uri);
}
if (uri.getFragment() != null) {
throw new RegistrationException(
"invalid_redirect_uri", "redirect_uri must not contain a fragment: " + uri);
}
if ("http".equalsIgnoreCase(scheme)) {
String host = uri.getHost();
if (host == null || !LOOPBACK_HOSTS.contains(host)) {
throw new RegistrationException(
"invalid_redirect_uri",
"http redirect_uri must use localhost/loopback address: " + uri);
}
}
}
// Validate supported grant types
if (metadata.getGrantTypes() != null) {
validateListSize(metadata.getGrantTypes(), "grant_types", MAX_GRANT_TYPES);
for (String grantType : metadata.getGrantTypes()) {
if (!isSupportedGrantType(grantType)) {
throw new RegistrationException(
"invalid_client_metadata", "Unsupported grant_type: " + grantType);
}
}
}
// Validate supported response types
if (metadata.getResponseTypes() != null) {
validateListSize(metadata.getResponseTypes(), "response_types", MAX_RESPONSE_TYPES);
for (String responseType : metadata.getResponseTypes()) {
if (!responseType.equals("code")) {
throw new RegistrationException(
"invalid_client_metadata",
"Unsupported response_type: " + responseType + ". Only 'code' is supported.");
}
}
}
// Validate token endpoint auth method
if (metadata.getTokenEndpointAuthMethod() != null) {
if (!isSupportedAuthMethod(metadata.getTokenEndpointAuthMethod())) {
throw new RegistrationException(
"invalid_client_metadata",
"Unsupported token_endpoint_auth_method: " + metadata.getTokenEndpointAuthMethod());
}
}
}
private boolean isSupportedGrantType(String grantType) {
return "authorization_code".equals(grantType) || "refresh_token".equals(grantType);
}
private boolean isSupportedAuthMethod(String authMethod) {
return AUTH_METHOD_CLIENT_SECRET_POST.equals(authMethod)
|| AUTH_METHOD_CLIENT_SECRET_BASIC.equals(authMethod)
|| AUTH_METHOD_NONE.equals(authMethod);
}
/**
* Generate cryptographically secure client ID.
*
* @return A unique client identifier
*/
private String generateClientId() {
return UUID.randomUUID().toString();
}
/**
* Generate cryptographically secure client secret.
*
* @return A base64url-encoded random secret
*/
private String generateClientSecret() {
byte[] secretBytes = new byte[CLIENT_SECRET_BYTES];
SECURE_RANDOM.nextBytes(secretBytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(secretBytes);
}
private void validateFieldLength(String value, String fieldName, int maxLength)
throws RegistrationException {
if (value != null && value.length() > maxLength) {
throw new RegistrationException(
"invalid_client_metadata", fieldName + " exceeds maximum length of " + maxLength);
}
}
private void validateUriFieldLength(URI value, String fieldName) throws RegistrationException {
if (value != null && value.toString().length() > MAX_URI_FIELD_LENGTH) {
throw new RegistrationException(
"invalid_client_metadata",
fieldName + " exceeds maximum length of " + MAX_URI_FIELD_LENGTH);
}
}
private void validateListSize(java.util.Collection<?> list, String fieldName, int maxSize)
throws RegistrationException {
if (list != null && list.size() > maxSize) {
throw new RegistrationException(
"invalid_client_metadata",
fieldName + " exceeds maximum allowed entries (" + maxSize + ")");
}
}
}
@@ -0,0 +1,85 @@
package org.openmetadata.mcp.server.auth.handlers;
import java.util.concurrent.CompletableFuture;
import lombok.extern.slf4j.Slf4j;
import org.openmetadata.mcp.server.auth.repository.OAuthTokenRepository;
/**
* Handler for OAuth token revocation requests (RFC 7009).
*
* <p>This handler processes token revocation requests and ensures RFC 7009 compliance by:
* - Returning success (200 OK) even if the token is not found
* - Supporting both access and refresh token revocation
* - Honoring token_type_hint when provided
*/
@Slf4j
public class RevocationHandler {
private final OAuthTokenRepository tokenRepository;
public RevocationHandler(OAuthTokenRepository tokenRepository) {
this.tokenRepository = tokenRepository;
}
/**
* Revoke a token (access or refresh).
*
* <p>RFC 7009 compliance: The authorization server responds with HTTP status code 200 if the
* token has been revoked successfully or if the client submitted an invalid token.
*
* @param token The token to revoke
* @param tokenTypeHint Optional hint about the token type ("access_token" or "refresh_token")
* @return CompletableFuture that completes when revocation is done
*/
public CompletableFuture<Void> revokeToken(String token, String tokenTypeHint) {
return CompletableFuture.runAsync(
() -> {
if (token == null || token.trim().isEmpty()) {
LOG.debug("Revocation request with empty token");
return;
}
boolean revoked = false;
Exception lastError = null;
if ("refresh_token".equals(tokenTypeHint) || tokenTypeHint == null) {
try {
tokenRepository.revokeRefreshToken(token);
LOG.debug("Successfully revoked refresh token");
revoked = true;
} catch (IllegalArgumentException e) {
// Token not found - expected case per RFC 7009
LOG.debug("Token not found as refresh token: {}", e.getMessage());
} catch (Exception e) {
// Database or other error - unexpected
LOG.warn("Database error while revoking refresh token: {}", e.getMessage());
lastError = e;
}
}
if (!revoked && ("access_token".equals(tokenTypeHint) || tokenTypeHint == null)) {
try {
tokenRepository.deleteAccessToken(token);
LOG.debug("Successfully revoked access token");
revoked = true;
} catch (IllegalArgumentException e) {
// Token not found - expected case per RFC 7009
LOG.debug("Token not found as access token: {}", e.getMessage());
} catch (Exception e) {
// Database or other error - unexpected
LOG.warn("Database error while revoking access token: {}", e.getMessage());
lastError = e;
}
}
if (!revoked && lastError != null) {
// If we couldn't revoke and had a database error, propagate it
throw new RuntimeException("Token revocation failed due to database error", lastError);
}
if (!revoked) {
LOG.debug("Token not found in database (RFC 7009 compliance: return success)");
}
});
}
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,46 @@
package org.openmetadata.mcp.server.auth.jobs;
import lombok.extern.slf4j.Slf4j;
import org.openmetadata.mcp.server.auth.repository.McpPendingAuthRequestRepository;
import org.openmetadata.mcp.server.auth.repository.OAuthAuthorizationCodeRepository;
import org.openmetadata.mcp.server.auth.repository.OAuthTokenRepository;
import org.quartz.Job;
import org.quartz.JobExecutionContext;
@Slf4j
public class OAuthTokenCleanupJob implements Job {
@Override
public void execute(JobExecutionContext context) {
LOG.debug("Starting OAuth token cleanup job");
boolean allSucceeded = true;
// Each cleanup step runs independently so a failure in one doesn't block the others
try {
new OAuthTokenRepository().deleteExpiredTokens();
} catch (Exception e) {
LOG.error("Failed to delete expired tokens", e);
allSucceeded = false;
}
try {
new OAuthAuthorizationCodeRepository().deleteExpired();
} catch (Exception e) {
LOG.error("Failed to delete expired authorization codes", e);
allSucceeded = false;
}
try {
new McpPendingAuthRequestRepository().deleteExpired();
} catch (Exception e) {
LOG.error("Failed to delete expired pending auth requests", e);
allSucceeded = false;
}
if (allSucceeded) {
LOG.info("OAuth token cleanup completed successfully");
} else {
LOG.warn("OAuth token cleanup completed with errors — some expired data was not removed");
}
}
}
@@ -0,0 +1,72 @@
package org.openmetadata.mcp.server.auth.jobs;
import lombok.extern.slf4j.Slf4j;
import org.quartz.JobBuilder;
import org.quartz.JobDetail;
import org.quartz.Scheduler;
import org.quartz.SchedulerException;
import org.quartz.SimpleScheduleBuilder;
import org.quartz.Trigger;
import org.quartz.TriggerBuilder;
import org.quartz.impl.StdSchedulerFactory;
@Slf4j
public class OAuthTokenCleanupScheduler {
private static final String JOB_NAME = "oauthTokenCleanupJob";
private static final String JOB_GROUP = "oauthMaintenance";
private static final String TRIGGER_NAME = "oauthTokenCleanupTrigger";
private static final int CLEANUP_INTERVAL_HOURS = 1;
private static OAuthTokenCleanupScheduler instance;
private final Scheduler scheduler;
private OAuthTokenCleanupScheduler() throws SchedulerException {
this.scheduler = new StdSchedulerFactory().getScheduler();
this.scheduler.start();
LOG.info("OAuth Token Cleanup Scheduler started");
}
public static synchronized void initialize() {
if (instance == null) {
try {
instance = new OAuthTokenCleanupScheduler();
instance.scheduleCleanupJob();
LOG.info(
"OAuth token cleanup job scheduled to run every {} hour(s)", CLEANUP_INTERVAL_HOURS);
} catch (SchedulerException e) {
LOG.error("Failed to initialize OAuth Token Cleanup Scheduler", e);
throw new RuntimeException("Failed to initialize OAuth Token Cleanup Scheduler", e);
}
} else {
LOG.debug("OAuth Token Cleanup Scheduler already initialized");
}
}
private void scheduleCleanupJob() throws SchedulerException {
JobDetail jobDetail =
JobBuilder.newJob(OAuthTokenCleanupJob.class).withIdentity(JOB_NAME, JOB_GROUP).build();
Trigger trigger =
TriggerBuilder.newTrigger()
.withIdentity(TRIGGER_NAME, JOB_GROUP)
.withSchedule(
SimpleScheduleBuilder.simpleSchedule()
.withIntervalInHours(CLEANUP_INTERVAL_HOURS)
.repeatForever())
.startNow()
.build();
scheduler.scheduleJob(jobDetail, trigger);
}
public static void shutdown() {
if (instance != null && instance.scheduler != null) {
try {
instance.scheduler.shutdown(true);
LOG.info("OAuth Token Cleanup Scheduler shut down");
} catch (SchedulerException e) {
LOG.error("Failed to shutdown OAuth Token Cleanup Scheduler", e);
}
}
}
}
@@ -0,0 +1,70 @@
package org.openmetadata.mcp.server.auth.middleware;
import at.favre.lib.crypto.bcrypt.BCrypt;
import java.util.concurrent.CompletableFuture;
import org.openmetadata.mcp.auth.OAuthAuthorizationServerProvider;
import org.openmetadata.mcp.auth.OAuthClientInformation;
/**
* Authenticates OAuth clients by verifying client_id and client_secret.
* Client secrets are stored as BCrypt hashes — only verification is possible, not recovery.
*/
public class ClientAuthenticator {
private final OAuthAuthorizationServerProvider provider;
public ClientAuthenticator(OAuthAuthorizationServerProvider provider) {
this.provider = provider;
}
/**
* Authenticate a client using client ID and optional client secret.
* @param clientId The client ID
* @param clientSecret The client secret (may be null)
* @return A CompletableFuture that resolves to the authenticated client information
*/
public CompletableFuture<OAuthClientInformation> authenticate(
String clientId, String clientSecret) {
if (clientId == null) {
return CompletableFuture.failedFuture(
new AuthenticationException("Missing client_id parameter"));
}
return provider
.getClient(clientId)
.thenCompose(
client -> {
if (client == null) {
return CompletableFuture.failedFuture(
new AuthenticationException("Client not found"));
}
// If client has a secret (stored as BCrypt hash), verify it
if (client.getClientSecret() != null) {
if (clientSecret == null) {
return CompletableFuture.failedFuture(
new AuthenticationException("Client secret required"));
}
BCrypt.Result result =
BCrypt.verifyer().verify(clientSecret.toCharArray(), client.getClientSecret());
if (!result.verified) {
return CompletableFuture.failedFuture(
new AuthenticationException("Invalid client secret"));
}
}
return CompletableFuture.completedFuture(client);
});
}
/**
* Exception thrown when client authentication fails.
*/
public static class AuthenticationException extends Exception {
public AuthenticationException(String message) {
super(message);
}
}
}
@@ -0,0 +1,47 @@
package org.openmetadata.mcp.server.auth.model;
import java.util.HashMap;
import java.util.Map;
/**
* Immutable OAuth authorization error response as defined in RFC 6749 Section 4.1.2.1.
*/
public class AuthorizationErrorResponse {
private final String error;
private final String errorDescription;
private final String state;
public AuthorizationErrorResponse(String error, String errorDescription, String state) {
this.error = error;
this.errorDescription = errorDescription;
this.state = state;
}
public String getError() {
return error;
}
public String getErrorDescription() {
return errorDescription;
}
public String getState() {
return state;
}
public Map<String, String> toQueryParams() {
Map<String, String> params = new HashMap<>();
params.put("error", error);
if (errorDescription != null) {
params.put("error_description", errorDescription);
}
if (state != null) {
params.put("state", state);
}
return params;
}
}
@@ -0,0 +1,100 @@
package org.openmetadata.mcp.server.auth.repository;
import java.security.SecureRandom;
import java.util.Base64;
import java.util.List;
import lombok.extern.slf4j.Slf4j;
import org.openmetadata.schema.utils.JsonUtils;
import org.openmetadata.service.Entity;
import org.openmetadata.service.jdbi3.CollectionDAO;
import org.openmetadata.service.jdbi3.oauth.OAuthRecords.McpPendingAuthRequest;
/**
* Repository for managing pending MCP OAuth authorization requests. These records store PKCE
* parameters and OAuth state across SSO redirects (survives cross-domain cookie loss).
*/
@Slf4j
public class McpPendingAuthRequestRepository {
private static final int AUTH_REQUEST_TTL_SECONDS = 600; // 10 minutes
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
private final CollectionDAO.McpPendingAuthRequestDAO dao;
public McpPendingAuthRequestRepository() {
this.dao = Entity.getCollectionDAO().mcpPendingAuthRequestDAO();
}
public String createPendingRequest(
String clientId,
String codeChallenge,
String codeChallengeMethod,
String redirectUri,
String mcpState,
List<String> scopes,
String pac4jState,
String pac4jNonce,
String pac4jCodeVerifier) {
String authRequestId = generateAuthRequestId();
long expiresAt = System.currentTimeMillis() + (AUTH_REQUEST_TTL_SECONDS * 1000L);
dao.insert(
authRequestId,
clientId,
codeChallenge,
codeChallengeMethod,
redirectUri,
mcpState,
JsonUtils.pojoToJson(scopes),
pac4jState,
pac4jNonce,
pac4jCodeVerifier,
expiresAt);
LOG.debug("Created pending auth request: {} for client: {}", authRequestId, clientId);
return authRequestId;
}
public McpPendingAuthRequest findByAuthRequestId(String authRequestId) {
McpPendingAuthRequest request = dao.findByAuthRequestId(authRequestId);
if (request != null && System.currentTimeMillis() > request.expiresAt()) {
LOG.warn("Pending auth request expired: {}", authRequestId);
dao.delete(authRequestId);
return null;
}
return request;
}
public McpPendingAuthRequest findByPac4jState(String pac4jState) {
McpPendingAuthRequest request = dao.findByPac4jState(pac4jState);
if (request != null && System.currentTimeMillis() > request.expiresAt()) {
LOG.warn("Pending auth request expired for pac4j state: {}", pac4jState);
dao.delete(request.authRequestId());
return null;
}
return request;
}
public void updatePac4jSession(
String authRequestId, String pac4jState, String pac4jNonce, String pac4jCodeVerifier) {
dao.updatePac4jSession(authRequestId, pac4jState, pac4jNonce, pac4jCodeVerifier);
LOG.debug("Updated pac4j session data for pending request: {}", authRequestId);
}
public void delete(String authRequestId) {
dao.delete(authRequestId);
LOG.debug("Deleted pending auth request: {}", authRequestId);
}
public void deleteExpired() {
long currentTime = System.currentTimeMillis();
dao.deleteExpired(currentTime);
LOG.info("Deleted expired pending auth requests");
}
private String generateAuthRequestId() {
byte[] bytes = new byte[24];
SECURE_RANDOM.nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
}
@@ -0,0 +1,106 @@
package org.openmetadata.mcp.server.auth.repository;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Base64;
import java.util.List;
import lombok.extern.slf4j.Slf4j;
import org.openmetadata.schema.utils.JsonUtils;
import org.openmetadata.service.Entity;
import org.openmetadata.service.jdbi3.CollectionDAO;
import org.openmetadata.service.jdbi3.oauth.OAuthRecords.OAuthAuthorizationCodeRecord;
/**
* Repository for managing OAuth authorization codes with database persistence.
*/
@Slf4j
public class OAuthAuthorizationCodeRepository {
private final CollectionDAO.OAuthAuthorizationCodeDAO dao;
public OAuthAuthorizationCodeRepository() {
this.dao = Entity.getCollectionDAO().oauthAuthorizationCodeDAO();
}
/**
* Store an authorization code.
*/
public void store(
String code,
String clientId,
String userName,
String codeChallenge,
String codeChallengeMethod,
URI redirectUri,
List<String> scopes,
long expiresAt) {
dao.insert(
hashAuthCode(code),
clientId,
userName,
codeChallenge,
codeChallengeMethod,
redirectUri.toString(),
JsonUtils.pojoToJson(scopes),
expiresAt);
LOG.debug(
"Stored authorization code in database for client: {} by user: {}", clientId, userName);
}
/**
* Find authorization code by code value.
*/
public OAuthAuthorizationCodeRecord findByCode(String code) {
return dao.findByCode(hashAuthCode(code));
}
/**
* Atomically mark authorization code as used and return the updated record.
* This prevents race conditions by using a database-level UPDATE with WHERE clause
* that checks the code is not already used.
*
* @param code The authorization code to mark as used
* @return The updated record if successful, null if code was already used or doesn't exist
*/
public OAuthAuthorizationCodeRecord markAsUsedAtomic(String code) {
String hashed = hashAuthCode(code);
int rowsAffected = dao.markAsUsedAtomic(hashed);
if (rowsAffected == 1) {
LOG.debug("Atomically marked authorization code as used");
return dao.findByCode(hashed);
}
LOG.warn("Failed to atomically mark authorization code as used (already used or not found)");
return null;
}
/**
* Delete authorization code.
*/
public void delete(String code) {
dao.delete(hashAuthCode(code));
LOG.debug("Deleted authorization code");
}
/**
* Delete all expired authorization codes.
* Note: Expiry times are stored in milliseconds (System.currentTimeMillis())
*/
public void deleteExpired() {
long currentTime = System.currentTimeMillis();
dao.deleteExpired(currentTime);
LOG.info("Deleted expired authorization codes");
}
private String hashAuthCode(String code) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(code.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(hash);
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("SHA-256 not available", e);
}
}
}
@@ -0,0 +1,85 @@
package org.openmetadata.mcp.server.auth.repository;
import at.favre.lib.crypto.bcrypt.BCrypt;
import java.net.URI;
import java.util.List;
import java.util.stream.Collectors;
import lombok.extern.slf4j.Slf4j;
import org.openmetadata.mcp.auth.OAuthClientInformation;
import org.openmetadata.schema.utils.JsonUtils;
import org.openmetadata.service.Entity;
import org.openmetadata.service.jdbi3.CollectionDAO;
import org.openmetadata.service.jdbi3.oauth.OAuthRecords.OAuthClientRecord;
/**
* Repository for managing OAuth client registrations with database persistence and BCrypt secret
* hashing.
*/
@Slf4j
public class OAuthClientRepository {
private static final int BCRYPT_COST = 12;
private final CollectionDAO.OAuthClientDAO dao;
public OAuthClientRepository() {
this.dao = Entity.getCollectionDAO().oauthClientDAO();
}
/**
* Register a new OAuth client.
*/
public void register(OAuthClientInformation clientInfo) {
String clientSecret = clientInfo.getClientSecret();
String hashedSecret =
clientSecret != null
? BCrypt.withDefaults().hashToString(BCRYPT_COST, clientSecret.toCharArray())
: null;
// Convert scope from String to List<String> for database storage
String scope = clientInfo.getScope();
List<String> scopeList =
scope != null && !scope.isEmpty() ? List.of(scope.split(" ")) : List.of();
dao.insert(
clientInfo.getClientId(),
hashedSecret,
clientInfo.getClientName(),
JsonUtils.pojoToJson(
clientInfo.getRedirectUris().stream().map(URI::toString).collect(Collectors.toList())),
JsonUtils.pojoToJson(clientInfo.getGrantTypes()),
clientInfo.getTokenEndpointAuthMethod(),
JsonUtils.pojoToJson(scopeList));
LOG.info("Registered OAuth client in database: {}", clientInfo.getClientId());
}
/**
* Get OAuth client by client ID. The clientSecret field is set to the BCrypt hash
* (not the original secret) for verification by ClientAuthenticator.
*/
public OAuthClientInformation findByClientId(String clientId) {
OAuthClientRecord record = dao.findByClientId(clientId);
if (record == null) {
return null;
}
OAuthClientInformation clientInfo = new OAuthClientInformation();
clientInfo.setClientId(record.clientId());
clientInfo.setClientSecret(record.clientSecretEncrypted());
clientInfo.setClientName(record.clientName());
clientInfo.setRedirectUris(
record.redirectUris().stream().map(URI::create).collect(Collectors.toList()));
clientInfo.setGrantTypes(record.grantTypes());
clientInfo.setTokenEndpointAuthMethod(record.tokenEndpointAuthMethod());
clientInfo.setScope(String.join(" ", record.scopes()));
return clientInfo;
}
/**
* Delete OAuth client.
*/
public void delete(String clientId) {
dao.delete(clientId);
LOG.info("Deleted OAuth client from database: {}", clientId);
}
}
@@ -0,0 +1,236 @@
package org.openmetadata.mcp.server.auth.repository;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Base64;
import java.util.List;
import lombok.extern.slf4j.Slf4j;
import org.openmetadata.mcp.auth.AccessToken;
import org.openmetadata.mcp.auth.RefreshToken;
import org.openmetadata.schema.utils.JsonUtils;
import org.openmetadata.service.Entity;
import org.openmetadata.service.fernet.Fernet;
import org.openmetadata.service.jdbi3.CollectionDAO;
import org.openmetadata.service.jdbi3.oauth.OAuthRecords.OAuthAccessTokenRecord;
import org.openmetadata.service.jdbi3.oauth.OAuthRecords.OAuthRefreshTokenRecord;
/**
* Repository for managing OAuth access and refresh tokens with database persistence,
* hashing, and encryption.
*/
@Slf4j
public class OAuthTokenRepository {
private final CollectionDAO.OAuthAccessTokenDAO accessTokenDAO;
private final CollectionDAO.OAuthRefreshTokenDAO refreshTokenDAO;
private final Fernet fernet;
public OAuthTokenRepository() {
CollectionDAO dao = Entity.getCollectionDAO();
this.accessTokenDAO = dao.oauthAccessTokenDAO();
this.refreshTokenDAO = dao.oauthRefreshTokenDAO();
this.fernet = Fernet.getInstance();
}
/**
* Store an access token.
*/
public void storeAccessToken(
AccessToken token, String clientId, String userName, List<String> scopes) {
String tokenHash = hashToken(token.getToken());
String encryptedToken = fernet.encrypt(token.getToken());
accessTokenDAO.insert(
tokenHash,
encryptedToken,
clientId,
userName,
JsonUtils.pojoToJson(scopes),
token.getExpiresAt());
LOG.debug("Stored access token in database for client: {}", clientId);
}
/**
* Find access token by token value. Returns null if not found or decryption fails.
*/
public AccessToken findAccessToken(String tokenValue) {
String tokenHash = hashToken(tokenValue);
OAuthAccessTokenRecord record = accessTokenDAO.findByTokenHash(tokenHash);
if (record == null) {
return null;
}
try {
String decryptedToken = fernet.decrypt(record.accessTokenEncrypted());
AccessToken token = new AccessToken();
token.setToken(decryptedToken);
token.setExpiresAt(record.expiresAt());
token.setClientId(record.clientId());
token.setScopes(record.scopes());
return token;
} catch (Exception e) {
LOG.error(
"Failed to decrypt access token for client {}. This may indicate Fernet key rotation. "
+ "User must re-authenticate. Error: {}",
record.clientId(),
e.getMessage());
return null;
}
}
/**
* Delete access token.
*/
public void deleteAccessToken(String tokenValue) {
String tokenHash = hashToken(tokenValue);
accessTokenDAO.delete(tokenHash);
LOG.debug("Deleted access token");
}
/**
* Store a refresh token.
*/
public void storeRefreshToken(
RefreshToken token, String clientId, String userName, List<String> scopes) {
String tokenHash = hashToken(token.getToken());
String encryptedToken = fernet.encrypt(token.getToken());
refreshTokenDAO.insert(
tokenHash,
encryptedToken,
clientId,
userName,
JsonUtils.pojoToJson(scopes),
token.getExpiresAt());
LOG.debug("Stored refresh token in database for client: {}", clientId);
}
/**
* Find refresh token by token value. Returns null if not found, revoked, or decryption fails.
*/
public RefreshToken findRefreshToken(String tokenValue) {
String tokenHash = hashToken(tokenValue);
OAuthRefreshTokenRecord record = refreshTokenDAO.findByTokenHash(tokenHash);
if (record == null || record.revoked()) {
return null;
}
try {
String decryptedToken = fernet.decrypt(record.refreshTokenEncrypted());
RefreshToken token = new RefreshToken();
token.setToken(decryptedToken);
token.setExpiresAt(record.expiresAt());
token.setClientId(record.clientId());
token.setUserName(record.userName());
token.setScopes(record.scopes());
return token;
} catch (Exception e) {
LOG.error(
"Failed to decrypt refresh token for user {} (client: {}). This may indicate Fernet key rotation. "
+ "User must re-authenticate. Error: {}",
record.userName(),
record.clientId(),
e.getMessage());
return null;
}
}
/**
* Revoke a refresh token.
*/
public void revokeRefreshToken(String tokenValue) {
String tokenHash = hashToken(tokenValue);
refreshTokenDAO.revoke(tokenHash);
LOG.debug("Revoked refresh token");
}
/**
* Rotate a refresh token using atomic CAS: atomically revoke the old token (UPDATE ... WHERE
* revoked = FALSE), then revoke any remaining tokens for cleanup, then store the new one.
* If the CAS fails (row count = 0), the old token was already revoked by a concurrent request.
*
* @param oldTokenValue The old refresh token value to atomically revoke
* @param newToken The new refresh token to store
* @throws TokenRotationException if the old token was already consumed (concurrent refresh)
*/
public void rotateRefreshToken(
String oldTokenValue,
RefreshToken newToken,
String clientId,
String userName,
List<String> scopes) {
// Atomic CAS: only one concurrent request can successfully revoke this token
String oldTokenHash = hashToken(oldTokenValue);
int rowsAffected = refreshTokenDAO.revokeAtomic(oldTokenHash);
if (rowsAffected == 0) {
throw new TokenRotationException(
"Refresh token already consumed — possible concurrent refresh or replay attack");
}
// Revoke any other lingering tokens for this user+client (belt-and-suspenders cleanup)
refreshTokenDAO.revokeAllForUser(clientId, userName);
// Store the new token
String tokenHash = hashToken(newToken.getToken());
String encryptedToken = fernet.encrypt(newToken.getToken());
refreshTokenDAO.insert(
tokenHash,
encryptedToken,
clientId,
userName,
JsonUtils.pojoToJson(scopes),
newToken.getExpiresAt());
LOG.debug("Rotated refresh token for user: {}, client: {}", userName, clientId);
}
public static class TokenRotationException extends RuntimeException {
public TokenRotationException(String message) {
super(message);
}
}
/**
* Delete a refresh token.
*/
public void deleteRefreshToken(String tokenValue) {
String tokenHash = hashToken(tokenValue);
refreshTokenDAO.delete(tokenHash);
LOG.debug("Deleted refresh token");
}
/**
* Delete all expired tokens.
* Note: Token expiry times are stored in milliseconds (System.currentTimeMillis())
*/
public void deleteExpiredTokens() {
long currentTime = System.currentTimeMillis();
accessTokenDAO.deleteExpired(currentTime);
refreshTokenDAO.deleteExpired(currentTime);
LOG.info("Deleted expired access and refresh tokens");
}
/**
* Hash token using SHA-256 for secure lookups.
*/
private String hashToken(String token) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(token.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(hash);
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("SHA-256 algorithm not available", e);
}
}
}
@@ -0,0 +1,114 @@
package org.openmetadata.mcp.server.auth.util;
import jakarta.servlet.http.HttpServletRequest;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
/**
* Extracts OAuth 2.0 client credentials from a request per RFC 6749 §2.3.1.
*
* <p>Supports both transport methods:
* <ul>
* <li>{@code client_secret_basic} — HTTP Basic auth header (preferred per RFC).
* <li>{@code client_secret_post} — credentials in form body parameters.
* </ul>
*
* <p>Per RFC 6749 §2.3.1, clients MUST NOT use more than one method per request, but several
* widely deployed clients (notably the Databricks MCP Proxy) duplicate the same credentials in
* both the Authorization header and the request body. Strict rejection blocks such clients
* outright. This implementation therefore prefers the Basic header (treated as the authoritative
* channel per RFC) and tolerates duplicate body parameters only when they exactly match the
* header credentials. Mismatched credentials are still rejected as {@code invalid_request} since
* they signal either a misconfiguration or an attempted credential confusion attack.
*/
public final class ClientCredentialsExtractor {
private static final String BASIC_PREFIX = "Basic ";
private ClientCredentialsExtractor() {}
public record Credentials(String clientId, String clientSecret) {}
/**
* Extract client credentials from the request.
*
* @param request the HTTP request
* @param bodyClientId value of the {@code client_id} form parameter (may be {@code null})
* @param bodyClientSecret value of the {@code client_secret} form parameter (may be {@code null})
* @return parsed credentials; {@code clientId} may be {@code null} when no credentials supplied
* @throws InvalidClientCredentialsException when the Basic header is malformed or when body
* credentials are present and do not match the header credentials
*/
public static Credentials extract(
HttpServletRequest request, String bodyClientId, String bodyClientSecret)
throws InvalidClientCredentialsException {
String header = request.getHeader("Authorization");
if (header == null || !header.regionMatches(true, 0, BASIC_PREFIX, 0, BASIC_PREFIX.length())) {
return new Credentials(bodyClientId, bodyClientSecret);
}
Credentials headerCreds = decodeBasic(header.substring(BASIC_PREFIX.length()).trim());
assertBodyMatchesHeader(headerCreds, bodyClientId, bodyClientSecret);
return headerCreds;
}
private static void assertBodyMatchesHeader(
Credentials headerCreds, String bodyClientId, String bodyClientSecret)
throws InvalidClientCredentialsException {
if (bodyClientId != null && !bodyClientId.equals(headerCreds.clientId())) {
throw new InvalidClientCredentialsException(
"client_id in request body does not match Authorization header");
}
if (bodyClientSecret != null && !bodyClientSecret.equals(headerCreds.clientSecret())) {
throw new InvalidClientCredentialsException(
"client_secret in request body does not match Authorization header");
}
}
private static Credentials decodeBasic(String encoded) throws InvalidClientCredentialsException {
if (encoded.isEmpty()) {
throw new InvalidClientCredentialsException("Empty Basic authorization value");
}
byte[] decoded;
try {
decoded = Base64.getDecoder().decode(encoded);
} catch (IllegalArgumentException e) {
throw new InvalidClientCredentialsException("Malformed Base64 in Authorization header");
}
String credential = new String(decoded, StandardCharsets.UTF_8);
int colonIndex = credential.indexOf(':');
if (colonIndex < 0) {
throw new InvalidClientCredentialsException(
"Authorization header missing client_id:client_secret separator");
}
String clientId = urlDecode(credential.substring(0, colonIndex));
String clientSecret = urlDecode(credential.substring(colonIndex + 1));
if (clientId.isEmpty()) {
throw new InvalidClientCredentialsException("Empty client_id in Authorization header");
}
return new Credentials(clientId, clientSecret);
}
// RFC 6749 §2.3.1: client_id and client_secret in Basic auth are
// application/x-www-form-urlencoded encoded before Base64.
private static String urlDecode(String value) throws InvalidClientCredentialsException {
try {
return URLDecoder.decode(value, StandardCharsets.UTF_8);
} catch (IllegalArgumentException e) {
throw new InvalidClientCredentialsException(
"Malformed percent-encoding in Authorization header");
}
}
public static class InvalidClientCredentialsException extends Exception {
public InvalidClientCredentialsException(String message) {
super(message);
}
}
}
@@ -0,0 +1,134 @@
package org.openmetadata.mcp.server.auth.util;
import java.net.URI;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* Utility class for URI operations.
*/
public class UriUtils {
/**
* Constructs a redirect URI with query parameters.
* @param redirectUriBase The base redirect URI.
* @param params The parameters to add to the query string.
* @return The constructed redirect URI.
*/
public static String constructRedirectUri(String redirectUriBase, Map<String, String> params) {
URI uri = URI.create(redirectUriBase);
StringBuilder queryBuilder = new StringBuilder();
String existingQuery = uri.getRawQuery();
if (existingQuery != null && !existingQuery.isEmpty()) {
queryBuilder.append(existingQuery);
}
String newParams =
params.entrySet().stream()
.filter(entry -> entry.getValue() != null)
.map(
entry ->
URLEncoder.encode(entry.getKey(), StandardCharsets.UTF_8)
+ "="
+ URLEncoder.encode(entry.getValue(), StandardCharsets.UTF_8))
.collect(Collectors.joining("&"));
if (!newParams.isEmpty()) {
if (queryBuilder.length() > 0) {
queryBuilder.append("&");
}
queryBuilder.append(newParams);
}
// Reassemble the URI by string concatenation. The query is already percent-encoded above;
// passing it through the multi-argument URI constructor would re-encode the '%' characters
// (e.g. a base64 state "a==" -> "a%3D%3D" -> "a%253D%253D"), corrupting opaque values such as
// the OAuth state and breaking clients that compare it byte-for-byte (e.g. VS Code loopback).
String base = redirectUriBase;
int fragmentIdx = base.indexOf('#');
if (fragmentIdx >= 0) {
base = base.substring(0, fragmentIdx);
}
int queryIdx = base.indexOf('?');
if (queryIdx >= 0) {
base = base.substring(0, queryIdx);
}
String fragment = uri.getRawFragment() != null ? "#" + uri.getRawFragment() : "";
return queryBuilder.length() > 0 ? base + "?" + queryBuilder + fragment : base + fragment;
}
/**
* Modify a URI's path using the provided mapper function.
* @param uri The URI to modify
* @param pathMapper Function to transform the path
* @return The modified URI
*/
public static URI modifyUriPath(URI uri, Function<String, String> pathMapper) {
String path = uri.getPath();
if (path == null) {
path = "";
}
String newPath = pathMapper.apply(path);
try {
return new URI(
uri.getScheme(),
uri.getUserInfo(),
uri.getHost(),
uri.getPort(),
newPath,
uri.getQuery(),
uri.getFragment());
} catch (Exception e) {
throw new IllegalArgumentException("Failed to modify URI path", e);
}
}
/**
* Validate that the issuer URL meets OAuth 2.0 requirements.
* @param url The issuer URL to validate
*/
public static void validateIssuerUrl(URI url) {
// RFC 8414 requires HTTPS, but we allow localhost HTTP for testing
String scheme = url.getScheme();
String host = url.getHost();
if (!"https".equals(scheme)
&& !"localhost".equals(host)
&& !"127.0.0.1".equals(host)
&& !"::1".equals(host)) {
throw new IllegalArgumentException("Issuer URL must be HTTPS");
}
// No fragments or query parameters allowed
if (url.getFragment() != null) {
throw new IllegalArgumentException("Issuer URL must not have a fragment");
}
if (url.getQuery() != null) {
throw new IllegalArgumentException("Issuer URL must not have a query string");
}
}
/**
* Build an endpoint URL by appending a path to the issuer URL.
* @param issuerUrl The issuer URL
* @param path The path to append
* @return The endpoint URL
*/
public static URI buildEndpointUrl(URI issuerUrl, String path) {
String baseUrl = issuerUrl.toString();
if (baseUrl.endsWith("/")) {
baseUrl = baseUrl.substring(0, baseUrl.length() - 1);
}
if (!path.startsWith("/")) {
path = "/" + path;
}
return URI.create(baseUrl + path);
}
}
@@ -0,0 +1,372 @@
/*
* Copyright 2021 Collate
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.openmetadata.mcp.server.auth.validators;
import com.auth0.jwk.Jwk;
import com.auth0.jwk.JwkException;
import com.auth0.jwk.JwkProvider;
import com.auth0.jwk.SigningKeyNotFoundException;
import com.auth0.jwk.UrlJwkProvider;
import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.JWTDecodeException;
import com.auth0.jwt.interfaces.DecodedJWT;
import com.google.common.cache.CacheBuilder;
import com.google.common.cache.CacheLoader;
import com.google.common.cache.LoadingCache;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.JWTParser;
import java.net.MalformedURLException;
import java.net.URL;
import java.security.interfaces.RSAPublicKey;
import java.util.Calendar;
import java.util.List;
import java.util.TimeZone;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import lombok.extern.slf4j.Slf4j;
import org.jetbrains.annotations.NotNull;
/**
* Validates ID tokens from OIDC providers using JWKS-based signature verification.
*
* <p>This validator ensures that ID tokens received from SSO providers (Google, Azure, Okta, etc.)
* are cryptographically valid by:
*
* <ol>
* <li>Verifying the JWT signature using the provider's public keys (JWKS endpoint)
* <li>Validating token expiration
* <li>Validating the issuer matches the expected OIDC provider
* <li>Checking audience (client ID) if present
* </ol>
*
* <p>This prevents token forgery attacks where an attacker could create fake ID tokens to
* impersonate users.
*
* <p><b>Usage:</b>
*
* <pre>{@code
* IdTokenValidator validator = new IdTokenValidator(
* authConfig.getPublicKeyUrls(),
* "https://accounts.google.com",
* "my-client-id"
* );
*
* try {
* JWTClaimsSet claims = validator.validateAndDecode(idTokenString);
* String email = (String) claims.getClaim("email");
* } catch (IdTokenValidationException e) {
* LOG.error("ID token validation failed", e);
* // Handle invalid token
* }
* }</pre>
*
* @see MultiUrlJwkProvider
* @see com.auth0.jwt.JWT
*/
@Slf4j
public class IdTokenValidator {
private final JwkProvider jwkProvider;
private final String expectedIssuer;
private final String expectedAudience; // Client ID
private final String tokenValidationAlgorithm;
/**
* Creates an ID token validator.
*
* @param publicKeyUrls List of JWKS endpoint URLs (e.g., Google's JWKS endpoint)
* @param expectedIssuer The expected issuer claim (e.g., "https://accounts.google.com")
* @param expectedAudience The expected audience (client ID), can be null if not validated
*/
public IdTokenValidator(
List<String> publicKeyUrls, String expectedIssuer, String expectedAudience) {
this(publicKeyUrls, expectedIssuer, expectedAudience, "RS256");
}
/**
* Creates an ID token validator with custom algorithm.
*
* @param publicKeyUrls List of JWKS endpoint URLs
* @param expectedIssuer The expected issuer claim
* @param expectedAudience The expected audience (client ID), can be null
* @param tokenValidationAlgorithm JWT signature algorithm (default: RS256)
*/
public IdTokenValidator(
List<String> publicKeyUrls,
String expectedIssuer,
String expectedAudience,
String tokenValidationAlgorithm) {
if (publicKeyUrls == null || publicKeyUrls.isEmpty()) {
throw new IllegalArgumentException("publicKeyUrls cannot be null or empty");
}
if (expectedIssuer == null || expectedIssuer.trim().isEmpty()) {
throw new IllegalArgumentException("expectedIssuer cannot be null or empty");
}
// Convert URLs and initialize multi-URL JWKS provider with caching
List<URL> urls =
publicKeyUrls.stream()
.map(
urlString -> {
try {
return new URL(urlString);
} catch (MalformedURLException e) {
throw new IllegalArgumentException(
"Invalid JWKS URL: " + urlString + " - " + e.getMessage(), e);
}
})
.collect(Collectors.toList());
this.jwkProvider = new CachingMultiUrlJwkProvider(urls);
this.expectedIssuer = expectedIssuer;
this.expectedAudience = expectedAudience;
this.tokenValidationAlgorithm = tokenValidationAlgorithm;
LOG.info(
"Initialized IdTokenValidator with issuer: {}, audience: {}, algorithm: {}",
expectedIssuer,
expectedAudience != null ? expectedAudience : "not validated",
tokenValidationAlgorithm);
}
/**
* Validates an ID token and returns the verified claims.
*
* <p>This method performs comprehensive validation:
*
* <ol>
* <li><b>JWT Decoding:</b> Parses the token structure
* <li><b>Expiration Check:</b> Ensures token hasn't expired (with 60s clock skew)
* <li><b>Issuer Validation:</b> Verifies token was issued by expected provider
* <li><b>Audience Validation:</b> Checks client ID matches (if configured)
* <li><b>Signature Verification:</b> Cryptographically verifies using JWKS public key
* </ol>
*
* @param idTokenString The ID token JWT string
* @return Validated JWT claims set
* @throws IdTokenValidationException if validation fails for any reason
*/
public JWTClaimsSet validateAndDecode(String idTokenString) throws IdTokenValidationException {
if (idTokenString == null || idTokenString.trim().isEmpty()) {
throw new IdTokenValidationException("ID token cannot be null or empty");
}
// Step 1: Decode JWT using auth0 library (for signature verification)
DecodedJWT jwt;
try {
jwt = JWT.decode(idTokenString);
} catch (JWTDecodeException e) {
LOG.error("Failed to decode ID token: {}", e.getMessage());
throw new IdTokenValidationException("Unable to decode ID token: " + e.getMessage(), e);
}
// Step 2: Check expiration (with clock skew tolerance)
if (jwt.getExpiresAt() != null) {
Calendar now = Calendar.getInstance(TimeZone.getTimeZone("UTC"));
// Allow 60 seconds clock skew (accept tokens that expired up to 60 seconds ago)
// This accounts for slight clock differences between servers
now.add(Calendar.SECOND, -60);
if (jwt.getExpiresAt().before(now.getTime())) {
LOG.warn("ID token has expired. Expiration: {}", jwt.getExpiresAt());
throw new IdTokenValidationException(
"ID token has expired at " + jwt.getExpiresAt() + ". Please restart authentication.");
}
}
// Step 3: Validate issuer
String issuer = jwt.getIssuer();
if (issuer == null || issuer.trim().isEmpty()) {
LOG.error("ID token missing issuer claim");
throw new IdTokenValidationException("ID token is missing required 'iss' (issuer) claim");
}
if (!expectedIssuer.equals(issuer)) {
LOG.warn(
"ID token issuer mismatch. Expected: {}, Got: {}. This may indicate a token from a different OIDC provider.",
expectedIssuer,
issuer);
throw new IdTokenValidationException(
String.format(
"ID token issuer mismatch. Expected '%s' but got '%s'. "
+ "Ensure the token is from the correct OIDC provider.",
expectedIssuer, issuer));
}
// Step 4: Validate audience (client ID) if configured
if (expectedAudience != null) {
List<String> audiences = jwt.getAudience();
if (audiences == null || audiences.isEmpty()) {
LOG.warn("ID token missing audience claim");
throw new IdTokenValidationException("ID token is missing required 'aud' (audience) claim");
}
if (!audiences.contains(expectedAudience)) {
LOG.warn("ID token audience mismatch. Expected: {}, Got: {}", expectedAudience, audiences);
throw new IdTokenValidationException(
String.format(
"ID token audience mismatch. Expected '%s' but got %s",
expectedAudience, audiences));
}
}
// Step 5: Verify JWT signature using JWKS public key
String keyId = jwt.getKeyId();
if (keyId == null || keyId.trim().isEmpty()) {
LOG.error("ID token missing key ID (kid) header");
throw new IdTokenValidationException(
"ID token is missing 'kid' header. Cannot verify signature.");
}
try {
// Fetch public key from JWKS endpoint (with caching)
Jwk jwk = jwkProvider.get(keyId);
RSAPublicKey publicKey = (RSAPublicKey) jwk.getPublicKey();
// Create algorithm for verification
Algorithm algorithm = getAlgorithm(tokenValidationAlgorithm, publicKey);
// Verify signature
algorithm.verify(jwt);
LOG.debug("ID token signature verified successfully for key ID: {}", keyId);
} catch (JwkException e) {
LOG.error("Failed to fetch public key for key ID '{}': {}", keyId, e.getMessage());
throw new IdTokenValidationException(
String.format(
"Failed to fetch public key for key ID '%s'. The signing key may have rotated or the JWKS endpoint is unreachable.",
keyId),
e);
} catch (RuntimeException e) {
LOG.error("ID token signature verification failed: {}", e.getMessage());
throw new IdTokenValidationException(
"ID token signature verification failed. The token may have been tampered with or signed with a different key.",
e);
}
// Step 6: Parse claims using nimbus library (for compatibility with existing code)
JWTClaimsSet claimsSet;
try {
claimsSet = JWTParser.parse(idTokenString).getJWTClaimsSet();
} catch (Exception e) {
LOG.error("Failed to parse ID token claims: {}", e.getMessage());
throw new IdTokenValidationException("Failed to parse ID token claims: " + e.getMessage(), e);
}
LOG.info(
"ID token validated successfully. Issuer: {}, Subject: {}, Expiration: {}",
issuer,
jwt.getSubject(),
jwt.getExpiresAt());
return claimsSet;
}
/**
* Creates the appropriate algorithm for JWT signature verification.
*
* @param algorithm Algorithm name (e.g., "RS256", "RS384", "RS512")
* @param publicKey RSA public key for verification
* @return Algorithm instance for verification
*/
private Algorithm getAlgorithm(String algorithm, RSAPublicKey publicKey) {
return switch (algorithm) {
case "RS256" -> Algorithm.RSA256(publicKey, null);
case "RS384" -> Algorithm.RSA384(publicKey, null);
case "RS512" -> Algorithm.RSA512(publicKey, null);
default -> throw new IllegalArgumentException(
"Unsupported token validation algorithm: "
+ algorithm
+ ". Supported: RS256, RS384, RS512");
};
}
/**
* Exception thrown when ID token validation fails.
*
* <p>This can occur for various reasons:
*
* <ul>
* <li>Token expired
* <li>Invalid signature (token tampered with or forged)
* <li>Issuer mismatch (token from different OIDC provider)
* <li>Audience mismatch (token intended for different client)
* <li>Missing required claims
* <li>JWKS endpoint unreachable
* </ul>
*/
public static class IdTokenValidationException extends Exception {
public IdTokenValidationException(String message) {
super(message);
}
public IdTokenValidationException(String message, Throwable cause) {
super(message, cause);
}
}
/**
* Multi-URL JWKS provider with caching for ID token signature verification.
*
* <p>Tries multiple JWKS endpoints to fetch public keys and caches them for 24 hours. This
* handles key rotation and multiple SSO providers gracefully.
*/
private static class CachingMultiUrlJwkProvider implements JwkProvider {
private final List<JwkProvider> jwkProviders;
private final LoadingCache<String, Jwk> cache;
public CachingMultiUrlJwkProvider(List<URL> publicKeyUrls) {
this.jwkProviders =
publicKeyUrls.stream().map(UrlJwkProvider::new).collect(Collectors.toList());
this.cache =
CacheBuilder.newBuilder()
.maximumSize(10)
.expireAfterWrite(6, TimeUnit.HOURS)
.build(
new CacheLoader<>() {
@Override
public @NotNull Jwk load(@NotNull String keyId) throws Exception {
JwkException lastException =
new SigningKeyNotFoundException(
"Key ID '" + keyId + "' not found in any configured JWKS endpoint",
null);
for (JwkProvider provider : jwkProviders) {
try {
return provider.get(keyId);
} catch (JwkException e) {
lastException.addSuppressed(e);
}
}
throw lastException;
}
});
}
@Override
public Jwk get(String keyId) throws JwkException {
try {
return cache.get(keyId);
} catch (Exception e) {
if (e.getCause() instanceof JwkException) {
throw (JwkException) e.getCause();
}
throw new JwkException("Failed to fetch JWK for key ID: " + keyId, e);
}
}
}
}
@@ -0,0 +1,419 @@
/*
* Copyright 2024-2024 the original author or authors.
*/
package org.openmetadata.mcp.server.transport;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.modelcontextprotocol.common.McpTransportContext;
import io.modelcontextprotocol.json.McpJsonDefaults;
import io.modelcontextprotocol.json.McpJsonMapper;
import io.modelcontextprotocol.server.McpStatelessServerHandler;
import io.modelcontextprotocol.server.McpTransportContextExtractor;
import io.modelcontextprotocol.spec.McpError;
import io.modelcontextprotocol.spec.McpSchema;
import io.modelcontextprotocol.spec.McpStatelessServerTransport;
import io.modelcontextprotocol.util.Assert;
import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.PrintWriter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import reactor.core.publisher.Mono;
/**
* Implementation of an HttpServlet based {@link McpStatelessServerTransport}.
*
* @author Christian Tzolov
* @author Dariusz Jędrzejczyk
*/
@WebServlet(asyncSupported = true)
public class HttpServletStatelessServerTransport extends HttpServlet
implements McpStatelessServerTransport {
private static final Logger logger =
LoggerFactory.getLogger(HttpServletStatelessServerTransport.class);
public static final String UTF_8 = "UTF-8";
public static final String APPLICATION_JSON = "application/json";
public static final String TEXT_EVENT_STREAM = "text/event-stream";
public static final String ACCEPT = "Accept";
public static final String FAILED_TO_SEND_ERROR_RESPONSE = "Failed to send error response: {}";
static final String HEADER_CACHE_CONTROL = "Cache-Control";
static final String HEADER_CONNECTION = "Connection";
static final String HEADER_X_ACCEL_BUFFERING = "X-Accel-Buffering";
static final String CACHE_CONTROL_NO_CACHE = "no-cache";
static final String CONNECTION_KEEP_ALIVE = "keep-alive";
static final String X_ACCEL_BUFFERING_NO = "no";
static final String SSE_DATA_PREFIX = "data: ";
static final String SSE_LINE_TERMINATOR = "\n";
static final String SSE_EVENT_TERMINATOR = "\n\n";
private final ObjectMapper objectMapper;
private final McpJsonMapper jsonMapper;
private final String mcpEndpoint;
private McpStatelessServerHandler mcpHandler;
private McpTransportContextExtractor<HttpServletRequest> contextExtractor;
private volatile boolean isClosing = false;
HttpServletStatelessServerTransport(
ObjectMapper objectMapper,
String mcpEndpoint,
McpTransportContextExtractor<HttpServletRequest> contextExtractor) {
Assert.notNull(objectMapper, "objectMapper must not be null");
Assert.notNull(mcpEndpoint, "mcpEndpoint must not be null");
Assert.notNull(contextExtractor, "contextExtractor must not be null");
this.objectMapper = objectMapper;
this.jsonMapper = McpJsonDefaults.getMapper();
this.mcpEndpoint = mcpEndpoint;
this.contextExtractor = contextExtractor;
}
@Override
public void setMcpHandler(McpStatelessServerHandler mcpHandler) {
this.mcpHandler = mcpHandler;
}
@Override
public Mono<Void> closeGracefully() {
return Mono.fromRunnable(() -> this.isClosing = true);
}
/**
* Handles GET requests - returns 405 METHOD NOT ALLOWED as stateless transport
* doesn't support GET requests.
* @param request The HTTP servlet request
* @param response The HTTP servlet response
* @throws ServletException If a servlet-specific error occurs
* @throws IOException If an I/O error occurs
*/
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
String requestURI = request.getRequestURI();
if (!requestURI.equals(mcpEndpoint)) {
response.sendError(HttpServletResponse.SC_NOT_FOUND);
return;
}
response.sendError(HttpServletResponse.SC_METHOD_NOT_ALLOWED);
}
/**
* Handles POST requests for incoming JSON-RPC messages from clients.
* @param request The HTTP servlet request containing the JSON-RPC message
* @param response The HTTP servlet response
* @throws ServletException If a servlet-specific error occurs
* @throws IOException If an I/O error occurs
*/
@Override
protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
String requestURI = request.getRequestURI();
if (!requestURI.equals(mcpEndpoint)) {
response.sendError(HttpServletResponse.SC_NOT_FOUND);
return;
}
if (isClosing) {
response.sendError(HttpServletResponse.SC_SERVICE_UNAVAILABLE, "Server is shutting down");
return;
}
if (!authenticateRequest(request, response)) {
return; // Authentication failed, response already set
}
McpTransportContext transportContext = this.contextExtractor.extract(request);
String accept = request.getHeader(ACCEPT);
boolean acceptsJson = accept != null && accept.contains(APPLICATION_JSON);
boolean acceptsSse = accept != null && accept.contains(TEXT_EVENT_STREAM);
if (!acceptsJson && !acceptsSse) {
this.responseError(
response,
HttpServletResponse.SC_BAD_REQUEST,
McpError.builder(McpSchema.ErrorCodes.INVALID_REQUEST)
.message("Accept header must include application/json or text/event-stream")
.build());
return;
}
try {
BufferedReader reader = request.getReader();
StringBuilder body = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
body.append(line);
}
McpSchema.JSONRPCMessage message =
McpSchema.deserializeJsonRpcMessage(jsonMapper, body.toString());
if (message instanceof McpSchema.JSONRPCRequest jsonrpcRequest) {
try {
McpSchema.JSONRPCResponse jsonrpcResponse =
this.mcpHandler
.handleRequest(transportContext, jsonrpcRequest)
.contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext))
.block();
String jsonResponseText = jsonMapper.writeValueAsString(jsonrpcResponse);
if (shouldEmitSse(acceptsJson, acceptsSse)) {
writeSseResponse(response, jsonResponseText);
} else {
writeJsonResponse(response, jsonResponseText);
}
} catch (Exception e) {
logger.error("Failed to handle request: {}", e.getMessage());
this.responseError(
response,
HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
McpError.builder(McpSchema.ErrorCodes.INTERNAL_ERROR)
.message("Failed to handle request: " + e.getMessage())
.build());
}
} else if (message instanceof McpSchema.JSONRPCNotification jsonrpcNotification) {
try {
this.mcpHandler
.handleNotification(transportContext, jsonrpcNotification)
.contextWrite(ctx -> ctx.put(McpTransportContext.KEY, transportContext))
.block();
response.setStatus(HttpServletResponse.SC_ACCEPTED);
} catch (Exception e) {
logger.error("Failed to handle notification: {}", e.getMessage());
this.responseError(
response,
HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
McpError.builder(McpSchema.ErrorCodes.INTERNAL_ERROR)
.message("Failed to handle notification: " + e.getMessage())
.build());
}
} else {
this.responseError(
response,
HttpServletResponse.SC_BAD_REQUEST,
McpError.builder(McpSchema.ErrorCodes.INVALID_REQUEST)
.message("The server accepts either requests or notifications")
.build());
}
} catch (IllegalArgumentException | IOException e) {
logger.error("Failed to deserialize message: {}", e.getMessage());
this.responseError(
response,
HttpServletResponse.SC_BAD_REQUEST,
McpError.builder(McpSchema.ErrorCodes.INVALID_REQUEST)
.message("Invalid message format")
.build());
} catch (Exception e) {
logger.error("Unexpected error handling message: {}", e.getMessage());
this.responseError(
response,
HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
McpError.builder(McpSchema.ErrorCodes.INTERNAL_ERROR)
.message("Unexpected error: " + e.getMessage())
.build());
}
}
/**
* Sends an error response to the client.
* @param response The HTTP servlet response
* @param httpCode The HTTP status code
* @param mcpError The MCP error to send
* @throws IOException If an I/O error occurs
*/
private void responseError(HttpServletResponse response, int httpCode, McpError mcpError)
throws IOException {
response.setContentType(APPLICATION_JSON);
response.setCharacterEncoding(UTF_8);
response.setStatus(httpCode);
String jsonError = jsonMapper.writeValueAsString(mcpError);
PrintWriter writer = response.getWriter();
writer.write(jsonError);
writer.flush();
}
/**
* Picks the response media type via Accept-header content negotiation. JSON is preferred whenever
* the client accepts it, because the MCP Streamable HTTP spec has clients send {@code Accept:
* application/json, text/event-stream} and most (e.g. the ai-sdk Python client) cannot parse an
* SSE {@code data: } framed body. SSE is emitted only for clients that accept event-stream but
* NOT JSON (e.g. the Databricks Supervisor Agent client).
*/
static boolean shouldEmitSse(boolean acceptsJson, boolean acceptsSse) {
return acceptsSse && !acceptsJson;
}
static void writeJsonResponse(HttpServletResponse response, String jsonResponseText)
throws IOException {
response.setContentType(APPLICATION_JSON);
response.setCharacterEncoding(UTF_8);
response.setStatus(HttpServletResponse.SC_OK);
PrintWriter writer = response.getWriter();
writer.write(jsonResponseText);
writer.flush();
}
/**
* Writes a JSON-RPC response as a one-shot Server-Sent Events stream. Required for MCP
* Streamable HTTP clients (e.g. Databricks Supervisor Agent's "databricks" v1.0.0 client) that
* negotiate {@code text/event-stream} via the {@code Accept} header and refuse to parse plain
* {@code application/json} responses.
*
* <p>Per the W3C SSE spec, payloads containing line breaks must prefix every line with
* {@code data: }. The default {@code McpJsonMapper} produces compact JSON, but this method
* splits defensively so that any embedded newline (e.g., a literal {@code \n} inside an error
* message) cannot truncate the event for the client.
*/
static void writeSseResponse(HttpServletResponse response, String jsonResponseText)
throws IOException {
response.setContentType(TEXT_EVENT_STREAM);
response.setCharacterEncoding(UTF_8);
response.setHeader(HEADER_CACHE_CONTROL, CACHE_CONTROL_NO_CACHE);
response.setHeader(HEADER_CONNECTION, CONNECTION_KEEP_ALIVE);
response.setHeader(HEADER_X_ACCEL_BUFFERING, X_ACCEL_BUFFERING_NO);
response.setStatus(HttpServletResponse.SC_OK);
PrintWriter writer = response.getWriter();
for (String line : jsonResponseText.split("\\R", -1)) {
writer.write(SSE_DATA_PREFIX);
writer.write(line);
writer.write(SSE_LINE_TERMINATOR);
}
writer.write(SSE_LINE_TERMINATOR);
writer.flush();
}
/**
* Cleans up resources when the servlet is being destroyed.
* <p>
* This method ensures a graceful shutdown before calling the parent's destroy method.
*/
@Override
public void destroy() {
closeGracefully().block();
super.destroy();
}
/**
* Create a builder for the server.
* @return a fresh {@link Builder} instance.
*/
public static Builder builder() {
return new Builder();
}
/**
* Builder for creating instances of {@link HttpServletStatelessServerTransport}.
* <p>
* This builder provides a fluent API for configuring and creating instances of
* HttpServletStatelessServerTransport with custom settings.
*/
public static class Builder {
private ObjectMapper objectMapper;
private String mcpEndpoint = "/mcp";
private McpTransportContextExtractor<HttpServletRequest> contextExtractor =
serverRequest -> McpTransportContext.EMPTY;
private Builder() {
// used by a static method
}
/**
* Sets the ObjectMapper to use for JSON serialization/deserialization of MCP
* messages.
* @param objectMapper The ObjectMapper instance. Must not be null.
* @return this builder instance
* @throws IllegalArgumentException if objectMapper is null
*/
public Builder objectMapper(ObjectMapper objectMapper) {
Assert.notNull(objectMapper, "ObjectMapper must not be null");
this.objectMapper = objectMapper;
return this;
}
/**
* Sets the endpoint URI where clients should send their JSON-RPC messages.
* @param messageEndpoint The message endpoint URI. Must not be null.
* @return this builder instance
* @throws IllegalArgumentException if messageEndpoint is null
*/
public Builder messageEndpoint(String messageEndpoint) {
Assert.notNull(messageEndpoint, "Message endpoint must not be null");
this.mcpEndpoint = messageEndpoint;
return this;
}
/**
* Sets the context extractor that allows providing the MCP feature
* implementations to inspect HTTP transport level metadata that was present at
* HTTP request processing time. This allows to extract custom headers and other
* useful data for use during execution later on in the process.
* @param contextExtractor The contextExtractor to fill in a
* {@link McpTransportContext}.
* @return this builder instance
* @throws IllegalArgumentException if contextExtractor is null
*/
public Builder contextExtractor(
McpTransportContextExtractor<HttpServletRequest> contextExtractor) {
Assert.notNull(contextExtractor, "Context extractor must not be null");
this.contextExtractor = contextExtractor;
return this;
}
/**
* Builds a new instance of {@link HttpServletStatelessServerTransport} with the
* configured settings.
* @return A new HttpServletStatelessServerTransport instance
* @throws IllegalStateException if required parameters are not set
*/
public HttpServletStatelessServerTransport build() {
Assert.notNull(objectMapper, "ObjectMapper must be set");
Assert.notNull(mcpEndpoint, "Message endpoint must be set");
return new HttpServletStatelessServerTransport(objectMapper, mcpEndpoint, contextExtractor);
}
}
/**
* Hook method for authentication. Subclasses can override this to provide
* authentication.
* @param request The HTTP servlet request
* @param response The HTTP servlet response
* @return true if authentication succeeded, false if it failed
* @throws ServletException If a servlet-specific error occurs
* @throws IOException If an I/O error occurs
*/
protected boolean authenticateRequest(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
// Default implementation does no authentication
return true;
}
}
@@ -0,0 +1,993 @@
package org.openmetadata.mcp.server.transport;
import static org.openmetadata.service.socket.SocketAddressFilter.validatePrefixedTokenRequest;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.modelcontextprotocol.server.McpTransportContextExtractor;
import io.modelcontextprotocol.spec.McpError;
import io.modelcontextprotocol.spec.McpSchema;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
import java.net.URI;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletionException;
import lombok.extern.slf4j.Slf4j;
import org.openmetadata.mcp.auth.AuthorizationCode;
import org.openmetadata.mcp.auth.OAuthAuthorizationServerProvider;
import org.openmetadata.mcp.auth.OAuthClientInformation;
import org.openmetadata.mcp.auth.OAuthClientMetadata;
import org.openmetadata.mcp.auth.OAuthMetadata;
import org.openmetadata.mcp.auth.OAuthToken;
import org.openmetadata.mcp.auth.ProtectedResourceMetadata;
import org.openmetadata.mcp.auth.RefreshToken;
import org.openmetadata.mcp.auth.exception.TokenException;
import org.openmetadata.mcp.server.auth.handlers.AuthorizationHandler;
import org.openmetadata.mcp.server.auth.handlers.RegistrationHandler;
import org.openmetadata.mcp.server.auth.handlers.RevocationHandler;
import org.openmetadata.mcp.server.auth.middleware.ClientAuthenticator;
import org.openmetadata.mcp.server.auth.repository.OAuthClientRepository;
import org.openmetadata.mcp.server.auth.repository.OAuthTokenRepository;
import org.openmetadata.mcp.server.auth.util.ClientCredentialsExtractor;
import org.openmetadata.mcp.server.auth.util.ClientCredentialsExtractor.InvalidClientCredentialsException;
import org.openmetadata.schema.services.connections.metadata.AuthProvider;
import org.openmetadata.service.security.JwtFilter;
import org.openmetadata.service.security.auth.SecurityConfigurationManager;
/**
* Extended transport provider that handles both MCP messages and OAuth routes. This class
* integrates OAuth authentication routes directly into the transport layer It also adds
* authentication middleware to validate requests for SSE and message endpoints.
* Implements ConfigurationChangeListener to dynamically update CORS origins without restart.
*/
@Slf4j
public class OAuthHttpStatelessServerTransportProvider extends HttpServletStatelessServerTransport
implements SecurityConfigurationManager.ConfigurationChangeListener {
private volatile OAuthMetadata oauthMetadata;
private volatile ProtectedResourceMetadata protectedResourceMetadata;
private final AuthorizationHandler authorizationHandler;
private final RegistrationHandler registrationHandler;
private final RevocationHandler revocationHandler;
private final ClientAuthenticator clientAuthenticator;
private final ObjectMapper objectMapper;
private volatile URI resourceMetadataUrl;
private final JwtFilter jwtFilter;
private volatile List<String> allowedOrigins;
private final OAuthAuthorizationServerProvider authProvider;
private final String mcpEndpoint;
private volatile org.openmetadata.mcp.server.auth.handlers.BasicAuthLoginServlet
basicAuthLoginServlet;
// In-memory rate limiters for registration and token endpoints.
// These are per-JVM-instance; in clustered deployments the effective limit is N × limit per hour.
// For multi-node production deployments, consider database-backed rate limiting.
private static final int REGISTRATION_MAX_PER_HOUR = 10;
private static final int TOKEN_MAX_PER_MINUTE = 30;
private static final int MAX_RATE_LIMIT_ENTRIES = 10_000;
private static final java.util.regex.Pattern IP_PATTERN =
java.util.regex.Pattern.compile("[0-9a-fA-F.:]+");
private final java.util.concurrent.ConcurrentHashMap<
String, java.util.concurrent.atomic.AtomicInteger>
registrationAttempts = new java.util.concurrent.ConcurrentHashMap<>();
private volatile long registrationWindowStart = System.currentTimeMillis();
private final java.util.concurrent.ConcurrentHashMap<
String, java.util.concurrent.atomic.AtomicInteger>
tokenAttempts = new java.util.concurrent.ConcurrentHashMap<>();
private volatile long tokenWindowStart = System.currentTimeMillis();
/**
* Creates a new OAuthHttpServletSseServerTransportProvider.
* @param objectMapper The JSON object mapper
* @param baseUrl The base URL of the server
* @param mcpEndpoint The MCP endpoint path
* @param contextExtractor The context extractor
* @param authProvider The OAuth authorization server provider
* @param allowedOrigins List of allowed origins for CORS
*/
public OAuthHttpStatelessServerTransportProvider(
ObjectMapper objectMapper,
String baseUrl,
String mcpEndpoint,
McpTransportContextExtractor<HttpServletRequest> contextExtractor,
OAuthAuthorizationServerProvider authProvider,
List<String> allowedOrigins) {
super(objectMapper, mcpEndpoint, contextExtractor);
this.objectMapper = objectMapper;
this.authProvider = authProvider;
this.mcpEndpoint = mcpEndpoint;
LOG.info("Initializing OAuth transport with base URL: {}", baseUrl);
this.clientAuthenticator = new ClientAuthenticator(authProvider);
// Create Authorization Server metadata (RFC 8414)
// Endpoints are relative to /mcp prefix since servlet is mounted there
List<String> supportedScopes = getSupportedScopesForProvider();
OAuthMetadata metadata = new OAuthMetadata();
metadata.setIssuer(URI.create(baseUrl + mcpEndpoint));
metadata.setAuthorizationEndpoint(URI.create(baseUrl + mcpEndpoint + "/authorize"));
metadata.setTokenEndpoint(URI.create(baseUrl + mcpEndpoint + "/token"));
metadata.setRegistrationEndpoint(URI.create(baseUrl + mcpEndpoint + "/register"));
metadata.setScopesSupported(supportedScopes);
metadata.setResponseTypesSupported(List.of("code"));
metadata.setGrantTypesSupported(List.of("authorization_code", "refresh_token"));
metadata.setTokenEndpointAuthMethodsSupported(
List.of("client_secret_basic", "client_secret_post", "none"));
metadata.setCodeChallengeMethodsSupported(List.of("S256"));
metadata.setRevocationEndpoint(URI.create(baseUrl + mcpEndpoint + "/revoke"));
metadata.setRevocationEndpointAuthMethodsSupported(
List.of("client_secret_basic", "client_secret_post"));
// Create Protected Resource metadata (RFC 9728) - MCP requirement
this.resourceMetadataUrl =
URI.create(baseUrl + mcpEndpoint + "/.well-known/oauth-protected-resource");
ProtectedResourceMetadata protectedResourceMetadata = new ProtectedResourceMetadata();
protectedResourceMetadata.setResource(URI.create(baseUrl + mcpEndpoint));
protectedResourceMetadata.setAuthorizationServers(List.of(URI.create(baseUrl + mcpEndpoint)));
protectedResourceMetadata.setScopesSupported(supportedScopes);
protectedResourceMetadata.setResourceDocumentation(URI.create(baseUrl + "/docs"));
this.oauthMetadata = metadata;
this.protectedResourceMetadata = protectedResourceMetadata;
this.authorizationHandler = new AuthorizationHandler(authProvider);
this.registrationHandler = new RegistrationHandler(new OAuthClientRepository());
this.revocationHandler = new RevocationHandler(new OAuthTokenRepository());
this.jwtFilter =
new JwtFilter(
SecurityConfigurationManager.getCurrentAuthConfig(),
SecurityConfigurationManager.getCurrentAuthzConfig());
this.allowedOrigins = allowedOrigins;
// Register as configuration change listener for dynamic CORS updates
SecurityConfigurationManager.getInstance().addConfigurationChangeListener(this);
LOG.info(
"OAuth transport initialized with base URL: {}, CORS origins: {}", baseUrl, allowedOrigins);
}
public void setBasicAuthLoginServlet(
org.openmetadata.mcp.server.auth.handlers.BasicAuthLoginServlet servlet) {
this.basicAuthLoginServlet = servlet;
}
/**
* ConfigurationChangeListener implementation.
* Called when configuration is reloaded, updates CORS origins dynamically without restart.
*/
@Override
public void onConfigurationChanged(
org.openmetadata.schema.api.security.AuthenticationConfiguration authConfig,
org.openmetadata.schema.api.security.AuthorizerConfiguration authzConfig,
org.openmetadata.schema.api.configuration.MCPConfiguration mcpConfig) {
if (mcpConfig != null) {
if (mcpConfig.getAllowedOrigins() != null) {
updateAllowedOrigins(mcpConfig.getAllowedOrigins());
LOG.info(
"Updated CORS origins from configuration reload: {}", mcpConfig.getAllowedOrigins());
}
if (mcpConfig.getBaseUrl() != null && !mcpConfig.getBaseUrl().isEmpty()) {
rebuildMetadata(mcpConfig.getBaseUrl());
}
}
}
private void rebuildMetadata(String baseUrl) {
LOG.info("Rebuilding OAuth metadata with new base URL: {}", baseUrl);
List<String> supportedScopes = getSupportedScopesForProvider();
OAuthMetadata newMetadata = new OAuthMetadata();
newMetadata.setIssuer(URI.create(baseUrl + mcpEndpoint));
newMetadata.setAuthorizationEndpoint(URI.create(baseUrl + mcpEndpoint + "/authorize"));
newMetadata.setTokenEndpoint(URI.create(baseUrl + mcpEndpoint + "/token"));
newMetadata.setRegistrationEndpoint(URI.create(baseUrl + mcpEndpoint + "/register"));
newMetadata.setScopesSupported(supportedScopes);
newMetadata.setResponseTypesSupported(List.of("code"));
newMetadata.setGrantTypesSupported(List.of("authorization_code", "refresh_token"));
newMetadata.setTokenEndpointAuthMethodsSupported(
List.of("client_secret_basic", "client_secret_post", "none"));
newMetadata.setCodeChallengeMethodsSupported(List.of("S256"));
newMetadata.setRevocationEndpoint(URI.create(baseUrl + mcpEndpoint + "/revoke"));
newMetadata.setRevocationEndpointAuthMethodsSupported(
List.of("client_secret_basic", "client_secret_post"));
this.oauthMetadata = newMetadata;
ProtectedResourceMetadata newResourceMetadata = new ProtectedResourceMetadata();
newResourceMetadata.setResource(URI.create(baseUrl + mcpEndpoint));
newResourceMetadata.setAuthorizationServers(List.of(URI.create(baseUrl + mcpEndpoint)));
newResourceMetadata.setScopesSupported(supportedScopes);
newResourceMetadata.setResourceDocumentation(URI.create(baseUrl + "/docs"));
this.protectedResourceMetadata = newResourceMetadata;
this.resourceMetadataUrl =
URI.create(baseUrl + mcpEndpoint + "/.well-known/oauth-protected-resource");
LOG.info("OAuth metadata rebuilt with base URL: {}", baseUrl);
}
/**
* Updates the allowed origins configuration for CORS.
* This method allows dynamic configuration updates without recreating the transport provider.
* @param newAllowedOrigins The new list of allowed origins
*/
public synchronized void updateAllowedOrigins(List<String> newAllowedOrigins) {
if (newAllowedOrigins != null) {
this.allowedOrigins = newAllowedOrigins;
LOG.info("Updated CORS allowed origins: {}", newAllowedOrigins);
}
}
/**
* Gets the current allowed origins for CORS.
* @return The list of allowed origins
*/
public List<String> getAllowedOrigins() {
return allowedOrigins;
}
/**
* Gets the object mapper.
* @return The object mapper
*/
protected ObjectMapper getObjectMapper() {
return objectMapper;
}
/**
* Sanitizes request parameters by removing sensitive values for logging.
* Removes tokens, secrets, codes, and verifiers to prevent credential leakage in logs.
* @param params The original parameters map
* @return A sanitized copy of the parameters safe for logging
*/
private Map<String, String> sanitizeParamsForLogging(Map<String, String> params) {
if (params == null) {
return new HashMap<>();
}
Map<String, String> sanitized = new HashMap<>(params);
// Remove all sensitive OAuth parameters
sanitized.remove("client_secret");
sanitized.remove("code");
sanitized.remove("code_verifier");
sanitized.remove("refresh_token");
sanitized.remove("access_token");
sanitized.remove("token");
sanitized.remove("password");
// Replace code_challenge with indicator (still safe to log)
if (sanitized.containsKey("code_challenge")) {
sanitized.put("code_challenge", "<present>");
}
return sanitized;
}
/**
* Sets CORS headers with origin validation.
* Only allows specific origins from the allowedOrigins list.
* Rejects requests from origins not in the allowed list.
* @param request The HTTP request
* @param response The HTTP response
*/
private void setCorsHeaders(HttpServletRequest request, HttpServletResponse response) {
String origin = request.getHeader("Origin");
if (origin != null && allowedOrigins.contains(origin)) {
response.setHeader("Access-Control-Allow-Origin", origin);
response.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
response.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, Accept");
response.setHeader("Access-Control-Max-Age", "3600");
response.setHeader("Vary", "Origin");
LOG.debug("CORS headers set for allowed origin: {}", origin);
} else {
// Log rejected origin attempts
if (origin != null) {
LOG.warn("CORS request rejected from unauthorized origin: {}", origin);
} else {
LOG.debug("CORS request without Origin header");
}
}
}
@Override
public void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
LOG.debug("Handling OAuth GET request: {}", request.getRequestURI());
String path = request.getRequestURI();
// Handle OAuth GET routes (exact path matching to prevent path confusion attacks)
if (path.equals("/mcp/.well-known/oauth-authorization-server")) {
handleMetadataRequest(request, response);
} else if (path.equals("/mcp/.well-known/oauth-protected-resource")) {
handleProtectedResourceMetadataRequest(request, response);
} else if (path.equals("/mcp/authorize")) {
handleAuthorizeRequest(request, response);
} else if (path.equals("/mcp/login") && basicAuthLoginServlet != null) {
basicAuthLoginServlet.doGet(request, response);
} else {
// Unknown GET path: base class returns 404 for sub-paths, 405 for /mcp exactly
super.doGet(request, response);
}
}
/**
* Authenticates a request using the Bearer token in the Authorization header.
* @param request The HTTP request
* @param response The HTTP response
* @return true if authentication succeeded, false otherwise
*/
@Override
protected boolean authenticateRequest(HttpServletRequest request, HttpServletResponse response)
throws IOException {
String tokenWithType = request.getHeader("Authorization");
try {
validatePrefixedTokenRequest(jwtFilter, tokenWithType);
return true;
} catch (Exception e) {
LOG.debug("Bearer token authentication failed", e);
sendAuthErrorWithChallenge(
response, "Invalid or expired token", HttpServletResponse.SC_UNAUTHORIZED);
return false;
}
}
/**
* Returns supported OAuth scopes based on the configured auth provider.
* Different providers support different scopes:
* - Google: openid, profile, email (no offline_access in OAuth context)
* - Okta/Auth0/Cognito: openid, profile, email, offline_access
* - Azure: openid, profile, email, offline_access (Azure-specific scopes come from config)
* - Basic/LDAP: openid, profile, email
*/
private static List<String> getSupportedScopesForProvider() {
try {
AuthProvider provider = SecurityConfigurationManager.getCurrentAuthConfig().getProvider();
if (provider == null) {
return List.of("openid", "profile", "email");
}
return switch (provider) {
case GOOGLE -> List.of("openid", "profile", "email");
case OKTA, AUTH_0, AWS_COGNITO, CUSTOM_OIDC -> List.of(
"openid", "profile", "email", "offline_access");
case AZURE -> List.of("openid", "profile", "email", "offline_access");
default -> List.of("openid", "profile", "email");
};
} catch (Exception e) {
LOG.warn("Could not determine auth provider for scopes, using default", e);
return List.of("openid", "profile", "email");
}
}
/**
* Sends an authentication error response with WWW-Authenticate header (MCP requirement).
* @param response The HTTP response
* @param message The error message
* @param statusCode The HTTP status code
*/
private void sendAuthErrorWithChallenge(
HttpServletResponse response, String message, int statusCode) throws IOException {
// Build WWW-Authenticate header per RFC 6750 and MCP spec
StringBuilder challenge = new StringBuilder("Bearer");
challenge.append(" resource_metadata=\"").append(resourceMetadataUrl).append("\"");
// Use provider-aware scopes
List<String> scopes = getSupportedScopesForProvider();
challenge.append(", scope=\"").append(String.join(" ", scopes)).append("\"");
if (statusCode == HttpServletResponse.SC_FORBIDDEN) {
challenge.append(", error=\"insufficient_scope\"");
}
response.setHeader("WWW-Authenticate", challenge.toString());
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
response.setStatus(statusCode);
McpError error = McpError.builder(McpSchema.ErrorCodes.INTERNAL_ERROR).message(message).build();
String jsonError = getObjectMapper().writeValueAsString(error);
PrintWriter writer = response.getWriter();
writer.write(jsonError);
writer.flush();
}
@Override
protected void doOptions(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
LOG.debug("Handling CORS preflight request: {}", request.getRequestURI());
// Set CORS headers for preflight request with origin validation
setCorsHeaders(request, response);
response.setStatus(HttpServletResponse.SC_OK);
}
@Override
public void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
LOG.debug("Handling OAuth POST request: {}", request.getRequestURI());
String path = request.getRequestURI();
// Handle OAuth POST routes (exact path matching to prevent path confusion attacks)
if (path.equals("/mcp/token")) {
handleTokenRequest(request, response);
} else if (path.equals("/mcp/authorize")) {
handleAuthorizeRequest(request, response);
} else if (path.equals("/mcp/register")) {
handleRegistrationRequest(request, response);
} else if (path.equals("/mcp/revoke")) {
handleRevocationRequest(request, response);
} else if (path.equals("/mcp/login") && basicAuthLoginServlet != null) {
basicAuthLoginServlet.doPost(request, response);
} else {
// Handle other POST requests using the parent class
super.doPost(request, response);
}
}
public void handleMetadata(HttpServletRequest request, HttpServletResponse response)
throws IOException {
handleMetadataRequest(request, response);
}
public void handleProtectedResourceMetadata(
HttpServletRequest request, HttpServletResponse response) throws IOException {
handleProtectedResourceMetadataRequest(request, response);
}
private void handleMetadataRequest(HttpServletRequest request, HttpServletResponse response)
throws IOException {
response.setContentType("application/json");
setCorsHeaders(request, response);
response.setStatus(200);
getObjectMapper().writeValue(response.getOutputStream(), oauthMetadata);
}
private void handleProtectedResourceMetadataRequest(
HttpServletRequest request, HttpServletResponse response) throws IOException {
response.setContentType("application/json");
setCorsHeaders(request, response);
response.setStatus(200);
getObjectMapper().writeValue(response.getOutputStream(), protectedResourceMetadata);
}
private void handleAuthorizeRequest(HttpServletRequest request, HttpServletResponse response)
throws IOException {
// Extract parameters
Map<String, String> params = new HashMap<>();
request
.getParameterMap()
.forEach(
(key, values) -> {
if (values.length > 0) {
params.put(key, values[0]);
}
});
try {
LOG.info("Authorization request params (sanitized): " + sanitizeParamsForLogging(params));
if (authProvider instanceof org.openmetadata.mcp.server.auth.provider.UserSSOOAuthProvider) {
((org.openmetadata.mcp.server.auth.provider.UserSSOOAuthProvider) authProvider)
.setRequestContext(request, response);
}
AuthorizationHandler.AuthorizationResponse authResponse =
authorizationHandler.handle(params).join();
String redirectUrl = authResponse.getRedirectUrl();
if (redirectUrl != null && response.isCommitted()) {
// The SSO provider (active-session shortcut) already committed the response with a
// redirect to /mcp/callback. Any error redirect URL produced by AuthorizationHandler
// cannot be sent — the browser is already navigating away. Log and bail out.
LOG.warn(
"Cannot send MCP OAuth redirect — response already committed by SSO provider. "
+ "Redirect target (sanitized): {}",
sanitizeRedirectUrlForLogging(redirectUrl));
} else if (redirectUrl != null) {
response.setHeader("Location", redirectUrl);
response.setHeader("Cache-Control", "no-store");
setCorsHeaders(request, response);
response.sendRedirect(redirectUrl);
} else if (response.isCommitted()) {
// SSO_REDIRECT_INITIATED: the provider (e.g. SAML AuthnRequest, or OIDC handleLogin)
// already wrote the login redirect straight to the browser and committed the response.
// There is no redirect URL to return and nothing more to write — attempting to write here
// would hit a closed stream (EofException).
LOG.debug("SSO login redirect already initiated by provider; response committed");
} else {
// No redirect URL — error case where redirect_uri is invalid or client is unknown.
// Per RFC 6749, display the error to the user instead of redirecting.
setCorsHeaders(request, response);
response.setContentType("application/json");
response.setStatus(400);
Map<String, String> error = new HashMap<>();
error.put("error", "invalid_request");
error.put("error_description", "Authorization request failed");
getObjectMapper().writeValue(response.getOutputStream(), error);
}
} catch (Exception ex) {
LOG.error("Authorization request failed", ex);
setCorsHeaders(request, response);
response.setContentType("application/json");
Throwable cause = ex.getCause() != null ? ex.getCause() : ex;
boolean isClientError =
cause instanceof org.openmetadata.mcp.auth.exception.AuthorizeException;
response.setStatus(
isClientError
? HttpServletResponse.SC_BAD_REQUEST
: HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
Map<String, String> error = new HashMap<>();
error.put("error", isClientError ? "invalid_request" : "server_error");
error.put(
"error_description",
isClientError
? (cause.getMessage() != null ? cause.getMessage() : "Invalid request")
: "Internal server error during authorization");
getObjectMapper().writeValue(response.getOutputStream(), error);
} finally {
if (authProvider instanceof org.openmetadata.mcp.server.auth.provider.UserSSOOAuthProvider) {
((org.openmetadata.mcp.server.auth.provider.UserSSOOAuthProvider) authProvider)
.clearRequestContext();
}
}
}
private void handleTokenRequest(HttpServletRequest request, HttpServletResponse response)
throws IOException {
// Extract parameters
Map<String, String> params = new HashMap<>();
request
.getParameterMap()
.forEach(
(key, values) -> {
if (values.length > 0) {
params.put(key, values[0]);
}
});
LOG.debug("Token request params (sanitized): {}", sanitizeParamsForLogging(params));
String clientIp = getClientIp(request);
if (isTokenRateLimited(clientIp)) {
LOG.warn("Token endpoint rate limit exceeded for IP: {}", clientIp);
setCorsHeaders(request, response);
response.setContentType("application/json");
response.setStatus(429);
Map<String, String> error = new HashMap<>();
error.put("error", "too_many_requests");
error.put("error_description", "Token request rate limit exceeded. Try again later.");
getObjectMapper().writeValue(response.getOutputStream(), error);
return;
}
if (authProvider instanceof org.openmetadata.mcp.server.auth.provider.UserSSOOAuthProvider) {
((org.openmetadata.mcp.server.auth.provider.UserSSOOAuthProvider) authProvider)
.setRequestContext(request, response);
}
try {
String grantType = params.get("grant_type");
ClientCredentialsExtractor.Credentials credentials;
try {
credentials =
ClientCredentialsExtractor.extract(
request, params.get("client_id"), params.get("client_secret"));
} catch (InvalidClientCredentialsException e) {
throw new TokenException("invalid_request", e.getMessage());
}
String clientId = credentials.clientId();
String clientSecret = credentials.clientSecret();
OAuthToken token = null;
// Authenticate the client before processing any grant type
OAuthClientInformation client;
try {
client = clientAuthenticator.authenticate(clientId, clientSecret).join();
} catch (Exception e) {
LOG.warn("Client authentication failed for client_id: {}", clientId, e);
throw new TokenException("invalid_client", "Client authentication failed");
}
if (grantType == null || grantType.isEmpty()) {
throw new TokenException("invalid_request", "grant_type parameter is required");
}
if ("authorization_code".equals(grantType)) {
// Handle authorization code exchange
String code = params.get("code");
String redirectUri = params.get("redirect_uri");
String codeVerifier = params.get("code_verifier");
if (code == null || code.isEmpty()) {
throw new TokenException("invalid_request", "code parameter is required");
}
// Load the authorization code to validate client_id and redirect_uri
AuthorizationCode storedCode = authProvider.loadAuthorizationCode(client, code).join();
if (storedCode == null) {
throw new TokenException("invalid_grant", "Invalid authorization code");
}
// Validate that the code was issued to this client
if (!storedCode.getClientId().equals(clientId)) {
throw new TokenException(
"invalid_grant", "Authorization code was not issued to this client");
}
// Validate redirect_uri (RFC 6749 Section 4.1.3): redirect_uri is always stored
// during authorization — reject if missing as a safety net
if (storedCode.getRedirectUri() == null) {
LOG.error(
"SECURITY ALERT: Authorization code has no stored redirect_uri. "
+ "This should never happen.");
throw new TokenException(
"server_error", "Authorization code has no associated redirect URI");
}
if (redirectUri == null) {
throw new TokenException(
"invalid_request",
"redirect_uri is required when it was included in the authorization request");
}
if (!storedCode.getRedirectUri().toString().equals(redirectUri)) {
throw new TokenException(
"invalid_grant", "redirect_uri does not match authorization request");
}
AuthorizationCode authCode = new AuthorizationCode();
authCode.setCode(code);
authCode.setClientId(clientId);
if (redirectUri != null) {
authCode.setRedirectUri(URI.create(redirectUri));
}
authCode.setCodeVerifier(codeVerifier);
token = authProvider.exchangeAuthorizationCode(client, authCode).join();
} else if ("refresh_token".equals(grantType)) {
String refreshTokenValue = params.get("refresh_token");
if (refreshTokenValue == null || refreshTokenValue.isEmpty()) {
throw new TokenException("invalid_request", "refresh_token parameter is required");
}
RefreshToken refreshToken = new RefreshToken();
refreshToken.setToken(refreshTokenValue);
refreshToken.setClientId(clientId);
String scopeParam = params.get("scope");
List<String> scopes = scopeParam != null ? List.of(scopeParam.split(" ")) : null;
token = authProvider.exchangeRefreshToken(client, refreshToken, scopes).join();
} else {
throw new TokenException(
"unsupported_grant_type",
"Only authorization_code and refresh_token grant types are supported");
}
response.setContentType("application/json");
response.setHeader("Cache-Control", "no-store");
response.setHeader("Pragma", "no-cache");
setCorsHeaders(request, response);
response.setStatus(200);
getObjectMapper().writeValue(response.getOutputStream(), token);
} catch (CompletionException ex) {
Throwable cause = ex.getCause() != null ? ex.getCause() : ex;
LOG.error("Token request failed", cause);
setCorsHeaders(request, response);
response.setContentType("application/json");
Map<String, String> error = new HashMap<>();
if (cause instanceof TokenException tokenEx) {
error.put("error", tokenEx.getError());
error.put("error_description", tokenEx.getErrorDescription());
// RFC 6749 Section 5.2: invalid_client → 401, server_error → 500, others → 400
int status =
"invalid_client".equals(tokenEx.getError())
? HttpServletResponse.SC_UNAUTHORIZED
: "server_error".equals(tokenEx.getError())
? HttpServletResponse.SC_INTERNAL_SERVER_ERROR
: HttpServletResponse.SC_BAD_REQUEST;
response.setStatus(status);
} else {
error.put("error", "server_error");
error.put("error_description", "Internal server error during token exchange");
response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
}
getObjectMapper().writeValue(response.getOutputStream(), error);
} catch (TokenException ex) {
LOG.error("Token request failed", ex);
setCorsHeaders(request, response);
response.setContentType("application/json");
int status =
"invalid_client".equals(ex.getError())
? HttpServletResponse.SC_UNAUTHORIZED
: "server_error".equals(ex.getError())
? HttpServletResponse.SC_INTERNAL_SERVER_ERROR
: HttpServletResponse.SC_BAD_REQUEST;
response.setStatus(status);
Map<String, String> error = new HashMap<>();
error.put("error", ex.getError());
error.put("error_description", ex.getErrorDescription());
getObjectMapper().writeValue(response.getOutputStream(), error);
} catch (Exception ex) {
LOG.error("Token request failed", ex);
setCorsHeaders(request, response);
response.setContentType("application/json");
response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
Map<String, String> error = new HashMap<>();
error.put("error", "server_error");
error.put("error_description", "Internal server error during token exchange");
getObjectMapper().writeValue(response.getOutputStream(), error);
} finally {
if (authProvider instanceof org.openmetadata.mcp.server.auth.provider.UserSSOOAuthProvider) {
((org.openmetadata.mcp.server.auth.provider.UserSSOOAuthProvider) authProvider)
.clearRequestContext();
}
}
}
/**
* Extract client IP, trusting X-Forwarded-For only when the direct connection is from a
* loopback address (i.e. a local reverse proxy). This prevents external clients from spoofing
* XFF to bypass rate limiting. In-memory counters are per-JVM — consider a shared rate limiter
* (Redis or DB-backed) for multi-node deployments.
*/
private String getClientIp(HttpServletRequest request) {
String remoteAddr = request.getRemoteAddr();
if (isLoopback(remoteAddr)) {
String xff = request.getHeader("X-Forwarded-For");
if (xff != null && !xff.isEmpty()) {
String clientIp = xff.split(",")[0].trim();
if (!clientIp.isEmpty()
&& clientIp.length() <= 45
&& IP_PATTERN.matcher(clientIp).matches()) {
return clientIp;
}
}
}
return remoteAddr;
}
private static boolean isLoopback(String ip) {
return "127.0.0.1".equals(ip) || "0:0:0:0:0:0:0:1".equals(ip) || "::1".equals(ip);
}
private boolean isRegistrationRateLimited(String clientIp) {
long now = System.currentTimeMillis();
synchronized (registrationAttempts) {
if (now - registrationWindowStart > 3_600_000) {
registrationAttempts.clear();
registrationWindowStart = now;
}
if (registrationAttempts.size() >= MAX_RATE_LIMIT_ENTRIES) {
return true;
}
java.util.concurrent.atomic.AtomicInteger count =
registrationAttempts.computeIfAbsent(
clientIp, k -> new java.util.concurrent.atomic.AtomicInteger(0));
return count.incrementAndGet() > REGISTRATION_MAX_PER_HOUR;
}
}
private boolean isTokenRateLimited(String clientIp) {
long now = System.currentTimeMillis();
synchronized (tokenAttempts) {
if (now - tokenWindowStart > 60_000) {
tokenAttempts.clear();
tokenWindowStart = now;
}
if (tokenAttempts.size() >= MAX_RATE_LIMIT_ENTRIES) {
return true;
}
java.util.concurrent.atomic.AtomicInteger count =
tokenAttempts.computeIfAbsent(
clientIp, k -> new java.util.concurrent.atomic.AtomicInteger(0));
return count.incrementAndGet() > TOKEN_MAX_PER_MINUTE;
}
}
private void handleRegistrationRequest(HttpServletRequest request, HttpServletResponse response)
throws IOException {
try {
String clientIp = getClientIp(request);
if (isRegistrationRateLimited(clientIp)) {
LOG.warn("Registration rate limit exceeded for IP: {}", clientIp);
setCorsHeaders(request, response);
response.setContentType("application/json");
response.setStatus(429);
Map<String, String> error = new HashMap<>();
error.put("error", "too_many_requests");
error.put("error_description", "Registration rate limit exceeded. Try again later.");
getObjectMapper().writeValue(response.getOutputStream(), error);
return;
}
LOG.info("Client registration request received");
// Parse registration request
OAuthClientMetadata metadata =
getObjectMapper().readValue(request.getInputStream(), OAuthClientMetadata.class);
// Register client
OAuthClientInformation clientInfo = registrationHandler.handle(metadata).join();
// Return client information per RFC 7591
response.setContentType("application/json");
response.setHeader("Cache-Control", "no-store");
response.setHeader("Pragma", "no-cache");
setCorsHeaders(request, response);
response.setStatus(201); // Created
getObjectMapper().writeValue(response.getOutputStream(), clientInfo);
LOG.info("Client registered successfully: {}", clientInfo.getClientId());
} catch (CompletionException ex) {
LOG.error("Client registration failed", ex);
setCorsHeaders(request, response);
response.setContentType("application/json");
Throwable cause = ex.getCause() != null ? ex.getCause() : ex;
// Extract RegistrationException from cause chain
org.openmetadata.mcp.auth.exception.RegistrationException regEx = null;
if (cause instanceof org.openmetadata.mcp.auth.exception.RegistrationException r) {
regEx = r;
} else if (cause.getCause()
instanceof org.openmetadata.mcp.auth.exception.RegistrationException r) {
regEx = r;
}
if (regEx != null) {
int status =
"server_error".equals(regEx.getError())
? HttpServletResponse.SC_INTERNAL_SERVER_ERROR
: HttpServletResponse.SC_BAD_REQUEST;
response.setStatus(status);
Map<String, String> error = new HashMap<>();
error.put("error", regEx.getError());
error.put("error_description", regEx.getErrorDescription());
getObjectMapper().writeValue(response.getOutputStream(), error);
} else {
response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
Map<String, String> error = new HashMap<>();
error.put("error", "server_error");
error.put("error_description", "Client registration failed");
getObjectMapper().writeValue(response.getOutputStream(), error);
}
} catch (Exception ex) {
LOG.error("Unexpected error during client registration", ex);
setCorsHeaders(request, response);
response.setContentType("application/json");
response.setStatus(500);
Map<String, String> error = new HashMap<>();
error.put("error", "server_error");
error.put("error_description", "Internal server error during client registration");
getObjectMapper().writeValue(response.getOutputStream(), error);
}
}
private void handleRevocationRequest(HttpServletRequest request, HttpServletResponse response)
throws IOException {
try {
LOG.debug("Token revocation request received");
Map<String, String> params = new HashMap<>();
request
.getParameterMap()
.forEach(
(key, values) -> {
if (values.length > 0) {
params.put(key, values[0]);
}
});
// Authenticate client before revocation (RFC 7009 Section 2.1)
ClientCredentialsExtractor.Credentials credentials;
try {
credentials =
ClientCredentialsExtractor.extract(
request, params.get("client_id"), params.get("client_secret"));
} catch (InvalidClientCredentialsException e) {
LOG.warn("Malformed client credentials on revocation request: {}", e.getMessage());
sendOAuthError(request, response, 400, "invalid_request", e.getMessage());
return;
}
try {
clientAuthenticator.authenticate(credentials.clientId(), credentials.clientSecret()).join();
} catch (Exception e) {
LOG.warn("Client authentication failed for revocation request");
sendOAuthError(request, response, 401, "invalid_client", "Client authentication failed");
return;
}
String token = params.get("token");
String tokenTypeHint = params.get("token_type_hint");
if (token == null || token.trim().isEmpty()) {
LOG.warn("Revocation request missing token parameter");
sendOAuthError(request, response, 400, "invalid_request", "token parameter is required");
return;
}
revocationHandler.revokeToken(token, tokenTypeHint).join();
setCorsHeaders(request, response);
response.setStatus(200);
LOG.info("Token revocation completed successfully");
} catch (CompletionException ex) {
// Per RFC 7009, return 200 for "token not found" to prevent token guessing
// But return 500 for actual server errors
Throwable cause = ex.getCause();
if (cause instanceof IllegalArgumentException) {
// Token not found or invalid - this is OK per RFC 7009
LOG.debug("Token revocation: token not found or invalid", ex);
setCorsHeaders(request, response);
response.setStatus(200);
} else {
// Actual server error
LOG.error("Token revocation failed with server error", ex);
sendOAuthError(
request, response, 500, "server_error", "Token revocation failed due to server error");
}
} catch (Exception ex) {
LOG.error("Unexpected error during token revocation", ex);
sendOAuthError(
request, response, 500, "server_error", "Unexpected error during token revocation");
}
}
/**
* Sends a uniform OAuth error response per RFC 6749 §5.2 / RFC 7009 §2.2.1: JSON body with
* {@code error} and {@code error_description}, plus standard CORS + content-type headers.
*/
private void sendOAuthError(
HttpServletRequest request,
HttpServletResponse response,
int status,
String error,
String description)
throws IOException {
setCorsHeaders(request, response);
response.setContentType("application/json");
response.setStatus(status);
Map<String, String> body = new HashMap<>();
body.put("error", error);
body.put("error_description", description);
getObjectMapper().writeValue(response.getOutputStream(), body);
}
private static String sanitizeRedirectUrlForLogging(String url) {
if (url == null) {
return "null";
}
int qIdx = url.indexOf('?');
return qIdx >= 0 ? url.substring(0, qIdx) + "?[params_redacted]" : url;
}
}
@@ -0,0 +1,156 @@
package org.openmetadata.mcp.tools;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import lombok.extern.slf4j.Slf4j;
import org.openmetadata.schema.CreateEntity;
import org.openmetadata.schema.entity.teams.Team;
import org.openmetadata.schema.entity.teams.User;
import org.openmetadata.schema.type.EntityReference;
import org.openmetadata.schema.type.Include;
import org.openmetadata.schema.type.TagLabel;
import org.openmetadata.schema.utils.JsonUtils;
import org.openmetadata.service.Entity;
import org.openmetadata.service.exception.EntityNotFoundException;
import org.openmetadata.service.jdbi3.TeamRepository;
import org.openmetadata.service.jdbi3.UserRepository;
import org.openmetadata.service.security.auth.CatalogSecurityContext;
@Slf4j
public class CommonUtils {
public static <T extends CreateEntity> void setOwners(T entity, Map<String, Object> params) {
List<EntityReference> owners = getTeamsOrUsers(params.get("owners"));
if (!owners.isEmpty()) {
entity.setOwners(owners);
}
}
public static List<EntityReference> getTeamsOrUsers(Object teamsOrUsersParam) {
UserRepository userRepository = Entity.getUserRepository();
TeamRepository teamRepository = (TeamRepository) Entity.getEntityRepository(Entity.TEAM);
List<EntityReference> teamsOrUsers = new java.util.ArrayList<>();
for (String owner : JsonUtils.readOrConvertValues(teamsOrUsersParam, String.class)) {
try {
User user = userRepository.findByNameOrNull(owner, Include.NON_DELETED);
if (user == null) {
// If the owner is not a user, check if it's a team
Team team = teamRepository.findByNameOrNull(owner, Include.NON_DELETED);
if (team != null) {
teamsOrUsers.add(team.getEntityReference());
}
} else {
// If the owner is a user, add their reference
teamsOrUsers.add(user.getEntityReference());
}
} catch (Exception e) {
LOG.error(
"Could not resolve owner or reviewer '{}' to a user or team: {}",
owner,
e.getMessage(),
e);
}
}
return teamsOrUsers;
}
public static String principal(CatalogSecurityContext securityContext) {
return securityContext.getUserPrincipal().getName();
}
public static String requireNonBlank(Object raw, String name) {
if (!(raw instanceof String s) || s.isBlank()) {
throw new IllegalArgumentException(
"Parameter '" + name + "' is required and must be a non-blank string. Received: " + raw);
}
return s;
}
public static String optString(Map<String, Object> params, String key) {
Object raw = params.get(key);
String result = null;
if (raw != null) {
if (!(raw instanceof String s)) {
throw new IllegalArgumentException(
"Parameter '" + key + "' must be a string. Received: " + raw);
}
result = s;
}
return result;
}
public static Boolean parseBoolean(Object raw, String name) {
Boolean result;
if (raw instanceof Boolean b) {
result = b;
} else if (raw instanceof String s
&& ("true".equalsIgnoreCase(s) || "false".equalsIgnoreCase(s))) {
result = Boolean.valueOf(s.toLowerCase(Locale.ROOT));
} else {
throw new IllegalArgumentException(
"Parameter '" + name + "' must be boolean or 'true'/'false'. Received: " + raw);
}
return result;
}
/**
* Returns true when an entity with the given name exists. Only an {@link EntityNotFoundException}
* counts as "does not exist" — any other failure (DB outage, etc.) propagates so a real infra
* error is never mislabelled as a missing entity.
*/
public static boolean entityExistsByName(String entityType, String fqn) {
boolean exists = true;
try {
Entity.getEntityReferenceByName(entityType, fqn, Include.NON_DELETED);
} catch (EntityNotFoundException e) {
exists = false;
}
return exists;
}
public static void requireExists(String entityType, String fqn, String notFoundMessage) {
if (!entityExistsByName(entityType, fqn)) {
throw new IllegalArgumentException(notFoundMessage);
}
}
public static void preflightDomains(List<String> domains) {
for (String domain : domains) {
requireExists(
Entity.DOMAIN,
domain,
"Domain '"
+ domain
+ "' not found. Verify the domain FQN using search_metadata with"
+ " entityType='domain'.");
}
}
public static void preflightExperts(List<String> experts) {
for (String expert : experts) {
requireExists(
Entity.USER,
expert,
"Expert user '"
+ expert
+ "' not found. Use the OpenMetadata login name (e.g. 'john.doe').");
}
}
public static List<TagLabel> buildTagLabels(Object tagsParam) {
List<TagLabel> tags = new ArrayList<>();
for (String tagFqn : JsonUtils.readOrConvertValues(tagsParam, String.class)) {
tags.add(
new TagLabel()
.withTagFQN(tagFqn)
.withSource(TagLabel.TagSource.CLASSIFICATION)
.withLabelType(TagLabel.LabelType.MANUAL));
}
return tags;
}
}
@@ -0,0 +1,135 @@
package org.openmetadata.mcp.tools;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import lombok.extern.slf4j.Slf4j;
import org.openmetadata.schema.api.classification.CreateClassification;
import org.openmetadata.schema.entity.classification.Classification;
import org.openmetadata.schema.type.EntityReference;
import org.openmetadata.schema.type.EventType;
import org.openmetadata.schema.type.MetadataOperation;
import org.openmetadata.schema.utils.JsonUtils;
import org.openmetadata.service.Entity;
import org.openmetadata.service.jdbi3.ClassificationRepository;
import org.openmetadata.service.limits.Limits;
import org.openmetadata.service.resources.tags.ClassificationMapper;
import org.openmetadata.service.security.Authorizer;
import org.openmetadata.service.security.ImpersonationContext;
import org.openmetadata.service.security.auth.CatalogSecurityContext;
import org.openmetadata.service.security.policyevaluator.CreateResourceContext;
import org.openmetadata.service.security.policyevaluator.OperationContext;
import org.openmetadata.service.util.RestUtil;
@Slf4j
public class CreateClassificationTool implements McpTool {
@Override
public Map<String, Object> execute(
Authorizer authorizer, CatalogSecurityContext securityContext, Map<String, Object> params) {
throw new UnsupportedOperationException("CreateClassificationTool requires limit validation.");
}
@Override
public Map<String, Object> execute(
Authorizer authorizer,
Limits limits,
CatalogSecurityContext securityContext,
Map<String, Object> params) {
final String name = CommonUtils.requireNonBlank(params.get("name"), "name");
final String description =
CommonUtils.requireNonBlank(params.get("description"), "description");
final CreateClassification create = new CreateClassification();
create.setName(name);
create.setDescription(description);
final String displayName = CommonUtils.optString(params, "displayName");
if (displayName != null) {
create.setDisplayName(displayName);
}
final Boolean suppliedMutuallyExclusive = applyMutuallyExclusive(create, params);
applyOwnersAndReviewers(create, params);
applyDomains(create, params);
final ClassificationMapper mapper = new ClassificationMapper();
final Classification entity =
mapper.createToEntity(create, CommonUtils.principal(securityContext));
authorize(authorizer, limits, securityContext, entity);
final ClassificationRepository repo =
(ClassificationRepository) Entity.getEntityRepository(Entity.CLASSIFICATION);
repo.prepareInternal(entity, false);
final String userName = CommonUtils.principal(securityContext);
final RestUtil.PutResponse<Classification> response =
repo.createOrUpdate(null, entity, userName, ImpersonationContext.getImpersonatedBy());
McpChangeEventUtil.publishChangeEvent(response.getEntity(), response.getChangeType(), userName);
final Map<String, Object> result =
McpResponseUtils.compact(response.getEntity(), response.getChangeType());
addMutuallyExclusiveWarning(result, response, suppliedMutuallyExclusive);
return result;
}
private static void authorize(
Authorizer authorizer,
Limits limits,
CatalogSecurityContext securityContext,
Classification entity) {
final OperationContext operationContext =
new OperationContext(Entity.CLASSIFICATION, MetadataOperation.CREATE);
final CreateResourceContext<Classification> createResourceContext =
new CreateResourceContext<>(Entity.CLASSIFICATION, entity);
limits.enforceLimits(securityContext, createResourceContext, operationContext);
authorizer.authorize(securityContext, operationContext, createResourceContext);
}
private static void addMutuallyExclusiveWarning(
Map<String, Object> result, RestUtil.PutResponse<Classification> response, Boolean supplied) {
final Boolean stored = response.getEntity().getMutuallyExclusive();
final boolean wasUpdate = !EventType.ENTITY_CREATED.equals(response.getChangeType());
if (wasUpdate && supplied != null && !Objects.equals(supplied, stored)) {
result.put(
"_warning",
"mutuallyExclusive cannot be changed on an existing classification. Retained existing"
+ " value: "
+ stored
+ ". Supplied value "
+ supplied
+ " was ignored.");
}
}
private static Boolean applyMutuallyExclusive(
CreateClassification create, Map<String, Object> params) {
Boolean supplied = null;
if (params.containsKey("mutuallyExclusive")) {
supplied = CommonUtils.parseBoolean(params.get("mutuallyExclusive"), "mutuallyExclusive");
create.setMutuallyExclusive(supplied);
}
return supplied;
}
private static void applyOwnersAndReviewers(
CreateClassification create, Map<String, Object> params) {
if (params.containsKey("owners")) {
CommonUtils.setOwners(create, params);
}
if (params.containsKey("reviewers")) {
final List<EntityReference> reviewers = CommonUtils.getTeamsOrUsers(params.get("reviewers"));
if (!reviewers.isEmpty()) {
create.setReviewers(reviewers);
}
}
}
private static void applyDomains(CreateClassification create, Map<String, Object> params) {
if (params.containsKey("domains")) {
final List<String> domains =
JsonUtils.readOrConvertValues(params.get("domains"), String.class);
CommonUtils.preflightDomains(domains);
create.setDomains(domains);
}
}
}
@@ -0,0 +1,172 @@
package org.openmetadata.mcp.tools;
import java.util.Map;
import lombok.extern.slf4j.Slf4j;
import org.openmetadata.schema.api.context.CreateContextMemory;
import org.openmetadata.schema.entity.context.ContextMemory;
import org.openmetadata.schema.entity.context.ContextMemoryScope;
import org.openmetadata.schema.entity.context.ContextMemorySourceType;
import org.openmetadata.schema.entity.context.ContextMemoryType;
import org.openmetadata.schema.type.MetadataOperation;
import org.openmetadata.schema.utils.JsonUtils;
import org.openmetadata.service.Entity;
import org.openmetadata.service.jdbi3.ContextMemoryRepository;
import org.openmetadata.service.limits.Limits;
import org.openmetadata.service.resources.context.ContextMemoryMapper;
import org.openmetadata.service.security.Authorizer;
import org.openmetadata.service.security.ImpersonationContext;
import org.openmetadata.service.security.auth.CatalogSecurityContext;
import org.openmetadata.service.security.policyevaluator.CreateResourceContext;
import org.openmetadata.service.security.policyevaluator.OperationContext;
import org.openmetadata.service.util.RestUtil;
/**
* Creates a reusable Context Center memory (a {@link ContextMemory}) the assistant should retain
* across conversations. Memories created through this tool are stamped with {@link
* ContextMemorySourceType#REMEMBER_REQUEST} so their provenance reflects an explicit "remember this"
* request rather than a manual UI edit, and so the Memory Agent can pick them up for glossary/metric
* derivation when enabled.
*/
@Slf4j
public class CreateContextMemoryTool implements McpTool {
private static final String MEMORY_TYPE_VALUES = "Preference, UseCase, Note, Runbook, Faq";
private static final String MEMORY_SCOPE_VALUES = "UserGlobal, EntityScoped";
@Override
public Map<String, Object> execute(
Authorizer authorizer, CatalogSecurityContext securityContext, Map<String, Object> params) {
throw new UnsupportedOperationException("CreateContextMemoryTool requires limit validation.");
}
@Override
public Map<String, Object> execute(
Authorizer authorizer,
Limits limits,
CatalogSecurityContext securityContext,
Map<String, Object> params) {
final CreateContextMemory create = buildCreateRequest(params);
final ContextMemoryMapper mapper = new ContextMemoryMapper();
final ContextMemory entity =
mapper.createToEntity(create, CommonUtils.principal(securityContext));
authorize(authorizer, limits, securityContext, entity);
final ContextMemoryRepository repo =
(ContextMemoryRepository) Entity.getEntityRepository(Entity.CONTEXT_MEMORY);
repo.prepareInternal(entity, false);
final String userName = CommonUtils.principal(securityContext);
final RestUtil.PutResponse<ContextMemory> response =
repo.createOrUpdate(null, entity, userName, ImpersonationContext.getImpersonatedBy());
McpChangeEventUtil.publishChangeEvent(response.getEntity(), response.getChangeType(), userName);
return McpResponseUtils.compact(response.getEntity(), response.getChangeType());
}
private CreateContextMemory buildCreateRequest(Map<String, Object> params) {
final CreateContextMemory create = new CreateContextMemory();
create.setName(CommonUtils.requireNonBlank(params.get("name"), "name"));
create.setQuestion(CommonUtils.requireNonBlank(params.get("question"), "question"));
create.setAnswer(CommonUtils.requireNonBlank(params.get("answer"), "answer"));
create.setSourceType(ContextMemorySourceType.REMEMBER_REQUEST);
applyContent(create, params);
applyClassification(create, params);
applyGovernance(create, params);
return create;
}
private void applyContent(CreateContextMemory create, Map<String, Object> params) {
final String title = CommonUtils.optString(params, "title");
if (title != null) {
create.setTitle(title);
}
final String summary = CommonUtils.optString(params, "summary");
if (summary != null) {
create.setSummary(summary);
}
final String description = CommonUtils.optString(params, "description");
if (description != null) {
create.setDescription(description);
}
final String displayName = CommonUtils.optString(params, "displayName");
if (displayName != null) {
create.setDisplayName(displayName);
}
}
private void applyClassification(CreateContextMemory create, Map<String, Object> params) {
if (params.containsKey("memoryType")) {
create.setMemoryType(parseMemoryType(params.get("memoryType")));
}
if (params.containsKey("memoryScope")) {
create.setMemoryScope(parseMemoryScope(params.get("memoryScope")));
}
}
private void applyGovernance(CreateContextMemory create, Map<String, Object> params) {
if (params.containsKey("owners")) {
CommonUtils.setOwners(create, params);
}
if (params.containsKey("tags")) {
create.setTags(CommonUtils.buildTagLabels(params.get("tags")));
}
if (params.containsKey("domains")) {
create.setDomains(JsonUtils.readOrConvertValues(params.get("domains"), String.class));
}
}
static ContextMemoryType parseMemoryType(Object raw) {
if (!(raw instanceof String value) || value.isBlank()) {
throw new IllegalArgumentException(
"Parameter 'memoryType' must be a non-blank string. Valid values are: "
+ MEMORY_TYPE_VALUES
+ ". Received: "
+ raw);
}
ContextMemoryType result;
try {
result = ContextMemoryType.fromValue(value);
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException(
"Parameter 'memoryType' has invalid value '"
+ value
+ "'. Valid values are: "
+ MEMORY_TYPE_VALUES);
}
return result;
}
static ContextMemoryScope parseMemoryScope(Object raw) {
if (!(raw instanceof String value) || value.isBlank()) {
throw new IllegalArgumentException(
"Parameter 'memoryScope' must be a non-blank string. Valid values are: "
+ MEMORY_SCOPE_VALUES
+ ". Received: "
+ raw);
}
ContextMemoryScope result;
try {
result = ContextMemoryScope.fromValue(value);
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException(
"Parameter 'memoryScope' has invalid value '"
+ value
+ "'. Valid values are: "
+ MEMORY_SCOPE_VALUES);
}
return result;
}
private static void authorize(
Authorizer authorizer,
Limits limits,
CatalogSecurityContext securityContext,
ContextMemory entity) {
final OperationContext operationContext =
new OperationContext(Entity.CONTEXT_MEMORY, MetadataOperation.CREATE);
final CreateResourceContext<ContextMemory> createResourceContext =
new CreateResourceContext<>(Entity.CONTEXT_MEMORY, entity);
limits.enforceLimits(securityContext, createResourceContext, operationContext);
authorizer.authorize(securityContext, operationContext, createResourceContext);
}
}
@@ -0,0 +1,112 @@
package org.openmetadata.mcp.tools;
import java.util.List;
import java.util.Map;
import lombok.extern.slf4j.Slf4j;
import org.openmetadata.schema.api.domains.CreateDataProduct;
import org.openmetadata.schema.entity.domains.DataProduct;
import org.openmetadata.schema.type.EntityReference;
import org.openmetadata.schema.type.MetadataOperation;
import org.openmetadata.schema.utils.JsonUtils;
import org.openmetadata.service.Entity;
import org.openmetadata.service.jdbi3.DataProductRepository;
import org.openmetadata.service.limits.Limits;
import org.openmetadata.service.resources.domains.DataProductMapper;
import org.openmetadata.service.security.Authorizer;
import org.openmetadata.service.security.ImpersonationContext;
import org.openmetadata.service.security.auth.CatalogSecurityContext;
import org.openmetadata.service.security.policyevaluator.CreateResourceContext;
import org.openmetadata.service.security.policyevaluator.OperationContext;
import org.openmetadata.service.util.RestUtil;
@Slf4j
public class CreateDataProductTool implements McpTool {
@Override
public Map<String, Object> execute(
Authorizer authorizer, CatalogSecurityContext securityContext, Map<String, Object> params) {
throw new UnsupportedOperationException("CreateDataProductTool requires limit validation.");
}
@Override
public Map<String, Object> execute(
Authorizer authorizer,
Limits limits,
CatalogSecurityContext securityContext,
Map<String, Object> params) {
final String name = CommonUtils.requireNonBlank(params.get("name"), "name");
final String description =
CommonUtils.requireNonBlank(params.get("description"), "description");
final List<String> domains = JsonUtils.readOrConvertValues(params.get("domains"), String.class);
requireDomains(domains);
CommonUtils.preflightDomains(domains);
final List<String> experts = JsonUtils.readOrConvertValues(params.get("experts"), String.class);
CommonUtils.preflightExperts(experts);
final CreateDataProduct create = new CreateDataProduct();
create.setName(name);
create.setDescription(description);
create.setDomains(domains);
if (!experts.isEmpty()) {
create.setExperts(experts);
}
final String displayName = CommonUtils.optString(params, "displayName");
if (displayName != null) {
create.setDisplayName(displayName);
}
if (params.containsKey("owners")) {
CommonUtils.setOwners(create, params);
}
applyReviewers(create, params);
if (params.containsKey("tags")) {
create.setTags(CommonUtils.buildTagLabels(params.get("tags")));
}
final DataProductMapper mapper = new DataProductMapper();
final DataProduct entity =
mapper.createToEntity(create, CommonUtils.principal(securityContext));
authorize(authorizer, limits, securityContext, entity);
final DataProductRepository repo =
(DataProductRepository) Entity.getEntityRepository(Entity.DATA_PRODUCT);
repo.prepareInternal(entity, false);
final String userName = CommonUtils.principal(securityContext);
final RestUtil.PutResponse<DataProduct> response =
repo.createOrUpdate(null, entity, userName, ImpersonationContext.getImpersonatedBy());
McpChangeEventUtil.publishChangeEvent(response.getEntity(), response.getChangeType(), userName);
return McpResponseUtils.compact(response.getEntity(), response.getChangeType());
}
private static void requireDomains(List<String> domains) {
if (domains == null || domains.isEmpty()) {
throw new IllegalArgumentException(
"Parameter 'domains' is required and must list at least one domain FQN. Example:"
+ " ['Finance', 'Marketing'].");
}
}
private static void applyReviewers(CreateDataProduct create, Map<String, Object> params) {
if (params.containsKey("reviewers")) {
final List<EntityReference> reviewers = CommonUtils.getTeamsOrUsers(params.get("reviewers"));
if (!reviewers.isEmpty()) {
create.setReviewers(reviewers);
}
}
}
private static void authorize(
Authorizer authorizer,
Limits limits,
CatalogSecurityContext securityContext,
DataProduct entity) {
final OperationContext operationContext =
new OperationContext(Entity.DATA_PRODUCT, MetadataOperation.CREATE);
final CreateResourceContext<DataProduct> createResourceContext =
new CreateResourceContext<>(Entity.DATA_PRODUCT, entity);
limits.enforceLimits(securityContext, createResourceContext, operationContext);
authorizer.authorize(securityContext, operationContext, createResourceContext);
}
}
@@ -0,0 +1,117 @@
package org.openmetadata.mcp.tools;
import java.util.List;
import java.util.Map;
import lombok.extern.slf4j.Slf4j;
import org.openmetadata.schema.api.domains.CreateDomain;
import org.openmetadata.schema.entity.domains.Domain;
import org.openmetadata.schema.type.MetadataOperation;
import org.openmetadata.schema.utils.JsonUtils;
import org.openmetadata.service.Entity;
import org.openmetadata.service.jdbi3.DomainRepository;
import org.openmetadata.service.limits.Limits;
import org.openmetadata.service.resources.domains.DomainMapper;
import org.openmetadata.service.security.Authorizer;
import org.openmetadata.service.security.ImpersonationContext;
import org.openmetadata.service.security.auth.CatalogSecurityContext;
import org.openmetadata.service.security.policyevaluator.CreateResourceContext;
import org.openmetadata.service.security.policyevaluator.OperationContext;
import org.openmetadata.service.util.RestUtil;
@Slf4j
public class CreateDomainTool implements McpTool {
private static final String DEFAULT_DOMAIN_TYPE = "Aggregate";
@Override
public Map<String, Object> execute(
Authorizer authorizer, CatalogSecurityContext securityContext, Map<String, Object> params) {
throw new UnsupportedOperationException("CreateDomainTool requires limit validation.");
}
@Override
public Map<String, Object> execute(
Authorizer authorizer,
Limits limits,
CatalogSecurityContext securityContext,
Map<String, Object> params) {
final String name = CommonUtils.requireNonBlank(params.get("name"), "name");
final String description =
CommonUtils.requireNonBlank(params.get("description"), "description");
final String parent = CommonUtils.optString(params, "parent");
preflightParent(parent);
final List<String> experts = JsonUtils.readOrConvertValues(params.get("experts"), String.class);
CommonUtils.preflightExperts(experts);
final CreateDomain create = new CreateDomain();
create.setName(name);
create.setDescription(description);
create.setDomainType(parseDomainType(params.get("domainType")));
if (parent != null) {
create.setParent(parent);
}
if (!experts.isEmpty()) {
create.setExperts(experts);
}
final String displayName = CommonUtils.optString(params, "displayName");
if (displayName != null) {
create.setDisplayName(displayName);
}
if (params.containsKey("owners")) {
CommonUtils.setOwners(create, params);
}
if (params.containsKey("tags")) {
create.setTags(CommonUtils.buildTagLabels(params.get("tags")));
}
final DomainMapper mapper = new DomainMapper();
final Domain entity = mapper.createToEntity(create, CommonUtils.principal(securityContext));
authorize(authorizer, limits, securityContext, entity);
final DomainRepository repo = (DomainRepository) Entity.getEntityRepository(Entity.DOMAIN);
repo.prepareInternal(entity, false);
final String userName = CommonUtils.principal(securityContext);
final RestUtil.PutResponse<Domain> response =
repo.createOrUpdate(null, entity, userName, ImpersonationContext.getImpersonatedBy());
McpChangeEventUtil.publishChangeEvent(response.getEntity(), response.getChangeType(), userName);
return McpResponseUtils.compact(response.getEntity(), response.getChangeType());
}
static CreateDomain.DomainType parseDomainType(Object raw) {
final String value = (raw instanceof String s && !s.isBlank()) ? s : DEFAULT_DOMAIN_TYPE;
CreateDomain.DomainType result;
try {
result = CreateDomain.DomainType.fromValue(value);
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException(
"Parameter 'domainType' has invalid value '"
+ value
+ "'. Valid values are: Source-aligned, Consumer-aligned, Aggregate");
}
return result;
}
private static void preflightParent(String parent) {
if (parent != null && !parent.isBlank()) {
CommonUtils.requireExists(
Entity.DOMAIN,
parent,
"Parent domain '"
+ parent
+ "' not found. The parent domain must already exist before creating a child"
+ " domain.");
}
}
private static void authorize(
Authorizer authorizer, Limits limits, CatalogSecurityContext securityContext, Domain entity) {
final OperationContext operationContext =
new OperationContext(Entity.DOMAIN, MetadataOperation.CREATE);
final CreateResourceContext<Domain> createResourceContext =
new CreateResourceContext<>(Entity.DOMAIN, entity);
limits.enforceLimits(securityContext, createResourceContext, operationContext);
authorizer.authorize(securityContext, operationContext, createResourceContext);
}
}
@@ -0,0 +1,192 @@
package org.openmetadata.mcp.tools;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import lombok.extern.slf4j.Slf4j;
import org.openmetadata.schema.api.data.CreateMetric;
import org.openmetadata.schema.api.data.MetricExpression;
import org.openmetadata.schema.entity.data.Metric;
import org.openmetadata.schema.type.MetadataOperation;
import org.openmetadata.schema.type.MetricExpressionLanguage;
import org.openmetadata.schema.type.MetricGranularity;
import org.openmetadata.schema.type.MetricType;
import org.openmetadata.schema.type.MetricUnitOfMeasurement;
import org.openmetadata.schema.type.TagLabel;
import org.openmetadata.schema.utils.JsonUtils;
import org.openmetadata.service.Entity;
import org.openmetadata.service.jdbi3.MetricRepository;
import org.openmetadata.service.limits.Limits;
import org.openmetadata.service.resources.metrics.MetricMapper;
import org.openmetadata.service.security.Authorizer;
import org.openmetadata.service.security.ImpersonationContext;
import org.openmetadata.service.security.auth.CatalogSecurityContext;
import org.openmetadata.service.security.policyevaluator.CreateResourceContext;
import org.openmetadata.service.security.policyevaluator.OperationContext;
import org.openmetadata.service.util.RestUtil;
@Slf4j
public class CreateMetricTool implements McpTool {
@Override
public Map<String, Object> execute(
Authorizer authorizer, CatalogSecurityContext securityContext, Map<String, Object> params) {
throw new UnsupportedOperationException("CreateMetricTool requires limit validation.");
}
@Override
public Map<String, Object> execute(
Authorizer authorizer,
Limits limits,
CatalogSecurityContext securityContext,
Map<String, Object> params) {
Object nameRaw = params.get("name");
if (!(nameRaw instanceof String name) || name.isBlank()) {
throw new IllegalArgumentException(
"Parameter 'name' is required and must be a non-blank string. Received: " + nameRaw);
}
CreateMetric createMetric = new CreateMetric();
createMetric.setName(name);
if (params.containsKey("description")) {
Object descRaw = params.get("description");
if (!(descRaw instanceof String)) {
throw new IllegalArgumentException(
"Parameter 'description' must be a string. Received: " + descRaw);
}
createMetric.setDescription((String) descRaw);
}
if (params.containsKey("displayName")) {
Object displayNameRaw = params.get("displayName");
if (!(displayNameRaw instanceof String)) {
throw new IllegalArgumentException(
"Parameter 'displayName' must be a string. Received: " + displayNameRaw);
}
createMetric.setDisplayName((String) displayNameRaw);
}
Object langRaw = params.get("metricExpressionLanguage");
if (!(langRaw instanceof String lang) || lang.isBlank()) {
throw new IllegalArgumentException(
"Parameter 'metricExpressionLanguage' is required and must be a non-blank string. Valid values are: SQL, Java, JavaScript, Python, External. Received: "
+ langRaw);
}
Object codeRaw = params.get("metricExpressionCode");
if (!(codeRaw instanceof String code) || code.isBlank()) {
throw new IllegalArgumentException(
"Parameter 'metricExpressionCode' is required and must be a non-blank string. Provide the expression that computes this metric (e.g. a SQL query). Received: "
+ codeRaw);
}
try {
createMetric.setMetricExpression(
new MetricExpression()
.withLanguage(MetricExpressionLanguage.fromValue(lang))
.withCode(code));
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException(
"Parameter 'metricExpressionLanguage' has invalid value '"
+ lang
+ "'. Valid values are: SQL, Java, JavaScript, Python, External");
}
if (params.containsKey("metricType")) {
Object rawValue = params.get("metricType");
if (!(rawValue instanceof String)) {
throw new IllegalArgumentException(
"Parameter 'metricType' must be a string. Received: " + rawValue);
}
try {
createMetric.setMetricType(MetricType.fromValue((String) rawValue));
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException(
"Parameter 'metricType' has invalid value '"
+ rawValue
+ "'. Valid values are: COUNT, SUM, AVERAGE, RATIO, PERCENTAGE, MIN, MAX, MEDIAN, MODE, STANDARD_DEVIATION, VARIANCE, OTHER");
}
}
if (params.containsKey("granularity")) {
Object rawValue = params.get("granularity");
if (!(rawValue instanceof String)) {
throw new IllegalArgumentException(
"Parameter 'granularity' must be a string. Received: " + rawValue);
}
try {
createMetric.setGranularity(MetricGranularity.fromValue((String) rawValue));
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException(
"Parameter 'granularity' has invalid value '"
+ rawValue
+ "'. Valid values are: SECOND, MINUTE, HOUR, DAY, WEEK, MONTH, QUARTER, YEAR");
}
}
if (params.containsKey("unitOfMeasurement")) {
Object rawValue = params.get("unitOfMeasurement");
if (!(rawValue instanceof String)) {
throw new IllegalArgumentException(
"Parameter 'unitOfMeasurement' must be a string. Received: " + rawValue);
}
try {
createMetric.setUnitOfMeasurement(MetricUnitOfMeasurement.fromValue((String) rawValue));
} catch (IllegalArgumentException e) {
throw new IllegalArgumentException(
"Parameter 'unitOfMeasurement' has invalid value '"
+ rawValue
+ "'. Valid values are: COUNT, DOLLARS, PERCENTAGE, TIMESTAMP, SIZE, REQUESTS, EVENTS, TRANSACTIONS, OTHER");
}
}
if (params.containsKey("customUnitOfMeasurement")) {
Object customUnitRaw = params.get("customUnitOfMeasurement");
if (!(customUnitRaw instanceof String)) {
throw new IllegalArgumentException(
"Parameter 'customUnitOfMeasurement' must be a string. Received: " + customUnitRaw);
}
createMetric.setCustomUnitOfMeasurement((String) customUnitRaw);
}
if (params.containsKey("owners")) {
CommonUtils.setOwners(createMetric, params);
}
if (params.containsKey("reviewers")) {
createMetric.setReviewers(CommonUtils.getTeamsOrUsers(params.get("reviewers")));
}
if (params.containsKey("relatedMetrics")) {
createMetric.setRelatedMetrics(
JsonUtils.readOrConvertValues(params.get("relatedMetrics"), String.class));
}
if (params.containsKey("tags")) {
List<TagLabel> tags = new ArrayList<>();
for (String tagFqn : JsonUtils.readOrConvertValues(params.get("tags"), String.class)) {
tags.add(
new TagLabel()
.withTagFQN(tagFqn)
.withSource(TagLabel.TagSource.CLASSIFICATION)
.withLabelType(TagLabel.LabelType.MANUAL));
}
createMetric.setTags(tags);
}
if (params.containsKey("domains")) {
createMetric.setDomains(JsonUtils.readOrConvertValues(params.get("domains"), String.class));
}
MetricMapper mapper = new MetricMapper();
Metric metric =
mapper.createToEntity(createMetric, securityContext.getUserPrincipal().getName());
OperationContext operationContext =
new OperationContext(Entity.METRIC, MetadataOperation.CREATE);
CreateResourceContext<Metric> createResourceContext =
new CreateResourceContext<>(Entity.METRIC, metric);
limits.enforceLimits(securityContext, createResourceContext, operationContext);
authorizer.authorize(securityContext, operationContext, createResourceContext);
MetricRepository repo = (MetricRepository) Entity.getEntityRepository(Entity.METRIC);
repo.prepareInternal(metric, false);
String userName = securityContext.getUserPrincipal().getName();
String impersonatedBy = ImpersonationContext.getImpersonatedBy();
RestUtil.PutResponse<Metric> response =
repo.createOrUpdate(null, metric, userName, impersonatedBy);
McpChangeEventUtil.publishChangeEvent(response.getEntity(), response.getChangeType(), userName);
return McpResponseUtils.compact(response.getEntity(), response.getChangeType());
}
}
@@ -0,0 +1,169 @@
package org.openmetadata.mcp.tools;
import java.util.List;
import java.util.Map;
import lombok.extern.slf4j.Slf4j;
import org.openmetadata.schema.api.classification.CreateTag;
import org.openmetadata.schema.entity.classification.Tag;
import org.openmetadata.schema.type.EntityReference;
import org.openmetadata.schema.type.MetadataOperation;
import org.openmetadata.schema.utils.JsonUtils;
import org.openmetadata.service.Entity;
import org.openmetadata.service.jdbi3.TagRepository;
import org.openmetadata.service.limits.Limits;
import org.openmetadata.service.resources.tags.TagMapper;
import org.openmetadata.service.security.Authorizer;
import org.openmetadata.service.security.ImpersonationContext;
import org.openmetadata.service.security.auth.CatalogSecurityContext;
import org.openmetadata.service.security.policyevaluator.CreateResourceContext;
import org.openmetadata.service.security.policyevaluator.OperationContext;
import org.openmetadata.service.util.FullyQualifiedName;
import org.openmetadata.service.util.RestUtil;
@Slf4j
public class CreateTagTool implements McpTool {
@Override
public Map<String, Object> execute(
Authorizer authorizer, CatalogSecurityContext securityContext, Map<String, Object> params) {
throw new UnsupportedOperationException("CreateTagTool requires limit validation.");
}
@Override
public Map<String, Object> execute(
Authorizer authorizer,
Limits limits,
CatalogSecurityContext securityContext,
Map<String, Object> params) {
final String name = CommonUtils.requireNonBlank(params.get("name"), "name");
final String description =
CommonUtils.requireNonBlank(params.get("description"), "description");
final String classificationParam = CommonUtils.optString(params, "classification");
final String parent = normalizeParent(CommonUtils.optString(params, "parent"));
final String classification = resolveClassification(classificationParam, parent);
preflightClassification(classification);
preflightParent(parent);
final CreateTag create = new CreateTag();
create.setName(name);
create.setDescription(description);
create.setClassification(classification);
if (parent != null) {
create.setParent(parent);
}
final String displayName = CommonUtils.optString(params, "displayName");
if (displayName != null) {
create.setDisplayName(displayName);
}
if (params.containsKey("mutuallyExclusive")) {
create.setMutuallyExclusive(
CommonUtils.parseBoolean(params.get("mutuallyExclusive"), "mutuallyExclusive"));
}
applyOwnersAndReviewers(create, params);
applyDomains(create, params);
final TagMapper mapper = new TagMapper();
final Tag entity = mapper.createToEntity(create, CommonUtils.principal(securityContext));
authorize(authorizer, limits, securityContext, entity);
final TagRepository repo = (TagRepository) Entity.getEntityRepository(Entity.TAG);
repo.prepareInternal(entity, false);
final String userName = CommonUtils.principal(securityContext);
final RestUtil.PutResponse<Tag> response =
repo.createOrUpdate(null, entity, userName, ImpersonationContext.getImpersonatedBy());
McpChangeEventUtil.publishChangeEvent(response.getEntity(), response.getChangeType(), userName);
return McpResponseUtils.compact(response.getEntity(), response.getChangeType());
}
static String resolveClassification(String classification, String parent) {
final boolean hasClassification = classification != null && !classification.isBlank();
String result;
if (parent != null && !hasClassification) {
result = FullyQualifiedName.split(parent)[0];
} else if (parent != null) {
final String derived = FullyQualifiedName.split(parent)[0];
if (!classification.equals(derived)) {
throw new IllegalArgumentException(
"'classification' ("
+ classification
+ ") must be the root segment of 'parent' ("
+ parent
+ "). Expected '"
+ derived
+ "'.");
}
result = classification;
} else if (hasClassification) {
result = classification;
} else {
throw new IllegalArgumentException(
"Parameter 'classification' is required. Provide the classification this tag belongs to"
+ " (e.g. 'PII', 'Tier').");
}
return result;
}
private static String normalizeParent(String parent) {
String result = null;
if (parent != null && !parent.isBlank()) {
result = parent.trim();
}
return result;
}
private static void preflightClassification(String classification) {
CommonUtils.requireExists(
Entity.CLASSIFICATION,
classification,
"Classification '"
+ classification
+ "' not found. Create it first with create_classification or verify its name.");
}
private static void preflightParent(String parent) {
if (parent != null) {
CommonUtils.requireExists(
Entity.TAG,
parent,
"Parent tag '"
+ parent
+ "' not found. Verify the FQN format is 'Classification.TagName' (e.g."
+ " 'PII.PersonalData').");
}
}
private static void applyOwnersAndReviewers(CreateTag create, Map<String, Object> params) {
if (params.containsKey("owners")) {
CommonUtils.setOwners(create, params);
}
if (params.containsKey("reviewers")) {
final List<EntityReference> reviewers = CommonUtils.getTeamsOrUsers(params.get("reviewers"));
if (!reviewers.isEmpty()) {
create.setReviewers(reviewers);
}
}
}
private static void applyDomains(CreateTag create, Map<String, Object> params) {
if (params.containsKey("domains")) {
final List<String> domains =
JsonUtils.readOrConvertValues(params.get("domains"), String.class);
CommonUtils.preflightDomains(domains);
create.setDomains(domains);
}
}
private static void authorize(
Authorizer authorizer, Limits limits, CatalogSecurityContext securityContext, Tag entity) {
final OperationContext operationContext =
new OperationContext(Entity.TAG, MetadataOperation.CREATE);
final CreateResourceContext<Tag> createResourceContext =
new CreateResourceContext<>(Entity.TAG, entity);
limits.enforceLimits(securityContext, createResourceContext, operationContext);
authorizer.authorize(securityContext, operationContext, createResourceContext);
}
}
@@ -0,0 +1,125 @@
package org.openmetadata.mcp.tools;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import lombok.extern.slf4j.Slf4j;
import org.openmetadata.schema.api.tests.CreateTestCase;
import org.openmetadata.schema.tests.TestCase;
import org.openmetadata.schema.tests.TestCaseParameterValue;
import org.openmetadata.schema.type.MetadataOperation;
import org.openmetadata.schema.utils.JsonUtils;
import org.openmetadata.service.Entity;
import org.openmetadata.service.jdbi3.TestCaseRepository;
import org.openmetadata.service.limits.Limits;
import org.openmetadata.service.resources.dqtests.TestCaseMapper;
import org.openmetadata.service.resources.feeds.MessageParser;
import org.openmetadata.service.security.Authorizer;
import org.openmetadata.service.security.ImpersonationContext;
import org.openmetadata.service.security.auth.CatalogSecurityContext;
import org.openmetadata.service.security.policyevaluator.CreateResourceContext;
import org.openmetadata.service.security.policyevaluator.OperationContext;
import org.openmetadata.service.util.RestUtil;
@Slf4j
public class CreateTestCaseTool implements McpTool {
private final TestCaseMapper testCaseMapper = new TestCaseMapper();
@Override
public Map<String, Object> execute(
Authorizer authorizer,
CatalogSecurityContext catalogSecurityContext,
Map<String, Object> params) {
throw new UnsupportedOperationException("CreateTestCaseTool requires limit validation.");
}
private TestCase getTestCase(
String name,
String description,
String entityLinkValue,
String testDefinitionName,
List<TestCaseParameterValue> parameterValue,
String updatedBy) {
return testCaseMapper.createToEntity(
new CreateTestCase()
.withName(name)
.withDisplayName(name)
.withDescription(description)
.withEntityLink(entityLinkValue)
.withParameterValues(parameterValue)
.withComputePassedFailedRowCount(false)
.withUseDynamicAssertion(false)
.withTestDefinition(testDefinitionName),
updatedBy);
}
@Override
public Map<String, Object> execute(
Authorizer authorizer,
Limits limits,
CatalogSecurityContext catalogSecurityContext,
Map<String, Object> params) {
String testDefinitionName = (String) params.get("testDefinitionName");
String fqn = (String) params.get("fqn");
if (testDefinitionName == null || testDefinitionName.trim().isEmpty()) {
throw new IllegalArgumentException("Parameter 'testDefinitionName' is required");
}
if (fqn == null || fqn.trim().isEmpty()) {
throw new IllegalArgumentException("Parameter 'fqn' is required");
}
String entityType =
params.containsKey("entityType") ? (String) params.get("entityType") : "table";
String description =
params.containsKey("description")
? (String) params.get("description")
: "Test case created by MCP tool";
String name =
params.containsKey("name")
? (String) params.get("name")
: "TestCase_" + System.currentTimeMillis();
String columnName = params.containsKey("columnName") ? (String) params.get("columnName") : null;
MessageParser.EntityLink entityLink;
if (columnName != null && !columnName.trim().isEmpty()) {
entityLink =
new MessageParser.EntityLink(entityType, fqn, "columns", columnName.trim(), null);
} else {
entityLink = new MessageParser.EntityLink(entityType, fqn);
}
String entityLinkValue = entityLink.getLinkString();
List<TestCaseParameterValue> parameterValue =
params.containsKey("parameterValues")
? JsonUtils.readOrConvertValues(
params.get("parameterValues"), TestCaseParameterValue.class)
: new ArrayList<>();
String updatedBy = catalogSecurityContext.getUserPrincipal().getName();
TestCase testCase =
getTestCase(
name, description, entityLinkValue, testDefinitionName, parameterValue, updatedBy);
TestCaseRepository repository =
(TestCaseRepository) Entity.getEntityRepository(Entity.TEST_CASE);
repository.setFullyQualifiedName(testCase);
repository.prepare(testCase, false);
OperationContext operationContext =
new OperationContext(Entity.TEST_CASE, MetadataOperation.CREATE);
CreateResourceContext<TestCase> createResourceContext =
new CreateResourceContext<>(Entity.TEST_CASE, testCase);
limits.enforceLimits(catalogSecurityContext, createResourceContext, operationContext);
authorizer.authorize(catalogSecurityContext, operationContext, createResourceContext);
LOG.info(
"Creating test case '{}' with definition '{}' for entity: {}",
name,
testDefinitionName,
fqn);
String impersonatedBy = ImpersonationContext.getImpersonatedBy();
RestUtil.PutResponse<TestCase> response =
repository.createOrUpdate(null, testCase, updatedBy, impersonatedBy);
McpChangeEventUtil.publishChangeEvent(
response.getEntity(), response.getChangeType(), updatedBy);
return McpResponseUtils.compact(response.getEntity(), response.getChangeType());
}
}
@@ -0,0 +1,405 @@
package org.openmetadata.mcp.tools;
import static org.openmetadata.mcp.McpUtils.getToolProperties;
import io.modelcontextprotocol.spec.McpSchema;
import java.util.Collections;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.function.Predicate;
import lombok.extern.slf4j.Slf4j;
import org.openmetadata.mcp.util.McpResponseTrim;
import org.openmetadata.schema.entity.app.mcp.McpToolCallUsage;
import org.openmetadata.schema.utils.JsonUtils;
import org.openmetadata.service.limits.Limits;
import org.openmetadata.service.security.AuthorizationException;
import org.openmetadata.service.security.Authorizer;
import org.openmetadata.service.security.auth.CatalogSecurityContext;
@Slf4j
public class DefaultToolContext {
private static final int STATUS_BAD_REQUEST = 400;
private static final int STATUS_FORBIDDEN = 403;
private static final int STATUS_NOT_FOUND = 404;
private static final int STATUS_TOO_MANY_REQUESTS = 429;
private static final int STATUS_INTERNAL_ERROR = 500;
private static final int STATUS_GATEWAY_TIMEOUT = 504;
private static final String OVERSIZED_ADVICE =
"Response exceeded the size limit and was withheld. Narrow your request — use a more specific "
+ "query, request fewer results, or fetch a single entity by its fullyQualifiedName.";
public DefaultToolContext() {}
/**
* Loads tool definitions from a JSON file located at the specified path.
* The JSON file should contain an array of tool definitions under the "tools" key.
*
* @return List of McpSchema.Tool objects loaded from the JSON file.
*/
public List<McpSchema.Tool> loadToolsDefinitionsFromJson(String toolFilePath) {
return getToolProperties(toolFilePath);
}
public McpSchema.CallToolResult callTool(
Authorizer authorizer,
Limits limits,
String toolName,
CatalogSecurityContext securityContext,
McpSchema.CallToolRequest request) {
return callToolWithMetadata(authorizer, limits, toolName, securityContext, request).result();
}
/**
* Phase 3 entry point. Returns the tool result alongside the metadata the {@link
* org.openmetadata.mcp.usage.McpUsageRecorder} needs (latency + error category). Kept as a
* separate method so the legacy single-result signature stays available for external callers
* that haven't migrated yet.
*/
public CallToolOutcome callToolWithMetadata(
Authorizer authorizer,
Limits limits,
String toolName,
CatalogSecurityContext securityContext,
McpSchema.CallToolRequest request) {
long startNanos = System.nanoTime();
LOG.info(
"Catalog Principal: {} is trying to call the tool: {}",
securityContext.getUserPrincipal().getName(),
toolName);
Map<String, Object> params = request.arguments();
Object result;
try {
McpTool tool;
switch (toolName) {
case "search_metadata":
tool = new SearchMetadataTool();
result = tool.execute(authorizer, securityContext, params);
break;
case "semantic_search":
result = new SemanticSearchTool().execute(authorizer, securityContext, params);
break;
case "get_entity_details":
tool = new GetEntityTool();
result = tool.execute(authorizer, securityContext, params);
break;
case "get_asset_context":
result = new GetAssetContextTool().execute(authorizer, securityContext, params);
break;
case "get_persona_context":
result = new GetPersonaContextTool().execute(authorizer, securityContext, params);
break;
case "find_context":
result = new FindContextTool().execute(authorizer, securityContext, params);
break;
case "get_knowledge_content":
result = new GetKnowledgeContentTool().execute(authorizer, securityContext, params);
break;
case "search_company_context":
result = new SearchCompanyContextTool().execute(authorizer, securityContext, params);
break;
case "get_company_context":
result = new GetCompanyContextTool().execute(authorizer, securityContext, params);
break;
case "create_context_memory":
result =
new CreateContextMemoryTool().execute(authorizer, limits, securityContext, params);
break;
case "create_glossary":
tool = new GlossaryTool();
result = tool.execute(authorizer, limits, securityContext, params);
break;
case "create_glossary_term":
tool = new GlossaryTermTool();
result = tool.execute(authorizer, limits, securityContext, params);
break;
case "patch_entity":
tool = new PatchEntityTool();
result = tool.execute(authorizer, securityContext, params);
break;
case "get_entity_lineage":
tool = new GetLineageTool();
result = tool.execute(authorizer, securityContext, params);
break;
case "create_lineage":
result = new LineageTool().execute(authorizer, securityContext, params);
break;
case "get_test_definitions":
result = new TestDefinitionsTool().execute(authorizer, securityContext, params);
break;
case "create_test_case":
result = new CreateTestCaseTool().execute(authorizer, limits, securityContext, params);
break;
case "root_cause_analysis":
result = new RootCauseAnalysisTool().execute(authorizer, securityContext, params);
break;
case "create_metric":
result = new CreateMetricTool().execute(authorizer, limits, securityContext, params);
break;
case "create_classification":
result =
new CreateClassificationTool().execute(authorizer, limits, securityContext, params);
break;
case "create_tag":
result = new CreateTagTool().execute(authorizer, limits, securityContext, params);
break;
case "create_domain":
result = new CreateDomainTool().execute(authorizer, limits, securityContext, params);
break;
case "create_data_product":
result = new CreateDataProductTool().execute(authorizer, limits, securityContext, params);
break;
default:
return new CallToolOutcome(
errorResult(errorPayload("Unknown function: " + toolName, STATUS_BAD_REQUEST)),
elapsedMs(startNanos),
McpToolCallUsage.ErrorCategory.VALIDATION);
}
McpSchema.CallToolResult success = buildSuccessResult(result, toolName);
return new CallToolOutcome(success, elapsedMs(startNanos), resultErrorCategory(result));
} catch (AuthorizationException ex) {
LOG.warn("Authorization error: {}", ex.getMessage());
Map<String, Object> error =
errorPayload(
String.format("Authorization error: %s", McpResponseTrim.safeMessage(ex)),
STATUS_FORBIDDEN);
return new CallToolOutcome(
errorResult(error), elapsedMs(startNanos), McpToolCallUsage.ErrorCategory.AUTH);
} catch (Exception ex) {
LOG.error("Error executing tool '{}': {}", toolName, ex.getMessage(), ex);
Map<String, Object> error =
errorPayload(
String.format("Error executing tool: %s", McpResponseTrim.safeMessage(ex)),
resolveStatusCode(ex));
return new CallToolOutcome(errorResult(error), elapsedMs(startNanos), classifyException(ex));
}
}
/**
* Builds the non-exception dispatch result. Attaches the tool's own payload as {@code
* structuredContent} (MCP spec machine-readable output) next to the serialized {@code TextContent}
* so structured-aware clients skip re-parsing the string. Sets the protocol {@code isError} flag
* from {@link #logicalError} so a tool that returns a soft {@code error}-key map is reported as a
* failure rather than a silent success. Truncation ({@code truncated:true}) and partial pages
* ({@code hasMore:true}) are successful partial responses, so they stay unflagged.
*/
static McpSchema.CallToolResult buildSuccessResult(Object result, String toolName) {
boolean isError = logicalError(result);
BudgetedResult budgeted = applyBudget(result, toolName);
return McpSchema.CallToolResult.builder()
.content(List.of(new McpSchema.TextContent(budgeted.json())))
.structuredContent(budgeted.payload())
.isError(isError)
.build();
}
private static McpSchema.CallToolResult errorResult(Map<String, Object> error) {
return McpSchema.CallToolResult.builder()
.content(List.of(new McpSchema.TextContent(JsonUtils.pojoToJson(error))))
.structuredContent(error)
.isError(true)
.build();
}
private static Map<String, Object> errorPayload(String message, int statusCode) {
return Map.of(McpResponseTrim.ERROR_KEY, message, McpResponseTrim.STATUS_CODE_KEY, statusCode);
}
/** A result is a logical failure when it is a map carrying a non-null {@link McpResponseTrim#ERROR_KEY}. */
static boolean logicalError(Object result) {
return result instanceof Map<?, ?> map && map.get(McpResponseTrim.ERROR_KEY) != null;
}
/**
* Telemetry bucket for a non-exception result: {@code null} for a successful (or partial) response,
* otherwise the category implied by the soft error's {@code statusCode}. Mirrors the exception-path
* buckets so a soft {@code error} map and the equivalent thrown exception land in the same tile.
*/
static McpToolCallUsage.ErrorCategory resultErrorCategory(Object payload) {
McpToolCallUsage.ErrorCategory category = null;
if (logicalError(payload)) {
category = categoryForStatus(((Map<?, ?>) payload).get(McpResponseTrim.STATUS_CODE_KEY));
}
return category;
}
private static McpToolCallUsage.ErrorCategory categoryForStatus(Object statusCode) {
if (!(statusCode instanceof Number number)) {
return McpToolCallUsage.ErrorCategory.INTERNAL;
}
return switch (number.intValue()) {
case STATUS_BAD_REQUEST, STATUS_NOT_FOUND -> McpToolCallUsage.ErrorCategory.VALIDATION;
case STATUS_TOO_MANY_REQUESTS -> McpToolCallUsage.ErrorCategory.RATE_LIMIT;
case STATUS_FORBIDDEN -> McpToolCallUsage.ErrorCategory.AUTH;
case STATUS_GATEWAY_TIMEOUT -> McpToolCallUsage.ErrorCategory.TIMEOUT;
default -> McpToolCallUsage.ErrorCategory.INTERNAL;
};
}
/**
* Maps an arbitrary exception type to one of the {@link McpToolCallUsage.ErrorCategory} values.
* Walks the cause chain because the tool wrappers usually rethrow framework errors wrapped in
* a {@link RuntimeException}. Defaults to {@link McpToolCallUsage.ErrorCategory#INTERNAL} when
* no specific bucket matches.
*/
protected static McpToolCallUsage.ErrorCategory classifyException(Throwable t) {
CategoryMatcher matched = matchException(t);
return matched != null ? matched.category() : McpToolCallUsage.ErrorCategory.INTERNAL;
}
/**
* Resolves the HTTP-style status code returned to the client for a failed tool call. Kept
* separate from {@link #classifyException} (which buckets for telemetry) because the wire status
* is a distinct concern: a missing entity is a 404 and a bad argument is a 400, even though both
* bucket as {@code VALIDATION}. Defaults to 500 when no specific matcher applies.
*/
protected static int resolveStatusCode(Throwable t) {
CategoryMatcher matched = matchException(t);
return matched != null ? matched.statusCode() : STATUS_INTERNAL_ERROR;
}
private static CategoryMatcher matchException(Throwable t) {
CategoryMatcher result = null;
// Identity-based visited set bounds the walk: a malformed cause cycle (A.cause=B, B.cause=A)
// would otherwise spin forever. seen.add returns false on a revisit, ending the loop.
Set<Throwable> seen = Collections.newSetFromMap(new IdentityHashMap<>());
Throwable cursor = t;
while (cursor != null && result == null && seen.add(cursor)) {
result = matchSingle(cursor);
cursor = cursor.getCause();
}
return result;
}
/**
* Pairing of an exception (name, message) predicate with the telemetry bucket and HTTP status it
* should produce. Kept as a static table so adding a new category (or extending an existing one
* with a new keyword) is a one-line change rather than another {@code else if} branch.
*/
private record CategoryMatcher(
Predicate<ExceptionMeta> matches, McpToolCallUsage.ErrorCategory category, int statusCode) {}
/** Lower-cased name + message pair so each matcher inspects both without re-parsing. */
private record ExceptionMeta(String name, String message) {}
/**
* Ordered category table. Check order matters: more specific patterns sit before broader ones so
* a {@code RateLimitException} doesn't get caught by the generic message-substring rules below
* it. {@code AUTH} sits above {@code VALIDATION} because some auth exceptions ({@code
* AuthorizationException}) extend {@code IllegalArgumentException}-style hierarchies and would
* otherwise be mis-bucketed.
*/
private static final List<CategoryMatcher> CATEGORY_MATCHERS =
List.of(
new CategoryMatcher(
meta -> meta.name().contains("RateLimit") || meta.message().contains("rate limit"),
McpToolCallUsage.ErrorCategory.RATE_LIMIT,
STATUS_TOO_MANY_REQUESTS),
new CategoryMatcher(
meta ->
meta.name().contains("Authorization")
|| meta.name().contains("Forbidden")
|| meta.name().contains("Unauthorized")
|| meta.message().contains("forbidden")
|| meta.message().contains("unauthorized")
|| meta.message().contains("access denied")
|| meta.message().contains("permission denied"),
McpToolCallUsage.ErrorCategory.AUTH,
STATUS_FORBIDDEN),
// Validation by class name runs before the NotFound message heuristic below, so a
// bad-argument exception whose message merely contains "not found" (e.g.
// IllegalArgumentException("parameter not found")) stays a 400 rather than a 404.
new CategoryMatcher(
meta ->
meta.name().contains("Validation")
|| meta.name().contains("IllegalArgument")
|| meta.name().contains("BadRequest")
|| meta.message().contains("invalid argument"),
McpToolCallUsage.ErrorCategory.VALIDATION,
STATUS_BAD_REQUEST),
new CategoryMatcher(
meta -> meta.name().contains("NotFound") || meta.message().contains("not found"),
McpToolCallUsage.ErrorCategory.VALIDATION,
STATUS_NOT_FOUND),
new CategoryMatcher(
meta ->
meta.name().contains("Timeout")
|| meta.message().contains("timeout")
|| meta.message().contains("timed out"),
McpToolCallUsage.ErrorCategory.TIMEOUT,
STATUS_GATEWAY_TIMEOUT));
/**
* Returns the matcher (category + status) for the supplied throwable's name or message, or
* {@code null} when no specific bucket applies. Kept separate from {@link #matchException} so the
* cause-chain walk reads as a single linear loop.
*/
private static CategoryMatcher matchSingle(Throwable cursor) {
ExceptionMeta meta =
new ExceptionMeta(
cursor.getClass().getSimpleName(),
cursor.getMessage() == null ? "" : cursor.getMessage().toLowerCase(Locale.ROOT));
return CATEGORY_MATCHERS.stream()
.filter(matcher -> matcher.matches().test(meta))
.findFirst()
.orElse(null);
}
private static long elapsedMs(long startNanos) {
return (System.nanoTime() - startNanos) / 1_000_000L;
}
/**
* Serializes a tool result once and, only when it exceeds {@link
* McpResponseTrim#MAX_RESPONSE_CHARS}, replaces it with a generic {@code truncated:true} envelope.
* This is the dispatch-level floor that bounds tools without their own per-tool trim ({@code
* get_entity_details}, {@code get_test_definitions}) and backstops the rest. The happy path
* serializes exactly once; the re-serialization runs only on the rare oversized path.
*
* <p>Public so the Collate dispatcher ({@code CollateToolContext}), which builds its own success
* result for Collate-only tools, applies the same floor instead of re-implementing it.
*/
public static String serializeWithinBudget(Object result, String toolName) {
return applyBudget(result, toolName).json();
}
/**
* Serializes a tool result once and, when it exceeds {@link McpResponseTrim#MAX_RESPONSE_CHARS},
* swaps in the generic {@code truncated:true} envelope. Returns both the effective payload and its
* JSON so the dispatch layer can attach the same object as {@code structuredContent} that it writes
* as {@code TextContent} — the two must never diverge. The happy path serializes exactly once; the
* re-serialization runs only on the rare oversized path.
*/
static BudgetedResult applyBudget(Object result, String toolName) {
String serialized = JsonUtils.pojoToJson(result);
Object payload = result;
if (serialized.length() > McpResponseTrim.MAX_RESPONSE_CHARS) {
LOG.warn(
"[MCP] tool '{}' response {} chars exceeds {} budget; returning truncation envelope",
toolName,
serialized.length(),
McpResponseTrim.MAX_RESPONSE_CHARS);
payload =
McpResponseTrim.oversizedEnvelope(
serialized.length(), Map.of("tool", toolName), OVERSIZED_ADVICE);
serialized = JsonUtils.pojoToJson(payload);
}
return new BudgetedResult(payload, serialized);
}
/** Effective wire payload plus its serialization, kept together so both content forms agree. */
record BudgetedResult(Object payload, String json) {}
/**
* Phase 3 — tuple returned by {@link #callToolWithMetadata} so the MCP server can record the
* call with full diagnostic detail without re-classifying the exception or re-measuring the
* latency at its level.
*/
public record CallToolOutcome(
McpSchema.CallToolResult result,
long latencyMs,
McpToolCallUsage.ErrorCategory errorCategory) {}
}
@@ -0,0 +1,149 @@
package org.openmetadata.mcp.tools;
import static org.openmetadata.schema.type.MetadataOperation.VIEW_ALL;
import static org.openmetadata.schema.type.MetadataOperation.VIEW_BASIC;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import lombok.extern.slf4j.Slf4j;
import org.openmetadata.mcp.util.McpParams;
import org.openmetadata.schema.utils.JsonUtils;
import org.openmetadata.service.Entity;
import org.openmetadata.service.aicontext.AIContextFinder;
import org.openmetadata.service.aicontext.AIContextFinder.CandidateAsset;
import org.openmetadata.service.aicontext.AIContextFinder.FoundContext;
import org.openmetadata.service.aicontext.AIContextMarkdown;
import org.openmetadata.service.limits.Limits;
import org.openmetadata.service.security.Authorizer;
import org.openmetadata.service.security.DefaultAuthorizer;
import org.openmetadata.service.security.auth.CatalogSecurityContext;
import org.openmetadata.service.security.policyevaluator.OperationContext;
import org.openmetadata.service.security.policyevaluator.ResourceContext;
import org.openmetadata.service.security.policyevaluator.SubjectContext;
/**
* Mode B of the AI Context Platform. Given a business question with no chosen asset, semantically
* searches the company-knowledge layer (glossary terms, metrics, Context Center articles) and
* returns the relevant definitions together with the candidate data assets each concept routes to.
* The agent then hands those candidate FQNs to {@code get_asset_context} to pull full asset context.
*
* <p>Authorization: the caller must hold VIEW_ALL on the knowledge types this tool surfaces
* (glossary terms, metrics, pages); glossary-routed candidates are RBAC-filtered by threading the
* caller's subject into the search, and relationship-routed candidates are permission-checked
* per asset before being returned.
*/
@Slf4j
public class FindContextTool implements McpTool {
private static final int DEFAULT_SIZE = 10;
private static final int MAX_SIZE = 50;
private static final String FORMAT_JSON = "json";
private static final String NO_RESULTS_MESSAGE = "No company knowledge found for this query.";
private static final List<String> KNOWLEDGE_TYPES =
List.of(Entity.GLOSSARY_TERM, Entity.METRIC, Entity.PAGE);
@Override
public Map<String, Object> execute(
Authorizer authorizer, CatalogSecurityContext securityContext, Map<String, Object> params)
throws IOException {
String query = (String) params.get("query");
Map<String, Object> result;
if (query == null || query.isBlank()) {
result = Map.of("error", "'query' parameter is required");
} else if (!Entity.getSearchRepository().isVectorEmbeddingEnabled()) {
result =
Map.of(
"error",
"Semantic search is not enabled. Configure vector embeddings in the OpenMetadata server settings.");
} else {
authorizeKnowledgeAccess(authorizer, securityContext);
String format = (String) params.getOrDefault("format", "markdown");
int size = Math.min(Math.max(McpParams.getInt(params, "size", DEFAULT_SIZE), 1), MAX_SIZE);
LOG.info("Finding company context for query: {}, format: {}", query, format);
SubjectContext subjectContext = DefaultAuthorizer.getSubjectContext(securityContext);
FoundContext found = new AIContextFinder(subjectContext).find(query, size);
result = render(filterCandidates(found, authorizer, securityContext), format);
}
return result;
}
private void authorizeKnowledgeAccess(
Authorizer authorizer, CatalogSecurityContext securityContext) {
for (String knowledgeType : KNOWLEDGE_TYPES) {
authorizer.authorize(
securityContext,
new OperationContext(knowledgeType, VIEW_ALL),
new ResourceContext<>(knowledgeType));
}
}
/**
* Drops candidate assets the caller cannot view. Glossary-routed candidates are already
* RBAC-filtered by the subject-scoped search; this guards the relationship-routed ones
* (metric APPLIED_TO, page HAS), which are read straight from the entity-relationship table.
*/
private FoundContext filterCandidates(
FoundContext found, Authorizer authorizer, CatalogSecurityContext securityContext) {
List<CandidateAsset> visible = new ArrayList<>();
for (CandidateAsset asset : found.candidateAssets()) {
if (canView(asset, authorizer, securityContext)) {
visible.add(asset);
}
}
return new FoundContext(found.items(), visible);
}
private boolean canView(
CandidateAsset asset, Authorizer authorizer, CatalogSecurityContext securityContext) {
boolean visible = false;
try {
authorizer.authorize(
securityContext,
new OperationContext(asset.entityType(), VIEW_BASIC),
new ResourceContext<>(asset.entityType(), null, asset.fullyQualifiedName()));
visible = true;
} catch (Exception e) {
LOG.debug(
"Dropping candidate asset {} not viewable by caller: {}",
asset.fullyQualifiedName(),
e.getMessage());
}
return visible;
}
private Map<String, Object> render(FoundContext found, String format) {
Map<String, Object> result;
if (FORMAT_JSON.equalsIgnoreCase(format)) {
result =
found.isEmpty()
? Map.of(
"items", List.of(), "candidateAssets", List.of(), "message", NO_RESULTS_MESSAGE)
: JsonUtils.getMap(found);
} else {
// Keep the {format, content} shape on the empty case too, so markdown consumers never need
// a special branch; the message field carries the human-readable reason.
result =
found.isEmpty()
? Map.of(
"format",
"markdown",
"content",
AIContextMarkdown.renderFound(found),
"message",
NO_RESULTS_MESSAGE)
: Map.of("format", "markdown", "content", AIContextMarkdown.renderFound(found));
}
return result;
}
@Override
public Map<String, Object> execute(
Authorizer authorizer,
Limits limits,
CatalogSecurityContext securityContext,
Map<String, Object> params)
throws IOException {
throw new UnsupportedOperationException("FindContextTool does not require limit validation.");
}
}
@@ -0,0 +1,75 @@
package org.openmetadata.mcp.tools;
import static org.openmetadata.schema.type.MetadataOperation.VIEW_ALL;
import java.io.IOException;
import java.util.Map;
import lombok.extern.slf4j.Slf4j;
import org.openmetadata.schema.type.AIContext;
import org.openmetadata.schema.utils.JsonUtils;
import org.openmetadata.service.aicontext.AIContextBuilder;
import org.openmetadata.service.aicontext.AIContextMarkdown;
import org.openmetadata.service.limits.Limits;
import org.openmetadata.service.security.Authorizer;
import org.openmetadata.service.security.auth.CatalogSecurityContext;
import org.openmetadata.service.security.policyevaluator.OperationContext;
import org.openmetadata.service.security.policyevaluator.ResourceContext;
/**
* Returns the full AI Context (Context Profile) for a single data asset in one call: the attached
* business knowledge (glossary terms, Context Center articles) plus the type-specific structural
* context (for tables: schema, primary/foreign keys, and frequently-joined columns). Agents use this
* after they have selected an asset to gather the strong, deterministic signals needed for tasks
* such as SQL generation, instead of stitching several metadata calls together.
*/
@Slf4j
public class GetAssetContextTool implements McpTool {
private static final String FORMAT_JSON = "json";
@Override
public Map<String, Object> execute(
Authorizer authorizer, CatalogSecurityContext securityContext, Map<String, Object> params)
throws IOException {
String entityType = (String) params.get("entityType");
String fqn = (String) params.get("fqn");
Map<String, Object> result;
if (entityType == null || entityType.isBlank() || fqn == null || fqn.isBlank()) {
result = Map.of("error", "'entityType' and 'fqn' parameters are required");
} else {
String format = (String) params.getOrDefault("format", "markdown");
// Authorize against the specific asset (by FQN), not just the type, so conditional
// per-entity policies (owner/domain/tag-based rules) are evaluated for this asset.
authorizer.authorize(
securityContext,
new OperationContext(entityType, VIEW_ALL),
new ResourceContext<>(entityType, null, fqn));
LOG.info(
"Assembling AI context for entity type: {}, FQN: {}, format: {}",
entityType,
fqn,
format);
String query = (String) params.get("query");
AIContext context =
new AIContextBuilder(entityType, fqn)
.withQuery(query)
.withSecurity(authorizer, securityContext)
.build();
result =
FORMAT_JSON.equalsIgnoreCase(format)
? JsonUtils.getMap(context)
: Map.of("format", "markdown", "content", AIContextMarkdown.render(context));
}
return result;
}
@Override
public Map<String, Object> execute(
Authorizer authorizer,
Limits limits,
CatalogSecurityContext securityContext,
Map<String, Object> params)
throws IOException {
throw new UnsupportedOperationException(
"GetAssetContextTool does not require limit validation.");
}
}
@@ -0,0 +1,109 @@
package org.openmetadata.mcp.tools;
import static org.openmetadata.schema.type.MetadataOperation.VIEW_ALL;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import lombok.extern.slf4j.Slf4j;
import org.openmetadata.schema.entity.context.ContextMemory;
import org.openmetadata.schema.entity.context.ContextMemorySourceType;
import org.openmetadata.schema.entity.context.MemoryVisibility;
import org.openmetadata.service.Entity;
import org.openmetadata.service.exception.EntityNotFoundException;
import org.openmetadata.service.limits.Limits;
import org.openmetadata.service.security.Authorizer;
import org.openmetadata.service.security.auth.CatalogSecurityContext;
import org.openmetadata.service.security.policyevaluator.OperationContext;
import org.openmetadata.service.security.policyevaluator.ResourceContext;
import org.openmetadata.service.util.FullyQualifiedName;
/** Fetches a single Company Context knowledge pill (a {@link ContextMemory}) by FQN. */
@Slf4j
public class GetCompanyContextTool implements McpTool {
private static final String NOT_A_SHARED_PILL_ERROR =
"Requested entity is not a shared Company Context knowledge pill";
@Override
public Map<String, Object> execute(
Authorizer authorizer, CatalogSecurityContext securityContext, Map<String, Object> params)
throws IOException {
authorizer.authorize(
securityContext,
new OperationContext(Entity.CONTEXT_MEMORY, VIEW_ALL),
new ResourceContext<>(Entity.CONTEXT_MEMORY));
Map<String, Object> result;
String fqn = (String) params.get("fqn");
if (fqn == null || fqn.isBlank()) {
result = Map.of("error", "'fqn' parameter is required");
} else {
result = lookupPill(fqn);
}
return result;
}
private static Map<String, Object> lookupPill(String fqn) {
Map<String, Object> result;
try {
ContextMemory memory = fetchPill(fqn);
result =
isExposablePill(memory) ? projectPill(memory) : Map.of("error", NOT_A_SHARED_PILL_ERROR);
} catch (EntityNotFoundException e) {
result = Map.of("error", "No Company Context knowledge pill found for '" + fqn + "'");
}
return result;
}
/**
* A {@link ContextMemory} FQN is a single name part, so a file-extracted pill name (e.g. {@code
* report.md_<hash>}) carries dots and is stored quoted ({@code "report.md_<hash>"}). MCP clients
* routinely hand the value back unquoted; {@link FullyQualifiedName#quoteName(String)} restores
* the canonical quoting (and is a no-op when the value is already correctly quoted), so the
* by-name lookup resolves whichever form the client supplies.
*/
private static ContextMemory fetchPill(String fqn) {
String normalizedFqn = FullyQualifiedName.quoteName(fqn);
LOG.debug("Getting company context pill: {} (normalized fqn: {})", fqn, normalizedFqn);
return Entity.getEntityByName(
Entity.CONTEXT_MEMORY, normalizedFqn, "sourceFile,owners,tags,domains", null);
}
/** Mirrors the search tool's scope: file-extracted pills with Shared visibility only. */
private static boolean isExposablePill(ContextMemory memory) {
return memory.getSourceType() == ContextMemorySourceType.FILE_EXTRACTION
&& memory.getShareConfig() != null
&& memory.getShareConfig().getVisibility() == MemoryVisibility.SHARED;
}
static Map<String, Object> projectPill(ContextMemory memory) {
Map<String, Object> pill = new HashMap<>();
pill.put("fullyQualifiedName", memory.getFullyQualifiedName());
pill.put("name", memory.getName());
putIfPresent(pill, "title", memory.getTitle());
putIfPresent(pill, "question", memory.getQuestion());
putIfPresent(pill, "answer", memory.getAnswer());
putIfPresent(pill, "summary", memory.getSummary());
putIfPresent(pill, "memoryType", memory.getMemoryType());
if (memory.getSourceFile() != null) {
pill.put("sourceFile", memory.getSourceFile().getFullyQualifiedName());
}
return pill;
}
private static void putIfPresent(Map<String, Object> pill, String key, Object value) {
if (value != null) {
pill.put(key, value);
}
}
@Override
public Map<String, Object> execute(
Authorizer authorizer,
Limits limits,
CatalogSecurityContext securityContext,
Map<String, Object> params) {
throw new UnsupportedOperationException(
"GetCompanyContextTool does not require limit validation.");
}
}
@@ -0,0 +1,321 @@
package org.openmetadata.mcp.tools;
import static org.openmetadata.schema.type.MetadataOperation.VIEW_ALL;
import com.google.common.annotations.VisibleForTesting;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import lombok.extern.slf4j.Slf4j;
import org.openmetadata.mcp.util.McpParams;
import org.openmetadata.mcp.util.McpResponseTrim;
import org.openmetadata.schema.utils.JsonUtils;
import org.openmetadata.service.Entity;
import org.openmetadata.service.limits.Limits;
import org.openmetadata.service.security.Authorizer;
import org.openmetadata.service.security.auth.CatalogSecurityContext;
import org.openmetadata.service.security.policyevaluator.OperationContext;
import org.openmetadata.service.security.policyevaluator.ResourceContext;
@Slf4j
public class GetEntityTool implements McpTool {
// Fields to exclude from response to optimize LLM context usage
// These fields are typically verbose and not useful for LLM understanding
private static final List<String> EXCLUDE_FIELDS =
List.of(
"version",
"updatedAt",
"updatedBy",
"changeDescription",
"incrementalChangeDescription",
"followers",
"votes",
"totalVotes",
"usageSummary",
"lifeCycle",
"sourceHash",
"fqnParts",
"fqnHash",
"entityRelationship",
"processedLineage",
"upstreamLineage",
"changeSummary",
"tierSources",
"tagSources",
"descriptionSources",
"columnDescriptionStatus",
"descriptionStatus");
private static final String COLUMNS_KEY = "columns";
private static final String SCHEMA_DEFINITION_KEY = "schemaDefinition";
private static final String DATA_MODEL_KEY = "dataModel";
private static final String SQL_KEY = "sql";
private static final String RAW_SQL_KEY = "rawSql";
private static final String SCHEMA_DEFINITION_TRUNCATED_KEY = "schemaDefinitionTruncated";
private static final String SQL_TRUNCATED_KEY = "sqlTruncated";
private static final String COLUMN_OFFSET_PARAM = "columnOffset";
private static final String COLUMN_LIMIT_PARAM = "columnLimit";
private static final String TOTAL_COLUMNS_KEY = "totalColumns";
private static final String RETURNED_COLUMNS_KEY = "returnedColumns";
private static final String COLUMN_OFFSET_KEY = "columnOffset";
private static final String COLUMNS_TRUNCATED_KEY = "columnsTruncated";
private static final String HAS_MORE_COLUMNS_KEY = "hasMoreColumns";
private static final String COLUMNS_MESSAGE_KEY = "columnsMessage";
private static final String OVERSIZED_COLUMN_OFFSET_KEY = "oversizedColumnOffset";
private static final int DEFAULT_COLUMN_OFFSET = 0;
private static final int NO_COLUMN_LIMIT = -1;
/**
* Anti-OOM/anti-nuke safety valve for the entity-level DDL ({@code schemaDefinition}) and dbt model
* {@code sql}/{@code rawSql}. These are single fields returned in full — this is the detail tool, so
* their content is never truncated for context optimization. Unlike columns they cannot be
* paginated, so a runaway value could push the response past the dispatch-level {@link
* McpResponseTrim#MAX_RESPONSE_CHARS} cap and discard everything. The valve sits far above any
* human-authored SQL or realistic DDL (~600-column tables) so real content is never cut; when it
* does trip on machine-generated bloat the response flags it and stays retrievable.
*/
private static final int SCHEMA_SQL_MAX_LENGTH = 30_000;
/**
* Combined ceiling shared across {@code schemaDefinition}, {@code dataModel.sql} and {@code
* dataModel.rawSql}. All three are entity-level, un-paginable text, so three independent {@link
* #SCHEMA_SQL_MAX_LENGTH} caps could sum close to the {@link McpResponseTrim#MAX_RESPONSE_CHARS}
* dispatch cap and still nuke the payload. A shared budget keeps their combined size bounded while
* the common single-field case still gets the full per-field valve.
*/
private static final int SCHEMA_SQL_COMBINED_MAX = 60_000;
/**
* Fraction of {@link McpResponseTrim#MAX_RESPONSE_CHARS} the windowed columns may occupy. Leaves
* headroom for the entity-level fields and the window markers so the assembled response lands
* comfortably below the dispatch-level cap rather than right at it.
*/
private static final double COLUMN_BUDGET_FACTOR = 0.8;
@Override
public Map<String, Object> execute(
Authorizer authorizer, CatalogSecurityContext securityContext, Map<String, Object> params)
throws IOException {
String entityType = (String) params.get("entityType");
String fqn = (String) params.get("fqn");
authorizer.authorize(
securityContext,
new OperationContext(entityType, VIEW_ALL),
new ResourceContext<>(entityType));
LOG.info("Getting details for entity type: {}, FQN: {}", entityType, fqn);
int columnOffset =
Math.max(0, McpParams.getInt(params, COLUMN_OFFSET_PARAM, DEFAULT_COLUMN_OFFSET));
int columnLimit = McpParams.getInt(params, COLUMN_LIMIT_PARAM, NO_COLUMN_LIMIT);
String fields = "*";
Map<String, Object> entityData =
JsonUtils.getMap(Entity.getEntityByName(entityType, fqn, fields, null));
// Clean response to optimize LLM context usage, then bound the columns array so a wide entity
// stays under the dispatch-level size cap instead of being replaced by an empty stub.
Map<String, Object> cleaned = cleanEntityResponse(entityData);
return applyColumnWindow(cleaned, columnOffset, columnLimit);
}
/**
* Bounds the {@code columns} array so a wide entity (hundreds/thousands of columns) never blows the
* {@link McpResponseTrim#MAX_RESPONSE_CHARS} cap that would otherwise discard the whole payload.
* Entity-level fields are always left intact — only columns are windowed. A client-supplied {@code
* columnLimit}/{@code columnOffset} pages deterministically (opt-in); with no limit, columns are
* auto-capped to the size budget. Non-column entities (no {@code columns} array) pass through
* unchanged, and a small response gains no markers so its shape is byte-identical to before.
*/
@VisibleForTesting
static Map<String, Object> applyColumnWindow(
Map<String, Object> cleaned, int columnOffset, int columnLimit) {
Map<String, Object> result = cleaned;
if (cleaned.get(COLUMNS_KEY) instanceof List<?> columns && !columns.isEmpty()) {
result = windowColumns(cleaned, columns, columnOffset, columnLimit);
}
return result;
}
private static Map<String, Object> windowColumns(
Map<String, Object> cleaned, List<?> columns, int columnOffset, int columnLimit) {
int total = columns.size();
int start = Math.min(columnOffset, total);
int requestedEnd = columnLimit >= 0 ? Math.min(start + columnLimit, total) : total;
int overhead = overheadChars(cleaned);
int end = fitToBudget(overhead, columns, start, requestedEnd);
boolean forcedOversized = end - start == 1 && columnExceedsBudget(overhead, columns.get(start));
cleaned.put(COLUMNS_KEY, new ArrayList<>(columns.subList(start, end)));
annotateWindow(cleaned, total, start, end, forcedOversized);
return cleaned;
}
/**
* True when a single column plus the entity overhead does not fit the column budget. Used to flag
* the force-advanced page: the column is still returned in full (content is never cut), but the
* caller is warned that it may trip the dispatch-level cap so an agent knows to skip past it. The
* durable fix for genuinely un-representable columns is index-backed sub-column paging.
*/
private static boolean columnExceedsBudget(int overhead, Object column) {
long available = (long) (McpResponseTrim.MAX_RESPONSE_CHARS * COLUMN_BUDGET_FACTOR) - overhead;
return McpResponseTrim.serializedLength(column) + 1 > available;
}
/** Serialized length of the response with the columns array excluded. */
private static int overheadChars(Map<String, Object> cleaned) {
Object savedColumns = cleaned.remove(COLUMNS_KEY);
int length = McpResponseTrim.serializedLength(cleaned);
cleaned.put(COLUMNS_KEY, savedColumns);
return length;
}
/**
* Returns the exclusive end index of the largest column window starting at {@code start} whose
* serialized size stays within the column budget. When the entity-level overhead alone already
* exceeds the budget nothing is added and the caller still gets full metadata (better than the
* empty oversized stub). When the overhead leaves room but a single column at {@code start} is
* itself larger than the budget, that one column is emitted anyway so a paging client always
* advances by at least one column instead of re-requesting the same offset forever.
*/
private static int fitToBudget(int overhead, List<?> columns, int start, int end) {
long available = (long) (McpResponseTrim.MAX_RESPONSE_CHARS * COLUMN_BUDGET_FACTOR) - overhead;
long used = 0;
int fitEnd = start;
for (int i = start; i < end && used <= available; i++) {
used += McpResponseTrim.serializedLength(columns.get(i)) + 1;
if (used <= available) {
fitEnd = i + 1;
}
}
boolean singleColumnOverflowsBudget = fitEnd == start && start < end && available > 0;
if (singleColumnOverflowsBudget) {
fitEnd = start + 1;
}
return fitEnd;
}
private static void annotateWindow(
Map<String, Object> cleaned, int total, int start, int end, boolean forcedOversized) {
boolean windowed = start > 0 || end < total || forcedOversized;
if (windowed) {
int returned = end - start;
boolean hasMore = end < total && returned > 0;
cleaned.put(TOTAL_COLUMNS_KEY, total);
cleaned.put(RETURNED_COLUMNS_KEY, returned);
cleaned.put(COLUMN_OFFSET_KEY, start);
cleaned.put(COLUMNS_TRUNCATED_KEY, Boolean.TRUE);
cleaned.put(HAS_MORE_COLUMNS_KEY, hasMore);
cleaned.put(
COLUMNS_MESSAGE_KEY,
columnsMessage(total, start, returned, end, hasMore, forcedOversized));
if (forcedOversized) {
cleaned.put(OVERSIZED_COLUMN_OFFSET_KEY, start);
}
}
}
/**
* Human/LLM-readable window summary. Uses the {@code returnedColumns}/{@code columnOffset} counts
* rather than an inclusive-vs-exclusive index range so it cannot be misread, and only advertises a
* next page when one is actually reachable. When a single over-budget column was force-emitted to
* keep paging moving, warns that the response may hit the size limit and how to skip past it.
*/
private static String columnsMessage(
int total, int start, int returned, int end, boolean hasMore, boolean forcedOversized) {
String message =
String.format(
"Returning %d of %d columns starting at columnOffset %d.", returned, total, start);
if (forcedOversized) {
message +=
String.format(
" The column at columnOffset %d is very large and may exceed the response size limit;"
+ " if this response was replaced by a size-limit notice, skip it with"
+ " columnOffset=%d.",
start, end);
}
if (hasMore) {
message += String.format(" Fetch the next page with columnOffset=%d.", end);
}
return message;
}
/**
* Removes verbose index/noise fields and applies the anti-nuke safety valve to the entity-level
* DDL and dbt model SQL. Column descriptions and the entity-level description are deliberately left
* in full — this is the detail tool, and total response size is bounded by column windowing rather
* than by mangling field content. The map tree comes from a fresh Jackson conversion ({@code
* JsonUtils.getMap}), so in-place edits never touch the cached entity POJO.
*/
@VisibleForTesting
static Map<String, Object> cleanEntityResponse(Map<String, Object> entityData) {
Map<String, Object> cleaned = new HashMap<>();
if (entityData != null) {
cleaned = new HashMap<>(entityData);
EXCLUDE_FIELDS.forEach(cleaned::remove);
McpResponseTrim.VECTOR_NOISE_FIELDS.forEach(cleaned::remove);
trimEntityText(cleaned);
}
return cleaned;
}
/**
* Applies the anti-nuke safety valve to the entity-level DDL and dbt model SQL under a single
* shared budget. schemaDefinition is trimmed first, then dataModel.sql/rawSql draw from whatever
* budget remains, so the three fields combined can never approach the dispatch cap.
*/
private static void trimEntityText(Map<String, Object> entity) {
int remaining = trimSchemaDefinition(entity, SCHEMA_SQL_COMBINED_MAX);
trimDataModelSql(entity, remaining);
}
private static int trimSchemaDefinition(Map<String, Object> entity, int budget) {
int cap = Math.min(SCHEMA_SQL_MAX_LENGTH, budget);
int used = 0;
if (entity.get(SCHEMA_DEFINITION_KEY) instanceof String ddl) {
used = Math.min(ddl.length(), cap);
if (ddl.length() > cap) {
entity.put(SCHEMA_DEFINITION_KEY, McpResponseTrim.truncate(ddl, cap));
entity.put(SCHEMA_DEFINITION_TRUNCATED_KEY, Boolean.TRUE);
}
}
return budget - used;
}
private static void trimDataModelSql(Map<String, Object> entity, int budget) {
if (entity.get(DATA_MODEL_KEY) instanceof Map) {
Map<String, Object> dataModel = castMap(entity.get(DATA_MODEL_KEY));
int remaining = trimSqlField(dataModel, SQL_KEY, budget);
trimSqlField(dataModel, RAW_SQL_KEY, remaining);
}
}
private static int trimSqlField(Map<String, Object> dataModel, String key, int budget) {
int cap = Math.min(SCHEMA_SQL_MAX_LENGTH, budget);
int used = 0;
if (dataModel.get(key) instanceof String sql) {
used = Math.min(sql.length(), cap);
if (sql.length() > cap) {
dataModel.put(key, McpResponseTrim.truncate(sql, cap));
dataModel.put(SQL_TRUNCATED_KEY, Boolean.TRUE);
}
}
return budget - used;
}
@SuppressWarnings("unchecked")
private static Map<String, Object> castMap(Object value) {
return (Map<String, Object>) value;
}
@Override
public Map<String, Object> execute(
Authorizer authorizer,
Limits limits,
CatalogSecurityContext securityContext,
Map<String, Object> params)
throws IOException {
throw new UnsupportedOperationException("GetEntityTool does not requires limit validation.");
}
}
@@ -0,0 +1,109 @@
package org.openmetadata.mcp.tools;
import static org.openmetadata.schema.type.MetadataOperation.VIEW_BASIC;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import lombok.extern.slf4j.Slf4j;
import org.openmetadata.mcp.util.McpParams;
import org.openmetadata.schema.EntityInterface;
import org.openmetadata.schema.type.Include;
import org.openmetadata.service.Entity;
import org.openmetadata.service.aicontext.AIContextBuilder;
import org.openmetadata.service.limits.Limits;
import org.openmetadata.service.search.vector.OpenSearchVectorService;
import org.openmetadata.service.security.Authorizer;
import org.openmetadata.service.security.auth.CatalogSecurityContext;
import org.openmetadata.service.security.policyevaluator.OperationContext;
import org.openmetadata.service.security.policyevaluator.ResourceContext;
/**
* Progressive disclosure for the AI Context Platform. get_asset_context and find_context return
* bounded excerpts of attached knowledge (marked {@code contentTruncated}) so a bundle never
* overwhelms the context window; when the agent needs the detail of one specific item it calls this
* tool with that item's type and fullyQualifiedName. Without a {@code query} it returns the full
* body; with a {@code query} it returns only the most relevant passages of a long article, using
* the dedicated per-chunk embeddings (issue #4789).
*/
@Slf4j
public class GetKnowledgeContentTool implements McpTool {
private static final int DEFAULT_PASSAGES = 3;
private static final int MAX_PASSAGES = 10;
private static final String FORMAT_JSON = "json";
@Override
public Map<String, Object> execute(
Authorizer authorizer, CatalogSecurityContext securityContext, Map<String, Object> params)
throws IOException {
String entityType = (String) params.get("entityType");
String fqn = (String) params.get("fqn");
Map<String, Object> result;
if (entityType == null || entityType.isBlank() || fqn == null || fqn.isBlank()) {
result = Map.of("error", "'entityType' and 'fqn' parameters are required");
} else {
authorizer.authorize(
securityContext,
new OperationContext(entityType, VIEW_BASIC),
new ResourceContext<>(entityType, null, fqn));
result = resolveContent(entityType, fqn, params);
}
return result;
}
private Map<String, Object> resolveContent(
String entityType, String fqn, Map<String, Object> params) {
EntityInterface entity = Entity.getEntityByName(entityType, fqn, "", Include.NON_DELETED);
String query = (String) params.get("query");
String format = (String) params.getOrDefault("format", "markdown");
Map<String, Object> result;
if (query != null && !query.isBlank() && vectorSearchEnabled()) {
int size =
Math.min(Math.max(McpParams.getInt(params, "size", DEFAULT_PASSAGES), 1), MAX_PASSAGES);
List<String> passages =
OpenSearchVectorService.getInstance()
.searchChunksByParent(entity.getId().toString(), query, size);
result = renderPassages(fqn, passages, format);
} else {
result = renderFull(fqn, AIContextBuilder.fullContentOf(entity), format);
}
return result;
}
private boolean vectorSearchEnabled() {
return Entity.getSearchRepository().isVectorEmbeddingEnabled()
&& OpenSearchVectorService.getInstance() != null;
}
private Map<String, Object> renderFull(String fqn, String content, String format) {
String body = content == null ? "" : content;
Map<String, Object> result;
if (FORMAT_JSON.equalsIgnoreCase(format)) {
result = Map.of("fullyQualifiedName", fqn, "content", body);
} else {
result = Map.of("format", "markdown", "content", body);
}
return result;
}
private Map<String, Object> renderPassages(String fqn, List<String> passages, String format) {
Map<String, Object> result;
if (FORMAT_JSON.equalsIgnoreCase(format)) {
result = Map.of("fullyQualifiedName", fqn, "passages", passages);
} else {
result = Map.of("format", "markdown", "content", String.join("\n\n---\n\n", passages));
}
return result;
}
@Override
public Map<String, Object> execute(
Authorizer authorizer,
Limits limits,
CatalogSecurityContext securityContext,
Map<String, Object> params)
throws IOException {
throw new UnsupportedOperationException(
"GetKnowledgeContentTool does not require limit validation.");
}
}
@@ -0,0 +1,325 @@
package org.openmetadata.mcp.tools;
import static org.openmetadata.common.utils.CommonUtil.nullOrEmpty;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.google.common.annotations.VisibleForTesting;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import lombok.extern.slf4j.Slf4j;
import org.openmetadata.mcp.util.McpParams;
import org.openmetadata.mcp.util.McpResponseTrim;
import org.openmetadata.mcp.util.ResponseBudget;
import org.openmetadata.schema.type.ColumnLineage;
import org.openmetadata.schema.type.Edge;
import org.openmetadata.schema.type.EntityLineage;
import org.openmetadata.schema.type.EntityReference;
import org.openmetadata.schema.type.LineageDetails;
import org.openmetadata.schema.type.MetadataOperation;
import org.openmetadata.schema.type.TempLineageTable;
import org.openmetadata.schema.utils.JsonUtils;
import org.openmetadata.service.Entity;
import org.openmetadata.service.limits.Limits;
import org.openmetadata.service.security.Authorizer;
import org.openmetadata.service.security.auth.CatalogSecurityContext;
import org.openmetadata.service.security.policyevaluator.OperationContext;
import org.openmetadata.service.security.policyevaluator.ResourceContext;
/**
* Returns a compact, LLM-friendly lineage graph. The raw {@link EntityLineage} from the repository
* is intentionally verbose (full SQL, column-level mappings, node descriptions) and can reach
* hundreds of KB for even a couple of nodes. This tool slims it to identity + relationship info,
* folding node details into edge endpoints. Column lineage and full SQL are dropped by default and
* only surfaced on request, keeping the default response table-level. All slimming happens here in
* the tool — the repository and its UI/RCA callers are untouched.
*/
@Slf4j
public class GetLineageTool implements McpTool {
// Defaults matching ai-platform GetLineageTool.kt for consistency
private static final int DEFAULT_DEPTH = 3;
// Maximum depth to prevent exponential response growth (lineage graphs can explode)
private static final int MAX_DEPTH = 10;
private static final String RELATIONSHIP_SQL = "sql";
@JsonInclude(JsonInclude.Include.NON_NULL)
record SlimEdge(
String fromFQN,
String toFQN,
String fromName,
String toName,
String fromType,
String toType,
String relationshipType,
String pipelineFQN,
String pipelineDescription,
String edgeDescription,
String source,
Integer assetEdges,
String sqlQuery,
Boolean sqlTruncated,
List<TempLineageTable> tempLineageTables,
Long updatedAt,
String updatedBy,
List<ColumnLineage> columnsLineage) {}
@JsonInclude(JsonInclude.Include.NON_NULL)
record SlimLineage(
String root,
String rootId,
String rootType,
List<SlimEdge> upstream,
List<SlimEdge> downstream) {}
private record SqlText(String value, Boolean truncated) {}
@Override
public Map<String, Object> execute(
Authorizer authorizer, CatalogSecurityContext securityContext, Map<String, Object> params)
throws IOException {
validateParams(params);
String entityType = (String) params.get("entityType");
String fqn = (String) params.get("fqn");
authorizer.authorize(
securityContext,
new OperationContext(entityType, MetadataOperation.VIEW_BASIC),
new ResourceContext<>(entityType));
int upstreamDepth = clampDepth(McpParams.getInt(params, "upstreamDepth", DEFAULT_DEPTH));
int downstreamDepth = clampDepth(McpParams.getInt(params, "downstreamDepth", DEFAULT_DEPTH));
boolean includeColumnLineage = McpParams.getBoolean(params, "includeColumnLineage", false);
LOG.info(
"Getting lineage for entity type: {}, FQN: {}, upstreamDepth: {}, downstreamDepth: {}, "
+ "includeColumnLineage: {}",
entityType,
fqn,
upstreamDepth,
downstreamDepth,
includeColumnLineage);
EntityLineage lineage =
Entity.getLineageRepository().getByName(entityType, fqn, upstreamDepth, downstreamDepth);
return enforceSizeBudget(toSlim(lineage, includeColumnLineage));
}
private static void validateParams(Map<String, Object> params) {
if (nullOrEmpty(params)) {
throw new IllegalArgumentException("Parameters cannot be null or empty");
}
String entityType = (String) params.get("entityType");
String fqn = (String) params.get("fqn");
if (nullOrEmpty(entityType) || nullOrEmpty(fqn)) {
throw new IllegalArgumentException("Parameters 'entityType' and 'fqn' are required");
}
}
@VisibleForTesting
static SlimLineage toSlim(EntityLineage lineage, boolean includeColumnLineage) {
Map<UUID, EntityReference> nodeIndex = buildNodeIndex(lineage);
List<SlimEdge> upstream =
slimEdges(lineage.getUpstreamEdges(), nodeIndex, includeColumnLineage);
List<SlimEdge> downstream =
slimEdges(lineage.getDownstreamEdges(), nodeIndex, includeColumnLineage);
EntityReference root = lineage.getEntity();
return new SlimLineage(
refFqn(root),
root != null && root.getId() != null ? root.getId().toString() : null,
refType(root),
upstream,
downstream);
}
private static Map<UUID, EntityReference> buildNodeIndex(EntityLineage lineage) {
Map<UUID, EntityReference> index = new HashMap<>();
if (lineage.getEntity() != null) {
index.put(lineage.getEntity().getId(), lineage.getEntity());
}
if (!nullOrEmpty(lineage.getNodes())) {
lineage.getNodes().forEach(node -> index.put(node.getId(), node));
}
return index;
}
private static List<SlimEdge> slimEdges(
List<Edge> edges, Map<UUID, EntityReference> nodeIndex, boolean includeColumnLineage) {
// The repository dedups nodes but not edges: a node reachable via multiple paths has its
// upstream/downstream edges re-added on each recursion. Identical slim edges carry no extra
// information, so collapse them with a LinkedHashSet (record equality), preserving order.
Set<SlimEdge> deduped = new LinkedHashSet<>();
if (!nullOrEmpty(edges)) {
edges.forEach(edge -> deduped.add(buildSlimEdge(edge, nodeIndex, includeColumnLineage)));
}
return new ArrayList<>(deduped);
}
private static SlimEdge buildSlimEdge(
Edge edge, Map<UUID, EntityReference> nodeIndex, boolean includeColumns) {
// computeLineage adds every edge endpoint to nodes (or it is the root), so nodeIndex
// resolves both ends. If that invariant ever breaks (a partial/cached graph), the endpoint
// fields come back null and identical anonymous edges dedup-collapse — warn instead of
// silently emitting a linkless edge.
EntityReference from = nodeIndex.get(edge.getFromEntity());
EntityReference to = nodeIndex.get(edge.getToEntity());
if (from == null || to == null) {
LOG.warn(
"Lineage edge endpoint missing from node index (from={}, to={}); emitting partial edge",
edge.getFromEntity(),
edge.getToEntity());
}
LineageDetails details = edge.getLineageDetails();
EntityReference pipeline = details != null ? details.getPipeline() : null;
SqlText sql = fullSqlQuery(details);
return new SlimEdge(
refFqn(from),
refFqn(to),
refName(from),
refName(to),
refType(from),
refType(to),
relationshipType(pipeline),
pipeline != null ? pipeline.getFullyQualifiedName() : null,
pipeline != null ? pipeline.getDescription() : null,
details != null ? details.getDescription() : null,
sourceValue(details),
details != null ? details.getAssetEdges() : null,
sql.value(),
sql.truncated(),
details != null ? details.getTempLineageTables() : null,
details != null ? details.getUpdatedAt() : null,
details != null ? details.getUpdatedBy() : null,
columnsLineageOf(details, includeColumns));
}
private static List<ColumnLineage> columnsLineageOf(
LineageDetails details, boolean includeColumns) {
List<ColumnLineage> columns = null;
if (includeColumns && details != null && !nullOrEmpty(details.getColumnsLineage())) {
columns = details.getColumnsLineage();
}
return columns;
}
private static String relationshipType(EntityReference pipeline) {
return pipeline != null ? pipeline.getType() + ":" + pipeline.getName() : RELATIONSHIP_SQL;
}
private static String sourceValue(LineageDetails details) {
return details != null && details.getSource() != null ? details.getSource().value() : null;
}
/**
* Edge SQL is returned in full. Size is controlled by returning fewer edges (see {@link
* #enforceSizeBudget}), never by cutting the transformation SQL, which is exactly the metadata a
* lineage caller needs. The {@code sqlTruncated} marker stays in the record for wire compatibility
* and is always null now.
*/
private static SqlText fullSqlQuery(LineageDetails details) {
String sql = details != null ? details.getSqlQuery() : null;
return new SqlText(sql, null);
}
private static String refFqn(EntityReference ref) {
return ref != null ? ref.getFullyQualifiedName() : null;
}
private static String refType(EntityReference ref) {
return ref != null ? ref.getType() : null;
}
private static String refName(EntityReference ref) {
String name = null;
if (ref != null) {
name = ref.getDisplayName() != null ? ref.getDisplayName() : ref.getName();
}
return name;
}
/**
* Keeps the response under the dispatch-level cap by returning fewer <em>edges</em>, never by
* dropping the whole graph to a bare count or by cutting an edge's SQL. When everything fits (the
* common case, including full edge SQL) the complete graph is returned unchanged. When it does not,
* the size budget is split fairly between the two directions so both upstream and downstream stay
* represented, and per-direction markers tell the caller how many edges were withheld.
*/
@VisibleForTesting
static Map<String, Object> enforceSizeBudget(SlimLineage slim) {
Map<String, Object> full = JsonUtils.getMap(slim);
Map<String, Object> result = full;
if (McpResponseTrim.serializedLength(full) > McpResponseTrim.MAX_RESPONSE_CHARS) {
result = fitGraphToBudget(slim);
}
return result;
}
private static Map<String, Object> fitGraphToBudget(SlimLineage slim) {
long overhead = graphOverheadChars(slim);
long available = Math.max(0, ResponseBudget.defaultBudgetChars() - overhead);
long halfShare = available / 2;
ResponseBudget.Fit up = ResponseBudget.fitWithin(slim.upstream(), halfShare);
ResponseBudget.Fit down =
ResponseBudget.fitWithin(slim.downstream(), available - up.usedChars());
boolean downstreamLeftRoom =
down.usedChars() < halfShare && up.count() < slim.upstream().size();
if (downstreamLeftRoom) {
up = ResponseBudget.fitWithin(slim.upstream(), available - down.usedChars());
}
return buildFittedGraph(slim, up.count(), down.count());
}
/** Serialized size of the graph shell (root identity + empty edge lists), the fixed overhead. */
private static long graphOverheadChars(SlimLineage slim) {
SlimLineage shell =
new SlimLineage(slim.root(), slim.rootId(), slim.rootType(), List.of(), List.of());
return McpResponseTrim.serializedLength(JsonUtils.getMap(shell));
}
private static Map<String, Object> buildFittedGraph(
SlimLineage slim, int upCount, int downCount) {
List<SlimEdge> up = slim.upstream().subList(0, upCount);
List<SlimEdge> down = slim.downstream().subList(0, downCount);
Map<String, Object> result =
JsonUtils.getMap(
new SlimLineage(
slim.root(),
slim.rootId(),
slim.rootType(),
new ArrayList<>(up),
new ArrayList<>(down)));
result.put("truncated", Boolean.TRUE);
result.put("upstreamReturned", upCount);
result.put("upstreamTotal", slim.upstream().size());
result.put("downstreamReturned", downCount);
result.put("downstreamTotal", slim.downstream().size());
result.put(
"message",
String.format(
"Lineage graph is large: returning %d of %d upstream and %d of %d downstream edges to"
+ " stay within the response size budget. Reduce upstreamDepth/downstreamDepth to"
+ " narrow the graph.",
upCount, slim.upstream().size(), downCount, slim.downstream().size()));
return result;
}
/**
* Clamps a requested depth into {@code [1, MAX_DEPTH]} to prevent excessive response sizes that
* could overwhelm LLM context. Parsing is delegated to {@link McpParams}; the valid range is
* specific to this tool, so the clamp stays here.
*/
private static int clampDepth(int depth) {
return Math.min(Math.max(depth, 1), MAX_DEPTH);
}
@Override
public Map<String, Object> execute(
Authorizer authorizer,
Limits limits,
CatalogSecurityContext securityContext,
Map<String, Object> params)
throws IOException {
throw new UnsupportedOperationException("GetLineageTool does not support limits enforcement.");
}
}
@@ -0,0 +1,145 @@
/*
* Copyright 2026 Collate
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.openmetadata.mcp.tools;
import java.io.IOException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.openmetadata.mcp.util.McpResponseTrim;
import org.openmetadata.schema.entity.teams.Persona;
import org.openmetadata.schema.type.Include;
import org.openmetadata.schema.utils.JsonUtils;
import org.openmetadata.service.Entity;
import org.openmetadata.service.aicontext.PersonaContextAccess;
import org.openmetadata.service.aicontext.PersonaContextBuilder;
import org.openmetadata.service.aicontext.PersonaContextCache;
import org.openmetadata.service.limits.Limits;
import org.openmetadata.service.security.Authorizer;
import org.openmetadata.service.security.auth.CatalogSecurityContext;
/** Returns one deterministic, line-bounded part of a persona's shared AI context document. */
public class GetPersonaContextTool implements McpTool {
private static final int PART_RESPONSE_BUDGET = McpResponseTrim.MAX_RESPONSE_CHARS - 10_000;
@Override
public Map<String, Object> execute(
Authorizer authorizer, CatalogSecurityContext securityContext, Map<String, Object> params)
throws IOException {
Persona persona = resolvePersona(securityContext, stringParam(params, "personaName"));
PersonaContextAccess.authorize(securityContext, persona);
PersonaContextBuilder.MaterializedPersonaContext materialized =
PersonaContextCache.getInstance().get(persona, false).value();
String format = stringParam(params, "format");
String content =
"json".equalsIgnoreCase(format)
? JsonUtils.pojoToJson(materialized.context())
: materialized.markdown();
List<String> parts = split(content);
int requestedPart = intParam(params.get("part"), 1);
if (requestedPart < 1 || requestedPart > parts.size()) {
return Map.of(
McpResponseTrim.ERROR_KEY,
"part must be between 1 and " + parts.size(),
McpResponseTrim.STATUS_CODE_KEY,
400);
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("format", "json".equalsIgnoreCase(format) ? "json" : "markdown");
result.put("content", parts.get(requestedPart - 1));
result.put("part", requestedPart);
result.put("totalParts", parts.size());
result.put(McpResponseTrim.HAS_MORE_KEY, requestedPart < parts.size());
result.put("fingerprint", materialized.context().getFingerprint());
return result;
}
@Override
public Map<String, Object> execute(
Authorizer authorizer,
Limits limits,
CatalogSecurityContext securityContext,
Map<String, Object> params)
throws IOException {
throw new UnsupportedOperationException(
"GetPersonaContextTool does not require limit validation.");
}
private static Persona resolvePersona(
CatalogSecurityContext securityContext, String personaName) {
return personaName == null || personaName.isBlank()
? PersonaContextAccess.activePersona(securityContext)
: Entity.getEntityByName(
Entity.PERSONA, personaName, "contextDefinition,users", Include.NON_DELETED);
}
static List<String> split(String content) {
List<String> parts = new ArrayList<>();
if (content == null || content.isEmpty()) {
return List.of("");
}
int start = 0;
while (start < content.length()) {
int candidateEnd = largestSerializableEnd(content, start);
int end = candidateEnd;
if (candidateEnd < content.length()) {
int lineEnd = content.lastIndexOf('\n', candidateEnd);
if (lineEnd > start) {
end = lineEnd + 1;
}
}
parts.add(content.substring(start, end));
start = end;
}
return parts;
}
private static int largestSerializableEnd(String content, int start) {
int low = start + 1;
int high = content.length();
int result = low;
while (low <= high) {
int midpoint = low + (high - low) / 2;
int serializedLength =
McpResponseTrim.serializedLength(Map.of("content", content.substring(start, midpoint)));
if (serializedLength <= PART_RESPONSE_BUDGET) {
result = midpoint;
low = midpoint + 1;
} else {
high = midpoint - 1;
}
}
return result;
}
private static String stringParam(Map<String, Object> params, String key) {
Object value = params.get(key);
return value == null ? null : String.valueOf(value);
}
private static int intParam(Object value, int defaultValue) {
if (value instanceof Number number) {
return number.intValue();
}
if (value != null) {
try {
return Integer.parseInt(String.valueOf(value));
} catch (NumberFormatException ignored) {
return -1;
}
}
return defaultValue;
}
}
@@ -0,0 +1,71 @@
package org.openmetadata.mcp.tools;
import java.util.Map;
import lombok.extern.slf4j.Slf4j;
import org.openmetadata.schema.entity.data.GlossaryTerm;
import org.openmetadata.schema.type.MetadataOperation;
import org.openmetadata.service.Entity;
import org.openmetadata.service.jdbi3.GlossaryTermRepository;
import org.openmetadata.service.limits.Limits;
import org.openmetadata.service.resources.glossary.GlossaryTermMapper;
import org.openmetadata.service.security.Authorizer;
import org.openmetadata.service.security.ImpersonationContext;
import org.openmetadata.service.security.auth.CatalogSecurityContext;
import org.openmetadata.service.security.policyevaluator.CreateResourceContext;
import org.openmetadata.service.security.policyevaluator.OperationContext;
import org.openmetadata.service.util.RestUtil;
@Slf4j
public class GlossaryTermTool implements McpTool {
private static GlossaryTermMapper glossaryTermMapper = new GlossaryTermMapper();
@Override
public Map<String, Object> execute(
Authorizer authorizer, CatalogSecurityContext securityContext, Map<String, Object> params) {
throw new UnsupportedOperationException("GlossaryTermTool requires limit validation.");
}
@Override
public Map<String, Object> execute(
Authorizer authorizer,
Limits limits,
CatalogSecurityContext securityContext,
Map<String, Object> params) {
org.openmetadata.schema.api.data.CreateGlossaryTerm createGlossaryTerm =
new org.openmetadata.schema.api.data.CreateGlossaryTerm();
createGlossaryTerm.setName((String) params.get("name"));
createGlossaryTerm.setGlossary((String) params.get("glossary"));
createGlossaryTerm.setParent((String) params.get("parentTerm"));
createGlossaryTerm.setDescription((String) params.get("description"));
if (params.containsKey("owners")) {
CommonUtils.setOwners(createGlossaryTerm, params);
}
if (params.containsKey("reviewers")) {
createGlossaryTerm.setReviewers(CommonUtils.getTeamsOrUsers(params.get("reviewers")));
}
GlossaryTerm glossaryTerm =
glossaryTermMapper.createToEntity(
createGlossaryTerm, securityContext.getUserPrincipal().getName());
// Validate If the User Can Perform the Create Operation
OperationContext operationContext =
new OperationContext(Entity.GLOSSARY_TERM, MetadataOperation.CREATE);
CreateResourceContext<GlossaryTerm> createResourceContext =
new CreateResourceContext<>(Entity.GLOSSARY_TERM, glossaryTerm);
limits.enforceLimits(securityContext, createResourceContext, operationContext);
authorizer.authorize(securityContext, operationContext, createResourceContext);
GlossaryTermRepository glossaryTermRepository =
(GlossaryTermRepository) Entity.getEntityRepository(Entity.GLOSSARY_TERM);
glossaryTermRepository.prepareInternal(glossaryTerm, false);
String impersonatedBy = ImpersonationContext.getImpersonatedBy();
String userName = securityContext.getUserPrincipal().getName();
RestUtil.PutResponse<GlossaryTerm> response =
glossaryTermRepository.createOrUpdate(null, glossaryTerm, userName, impersonatedBy);
McpChangeEventUtil.publishChangeEvent(response.getEntity(), response.getChangeType(), userName);
return McpResponseUtils.compact(response.getEntity(), response.getChangeType());
}
}
@@ -0,0 +1,86 @@
package org.openmetadata.mcp.tools;
import java.util.List;
import java.util.Map;
import lombok.extern.slf4j.Slf4j;
import org.openmetadata.schema.api.data.CreateGlossary;
import org.openmetadata.schema.entity.data.Glossary;
import org.openmetadata.schema.type.EntityReference;
import org.openmetadata.schema.type.MetadataOperation;
import org.openmetadata.service.Entity;
import org.openmetadata.service.jdbi3.GlossaryRepository;
import org.openmetadata.service.limits.Limits;
import org.openmetadata.service.resources.glossary.GlossaryMapper;
import org.openmetadata.service.security.Authorizer;
import org.openmetadata.service.security.ImpersonationContext;
import org.openmetadata.service.security.auth.CatalogSecurityContext;
import org.openmetadata.service.security.policyevaluator.CreateResourceContext;
import org.openmetadata.service.security.policyevaluator.OperationContext;
import org.openmetadata.service.util.RestUtil;
@Slf4j
public class GlossaryTool implements McpTool {
private static GlossaryMapper glossaryMapper = new GlossaryMapper();
@Override
public Map<String, Object> execute(
Authorizer authorizer, CatalogSecurityContext securityContext, Map<String, Object> params) {
throw new UnsupportedOperationException("GlossaryTool requires limit validation.");
}
@Override
public Map<String, Object> execute(
Authorizer authorizer,
Limits limits,
CatalogSecurityContext securityContext,
Map<String, Object> params) {
CreateGlossary createGlossary = new CreateGlossary();
createGlossary.setName((String) params.get("name"));
createGlossary.setDescription((String) params.get("description"));
if (params.containsKey("owners")) {
CommonUtils.setOwners(createGlossary, params);
}
if (params.containsKey("reviewers")) {
setReviewers(createGlossary, params);
}
if (params.containsKey("mutuallyExclusive")) {
Object meObj = params.get("mutuallyExclusive");
if (meObj instanceof Boolean b) {
createGlossary.setMutuallyExclusive(b);
} else if (meObj instanceof String s) {
createGlossary.setMutuallyExclusive("true".equalsIgnoreCase(s));
}
}
Glossary glossary =
glossaryMapper.createToEntity(createGlossary, securityContext.getUserPrincipal().getName());
// Validate If the User Can Perform the Create Operation
OperationContext operationContext =
new OperationContext(Entity.GLOSSARY, MetadataOperation.CREATE);
CreateResourceContext<Glossary> createResourceContext =
new CreateResourceContext<>(Entity.GLOSSARY, glossary);
limits.enforceLimits(securityContext, createResourceContext, operationContext);
authorizer.authorize(securityContext, operationContext, createResourceContext);
GlossaryRepository glossaryRepository =
(GlossaryRepository) Entity.getEntityRepository(Entity.GLOSSARY);
glossaryRepository.prepareInternal(glossary, false);
String impersonatedBy = ImpersonationContext.getImpersonatedBy();
String userName = securityContext.getUserPrincipal().getName();
RestUtil.PutResponse<Glossary> response =
glossaryRepository.createOrUpdate(null, glossary, userName, impersonatedBy);
McpChangeEventUtil.publishChangeEvent(response.getEntity(), response.getChangeType(), userName);
return McpResponseUtils.compact(response.getEntity(), response.getChangeType());
}
public static void setReviewers(CreateGlossary entity, Map<String, Object> params) {
List<EntityReference> reviewers = CommonUtils.getTeamsOrUsers(params.get("reviewers"));
if (!reviewers.isEmpty()) {
entity.setReviewers(reviewers);
}
}
}
@@ -0,0 +1,72 @@
package org.openmetadata.mcp.tools;
import java.io.IOException;
import java.util.Map;
import lombok.extern.slf4j.Slf4j;
import org.openmetadata.schema.api.lineage.AddLineage;
import org.openmetadata.schema.type.EntitiesEdge;
import org.openmetadata.schema.type.EntityReference;
import org.openmetadata.schema.type.MetadataOperation;
import org.openmetadata.schema.utils.JsonUtils;
import org.openmetadata.service.Entity;
import org.openmetadata.service.limits.Limits;
import org.openmetadata.service.security.Authorizer;
import org.openmetadata.service.security.auth.CatalogSecurityContext;
import org.openmetadata.service.security.policyevaluator.OperationContext;
import org.openmetadata.service.security.policyevaluator.ResourceContext;
@Slf4j
public class LineageTool implements McpTool {
@Override
public Map<String, Object> execute(
Authorizer authorizer,
CatalogSecurityContext catalogSecurityContext,
Map<String, Object> params) {
EntityReference fromEntity =
JsonUtils.convertValue(params.get("fromEntity"), EntityReference.class);
EntityReference toEntity =
JsonUtils.convertValue(params.get("toEntity"), EntityReference.class);
if (fromEntity == null || fromEntity.getType() == null || fromEntity.getId() == null) {
throw new IllegalArgumentException(
"Parameter 'fromEntity' is required and must include 'type' and 'id'");
}
if (toEntity == null || toEntity.getType() == null || toEntity.getId() == null) {
throw new IllegalArgumentException(
"Parameter 'toEntity' is required and must include 'type' and 'id'");
}
authorizer.authorize(
catalogSecurityContext,
new OperationContext(fromEntity.getType(), MetadataOperation.EDIT_LINEAGE),
new ResourceContext<>(fromEntity.getType(), fromEntity.getId(), fromEntity.getName()));
authorizer.authorize(
catalogSecurityContext,
new OperationContext(toEntity.getType(), MetadataOperation.EDIT_LINEAGE),
new ResourceContext<>(toEntity.getType(), toEntity.getId(), toEntity.getName()));
LOG.info(
"Creating lineage edge from {}.{} to {}.{}",
fromEntity.getType(),
fromEntity.getName(),
toEntity.getType(),
toEntity.getName());
AddLineage lineage =
new AddLineage()
.withEdge(new EntitiesEdge().withFromEntity(fromEntity).withToEntity(toEntity));
String updatedBy = catalogSecurityContext.getUserPrincipal().getName();
Entity.getLineageRepository().addLineage(lineage, updatedBy);
return Map.of("result", "Lineage Edge created successfully");
}
@Override
public Map<String, Object> execute(
Authorizer authorizer,
Limits limits,
CatalogSecurityContext catalogSecurityContext,
Map<String, Object> map)
throws IOException {
throw new UnsupportedOperationException("LineageTool does not require limit validation.");
}
}
@@ -0,0 +1,44 @@
package org.openmetadata.mcp.tools;
import lombok.extern.slf4j.Slf4j;
import org.openmetadata.schema.EntityInterface;
import org.openmetadata.schema.type.ChangeEvent;
import org.openmetadata.schema.type.EventType;
import org.openmetadata.schema.utils.JsonUtils;
import org.openmetadata.service.Entity;
import org.openmetadata.service.formatter.util.FormatterUtil;
@Slf4j
public final class McpChangeEventUtil {
private McpChangeEventUtil() {}
public static <T extends EntityInterface> void publishChangeEvent(
T entity, EventType changeType, String userName) {
if (entity == null || changeType == null || changeType.equals(EventType.ENTITY_NO_CHANGE)) {
return;
}
try {
ChangeEvent changeEvent =
FormatterUtil.createChangeEventForEntity(userName, changeType, entity);
changeEvent.setUserName(userName);
if (changeEvent.getEntity() != null) {
Object rawEntity = changeEvent.getEntity();
ChangeEvent copy =
org.openmetadata.service.events.ChangeEventHandler.copyChangeEvent(changeEvent);
copy.setEntity(JsonUtils.pojoToMaskedJson(rawEntity));
Entity.getCollectionDAO().changeEventDAO().insert(JsonUtils.pojoToJson(copy));
} else {
Entity.getCollectionDAO().changeEventDAO().insert(JsonUtils.pojoToJson(changeEvent));
}
LOG.debug(
"Published MCP change event {}:{}:{}",
changeEvent.getEntityId(),
changeEvent.getEventType(),
changeEvent.getEntityType());
} catch (Exception e) {
LOG.error("Failed to publish MCP change event for {}", entity.getId(), e);
}
}
}
@@ -0,0 +1,37 @@
package org.openmetadata.mcp.tools;
import java.util.Map;
import java.util.Set;
import org.openmetadata.schema.EntityInterface;
import org.openmetadata.schema.type.EventType;
import org.openmetadata.schema.utils.JsonUtils;
public final class McpResponseUtils {
private static final Set<String> NOISE_FIELDS =
Set.of(
"version",
"updatedAt",
"updatedBy",
"changeDescription",
"incrementalChangeDescription",
"followers",
"votes",
"sourceHash");
private static final String CREATED = "created";
private static final String UPDATED = "updated";
private static final String OPERATION_KEY = "_operation";
private static final String DELETED_KEY = "deleted";
private McpResponseUtils() {}
public static Map<String, Object> compact(EntityInterface entity, EventType changeType) {
Map<String, Object> doc = JsonUtils.getMap(entity);
NOISE_FIELDS.forEach(doc::remove);
if (Boolean.FALSE.equals(doc.get(DELETED_KEY))) {
doc.remove(DELETED_KEY);
}
doc.put(OPERATION_KEY, EventType.ENTITY_CREATED.equals(changeType) ? CREATED : UPDATED);
return doc;
}
}
@@ -0,0 +1,20 @@
package org.openmetadata.mcp.tools;
import java.io.IOException;
import java.util.Map;
import org.openmetadata.service.limits.Limits;
import org.openmetadata.service.security.Authorizer;
import org.openmetadata.service.security.auth.CatalogSecurityContext;
public interface McpTool {
Map<String, Object> execute(
Authorizer authorizer, CatalogSecurityContext securityContext, Map<String, Object> params)
throws IOException;
Map<String, Object> execute(
Authorizer authorizer,
Limits limits,
CatalogSecurityContext securityContext,
Map<String, Object> params)
throws IOException;
}
@@ -0,0 +1,62 @@
package org.openmetadata.mcp.tools;
import static org.openmetadata.common.utils.CommonUtil.nullOrEmpty;
import jakarta.json.Json;
import jakarta.json.JsonArray;
import jakarta.json.JsonPatch;
import java.io.StringReader;
import java.util.Map;
import lombok.extern.slf4j.Slf4j;
import org.openmetadata.schema.EntityInterface;
import org.openmetadata.schema.type.change.ChangeSource;
import org.openmetadata.schema.utils.JsonUtils;
import org.openmetadata.service.Entity;
import org.openmetadata.service.jdbi3.EntityRepository;
import org.openmetadata.service.limits.Limits;
import org.openmetadata.service.security.Authorizer;
import org.openmetadata.service.security.ImpersonationContext;
import org.openmetadata.service.security.auth.CatalogSecurityContext;
import org.openmetadata.service.security.policyevaluator.OperationContext;
import org.openmetadata.service.security.policyevaluator.ResourceContext;
import org.openmetadata.service.util.RestUtil;
@Slf4j
public class PatchEntityTool implements McpTool {
@Override
public Map<String, Object> execute(
Authorizer authorizer, CatalogSecurityContext securityContext, Map<String, Object> params) {
String entityType = (String) params.get("entityType");
String fqn = (String) params.get("fqn");
String jsonPatchString = (String) params.get("patch");
if (nullOrEmpty(jsonPatchString)) {
throw new IllegalArgumentException("Patch cannot be null or empty");
}
JsonArray patchArray = Json.createReader(new StringReader(jsonPatchString)).readArray();
JsonPatch jsonPatch = Json.createPatch(patchArray);
// Validate If the User Can Perform the Patch Operation
OperationContext operationContext = new OperationContext(entityType, jsonPatch);
authorizer.authorize(
securityContext, operationContext, new ResourceContext<>(entityType, null, fqn));
EntityRepository<? extends EntityInterface> repository = Entity.getEntityRepository(entityType);
String userName = securityContext.getUserPrincipal().getName();
String impersonatedBy = ImpersonationContext.getImpersonatedBy();
RestUtil.PatchResponse<? extends EntityInterface> response =
repository.patch(null, fqn, userName, jsonPatch, ChangeSource.MANUAL, null, impersonatedBy);
McpChangeEventUtil.publishChangeEvent(response.entity(), response.changeType(), userName);
return JsonUtils.convertValue(response, Map.class);
}
@Override
public Map<String, Object> execute(
Authorizer authorizer,
Limits limits,
CatalogSecurityContext securityContext,
Map<String, Object> params) {
throw new UnsupportedOperationException("PatchEntityTool does not support limits enforcement.");
}
}
@@ -0,0 +1,535 @@
package org.openmetadata.mcp.tools;
import static org.openmetadata.mcp.tools.SearchMetadataTool.cleanSearchResponseObject;
import static org.openmetadata.service.search.SearchUtils.isConnectedVia;
import com.google.common.annotations.VisibleForTesting;
import jakarta.ws.rs.core.Response;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import lombok.extern.slf4j.Slf4j;
import org.openmetadata.mcp.util.McpParams;
import org.openmetadata.mcp.util.McpResponseTrim;
import org.openmetadata.mcp.util.ResponseBudget;
import org.openmetadata.schema.api.lineage.LineageDirection;
import org.openmetadata.schema.api.lineage.SearchLineageRequest;
import org.openmetadata.schema.api.lineage.SearchLineageResult;
import org.openmetadata.schema.tests.type.TestCaseResult;
import org.openmetadata.schema.type.MetadataOperation;
import org.openmetadata.schema.utils.JsonUtils;
import org.openmetadata.schema.utils.ResultList;
import org.openmetadata.service.Entity;
import org.openmetadata.service.jdbi3.TestCaseResultRepository;
import org.openmetadata.service.limits.Limits;
import org.openmetadata.service.search.SearchListFilter;
import org.openmetadata.service.security.Authorizer;
import org.openmetadata.service.security.auth.CatalogSecurityContext;
import org.openmetadata.service.security.policyevaluator.OperationContext;
import org.openmetadata.service.security.policyevaluator.ResourceContext;
@Slf4j
public class RootCauseAnalysisTool implements McpTool {
private static final int DEFAULT_DEPTH = 3;
private static final int MAX_DEPTH = 10;
// Slimming budgets come from McpResponseTrim so RCA's lineage-derived payload stays within
// LLM/MCP context limits. The backend (searchDataQualityLineage / searchLineageWithDirection)
// is shared with the UI LineageResource and is never touched — we only transform the
// in-memory result before returning it to the MCP client.
private static final String RELATIONSHIP_SQL = "sql";
@Override
public Map<String, Object> execute(
Authorizer authorizer,
CatalogSecurityContext securityContext,
Map<String, Object> parameters) {
String fqn = (String) parameters.get("fqn");
String entityType = (String) parameters.getOrDefault("entityType", "table");
int upstreamDepth = clampDepth(McpParams.getInt(parameters, "upstreamDepth", DEFAULT_DEPTH));
int downstreamDepth =
clampDepth(McpParams.getInt(parameters, "downstreamDepth", DEFAULT_DEPTH));
String queryFilter = (String) parameters.get("queryFilter");
boolean includeDeleted = McpParams.getBoolean(parameters, "includeDeleted", false);
boolean includeColumns = McpParams.getBoolean(parameters, "includeColumnLineage", false);
if (fqn == null || fqn.trim().isEmpty()) {
throw new IllegalArgumentException("Parameter 'fqn' is required and cannot be empty");
}
authorizer.authorize(
securityContext,
new OperationContext(entityType, MetadataOperation.VIEW_BASIC),
new ResourceContext<>(entityType));
RcaRequest request =
new RcaRequest(
fqn.trim(),
entityType,
upstreamDepth,
downstreamDepth,
queryFilter,
includeDeleted,
includeColumns);
try {
return analyze(request);
} catch (IOException e) {
LOG.error("IOException during root cause analysis for entity: {}", fqn, e);
throw new RuntimeException(
"Failed to perform root cause analysis: " + McpResponseTrim.safeMessage(e), e);
} catch (Exception e) {
LOG.error("Unexpected error during root cause analysis for entity: {}", fqn, e);
throw new RuntimeException(
"Unexpected error during root cause analysis: " + McpResponseTrim.safeMessage(e), e);
}
}
/** Bundles the parsed and validated tool arguments for a single root cause analysis run. */
private record RcaRequest(
String fqn,
String entityType,
int upstreamDepth,
int downstreamDepth,
String queryFilter,
boolean includeDeleted,
boolean includeColumns) {}
private Map<String, Object> analyze(RcaRequest request) throws IOException {
Map<String, Object> result = new HashMap<>();
result.put("fqn", request.fqn());
result.put("upstreamDepth", request.upstreamDepth());
result.put("downstreamDepth", request.downstreamDepth());
Response upstreamResponse =
Entity.getSearchRepository()
.searchDataQualityLineage(
request.fqn(),
request.upstreamDepth(),
request.queryFilter(),
request.includeDeleted());
Map<String, Object> upstreamAnalysis =
buildUpstreamAnalysis(upstreamResponse.getEntity(), request.includeColumns());
result.put("upstreamAnalysis", upstreamAnalysis);
int failureCount =
((Number) upstreamAnalysis.getOrDefault("failingUpstreamNodesCount", 0)).intValue();
boolean hasFailures = failureCount > 0;
result.put(
"downstreamAnalysis",
hasFailures ? buildDownstreamAnalysis(request) : noFailuresDownstream());
result.put("status", hasFailures ? "failed" : "success");
result.put(
"summary",
String.format(
"Analyzed upstream causes and downstream impacts for '%s'. Found %d upstream failure(s).",
request.fqn(), failureCount));
return enforceSizeBudget(result);
}
private Map<String, Object> buildUpstreamAnalysis(Object upstreamEntity, boolean includeColumns) {
Map<String, Object> upstreamAnalysis = new HashMap<>();
if (!(upstreamEntity instanceof Map)) {
return upstreamAnalysis;
}
Map<String, Object> upstreamLineageData = castMap(upstreamEntity);
Set<?> rawEdges = asSet(upstreamLineageData.get("edges"));
List<Map<String, Object>> nodes = slimUpstreamNodes(asSet(upstreamLineageData.get("nodes")));
upstreamAnalysis.put("failingUpstreamNodesCount", nodes.size());
if (!nodes.isEmpty()) {
nodes.forEach(node -> node.put("failingTestCases", addTestCaseResultForTestSuite(node)));
upstreamAnalysis.put("failingUpstreamNodes", nodes);
}
upstreamAnalysis.put("failingUpstreamEdgesCount", rawEdges.size());
upstreamAnalysis.put("failingUpstreamEdges", slimEdges(rawEdges, includeColumns));
upstreamAnalysis.put(
"description", "Upstream entities that may be causing data quality failures");
return upstreamAnalysis;
}
private Map<String, Object> buildDownstreamAnalysis(RcaRequest request) {
Map<String, Object> downstreamAnalysis = new HashMap<>();
downstreamAnalysis.put(
"description", "Downstream entities that may be impacted by the identified failures");
try {
SearchLineageRequest downstreamRequest =
new SearchLineageRequest()
.withFqn(request.fqn())
.withDirection(LineageDirection.DOWNSTREAM)
.withUpstreamDepth(0)
.withDownstreamDepth(request.downstreamDepth())
.withQueryFilter(request.queryFilter())
.withIsConnectedVia(isConnectedVia(request.entityType()))
.withIncludeDeleted(request.includeDeleted());
SearchLineageResult downstreamResult =
Entity.getSearchRepository().searchLineageWithDirection(downstreamRequest);
addDownstreamNodes(downstreamAnalysis, downstreamResult);
addDownstreamEdges(downstreamAnalysis, downstreamResult, request.includeColumns());
} catch (Exception e) {
LOG.warn("Failed to perform downstream impact analysis for entity: {}", request.fqn(), e);
downstreamAnalysis.put(
"error", "Failed to analyze downstream impact: " + McpResponseTrim.safeMessage(e));
}
return downstreamAnalysis;
}
private static void addDownstreamNodes(
Map<String, Object> downstreamAnalysis, SearchLineageResult result) {
if (result.getNodes() != null) {
downstreamAnalysis.put("downstreamImpactedNodesCount", result.getNodes().size());
downstreamAnalysis.put("downstreamNodes", slimDownstreamNodes(result.getNodes()));
}
}
private static void addDownstreamEdges(
Map<String, Object> downstreamAnalysis, SearchLineageResult result, boolean includeColumns) {
if (result.getDownstreamEdges() != null) {
downstreamAnalysis.put("downstreamImpactedEdgesCount", result.getDownstreamEdges().size());
downstreamAnalysis.put(
"downstreamEdges", slimEdgeMap(result.getDownstreamEdges(), includeColumns));
}
}
private static Map<String, Object> noFailuresDownstream() {
Map<String, Object> downstreamAnalysis = new HashMap<>();
downstreamAnalysis.put(
"reason", "No failures found in upstream analysis, downstream impact analysis not needed");
return downstreamAnalysis;
}
private static List<Map<String, Object>> slimUpstreamNodes(Set<?> rawNodes) {
List<Map<String, Object>> nodes = new ArrayList<>();
for (Object node : rawNodes) {
if (node instanceof Map) {
nodes.add(slimNodeEntity(castMap(node)));
}
}
return nodes;
}
@VisibleForTesting
static Map<String, Object> slimDownstreamNodes(Map<String, ?> rawNodes) {
Map<String, Object> slim = new LinkedHashMap<>();
rawNodes.forEach((id, nodeInfo) -> slim.put(id, slimNodeInformation(nodeInfo)));
return slim;
}
private static Map<String, Object> slimNodeInformation(Object nodeInfo) {
Map<String, Object> info = JsonUtils.getMap(nodeInfo);
Map<String, Object> slim = new LinkedHashMap<>();
if (info != null) {
Object entity = info.get("entity");
if (entity instanceof Map) {
slim.put("entity", slimNodeEntity(castMap(entity)));
}
putIfPresent(slim, "nodeDepth", info.get("nodeDepth"));
}
return slim;
}
/**
* Cleans an entity document the same way upstream nodes are cleaned ({@link
* SearchMetadataTool#cleanSearchResponseObject} drops {@code columns}, {@code schemaDefinition},
* {@code queries} and other verbose keys). The description is left in full; overall size is bounded
* by fitting fewer edges in {@link #enforceSizeBudget}, not by cutting field content.
*/
@VisibleForTesting
static Map<String, Object> slimNodeEntity(Map<String, Object> node) {
return cleanSearchResponseObject(node);
}
@VisibleForTesting
static List<Map<String, Object>> slimEdges(Collection<?> rawEdges, boolean includeColumns) {
List<Map<String, Object>> edges = new ArrayList<>();
for (Object edge : rawEdges) {
edges.add(slimEdge(JsonUtils.getMap(edge), includeColumns));
}
return edges;
}
@VisibleForTesting
static Map<String, Object> slimEdgeMap(Map<String, ?> rawEdges, boolean includeColumns) {
Map<String, Object> slim = new LinkedHashMap<>();
rawEdges.forEach((id, edge) -> slim.put(id, slimEdge(JsonUtils.getMap(edge), includeColumns)));
return slim;
}
/**
* Reduces a raw {@code EsLineageData} edge to the fields useful for reasoning. Drops {@code
* docId}/{@code docUniqueId}/{@code fqnHash}, audit fields and the raw {@code pipeline} blob
* (folded into {@code relationshipType}); truncates {@code sqlQuery}; and includes column-level
* lineage only when explicitly requested.
*/
@VisibleForTesting
static Map<String, Object> slimEdge(Map<String, Object> edge, boolean includeColumns) {
Map<String, Object> slim = new LinkedHashMap<>();
if (edge != null) {
putIfPresent(slim, "fromEntity", slimRef(edge.get("fromEntity")));
putIfPresent(slim, "toEntity", slimRef(edge.get("toEntity")));
slim.put("relationshipType", relationshipType(edge.get("pipeline")));
putIfPresent(slim, "source", edge.get("source"));
putIfPresent(slim, "assetEdges", edge.get("assetEdges"));
putIfPresent(slim, "tempLineageTables", edge.get("tempLineageTables"));
applyDescription(slim, edge.get("description"));
applySqlQuery(slim, edge.get("sqlQuery"));
// For deduplicated SQL the backend empties sqlQuery and stores a pointer into the parent
// doc's lineageSqlQueries map; carry the pointer so shared SQL isn't silently lost.
putIfPresent(slim, "sqlQueryKey", edge.get("sqlQueryKey"));
if (includeColumns) {
putIfPresent(slim, "columns", edge.get("columns"));
}
}
return slim;
}
private static Map<String, Object> slimRef(Object ref) {
Map<String, Object> result = null;
if (ref instanceof Map) {
Map<String, Object> refMap = castMap(ref);
result = new LinkedHashMap<>();
putIfPresent(result, "id", refMap.get("id"));
putIfPresent(result, "fullyQualifiedName", refMap.get("fullyQualifiedName"));
putIfPresent(result, "type", refMap.get("type"));
}
return result;
}
private static String relationshipType(Object pipeline) {
String result = RELATIONSHIP_SQL;
if (pipeline instanceof Map) {
Map<String, Object> pipelineMap = castMap(pipeline);
Object type = pipelineMap.get("type");
Object name = pipelineMap.get("name");
if (type != null && name != null) {
result = type + ":" + name;
}
}
return result;
}
private static void applyDescription(Map<String, Object> slim, Object description) {
if (description instanceof String text && !text.isEmpty()) {
slim.put("description", text);
}
}
private static void applySqlQuery(Map<String, Object> slim, Object sqlQuery) {
if (sqlQuery instanceof String sql && !sql.isEmpty()) {
slim.put("sqlQuery", sql);
}
}
private static final String UPSTREAM_ANALYSIS = "upstreamAnalysis";
private static final String DOWNSTREAM_ANALYSIS = "downstreamAnalysis";
private static final String UPSTREAM_EDGES = "failingUpstreamEdges";
private static final String DOWNSTREAM_EDGES = "downstreamEdges";
/**
* Keeps RCA under the dispatch cap by returning fewer <em>edges</em> (the SQL-bearing, heaviest
* part) in each direction, never by cutting an edge's SQL or dropping the whole analysis to a bare
* hint. Nodes, counts and summary are preserved, and a per-direction "...Returned" marker records
* how many edges were withheld. Only when the non-edge content alone already exceeds the budget
* does it fall back to the minimal identity hint.
*/
@VisibleForTesting
static Map<String, Object> enforceSizeBudget(Map<String, Object> result) {
Map<String, Object> output = result;
if (McpResponseTrim.serializedLength(result) > McpResponseTrim.MAX_RESPONSE_CHARS) {
output = fitAnalysisToBudget(result);
}
return output;
}
private static Map<String, Object> fitAnalysisToBudget(Map<String, Object> result) {
Map<String, Object> upstream = mapAt(result, UPSTREAM_ANALYSIS);
Map<String, Object> downstream = mapAt(result, DOWNSTREAM_ANALYSIS);
long available =
ResponseBudget.defaultBudgetChars() - edgeFreeOverhead(result, upstream, downstream);
Map<String, Object> output;
if (available <= 0) {
output = oversizedHint(result);
} else {
fitEdgeLists(upstream, downstream, available);
result.put("truncated", Boolean.TRUE);
output = result;
}
return output;
}
/**
* Splits the budget across the two directions and, mirroring {@link GetLineageTool}, reclaims the
* other direction's unused budget so an asymmetric analysis (RCA commonly has only upstream
* failing edges) can use the whole budget instead of being capped at half.
*/
private static void fitEdgeLists(
Map<String, Object> upstream, Map<String, Object> downstream, long available) {
List<?> upEdges = edgeValues(upstream, UPSTREAM_EDGES);
List<?> downEdges = edgeValues(downstream, DOWNSTREAM_EDGES);
long half = available / 2;
ResponseBudget.Fit up = ResponseBudget.fitWithin(upEdges, half);
ResponseBudget.Fit down = ResponseBudget.fitWithin(downEdges, available - up.usedChars());
boolean downstreamLeftRoom = down.usedChars() < half && up.count() < upEdges.size();
if (downstreamLeftRoom) {
up = ResponseBudget.fitWithin(upEdges, available - down.usedChars());
}
trimEdges(upstream, UPSTREAM_EDGES, up.count());
trimEdges(downstream, DOWNSTREAM_EDGES, down.count());
}
/** Serialized size of the result with both edge collections detached, i.e. the non-edge cost. */
private static long edgeFreeOverhead(
Map<String, Object> result, Map<String, Object> upstream, Map<String, Object> downstream) {
Object up = detachEdges(upstream, UPSTREAM_EDGES);
Object down = detachEdges(downstream, DOWNSTREAM_EDGES);
long overhead = McpResponseTrim.serializedLength(result);
reattachEdges(upstream, UPSTREAM_EDGES, up);
reattachEdges(downstream, DOWNSTREAM_EDGES, down);
return overhead;
}
private static Map<String, Object> mapAt(Map<String, Object> map, String key) {
return map.get(key) instanceof Map ? castMap(map.get(key)) : null;
}
/**
* Edges are a {@code List} on the upstream side ({@link #slimEdges}) but a {@code Map} keyed by
* entity id on the downstream side ({@link #slimEdgeMap}). Both shapes are reduced to a list of
* edge values so {@link ResponseBudget} can measure and count them uniformly.
*/
private static List<?> edgeValues(Map<String, Object> analysis, String key) {
Object edges = analysis == null ? null : analysis.get(key);
List<?> values = List.of();
if (edges instanceof List<?> list) {
values = list;
} else if (edges instanceof Map<?, ?> map) {
values = new ArrayList<>(map.values());
}
return values;
}
private static Object detachEdges(Map<String, Object> analysis, String key) {
Object edges = null;
if (analysis != null
&& (analysis.get(key) instanceof List || analysis.get(key) instanceof Map)) {
edges = analysis.remove(key);
}
return edges;
}
private static void reattachEdges(Map<String, Object> analysis, String key, Object edges) {
if (edges != null) {
analysis.put(key, edges);
}
}
private static void trimEdges(Map<String, Object> analysis, String key, int count) {
if (analysis != null) {
Object edges = analysis.get(key);
if (edges instanceof List<?> list && count < list.size()) {
analysis.put(key, new ArrayList<>(list.subList(0, count)));
analysis.put(key + "Returned", count);
} else if (edges instanceof Map<?, ?> map && count < map.size()) {
analysis.put(key, firstEntries(map, count));
analysis.put(key + "Returned", count);
}
}
}
private static Map<String, Object> firstEntries(Map<?, ?> map, int count) {
Map<String, Object> kept = new LinkedHashMap<>();
int index = 0;
for (Map.Entry<?, ?> entry : map.entrySet()) {
if (index >= count) {
break;
}
kept.put(String.valueOf(entry.getKey()), entry.getValue());
index++;
}
return kept;
}
private static Map<String, Object> oversizedHint(Map<String, Object> result) {
Map<String, Object> hint = new LinkedHashMap<>();
putIfPresent(hint, "fqn", result.get("fqn"));
putIfPresent(hint, "upstreamDepth", result.get("upstreamDepth"));
putIfPresent(hint, "downstreamDepth", result.get("downstreamDepth"));
putIfPresent(hint, "status", result.get("status"));
putIfPresent(hint, "summary", result.get("summary"));
hint.put("truncated", Boolean.TRUE);
hint.put(
"message",
"Root cause analysis result exceeds the size budget. Reduce upstreamDepth/downstreamDepth, "
+ "or leave includeColumnLineage off, to get a smaller response.");
return hint;
}
private Map<String, Object> addTestCaseResultForTestSuite(Map<String, Object> node) {
Map<String, Object> testCaseResult = new HashMap<>();
Map<String, Object> testSuiteMap = JsonUtils.getMap(node.get("testSuite"));
if (testSuiteMap == null || testSuiteMap.get("id") == null) {
return testCaseResult;
}
String testSuiteId = (String) testSuiteMap.get("id");
SearchListFilter searchListFilter = new SearchListFilter();
searchListFilter.addQueryParam("testCaseStatus", "Failed");
searchListFilter.addQueryParam("testSuiteId", testSuiteId);
TestCaseResultRepository testResultTimeSeriesRepository =
(TestCaseResultRepository) Entity.getEntityTimeSeriesRepository(Entity.TEST_CASE_RESULT);
try {
ResultList<TestCaseResult> testCaseResults =
testResultTimeSeriesRepository.listLatestFromSearch(
testResultTimeSeriesRepository.getFields("testCaseStatus,result,testResultValue"),
searchListFilter,
"testCaseFQN.keyword",
null,
null,
null,
null,
null);
if (testCaseResults.getData() != null && !testCaseResults.getData().isEmpty()) {
testCaseResult.put("testCaseResults", testCaseResults.getData());
testCaseResult.put("testSuiteId", testSuiteId);
} else {
LOG.info("No failed test case results found for test suite: {}", testSuiteId);
}
} catch (IOException e) {
LOG.error("Failed to fetch test case results for test suite: {}", testSuiteId, e);
}
return testCaseResult;
}
private static int clampDepth(int depth) {
return Math.min(Math.max(depth, 1), MAX_DEPTH);
}
private static Set<?> asSet(Object value) {
return value instanceof Set<?> set ? set : Collections.emptySet();
}
@SuppressWarnings("unchecked")
private static Map<String, Object> castMap(Object value) {
return (Map<String, Object>) value;
}
private static void putIfPresent(Map<String, Object> map, String key, Object value) {
if (value != null) {
map.put(key, value);
}
}
@Override
public Map<String, Object> execute(
Authorizer authorizer,
Limits limits,
CatalogSecurityContext securityContext,
Map<String, Object> params) {
throw new UnsupportedOperationException(
"RootCauseAnalysisTool does not require limit validation.");
}
}
@@ -0,0 +1,184 @@
package org.openmetadata.mcp.tools;
import static org.openmetadata.schema.type.MetadataOperation.VIEW_ALL;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import lombok.extern.slf4j.Slf4j;
import org.openmetadata.mcp.util.McpParams;
import org.openmetadata.mcp.util.McpResponseTrim;
import org.openmetadata.mcp.util.ResponseBudget;
import org.openmetadata.mcp.util.VectorPagingContract;
import org.openmetadata.schema.entity.context.ContextMemorySourceType;
import org.openmetadata.schema.entity.context.MemoryVisibility;
import org.openmetadata.service.Entity;
import org.openmetadata.service.limits.Limits;
import org.openmetadata.service.search.vector.OpenSearchVectorService;
import org.openmetadata.service.search.vector.utils.DTOs.VectorSearchResponse;
import org.openmetadata.service.security.Authorizer;
import org.openmetadata.service.security.auth.CatalogSecurityContext;
import org.openmetadata.service.security.policyevaluator.OperationContext;
import org.openmetadata.service.security.policyevaluator.ResourceContext;
/**
* Semantic search over Company Context knowledge pills (file-extracted {@link
* org.openmetadata.schema.entity.context.ContextMemory}). Returns the pill content (title,
* question, answer, summary, sourceFile) in a single call.
*/
@Slf4j
public class SearchCompanyContextTool implements McpTool {
private static final int DEFAULT_SIZE = 10;
private static final int MAX_SIZE = 50;
private static final int DEFAULT_K = 100;
private static final double DEFAULT_THRESHOLD = 0.0;
@Override
public Map<String, Object> execute(
Authorizer authorizer, CatalogSecurityContext securityContext, Map<String, Object> params)
throws IOException {
authorizer.authorize(
securityContext,
new OperationContext(Entity.CONTEXT_MEMORY, VIEW_ALL),
new ResourceContext<>(Entity.CONTEXT_MEMORY));
Map<String, Object> result;
String query = (String) params.get("query");
if (query == null || query.isBlank()) {
result = errorResponse("'query' parameter is required");
} else if (!Entity.getSearchRepository().isVectorEmbeddingEnabled()) {
result =
errorResponse(
"Semantic search is not enabled. Configure vector embeddings in the OpenMetadata server settings.");
} else {
result = runSearch(query, params);
}
return result;
}
private Map<String, Object> runSearch(String query, Map<String, Object> params) {
Map<String, Object> result;
OpenSearchVectorService vectorService = OpenSearchVectorService.getInstance();
if (vectorService == null) {
result = errorResponse("Vector search service is not initialized");
} else {
int size = Math.min(Math.max(McpParams.getInt(params, "size", DEFAULT_SIZE), 1), MAX_SIZE);
int from =
VectorPagingContract.cursorOffsetOrDefault(
params, Math.max(McpParams.getInt(params, "from", 0), 0));
try {
VectorSearchResponse response =
vectorService.search(
query, companyContextFilters(), size, from, DEFAULT_K, DEFAULT_THRESHOLD);
result = buildResponse(query, response, size, from);
} catch (Exception e) {
LOG.error("Company context search failed: {}", e.getMessage(), e);
result = errorResponse("Company context search failed: " + McpResponseTrim.safeMessage(e));
}
}
return result;
}
private Map<String, List<String>> companyContextFilters() {
Map<String, List<String>> filters = new HashMap<>();
filters.put("entityType", List.of(Entity.CONTEXT_MEMORY));
filters.put("sourceType", List.of(ContextMemorySourceType.FILE_EXTRACTION.value()));
filters.put("visibility", List.of(MemoryVisibility.SHARED.value()));
return filters;
}
private Map<String, Object> buildResponse(
String query, VectorSearchResponse response, int requestedSize, int from) {
List<Map<String, Object>> pills = new ArrayList<>();
if (response.getHits() != null) {
for (Map<String, Object> hit : response.getHits()) {
pills.add(projectPill(hit));
}
}
Map<String, Object> result = new HashMap<>();
result.put("query", query);
result.put("results", pills);
result.put("returnedCount", pills.size());
int rawCount = pills.size();
fitResultsToBudget(result, pills);
VectorPagingContract.attach(
result,
from,
rawCount,
requestedSize,
response,
"Showing %d knowledge pills. Pass 'nextCursor' to fetch the next page.");
return result;
}
/**
* Keeps the response under the dispatch-level size cap by returning fewer <em>pills</em> (never
* mangling the content of the ones kept), so the tool never falls through to the empty-stub nuke.
* Uses {@link ResponseBudget} to fit pills by measuring each pill's real serialized size.
*/
private static void fitResultsToBudget(
Map<String, Object> result, List<Map<String, Object>> pills) {
long overhead = overheadWithoutResults(result);
int fit = ResponseBudget.fitCount(pills, overhead);
if (fit < pills.size()) {
List<Map<String, Object>> trimmed = new ArrayList<>(pills.subList(0, fit));
result.put("results", trimmed);
result.put("returnedCount", trimmed.size());
result.put("hasMore", true);
result.put(
"message",
String.format(
"Returning %d of %d knowledge pills to stay within the response size budget. "
+ "Refine the query or lower 'size' for a smaller response.",
trimmed.size(), pills.size()));
}
}
private static long overheadWithoutResults(Map<String, Object> result) {
Object savedResults = result.remove("results");
long overhead = McpResponseTrim.serializedLength(result);
result.put("results", savedResults);
return overhead;
}
private Map<String, Object> projectPill(Map<String, Object> hit) {
Map<String, Object> pill = new HashMap<>();
copy(hit, pill, "fullyQualifiedName");
copy(hit, pill, "name");
copy(hit, pill, "title");
copy(hit, pill, "question");
copy(hit, pill, "answer");
copy(hit, pill, "summary");
copy(hit, pill, "sourceFile");
if (hit.containsKey("_score")) {
pill.put("similarityScore", hit.get("_score"));
}
return pill;
}
private void copy(Map<String, Object> from, Map<String, Object> to, String key) {
if (from.containsKey(key)) {
to.put(key, from.get(key));
}
}
private Map<String, Object> errorResponse(String message) {
Map<String, Object> result = new HashMap<>();
result.put("results", Collections.emptyList());
result.put("returnedCount", 0);
result.put("error", message);
return result;
}
@Override
public Map<String, Object> execute(
Authorizer authorizer,
Limits limits,
CatalogSecurityContext securityContext,
Map<String, Object> params) {
throw new UnsupportedOperationException(
"SearchCompanyContextTool does not support limits enforcement.");
}
}
@@ -0,0 +1,613 @@
package org.openmetadata.mcp.tools;
import static org.openmetadata.common.utils.CommonUtil.nullOrEmpty;
import static org.openmetadata.service.security.DefaultAuthorizer.getSubjectContext;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.google.common.annotations.VisibleForTesting;
import jakarta.ws.rs.core.Response;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import lombok.extern.slf4j.Slf4j;
import org.openmetadata.mcp.util.McpResponseTrim;
import org.openmetadata.mcp.util.PageCursor;
import org.openmetadata.mcp.util.ResponseBudget;
import org.openmetadata.schema.search.SearchRequest;
import org.openmetadata.schema.utils.JsonUtils;
import org.openmetadata.service.Entity;
import org.openmetadata.service.limits.Limits;
import org.openmetadata.service.security.Authorizer;
import org.openmetadata.service.security.auth.CatalogSecurityContext;
import org.openmetadata.service.security.policyevaluator.SubjectContext;
@Slf4j
public class SearchMetadataTool implements McpTool {
private static final int DEFAULT_MAX_AGGREGATION_BUCKETS = 10;
private static final int MAX_ALLOWED_AGGREGATION_BUCKETS = 50;
private static final List<String> ESSENTIAL_FIELDS_ONLY =
List.of(
"name",
"displayName",
"fullyQualifiedName",
"description",
"entityType",
"service",
"database",
"databaseSchema",
"serviceType",
"href",
"tags",
"owners",
"tier",
"tableType",
"columnNames",
"deleted",
"entityFQN",
"originEntityFQN",
"testCaseStatus",
"testCaseType",
"dataQualityDimension",
"testPlatforms",
"basic",
"lastResultTimestamp");
// Latest-result subset kept in test case search results; the full testCaseResult object
// (testResultValue, sample row counts, ...) is available via the 'fields' parameter.
private static final List<String> TEST_CASE_RESULT_SLIM_FIELDS =
List.of("testCaseStatus", "timestamp", "result");
private static final List<String> DETAILED_EXCLUDE_KEYS =
Stream.concat(
Stream.of(
"id",
"version",
"updatedAt",
"updatedBy",
"usageSummary",
"followers",
"votes",
"lifeCycle",
"sourceHash",
"processedLineage",
"totalVotes",
"fqnParts",
"service_suggest",
"column_suggest",
"schema_suggest",
"database_suggest",
"upstreamLineage",
"entityRelationship",
"changeSummary",
"fqnHash",
"columns",
"schemaDefinition",
"queries",
"sourceUrl",
"locationPath",
"customMetrics",
"tierSources",
"tagSources",
"descriptionSources",
"columnDescriptionStatus",
"columnNamesFuzzy",
"descriptionStatus",
"domains"),
McpResponseTrim.VECTOR_NOISE_FIELDS.stream())
.toList();
@Override
public Map<String, Object> execute(
Authorizer authorizer, CatalogSecurityContext securityContext, Map<String, Object> params)
throws IOException {
LOG.info("Executing searchMetadata with params: {}", params);
String query = stringParam(params, "query", "*");
String entityType = stringParam(params, "entityType", null);
String index = resolveIndex(entityType);
int size = 10;
if (params.containsKey("size")) {
Object limitObj = params.get("size");
if (limitObj instanceof Number number) {
size = number.intValue();
} else if (limitObj instanceof String string) {
try {
size = Integer.parseInt(string);
} catch (NumberFormatException e) {
size = 10;
}
}
}
int from = 0;
if (params.containsKey("from")) {
Object limitObj = params.get("from");
if (limitObj instanceof Number number) {
from = number.intValue();
} else if (limitObj instanceof String string) {
try {
from = Integer.parseInt(string);
} catch (NumberFormatException e) {
from = 0;
}
}
}
Optional<PageCursor.Cursor> cursor = PageCursor.decode(stringParam(params, "cursor", null));
if (cursor.isPresent() && cursor.get().isOffset()) {
from = cursor.get().offset();
}
size = Math.min(size, 50);
boolean includeDeleted = false;
if (params.containsKey("includeDeleted")) {
Object deletedObj = params.get("includeDeleted");
if (deletedObj instanceof Boolean booleanValue) {
includeDeleted = booleanValue;
} else if (deletedObj instanceof String) {
includeDeleted = "true".equals(deletedObj);
}
}
// Parse includeAggregations - defaults to false to keep LLM context size manageable
boolean includeAggregations = false;
if (params.containsKey("includeAggregations")) {
Object aggObj = params.get("includeAggregations");
if (aggObj instanceof Boolean booleanValue) {
includeAggregations = booleanValue;
} else if (aggObj instanceof String) {
includeAggregations = "true".equals(aggObj);
}
}
// Parse maxAggregationBuckets - limit aggregation size to prevent context overflow
int maxAggregationBuckets = DEFAULT_MAX_AGGREGATION_BUCKETS;
if (params.containsKey("maxAggregationBuckets")) {
Object maxBucketsObj = params.get("maxAggregationBuckets");
if (maxBucketsObj instanceof Number number) {
maxAggregationBuckets =
Math.min(Math.max(number.intValue(), 1), MAX_ALLOWED_AGGREGATION_BUCKETS);
} else if (maxBucketsObj instanceof String string) {
try {
maxAggregationBuckets =
Math.min(Math.max(Integer.parseInt(string), 1), MAX_ALLOWED_AGGREGATION_BUCKETS);
} catch (NumberFormatException e) {
maxAggregationBuckets = DEFAULT_MAX_AGGREGATION_BUCKETS;
}
}
}
List<String> requestedFields = new ArrayList<>();
String fieldsParam = stringParam(params, "fields", null);
if (fieldsParam != null && !fieldsParam.trim().isEmpty()) {
requestedFields =
List.of(fieldsParam.split(",")).stream()
.map(String::trim)
.filter(field -> !field.isEmpty())
.collect(Collectors.toList());
}
String queryFilter = null;
Object queryFilterParam = params.get("queryFilter");
if (queryFilterParam != null) {
// LLM callers occasionally send the filter as a JSON object instead of a string; serialize
// non-string input back to JSON rather than failing on a cast.
String rawFilter =
queryFilterParam instanceof String stringValue
? stringValue
: JsonUtils.pojoToJson(queryFilterParam);
JsonNode queryNode = JsonUtils.getObjectMapper().readTree(rawFilter);
if (!queryNode.has("query")) {
ObjectNode queryWrapper = JsonUtils.getObjectMapper().createObjectNode();
queryWrapper.set("query", queryNode);
queryFilter = JsonUtils.pojoToJson(queryWrapper);
} else {
queryFilter = JsonUtils.pojoToJson(queryNode);
}
LOG.debug("Applied query filter to query: {}", queryFilter);
}
LOG.info(
"Search query: {}, index: {}, limit: {}, includeDeleted: {}",
queryFilter,
index,
size,
includeDeleted);
SearchRequest searchRequest;
if (!nullOrEmpty(queryFilter)) {
// When queryFilter is provided, use it directly as it's already a transformed OpenSearch
// query
searchRequest =
new SearchRequest()
.withIndex(Entity.getSearchRepository().getIndexOrAliasName(index))
.withQueryFilter(queryFilter)
.withSize(size)
.withFrom(from)
.withFetchSource(true)
.withDeleted(includeDeleted);
} else {
// Fallback to basic query when no queryFilter is provided
searchRequest =
new SearchRequest()
.withQuery(query)
.withIndex(Entity.getSearchRepository().getIndexOrAliasName(index))
.withSize(size)
.withFrom(from)
.withFetchSource(true)
.withDeleted(includeDeleted);
}
SubjectContext subjectContext = getSubjectContext(securityContext);
Response response;
if (!nullOrEmpty(queryFilter)) {
// Use direct query method when queryFilter is provided since it's already a transformed query
response = Entity.getSearchRepository().searchWithDirectQuery(searchRequest, subjectContext);
} else {
// Use regular search for basic queries
response = Entity.getSearchRepository().search(searchRequest, subjectContext);
}
Map<String, Object> searchResponse;
if (response.getEntity() instanceof String responseStr) {
LOG.debug("Search returned string response");
JsonNode jsonNode = JsonUtils.readTree(responseStr);
searchResponse = JsonUtils.convertValue(jsonNode, Map.class);
} else {
LOG.debug("Search returned object response: {}", response.getEntity().getClass().getName());
searchResponse = JsonUtils.convertValue(response.getEntity(), Map.class);
}
return buildEnhancedSearchResponse(
searchResponse,
query,
size,
from,
requestedFields,
includeAggregations,
maxAggregationBuckets);
}
@Override
public Map<String, Object> execute(
Authorizer authorizer,
Limits limits,
CatalogSecurityContext securityContext,
Map<String, Object> params) {
throw new UnsupportedOperationException(
"SearchMetadataTool does not support limits enforcement.");
}
@VisibleForTesting
static Map<String, Object> buildEnhancedSearchResponse(
Map<String, Object> searchResponse,
String query,
int requestedLimit,
List<String> requestedFields,
boolean includeAggregations,
int maxAggregationBuckets) {
return buildEnhancedSearchResponse(
searchResponse,
query,
requestedLimit,
0,
requestedFields,
includeAggregations,
maxAggregationBuckets);
}
static Map<String, Object> buildEnhancedSearchResponse(
Map<String, Object> searchResponse,
String query,
int requestedLimit,
int from,
List<String> requestedFields,
boolean includeAggregations,
int maxAggregationBuckets) {
if (searchResponse == null) {
return createEmptyResponse();
}
Map<String, Object> topHits = safeGetMap(searchResponse.get("hits"));
if (topHits == null) {
return createEmptyResponse();
}
List<Object> hits = safeGetList(topHits.get("hits"));
List<Map<String, Object>> cleanedResults = new ArrayList<>();
int totalResults = 0;
if (hits != null && !hits.isEmpty()) {
if (topHits.get("total") instanceof Map) {
Map<String, Object> totalObj = safeGetMap(topHits.get("total"));
if (totalObj != null && totalObj.get("value") instanceof Number) {
totalResults = ((Number) totalObj.get("value")).intValue();
}
} else if (topHits.get("total") instanceof Number) {
totalResults = ((Number) topHits.get("total")).intValue();
}
for (Object hitObj : hits) {
Map<String, Object> hit = safeGetMap(hitObj);
if (hit == null) continue;
Map<String, Object> source = safeGetMap(hit.get("_source"));
if (source == null) continue;
Map<String, Object> cleanedSource = cleanSearchResult(source, requestedFields);
if (hit.containsKey("_score")) {
cleanedSource.put("similarityScore", hit.get("_score"));
}
cleanedResults.add(cleanedSource);
}
}
Map<String, Object> result = new HashMap<>();
result.put("results", cleanedResults);
result.put("totalFound", totalResults);
result.put("returnedCount", cleanedResults.size());
result.put("query", query);
result.put(
"usage",
"To get full details for any result, call get_entity_details with the result's exact 'entityType' and 'fullyQualifiedName' values.");
// Handle aggregations based on includeAggregations flag
if (includeAggregations && searchResponse.containsKey("aggregations")) {
Map<String, Object> rawAggregations = safeGetMap(searchResponse.get("aggregations"));
if (rawAggregations != null && !rawAggregations.isEmpty()) {
Map<String, Object> truncatedAggregations =
truncateAggregations(rawAggregations, maxAggregationBuckets);
result.put("aggregations", truncatedAggregations.get("aggregations"));
if (truncatedAggregations.containsKey("aggregationsTruncated")) {
result.put("aggregationsTruncated", true);
result.put(
"aggregationsMessage",
String.format(
"Aggregation buckets truncated to %d per field to optimize LLM context. "
+ "Set maxAggregationBuckets parameter for more (max %d).",
maxAggregationBuckets, MAX_ALLOWED_AGGREGATION_BUCKETS));
}
}
}
boolean moreInIndex = (long) from + cleanedResults.size() < totalResults;
if (moreInIndex) {
result.put(
"message",
String.format(
"Found %d total results, showing first %d. "
+ "There are many matching assets. Are you looking for something specific? "
+ "Try narrowing with a service name, schema name, or more specific search term.",
totalResults, cleanedResults.size()));
result.put("hasMore", true);
}
fitResultsToBudget(result, cleanedResults, totalResults, query);
attachPagingContract(result, from, totalResults);
return result;
}
/**
* Sets the unified paging markers. {@code total} is the real ES hit count; {@code nextCursor}
* advances by the count actually returned this page (after any budget trim) so the next call never
* skips rows. Emitted only when {@code hasMore} was set — either the total exceeds this page or the
* size budget trimmed it.
*/
private static void attachPagingContract(
Map<String, Object> result, int from, long totalResults) {
result.put(McpResponseTrim.TOTAL_KEY, totalResults);
int returned = result.get("returnedCount") instanceof Number number ? number.intValue() : 0;
if (Boolean.TRUE.equals(result.get(McpResponseTrim.HAS_MORE_KEY)) && returned > 0) {
result.put(McpResponseTrim.NEXT_CURSOR_KEY, PageCursor.encodeOffset(from + returned));
} else if (returned == 0) {
result.remove(McpResponseTrim.HAS_MORE_KEY);
result.remove(McpResponseTrim.MESSAGE_KEY);
}
}
/**
* Ensures the response stays under the dispatch-level size cap by returning fewer <em>results</em>
* (never mangling the ones kept), so search never falls through to the empty-stub nuke. Uses
* {@link ResponseBudget} to fit results to the budget by measuring each result's real serialized
* size, which the previous single proportional estimate could undershoot on heavy `fields=`
* responses, leaving the payload above the cap.
*/
private static void fitResultsToBudget(
Map<String, Object> result,
List<Map<String, Object>> cleanedResults,
long totalResults,
String query) {
long overhead = overheadWithoutResults(result);
int fit = ResponseBudget.fitCount(cleanedResults, overhead);
if (fit < cleanedResults.size()) {
List<Map<String, Object>> trimmed = new ArrayList<>(cleanedResults.subList(0, fit));
LOG.warn(
"[MCP] search_metadata fit {} of {} results to size budget for query '{}'",
trimmed.size(),
cleanedResults.size(),
query);
result.put("results", trimmed);
result.put("returnedCount", trimmed.size());
result.put("hasMore", true);
result.put(
"message",
String.format(
"Returning %d of %d results to stay within the response size budget. "
+ "Pass 'nextCursor' to fetch the next page, or narrow the query.",
trimmed.size(), totalResults));
}
}
private static long overheadWithoutResults(Map<String, Object> result) {
Object savedResults = result.remove("results");
long overhead = McpResponseTrim.serializedLength(result);
result.put("results", savedResults);
return overhead;
}
public static Map<String, Object> cleanSearchResult(
Map<String, Object> source, List<String> requestedFields) {
Map<String, Object> result = new HashMap<>();
// Always include essential fields
for (String field : ESSENTIAL_FIELDS_ONLY) {
if (source.containsKey(field)) {
result.put(field, source.get(field));
}
}
// Add any specifically requested additional fields
for (String field : requestedFields) {
if (source.containsKey(field)) {
result.put(field, source.get(field));
}
}
addSlimTestCaseResult(source, result);
// Truncate long descriptions to optimize LLM context usage
if (result.get("description") instanceof String description) {
result.put("description", McpResponseTrim.truncateDescription(description));
}
return result;
}
private static void addSlimTestCaseResult(
Map<String, Object> source, Map<String, Object> result) {
if (result.containsKey("testCaseResult")
|| !(source.get("testCaseResult") instanceof Map<?, ?> testCaseResult)) {
return;
}
Map<String, Object> slim = new HashMap<>();
for (String field : TEST_CASE_RESULT_SLIM_FIELDS) {
if (testCaseResult.containsKey(field)) {
slim.put(field, testCaseResult.get(field));
}
}
if (!slim.isEmpty()) {
result.put("testCaseResult", slim);
}
}
public static Map<String, Object> createEmptyResponse() {
Map<String, Object> result = new HashMap<>();
result.put("results", Collections.emptyList());
result.put("totalFound", 0);
result.put("returnedCount", 0);
result.put("message", "No results found");
return result;
}
@SuppressWarnings("unused")
public static Map<String, Object> cleanSearchResponseObject(Map<String, Object> object) {
DETAILED_EXCLUDE_KEYS.forEach(object::remove);
return object;
}
/**
* Reads a parameter as a string without assuming the caller sent a string. LLM callers sometimes
* send numbers or other scalars (e.g. {@code "entityType": 123}); {@code toString} keeps the tool
* tolerant instead of failing on a class cast.
*/
private static String stringParam(Map<String, Object> params, String key, String defaultValue) {
Object value = params.get(key);
return value == null ? defaultValue : value.toString();
}
/**
* Resolves the search index from the requested entity type using the authoritative index registry
* instead of a hand-maintained switch. A registered entity type resolves to its own single-type
* index, so results are correctly scoped (fixing #27796, where unlisted types fell back to the
* broad dataAsset alias and leaked other types). Null, unregistered, wildcard, or comma-separated
* input is not a registry key and falls back to dataAsset, preserving the prior graceful default
* rather than erroring or widening the search.
*/
@VisibleForTesting
static String resolveIndex(String entityType) {
String index = "dataAsset";
if (!nullOrEmpty(entityType)
&& Entity.getSearchRepository().getIndexMapping(entityType) != null) {
index = entityType;
}
return index;
}
/**
* Truncates aggregation buckets to prevent excessive response size that could overwhelm LLM
* context windows. Based on industry best practices, LLM performance degrades when context
* utilization exceeds 85%, so keeping responses concise is critical.
*
* @param aggregations Raw aggregations from search response
* @param maxBuckets Maximum number of buckets to keep per aggregation field
* @return Map containing truncated aggregations and a flag if any were truncated
*/
@SuppressWarnings("unchecked")
private static Map<String, Object> truncateAggregations(
Map<String, Object> aggregations, int maxBuckets) {
Map<String, Object> result = new HashMap<>();
Map<String, Object> truncatedAggs = new HashMap<>();
boolean anyTruncated = false;
for (Map.Entry<String, Object> entry : aggregations.entrySet()) {
String aggName = entry.getKey();
Object aggValue = entry.getValue();
if (aggValue instanceof Map) {
Map<String, Object> aggMap = (Map<String, Object>) aggValue;
// Check if this aggregation has buckets
if (aggMap.containsKey("buckets")) {
Object bucketsObj = aggMap.get("buckets");
if (bucketsObj instanceof List) {
List<Object> buckets = (List<Object>) bucketsObj;
if (buckets.size() > maxBuckets) {
// Truncate buckets
Map<String, Object> truncatedAgg = new HashMap<>(aggMap);
truncatedAgg.put("buckets", buckets.subList(0, maxBuckets));
truncatedAgg.put("_originalBucketCount", buckets.size());
truncatedAgg.put("_truncated", true);
truncatedAggs.put(aggName, truncatedAgg);
anyTruncated = true;
} else {
truncatedAggs.put(aggName, aggMap);
}
} else {
truncatedAggs.put(aggName, aggMap);
}
} else {
// Not a bucket aggregation (e.g., value_count, sum, etc.)
truncatedAggs.put(aggName, aggMap);
}
} else {
truncatedAggs.put(aggName, aggValue);
}
}
result.put("aggregations", truncatedAggs);
if (anyTruncated) {
result.put("aggregationsTruncated", true);
}
return result;
}
@SuppressWarnings("unchecked")
private static Map<String, Object> safeGetMap(Object obj) {
return (obj instanceof Map) ? (Map<String, Object>) obj : null;
}
@SuppressWarnings("unchecked")
private static List<Object> safeGetList(Object obj) {
return (obj instanceof List) ? (List<Object>) obj : null;
}
}
@@ -0,0 +1,243 @@
package org.openmetadata.mcp.tools;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import lombok.extern.slf4j.Slf4j;
import org.openmetadata.mcp.util.McpParams;
import org.openmetadata.mcp.util.McpResponseTrim;
import org.openmetadata.mcp.util.ResponseBudget;
import org.openmetadata.mcp.util.VectorPagingContract;
import org.openmetadata.schema.utils.JsonUtils;
import org.openmetadata.service.Entity;
import org.openmetadata.service.limits.Limits;
import org.openmetadata.service.search.vector.OpenSearchVectorService;
import org.openmetadata.service.search.vector.utils.DTOs.VectorSearchResponse;
import org.openmetadata.service.security.Authorizer;
import org.openmetadata.service.security.auth.CatalogSecurityContext;
@Slf4j
public class SemanticSearchTool implements McpTool {
private static final int DEFAULT_SIZE = 10;
private static final int MAX_SIZE = 50;
private static final int DEFAULT_K = 100;
private static final int MAX_K = 10_000;
private static final double DEFAULT_THRESHOLD = 0.0;
@Override
public Map<String, Object> execute(
Authorizer authorizer, CatalogSecurityContext securityContext, Map<String, Object> params)
throws IOException {
LOG.info("Executing semanticSearch with params: {}", params);
String query = (String) params.get("query");
if (query == null || query.isBlank()) {
return errorResponse("'query' parameter is required");
}
if (!Entity.getSearchRepository().isVectorEmbeddingEnabled()) {
return errorResponse(
"Semantic search is not enabled. Configure vector embeddings in the OpenMetadata server settings.");
}
OpenSearchVectorService vectorService = OpenSearchVectorService.getInstance();
if (vectorService == null) {
return errorResponse("Vector search service is not initialized");
}
int size = McpParams.getInt(params, "size", DEFAULT_SIZE);
size = Math.min(Math.max(size, 1), MAX_SIZE);
int from = McpParams.getInt(params, "from", 0);
from = Math.max(from, 0);
from = VectorPagingContract.cursorOffsetOrDefault(params, from);
int k = McpParams.getInt(params, "k", DEFAULT_K);
k = Math.min(Math.max(k, 1), MAX_K);
double threshold = McpParams.getDouble(params, "threshold", DEFAULT_THRESHOLD);
threshold = Math.min(Math.max(threshold, 0.0), 1.0);
Map<String, List<String>> filters = parseFilters(params);
try {
VectorSearchResponse response =
vectorService.search(query, filters, size, from, k, threshold);
return buildResponse(query, response, size, from);
} catch (Exception e) {
LOG.error("Semantic search failed: {}", e.getMessage(), e);
return errorResponse("Semantic search failed: " + McpResponseTrim.safeMessage(e));
}
}
@Override
public Map<String, Object> execute(
Authorizer authorizer,
Limits limits,
CatalogSecurityContext securityContext,
Map<String, Object> params) {
throw new UnsupportedOperationException(
"SemanticSearchTool does not support limits enforcement.");
}
private Map<String, Object> buildResponse(
String query, VectorSearchResponse response, int requestedSize, int from) {
Map<String, Object> result = new HashMap<>();
result.put("query", query);
result.put("tookMillis", response.getTookMillis());
if (response.getHits() == null || response.getHits().isEmpty()) {
result.put("results", Collections.emptyList());
result.put("totalFound", 0);
result.put("returnedCount", 0);
result.put("message", "No results found for semantic search");
return result;
}
List<Map<String, Object>> cleanedResults = new ArrayList<>();
for (Map<String, Object> hit : response.getHits()) {
cleanedResults.add(cleanHit(hit));
}
result.put("results", cleanedResults);
result.put("returnedCount", cleanedResults.size());
result.put("totalFound", cleanedResults.size());
result.put(
"usage",
"To get full details for any result, call get_entity_details with the result's exact 'entityType' and 'fullyQualifiedName' values.");
int rawCount = cleanedResults.size();
fitResultsToBudget(result, cleanedResults);
VectorPagingContract.attach(
result,
from,
rawCount,
requestedSize,
response,
"Showing %d results. Pass 'nextCursor' to fetch the next page, or refine your query. "
+ "Adjust 'threshold' to filter by similarity score.");
return result;
}
/**
* Ensures the response stays under the dispatch-level size cap by returning fewer <em>results</em>
* (never mangling the ones kept), so semantic_search never falls through to the empty-stub nuke.
* Uses {@link ResponseBudget} to fit results by measuring each result's real serialized size.
*/
private static void fitResultsToBudget(
Map<String, Object> result, List<Map<String, Object>> cleanedResults) {
long overhead = overheadWithoutResults(result);
int fit = ResponseBudget.fitCount(cleanedResults, overhead);
if (fit < cleanedResults.size()) {
List<Map<String, Object>> trimmed = new ArrayList<>(cleanedResults.subList(0, fit));
result.put("results", trimmed);
result.put("returnedCount", trimmed.size());
result.put("hasMore", true);
result.put(
"message",
String.format(
"Returning %d of %d results to stay within the response size budget. "
+ "Refine the query or lower 'size' for a smaller response.",
trimmed.size(), cleanedResults.size()));
}
}
private static long overheadWithoutResults(Map<String, Object> result) {
Object savedResults = result.remove("results");
long overhead = McpResponseTrim.serializedLength(result);
result.put("results", savedResults);
return overhead;
}
private Map<String, Object> cleanHit(Map<String, Object> hit) {
Map<String, Object> cleaned = new HashMap<>();
copyIfPresent(hit, cleaned, "parentId");
copyIfPresent(hit, cleaned, "entityType");
copyIfPresent(hit, cleaned, "fullyQualifiedName");
copyIfPresent(hit, cleaned, "name");
copyIfPresent(hit, cleaned, "displayName");
copyIfPresent(hit, cleaned, "serviceType");
copyIfPresent(hit, cleaned, "service");
copyIfPresent(hit, cleaned, "database");
copyIfPresent(hit, cleaned, "databaseSchema");
copyIfPresent(hit, cleaned, "owners");
copyIfPresent(hit, cleaned, "tier");
copyIfPresent(hit, cleaned, "tags");
copyIfPresent(hit, cleaned, "domains");
copyIfPresent(hit, cleaned, "columns");
copyIfPresent(hit, cleaned, "certification");
if (hit.containsKey("_score")) {
cleaned.put("similarityScore", hit.get("_score"));
}
if (hit.containsKey("description")) {
Object descObj = hit.get("description");
cleaned.put(
"description",
descObj instanceof String desc ? McpResponseTrim.truncateDescription(desc) : descObj);
}
return cleaned;
}
private void copyIfPresent(Map<String, Object> source, Map<String, Object> target, String key) {
if (source.containsKey(key)) {
target.put(key, source.get(key));
}
}
@SuppressWarnings("unchecked")
private Map<String, List<String>> parseFilters(Map<String, Object> params) {
if (!params.containsKey("filters")) {
return Collections.emptyMap();
}
Object filtersObj = params.get("filters");
if (filtersObj instanceof Map) {
Map<String, Object> rawFilters = (Map<String, Object>) filtersObj;
Map<String, List<String>> filters = new HashMap<>();
for (Map.Entry<String, Object> entry : rawFilters.entrySet()) {
if (entry.getValue() instanceof List) {
filters.put(entry.getKey(), (List<String>) entry.getValue());
} else if (entry.getValue() instanceof String) {
filters.put(entry.getKey(), List.of((String) entry.getValue()));
}
}
return filters;
}
if (filtersObj instanceof String filterStr) {
try {
Map<String, Object> parsed = JsonUtils.readValue(filterStr, Map.class);
Map<String, List<String>> filters = new HashMap<>();
for (Map.Entry<String, Object> entry : parsed.entrySet()) {
if (entry.getValue() instanceof List) {
filters.put(entry.getKey(), (List<String>) entry.getValue());
} else if (entry.getValue() instanceof String) {
filters.put(entry.getKey(), List.of((String) entry.getValue()));
}
}
return filters;
} catch (Exception e) {
LOG.warn("Failed to parse filters string: {}", filterStr, e);
}
}
return Collections.emptyMap();
}
private Map<String, Object> errorResponse(String message) {
Map<String, Object> result = new HashMap<>();
result.put("results", Collections.emptyList());
result.put("totalFound", 0);
result.put("returnedCount", 0);
result.put("error", message);
return result;
}
}
@@ -0,0 +1,138 @@
package org.openmetadata.mcp.tools;
import com.google.common.annotations.VisibleForTesting;
import java.util.Map;
import java.util.Optional;
import lombok.extern.slf4j.Slf4j;
import org.openmetadata.mcp.util.McpParams;
import org.openmetadata.mcp.util.McpResponseTrim;
import org.openmetadata.mcp.util.PageCursor;
import org.openmetadata.mcp.util.ResponseBudget;
import org.openmetadata.schema.tests.TestPlatform;
import org.openmetadata.schema.type.Include;
import org.openmetadata.schema.type.MetadataOperation;
import org.openmetadata.schema.utils.JsonUtils;
import org.openmetadata.service.Entity;
import org.openmetadata.service.jdbi3.ListFilter;
import org.openmetadata.service.jdbi3.TestDefinitionRepository;
import org.openmetadata.service.limits.Limits;
import org.openmetadata.service.security.Authorizer;
import org.openmetadata.service.security.auth.CatalogSecurityContext;
import org.openmetadata.service.security.policyevaluator.OperationContext;
import org.openmetadata.service.security.policyevaluator.ResourceContext;
@Slf4j
public class TestDefinitionsTool implements McpTool {
private static final int DEFAULT_LIMIT = 10;
private static final int MAX_LIMIT = 50;
private static final String DEFAULT_ENTITY_TYPE = "TABLE";
@Override
public Map<String, Object> execute(
Authorizer authorizer,
CatalogSecurityContext catalogSecurityContext,
Map<String, Object> params) {
authorizer.authorize(
catalogSecurityContext,
new OperationContext(Entity.TEST_DEFINITION, MetadataOperation.VIEW_BASIC),
new ResourceContext<>(Entity.TEST_DEFINITION));
int limit = resolveLimit(params);
String entityType = stringParam(params, "entityType", DEFAULT_ENTITY_TYPE);
String testPlatform = stringParam(params, "testPlatform", TestPlatform.OPEN_METADATA.value());
String after = keysetCursorOrAfter(params);
LOG.info(
"Listing test definitions for entityType: {}, testPlatform: {}, limit: {}",
entityType,
testPlatform,
limit);
Map<String, Object> page =
listWithinBudget(buildFilter(entityType, testPlatform), limit, after);
attachPagingContract(page);
return page;
}
private static String keysetCursorOrAfter(Map<String, Object> params) {
String after = stringParam(params, "after", null);
Optional<PageCursor.Cursor> cursor = PageCursor.decode(stringParam(params, "cursor", null));
if (cursor.isPresent() && !cursor.get().isOffset()) {
after = cursor.get().after();
}
return after;
}
/**
* Surfaces the repository's native keyset cursor as the unified {@code nextCursor}/{@code hasMore}/
* {@code total} markers. {@code listAfter} sets {@code paging.after} to the token for the next page
* (null on the last page); wrapping it in {@link PageCursor} keeps the wire contract identical to
* the offset-based tools while preserving keyset write-stability underneath.
*/
@VisibleForTesting
static void attachPagingContract(Map<String, Object> page) {
if (page.get("paging") instanceof Map<?, ?> paging) {
Object total = paging.get("total");
if (total != null) {
page.put(McpResponseTrim.TOTAL_KEY, total);
}
if (paging.get("after") instanceof String nativeAfter && !nativeAfter.isBlank()) {
page.put(McpResponseTrim.HAS_MORE_KEY, Boolean.TRUE);
page.put(McpResponseTrim.NEXT_CURSOR_KEY, PageCursor.encodeKeyset(nativeAfter));
}
}
}
private static int resolveLimit(Map<String, Object> params) {
int limit = McpParams.getInt(params, "limit", DEFAULT_LIMIT);
return Math.min(Math.max(limit, 1), MAX_LIMIT);
}
private static String stringParam(Map<String, Object> params, String key, String defaultValue) {
return params.containsKey(key) ? (String) params.get(key) : defaultValue;
}
private static ListFilter buildFilter(String entityType, String testPlatform) {
ListFilter filter = new ListFilter(Include.NON_DELETED);
if (entityType != null) {
filter.addQueryParam("entityType", entityType);
}
if (testPlatform != null) {
filter.addQueryParam("testPlatform", testPlatform);
}
return filter;
}
/**
* Keeps the page under the dispatch-level size cap by requesting fewer test definitions, never by
* mangling the ones returned. Because {@code listAfter} produces the {@code after} cursor for the
* exact window it returns, shrinking the limit and refetching keeps native cursor pagination
* consistent (no test definition is skipped on the next page).
*/
private static Map<String, Object> listWithinBudget(ListFilter filter, int limit, String after) {
TestDefinitionRepository repository =
(TestDefinitionRepository) Entity.getEntityRepository(Entity.TEST_DEFINITION);
long budget = ResponseBudget.defaultBudgetChars();
int pageLimit = limit;
Map<String, Object> page = listPage(repository, filter, pageLimit, after);
while (pageLimit > 1 && McpResponseTrim.serializedLength(page) > budget) {
pageLimit = pageLimit / 2;
page = listPage(repository, filter, pageLimit, after);
}
return page;
}
private static Map<String, Object> listPage(
TestDefinitionRepository repository, ListFilter filter, int limit, String after) {
return JsonUtils.getMap(
repository.listAfter(null, repository.getFields("*"), filter, limit, after));
}
@Override
public Map<String, Object> execute(
Authorizer authorizer,
Limits limits,
CatalogSecurityContext catalogSecurityContext,
Map<String, Object> map) {
throw new UnsupportedOperationException(
"TestDefinitionsTool does not require limit validation.");
}
}
@@ -0,0 +1,95 @@
/*
* Copyright 2025 Collate
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.openmetadata.mcp.usage;
import org.openmetadata.schema.entity.app.AppExtension;
import org.openmetadata.schema.entity.app.mcp.McpToolCallUsage;
import org.openmetadata.schema.utils.JsonUtils;
import org.openmetadata.service.Entity;
import org.openmetadata.service.apps.bundles.mcp.McpAppConstants;
import org.openmetadata.service.jdbi3.CollectionDAO;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Best-effort one-row-per-call writer for MCP tool invocations. Records to the
* {@code apps_extension_time_series} table reusing the {@code limits} extension type — the same
* per-app usage bucket CollateAI writes to. Rows are isolated from other apps by
* {@code appName='McpApplication'}, so the shared extension causes no cross-talk. Pure tracking.
* No billing, no enforcement, no rate-limiting. A recording failure must never break the tool
* call, so every code path catches and logs.
*/
public final class McpUsageRecorder {
private static final Logger LOG = LoggerFactory.getLogger(McpUsageRecorder.class);
private McpUsageRecorder() {}
/**
* Records a tool invocation with the full Phase 3 payload. The legacy 3-arg overload below is
* kept so existing call sites and tests compile unchanged. New call sites should call this one
* directly with the latency timer reading + error category + client name they already have.
*
* @param toolName name of the tool that was invoked
* @param userName principal name from the security context
* @param success true when the tool returned without an error result
* @param latencyMs wall-clock duration in milliseconds, or null when timing wasn't captured
* @param errorCategory bucket the failure falls into (null on success or when we couldn't
* classify the exception)
* @param clientName best-effort name of the calling client (Claude Desktop / Cursor / VS Code /
* CLI), or null when the client didn't identify itself
*/
public static void record(
String toolName,
String userName,
boolean success,
Long latencyMs,
McpToolCallUsage.ErrorCategory errorCategory,
String clientName) {
try {
McpToolCallUsage usage =
new McpToolCallUsage()
.withAppId(McpAppConstants.MCP_APP_ID)
.withAppName(McpAppConstants.MCP_APP_NAME)
.withTimestamp(System.currentTimeMillis())
.withExtension(AppExtension.ExtensionType.LIMITS)
.withToolName(toolName)
.withUserName(userName)
.withSuccess(success)
.withLatencyMs(latencyMs)
.withErrorCategory(errorCategory)
.withClientName(clientName);
getDao().insert(JsonUtils.pojoToJson(usage), AppExtension.ExtensionType.LIMITS.toString());
} catch (Exception e) {
LOG.warn(
"Failed to record MCP usage for tool={} user={} success={}: {}",
toolName,
userName,
success,
e.getMessage());
}
}
/**
* Backwards-compatible overload. New call sites should use the 6-arg variant so the row gets
* the full Phase 3 payload.
*/
public static void record(String toolName, String userName, boolean success) {
record(toolName, userName, success, null, null, null);
}
private static CollectionDAO.AppExtensionTimeSeries getDao() {
return Entity.getCollectionDAO().appExtensionTimeSeriesDao();
}
}
@@ -0,0 +1,71 @@
package org.openmetadata.mcp.util;
import java.util.Map;
/**
* Lenient coercion of MCP tool arguments. MCP clients send arguments as an untyped {@code
* Map<String, Object>} where a numeric value may arrive as a JSON number <em>or</em> a string, so
* every read tool was carrying its own {@code parseIntParam}/{@code parseBooleanParam}/{@code
* parseDoubleParam} copy. This centralizes that logic.
*
* <p>All three readers share the same fall-through contract: a missing key, a present-but-null value
* and an unparseable value all yield the supplied default. Range clamping (depth/size bounds) stays
* with the caller because the valid range is tool-specific.
*/
public final class McpParams {
private McpParams() {}
public static int getInt(Map<String, Object> params, String key, int defaultValue) {
int result = defaultValue;
Object value = params.get(key);
if (value instanceof Number number) {
result = number.intValue();
} else if (value instanceof String string) {
result = parseInt(string, defaultValue);
}
return result;
}
public static double getDouble(Map<String, Object> params, String key, double defaultValue) {
double result = defaultValue;
Object value = params.get(key);
if (value instanceof Number number) {
result = number.doubleValue();
} else if (value instanceof String string) {
result = parseDouble(string, defaultValue);
}
return result;
}
public static boolean getBoolean(Map<String, Object> params, String key, boolean defaultValue) {
boolean result = defaultValue;
Object value = params.get(key);
if (value instanceof Boolean bool) {
result = bool;
} else if (value instanceof String string) {
result = Boolean.parseBoolean(string);
}
return result;
}
private static int parseInt(String value, int defaultValue) {
int result = defaultValue;
try {
result = Integer.parseInt(value);
} catch (NumberFormatException e) {
result = defaultValue;
}
return result;
}
private static double parseDouble(String value, double defaultValue) {
double result = defaultValue;
try {
result = Double.parseDouble(value);
} catch (NumberFormatException e) {
result = defaultValue;
}
return result;
}
}
@@ -0,0 +1,140 @@
package org.openmetadata.mcp.util;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.openmetadata.schema.utils.JsonUtils;
/**
* Shared payload-trimming primitives for MCP tools. The truncation budgets, the response size cap,
* the {@code substring(0, n) + "..."} truncate logic and the embedding/vector field list were each
* copy-pasted across {@code GetLineageTool}, {@code RootCauseAnalysisTool}, {@code
* SearchMetadataTool} and {@code SemanticSearchTool}. Centralizing them here makes a change to any
* limit a one-line edit and keeps every tool's trimming behaviour identical.
*
* <p>This holds primitives only — each tool keeps ownership of <em>what</em> to trim. Tools that can
* trim intelligently (drop whole results, emit an actionable depth hint) do so themselves; {@link
* #oversizedEnvelope} is only the generic last-resort stub used by the dispatch-level safety net.
*/
public final class McpResponseTrim {
/** SQL is the single heaviest lineage field; keep the gist of the transform, cap the size. */
public static final int SQL_MAX_LENGTH = 500;
/**
* Free-text markdown (pipeline / edge descriptions) is capped for the same reason as SQL: a single
* long doc string can be shared across many edges and reintroduce bloat.
*/
public static final int TEXT_MAX_LENGTH = 500;
/** Description length above which the search/semantic tools truncate. */
public static final int DESCRIPTION_MAX_LENGTH = 500;
/**
* Where an over-length description is cut. Sits below {@link #DESCRIPTION_MAX_LENGTH} so the result
* lands comfortably under the threshold rather than right at it.
*/
public static final int DESCRIPTION_TRUNCATE_LENGTH = 450;
/** Final safety net: even slimmed, a wide payload can blow the LLM/MCP context limit. */
public static final int MAX_RESPONSE_CHARS = 100_000;
/**
* Machine-readable marker keys shared by tools, the dispatch layer and MCP clients. A tool signals
* a logical failure with {@link #ERROR_KEY} (optionally {@link #STATUS_CODE_KEY}); a partial-but-
* successful response with {@link #TRUNCATED_KEY} (dispatch floor) or {@link #HAS_MORE_KEY} plus
* {@link #NEXT_OFFSET_KEY} (per-tool paging). The dispatch layer reads {@link #ERROR_KEY} to set
* the protocol {@code isError} flag; the paging keys are the contract the unified-pagination work
* builds on. Centralizing the strings keeps tool output and dispatch detection from drifting apart.
*/
public static final String ERROR_KEY = "error";
public static final String STATUS_CODE_KEY = "statusCode";
public static final String TRUNCATED_KEY = "truncated";
public static final String RESPONSE_SIZE_CHARS_KEY = "responseSizeChars";
public static final String MAX_RESPONSE_CHARS_KEY = "maxResponseChars";
public static final String HAS_MORE_KEY = "hasMore";
public static final String NEXT_OFFSET_KEY = "nextOffset";
public static final String NEXT_CURSOR_KEY = "nextCursor";
public static final String TOTAL_KEY = "total";
public static final String MESSAGE_KEY = "message";
/**
* Elasticsearch index-only document fields — the embedding vector and the RAG source/context text
* used to build it. Each adds several kB per node and carries no value for an LLM reading the
* result, so search/lineage documents drop them before returning.
*/
public static final List<String> VECTOR_NOISE_FIELDS =
List.of(
"embeddings",
"embedding",
"textToEmbed",
"textToLLMContext",
"fingerprint",
"chunkCount",
"chunkIndex");
private static final String NO_MESSAGE = "<no message>";
private McpResponseTrim() {}
/** Cuts {@code value} to {@code maxLength} characters plus an ellipsis when it is longer. */
public static String truncate(String value, int maxLength) {
String result = value;
if (value != null && value.length() > maxLength) {
result = value.substring(0, maxLength) + "...";
}
return result;
}
/**
* Truncates a free-text description using the search/semantic convention: only when it exceeds
* {@link #DESCRIPTION_MAX_LENGTH}, cut to {@link #DESCRIPTION_TRUNCATE_LENGTH} so the truncated
* result sits below the threshold. Distinct from {@link #truncate(String, int)} (cut-at-max) on
* purpose — collapsing the two would change the output of half the tools.
*/
public static String truncateDescription(String value) {
String result = value;
if (value != null && value.length() > DESCRIPTION_MAX_LENGTH) {
result = value.substring(0, DESCRIPTION_TRUNCATE_LENGTH) + "...";
}
return result;
}
/** Serialized JSON length of a result, used by the size-budget checks. */
public static int serializedLength(Object result) {
return JsonUtils.pojoToJson(result).length();
}
/**
* Generic oversized-response envelope for the dispatch-level safety net (tools without a smarter
* per-tool trim). Stays on the success path ({@code truncated:true}) because a deliberate cap is
* not a failure the caller can retry. Merges the supplied identity fields so the client can still
* tell which call was capped.
*/
public static Map<String, Object> oversizedEnvelope(
int sizeChars, Map<String, Object> identity, String advice) {
Map<String, Object> envelope = new LinkedHashMap<>();
if (identity != null) {
envelope.putAll(identity);
}
envelope.put(TRUNCATED_KEY, Boolean.TRUE);
envelope.put(RESPONSE_SIZE_CHARS_KEY, sizeChars);
envelope.put(MAX_RESPONSE_CHARS_KEY, MAX_RESPONSE_CHARS);
envelope.put(MESSAGE_KEY, advice);
return envelope;
}
/**
* Returns the throwable's message, or a placeholder when it is null, so a client-facing payload
* never renders {@code "... null"}. Use only for the message surfaced to the caller — logging
* should keep passing the throwable itself for the stack trace.
*/
public static String safeMessage(Throwable t) {
String result = NO_MESSAGE;
if (t != null && t.getMessage() != null) {
result = t.getMessage();
}
return result;
}
}
@@ -0,0 +1,97 @@
package org.openmetadata.mcp.util;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.Map;
import java.util.Optional;
import lombok.extern.slf4j.Slf4j;
import org.openmetadata.schema.utils.JsonUtils;
/**
* Opaque page-cursor codec shared by the paginating MCP read tools. A cursor is the base64 of a tiny
* JSON tag ({@code {"t":"o","v":60}} for an offset page, {@code {"t":"k","v":"<afterId>"}} for a
* keyset page). Tools hand the encoded string back as {@code nextCursor}; the LLM client echoes it
* verbatim on the next call. Because the client never inspects the token, each tool can carry
* whatever paging state is correct for it (ES {@code from} offset, repository {@code listAfter} id)
* behind one uniform wire field.
*
* <p>{@link #decode} never throws: a null, blank or malformed token yields {@link Optional#empty()}
* so the tool falls back to the first page rather than surfacing an error the client cannot act on.
*/
@Slf4j
public final class PageCursor {
private static final String TYPE_KEY = "t";
private static final String VALUE_KEY = "v";
private static final String OFFSET_TAG = "o";
private static final String KEYSET_TAG = "k";
private PageCursor() {}
/** Paging mechanism a decoded cursor represents. */
public enum Kind {
OFFSET,
KEYSET
}
/**
* Decoded cursor. {@code offset} is meaningful only for {@link Kind#OFFSET}; {@code after} only for
* {@link Kind#KEYSET}. Use {@link #isOffset()} to branch.
*/
public record Cursor(Kind kind, int offset, String after) {
public boolean isOffset() {
return kind == Kind.OFFSET;
}
}
/** Encodes an offset page (start index of the next page). */
public static String encodeOffset(int from) {
return encode(OFFSET_TAG, from);
}
/** Encodes a keyset page (the repository {@code after} id for the next page). */
public static String encodeKeyset(String after) {
return encode(KEYSET_TAG, after);
}
/** Decodes a token to a cursor, or empty when the token is absent or unusable. */
public static Optional<Cursor> decode(String token) {
Optional<Cursor> result = Optional.empty();
if (token != null && !token.isBlank()) {
result = tryDecode(token);
}
return result;
}
private static Optional<Cursor> tryDecode(String token) {
Optional<Cursor> result = Optional.empty();
try {
byte[] raw = Base64.getUrlDecoder().decode(token);
Map<String, Object> map =
JsonUtils.readValue(new String(raw, StandardCharsets.UTF_8), Map.class);
result = fromMap(map);
} catch (Exception ex) {
LOG.debug("Ignoring malformed page cursor '{}': {}", token, ex.getMessage());
}
return result;
}
private static Optional<Cursor> fromMap(Map<String, Object> map) {
Optional<Cursor> result = Optional.empty();
Object type = map.get(TYPE_KEY);
Object value = map.get(VALUE_KEY);
if (OFFSET_TAG.equals(type) && value instanceof Number number) {
result = Optional.of(new Cursor(Kind.OFFSET, number.intValue(), null));
} else if (KEYSET_TAG.equals(type) && value instanceof String after) {
result = Optional.of(new Cursor(Kind.KEYSET, 0, after));
}
return result;
}
private static String encode(String type, Object value) {
String json = JsonUtils.pojoToJson(Map.of(TYPE_KEY, type, VALUE_KEY, value));
return Base64.getUrlEncoder()
.withoutPadding()
.encodeToString(json.getBytes(StandardCharsets.UTF_8));
}
}
@@ -0,0 +1,102 @@
/*
* Copyright 2025 Collate
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.openmetadata.mcp.util;
import java.util.List;
/**
* Shared size-budgeting for MCP read tools. Every tool that returns a list of items (search
* results, columns, lineage nodes) must keep its serialized response under the dispatch-level {@link
* McpResponseTrim#MAX_RESPONSE_CHARS} cap, or the dispatch floor discards the entire payload and
* returns a data-less stub. The correct way to stay under the cap is to return <em>fewer items</em>,
* never to mangle the content of the items that are kept.
*
* <p>The one rule this enforces: measure each item's <em>actual</em> serialized size and include
* items until the budget is reached. A single proportional guess (target = count * budget / total)
* can undershoot when item sizes vary, leaving the response above the cap and re-triggering the
* empty-stub nuke; this counts exactly instead.
*/
public final class ResponseBudget {
/**
* Fraction of {@link McpResponseTrim#MAX_RESPONSE_CHARS} an item list may occupy, leaving headroom
* for the surrounding metadata (query echo, counts, markers) and the serialization overhead of the
* enclosing structure so the assembled response lands below the hard cap rather than at it.
*/
public static final double DEFAULT_BUDGET_FACTOR = 0.8;
private ResponseBudget() {}
/** How many leading items fit and how many chars they consumed. */
public record Fit(int count, long usedChars) {}
/**
* Fits leading items of {@code items} within {@code budgetChars}, measuring each item's real
* serialized size, and returns both the count and the chars consumed. Guarantees forward progress:
* when the first item alone exceeds a positive budget, one item is still returned. Callers with two
* lists sharing one budget (e.g. upstream/downstream edges) use the returned {@code usedChars} to
* hand the remainder to the second list.
*
* <p>Residual: a single item whose serialized size exceeds {@link
* McpResponseTrim#MAX_RESPONSE_CHARS} is inherently un-pageable without truncating its content
* (which this design refuses to do). Forward progress still returns that one item, so the assembled
* response can exceed the cap; the dispatch floor ({@code
* DefaultToolContext.serializeWithinBudget}) then replaces it with an actionable {@code truncated}
* envelope (tool name, size, cap, advice) rather than a silent empty stub. See {@code
* ResponseBudgetTest#singleItemOverMaxResponseCharsStillReturnsOne}.
*/
public static Fit fitWithin(List<?> items, long budgetChars) {
long used = 0;
int fit = 0;
for (int i = 0; i < items.size(); i++) {
long next = used + McpResponseTrim.serializedLength(items.get(i)) + 1;
if (next > budgetChars) {
break;
}
used = next;
fit = i + 1;
}
boolean firstItemOverflows = fit == 0 && !items.isEmpty() && budgetChars > 0;
if (firstItemOverflows) {
fit = 1;
used = McpResponseTrim.serializedLength(items.getFirst()) + 1;
}
return new Fit(fit, used);
}
/** Default item budget: {@link #DEFAULT_BUDGET_FACTOR} of the dispatch-level cap. */
public static long defaultBudgetChars() {
return (long) (McpResponseTrim.MAX_RESPONSE_CHARS * DEFAULT_BUDGET_FACTOR);
}
/**
* Returns how many leading items of {@code items} fit within {@code budgetChars} once {@code
* overheadChars} (the serialized size of everything except the items) is accounted for. Items are
* measured one by one with {@link McpResponseTrim#serializedLength(Object)} and added while they
* fit.
*
* <p>Guarantees forward progress for paging: when the overhead still leaves room but the very
* first item alone exceeds the remaining budget, one item is returned rather than zero, so a
* caller advancing by the returned count never stalls on the same offset. When the overhead itself
* exceeds the budget, zero items are returned (the caller keeps its metadata and must not claim
* more is reachable).
*/
public static int fitCount(List<?> items, long overheadChars, long budgetChars) {
return fitWithin(items, budgetChars - overheadChars).count();
}
/** Convenience overload using {@link #defaultBudgetChars()}. */
public static int fitCount(List<?> items, long overheadChars) {
return fitCount(items, overheadChars, defaultBudgetChars());
}
}
@@ -0,0 +1,57 @@
package org.openmetadata.mcp.util;
import java.util.Map;
import java.util.Optional;
import org.openmetadata.service.search.vector.utils.DTOs.VectorSearchResponse;
public final class VectorPagingContract {
private VectorPagingContract() {}
public static void attach(
Map<String, Object> result,
int from,
int rawCount,
int requestedSize,
VectorSearchResponse response,
String pageMessage) {
int returned =
result.get("returnedCount") instanceof Number number ? number.intValue() : rawCount;
boolean budgetTrimmed = returned < rawCount;
boolean fullPage = rawCount >= requestedSize;
boolean moreInIndex = hasMoreInIndex(response, from, rawCount);
boolean canAdvance = returned > 0;
if (canAdvance && (budgetTrimmed || (fullPage && moreInIndex))) {
result.put(McpResponseTrim.HAS_MORE_KEY, Boolean.TRUE);
result.put(McpResponseTrim.NEXT_CURSOR_KEY, PageCursor.encodeOffset(from + returned));
} else if (!canAdvance) {
result.remove(McpResponseTrim.HAS_MORE_KEY);
result.remove(McpResponseTrim.MESSAGE_KEY);
}
if (fullPage && !budgetTrimmed && moreInIndex && pageMessage != null) {
result.put(McpResponseTrim.MESSAGE_KEY, String.format(pageMessage, returned));
}
}
public static int cursorOffsetOrDefault(Map<String, Object> params, int defaultFrom) {
String token = params.get("cursor") instanceof String value ? value : null;
Optional<PageCursor.Cursor> cursor = PageCursor.decode(token);
int from = defaultFrom;
if (cursor.isPresent() && cursor.get().isOffset()) {
from = cursor.get().offset();
}
return from;
}
// hasMore is preferred over totalHits because the vector service groups chunks by parent entity;
// totalHits reflects raw ES hits (multiple chunks per parent) and overstates paginable results.
static boolean hasMoreInIndex(VectorSearchResponse response, int from, int rawCount) {
if (response.getHasMore() != null) {
return response.getHasMore();
}
if (response.getTotalHits() != null) {
return (long) from + rawCount < response.getTotalHits();
}
return false;
}
}
@@ -0,0 +1,23 @@
{
"tools": [
{
"name": "search_metadata",
"description": "Creates a prompt for Searching metadata in OpenMetadata.",
"arguments": [
{
"name": "query",
"description": "Keywords to use for searching.",
"required": true
},
{
"name": "entityType",
"description": "Entity Type to Filter Report."
},
{
"name": "limit",
"description": "Maximum number of results to return. Default is 10. Try to keep this number low unless the user asks for more."
}
]
}
]
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,99 @@
/*
* Copyright 2025 Collate
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.openmetadata.mcp;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
/**
* Unit-level coverage for the User-Agent -> client name heuristic. The headers we recognise here
* are the ones the Billing > MCP page renders explicitly; an unknown UA still produces a sensible
* label so a new client surfaces in the per-user breakdown without an extractor change.
*/
class AuthEnrichedMcpContextExtractorTest {
@Test
void recognisesClaudeDesktop() {
assertThat(
AuthEnrichedMcpContextExtractor.resolveClientName(
"Claude-Desktop/1.4.2 (macOS; arm64)"))
.isEqualTo("Claude Desktop");
}
@Test
void recognisesClaudeCli() {
assertThat(AuthEnrichedMcpContextExtractor.resolveClientName("claude-cli/0.9.1"))
.isEqualTo("Claude CLI");
}
@Test
void recognisesCursor() {
assertThat(AuthEnrichedMcpContextExtractor.resolveClientName("Cursor/0.42.3"))
.isEqualTo("Cursor");
}
@Test
void recognisesVSCode() {
assertThat(AuthEnrichedMcpContextExtractor.resolveClientName("Visual Studio Code/1.92.0"))
.isEqualTo("VS Code");
}
@Test
void vsCodeWithClaudeExtensionDoesNotMisclassifyAsCli() {
assertThat(
AuthEnrichedMcpContextExtractor.resolveClientName(
"Visual Studio Code/1.92.0 claude-ext/1.0"))
.isEqualTo("VS Code");
assertThat(AuthEnrichedMcpContextExtractor.resolveClientName("vscode-claude-ext/0.1"))
.isEqualTo("VS Code");
}
@Test
void claudeCodeIsRecognisedAsCli() {
assertThat(AuthEnrichedMcpContextExtractor.resolveClientName("claude-code/0.9.1"))
.isEqualTo("Claude CLI");
}
@Test
void unknownAgentFallsBackToCapitalisedProductToken() {
assertThat(AuthEnrichedMcpContextExtractor.resolveClientName("zed/0.150")).isEqualTo("Zed");
assertThat(AuthEnrichedMcpContextExtractor.resolveClientName("someTool/2.0 extra/info"))
.isEqualTo("SomeTool");
}
@Test
void nullAndBlankAgentsReturnNull() {
assertThat(AuthEnrichedMcpContextExtractor.resolveClientName(null)).isNull();
assertThat(AuthEnrichedMcpContextExtractor.resolveClientName("")).isNull();
assertThat(AuthEnrichedMcpContextExtractor.resolveClientName(" ")).isNull();
}
@Test
void oversizedFallbackTokenIsCappedToMaxLength() {
String huge = "x".repeat(500) + "/1.0";
String resolved = AuthEnrichedMcpContextExtractor.resolveClientName(huge);
assertThat(resolved).isNotNull();
assertThat(resolved.length()).isLessThanOrEqualTo(64);
}
@Test
void controlCharactersAreStrippedFromResolvedClient() {
String userAgent = "MyTool\u0001\u0007/1.0";
assertThat(AuthEnrichedMcpContextExtractor.resolveClientName(userAgent)).isEqualTo("MyTool");
}
}
@@ -0,0 +1,226 @@
package org.openmetadata.mcp;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import io.modelcontextprotocol.common.McpTransportContext;
import io.modelcontextprotocol.server.McpStatelessServerFeatures;
import io.modelcontextprotocol.spec.McpSchema;
import java.security.Principal;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.openmetadata.mcp.tools.DefaultToolContext;
import org.openmetadata.service.limits.Limits;
import org.openmetadata.service.security.Authorizer;
import org.openmetadata.service.security.ImpersonationContext;
import org.openmetadata.service.security.JwtFilter;
import org.openmetadata.service.security.auth.CatalogSecurityContext;
/**
* Tests that MCP tool execution correctly sets ImpersonationContext on the execution thread.
*
* <p>Key invariant: ImpersonationContext must be set on the TOOL EXECUTION thread, not on the
* Jetty servlet thread that handles OAuth token validation. ThreadLocal values do not cross thread
* boundaries.
*/
public class McpImpersonationTest {
@AfterEach
void clearContext() {
ImpersonationContext.clear();
}
/**
* Verifies that when a tool is registered via McpServer.getTool(), the tool callback receives
* ImpersonationContext set to the MCP bot name on the calling thread.
*/
@Test
void toolCallbackSetsImpersonationContextOnExecutionThread() {
AtomicReference<String> capturedImpersonation = new AtomicReference<>();
JwtFilter jwtFilter = mock(JwtFilter.class);
CatalogSecurityContext securityContext = mock(CatalogSecurityContext.class);
Principal principal = mock(Principal.class);
when(principal.getName()).thenReturn("admin");
when(securityContext.getUserPrincipal()).thenReturn(principal);
when(jwtFilter.getCatalogSecurityContext(anyString())).thenReturn(securityContext);
Authorizer authorizer = mock(Authorizer.class);
Limits limits = mock(Limits.class);
DefaultToolContext toolContext = mock(DefaultToolContext.class);
doAnswer(
invocation -> {
capturedImpersonation.set(ImpersonationContext.getImpersonatedBy());
return new DefaultToolContext.CallToolOutcome(
McpSchema.CallToolResult.builder()
.content(List.of(new McpSchema.TextContent("{}")))
.isError(false)
.build(),
0L,
null);
})
.when(toolContext)
.callToolWithMetadata(any(), any(), anyString(), any(), any());
TestMcpServer server = new TestMcpServer(toolContext, jwtFilter, authorizer, limits);
McpSchema.Tool tool = McpSchema.Tool.builder().name("test_tool").description("desc").build();
McpStatelessServerFeatures.SyncToolSpecification spec = server.buildToolSpec(tool);
McpTransportContext context =
McpTransportContext.create(Map.of("Authorization", "Bearer test-token"));
spec.callHandler().apply(context, mock(McpSchema.CallToolRequest.class));
assertThat(capturedImpersonation.get())
.as("ImpersonationContext must be set to MCP bot name on the tool execution thread")
.isEqualTo("McpApplicationBot");
}
/**
* Verifies that ImpersonationContext is cleared after tool execution completes, preventing
* ThreadLocal leaks on Reactor thread pool reuse.
*/
@Test
void impersonationContextClearedAfterToolExecution() {
JwtFilter jwtFilter = mock(JwtFilter.class);
CatalogSecurityContext securityContext = mock(CatalogSecurityContext.class);
Principal principal = mock(Principal.class);
when(principal.getName()).thenReturn("admin");
when(securityContext.getUserPrincipal()).thenReturn(principal);
when(jwtFilter.getCatalogSecurityContext(anyString())).thenReturn(securityContext);
DefaultToolContext toolContext = mock(DefaultToolContext.class);
when(toolContext.callToolWithMetadata(any(), any(), anyString(), any(), any()))
.thenReturn(
new DefaultToolContext.CallToolOutcome(
McpSchema.CallToolResult.builder()
.content(List.of(new McpSchema.TextContent("{}")))
.isError(false)
.build(),
0L,
null));
TestMcpServer server =
new TestMcpServer(toolContext, jwtFilter, mock(Authorizer.class), mock(Limits.class));
McpSchema.Tool tool = McpSchema.Tool.builder().name("test_tool").description("desc").build();
McpStatelessServerFeatures.SyncToolSpecification spec = server.buildToolSpec(tool);
McpTransportContext context =
McpTransportContext.create(Map.of("Authorization", "Bearer token"));
spec.callHandler().apply(context, mock(McpSchema.CallToolRequest.class));
assertThat(ImpersonationContext.getImpersonatedBy())
.as(
"ImpersonationContext must be cleared after tool execution to prevent ThreadLocal leaks")
.isNull();
}
/**
* Verifies that ImpersonationContext is cleared even when tool execution throws an exception.
*/
@Test
void impersonationContextClearedOnToolException() {
JwtFilter jwtFilter = mock(JwtFilter.class);
CatalogSecurityContext securityContext = mock(CatalogSecurityContext.class);
Principal principal = mock(Principal.class);
when(principal.getName()).thenReturn("admin");
when(securityContext.getUserPrincipal()).thenReturn(principal);
when(jwtFilter.getCatalogSecurityContext(anyString())).thenReturn(securityContext);
DefaultToolContext toolContext = mock(DefaultToolContext.class);
when(toolContext.callToolWithMetadata(any(), any(), eq("error_tool"), any(), any()))
.thenThrow(new RuntimeException("tool failed"));
TestMcpServer server =
new TestMcpServer(toolContext, jwtFilter, mock(Authorizer.class), mock(Limits.class));
McpSchema.Tool tool = McpSchema.Tool.builder().name("error_tool").description("desc").build();
McpStatelessServerFeatures.SyncToolSpecification spec = server.buildToolSpec(tool);
try {
McpTransportContext context =
McpTransportContext.create(Map.of("Authorization", "Bearer token"));
spec.callHandler().apply(context, mock(McpSchema.CallToolRequest.class));
} catch (Exception ignored) {
}
assertThat(ImpersonationContext.getImpersonatedBy())
.as("ImpersonationContext must be cleared even when tool throws an exception")
.isNull();
}
/**
* Verifies that getMcpBotName() falls back to DEFAULT_MCP_BOT_NAME without caching it, so that
* subsequent calls retry the app registry once it becomes available.
*/
@Test
void getMcpBotName_fallsBackToDefaultWithoutCaching() {
JwtFilter jwtFilter = mock(JwtFilter.class);
CatalogSecurityContext securityContext = mock(CatalogSecurityContext.class);
Principal principal = mock(Principal.class);
when(principal.getName()).thenReturn("admin");
when(securityContext.getUserPrincipal()).thenReturn(principal);
when(jwtFilter.getCatalogSecurityContext(anyString())).thenReturn(securityContext);
AtomicReference<String> firstCall = new AtomicReference<>();
AtomicReference<String> secondCall = new AtomicReference<>();
DefaultToolContext toolContext = mock(DefaultToolContext.class);
doAnswer(
invocation -> {
if (firstCall.get() == null) {
firstCall.set(ImpersonationContext.getImpersonatedBy());
} else {
secondCall.set(ImpersonationContext.getImpersonatedBy());
}
return new DefaultToolContext.CallToolOutcome(
McpSchema.CallToolResult.builder()
.content(List.of(new McpSchema.TextContent("{}")))
.isError(false)
.build(),
0L,
null);
})
.when(toolContext)
.callToolWithMetadata(any(), any(), anyString(), any(), any());
TestMcpServer server =
new TestMcpServer(toolContext, jwtFilter, mock(Authorizer.class), mock(Limits.class));
McpSchema.Tool tool = McpSchema.Tool.builder().name("test_tool").description("desc").build();
McpStatelessServerFeatures.SyncToolSpecification spec = server.buildToolSpec(tool);
McpTransportContext context =
McpTransportContext.create(Map.of("Authorization", "Bearer token"));
spec.callHandler().apply(context, mock(McpSchema.CallToolRequest.class));
spec.callHandler().apply(context, mock(McpSchema.CallToolRequest.class));
assertThat(firstCall.get())
.as("First call with no app registered must still fall back to McpApplicationBot")
.isEqualTo("McpApplicationBot");
assertThat(secondCall.get())
.as("Second call must also use McpApplicationBot (retry path works)")
.isEqualTo("McpApplicationBot");
}
/** Test-only subclass of McpServer that exposes the tool spec builder for unit testing. */
static class TestMcpServer extends McpServer {
TestMcpServer(
DefaultToolContext toolContext, JwtFilter jwtFilter, Authorizer authorizer, Limits limits) {
super(toolContext, null);
this.jwtFilter = jwtFilter;
this.authorizer = authorizer;
this.limits = limits;
}
McpStatelessServerFeatures.SyncToolSpecification buildToolSpec(McpSchema.Tool tool) {
return getTool(tool);
}
}
}
@@ -0,0 +1,235 @@
package org.openmetadata.mcp;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import io.modelcontextprotocol.common.McpTransportContext;
import io.modelcontextprotocol.json.jackson2.JacksonMcpJsonMapper;
import io.modelcontextprotocol.spec.McpSchema;
import jakarta.servlet.http.HttpServletRequest;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.openmetadata.schema.utils.JsonUtils;
/**
* Tests to verify the MCP SDK 1.1.0 upgrade works correctly. These tests validate the API changes
* made during the upgrade from 0.17.1 to 1.1.0.
*/
public class McpSdkUpgradeTest {
@Test
void testJacksonMcpJsonMapperCreation() {
// Verify JacksonMcpJsonMapper can be created with our ObjectMapper
JacksonMcpJsonMapper jsonMapper = new JacksonMcpJsonMapper(JsonUtils.getObjectMapper());
assertThat(jsonMapper).isNotNull();
}
@Test
void testMcpTransportContextCreation() {
// Verify immutable McpTransportContext can be created with metadata
Map<String, Object> metadata = Map.of("Authorization", "Bearer test-token", "key2", "value2");
McpTransportContext context = McpTransportContext.create(metadata);
assertThat(context).isNotNull();
assertThat(context.get("Authorization")).isEqualTo("Bearer test-token");
assertThat(context.get("key2")).isEqualTo("value2");
}
@Test
void testMcpTransportContextWithEmptyMetadata() {
// Verify context can be created with empty metadata
McpTransportContext context = McpTransportContext.create(Map.of());
assertThat(context).isNotNull();
}
@Test
void testToolBuilderPattern() {
// Verify Tool can be created using builder pattern with JSON schema string
JacksonMcpJsonMapper jsonMapper = new JacksonMcpJsonMapper(JsonUtils.getObjectMapper());
String schemaJson =
"""
{
"type": "object",
"properties": {
"query": {"type": "string", "description": "Search query"}
},
"required": ["query"]
}
""";
McpSchema.Tool tool =
McpSchema.Tool.builder()
.name("test_tool")
.description("A test tool")
.inputSchema(jsonMapper, schemaJson)
.build();
assertThat(tool).isNotNull();
assertThat(tool.name()).isEqualTo("test_tool");
assertThat(tool.description()).isEqualTo("A test tool");
assertThat(tool.inputSchema()).isNotNull();
}
@Test
void testAuthEnrichedMcpContextExtractorInterface() {
// Verify our context extractor compiles and works with the new interface
AuthEnrichedMcpContextExtractor extractor = new AuthEnrichedMcpContextExtractor();
assertThat(extractor).isNotNull();
}
@Test
void testMcpSchemaPromptCreation() {
// Verify Prompt can still be created (no breaking changes here)
McpSchema.Prompt prompt =
new McpSchema.Prompt("test_prompt", "A test prompt description", null);
assertThat(prompt).isNotNull();
assertThat(prompt.name()).isEqualTo("test_prompt");
assertThat(prompt.description()).isEqualTo("A test prompt description");
}
@Test
void testJsonRpcMessageDeserialization() throws Exception {
// Verify JSON-RPC message deserialization works with new McpJsonMapper
JacksonMcpJsonMapper jsonMapper = new JacksonMcpJsonMapper(JsonUtils.getObjectMapper());
String jsonRpcMessage =
"""
{
"jsonrpc": "2.0",
"id": "test-id",
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {"name": "test", "version": "1.0"}
}
}
""";
McpSchema.JSONRPCMessage message =
McpSchema.deserializeJsonRpcMessage(jsonMapper, jsonRpcMessage);
assertThat(message).isNotNull();
assertThat(message).isInstanceOf(McpSchema.JSONRPCRequest.class);
McpSchema.JSONRPCRequest request = (McpSchema.JSONRPCRequest) message;
assertThat(request.id()).isEqualTo("test-id");
assertThat(request.method()).isEqualTo("initialize");
}
@Test
void testAuthEnrichedMcpContextExtractorExtractsToken() {
// Test that the extractor correctly extracts Authorization header
AuthEnrichedMcpContextExtractor extractor = new AuthEnrichedMcpContextExtractor();
HttpServletRequest mockRequest = mock(HttpServletRequest.class);
when(mockRequest.getHeader("Authorization")).thenReturn("Bearer my-jwt-token");
McpTransportContext context = extractor.extract(mockRequest);
assertThat(context).isNotNull();
// JwtFilter.extractToken strips "Bearer " prefix
assertThat(context.get("Authorization")).isEqualTo("my-jwt-token");
}
@Test
void testAuthEnrichedMcpContextExtractorHandlesMissingToken() {
// Test that the extractor throws exception for missing Authorization header
// This is expected behavior - MCP endpoint requires authentication
AuthEnrichedMcpContextExtractor extractor = new AuthEnrichedMcpContextExtractor();
HttpServletRequest mockRequest = mock(HttpServletRequest.class);
when(mockRequest.getHeader("Authorization")).thenReturn(null);
// JwtFilter.extractToken throws AuthenticationException for null token
org.junit.jupiter.api.Assertions.assertThrows(
Exception.class,
() -> extractor.extract(mockRequest),
"Should throw exception for missing Authorization header");
}
@Test
void testMcpUtilsGetToolPropertiesLoadsTools() {
// Test that McpUtils.getToolProperties loads tools from JSON file using new builder pattern
List<McpSchema.Tool> tools = McpUtils.getToolProperties("json/data/mcp/tools.json");
assertThat(tools).isNotNull();
assertThat(tools).isNotEmpty();
// Verify at least one expected tool is present
boolean hasSearchMetadataTool =
tools.stream().anyMatch(tool -> "search_metadata".equals(tool.name()));
assertThat(hasSearchMetadataTool).isTrue();
// Verify tool has required properties
McpSchema.Tool searchTool =
tools.stream().filter(t -> "search_metadata".equals(t.name())).findFirst().orElse(null);
assertThat(searchTool).isNotNull();
assertThat(searchTool.description()).isNotNull();
assertThat(searchTool.inputSchema()).isNotNull();
}
@Test
void testServerCapabilitiesDoNotAdvertiseLogging() {
// The stateless MCP server has no handler for logging/setLevel.
// Advertising logging capability causes spec-compliant clients (e.g. VSCode)
// to call logging/setLevel, which fails with MethodNotFound.
McpSchema.ServerCapabilities capabilities =
McpSchema.ServerCapabilities.builder()
.tools(true)
.prompts(true)
.resources(true, true)
.build();
assertThat(capabilities.logging()).isNull();
}
@Test
void testElicitationCapabilitiesDeserialization() throws Exception {
// Regression test for https://github.com/open-metadata/OpenMetadata/issues/26454
// MCP clients implementing the 2025-11-25 spec send elicitation with form/url fields.
// SDK <= 0.17.1 threw UnrecognizedPropertyException crashing the handshake.
JacksonMcpJsonMapper jsonMapper = new JacksonMcpJsonMapper(JsonUtils.getObjectMapper());
String initRequest =
"""
{
"jsonrpc": "2.0",
"id": 0,
"method": "initialize",
"params": {
"protocolVersion": "2025-11-25",
"capabilities": {
"roots": {},
"elicitation": {
"form": {},
"url": {}
}
},
"clientInfo": {"name": "claude-code", "version": "2.1.74"}
}
}
""";
McpSchema.JSONRPCMessage message = McpSchema.deserializeJsonRpcMessage(jsonMapper, initRequest);
assertThat(message).isNotNull();
assertThat(message).isInstanceOf(McpSchema.JSONRPCRequest.class);
McpSchema.JSONRPCRequest request = (McpSchema.JSONRPCRequest) message;
assertThat(request.method()).isEqualTo("initialize");
}
@Test
void testMcpUtilsGetPromptsLoadsPrompts() {
// Test that McpUtils.getPrompts loads prompts from JSON file
List<McpSchema.Prompt> prompts = McpUtils.getPrompts("json/data/mcp/prompts.json");
assertThat(prompts).isNotNull();
// Prompts may be empty depending on configuration, but should not throw
}
}
@@ -0,0 +1,122 @@
package org.openmetadata.mcp.auth;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.net.URI;
import java.util.List;
import org.junit.jupiter.api.Test;
class OAuthClientMetadataTest {
@Test
void testValidateScope_nullReturnsNull() throws InvalidScopeException {
OAuthClientMetadata metadata = new OAuthClientMetadata();
metadata.setScope("read write");
assertThat(metadata.validateScope(null)).isNull();
}
@Test
void testValidateScope_noRegisteredScopesAllowsAny() throws InvalidScopeException {
OAuthClientMetadata metadata = new OAuthClientMetadata();
metadata.setScope(null);
List<String> result = metadata.validateScope("custom admin");
assertThat(result).containsExactly("custom", "admin");
}
@Test
void testValidateScope_emptyRegisteredScopesAllowsAny() throws InvalidScopeException {
OAuthClientMetadata metadata = new OAuthClientMetadata();
metadata.setScope(" ");
List<String> result = metadata.validateScope("anything");
assertThat(result).containsExactly("anything");
}
@Test
void testValidateScope_validSubsetAccepted() throws InvalidScopeException {
OAuthClientMetadata metadata = new OAuthClientMetadata();
metadata.setScope("read write admin");
List<String> result = metadata.validateScope("read write");
assertThat(result).containsExactly("read", "write");
}
@Test
void testValidateScope_invalidScopeThrows() {
OAuthClientMetadata metadata = new OAuthClientMetadata();
metadata.setScope("read write");
assertThatThrownBy(() -> metadata.validateScope("admin"))
.isInstanceOf(InvalidScopeException.class)
.hasMessageContaining("admin");
}
@Test
void testValidateRedirectUri_exactMatchSucceeds() throws InvalidRedirectUriException {
OAuthClientMetadata metadata = new OAuthClientMetadata();
URI uri = URI.create("https://example.com/callback");
metadata.setRedirectUris(List.of(uri, URI.create("https://other.com/cb")));
URI result = metadata.validateRedirectUri(uri);
assertThat(result).isEqualTo(uri);
}
@Test
void testValidateRedirectUri_noMatchThrows() {
OAuthClientMetadata metadata = new OAuthClientMetadata();
metadata.setRedirectUris(List.of(URI.create("https://example.com/callback")));
assertThatThrownBy(() -> metadata.validateRedirectUri(URI.create("https://evil.com/callback")))
.isInstanceOf(InvalidRedirectUriException.class)
.hasMessageContaining("not registered");
}
@Test
void testValidateRedirectUri_nullWithSingleRegisteredReturnsSingle()
throws InvalidRedirectUriException {
OAuthClientMetadata metadata = new OAuthClientMetadata();
URI uri = URI.create("https://example.com/callback");
metadata.setRedirectUris(List.of(uri));
URI result = metadata.validateRedirectUri(null);
assertThat(result).isEqualTo(uri);
}
@Test
void testValidateRedirectUri_nullWithMultipleRegisteredThrows() {
OAuthClientMetadata metadata = new OAuthClientMetadata();
metadata.setRedirectUris(
List.of(URI.create("https://a.com/cb"), URI.create("https://b.com/cb")));
assertThatThrownBy(() -> metadata.validateRedirectUri(null))
.isInstanceOf(InvalidRedirectUriException.class)
.hasMessageContaining("must be specified");
}
@Test
void testValidateRedirectUri_noRegisteredUrisThrows() {
OAuthClientMetadata metadata = new OAuthClientMetadata();
metadata.setRedirectUris(null);
assertThatThrownBy(() -> metadata.validateRedirectUri(URI.create("https://example.com/cb")))
.isInstanceOf(InvalidRedirectUriException.class)
.hasMessageContaining("No redirect URIs registered");
}
@Test
void testDefaultConstructorSetsDefaults() {
OAuthClientMetadata metadata = new OAuthClientMetadata();
assertThat(metadata.getTokenEndpointAuthMethod()).isEqualTo("client_secret_post");
assertThat(metadata.getGrantTypes()).containsExactly("authorization_code", "refresh_token");
assertThat(metadata.getResponseTypes()).containsExactly("code");
}
}
@@ -0,0 +1,236 @@
package org.openmetadata.mcp.server.auth.handlers;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
import java.net.URI;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.openmetadata.mcp.auth.InvalidRedirectUriException;
import org.openmetadata.mcp.auth.InvalidScopeException;
import org.openmetadata.mcp.auth.OAuthAuthorizationServerProvider;
import org.openmetadata.mcp.auth.OAuthClientInformation;
import org.openmetadata.mcp.auth.exception.AuthorizeException;
@ExtendWith(MockitoExtension.class)
class AuthorizationHandlerTest {
@Mock private OAuthAuthorizationServerProvider provider;
@Mock private OAuthClientInformation client;
private AuthorizationHandler handler;
@BeforeEach
void setUp() {
handler = new AuthorizationHandler(provider);
}
private Map<String, String> validParams() {
Map<String, String> params = new HashMap<>();
params.put("client_id", "test-client");
params.put("response_type", "code");
params.put("code_challenge", "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
params.put("code_challenge_method", "S256");
params.put("state", "state123");
params.put("redirect_uri", "https://example.com/callback");
return params;
}
@Test
void testMissingClientId_returnsInvalidRequest() {
Map<String, String> params = validParams();
params.remove("client_id");
AuthorizationHandler.AuthorizationResponse response = handler.handle(params).join();
assertThat(response.isSuccess()).isFalse();
assertThat(response.getError().getError()).isEqualTo("invalid_request");
assertThat(response.isRedirect()).isFalse();
}
@Test
void testMissingResponseType_returnsInvalidRequest() {
Map<String, String> params = validParams();
params.remove("response_type");
AuthorizationHandler.AuthorizationResponse response = handler.handle(params).join();
assertThat(response.isSuccess()).isFalse();
assertThat(response.getError().getError()).isEqualTo("invalid_request");
}
@Test
void testMissingCodeChallenge_returnsInvalidRequest() {
Map<String, String> params = validParams();
params.remove("code_challenge");
AuthorizationHandler.AuthorizationResponse response = handler.handle(params).join();
assertThat(response.isSuccess()).isFalse();
assertThat(response.getError().getError()).isEqualTo("invalid_request");
}
@Test
void testUnsupportedResponseType_returnsUnsupportedResponseType() {
Map<String, String> params = validParams();
params.put("response_type", "token");
AuthorizationHandler.AuthorizationResponse response = handler.handle(params).join();
assertThat(response.isSuccess()).isFalse();
assertThat(response.getError().getError()).isEqualTo("unsupported_response_type");
}
@Test
void testInvalidCodeChallengeMethod_returnsInvalidRequest() {
Map<String, String> params = validParams();
params.put("code_challenge_method", "plain");
AuthorizationHandler.AuthorizationResponse response = handler.handle(params).join();
assertThat(response.isSuccess()).isFalse();
assertThat(response.getError().getError()).isEqualTo("invalid_request");
assertThat(response.getError().getErrorDescription()).contains("S256");
}
@Test
void testClientNotFound_returnsInvalidRequest() {
when(provider.getClient("test-client")).thenReturn(CompletableFuture.completedFuture(null));
AuthorizationHandler.AuthorizationResponse response = handler.handle(validParams()).join();
assertThat(response.isSuccess()).isFalse();
assertThat(response.getError().getError()).isEqualTo("invalid_request");
assertThat(response.getError().getErrorDescription()).contains("Client ID not found");
}
@Test
void testInvalidRedirectUri_returnsInvalidRequest() throws InvalidRedirectUriException {
when(provider.getClient("test-client")).thenReturn(CompletableFuture.completedFuture(client));
when(client.validateRedirectUri(any()))
.thenThrow(new InvalidRedirectUriException("URI not registered"));
AuthorizationHandler.AuthorizationResponse response = handler.handle(validParams()).join();
assertThat(response.isSuccess()).isFalse();
assertThat(response.getError().getError()).isEqualTo("invalid_request");
assertThat(response.isRedirect()).isFalse();
}
@Test
void testInvalidScope_returnsInvalidScopeWithRedirect()
throws InvalidRedirectUriException, InvalidScopeException {
URI redirectUri = URI.create("https://example.com/callback");
when(provider.getClient("test-client")).thenReturn(CompletableFuture.completedFuture(client));
when(client.validateRedirectUri(any())).thenReturn(redirectUri);
when(client.validateScope("admin")).thenThrow(new InvalidScopeException("Not allowed"));
Map<String, String> params = validParams();
params.put("scope", "admin");
AuthorizationHandler.AuthorizationResponse response = handler.handle(params).join();
assertThat(response.isSuccess()).isFalse();
assertThat(response.getError().getError()).isEqualTo("invalid_scope");
assertThat(response.isRedirect()).isTrue();
assertThat(response.getRedirectUrl()).contains("error=invalid_scope");
}
@Test
void testSuccessfulAuthCodeGeneration_returnsRedirectWithCode()
throws InvalidRedirectUriException, InvalidScopeException, AuthorizeException {
URI redirectUri = URI.create("https://example.com/callback");
when(provider.getClient("test-client")).thenReturn(CompletableFuture.completedFuture(client));
when(client.validateRedirectUri(any())).thenReturn(redirectUri);
when(client.validateScope(any())).thenReturn(null);
when(provider.authorize(eq(client), any()))
.thenReturn(CompletableFuture.completedFuture("auth-code-123"));
AuthorizationHandler.AuthorizationResponse response = handler.handle(validParams()).join();
assertThat(response.isSuccess()).isTrue();
assertThat(response.isRedirect()).isTrue();
assertThat(response.getRedirectUrl()).contains("code=auth-code-123");
assertThat(response.getRedirectUrl()).contains("state=state123");
}
@Test
void testSuccessfulSSORedirect_returnsRedirectUrl()
throws InvalidRedirectUriException, InvalidScopeException, AuthorizeException {
URI redirectUri = URI.create("https://example.com/callback");
when(provider.getClient("test-client")).thenReturn(CompletableFuture.completedFuture(client));
when(client.validateRedirectUri(any())).thenReturn(redirectUri);
when(client.validateScope(any())).thenReturn(null);
when(provider.authorize(eq(client), any()))
.thenReturn(
CompletableFuture.completedFuture("https://accounts.google.com/o/oauth2/auth?..."));
AuthorizationHandler.AuthorizationResponse response = handler.handle(validParams()).join();
assertThat(response.isSuccess()).isTrue();
assertThat(response.isRedirect()).isTrue();
assertThat(response.getRedirectUrl()).startsWith("https://accounts.google.com");
}
@Test
void testSSORedirectInitiated_returnsNoRedirect()
throws InvalidRedirectUriException, InvalidScopeException, AuthorizeException {
URI redirectUri = URI.create("https://example.com/callback");
when(provider.getClient("test-client")).thenReturn(CompletableFuture.completedFuture(client));
when(client.validateRedirectUri(any())).thenReturn(redirectUri);
when(client.validateScope(any())).thenReturn(null);
when(provider.authorize(eq(client), any()))
.thenReturn(CompletableFuture.completedFuture("SSO_REDIRECT_INITIATED"));
AuthorizationHandler.AuthorizationResponse response = handler.handle(validParams()).join();
assertThat(response.isSuccess()).isTrue();
assertThat(response.isRedirect()).isFalse();
assertThat(response.getRedirectUrl()).isNull();
}
@Test
void testAuthorizeException_returnsErrorWithRedirect()
throws InvalidRedirectUriException, InvalidScopeException, AuthorizeException {
URI redirectUri = URI.create("https://example.com/callback");
when(provider.getClient("test-client")).thenReturn(CompletableFuture.completedFuture(client));
when(client.validateRedirectUri(any())).thenReturn(redirectUri);
when(client.validateScope(any())).thenReturn(null);
when(provider.authorize(eq(client), any()))
.thenReturn(
CompletableFuture.failedFuture(
new AuthorizeException("access_denied", "User denied access")));
AuthorizationHandler.AuthorizationResponse response = handler.handle(validParams()).join();
assertThat(response.isSuccess()).isFalse();
assertThat(response.getError().getError()).isEqualTo("access_denied");
assertThat(response.isRedirect()).isTrue();
assertThat(response.getRedirectUrl()).contains("error=access_denied");
}
@Test
void testUnexpectedException_returnsServerError()
throws InvalidRedirectUriException, InvalidScopeException, AuthorizeException {
URI redirectUri = URI.create("https://example.com/callback");
when(provider.getClient("test-client")).thenReturn(CompletableFuture.completedFuture(client));
when(client.validateRedirectUri(any())).thenReturn(redirectUri);
when(client.validateScope(any())).thenReturn(null);
when(provider.authorize(eq(client), any()))
.thenReturn(CompletableFuture.failedFuture(new RuntimeException("DB connection lost")));
AuthorizationHandler.AuthorizationResponse response = handler.handle(validParams()).join();
assertThat(response.isSuccess()).isFalse();
assertThat(response.getError().getError()).isEqualTo("server_error");
assertThat(response.isRedirect()).isTrue();
}
}
@@ -0,0 +1,387 @@
package org.openmetadata.mcp.server.auth.handlers;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import jakarta.servlet.ServletOutputStream;
import jakarta.servlet.WriteListener;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.ByteArrayOutputStream;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.lang.reflect.Constructor;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import org.junit.jupiter.api.Test;
import org.openmetadata.mcp.server.auth.provider.UserSSOOAuthProvider;
import org.openmetadata.mcp.server.auth.repository.McpPendingAuthRequestRepository;
import org.openmetadata.service.security.AuthenticationCodeFlowHandler;
class McpCallbackServletTest {
// ── existing test ────────────────────────────────────────────────────────
@Test
void bufferedResponseSendErrorSupportsExistingOutputStreamUsage() throws Exception {
HttpServletResponse delegateResponse = mock(HttpServletResponse.class);
ByteArrayOutputStream committedBody = new ByteArrayOutputStream();
when(delegateResponse.getOutputStream())
.thenReturn(new CapturingServletOutputStream(committedBody));
HttpServletResponse bufferedResponse = newBufferedResponse(delegateResponse);
bufferedResponse.getOutputStream().write("prefix:".getBytes(StandardCharsets.UTF_8));
assertDoesNotThrow(
() -> bufferedResponse.sendError(HttpServletResponse.SC_BAD_REQUEST, "invalid-request"));
commitTo(bufferedResponse, delegateResponse);
verify(delegateResponse).setStatus(HttpServletResponse.SC_BAD_REQUEST);
assertThat(committedBody.toString(StandardCharsets.UTF_8)).isEqualTo("prefix:invalid-request");
}
// ── serveFragmentExtractionPage ──────────────────────────────────────────
@Test
void serveFragmentExtractionPage_setsHtmlContentTypeAndStatus200() throws Exception {
StringWriter body = new StringWriter();
HttpServletResponse response = mock(HttpServletResponse.class);
when(response.getWriter()).thenReturn(new PrintWriter(body));
invokeServeFragmentExtractionPage(makeServlet(), response);
verify(response).setContentType("text/html;charset=UTF-8");
verify(response).setStatus(HttpServletResponse.SC_OK);
}
@Test
void serveFragmentExtractionPage_htmlUsesFormPostNotGetRedirect() throws Exception {
StringWriter body = new StringWriter();
HttpServletResponse response = mock(HttpServletResponse.class);
when(response.getWriter()).thenReturn(new PrintWriter(body));
invokeServeFragmentExtractionPage(makeServlet(), response);
String html = body.toString();
assertThat(html).contains("f.method = 'POST'");
assertThat(html).doesNotContain("window.location.replace");
assertThat(html).doesNotContain("?id_token=");
}
@Test
void serveFragmentExtractionPage_errorHandlerUsesTextContent() throws Exception {
StringWriter body = new StringWriter();
HttpServletResponse response = mock(HttpServletResponse.class);
when(response.getWriter()).thenReturn(new PrintWriter(body));
invokeServeFragmentExtractionPage(makeServlet(), response);
String html = body.toString();
assertThat(html).contains("document.body.textContent");
assertThat(html).doesNotContain("innerHTML");
}
// ── getServerOrigin: lazy cache ───────────────────────────────────────────
@Test
void getServerOrigin_cachesOnFirstResolution_resolveServerOriginCalledOnce() {
// resolveServerOrigin() should be called exactly once; subsequent isOriginAllowed()
// calls must use the cached value without hitting DB again.
int[] callCount = {0};
McpCallbackServlet servlet =
new McpCallbackServlet(
mock(UserSSOOAuthProvider.class), mock(McpPendingAuthRequestRepository.class)) {
@Override
String resolveServerOrigin() {
callCount[0]++;
return "https://example.getcollate.io";
}
};
HttpServletRequest req = mock(HttpServletRequest.class);
when(req.getHeader("Origin")).thenReturn("https://example.getcollate.io");
servlet.isOriginAllowed(req);
servlet.isOriginAllowed(req);
servlet.isOriginAllowed(req);
assertThat(callCount[0]).isEqualTo(1);
}
@Test
void getServerOrigin_doesNotCacheNullResult_retriesOnNextCall() {
// If resolveServerOrigin() returns null (transient DB miss), don't cache — retry next time.
int[] callCount = {0};
McpCallbackServlet servlet =
new McpCallbackServlet(
mock(UserSSOOAuthProvider.class), mock(McpPendingAuthRequestRepository.class)) {
@Override
String resolveServerOrigin() {
callCount[0]++;
return null;
}
};
HttpServletRequest req = mock(HttpServletRequest.class);
when(req.getHeader("Origin")).thenReturn("https://attacker.com");
servlet.isOriginAllowed(req);
servlet.isOriginAllowed(req);
assertThat(callCount[0]).isEqualTo(2);
}
// ── CSRF: isOriginAllowed ─────────────────────────────────────────────────
@Test
void isOriginAllowed_noOriginHeader_allowed() {
HttpServletRequest request = mock(HttpServletRequest.class);
when(request.getHeader("Origin")).thenReturn(null);
assertThat(makeServlet().isOriginAllowed(request)).isTrue();
}
// ── CSRF: resolveServerOrigin default-port stripping ─────────────────────
@Test
void resolveServerOrigin_stripsDefaultHttpsPort() throws Exception {
// baseUrl with explicit :443 → origin must not include :443 (browsers omit default ports)
String origin = invokeResolveServerOrigin("https://example.com:443");
assertThat(origin).isEqualTo("https://example.com");
}
@Test
void resolveServerOrigin_stripsDefaultHttpPort() throws Exception {
String origin = invokeResolveServerOrigin("http://example.com:80");
assertThat(origin).isEqualTo("http://example.com");
}
@Test
void resolveServerOrigin_keepsNonDefaultPort() throws Exception {
String origin = invokeResolveServerOrigin("https://example.com:8585");
assertThat(origin).isEqualTo("https://example.com:8585");
}
@Test
void resolveServerOrigin_noPort_returnsSchemePlusHost() throws Exception {
String origin = invokeResolveServerOrigin("https://devrel.getcollate.io");
assertThat(origin).isEqualTo("https://devrel.getcollate.io");
}
@Test
void isOriginAllowed_matchingOrigin_allowed() {
McpCallbackServlet servlet =
new McpCallbackServlet(
mock(UserSSOOAuthProvider.class), mock(McpPendingAuthRequestRepository.class)) {
@Override
String resolveServerOrigin() {
return "https://example.getcollate.io";
}
};
HttpServletRequest request = mock(HttpServletRequest.class);
when(request.getHeader("Origin")).thenReturn("https://example.getcollate.io");
assertThat(servlet.isOriginAllowed(request)).isTrue();
}
@Test
void isOriginAllowed_mismatchedOrigin_rejected() {
McpCallbackServlet servlet =
new McpCallbackServlet(
mock(UserSSOOAuthProvider.class), mock(McpPendingAuthRequestRepository.class)) {
@Override
String resolveServerOrigin() {
return "https://example.getcollate.io";
}
};
HttpServletRequest request = mock(HttpServletRequest.class);
when(request.getHeader("Origin")).thenReturn("https://malicious.attacker.com");
assertThat(servlet.isOriginAllowed(request)).isFalse();
}
@Test
void isOriginAllowed_unknownServerOrigin_rejectsPresentOrigin() {
// When server origin cannot be resolved (misconfiguration/startup race), a present
// Origin header must be rejected — not silently allowed — to keep CSRF protection active.
McpCallbackServlet servlet =
new McpCallbackServlet(
mock(UserSSOOAuthProvider.class), mock(McpPendingAuthRequestRepository.class)) {
@Override
String resolveServerOrigin() {
return null;
}
};
HttpServletRequest request = mock(HttpServletRequest.class);
when(request.getHeader("Origin")).thenReturn("https://attacker.example.com");
assertThat(servlet.isOriginAllowed(request)).isFalse();
}
@Test
void doPost_mismatchedOrigin_sends403() throws Exception {
McpCallbackServlet servlet =
new McpCallbackServlet(
mock(UserSSOOAuthProvider.class), mock(McpPendingAuthRequestRepository.class)) {
@Override
protected AuthenticationCodeFlowHandler resolveSsoHandler() {
return mock(AuthenticationCodeFlowHandler.class);
}
@Override
String resolveServerOrigin() {
return "https://example.getcollate.io";
}
};
HttpServletRequest request = mock(HttpServletRequest.class);
HttpServletResponse response = mock(HttpServletResponse.class);
when(request.getHeader("Origin")).thenReturn("https://malicious.attacker.com");
servlet.doPost(request, response);
verify(response)
.sendError(HttpServletResponse.SC_FORBIDDEN, McpCallbackServlet.ERR_CSRF_ORIGIN_MISMATCH);
}
// ── doPost: null SSO handler → 503 ───────────────────────────────────────
@Test
void doPost_noSsoHandler_sends503() throws Exception {
HttpServletRequest request = mock(HttpServletRequest.class);
HttpServletResponse response = mock(HttpServletResponse.class);
McpCallbackServlet servlet = makeServletWithHandler(null);
servlet.doPost(request, response);
verify(response)
.sendError(
HttpServletResponse.SC_SERVICE_UNAVAILABLE, McpCallbackServlet.ERR_SSO_UNAVAILABLE);
}
// ── doPost: missing id_token → 400 ───────────────────────────────────────
@Test
void doPost_nullIdToken_sends400() throws Exception {
HttpServletRequest request = mock(HttpServletRequest.class);
HttpServletResponse response = mock(HttpServletResponse.class);
when(request.getParameter("id_token")).thenReturn(null);
AuthenticationCodeFlowHandler handler = mock(AuthenticationCodeFlowHandler.class);
McpCallbackServlet servlet = makeServletWithHandler(handler);
servlet.doPost(request, response);
verify(response)
.sendError(HttpServletResponse.SC_BAD_REQUEST, McpCallbackServlet.ERR_MISSING_ID_TOKEN);
}
@Test
void doPost_emptyIdToken_sends400() throws Exception {
HttpServletRequest request = mock(HttpServletRequest.class);
HttpServletResponse response = mock(HttpServletResponse.class);
when(request.getParameter("id_token")).thenReturn("");
AuthenticationCodeFlowHandler handler = mock(AuthenticationCodeFlowHandler.class);
McpCallbackServlet servlet = makeServletWithHandler(handler);
servlet.doPost(request, response);
verify(response)
.sendError(HttpServletResponse.SC_BAD_REQUEST, McpCallbackServlet.ERR_MISSING_ID_TOKEN);
}
// ── helpers ──────────────────────────────────────────────────────────────
private static McpCallbackServlet makeServlet() {
return new McpCallbackServlet(
mock(UserSSOOAuthProvider.class), mock(McpPendingAuthRequestRepository.class));
}
/** Creates a servlet whose resolveSsoHandler() returns the given handler (null = no SSO). */
private static McpCallbackServlet makeServletWithHandler(AuthenticationCodeFlowHandler handler) {
return new McpCallbackServlet(
mock(UserSSOOAuthProvider.class), mock(McpPendingAuthRequestRepository.class)) {
@Override
protected AuthenticationCodeFlowHandler resolveSsoHandler() {
return handler;
}
};
}
/**
* Invokes resolveServerOrigin() on a servlet subclass that has the port-stripping logic but
* short-circuits the DB/config lookup, injecting the given baseUrl directly.
*/
private static String invokeResolveServerOrigin(String baseUrl) throws Exception {
McpCallbackServlet servlet =
new McpCallbackServlet(
mock(UserSSOOAuthProvider.class), mock(McpPendingAuthRequestRepository.class)) {
@Override
String resolveServerOrigin() {
try {
java.net.URI uri = java.net.URI.create(baseUrl);
int port = uri.getPort();
boolean isDefaultPort =
("https".equals(uri.getScheme()) && port == 443)
|| ("http".equals(uri.getScheme()) && port == 80);
String portPart = (port != -1 && !isDefaultPort) ? ":" + port : "";
return uri.getScheme() + "://" + uri.getHost() + portPart;
} catch (Exception e) {
return null;
}
}
};
return servlet.resolveServerOrigin();
}
private static void invokeServeFragmentExtractionPage(
McpCallbackServlet servlet, HttpServletResponse response) throws Exception {
Method m =
McpCallbackServlet.class.getDeclaredMethod(
"serveFragmentExtractionPage", HttpServletResponse.class);
m.setAccessible(true);
m.invoke(servlet, response);
}
private static HttpServletResponse newBufferedResponse(HttpServletResponse response)
throws Exception {
Class<?> wrapperClass =
Class.forName(
"org.openmetadata.mcp.server.auth.handlers.McpCallbackServlet$BufferedServletResponseWrapper");
Constructor<?> constructor = wrapperClass.getDeclaredConstructor(HttpServletResponse.class);
constructor.setAccessible(true);
return (HttpServletResponse) constructor.newInstance(response);
}
private static void commitTo(HttpServletResponse bufferedResponse, HttpServletResponse response)
throws Exception {
Method method =
bufferedResponse.getClass().getDeclaredMethod("commitTo", HttpServletResponse.class);
method.setAccessible(true);
method.invoke(bufferedResponse, response);
}
private static final class CapturingServletOutputStream extends ServletOutputStream {
private final ByteArrayOutputStream outputStream;
private CapturingServletOutputStream(ByteArrayOutputStream outputStream) {
this.outputStream = outputStream;
}
@Override
public void write(int b) {
outputStream.write(b);
}
@Override
public boolean isReady() {
return true;
}
@Override
public void setWriteListener(WriteListener writeListener) {}
}
}
@@ -0,0 +1,323 @@
package org.openmetadata.mcp.server.auth.handlers;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import java.net.URI;
import java.util.List;
import java.util.concurrent.CompletionException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.openmetadata.mcp.auth.OAuthClientInformation;
import org.openmetadata.mcp.auth.OAuthClientMetadata;
import org.openmetadata.mcp.auth.exception.RegistrationException;
import org.openmetadata.mcp.server.auth.repository.OAuthClientRepository;
@ExtendWith(MockitoExtension.class)
class RegistrationHandlerTest {
@Mock private OAuthClientRepository clientRepository;
private RegistrationHandler handler;
@BeforeEach
void setUp() {
handler = new RegistrationHandler(clientRepository);
}
private OAuthClientMetadata validMetadata() {
OAuthClientMetadata metadata = new OAuthClientMetadata();
metadata.setClientName("TestClient");
metadata.setRedirectUris(List.of(URI.create("https://example.com/callback")));
return metadata;
}
@Test
void testValidRegistration_generatesCredentials() {
OAuthClientInformation result = handler.handle(validMetadata()).join();
assertThat(result.getClientId()).isNotNull().isNotEmpty();
assertThat(result.getClientSecret()).isNotNull().isNotEmpty();
assertThat(result.getClientIdIssuedAt()).isGreaterThan(0);
assertThat(result.getClientSecretExpiresAt()).isEqualTo(0L);
verify(clientRepository).register(any(OAuthClientInformation.class));
}
@Test
void testValidRegistration_copiesMetadata() {
OAuthClientMetadata metadata = validMetadata();
metadata.setClientName("MyApp");
metadata.setScope("read write");
OAuthClientInformation result = handler.handle(metadata).join();
assertThat(result.getClientName()).isEqualTo("MyApp");
assertThat(result.getScope()).isEqualTo("read write");
assertThat(result.getRedirectUris())
.containsExactly(URI.create("https://example.com/callback"));
}
@Test
void testValidRegistration_defaultsGrantTypes() {
OAuthClientMetadata metadata = validMetadata();
metadata.setGrantTypes(null);
OAuthClientInformation result = handler.handle(metadata).join();
assertThat(result.getGrantTypes()).containsExactly("authorization_code", "refresh_token");
}
@Test
void testValidRegistration_defaultsResponseTypes() {
OAuthClientMetadata metadata = validMetadata();
metadata.setResponseTypes(null);
OAuthClientInformation result = handler.handle(metadata).join();
assertThat(result.getResponseTypes()).containsExactly("code");
}
@Test
void testValidRegistration_defaultsAuthMethod() {
OAuthClientMetadata metadata = validMetadata();
metadata.setTokenEndpointAuthMethod(null);
OAuthClientInformation result = handler.handle(metadata).join();
assertThat(result.getTokenEndpointAuthMethod()).isEqualTo("client_secret_post");
assertThat(result.getClientSecret()).isNotNull().isNotEmpty();
}
@Test
void testMissingRedirectUris_throwsRegistrationException() {
OAuthClientMetadata metadata = validMetadata();
metadata.setRedirectUris(null);
assertThatThrownBy(() -> handler.handle(metadata).join())
.isInstanceOf(CompletionException.class)
.hasCauseInstanceOf(RuntimeException.class)
.hasRootCauseInstanceOf(RegistrationException.class);
verify(clientRepository, never()).register(any());
}
@Test
void testEmptyRedirectUris_throwsRegistrationException() {
OAuthClientMetadata metadata = validMetadata();
metadata.setRedirectUris(List.of());
assertThatThrownBy(() -> handler.handle(metadata).join())
.isInstanceOf(CompletionException.class)
.hasRootCauseInstanceOf(RegistrationException.class);
verify(clientRepository, never()).register(any());
}
@Test
void testBlockedScheme_javascript_throwsRegistrationException() {
OAuthClientMetadata metadata = validMetadata();
metadata.setRedirectUris(List.of(URI.create("javascript:alert(1)")));
assertThatThrownBy(() -> handler.handle(metadata).join())
.isInstanceOf(CompletionException.class)
.hasRootCauseInstanceOf(RegistrationException.class);
verify(clientRepository, never()).register(any());
}
@Test
void testBlockedScheme_data_throwsRegistrationException() {
OAuthClientMetadata metadata = validMetadata();
metadata.setRedirectUris(List.of(URI.create("data:text/html,hello")));
assertThatThrownBy(() -> handler.handle(metadata).join())
.isInstanceOf(CompletionException.class)
.hasRootCauseInstanceOf(RegistrationException.class);
verify(clientRepository, never()).register(any());
}
@Test
void testFragmentInUri_throwsRegistrationException() {
OAuthClientMetadata metadata = validMetadata();
metadata.setRedirectUris(List.of(URI.create("https://example.com/cb#fragment")));
assertThatThrownBy(() -> handler.handle(metadata).join())
.isInstanceOf(CompletionException.class)
.hasRootCauseInstanceOf(RegistrationException.class);
verify(clientRepository, never()).register(any());
}
@Test
void testNonLoopbackHttp_throwsRegistrationException() {
OAuthClientMetadata metadata = validMetadata();
metadata.setRedirectUris(List.of(URI.create("http://example.com/callback")));
assertThatThrownBy(() -> handler.handle(metadata).join())
.isInstanceOf(CompletionException.class)
.hasRootCauseInstanceOf(RegistrationException.class);
verify(clientRepository, never()).register(any());
}
@Test
void testLoopbackHttp_accepted() {
OAuthClientMetadata metadata = validMetadata();
metadata.setRedirectUris(List.of(URI.create("http://localhost:8080/callback")));
OAuthClientInformation result = handler.handle(metadata).join();
assertThat(result.getClientId()).isNotNull();
verify(clientRepository).register(any());
}
@Test
void testLoopback127001Http_accepted() {
OAuthClientMetadata metadata = validMetadata();
metadata.setRedirectUris(List.of(URI.create("http://127.0.0.1:8080/callback")));
OAuthClientInformation result = handler.handle(metadata).join();
assertThat(result.getClientId()).isNotNull();
verify(clientRepository).register(any());
}
@Test
void testPrivateUseScheme_accepted() {
OAuthClientMetadata metadata = validMetadata();
metadata.setRedirectUris(List.of(URI.create("cursor://callback")));
OAuthClientInformation result = handler.handle(metadata).join();
assertThat(result.getClientId()).isNotNull();
verify(clientRepository).register(any());
}
@Test
void testUnsupportedGrantType_throwsRegistrationException() {
OAuthClientMetadata metadata = validMetadata();
metadata.setGrantTypes(List.of("implicit"));
assertThatThrownBy(() -> handler.handle(metadata).join())
.isInstanceOf(CompletionException.class)
.hasRootCauseInstanceOf(RegistrationException.class);
verify(clientRepository, never()).register(any());
}
@Test
void testUnsupportedResponseType_throwsRegistrationException() {
OAuthClientMetadata metadata = validMetadata();
metadata.setResponseTypes(List.of("token"));
assertThatThrownBy(() -> handler.handle(metadata).join())
.isInstanceOf(CompletionException.class)
.hasRootCauseInstanceOf(RegistrationException.class);
verify(clientRepository, never()).register(any());
}
@Test
void testClientSecretBasic_accepted() {
OAuthClientMetadata metadata = validMetadata();
metadata.setTokenEndpointAuthMethod("client_secret_basic");
OAuthClientInformation result = handler.handle(metadata).join();
assertThat(result.getTokenEndpointAuthMethod()).isEqualTo("client_secret_basic");
verify(clientRepository).register(any());
}
@Test
void testClientSecretPost_accepted() {
OAuthClientMetadata metadata = validMetadata();
metadata.setTokenEndpointAuthMethod("client_secret_post");
OAuthClientInformation result = handler.handle(metadata).join();
assertThat(result.getTokenEndpointAuthMethod()).isEqualTo("client_secret_post");
verify(clientRepository).register(any());
}
@Test
void testNoneAuthMethod_accepted() {
OAuthClientMetadata metadata = validMetadata();
metadata.setTokenEndpointAuthMethod("none");
OAuthClientInformation result = handler.handle(metadata).join();
assertThat(result.getTokenEndpointAuthMethod()).isEqualTo("none");
verify(clientRepository).register(any());
}
/**
* Regression for PR #28552: a public client (token_endpoint_auth_method=none) MUST NOT be issued a
* client secret. Before the fix the secret was generated unconditionally, so the token endpoint
* then demanded a secret the public PKCE client (Cursor, ChatGPT) never had, failing with
* "Client secret required" (401). Public clients are secured by PKCE, not a secret.
*/
@Test
void testPublicClient_none_issuesNoSecret() {
OAuthClientMetadata metadata = validMetadata();
metadata.setTokenEndpointAuthMethod("none");
OAuthClientInformation result = handler.handle(metadata).join();
assertThat(result.getClientSecret()).isNull();
assertThat(result.getClientSecretExpiresAt()).isNull();
verify(clientRepository).register(any());
}
@Test
void testConfidentialClient_clientSecretPost_issuesSecret() {
OAuthClientMetadata metadata = validMetadata();
metadata.setTokenEndpointAuthMethod("client_secret_post");
OAuthClientInformation result = handler.handle(metadata).join();
assertThat(result.getClientSecret()).isNotNull().isNotEmpty();
assertThat(result.getClientSecretExpiresAt()).isEqualTo(0L);
}
@Test
void testConfidentialClient_clientSecretBasic_issuesSecret() {
OAuthClientMetadata metadata = validMetadata();
metadata.setTokenEndpointAuthMethod("client_secret_basic");
OAuthClientInformation result = handler.handle(metadata).join();
assertThat(result.getClientSecret()).isNotNull().isNotEmpty();
assertThat(result.getClientSecretExpiresAt()).isEqualTo(0L);
}
@Test
void testUnsupportedAuthMethod_throwsRegistrationException() {
OAuthClientMetadata metadata = validMetadata();
metadata.setTokenEndpointAuthMethod("private_key_jwt");
assertThatThrownBy(() -> handler.handle(metadata).join())
.isInstanceOf(CompletionException.class)
.hasRootCauseInstanceOf(RegistrationException.class);
verify(clientRepository, never()).register(any());
}
@Test
void testFieldLengthExceeded_throwsRegistrationException() {
OAuthClientMetadata metadata = validMetadata();
metadata.setClientName("x".repeat(256));
assertThatThrownBy(() -> handler.handle(metadata).join())
.isInstanceOf(CompletionException.class)
.hasRootCauseInstanceOf(RegistrationException.class);
verify(clientRepository, never()).register(any());
}
}
@@ -0,0 +1,150 @@
package org.openmetadata.mcp.server.auth.handlers;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import java.util.concurrent.CompletableFuture;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.openmetadata.mcp.server.auth.repository.OAuthTokenRepository;
/**
* Unit tests for RevocationHandler - RFC 7009 compliant token revocation.
*
* <p>Tests cover:
* - Refresh token revocation
* - Access token revocation
* - RFC 7009 compliance (returns success for non-existent tokens)
* - Token type hint handling
* - Error handling and logging
*/
@ExtendWith(MockitoExtension.class)
public class RevocationHandlerTest {
@Mock private OAuthTokenRepository tokenRepository;
private RevocationHandler revocationHandler;
@BeforeEach
void setUp() {
revocationHandler = new RevocationHandler(tokenRepository);
}
@Test
void testRevokeRefreshToken() {
String token = "test-refresh-token";
CompletableFuture<Void> result = revocationHandler.revokeToken(token, "refresh_token");
assertThat(result).isNotNull();
result.join();
verify(tokenRepository).revokeRefreshToken(token);
verify(tokenRepository, never()).deleteAccessToken(anyString());
}
@Test
void testRevokeAccessToken() {
String token = "test-access-token";
CompletableFuture<Void> result = revocationHandler.revokeToken(token, "access_token");
assertThat(result).isNotNull();
result.join();
verify(tokenRepository).deleteAccessToken(token);
verify(tokenRepository, never()).revokeRefreshToken(anyString());
}
@Test
void testRevokeWithoutTypeHint() {
String token = "test-token";
CompletableFuture<Void> result = revocationHandler.revokeToken(token, null);
assertThat(result).isNotNull();
result.join();
verify(tokenRepository).revokeRefreshToken(token);
}
@Test
void testRevokeNonExistentToken() {
String token = "non-existent-token";
doThrow(new IllegalArgumentException("Token not found"))
.when(tokenRepository)
.revokeRefreshToken(token);
doThrow(new IllegalArgumentException("Token not found"))
.when(tokenRepository)
.deleteAccessToken(token);
CompletableFuture<Void> result = revocationHandler.revokeToken(token, null);
assertThat(result).isNotNull();
result.join();
verify(tokenRepository).revokeRefreshToken(token);
verify(tokenRepository).deleteAccessToken(token);
}
@Test
void testRevokeEmptyToken() {
CompletableFuture<Void> result = revocationHandler.revokeToken("", null);
assertThat(result).isNotNull();
result.join();
verify(tokenRepository, never()).revokeRefreshToken(anyString());
verify(tokenRepository, never()).deleteAccessToken(anyString());
}
@Test
void testRevokeNullToken() {
CompletableFuture<Void> result = revocationHandler.revokeToken(null, null);
assertThat(result).isNotNull();
result.join();
verify(tokenRepository, never()).revokeRefreshToken(anyString());
verify(tokenRepository, never()).deleteAccessToken(anyString());
}
@Test
void testRevokeWithRefreshTokenHintButIsAccessToken() {
String token = "access-token";
doThrow(new IllegalArgumentException("Token not found"))
.when(tokenRepository)
.revokeRefreshToken(token);
CompletableFuture<Void> result = revocationHandler.revokeToken(token, "refresh_token");
assertThat(result).isNotNull();
result.join();
verify(tokenRepository).revokeRefreshToken(token);
verify(tokenRepository, never()).deleteAccessToken(anyString());
}
@Test
void testRevokeWithAccessTokenHintButIsRefreshToken() {
String token = "refresh-token";
CompletableFuture<Void> result = revocationHandler.revokeToken(token, "access_token");
assertThat(result).isNotNull();
result.join();
verify(tokenRepository).deleteAccessToken(token);
verify(tokenRepository, never()).revokeRefreshToken(anyString());
}
@Test
void testRevokeWithInvalidTypeHint() {
String token = "test-token";
CompletableFuture<Void> result = revocationHandler.revokeToken(token, "invalid_hint");
assertThat(result).isNotNull();
result.join();
verify(tokenRepository, never()).revokeRefreshToken(anyString());
verify(tokenRepository, never()).deleteAccessToken(anyString());
}
}
@@ -0,0 +1,104 @@
package org.openmetadata.mcp.server.auth.jobs;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.MockedConstruction;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import org.openmetadata.mcp.server.auth.repository.McpPendingAuthRequestRepository;
import org.openmetadata.mcp.server.auth.repository.OAuthAuthorizationCodeRepository;
import org.openmetadata.mcp.server.auth.repository.OAuthTokenRepository;
import org.quartz.JobExecutionContext;
@ExtendWith(MockitoExtension.class)
class OAuthTokenCleanupJobTest {
@Test
void testAllCleanupStepsSucceed() {
try (MockedConstruction<OAuthTokenRepository> tokenRepoMock =
Mockito.mockConstruction(OAuthTokenRepository.class);
MockedConstruction<OAuthAuthorizationCodeRepository> codeRepoMock =
Mockito.mockConstruction(OAuthAuthorizationCodeRepository.class);
MockedConstruction<McpPendingAuthRequestRepository> pendingRepoMock =
Mockito.mockConstruction(McpPendingAuthRequestRepository.class)) {
OAuthTokenCleanupJob job = new OAuthTokenCleanupJob();
job.execute(mock(JobExecutionContext.class));
verify(tokenRepoMock.constructed().get(0)).deleteExpiredTokens();
verify(codeRepoMock.constructed().get(0)).deleteExpired();
verify(pendingRepoMock.constructed().get(0)).deleteExpired();
}
}
@Test
void testTokenCleanupFails_othersStillRun() {
try (MockedConstruction<OAuthTokenRepository> tokenRepoMock =
Mockito.mockConstruction(
OAuthTokenRepository.class,
(repo, ctx) ->
doThrow(new RuntimeException("DB error")).when(repo).deleteExpiredTokens());
MockedConstruction<OAuthAuthorizationCodeRepository> codeRepoMock =
Mockito.mockConstruction(OAuthAuthorizationCodeRepository.class);
MockedConstruction<McpPendingAuthRequestRepository> pendingRepoMock =
Mockito.mockConstruction(McpPendingAuthRequestRepository.class)) {
OAuthTokenCleanupJob job = new OAuthTokenCleanupJob();
job.execute(mock(JobExecutionContext.class));
verify(codeRepoMock.constructed().get(0)).deleteExpired();
verify(pendingRepoMock.constructed().get(0)).deleteExpired();
}
}
@Test
void testAuthCodeCleanupFails_othersStillRun() {
try (MockedConstruction<OAuthTokenRepository> tokenRepoMock =
Mockito.mockConstruction(OAuthTokenRepository.class);
MockedConstruction<OAuthAuthorizationCodeRepository> codeRepoMock =
Mockito.mockConstruction(
OAuthAuthorizationCodeRepository.class,
(repo, ctx) ->
doThrow(new RuntimeException("DB error")).when(repo).deleteExpired());
MockedConstruction<McpPendingAuthRequestRepository> pendingRepoMock =
Mockito.mockConstruction(McpPendingAuthRequestRepository.class)) {
OAuthTokenCleanupJob job = new OAuthTokenCleanupJob();
job.execute(mock(JobExecutionContext.class));
verify(tokenRepoMock.constructed().get(0)).deleteExpiredTokens();
verify(pendingRepoMock.constructed().get(0)).deleteExpired();
}
}
@Test
void testAllCleanupStepsFail_jobStillCompletes() {
try (MockedConstruction<OAuthTokenRepository> tokenRepoMock =
Mockito.mockConstruction(
OAuthTokenRepository.class,
(repo, ctx) ->
doThrow(new RuntimeException("DB error")).when(repo).deleteExpiredTokens());
MockedConstruction<OAuthAuthorizationCodeRepository> codeRepoMock =
Mockito.mockConstruction(
OAuthAuthorizationCodeRepository.class,
(repo, ctx) ->
doThrow(new RuntimeException("DB error")).when(repo).deleteExpired());
MockedConstruction<McpPendingAuthRequestRepository> pendingRepoMock =
Mockito.mockConstruction(
McpPendingAuthRequestRepository.class,
(repo, ctx) ->
doThrow(new RuntimeException("DB error")).when(repo).deleteExpired())) {
OAuthTokenCleanupJob job = new OAuthTokenCleanupJob();
job.execute(mock(JobExecutionContext.class));
verify(tokenRepoMock.constructed().get(0)).deleteExpiredTokens();
verify(codeRepoMock.constructed().get(0)).deleteExpired();
verify(pendingRepoMock.constructed().get(0)).deleteExpired();
}
}
}
@@ -0,0 +1,118 @@
package org.openmetadata.mcp.server.auth.middleware;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.when;
import at.favre.lib.crypto.bcrypt.BCrypt;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.openmetadata.mcp.auth.OAuthAuthorizationServerProvider;
import org.openmetadata.mcp.auth.OAuthClientInformation;
@ExtendWith(MockitoExtension.class)
class ClientAuthenticatorTest {
@Mock private OAuthAuthorizationServerProvider provider;
private ClientAuthenticator authenticator;
@BeforeEach
void setUp() {
authenticator = new ClientAuthenticator(provider);
}
@Test
void testNullClientId_failsWithMissingClientId() {
assertThatThrownBy(() -> authenticator.authenticate(null, "secret").join())
.isInstanceOf(CompletionException.class)
.hasCauseInstanceOf(ClientAuthenticator.AuthenticationException.class)
.hasRootCauseMessage("Missing client_id parameter");
}
@Test
void testClientNotFound_failsWithClientNotFound() {
when(provider.getClient("unknown")).thenReturn(CompletableFuture.completedFuture(null));
assertThatThrownBy(() -> authenticator.authenticate("unknown", "secret").join())
.isInstanceOf(CompletionException.class)
.hasCauseInstanceOf(ClientAuthenticator.AuthenticationException.class)
.hasRootCauseMessage("Client not found");
}
@Test
void testClientHasSecret_missingSecret_failsWithSecretRequired() {
OAuthClientInformation client = new OAuthClientInformation();
client.setClientId("client-1");
String hashedSecret = BCrypt.withDefaults().hashToString(4, "real-secret".toCharArray());
client.setClientSecret(hashedSecret);
when(provider.getClient("client-1")).thenReturn(CompletableFuture.completedFuture(client));
assertThatThrownBy(() -> authenticator.authenticate("client-1", null).join())
.isInstanceOf(CompletionException.class)
.hasCauseInstanceOf(ClientAuthenticator.AuthenticationException.class)
.hasRootCauseMessage("Client secret required");
}
@Test
void testClientHasSecret_wrongSecret_failsWithInvalidSecret() {
OAuthClientInformation client = new OAuthClientInformation();
client.setClientId("client-1");
String hashedSecret = BCrypt.withDefaults().hashToString(4, "correct-secret".toCharArray());
client.setClientSecret(hashedSecret);
when(provider.getClient("client-1")).thenReturn(CompletableFuture.completedFuture(client));
assertThatThrownBy(() -> authenticator.authenticate("client-1", "wrong-secret").join())
.isInstanceOf(CompletionException.class)
.hasCauseInstanceOf(ClientAuthenticator.AuthenticationException.class)
.hasRootCauseMessage("Invalid client secret");
}
@Test
void testClientHasSecret_correctSecret_succeeds() {
OAuthClientInformation client = new OAuthClientInformation();
client.setClientId("client-1");
String hashedSecret = BCrypt.withDefaults().hashToString(4, "correct-secret".toCharArray());
client.setClientSecret(hashedSecret);
when(provider.getClient("client-1")).thenReturn(CompletableFuture.completedFuture(client));
OAuthClientInformation result = authenticator.authenticate("client-1", "correct-secret").join();
assertThat(result).isSameAs(client);
}
@Test
void testPublicClient_noSecret_succeeds() {
OAuthClientInformation client = new OAuthClientInformation();
client.setClientId("public-client");
client.setClientSecret(null);
when(provider.getClient("public-client")).thenReturn(CompletableFuture.completedFuture(client));
OAuthClientInformation result = authenticator.authenticate("public-client", null).join();
assertThat(result).isSameAs(client);
}
@Test
void testPublicClient_secretProvided_stillSucceeds() {
OAuthClientInformation client = new OAuthClientInformation();
client.setClientId("public-client");
client.setClientSecret(null);
when(provider.getClient("public-client")).thenReturn(CompletableFuture.completedFuture(client));
OAuthClientInformation result =
authenticator.authenticate("public-client", "some-secret").join();
assertThat(result).isSameAs(client);
}
}
@@ -0,0 +1,48 @@
package org.openmetadata.mcp.server.auth.model;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.Map;
import org.junit.jupiter.api.Test;
class AuthorizationErrorResponseTest {
@Test
void testToQueryParams_allFieldsPresent() {
AuthorizationErrorResponse response =
new AuthorizationErrorResponse("invalid_request", "Missing client_id", "state123");
Map<String, String> params = response.toQueryParams();
assertThat(params).containsEntry("error", "invalid_request");
assertThat(params).containsEntry("error_description", "Missing client_id");
assertThat(params).containsEntry("state", "state123");
assertThat(params).hasSize(3);
}
@Test
void testToQueryParams_nullDescriptionOmitted() {
AuthorizationErrorResponse response =
new AuthorizationErrorResponse("server_error", null, "state123");
Map<String, String> params = response.toQueryParams();
assertThat(params).containsEntry("error", "server_error");
assertThat(params).containsEntry("state", "state123");
assertThat(params).doesNotContainKey("error_description");
assertThat(params).hasSize(2);
}
@Test
void testToQueryParams_nullStateOmitted() {
AuthorizationErrorResponse response =
new AuthorizationErrorResponse("invalid_scope", "Scope not allowed", null);
Map<String, String> params = response.toQueryParams();
assertThat(params).containsEntry("error", "invalid_scope");
assertThat(params).containsEntry("error_description", "Scope not allowed");
assertThat(params).doesNotContainKey("state");
assertThat(params).hasSize(2);
}
}
@@ -0,0 +1,103 @@
package org.openmetadata.mcp.server.auth.repository;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.List;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.openmetadata.mcp.auth.RefreshToken;
import org.openmetadata.service.fernet.Fernet;
import org.openmetadata.service.jdbi3.CollectionDAO;
/**
* Unit tests for OAuthTokenRepository - token storage, retrieval, revocation, and encryption.
*
* <p>Tests cover: - Refresh token storage with hashing and encryption - Token retrieval and
* decryption - Token revocation - Expired token cleanup - Security (hashing prevents raw lookup)
*
* <p>Note: These are mock-based unit tests. For integration tests with real database, see
* OAuthFlowIntegrationTest.
*/
@ExtendWith(MockitoExtension.class)
public class OAuthTokenRepositoryTest {
@Mock private CollectionDAO.OAuthRefreshTokenDAO refreshTokenDAO;
@Mock private Fernet fernet;
private OAuthTokenRepository tokenRepository;
@Test
void testRefreshTokenDataModel() {
String tokenValue = "test-refresh-token-" + UUID.randomUUID();
String clientId = "test-client-id";
String userName = "test-user";
List<String> scopes = List.of("read:metadata", "write:metadata");
long expiresAt = System.currentTimeMillis() + 3600000;
RefreshToken token = new RefreshToken(tokenValue, clientId, userName, scopes, expiresAt);
assertThat(token.getToken()).isEqualTo(tokenValue);
assertThat(token.getClientId()).isEqualTo(clientId);
assertThat(token.getUserName()).isEqualTo(userName);
assertThat(token.getScopes()).containsExactlyInAnyOrderElementsOf(scopes);
assertThat(token.getExpiresAt()).isEqualTo(expiresAt);
}
@Test
void testRefreshTokenSetters() {
RefreshToken token = new RefreshToken();
String tokenValue = "setter-test-token";
String clientId = "setter-client-id";
String userName = "setter-user";
List<String> scopes = List.of("read:metadata");
long expiresAt = System.currentTimeMillis() + 3600000;
token.setToken(tokenValue);
token.setClientId(clientId);
token.setUserName(userName);
token.setScopes(scopes);
token.setExpiresAt(expiresAt);
assertThat(token.getToken()).isEqualTo(tokenValue);
assertThat(token.getClientId()).isEqualTo(clientId);
assertThat(token.getUserName()).isEqualTo(userName);
assertThat(token.getScopes()).containsExactlyInAnyOrderElementsOf(scopes);
assertThat(token.getExpiresAt()).isEqualTo(expiresAt);
}
@Test
void testRefreshTokenExpiration() {
long currentTime = System.currentTimeMillis();
long expiryTime = currentTime + 30L * 24 * 60 * 60 * 1000;
RefreshToken token =
new RefreshToken("token", "client", "user", List.of("read:metadata"), expiryTime);
assertThat(token.getExpiresAt()).isGreaterThan(currentTime);
assertThat(token.getExpiresAt() - currentTime).isGreaterThan(29L * 24 * 60 * 60 * 1000);
}
@Test
void testRefreshTokenWithEmptyScopes() {
RefreshToken token =
new RefreshToken("token", "client", "user", List.of(), System.currentTimeMillis());
assertThat(token.getScopes()).isEmpty();
}
@Test
void testRefreshTokenWithMultipleScopes() {
List<String> scopes =
List.of("read:metadata", "write:metadata", "read:tables", "write:tables", "admin:lineage");
RefreshToken token =
new RefreshToken("token", "client", "user", scopes, System.currentTimeMillis());
assertThat(token.getScopes()).hasSize(5);
assertThat(token.getScopes()).containsExactlyInAnyOrderElementsOf(scopes);
}
}
@@ -0,0 +1,195 @@
package org.openmetadata.mcp.server.auth.util;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import jakarta.servlet.http.HttpServletRequest;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import org.junit.jupiter.api.Test;
import org.openmetadata.mcp.server.auth.util.ClientCredentialsExtractor.Credentials;
import org.openmetadata.mcp.server.auth.util.ClientCredentialsExtractor.InvalidClientCredentialsException;
class ClientCredentialsExtractorTest {
private HttpServletRequest requestWithAuthHeader(String headerValue) {
HttpServletRequest request = mock(HttpServletRequest.class);
when(request.getHeader("Authorization")).thenReturn(headerValue);
return request;
}
private static String basicHeader(String clientId, String clientSecret) {
String raw = clientId + ":" + clientSecret;
return "Basic " + Base64.getEncoder().encodeToString(raw.getBytes(StandardCharsets.UTF_8));
}
@Test
void testBasicHeader_parsesCredentials() throws Exception {
HttpServletRequest request = requestWithAuthHeader(basicHeader("my-client", "s3cret"));
Credentials credentials = ClientCredentialsExtractor.extract(request, null, null);
assertThat(credentials.clientId()).isEqualTo("my-client");
assertThat(credentials.clientSecret()).isEqualTo("s3cret");
}
@Test
void testBasicHeader_lowercaseScheme_accepted() throws Exception {
String encoded = Base64.getEncoder().encodeToString("a:b".getBytes(StandardCharsets.UTF_8));
HttpServletRequest request = requestWithAuthHeader("basic " + encoded);
Credentials credentials = ClientCredentialsExtractor.extract(request, null, null);
assertThat(credentials.clientId()).isEqualTo("a");
assertThat(credentials.clientSecret()).isEqualTo("b");
}
@Test
void testBasicHeader_urlEncodedCredentials_decoded() throws Exception {
// RFC 6749 §2.3.1: client_id / client_secret are application/x-www-form-urlencoded
// before Base64. Secret containing ':' is encoded as %3A.
String encodedRaw = "client%2Fid:secret%3Awith%3Acolons";
String header =
"Basic " + Base64.getEncoder().encodeToString(encodedRaw.getBytes(StandardCharsets.UTF_8));
HttpServletRequest request = requestWithAuthHeader(header);
Credentials credentials = ClientCredentialsExtractor.extract(request, null, null);
assertThat(credentials.clientId()).isEqualTo("client/id");
assertThat(credentials.clientSecret()).isEqualTo("secret:with:colons");
}
@Test
void testNoHeaderNoBody_returnsNullCredentials() throws Exception {
HttpServletRequest request = requestWithAuthHeader(null);
Credentials credentials = ClientCredentialsExtractor.extract(request, null, null);
assertThat(credentials.clientId()).isNull();
assertThat(credentials.clientSecret()).isNull();
}
@Test
void testBodyOnly_returnsBodyValues() throws Exception {
HttpServletRequest request = requestWithAuthHeader(null);
Credentials credentials =
ClientCredentialsExtractor.extract(request, "body-client", "body-secret");
assertThat(credentials.clientId()).isEqualTo("body-client");
assertThat(credentials.clientSecret()).isEqualTo("body-secret");
}
@Test
void testHeaderAndBody_mismatchedClientId_throws() {
HttpServletRequest request = requestWithAuthHeader(basicHeader("hdr-client", "hdr-secret"));
assertThatThrownBy(
() -> ClientCredentialsExtractor.extract(request, "body-client", "hdr-secret"))
.isInstanceOf(InvalidClientCredentialsException.class)
.hasMessageContaining("client_id");
}
@Test
void testHeaderAndBody_mismatchedClientSecret_throws() {
HttpServletRequest request = requestWithAuthHeader(basicHeader("hdr-client", "hdr-secret"));
assertThatThrownBy(
() -> ClientCredentialsExtractor.extract(request, "hdr-client", "body-secret"))
.isInstanceOf(InvalidClientCredentialsException.class)
.hasMessageContaining("client_secret");
}
@Test
void testHeaderAndBody_matchingDuplicates_returnsHeaderCredentials() throws Exception {
HttpServletRequest request = requestWithAuthHeader(basicHeader("hdr-client", "hdr-secret"));
Credentials credentials =
ClientCredentialsExtractor.extract(request, "hdr-client", "hdr-secret");
assertThat(credentials.clientId()).isEqualTo("hdr-client");
assertThat(credentials.clientSecret()).isEqualTo("hdr-secret");
}
@Test
void testHeaderAndBody_clientIdOnlyAndMatching_returnsHeaderCredentials() throws Exception {
HttpServletRequest request = requestWithAuthHeader(basicHeader("hdr-client", "hdr-secret"));
Credentials credentials = ClientCredentialsExtractor.extract(request, "hdr-client", null);
assertThat(credentials.clientId()).isEqualTo("hdr-client");
assertThat(credentials.clientSecret()).isEqualTo("hdr-secret");
}
@Test
void testHeaderAndBody_clientSecretOnlyAndMatching_returnsHeaderCredentials() throws Exception {
HttpServletRequest request = requestWithAuthHeader(basicHeader("hdr-client", "hdr-secret"));
Credentials credentials = ClientCredentialsExtractor.extract(request, null, "hdr-secret");
assertThat(credentials.clientId()).isEqualTo("hdr-client");
assertThat(credentials.clientSecret()).isEqualTo("hdr-secret");
}
@Test
void testBasicHeader_missingColon_throws() {
String header =
"Basic " + Base64.getEncoder().encodeToString("nocolon".getBytes(StandardCharsets.UTF_8));
HttpServletRequest request = requestWithAuthHeader(header);
assertThatThrownBy(() -> ClientCredentialsExtractor.extract(request, null, null))
.isInstanceOf(InvalidClientCredentialsException.class);
}
@Test
void testBasicHeader_invalidBase64_throws() {
HttpServletRequest request = requestWithAuthHeader("Basic !!!not-base64!!!");
assertThatThrownBy(() -> ClientCredentialsExtractor.extract(request, null, null))
.isInstanceOf(InvalidClientCredentialsException.class);
}
@Test
void testBasicHeader_emptyValue_throws() {
HttpServletRequest request = requestWithAuthHeader("Basic ");
assertThatThrownBy(() -> ClientCredentialsExtractor.extract(request, null, null))
.isInstanceOf(InvalidClientCredentialsException.class);
}
@Test
void testBasicHeader_emptyClientId_throws() {
String header =
"Basic " + Base64.getEncoder().encodeToString(":secret".getBytes(StandardCharsets.UTF_8));
HttpServletRequest request = requestWithAuthHeader(header);
assertThatThrownBy(() -> ClientCredentialsExtractor.extract(request, null, null))
.isInstanceOf(InvalidClientCredentialsException.class);
}
@Test
void testBasicHeader_emptyClientSecret_allowed() throws Exception {
String header =
"Basic "
+ Base64.getEncoder().encodeToString("public-client:".getBytes(StandardCharsets.UTF_8));
HttpServletRequest request = requestWithAuthHeader(header);
Credentials credentials = ClientCredentialsExtractor.extract(request, null, null);
assertThat(credentials.clientId()).isEqualTo("public-client");
assertThat(credentials.clientSecret()).isEmpty();
}
@Test
void testNonBasicScheme_fallsBackToBody() throws Exception {
HttpServletRequest request = requestWithAuthHeader("Bearer abc.def.ghi");
Credentials credentials =
ClientCredentialsExtractor.extract(request, "body-client", "body-secret");
assertThat(credentials.clientId()).isEqualTo("body-client");
assertThat(credentials.clientSecret()).isEqualTo("body-secret");
}
}
@@ -0,0 +1,154 @@
package org.openmetadata.mcp.server.auth.util;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.net.URI;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.Map;
import org.junit.jupiter.api.Test;
class UriUtilsTest {
@Test
void testConstructRedirectUri_addsQueryParams() {
Map<String, String> params = new LinkedHashMap<>();
params.put("code", "abc123");
params.put("state", "xyz");
String result = UriUtils.constructRedirectUri("https://example.com/callback", params);
assertThat(result).contains("code=abc123");
assertThat(result).contains("state=xyz");
assertThat(result).startsWith("https://example.com/callback?");
}
@Test
void testConstructRedirectUri_preservesExistingQueryParams() {
Map<String, String> params = Map.of("code", "abc123");
String result = UriUtils.constructRedirectUri("https://example.com/cb?existing=1", params);
assertThat(result).contains("existing=1");
assertThat(result).contains("code=abc123");
assertThat(result).contains("&");
}
@Test
void testConstructRedirectUri_encodesSpecialCharacters() {
Map<String, String> params = Map.of("redirect", "https://other.com?a=1&b=2");
String result = UriUtils.constructRedirectUri("https://example.com/cb", params);
assertThat(result).doesNotContain("https://other.com?a=1&b=2");
assertThat(result).contains("redirect=");
}
@Test
void testConstructRedirectUri_opaqueStateRoundTripsWithSingleEncoding() {
// Regression: a base64 state with '+' and '=' padding must survive byte-for-byte after a
// single URL-decode. Previously the encoded query was re-quoted by the multi-arg URI
// constructor ("a==" -> "a%3D%3D" -> "a%253D%253D"), breaking clients that compare state
// exactly (e.g. VS Code's loopback redirect -> "State does not match").
String state = "n+eBY2DPiNEk3xEe7rqmtg==";
Map<String, String> params = new LinkedHashMap<>();
params.put("code", "abc123");
params.put("state", state);
String result = UriUtils.constructRedirectUri("http://127.0.0.1:33418/", params);
String returnedState =
URLDecoder.decode(
result.replaceAll(".*[?&]state=", "").replaceAll("&.*", ""), StandardCharsets.UTF_8);
assertThat(returnedState).isEqualTo(state);
assertThat(result).doesNotContain("%25");
}
@Test
void testConstructRedirectUri_skipsNullValues() {
Map<String, String> params = new LinkedHashMap<>();
params.put("code", "abc123");
params.put("state", null);
String result = UriUtils.constructRedirectUri("https://example.com/cb", params);
assertThat(result).contains("code=abc123");
assertThat(result).doesNotContain("state");
}
@Test
void testConstructRedirectUri_emptyParams() {
String result = UriUtils.constructRedirectUri("https://example.com/cb", Map.of());
assertThat(result).startsWith("https://example.com/cb");
}
@Test
void testConstructRedirectUri_invalidUri_throwsIllegalArgument() {
assertThatThrownBy(() -> UriUtils.constructRedirectUri("not a valid uri[", Map.of("k", "v")))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
void testValidateIssuerUrl_httpsAccepted() {
UriUtils.validateIssuerUrl(URI.create("https://example.com"));
}
@Test
void testValidateIssuerUrl_localhostHttpAccepted() {
UriUtils.validateIssuerUrl(URI.create("http://localhost:8585"));
}
@Test
void testValidateIssuerUrl_127001HttpAccepted() {
UriUtils.validateIssuerUrl(URI.create("http://127.0.0.1:8585"));
}
@Test
void testValidateIssuerUrl_httpNonLocalhostRejected() {
assertThatThrownBy(() -> UriUtils.validateIssuerUrl(URI.create("http://example.com")))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("HTTPS");
}
@Test
void testValidateIssuerUrl_fragmentRejected() {
assertThatThrownBy(() -> UriUtils.validateIssuerUrl(URI.create("https://example.com#frag")))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("fragment");
}
@Test
void testValidateIssuerUrl_queryRejected() {
assertThatThrownBy(() -> UriUtils.validateIssuerUrl(URI.create("https://example.com?q=1")))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("query");
}
@Test
void testBuildEndpointUrl_appendsPath() {
URI result = UriUtils.buildEndpointUrl(URI.create("https://example.com"), "/authorize");
assertThat(result).isEqualTo(URI.create("https://example.com/authorize"));
}
@Test
void testBuildEndpointUrl_trailingSlashHandled() {
URI result = UriUtils.buildEndpointUrl(URI.create("https://example.com/"), "/authorize");
assertThat(result).isEqualTo(URI.create("https://example.com/authorize"));
}
@Test
void testModifyUriPath_transformsPath() {
URI original = URI.create("https://example.com/old/path");
URI result = UriUtils.modifyUriPath(original, p -> "/new/path");
assertThat(result.getPath()).isEqualTo("/new/path");
assertThat(result.getHost()).isEqualTo("example.com");
assertThat(result.getScheme()).isEqualTo("https");
}
}
@@ -0,0 +1,264 @@
package org.openmetadata.mcp.server.auth.validators;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import com.nimbusds.jwt.JWTClaimsSet;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.util.Base64;
import java.util.Calendar;
import java.util.Date;
import java.util.List;
import java.util.TimeZone;
import java.util.UUID;
import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.openmetadata.mcp.server.auth.validators.IdTokenValidator.IdTokenValidationException;
/**
* Unit tests for IdTokenValidator - JWKS-based ID token signature verification.
*
* <p>Tests cover: - Valid token validation - Signature verification - Expiration handling - Issuer
* validation - Audience validation - Security edge cases (forgery, tampering, replay)
*/
public class IdTokenValidatorTest {
private MockWebServer jwksServer;
private IdTokenValidator validator;
private RSAPublicKey publicKey;
private RSAPrivateKey privateKey;
private String keyId;
private String expectedIssuer;
private String expectedAudience;
@BeforeEach
void setUp() throws Exception {
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
keyPairGenerator.initialize(2048);
KeyPair keyPair = keyPairGenerator.generateKeyPair();
publicKey = (RSAPublicKey) keyPair.getPublic();
privateKey = (RSAPrivateKey) keyPair.getPrivate();
keyId = "test-key-" + UUID.randomUUID();
expectedIssuer = "https://accounts.test.com";
expectedAudience = "test-client-id";
jwksServer = new MockWebServer();
jwksServer.start();
String jwksJson = createJwksResponse(publicKey, keyId);
jwksServer.enqueue(
new MockResponse().setBody(jwksJson).setHeader("Content-Type", "application/json"));
String jwksUrl = jwksServer.url("/jwks").toString();
validator = new IdTokenValidator(List.of(jwksUrl), expectedIssuer, expectedAudience);
}
@AfterEach
void tearDown() throws Exception {
if (jwksServer != null) {
jwksServer.shutdown();
}
}
@Test
void testValidateAndDecode_ValidToken() throws Exception {
String idToken = createValidIdToken(expectedIssuer, expectedAudience, "test@example.com");
JWTClaimsSet claims = validator.validateAndDecode(idToken);
assertThat(claims).isNotNull();
assertThat(claims.getIssuer()).isEqualTo(expectedIssuer);
assertThat(claims.getAudience()).contains(expectedAudience);
assertThat(claims.getStringClaim("email")).isEqualTo("test@example.com");
}
@Test
void testValidateAndDecode_ExpiredToken() {
String expiredToken =
createExpiredIdToken(expectedIssuer, expectedAudience, "test@example.com");
assertThatThrownBy(() -> validator.validateAndDecode(expiredToken))
.isInstanceOf(IdTokenValidationException.class)
.hasMessageContaining("expired");
}
@Test
void testValidateAndDecode_WrongIssuer() {
String wrongIssuerToken =
createValidIdToken("https://wrong-issuer.com", expectedAudience, "test@example.com");
assertThatThrownBy(() -> validator.validateAndDecode(wrongIssuerToken))
.isInstanceOf(IdTokenValidationException.class)
.hasMessageContaining("issuer mismatch");
}
@Test
void testValidateAndDecode_WrongAudience() {
String wrongAudienceToken =
createValidIdToken(expectedIssuer, "wrong-audience", "test@example.com");
assertThatThrownBy(() -> validator.validateAndDecode(wrongAudienceToken))
.isInstanceOf(IdTokenValidationException.class)
.hasMessageContaining("audience mismatch");
}
@Test
void testValidateAndDecode_ForgedSignature() throws Exception {
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
keyPairGenerator.initialize(2048);
KeyPair wrongKeyPair = keyPairGenerator.generateKeyPair();
RSAPrivateKey wrongPrivateKey = (RSAPrivateKey) wrongKeyPair.getPrivate();
String forgedToken =
createIdTokenWithKey(
expectedIssuer,
expectedAudience,
"attacker@example.com",
System.currentTimeMillis() + 3600000,
wrongPrivateKey);
assertThatThrownBy(() -> validator.validateAndDecode(forgedToken))
.isInstanceOf(IdTokenValidationException.class)
.hasMessageContaining("signature verification failed");
}
@Test
void testValidateAndDecode_MissingKeyId() {
String tokenWithoutKid =
createIdTokenWithoutKid(expectedIssuer, expectedAudience, "test@example.com");
assertThatThrownBy(() -> validator.validateAndDecode(tokenWithoutKid))
.isInstanceOf(IdTokenValidationException.class)
.hasMessageContaining("missing 'kid' header");
}
@Test
void testValidateAndDecode_EmptyToken() {
assertThatThrownBy(() -> validator.validateAndDecode(""))
.isInstanceOf(IdTokenValidationException.class)
.hasMessageContaining("cannot be null or empty");
assertThatThrownBy(() -> validator.validateAndDecode(null))
.isInstanceOf(IdTokenValidationException.class)
.hasMessageContaining("cannot be null or empty");
}
@Test
void testValidateAndDecode_MalformedToken() {
assertThatThrownBy(() -> validator.validateAndDecode("not.a.valid.jwt"))
.isInstanceOf(IdTokenValidationException.class)
.hasMessageContaining("Unable to decode");
}
@Test
void testValidateAndDecode_ClockSkewTolerance() throws Exception {
Calendar now = Calendar.getInstance(TimeZone.getTimeZone("UTC"));
now.add(Calendar.SECOND, -30);
Date expiresAt = now.getTime();
String tokenExpiredRecently =
createIdTokenWithExpiry(
expectedIssuer, expectedAudience, "test@example.com", expiresAt.getTime());
JWTClaimsSet claims = validator.validateAndDecode(tokenExpiredRecently);
assertThat(claims).isNotNull();
}
@Test
void testValidateAndDecode_NoAudienceValidationWhenNull() throws Exception {
IdTokenValidator validatorNoAudience =
new IdTokenValidator(List.of(jwksServer.url("/jwks").toString()), expectedIssuer, null);
jwksServer.enqueue(
new MockResponse()
.setBody(createJwksResponse(publicKey, keyId))
.setHeader("Content-Type", "application/json"));
String tokenWithDifferentAudience =
createValidIdToken(expectedIssuer, "any-audience", "test@example.com");
JWTClaimsSet claims = validatorNoAudience.validateAndDecode(tokenWithDifferentAudience);
assertThat(claims).isNotNull();
}
private String createValidIdToken(String issuer, String audience, String email) {
long now = System.currentTimeMillis();
long expiresAt = now + 3600000;
return createIdTokenWithExpiry(issuer, audience, email, expiresAt);
}
private String createExpiredIdToken(String issuer, String audience, String email) {
long now = System.currentTimeMillis();
long expiresAt = now - 120000;
return createIdTokenWithExpiry(issuer, audience, email, expiresAt);
}
private String createIdTokenWithExpiry(
String issuer, String audience, String email, long expiresAt) {
return createIdTokenWithKey(issuer, audience, email, expiresAt, privateKey);
}
private String createIdTokenWithKey(
String issuer, String audience, String email, long expiresAt, RSAPrivateKey signingKey) {
Algorithm algorithm = Algorithm.RSA256(null, signingKey);
return JWT.create()
.withIssuer(issuer)
.withAudience(audience)
.withSubject("test-user-123")
.withClaim("email", email)
.withClaim("email_verified", true)
.withIssuedAt(new Date(System.currentTimeMillis()))
.withExpiresAt(new Date(expiresAt))
.withKeyId(keyId)
.sign(algorithm);
}
private String createIdTokenWithoutKid(String issuer, String audience, String email) {
Algorithm algorithm = Algorithm.RSA256(null, privateKey);
return JWT.create()
.withIssuer(issuer)
.withAudience(audience)
.withSubject("test-user-123")
.withClaim("email", email)
.withIssuedAt(new Date(System.currentTimeMillis()))
.withExpiresAt(new Date(System.currentTimeMillis() + 3600000))
.sign(algorithm);
}
private String createJwksResponse(RSAPublicKey publicKey, String kid) {
String n =
Base64.getUrlEncoder()
.withoutPadding()
.encodeToString(publicKey.getModulus().toByteArray());
String e =
Base64.getUrlEncoder()
.withoutPadding()
.encodeToString(publicKey.getPublicExponent().toByteArray());
return String.format(
"""
{
"keys": [{
"kty": "RSA",
"kid": "%s",
"use": "sig",
"alg": "RS256",
"n": "%s",
"e": "%s"
}]
}
""",
kid, n, e);
}
}
@@ -0,0 +1,142 @@
/*
* Copyright 2025 Collate
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.openmetadata.mcp.server.transport;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import jakarta.servlet.http.HttpServletResponse;
import java.io.PrintWriter;
import java.io.StringWriter;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
class HttpServletStatelessServerTransportTest {
private static final String JSON_RESPONSE = "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{}}";
private HttpServletResponse response;
private StringWriter body;
@BeforeEach
void setUp() throws Exception {
response = mock(HttpServletResponse.class);
body = new StringWriter();
when(response.getWriter()).thenReturn(new PrintWriter(body));
}
@Test
void writeJsonResponse_setsContentTypeAndStatus() throws Exception {
HttpServletStatelessServerTransport.writeJsonResponse(response, JSON_RESPONSE);
verify(response).setContentType(HttpServletStatelessServerTransport.APPLICATION_JSON);
verify(response).setCharacterEncoding(HttpServletStatelessServerTransport.UTF_8);
verify(response).setStatus(HttpServletResponse.SC_OK);
assertThat(body.toString()).isEqualTo(JSON_RESPONSE);
}
@Test
void writeSseResponse_emitsSingleSseEvent() throws Exception {
HttpServletStatelessServerTransport.writeSseResponse(response, JSON_RESPONSE);
verify(response).setContentType(HttpServletStatelessServerTransport.TEXT_EVENT_STREAM);
verify(response).setCharacterEncoding(HttpServletStatelessServerTransport.UTF_8);
verify(response).setStatus(HttpServletResponse.SC_OK);
assertThat(body.toString()).isEqualTo("data: " + JSON_RESPONSE + "\n\n");
}
@Test
void writeSseResponse_setsStreamingHeaders() throws Exception {
HttpServletStatelessServerTransport.writeSseResponse(response, JSON_RESPONSE);
verify(response)
.setHeader(
HttpServletStatelessServerTransport.HEADER_CACHE_CONTROL,
HttpServletStatelessServerTransport.CACHE_CONTROL_NO_CACHE);
verify(response)
.setHeader(
HttpServletStatelessServerTransport.HEADER_CONNECTION,
HttpServletStatelessServerTransport.CONNECTION_KEEP_ALIVE);
verify(response)
.setHeader(
HttpServletStatelessServerTransport.HEADER_X_ACCEL_BUFFERING,
HttpServletStatelessServerTransport.X_ACCEL_BUFFERING_NO);
}
@Test
void writeSseResponse_payloadWithNewlines_prefixesEachLine() throws Exception {
String multiLineJson = "{\n \"jsonrpc\": \"2.0\"\n}";
HttpServletStatelessServerTransport.writeSseResponse(response, multiLineJson);
assertThat(body.toString()).isEqualTo("data: {\ndata: \"jsonrpc\": \"2.0\"\ndata: }\n\n");
}
@Test
void writeSseResponse_payloadWithCarriageReturnLineFeed_prefixesEachLine() throws Exception {
String windowsJson = "{\r\n \"x\": 1\r\n}";
HttpServletStatelessServerTransport.writeSseResponse(response, windowsJson);
assertThat(body.toString()).isEqualTo("data: {\ndata: \"x\": 1\ndata: }\n\n");
}
@Test
void writeJsonResponse_doesNotSetStreamingHeaders() throws Exception {
HttpServletStatelessServerTransport.writeJsonResponse(response, JSON_RESPONSE);
verify(response, org.mockito.Mockito.never()).setHeader(anyString(), anyString());
}
@Test
void writeSseResponse_doesNotCallSendError() throws Exception {
HttpServletStatelessServerTransport.writeSseResponse(response, JSON_RESPONSE);
verify(response, org.mockito.Mockito.never()).sendError(anyInt());
verify(response, org.mockito.Mockito.never()).sendError(anyInt(), anyString());
}
@Test
void writeSseResponse_payloadStartsWithDataPrefix_perSseSpec() throws Exception {
HttpServletStatelessServerTransport.writeSseResponse(response, JSON_RESPONSE);
String written = body.toString();
assertThat(written).startsWith("data: ");
assertThat(written).endsWith("\n\n");
}
@Test
void responseFlush_writerOnly() {
verifyNoInteractions(response);
}
@Test
void shouldEmitSse_acceptsBoth_prefersJson() {
assertThat(HttpServletStatelessServerTransport.shouldEmitSse(true, true)).isFalse();
}
@Test
void shouldEmitSse_acceptsJsonOnly_emitsJson() {
assertThat(HttpServletStatelessServerTransport.shouldEmitSse(true, false)).isFalse();
}
@Test
void shouldEmitSse_acceptsSseOnly_emitsSse() {
assertThat(HttpServletStatelessServerTransport.shouldEmitSse(false, true)).isTrue();
}
}
@@ -0,0 +1,42 @@
package org.openmetadata.mcp.server.transport;
import static org.assertj.core.api.Assertions.assertThat;
import java.lang.reflect.Method;
import org.junit.jupiter.api.Test;
class OAuthHttpStatelessServerTransportProviderTest {
// ── sanitizeRedirectUrlForLogging ─────────────────────────────────────────
@Test
void sanitizeRedirectUrl_stripsQueryParams() throws Exception {
String result = sanitize("http://127.0.0.1:9999/callback?error=server_error&state=abc");
assertThat(result).isEqualTo("http://127.0.0.1:9999/callback?[params_redacted]");
}
@Test
void sanitizeRedirectUrl_noQueryParam_returnsAsIs() throws Exception {
String result = sanitize("http://127.0.0.1:9999/callback");
assertThat(result).isEqualTo("http://127.0.0.1:9999/callback");
}
@Test
void sanitizeRedirectUrl_null_returnsNullString() throws Exception {
assertThat(sanitize(null)).isEqualTo("null");
}
// Note: the committed-response guard in handleAuthorizeRequest() cannot be meaningfully
// exercised at unit level because handleAuthorizeRequest is private and the class constructor
// requires a full running auth stack. The guard is covered by integration tests.
// ── helpers ──────────────────────────────────────────────────────────────
private static String sanitize(String url) throws Exception {
Method m =
OAuthHttpStatelessServerTransportProvider.class.getDeclaredMethod(
"sanitizeRedirectUrlForLogging", String.class);
m.setAccessible(true);
return (String) m.invoke(null, url);
}
}
@@ -0,0 +1,87 @@
package org.openmetadata.mcp.tools;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockConstruction;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import jakarta.ws.rs.core.Response;
import java.security.Principal;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.MockedConstruction;
import org.mockito.MockedStatic;
import org.mockito.junit.jupiter.MockitoExtension;
import org.openmetadata.schema.entity.classification.Classification;
import org.openmetadata.schema.type.EventType;
import org.openmetadata.service.Entity;
import org.openmetadata.service.jdbi3.ClassificationRepository;
import org.openmetadata.service.limits.Limits;
import org.openmetadata.service.resources.tags.ClassificationMapper;
import org.openmetadata.service.security.Authorizer;
import org.openmetadata.service.security.auth.CatalogSecurityContext;
import org.openmetadata.service.util.RestUtil;
@ExtendWith(MockitoExtension.class)
class CreateClassificationToolTest {
private Authorizer authorizer;
private Limits limits;
private CatalogSecurityContext securityContext;
@BeforeEach
void setUp() {
authorizer = mock(Authorizer.class);
limits = mock(Limits.class);
securityContext = mock(CatalogSecurityContext.class);
Principal mockPrincipal = mock(Principal.class);
when(mockPrincipal.getName()).thenReturn("test-user");
when(securityContext.getUserPrincipal()).thenReturn(mockPrincipal);
}
@Test
void testExecuteCallsPrepareInternal() {
ClassificationRepository repo = mock(ClassificationRepository.class);
Classification classification = new Classification();
classification.setId(UUID.randomUUID());
classification.setName("PII");
RestUtil.PutResponse<Classification> putResponse =
new RestUtil.PutResponse<>(
Response.Status.CREATED, classification, EventType.ENTITY_CREATED);
when(repo.createOrUpdate(isNull(), any(Classification.class), anyString(), any()))
.thenReturn(putResponse);
try (MockedStatic<Entity> entityMock = mockStatic(Entity.class);
MockedConstruction<ClassificationMapper> mapperMock =
mockConstruction(
ClassificationMapper.class,
(mapper, context) ->
when(mapper.createToEntity(any(), anyString())).thenReturn(classification))) {
entityMock.when(() -> Entity.getEntityRepository(Entity.CLASSIFICATION)).thenReturn(repo);
Map<String, Object> params = new HashMap<>();
params.put("name", "PII");
params.put("description", "Personally identifiable information");
CreateClassificationTool tool = new CreateClassificationTool();
Map<String, Object> result = tool.execute(authorizer, limits, securityContext, params);
assertNotNull(result);
verify(repo).prepareInternal(any(Classification.class), eq(false));
}
}
}
@@ -0,0 +1,134 @@
package org.openmetadata.mcp.tools;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockConstruction;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import jakarta.ws.rs.core.Response;
import java.security.Principal;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.MockedConstruction;
import org.mockito.MockedStatic;
import org.mockito.junit.jupiter.MockitoExtension;
import org.openmetadata.schema.api.context.CreateContextMemory;
import org.openmetadata.schema.entity.context.ContextMemory;
import org.openmetadata.schema.entity.context.ContextMemoryScope;
import org.openmetadata.schema.entity.context.ContextMemorySourceType;
import org.openmetadata.schema.entity.context.ContextMemoryType;
import org.openmetadata.schema.type.EventType;
import org.openmetadata.service.Entity;
import org.openmetadata.service.jdbi3.ContextMemoryRepository;
import org.openmetadata.service.limits.Limits;
import org.openmetadata.service.resources.context.ContextMemoryMapper;
import org.openmetadata.service.security.Authorizer;
import org.openmetadata.service.security.auth.CatalogSecurityContext;
import org.openmetadata.service.util.RestUtil;
@ExtendWith(MockitoExtension.class)
class CreateContextMemoryToolTest {
private static final String QUESTION = "Which warehouse should finance dashboards query?";
private static final String ANSWER = "Always query the FINANCE_PROD warehouse.";
private Authorizer authorizer;
private Limits limits;
private CatalogSecurityContext securityContext;
@BeforeEach
void setUp() {
authorizer = mock(Authorizer.class);
limits = mock(Limits.class);
securityContext = mock(CatalogSecurityContext.class);
}
@Test
void testExecuteStampsRememberRequestProvenanceAndPersists() {
Principal mockPrincipal = mock(Principal.class);
when(mockPrincipal.getName()).thenReturn("test-user");
when(securityContext.getUserPrincipal()).thenReturn(mockPrincipal);
ContextMemoryRepository repo = mock(ContextMemoryRepository.class);
ContextMemory memory = new ContextMemory();
memory.setId(UUID.randomUUID());
memory.setName("finance-warehouse");
RestUtil.PutResponse<ContextMemory> putResponse =
new RestUtil.PutResponse<>(Response.Status.CREATED, memory, EventType.ENTITY_CREATED);
when(repo.createOrUpdate(isNull(), any(ContextMemory.class), anyString(), any()))
.thenReturn(putResponse);
AtomicReference<CreateContextMemory> captured = new AtomicReference<>();
try (MockedStatic<Entity> entityMock = mockStatic(Entity.class);
MockedConstruction<ContextMemoryMapper> mapperMock =
mockConstruction(
ContextMemoryMapper.class,
(mapper, context) ->
when(mapper.createToEntity(any(CreateContextMemory.class), anyString()))
.thenAnswer(
invocation -> {
captured.set(invocation.getArgument(0));
return memory;
}))) {
entityMock.when(() -> Entity.getEntityRepository(Entity.CONTEXT_MEMORY)).thenReturn(repo);
Map<String, Object> params = new HashMap<>();
params.put("name", "finance-warehouse");
params.put("question", QUESTION);
params.put("answer", ANSWER);
params.put("memoryType", "Preference");
CreateContextMemoryTool tool = new CreateContextMemoryTool();
Map<String, Object> result = tool.execute(authorizer, limits, securityContext, params);
assertNotNull(result);
verify(repo).prepareInternal(any(ContextMemory.class), eq(false));
CreateContextMemory create = captured.get();
assertNotNull(create);
assertEquals(ContextMemorySourceType.REMEMBER_REQUEST, create.getSourceType());
assertEquals(QUESTION, create.getQuestion());
assertEquals(ANSWER, create.getAnswer());
assertEquals(ContextMemoryType.PREFERENCE, create.getMemoryType());
}
}
@Test
void testMissingRequiredAnswerThrows() {
Map<String, Object> params = new HashMap<>();
params.put("name", "finance-warehouse");
params.put("question", QUESTION);
CreateContextMemoryTool tool = new CreateContextMemoryTool();
assertThrows(
IllegalArgumentException.class,
() -> tool.execute(authorizer, limits, securityContext, params));
}
@Test
void testInvalidMemoryTypeThrows() {
assertThrows(
IllegalArgumentException.class, () -> CreateContextMemoryTool.parseMemoryType("NotAType"));
}
@Test
void testParseMemoryScopeAcceptsValidValue() {
assertEquals(
ContextMemoryScope.ENTITY_SCOPED, CreateContextMemoryTool.parseMemoryScope("EntityScoped"));
}
}
@@ -0,0 +1,109 @@
package org.openmetadata.mcp.tools;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockConstruction;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import jakarta.ws.rs.core.Response;
import java.security.Principal;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.MockedConstruction;
import org.mockito.MockedStatic;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.openmetadata.schema.entity.domains.DataProduct;
import org.openmetadata.schema.type.EntityReference;
import org.openmetadata.schema.type.EventType;
import org.openmetadata.service.Entity;
import org.openmetadata.service.jdbi3.DataProductRepository;
import org.openmetadata.service.limits.Limits;
import org.openmetadata.service.resources.domains.DataProductMapper;
import org.openmetadata.service.security.Authorizer;
import org.openmetadata.service.security.auth.CatalogSecurityContext;
import org.openmetadata.service.util.RestUtil;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class CreateDataProductToolTest {
private Authorizer authorizer;
private Limits limits;
private CatalogSecurityContext securityContext;
@BeforeEach
void setUp() {
authorizer = mock(Authorizer.class);
limits = mock(Limits.class);
securityContext = mock(CatalogSecurityContext.class);
Principal mockPrincipal = mock(Principal.class);
when(mockPrincipal.getName()).thenReturn("test-user");
when(securityContext.getUserPrincipal()).thenReturn(mockPrincipal);
}
@Test
void testExecuteCallsPrepareInternal() {
DataProductRepository repo = mock(DataProductRepository.class);
DataProduct dataProduct = new DataProduct();
dataProduct.setId(UUID.randomUUID());
dataProduct.setName("CustomerInsights");
RestUtil.PutResponse<DataProduct> putResponse =
new RestUtil.PutResponse<>(Response.Status.CREATED, dataProduct, EventType.ENTITY_CREATED);
when(repo.createOrUpdate(isNull(), any(DataProduct.class), anyString(), any()))
.thenReturn(putResponse);
try (MockedStatic<Entity> entityMock = mockStatic(Entity.class);
MockedConstruction<DataProductMapper> mapperMock =
mockConstruction(
DataProductMapper.class,
(mapper, context) ->
when(mapper.createToEntity(any(), anyString())).thenReturn(dataProduct))) {
entityMock.when(() -> Entity.getEntityRepository(Entity.DATA_PRODUCT)).thenReturn(repo);
entityMock
.when(() -> Entity.getEntityReferenceByName(anyString(), anyString(), any()))
.thenReturn(new EntityReference());
Map<String, Object> params = new HashMap<>();
params.put("name", "CustomerInsights");
params.put("description", "Customer insights data product");
params.put("domains", List.of("Finance"));
CreateDataProductTool tool = new CreateDataProductTool();
Map<String, Object> result = tool.execute(authorizer, limits, securityContext, params);
assertNotNull(result);
verify(repo).prepareInternal(any(DataProduct.class), eq(false));
}
}
@Test
void testEmptyDomainsThrows() {
Map<String, Object> params = new HashMap<>();
params.put("name", "CustomerInsights");
params.put("description", "Customer insights data product");
params.put("domains", List.of());
CreateDataProductTool tool = new CreateDataProductTool();
assertThrows(
IllegalArgumentException.class,
() -> tool.execute(authorizer, limits, securityContext, params));
}
}
@@ -0,0 +1,87 @@
package org.openmetadata.mcp.tools;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockConstruction;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import jakarta.ws.rs.core.Response;
import java.security.Principal;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.MockedConstruction;
import org.mockito.MockedStatic;
import org.mockito.junit.jupiter.MockitoExtension;
import org.openmetadata.schema.entity.domains.Domain;
import org.openmetadata.schema.type.EventType;
import org.openmetadata.service.Entity;
import org.openmetadata.service.jdbi3.DomainRepository;
import org.openmetadata.service.limits.Limits;
import org.openmetadata.service.resources.domains.DomainMapper;
import org.openmetadata.service.security.Authorizer;
import org.openmetadata.service.security.auth.CatalogSecurityContext;
import org.openmetadata.service.util.RestUtil;
@ExtendWith(MockitoExtension.class)
class CreateDomainToolTest {
private Authorizer authorizer;
private Limits limits;
private CatalogSecurityContext securityContext;
@BeforeEach
void setUp() {
authorizer = mock(Authorizer.class);
limits = mock(Limits.class);
securityContext = mock(CatalogSecurityContext.class);
Principal mockPrincipal = mock(Principal.class);
when(mockPrincipal.getName()).thenReturn("test-user");
when(securityContext.getUserPrincipal()).thenReturn(mockPrincipal);
}
@Test
void testExecuteCallsPrepareInternal() {
DomainRepository repo = mock(DomainRepository.class);
Domain domain = new Domain();
domain.setId(UUID.randomUUID());
domain.setName("Finance");
RestUtil.PutResponse<Domain> putResponse =
new RestUtil.PutResponse<>(Response.Status.CREATED, domain, EventType.ENTITY_CREATED);
when(repo.createOrUpdate(isNull(), any(Domain.class), anyString(), any()))
.thenReturn(putResponse);
try (MockedStatic<Entity> entityMock = mockStatic(Entity.class);
MockedConstruction<DomainMapper> mapperMock =
mockConstruction(
DomainMapper.class,
(mapper, context) ->
when(mapper.createToEntity(any(), anyString())).thenReturn(domain))) {
entityMock.when(() -> Entity.getEntityRepository(Entity.DOMAIN)).thenReturn(repo);
Map<String, Object> params = new HashMap<>();
params.put("name", "Finance");
params.put("description", "Finance domain");
params.put("domainType", "Aggregate");
CreateDomainTool tool = new CreateDomainTool();
Map<String, Object> result = tool.execute(authorizer, limits, securityContext, params);
assertNotNull(result);
verify(repo).prepareInternal(any(Domain.class), eq(false));
}
}
}
@@ -0,0 +1,87 @@
package org.openmetadata.mcp.tools;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockConstruction;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import jakarta.ws.rs.core.Response;
import java.security.Principal;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.MockedConstruction;
import org.mockito.MockedStatic;
import org.mockito.junit.jupiter.MockitoExtension;
import org.openmetadata.schema.entity.data.Metric;
import org.openmetadata.schema.type.EventType;
import org.openmetadata.service.Entity;
import org.openmetadata.service.jdbi3.MetricRepository;
import org.openmetadata.service.limits.Limits;
import org.openmetadata.service.resources.metrics.MetricMapper;
import org.openmetadata.service.security.Authorizer;
import org.openmetadata.service.security.auth.CatalogSecurityContext;
import org.openmetadata.service.util.RestUtil;
@ExtendWith(MockitoExtension.class)
class CreateMetricToolTest {
private Authorizer authorizer;
private Limits limits;
private CatalogSecurityContext securityContext;
@BeforeEach
void setUp() {
authorizer = mock(Authorizer.class);
limits = mock(Limits.class);
securityContext = mock(CatalogSecurityContext.class);
Principal mockPrincipal = mock(Principal.class);
when(mockPrincipal.getName()).thenReturn("test-user");
when(securityContext.getUserPrincipal()).thenReturn(mockPrincipal);
}
@Test
void testExecuteCallsPrepareInternal() {
MetricRepository repo = mock(MetricRepository.class);
Metric metric = new Metric();
metric.setId(UUID.randomUUID());
metric.setName("TestMetric");
RestUtil.PutResponse<Metric> putResponse =
new RestUtil.PutResponse<>(Response.Status.CREATED, metric, EventType.ENTITY_CREATED);
when(repo.createOrUpdate(isNull(), any(Metric.class), anyString(), any()))
.thenReturn(putResponse);
try (MockedStatic<Entity> entityMock = mockStatic(Entity.class);
MockedConstruction<MetricMapper> mapperMock =
mockConstruction(
MetricMapper.class,
(mapper, context) ->
when(mapper.createToEntity(any(), anyString())).thenReturn(metric))) {
entityMock.when(() -> Entity.getEntityRepository(Entity.METRIC)).thenReturn(repo);
Map<String, Object> params = new HashMap<>();
params.put("name", "TestMetric");
params.put("metricExpressionLanguage", "SQL");
params.put("metricExpressionCode", "SELECT COUNT(*) FROM orders");
CreateMetricTool tool = new CreateMetricTool();
Map<String, Object> result = tool.execute(authorizer, limits, securityContext, params);
assertNotNull(result);
verify(repo).prepareInternal(any(Metric.class), eq(false));
}
}
}

Some files were not shown because too many files have changed in this diff Show More