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
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:
+131
@@ -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;
|
||||
}
|
||||
}
|
||||
+11
@@ -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);
|
||||
}
|
||||
}
|
||||
+101
@@ -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;
|
||||
}
|
||||
}
|
||||
+85
@@ -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;
|
||||
}
|
||||
}
|
||||
+25
@@ -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;
|
||||
}
|
||||
}
|
||||
+25
@@ -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;
|
||||
}
|
||||
}
|
||||
+25
@@ -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;
|
||||
}
|
||||
}
|
||||
+45
@@ -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)))));
|
||||
}
|
||||
}
|
||||
+215
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+285
@@ -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);
|
||||
}
|
||||
}
|
||||
+828
@@ -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) {}
|
||||
}
|
||||
}
|
||||
+282
@@ -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 + ")");
|
||||
}
|
||||
}
|
||||
}
|
||||
+85
@@ -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)");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
+192
File diff suppressed because one or more lines are too long
+46
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+72
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+70
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+47
@@ -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;
|
||||
}
|
||||
}
|
||||
+1131
File diff suppressed because it is too large
Load Diff
+100
@@ -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);
|
||||
}
|
||||
}
|
||||
+106
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+85
@@ -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);
|
||||
}
|
||||
}
|
||||
+236
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+114
@@ -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);
|
||||
}
|
||||
}
|
||||
+372
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+419
@@ -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;
|
||||
}
|
||||
}
|
||||
+993
@@ -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;
|
||||
}
|
||||
}
|
||||
+135
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+172
@@ -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.");
|
||||
}
|
||||
}
|
||||
+109
@@ -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.");
|
||||
}
|
||||
}
|
||||
+184
@@ -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
+99
@@ -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
|
||||
}
|
||||
}
|
||||
+122
@@ -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");
|
||||
}
|
||||
}
|
||||
+236
@@ -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();
|
||||
}
|
||||
}
|
||||
+387
@@ -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) {}
|
||||
}
|
||||
}
|
||||
+323
@@ -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());
|
||||
}
|
||||
}
|
||||
+150
@@ -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());
|
||||
}
|
||||
}
|
||||
+104
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+118
@@ -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);
|
||||
}
|
||||
}
|
||||
+48
@@ -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);
|
||||
}
|
||||
}
|
||||
+103
@@ -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);
|
||||
}
|
||||
}
|
||||
+195
@@ -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");
|
||||
}
|
||||
}
|
||||
+154
@@ -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");
|
||||
}
|
||||
}
|
||||
+264
@@ -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);
|
||||
}
|
||||
}
|
||||
+142
@@ -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();
|
||||
}
|
||||
}
|
||||
+42
@@ -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);
|
||||
}
|
||||
}
|
||||
+87
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+134
@@ -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"));
|
||||
}
|
||||
}
|
||||
+109
@@ -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
Reference in New Issue
Block a user