feat(ext-data): add unified Data gateway; mcp: derive roles claim from OIDC
Data/RepositoryFactory/HibernateData give applications one cached, stateless entry point for repositories per entity type instead of per-request instantiation, with write()/afterCommit() replacing manual transaction+reload choreography. McpConfig.rolesClaimPath is removed - MCP now derives the claim path from OidcMiddleware.rolesClaimPath() so applications never duplicate the roles-claim config between OIDC and MCP. McpPackageScanner is rebuilt on the shared PackageScanner.discover(packageName) primitive, doing only McpTool/McpResource/McpPrompt classification itself. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
891ef99b8e
commit
9e045287c1
@@ -28,7 +28,6 @@ public final class McpConfig {
|
||||
private final String authorizationServerIssuer;
|
||||
private final List<String> allowedOrigins;
|
||||
private final List<String> scopesSupported;
|
||||
private final String rolesClaimPath;
|
||||
|
||||
private McpConfig(Builder b) {
|
||||
this.name = b.name;
|
||||
@@ -41,7 +40,6 @@ public final class McpConfig {
|
||||
this.authorizationServerIssuer = b.authorizationServerIssuer;
|
||||
this.allowedOrigins = List.copyOf(b.allowedOrigins);
|
||||
this.scopesSupported = List.copyOf(b.scopesSupported);
|
||||
this.rolesClaimPath = b.rolesClaimPath;
|
||||
}
|
||||
|
||||
String name() { return name; }
|
||||
@@ -54,7 +52,6 @@ public final class McpConfig {
|
||||
String authorizationServerIssuer() { return authorizationServerIssuer; }
|
||||
List<String> allowedOrigins() { return allowedOrigins; }
|
||||
List<String> scopesSupported() { return scopesSupported; }
|
||||
String rolesClaimPath() { return rolesClaimPath; }
|
||||
|
||||
public static Builder builder(String name) { return new Builder(name); }
|
||||
|
||||
@@ -69,7 +66,6 @@ public final class McpConfig {
|
||||
private String authorizationServerIssuer;
|
||||
private final List<String> allowedOrigins = new ArrayList<>();
|
||||
private final List<String> scopesSupported = new ArrayList<>();
|
||||
private String rolesClaimPath = "realm_access.roles";
|
||||
|
||||
private Builder(String name) {
|
||||
if (name == null || name.isBlank())
|
||||
@@ -132,15 +128,6 @@ public final class McpConfig {
|
||||
*/
|
||||
public Builder scopesSupported(String... scopes) { this.scopesSupported.addAll(List.of(scopes)); return this; }
|
||||
|
||||
/**
|
||||
* Claim path used to resolve roles for {@code @RolesAllowed} on an {@link McpTool} —
|
||||
* same dot-path syntax and default (Keycloak's {@code realm_access.roles}) as {@code
|
||||
* OidcConfig#rolesClaimPath()}. Set this only if the two configs diverge; there is no
|
||||
* way to auto-derive it from the installed {@code OidcExtension} (see {@code
|
||||
* docs/security.md}'s {@code @RolesAllowed}/{@code @ScopesAllowed} section for why).
|
||||
*/
|
||||
public Builder rolesClaimPath(String rolesClaimPath) { this.rolesClaimPath = rolesClaimPath; return this; }
|
||||
|
||||
public McpConfig build() {
|
||||
if (toolsPackage == null || toolsPackage.isBlank())
|
||||
throw new IllegalStateException(
|
||||
|
||||
+2
-1
@@ -61,7 +61,8 @@ public class McpExtension implements FlashExtension {
|
||||
// @ScopesAllowed are backed by real OAuth2 protection or a boot-time misconfiguration
|
||||
// (see McpOidcIntegration#compileToolPolicy) — must run first, not after.
|
||||
McpOidcIntegration.Resolved secured = resolveSecurity(ctx);
|
||||
McpRegistry registry = McpRegistry.scan(config.toolsPackage(), ctx, secured != null, config.rolesClaimPath());
|
||||
McpRegistry registry = McpRegistry.scan(config.toolsPackage(), ctx, secured != null,
|
||||
secured == null ? null : secured.rolesClaimPath());
|
||||
McpDispatcher dispatcher = new McpDispatcher(registry, config.name(), config.version(), config.instructions());
|
||||
|
||||
List<Middleware> chain = new ArrayList<>(3);
|
||||
|
||||
+3
-2
@@ -46,7 +46,8 @@ final class McpOidcIntegration {
|
||||
private McpOidcIntegration() {}
|
||||
|
||||
/** Everything {@link McpExtension} needs once oidc security is resolved. */
|
||||
record Resolved(Middleware security, String issuer, Function<Request, String> resourceIdentifier) {}
|
||||
record Resolved(Middleware security, String issuer, String rolesClaimPath,
|
||||
Function<Request, String> resourceIdentifier) {}
|
||||
|
||||
/** Returns the resolved security bundle, or {@code null} if oidc is not installed. */
|
||||
static Resolved resolve(FlashContext ctx, McpConfig config) {
|
||||
@@ -63,7 +64,7 @@ final class McpOidcIntegration {
|
||||
|
||||
Middleware protect = oidcMw.protect(resourceMetadataPath);
|
||||
Middleware secured = Middleware.of(protect, audienceGuard(resourceId));
|
||||
return new Resolved(secured, issuer, resourceId);
|
||||
return new Resolved(secured, issuer, oidcMw.rolesClaimPath(), resourceId);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+4
-85
@@ -2,14 +2,10 @@ package dev.relism.flash.ext.mcp;
|
||||
|
||||
import dev.relism.flash.exceptions.InitializationException;
|
||||
|
||||
import java.io.File;
|
||||
import java.lang.reflect.Modifier;
|
||||
import java.net.URL;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Enumeration;
|
||||
import java.util.List;
|
||||
import java.util.jar.JarEntry;
|
||||
import java.util.jar.JarFile;
|
||||
import dev.relism.flash.extension.PackageScanner;
|
||||
|
||||
/**
|
||||
* Minimal classpath scanner used by {@link McpConfig#toolsPackage(String)}. Finds
|
||||
@@ -37,40 +33,11 @@ final class McpPackageScanner {
|
||||
if (packageName == null || packageName.isBlank())
|
||||
throw new InitializationException("McpConfig.toolsPackage() called with null or blank package name");
|
||||
|
||||
String resourcePath = packageName.replace('.', '/');
|
||||
ClassLoader cl = Thread.currentThread().getContextClassLoader();
|
||||
List<Class<? extends McpTool>> tools = new ArrayList<>();
|
||||
List<Class<? extends McpResource>> resources = new ArrayList<>();
|
||||
List<Class<? extends McpPrompt>> prompts = new ArrayList<>();
|
||||
List<String> errors = new ArrayList<>();
|
||||
boolean packageFound = false;
|
||||
|
||||
try {
|
||||
Enumeration<URL> urls = cl.getResources(resourcePath);
|
||||
while (urls.hasMoreElements()) {
|
||||
packageFound = true;
|
||||
URL url = urls.nextElement();
|
||||
String protocol = url.getProtocol();
|
||||
if ("file".equals(protocol)) {
|
||||
scanDirectory(new File(url.toURI()), packageName, cl, tools, resources, prompts, errors);
|
||||
} else if ("jar".equals(protocol)) {
|
||||
String jarPath = url.getPath();
|
||||
String filePart = jarPath.substring(jarPath.indexOf("file:") + 5, jarPath.indexOf('!'));
|
||||
try (JarFile jar = new JarFile(filePart)) {
|
||||
scanJar(jar, resourcePath, cl, tools, resources, prompts, errors);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (InitializationException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new InitializationException("Failed to scan MCP package: " + packageName, e);
|
||||
}
|
||||
|
||||
if (!packageFound)
|
||||
throw new InitializationException(
|
||||
"McpConfig.toolsPackage(\"" + packageName + "\") — package not found on classpath. " +
|
||||
"Verify the package name and ensure the module is on the classpath.");
|
||||
for (Class<?> cls : PackageScanner.discover(packageName)) tryLoad(cls, tools, resources, prompts, errors);
|
||||
|
||||
if (!errors.isEmpty())
|
||||
throw new InitializationException(
|
||||
@@ -86,55 +53,13 @@ final class McpPackageScanner {
|
||||
return new ScanResult(List.copyOf(tools), List.copyOf(resources), List.copyOf(prompts));
|
||||
}
|
||||
|
||||
private static void scanDirectory(File dir, String packageName, ClassLoader cl,
|
||||
List<Class<? extends McpTool>> tools,
|
||||
List<Class<? extends McpResource>> resources,
|
||||
List<Class<? extends McpPrompt>> prompts,
|
||||
List<String> errors) {
|
||||
File[] files = dir.listFiles();
|
||||
if (files == null) return;
|
||||
for (File file : files) {
|
||||
if (file.isDirectory()) {
|
||||
scanDirectory(file, packageName + '.' + file.getName(), cl, tools, resources, prompts, errors);
|
||||
} else if (file.getName().endsWith(".class") && !isAnonymous(file.getName())) {
|
||||
String className = packageName + '.' + file.getName().replace(".class", "");
|
||||
tryLoad(className, cl, tools, resources, prompts, errors);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void scanJar(JarFile jar, String resourcePath, ClassLoader cl,
|
||||
List<Class<? extends McpTool>> tools,
|
||||
List<Class<? extends McpResource>> resources,
|
||||
List<Class<? extends McpPrompt>> prompts,
|
||||
List<String> errors) {
|
||||
String prefix = resourcePath + "/";
|
||||
Enumeration<JarEntry> entries = jar.entries();
|
||||
while (entries.hasMoreElements()) {
|
||||
String name = entries.nextElement().getName();
|
||||
if (name.startsWith(prefix) && name.endsWith(".class") && !isAnonymous(name)) {
|
||||
String className = name.replace('/', '.').replace(".class", "");
|
||||
tryLoad(className, cl, tools, resources, prompts, errors);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isAnonymous(String fileName) {
|
||||
int dollar = fileName.lastIndexOf('$');
|
||||
if (dollar < 0) return false;
|
||||
int next = dollar + 1;
|
||||
while (next < fileName.length() && fileName.charAt(next) == '$') next++;
|
||||
return next < fileName.length() && Character.isDigit(fileName.charAt(next));
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static void tryLoad(String className, ClassLoader cl,
|
||||
private static void tryLoad(Class<?> cls,
|
||||
List<Class<? extends McpTool>> tools,
|
||||
List<Class<? extends McpResource>> resources,
|
||||
List<Class<? extends McpPrompt>> prompts,
|
||||
List<String> errors) {
|
||||
try {
|
||||
Class<?> cls = cl.loadClass(className);
|
||||
if (Modifier.isAbstract(cls.getModifiers())) return;
|
||||
|
||||
if (McpTool.class.isAssignableFrom(cls) && cls.isAnnotationPresent(Tool.class)) {
|
||||
@@ -151,13 +76,7 @@ final class McpPackageScanner {
|
||||
assertNoArgConstructor(cls, errors);
|
||||
prompts.add((Class<? extends McpPrompt>) cls);
|
||||
}
|
||||
} catch (ClassNotFoundException e) {
|
||||
errors.add(className + " — class not found: " + e.getMessage());
|
||||
} catch (NoClassDefFoundError e) {
|
||||
errors.add(className + " — missing dependency: " + e.getMessage());
|
||||
} catch (LinkageError e) {
|
||||
errors.add(className + " — linkage error: " + e.getMessage());
|
||||
}
|
||||
} catch (LinkageError e) { errors.add(cls.getName() + " — linkage error: " + e.getMessage()); }
|
||||
}
|
||||
|
||||
private static void assertNoArgConstructor(Class<?> cls, List<String> errors) {
|
||||
|
||||
+1
-2
@@ -45,8 +45,7 @@ final class McpRegistry {
|
||||
* {@code @RolesAllowed}/{@code @ScopesAllowed} on a tool are honored or
|
||||
* rejected at boot as a misconfiguration; see
|
||||
* {@link McpOidcIntegration#compileToolPolicy}.
|
||||
* @param rolesClaimPath claim path forwarded to {@code @RolesAllowed} checks; see
|
||||
* {@link McpConfig#rolesClaimPath(String)}.
|
||||
* @param rolesClaimPath claim path resolved from the installed OIDC extension.
|
||||
*/
|
||||
static McpRegistry scan(String packageName, FlashContext ctx, boolean oidcActive, String rolesClaimPath) {
|
||||
McpPackageScanner.ScanResult found = McpPackageScanner.scan(packageName);
|
||||
|
||||
Reference in New Issue
Block a user