arguments) throws Exception {
+ ensureInitialized();
+ return withAuthRetry(() -> mcpClient.getPrompt(new McpSchema.GetPromptRequest(promptName, arguments)));
+ }
+
+ /**
+ * Sends a ping to the MCP server to verify connectivity.
+ *
+ * @throws Exception if the ping fails
+ * @throws IllegalStateException if client is not initialized
+ */
+ public void ping() throws Exception {
+ ensureInitialized();
+ withAuthRetry(() -> {
+ mcpClient.ping();
+ return null;
+ });
+ }
+
+ /**
+ * Gets the current access token.
+ *
+ * @return Access token, or null if not authenticated
+ */
+ public String getAccessToken() {
+ return tokenManager.getAccessToken();
+ }
+
+ /**
+ * Checks if the client is authenticated.
+ *
+ * @return true if valid access token is available
+ */
+ public boolean isAuthenticated() {
+ return tokenManager.hasValidAccessToken();
+ }
+
+ /**
+ * Checks if the client has been initialized.
+ *
+ * @return true if initialize() has been called successfully
+ */
+ public boolean isInitialized() {
+ return initialized;
+ }
+
+ /**
+ * Gets the server URL.
+ *
+ * @return Server URL
+ */
+ public String getServerUrl() {
+ return config.getServerUrl();
+ }
+
+ /**
+ * Gets the underlying MCP sync client for advanced usage.
+ *
+ * This provides direct access to the MCP SDK client for operations
+ * not exposed through this wrapper.
+ *
+ * @return The underlying McpSyncClient, or null if not initialized
+ */
+ public McpSyncClient getMcpClient() {
+ return mcpClient;
+ }
+
+ /**
+ * Closes the MCP connection gracefully.
+ *
+ *
This method should be called when the client is no longer needed
+ * to release resources properly.
+ */
+ public void close() {
+ if (mcpClient != null) {
+ try {
+ mcpClient.closeGracefully();
+ LOG.info("MCP client closed gracefully");
+ } catch (Exception e) {
+ LOG.warn("Error closing MCP client: {}", e.getMessage());
+ }
+ }
+ this.initialized = false;
+ this.mcpClient = null;
+ this.transport = null;
+ }
+
+ /**
+ * Forces re-authentication by clearing tokens and executing OAuth flow.
+ *
+ * @throws Exception if re-authentication fails
+ */
+ public void reauthenticate() throws Exception {
+ LOG.info("Forcing re-authentication...");
+ tokenManager.clearTokens();
+ oauthFlowHandler.clearCachedState();
+ oauthFlowHandler.executeFlow();
+ }
+
+ private void ensureInitialized() {
+ if (!initialized || mcpClient == null) {
+ throw new IllegalStateException("MCP client not initialized. Call initialize() first.");
+ }
+ }
+
+ /**
+ * Executes an operation with automatic token refresh on authentication failure.
+ */
+ private T withAuthRetry(McpOperation operation) throws Exception {
+ try {
+ return operation.execute();
+ } catch (Exception e) {
+ // Check if this is an authentication error
+ if (isAuthenticationError(e)) {
+ LOG.info("Request failed with authentication error, attempting token refresh...");
+ try {
+ oauthFlowHandler.refreshAccessToken();
+ // Retry the operation
+ return operation.execute();
+ } catch (Exception refreshException) {
+ LOG.warn("Token refresh failed: {}", refreshException.getMessage());
+ // If refresh fails, try full re-auth
+ LOG.info("Attempting full re-authentication...");
+ reauthenticate();
+ reinitializeTransport();
+ return operation.execute();
+ }
+ }
+ throw e;
+ }
+ }
+
+ private void reinitializeTransport() throws Exception {
+ LOG.info("Reinitializing transport with new credentials...");
+
+ if (mcpClient != null) {
+ try {
+ mcpClient.closeGracefully();
+ } catch (Exception e) {
+ LOG.debug("Error closing previous client: {}", e.getMessage());
+ }
+ }
+
+ OAuthRequestCustomizer oauthCustomizer = new OAuthRequestCustomizer(tokenManager, oauthFlowHandler);
+
+ this.transport = createTransport(oauthCustomizer);
+
+ this.mcpClient = McpClient.sync(transport)
+ .requestTimeout(requestTimeout)
+ .build();
+
+ mcpClient.initialize();
+ }
+
+ private McpClientTransport createTransport(OAuthRequestCustomizer oauthCustomizer) {
+ if (config.getTransportType() == McpClientConfig.TransportType.SSE) {
+ LOG.info("Using SSE transport");
+ return HttpClientSseClientTransport.builder(config.getServerUrl())
+ .httpRequestCustomizer(oauthCustomizer)
+ .build();
+ } else {
+ LOG.info("Using HTTP (Streamable) transport");
+ return HttpClientStreamableHttpTransport.builder(config.getServerUrl())
+ .httpRequestCustomizer(oauthCustomizer)
+ .build();
+ }
+ }
+
+ private boolean isAuthenticationError(Exception e) {
+ String message = e.getMessage();
+ if (message == null) {
+ return false;
+ }
+ // Check for common authentication error indicators
+ // Be specific to avoid false positives (e.g., "token" could appear in other contexts)
+ String lowerMessage = message.toLowerCase();
+ return message.contains("401") ||
+ message.contains("403") ||
+ lowerMessage.contains("unauthorized") ||
+ lowerMessage.contains("forbidden") ||
+ lowerMessage.contains("authentication failed") ||
+ lowerMessage.contains("invalid_token") ||
+ lowerMessage.contains("token expired") ||
+ lowerMessage.contains("access denied");
+ }
+
+ @FunctionalInterface
+ private interface McpOperation {
+ T execute() throws Exception;
+ }
+
+ /**
+ * Exception indicating an authentication failure.
+ */
+ public static class McpAuthenticationException extends Exception {
+ public McpAuthenticationException(String message) {
+ super(message);
+ }
+
+ public McpAuthenticationException(String message, Throwable cause) {
+ super(message, cause);
+ }
+ }
+}
diff --git a/jvm/src/main/java/com/muchq/mcpclient/demo/BUILD.bazel b/jvm/src/main/java/com/muchq/mcpclient/demo/BUILD.bazel
new file mode 100644
index 00000000..025e749d
--- /dev/null
+++ b/jvm/src/main/java/com/muchq/mcpclient/demo/BUILD.bazel
@@ -0,0 +1,17 @@
+load("@rules_java//java:java_binary.bzl", "java_binary")
+
+java_binary(
+ name = "demo",
+ srcs = ["McpClientDemo.java"],
+ main_class = "com.muchq.mcpclient.demo.McpClientDemo",
+ visibility = ["//visibility:public"],
+ runtime_deps = [
+ "@maven//:ch_qos_logback_logback_classic",
+ ],
+ deps = [
+ "//jvm/src/main/java/com/muchq/mcpclient",
+ "//jvm/src/main/java/com/muchq/mcpclient:config",
+ "@maven//:io_modelcontextprotocol_sdk_mcp",
+ "@maven//:org_slf4j_slf4j_api",
+ ],
+)
diff --git a/jvm/src/main/java/com/muchq/mcpclient/demo/McpClientDemo.java b/jvm/src/main/java/com/muchq/mcpclient/demo/McpClientDemo.java
new file mode 100644
index 00000000..5c2c76eb
--- /dev/null
+++ b/jvm/src/main/java/com/muchq/mcpclient/demo/McpClientDemo.java
@@ -0,0 +1,171 @@
+package com.muchq.mcpclient.demo;
+
+import com.muchq.mcpclient.McpClientConfig;
+import com.muchq.mcpclient.McpClientWrapper;
+import io.modelcontextprotocol.spec.McpSchema.InitializeResult;
+import io.modelcontextprotocol.spec.McpSchema.ListPromptsResult;
+import io.modelcontextprotocol.spec.McpSchema.ListResourcesResult;
+import io.modelcontextprotocol.spec.McpSchema.ListToolsResult;
+import io.modelcontextprotocol.spec.McpSchema.Prompt;
+import io.modelcontextprotocol.spec.McpSchema.Resource;
+import io.modelcontextprotocol.spec.McpSchema.Tool;
+import java.time.Duration;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Demo application for MCP OAuth 2.1 + PKCE + DCR flow with full SDK integration.
+ *
+ * This demonstrates:
+ *
+ * - Creating an MCP client with OAuth support
+ * - Automatic OAuth discovery (RFC 9728, RFC 8414)
+ * - Dynamic Client Registration (RFC 7591)
+ * - PKCE-protected authorization code flow
+ * - Token management and automatic refresh
+ * - Full MCP SDK integration with typed APIs
+ *
+ *
+ * Prerequisites:
+ *
+ * - Keycloak running on http://localhost:8180
+ * - MCP server running on http://localhost:8080 with OAuth enabled
+ *
+ *
+ * Environment variables:
+ *
+ * - MCP_SERVER_URL (default: http://localhost:8080/mcp)
+ * - MCP_CLIENT_NAME (default: MCP Demo Client)
+ * - CALLBACK_PORT (default: 8888)
+ * - MCP_CLIENT_ID (optional: pre-registered client ID)
+ * - MCP_CLIENT_SECRET (optional: pre-registered client secret)
+ *
+ */
+public class McpClientDemo {
+
+ private static final Logger LOG = LoggerFactory.getLogger(McpClientDemo.class);
+
+ public static void main(String[] args) {
+ McpClientWrapper client = null;
+ try {
+ LOG.info("=== MCP OAuth Demo Starting ===");
+
+ // Configuration
+ String serverUrl = System.getenv().getOrDefault(
+ "MCP_SERVER_URL",
+ "http://localhost:8080/mcp"
+ );
+ String clientName = System.getenv().getOrDefault(
+ "MCP_CLIENT_NAME",
+ "MCP Demo Client"
+ );
+ String clientId = System.getenv("MCP_CLIENT_ID");
+ String clientSecret = System.getenv("MCP_CLIENT_SECRET");
+ int callbackPort = Integer.parseInt(
+ System.getenv().getOrDefault("CALLBACK_PORT", "8888")
+ );
+
+ LOG.info("Server URL: {}", serverUrl);
+ LOG.info("Client Name: {}", clientName);
+ LOG.info("Callback Port: {}", callbackPort);
+ if (clientId != null && !clientId.isEmpty()) {
+ LOG.info("Client ID override: {}", clientId);
+ }
+
+ // Create client configuration
+ McpClientConfig config = McpClientConfig.builder()
+ .serverUrl(serverUrl)
+ .clientName(clientName)
+ .clientId(clientId)
+ .clientSecret(clientSecret)
+ .callbackPort(callbackPort)
+ .build();
+
+ // Create MCP client with custom timeout
+ client = new McpClientWrapper(config, Duration.ofSeconds(30));
+
+ // Initialize connection (triggers OAuth flow if needed)
+ LOG.info("\n=== Step 1: Initializing MCP Connection ===");
+ InitializeResult initResult = client.initialize();
+ LOG.info("Server: {} v{}", initResult.serverInfo().name(), initResult.serverInfo().version());
+ LOG.info("Protocol version: {}", initResult.protocolVersion());
+
+ // Check authentication status
+ LOG.info("\n=== Step 2: Checking Authentication ===");
+ if (client.isAuthenticated()) {
+ LOG.info("Client is authenticated!");
+ String token = client.getAccessToken();
+ LOG.info("Access token available: {}...", token.substring(0, Math.min(20, token.length())));
+ } else {
+ LOG.error("Client is NOT authenticated!");
+ System.exit(1);
+ }
+
+ // List available tools
+ LOG.info("\n=== Step 3: Listing Available Tools ===");
+ ListToolsResult toolsResult = client.listTools();
+ if (toolsResult.tools() != null && !toolsResult.tools().isEmpty()) {
+ LOG.info("Found {} tools:", toolsResult.tools().size());
+ for (Tool tool : toolsResult.tools()) {
+ LOG.info(" - {}: {}", tool.name(), tool.description());
+ }
+ } else {
+ LOG.info("No tools available from server");
+ }
+
+ // List available resources (if supported)
+ LOG.info("\n=== Step 4: Listing Available Resources ===");
+ if (initResult.capabilities() != null && initResult.capabilities().resources() != null) {
+ ListResourcesResult resourcesResult = client.listResources();
+ if (resourcesResult.resources() != null && !resourcesResult.resources().isEmpty()) {
+ LOG.info("Found {} resources:", resourcesResult.resources().size());
+ for (Resource resource : resourcesResult.resources()) {
+ LOG.info(" - {}: {}", resource.uri(), resource.name());
+ }
+ } else {
+ LOG.info("No resources available from server");
+ }
+ } else {
+ LOG.info("Server does not support resources capability - skipping");
+ }
+
+ // List available prompts (if supported)
+ LOG.info("\n=== Step 5: Listing Available Prompts ===");
+ if (initResult.capabilities() != null && initResult.capabilities().prompts() != null) {
+ ListPromptsResult promptsResult = client.listPrompts();
+ if (promptsResult.prompts() != null && !promptsResult.prompts().isEmpty()) {
+ LOG.info("Found {} prompts:", promptsResult.prompts().size());
+ for (Prompt prompt : promptsResult.prompts()) {
+ LOG.info(" - {}: {}", prompt.name(), prompt.description());
+ }
+ } else {
+ LOG.info("No prompts available from server");
+ }
+ } else {
+ LOG.info("Server does not support prompts capability - skipping");
+ }
+
+ LOG.info("\n=== Demo Completed Successfully! ===");
+ LOG.info("The OAuth 2.1 + PKCE + DCR flow with full MCP SDK integration worked correctly.");
+ LOG.info("Key accomplishments:");
+ LOG.info(" 1. Discovered authorization server via RFC 9728");
+ LOG.info(" 2. Fetched authorization server metadata via RFC 8414");
+ LOG.info(" 3. Dynamically registered client via RFC 7591");
+ LOG.info(" 4. Generated PKCE parameters (S256)");
+ LOG.info(" 5. Opened browser for user authentication");
+ LOG.info(" 6. Received authorization code via callback");
+ LOG.info(" 7. Exchanged code for access token (with resource parameter)");
+ LOG.info(" 8. Successfully used MCP SDK with typed APIs");
+ LOG.info("\nYou can now use this pattern to make authenticated MCP requests!");
+
+ } catch (Exception e) {
+ LOG.error("Demo failed with error", e);
+ System.exit(1);
+ } finally {
+ // Clean up resources
+ if (client != null) {
+ client.close();
+ }
+ }
+ }
+}
diff --git a/jvm/src/main/java/com/muchq/mcpclient/docs/DCR_IN_MCP.md b/jvm/src/main/java/com/muchq/mcpclient/docs/DCR_IN_MCP.md
new file mode 100644
index 00000000..5c7ee580
--- /dev/null
+++ b/jvm/src/main/java/com/muchq/mcpclient/docs/DCR_IN_MCP.md
@@ -0,0 +1,128 @@
+# Dynamic Client Registration in MCP
+
+This document explains when and why to use OAuth 2.0 Dynamic Client Registration (DCR) with Model Context Protocol (MCP) servers.
+
+## What is DCR?
+
+Dynamic Client Registration ([RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591)) allows OAuth clients to register themselves with an authorization server programmatically, rather than requiring manual pre-registration.
+
+## When DCR Makes Sense
+
+| Context | Why DCR Works |
+|---------|---------------|
+| **Developer tools/IDEs** | Claude Code, VS Code extensions, etc. can self-register without IT creating OAuth clients for each developer |
+| **Desktop/CLI apps** | Each installation gets unique credentials; no shared client secrets baked into distributed binaries |
+| **Multi-tenant platforms** | Client apps connecting to many different organizations' MCP servers can register with each tenant's auth server |
+| **Self-service AI tooling** | Teams can deploy MCP-enabled tools without filing tickets to get OAuth clients provisioned |
+| **Open ecosystems** | Public MCP servers that want to allow any compatible client to connect |
+
+## When to Use Pre-registered Clients Instead
+
+| Context | Why Skip DCR |
+|---------|--------------|
+| **High-security environments** | All clients must be vetted before access |
+| **Production backends** | Server-to-server MCP calls where clients are known and fixed |
+| **Audit requirements** | Need explicit control over which clients exist |
+| **Rate limiting by client** | Want to assign quotas to specific known clients |
+
+## Open Ecosystem Pattern
+
+The most interesting DCR use case is building **public MCP servers that any compatible client can connect to**, similar to how public REST APIs work with OAuth.
+
+### Example: Public MCP Server
+
+```
+https://api.example.com/mcp
+├── tools/
+│ ├── weather_forecast
+│ ├── stock_quotes
+│ └── flight_search
+```
+
+### The Flow
+
+```
+┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
+│ Any MCP │ │ Your Auth │ │ Your MCP │
+│ Client │ │ Server │ │ Server │
+└────────┬────────┘ └────────┬────────┘ └────────┬────────┘
+ │ │ │
+ │ 1. GET /.well-known/oauth-protected-resource │
+ │──────────────────────────────────────────────>│
+ │ │ │
+ │ 2. Discover auth server │
+ │<──────────────────────────────────────────────│
+ │ │ │
+ │ 3. POST /register (DCR) │
+ │──────────────────────>│ │
+ │ │ │
+ │ 4. client_id assigned │ │
+ │<──────────────────────│ │
+ │ │ │
+ │ 5. User authenticates │ │
+ │──────────────────────>│ │
+ │ │ │
+ │ 6. Access token │ │
+ │<──────────────────────│ │
+ │ │ │
+ │ 7. Call tools with token │
+ │──────────────────────────────────────────────>│
+```
+
+### What DCR Enables
+
+| Without DCR | With DCR |
+|-------------|----------|
+| "Email us to request API access" | Client self-registers instantly |
+| Manual client_id provisioning | Automated client_id assignment |
+| You maintain client registry | Auth server handles it |
+| Friction for new integrations | Zero-friction onboarding |
+
+### The Key Insight
+
+DCR separates two concerns:
+
+1. **Client identity** - "What software is making requests?" (handled by DCR)
+2. **User identity** - "Who authorized this access?" (handled by OAuth login)
+
+In an open ecosystem, you don't gate on *which client* is connecting. You gate on:
+- Is the **user** authenticated?
+- Does the **user** have permission to use these tools?
+- Is the **user** within rate limits?
+
+### Real-World Analogues
+
+| Service | Pattern |
+|---------|---------|
+| Twitter API | Any app can register, users authorize access to their account |
+| GitHub OAuth Apps | Any developer can create an app, users grant repo access |
+| Google APIs | Register app in console, users consent to scopes |
+
+MCP + DCR enables the same pattern for AI tool ecosystems.
+
+## Implementation
+
+This MCP client supports both patterns:
+
+```java
+// DCR (automatic registration)
+McpClientConfig config = McpClientConfig.builder()
+ .serverUrl("https://api.example.com/mcp")
+ .clientName("My AI Tool")
+ .build();
+
+// Pre-registered (skip DCR)
+McpClientConfig config = McpClientConfig.builder()
+ .serverUrl("https://api.example.com/mcp")
+ .clientId("known-client-id")
+ .clientSecret("secret")
+ .build();
+```
+
+## Standards References
+
+- [RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591) - OAuth 2.0 Dynamic Client Registration
+- [RFC 7636](https://datatracker.ietf.org/doc/html/rfc7636) - PKCE (Proof Key for Code Exchange)
+- [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) - OAuth 2.0 Authorization Server Metadata
+- [RFC 8707](https://datatracker.ietf.org/doc/html/rfc8707) - Resource Indicators for OAuth 2.0
+- [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) - OAuth 2.0 Protected Resource Metadata
diff --git a/jvm/src/main/java/com/muchq/mcpclient/oauth/BUILD.bazel b/jvm/src/main/java/com/muchq/mcpclient/oauth/BUILD.bazel
new file mode 100644
index 00000000..b0d94dcd
--- /dev/null
+++ b/jvm/src/main/java/com/muchq/mcpclient/oauth/BUILD.bazel
@@ -0,0 +1,19 @@
+load("@rules_java//java:java_library.bzl", "java_library")
+
+java_library(
+ name = "oauth",
+ srcs = glob(["*.java"]),
+ visibility = [
+ "//jvm/src/main/java/com/muchq/mcpclient:__pkg__",
+ "//jvm/src/test/java/com/muchq/mcpclient/oauth:__pkg__",
+ ],
+ deps = [
+ "//jvm/src/main/java/com/muchq/mcpclient:config",
+ "@maven//:com_fasterxml_jackson_core_jackson_annotations",
+ "@maven//:com_fasterxml_jackson_core_jackson_databind",
+ "@maven//:com_nimbusds_nimbus_jose_jwt",
+ "@maven//:com_nimbusds_oauth2_oidc_sdk",
+ "@maven//:io_modelcontextprotocol_sdk_mcp",
+ "@maven//:org_slf4j_slf4j_api",
+ ],
+)
diff --git a/jvm/src/main/java/com/muchq/mcpclient/oauth/BrowserLauncher.java b/jvm/src/main/java/com/muchq/mcpclient/oauth/BrowserLauncher.java
new file mode 100644
index 00000000..3887a440
--- /dev/null
+++ b/jvm/src/main/java/com/muchq/mcpclient/oauth/BrowserLauncher.java
@@ -0,0 +1,56 @@
+package com.muchq.mcpclient.oauth;
+
+import java.awt.Desktop;
+import java.io.IOException;
+import java.net.URI;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Utility for opening the system web browser.
+ *
+ * Used to launch the OAuth authorization URL in the user's default browser.
+ *
+ * Fallback: If Desktop.browse() is not supported (headless systems),
+ * the URL is printed to console for manual opening.
+ */
+public class BrowserLauncher {
+
+ private static final Logger LOG = LoggerFactory.getLogger(BrowserLauncher.class);
+
+ /**
+ * Opens the specified URL in the system's default web browser.
+ *
+ * @param url The URL to open
+ * @throws IOException if the browser cannot be launched
+ */
+ public static void open(String url) throws IOException {
+ if (url == null || url.isEmpty()) {
+ throw new IllegalArgumentException("URL is required");
+ }
+
+ try {
+ if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) {
+ LOG.info("Opening browser: {}", url);
+ Desktop.getDesktop().browse(URI.create(url));
+ } else {
+ // Fallback for headless systems
+ LOG.warn("Desktop.browse() not supported. Please open this URL manually:");
+ System.out.println("\n" + "=".repeat(80));
+ System.out.println("Please open this URL in your browser to authorize:");
+ System.out.println(url);
+ System.out.println("=".repeat(80) + "\n");
+ }
+ } catch (Exception e) {
+ LOG.error("Failed to open browser", e);
+
+ // Fallback: print URL for manual opening
+ System.err.println("\nFailed to open browser automatically.");
+ System.err.println("Please open this URL manually:");
+ System.err.println(url);
+ System.err.println();
+
+ throw new IOException("Failed to open browser: " + e.getMessage(), e);
+ }
+ }
+}
diff --git a/jvm/src/main/java/com/muchq/mcpclient/oauth/CallbackServer.java b/jvm/src/main/java/com/muchq/mcpclient/oauth/CallbackServer.java
new file mode 100644
index 00000000..9f8c9478
--- /dev/null
+++ b/jvm/src/main/java/com/muchq/mcpclient/oauth/CallbackServer.java
@@ -0,0 +1,315 @@
+package com.muchq.mcpclient.oauth;
+
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Temporary HTTP server for OAuth callback.
+ *
+ * This server:
+ * 1. Listens on localhost for the OAuth redirect
+ * 2. Parses the authorization code from the query string
+ * 3. Returns a success/error HTML page to the browser
+ * 4. Provides the authorization code to the OAuth flow
+ *
+ * Security: Only accepts connections on localhost to prevent external access.
+ */
+public class CallbackServer {
+
+ private static final Logger LOG = LoggerFactory.getLogger(CallbackServer.class);
+
+ private final HttpServer server;
+ private final CompletableFuture responseFuture;
+ private final int port;
+ private final String expectedState;
+
+ /**
+ * Creates a new callback server listening on the specified port.
+ *
+ * @param port Port to listen on (typically 8888)
+ * @throws IOException if the server cannot be started
+ */
+ public CallbackServer(int port, String expectedState) throws IOException {
+ this.port = port;
+ this.expectedState = expectedState;
+ this.responseFuture = new CompletableFuture<>();
+
+ // Create server bound to localhost only
+ this.server = HttpServer.create(new InetSocketAddress("localhost", port), 0);
+
+ // Register callback endpoint
+ server.createContext("/callback", this::handleCallback);
+
+ LOG.info("OAuth callback server created on port {}", port);
+ }
+
+ /**
+ * Starts the callback server.
+ */
+ public void start() {
+ server.start();
+ LOG.info("OAuth callback server started: http://localhost:{}/callback", port);
+ }
+
+ /**
+ * Stops the callback server.
+ */
+ public void stop() {
+ server.stop(0);
+ LOG.info("OAuth callback server stopped");
+ }
+
+ /**
+ * Waits for the OAuth authorization response.
+ *
+ * @param timeout Maximum time to wait
+ * @param unit Time unit for timeout
+ * @return Authorization code if successful
+ * @throws TimeoutException if timeout is reached
+ * @throws IOException if authorization failed or was denied
+ * @throws InterruptedException if interrupted while waiting
+ */
+ public String waitForAuthorizationCode(long timeout, TimeUnit unit)
+ throws TimeoutException, IOException, InterruptedException {
+ try {
+ AuthorizationResponse response = responseFuture.get(timeout, unit);
+
+ if (response.isSuccess()) {
+ return response.code();
+ } else {
+ throw new IOException("Authorization failed: " + response.error());
+ }
+ } catch (java.util.concurrent.ExecutionException e) {
+ throw new IOException("Authorization error", e.getCause());
+ }
+ }
+
+ /**
+ * Handles the OAuth callback request.
+ */
+ private void handleCallback(HttpExchange exchange) throws IOException {
+ try {
+ URI requestUri = exchange.getRequestURI();
+ Map queryParams = parseQueryString(requestUri.getQuery());
+
+ LOG.debug("Received OAuth callback: {}", queryParams.keySet());
+
+ if (expectedState != null) {
+ String receivedState = queryParams.get("state");
+ if (receivedState == null || !expectedState.equals(receivedState)) {
+ LOG.warn("Invalid OAuth state: expected={}, received={}", expectedState, receivedState);
+ responseFuture.complete(AuthorizationResponse.error("invalid_state", "State mismatch"));
+ sendErrorResponse(exchange, "invalid_state", "State mismatch");
+ return;
+ }
+ }
+
+ if (queryParams.containsKey("code")) {
+ // Success - authorization code received
+ String code = queryParams.get("code");
+ LOG.info("Authorization code received");
+
+ responseFuture.complete(AuthorizationResponse.success(code));
+ try {
+ sendSuccessResponse(exchange);
+ } catch (IOException e) {
+ LOG.debug("Failed to write success response to browser", e);
+ }
+
+ } else if (queryParams.containsKey("error")) {
+ // Error - authorization failed/denied
+ String error = queryParams.get("error");
+ String errorDescription = queryParams.getOrDefault("error_description", "Unknown error");
+ LOG.warn("Authorization failed: {} - {}", error, errorDescription);
+
+ responseFuture.complete(AuthorizationResponse.error(error, errorDescription));
+ try {
+ sendErrorResponse(exchange, error, errorDescription);
+ } catch (IOException e) {
+ LOG.debug("Failed to write error response to browser", e);
+ }
+
+ } else {
+ // Invalid callback - missing both code and error
+ LOG.warn("Invalid OAuth callback: missing code and error parameters");
+ responseFuture.completeExceptionally(
+ new IOException("Invalid OAuth callback: missing code and error")
+ );
+ try {
+ sendErrorResponse(exchange, "invalid_request", "Missing authorization code");
+ } catch (IOException e) {
+ LOG.debug("Failed to write error response to browser", e);
+ }
+ }
+
+ } catch (Exception e) {
+ LOG.error("Error handling OAuth callback", e);
+ responseFuture.completeExceptionally(e);
+ try {
+ sendErrorResponse(exchange, "server_error", "Internal server error");
+ } catch (IOException ioException) {
+ LOG.debug("Failed to write error response to browser", ioException);
+ }
+ }
+ }
+
+ /**
+ * Parses query string into key-value map.
+ * Handles parameters with and without values (e.g., "foo=bar" and "foo").
+ */
+ private Map parseQueryString(String query) {
+ Map params = new HashMap<>();
+ if (query == null || query.isEmpty()) {
+ return params;
+ }
+
+ for (String param : query.split("&")) {
+ if (param.isEmpty()) {
+ continue;
+ }
+ String[] pair = param.split("=", 2);
+ String key = urlDecode(pair[0]);
+ String value = pair.length == 2 ? urlDecode(pair[1]) : "";
+ params.put(key, value);
+ }
+
+ return params;
+ }
+
+ private String urlDecode(String value) {
+ try {
+ return java.net.URLDecoder.decode(value, StandardCharsets.UTF_8);
+ } catch (IllegalArgumentException e) {
+ return value;
+ }
+ }
+
+ /**
+ * Sends success HTML response to browser.
+ */
+ private void sendSuccessResponse(HttpExchange exchange) throws IOException {
+ String html = """
+
+
+
+ Authorization Successful
+
+
+
+
+
Authorization Successful!
+
You can close this window and return to the application.
+
+
+
+ """;
+
+ sendHtmlResponse(exchange, 200, html);
+ }
+
+ /**
+ * Sends error HTML response to browser.
+ */
+ private void sendErrorResponse(HttpExchange exchange, String error, String description) throws IOException {
+ String html = String.format("""
+
+
+
+ Authorization Failed
+
+
+
+
+
Authorization Failed
+
%s
+
%s
+
+
+
+ """, error, description);
+
+ sendHtmlResponse(exchange, 200, html);
+ }
+
+ /**
+ * Sends HTML response.
+ */
+ private void sendHtmlResponse(HttpExchange exchange, int statusCode, String html) throws IOException {
+ byte[] bytes = html.getBytes(StandardCharsets.UTF_8);
+ exchange.getResponseHeaders().set("Content-Type", "text/html; charset=UTF-8");
+ exchange.sendResponseHeaders(statusCode, bytes.length);
+
+ try (OutputStream os = exchange.getResponseBody()) {
+ os.write(bytes);
+ }
+ }
+
+ /**
+ * Authorization response from OAuth callback.
+ */
+ private record AuthorizationResponse(boolean success, String code, String error, String errorDescription) {
+
+ static AuthorizationResponse success(String code) {
+ return new AuthorizationResponse(true, code, null, null);
+ }
+
+ static AuthorizationResponse error(String error, String errorDescription) {
+ return new AuthorizationResponse(false, null, error, errorDescription);
+ }
+
+ boolean isSuccess() {
+ return success;
+ }
+ }
+}
diff --git a/jvm/src/main/java/com/muchq/mcpclient/oauth/OAuthFlowHandler.java b/jvm/src/main/java/com/muchq/mcpclient/oauth/OAuthFlowHandler.java
new file mode 100644
index 00000000..52af98d9
--- /dev/null
+++ b/jvm/src/main/java/com/muchq/mcpclient/oauth/OAuthFlowHandler.java
@@ -0,0 +1,509 @@
+package com.muchq.mcpclient.oauth;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.muchq.mcpclient.McpClientConfig;
+import com.nimbusds.oauth2.sdk.*;
+import com.nimbusds.oauth2.sdk.RefreshTokenGrant;
+import com.nimbusds.oauth2.sdk.auth.ClientAuthentication;
+import com.nimbusds.oauth2.sdk.auth.ClientAuthenticationMethod;
+import com.nimbusds.oauth2.sdk.auth.ClientSecretBasic;
+import com.nimbusds.oauth2.sdk.auth.ClientSecretPost;
+import com.nimbusds.oauth2.sdk.auth.Secret;
+import com.nimbusds.oauth2.sdk.http.HTTPRequest;
+import com.nimbusds.oauth2.sdk.http.HTTPResponse;
+import com.nimbusds.oauth2.sdk.id.ClientID;
+import com.nimbusds.oauth2.sdk.id.State;
+import com.nimbusds.oauth2.sdk.pkce.CodeChallenge;
+import com.nimbusds.oauth2.sdk.pkce.CodeChallengeMethod;
+import com.nimbusds.oauth2.sdk.pkce.CodeVerifier;
+import com.nimbusds.oauth2.sdk.token.AccessToken;
+import com.nimbusds.oauth2.sdk.token.RefreshToken;
+import com.nimbusds.openid.connect.sdk.OIDCTokenResponse;
+import com.nimbusds.openid.connect.sdk.OIDCTokenResponseParser;
+import com.nimbusds.openid.connect.sdk.rp.OIDCClientInformation;
+import com.nimbusds.openid.connect.sdk.rp.OIDCClientInformationResponse;
+import com.nimbusds.openid.connect.sdk.rp.OIDCClientMetadata;
+import com.nimbusds.openid.connect.sdk.rp.OIDCClientRegistrationRequest;
+import java.io.IOException;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Handles the complete OAuth 2.1 + PKCE + DCR flow for MCP authentication.
+ *
+ * Flow:
+ * 1. Fetch Protected Resource Metadata (RFC 9728) from MCP server
+ * 2. Fetch Authorization Server Metadata (RFC 8414) from Keycloak
+ * 3. Dynamically Register Client (RFC 7591)
+ * 4. Generate PKCE parameters
+ * 5. Build authorization URL with resource parameter (RFC 8707)
+ * 6. Open browser for user authentication
+ * 7. Wait for authorization code via callback server
+ * 8. Exchange code for tokens (with code_verifier + resource parameter)
+ * 9. Store tokens in TokenManager
+ */
+public class OAuthFlowHandler {
+
+ private static final Logger LOG = LoggerFactory.getLogger(OAuthFlowHandler.class);
+
+ private final McpClientConfig config;
+ private final TokenManager tokenManager;
+ private final HttpClient httpClient;
+ private final ObjectMapper objectMapper;
+
+ // Cached state for token refresh
+ private volatile String cachedTokenEndpoint;
+ private volatile ClientRegistration cachedClientRegistration;
+ private volatile String cachedResourceUri;
+
+ public OAuthFlowHandler(McpClientConfig config, TokenManager tokenManager) {
+ this.config = config;
+ this.tokenManager = tokenManager;
+ this.httpClient = HttpClient.newHttpClient();
+ this.objectMapper = new ObjectMapper();
+ }
+
+ /**
+ * Refreshes the access token using the stored refresh token.
+ *
+ * @throws Exception if token refresh fails or no refresh token is available
+ */
+ public synchronized void refreshAccessToken() throws Exception {
+ String refreshToken = tokenManager.getRefreshToken();
+ if (refreshToken == null) {
+ throw new IOException("No refresh token available for token refresh");
+ }
+
+ if (cachedTokenEndpoint == null || cachedClientRegistration == null) {
+ LOG.info("No cached OAuth metadata, performing full discovery...");
+ // Re-discover OAuth endpoints
+ String resourceUri = extractResourceUri(config.getServerUrl());
+ ProtectedResourceMetadata resourceMetadata = fetchProtectedResourceMetadata(resourceUri);
+ String authzServerUrl = resourceMetadata.authorization_servers().get(0);
+ AuthorizationServerMetadata authzMetadata = fetchAuthorizationServerMetadata(authzServerUrl);
+
+ cachedTokenEndpoint = authzMetadata.token_endpoint();
+ cachedResourceUri = resourceUri;
+
+ String redirectUri = "http://localhost:" + config.getCallbackPort() + "/callback";
+ cachedClientRegistration = resolveClientRegistration(authzMetadata, redirectUri);
+ }
+
+ LOG.info("Refreshing access token...");
+
+ RefreshTokenGrant grant = new RefreshTokenGrant(
+ new com.nimbusds.oauth2.sdk.token.RefreshToken(refreshToken)
+ );
+
+ ClientAuthentication clientAuthentication = buildClientAuthentication(cachedClientRegistration);
+ List resources = List.of(URI.create(cachedResourceUri));
+
+ com.nimbusds.oauth2.sdk.TokenRequest tokenRequest;
+ if (clientAuthentication == null) {
+ tokenRequest = new com.nimbusds.oauth2.sdk.TokenRequest(
+ URI.create(cachedTokenEndpoint),
+ cachedClientRegistration.clientId(),
+ grant,
+ null,
+ resources,
+ null,
+ null
+ );
+ } else {
+ tokenRequest = new com.nimbusds.oauth2.sdk.TokenRequest(
+ URI.create(cachedTokenEndpoint),
+ clientAuthentication,
+ grant,
+ null,
+ resources,
+ null
+ );
+ }
+
+ HTTPRequest httpRequest = tokenRequest.toHTTPRequest();
+ HTTPResponse httpResponse = httpRequest.send();
+
+ com.nimbusds.oauth2.sdk.TokenResponse tokenResponse = OIDCTokenResponseParser.parse(httpResponse);
+
+ if (!tokenResponse.indicatesSuccess()) {
+ // Clear cached state on failure so next attempt does full flow
+ clearCachedState();
+ throw new IOException("Token refresh failed: " + tokenResponse.toErrorResponse().getErrorObject());
+ }
+
+ OIDCTokenResponse successResponse = (OIDCTokenResponse) tokenResponse.toSuccessResponse();
+ AccessToken accessToken = successResponse.getOIDCTokens().getAccessToken();
+ com.nimbusds.oauth2.sdk.token.RefreshToken newRefreshToken = successResponse.getOIDCTokens().getRefreshToken();
+
+ long expiresIn = accessToken.getLifetime() != 0 ? accessToken.getLifetime() : 300L;
+
+ tokenManager.storeTokens(
+ accessToken.getValue(),
+ newRefreshToken != null ? newRefreshToken.getValue() : refreshToken,
+ expiresIn
+ );
+
+ LOG.info("Access token refreshed successfully, expires in {} seconds", expiresIn);
+ }
+
+ /**
+ * Clears cached OAuth state, forcing full re-discovery on next operation.
+ */
+ public synchronized void clearCachedState() {
+ cachedTokenEndpoint = null;
+ cachedClientRegistration = null;
+ cachedResourceUri = null;
+ }
+
+ /**
+ * Executes the complete OAuth 2.1 flow and stores tokens.
+ *
+ * @throws Exception if any step of the OAuth flow fails
+ */
+ public void executeFlow() throws Exception {
+ LOG.info("Starting OAuth 2.1 + PKCE + DCR flow for MCP server: {}", config.getServerUrl());
+
+ // Step 1: Fetch Protected Resource Metadata (RFC 9728)
+ String resourceUri = extractResourceUri(config.getServerUrl());
+ ProtectedResourceMetadata resourceMetadata = fetchProtectedResourceMetadata(resourceUri);
+ LOG.info("Protected Resource: {}", resourceMetadata.resource());
+
+ if (resourceMetadata.authorization_servers() == null || resourceMetadata.authorization_servers().isEmpty()) {
+ throw new IOException("Protected Resource Metadata missing authorization_servers");
+ }
+ String authzServerUrl = resourceMetadata.authorization_servers().get(0);
+ LOG.info("Authorization Server: {}", authzServerUrl);
+
+ // Step 2: Fetch Authorization Server Metadata (RFC 8414)
+ AuthorizationServerMetadata authzMetadata = fetchAuthorizationServerMetadata(authzServerUrl);
+ LOG.info("Authorization Endpoint: {}", authzMetadata.authorization_endpoint());
+ LOG.info("Token Endpoint: {}", authzMetadata.token_endpoint());
+ LOG.info("Registration Endpoint: {}", authzMetadata.registration_endpoint());
+
+ // Step 3: Dynamic Client Registration (RFC 7591)
+ String redirectUri = "http://localhost:" + config.getCallbackPort() + "/callback";
+ ClientRegistration clientRegistration = resolveClientRegistration(
+ authzMetadata,
+ redirectUri
+ );
+ LOG.info("Client ID: {}", clientRegistration.clientId().getValue());
+
+ // Cache OAuth state for token refresh
+ this.cachedTokenEndpoint = authzMetadata.token_endpoint();
+ this.cachedClientRegistration = clientRegistration;
+ this.cachedResourceUri = resourceUri;
+
+ // Step 4: Generate PKCE parameters (Nimbus SDK handles this)
+ CodeVerifier codeVerifier = new CodeVerifier();
+ CodeChallenge codeChallenge = CodeChallenge.compute(CodeChallengeMethod.S256, codeVerifier);
+
+ // Step 5: Build authorization request with resource parameter (RFC 8707)
+ State state = new State();
+ AuthorizationRequest authzRequest = new AuthorizationRequest.Builder(
+ new ResponseType(ResponseType.Value.CODE),
+ clientRegistration.clientId()
+ )
+ .endpointURI(URI.create(authzMetadata.authorization_endpoint()))
+ .redirectionURI(URI.create(redirectUri))
+ .codeChallenge(codeChallenge, CodeChallengeMethod.S256)
+ .customParameter("resource", resourceUri) // RFC 8707 - CRITICAL!
+ .state(state)
+ .scope(new Scope("openid"))
+ .build();
+
+ String authzUrl = authzRequest.toURI().toString();
+ LOG.info("Authorization URL: {}", authzUrl);
+
+ // Step 6: Start local callback server
+ CallbackServer callbackServer = new CallbackServer(config.getCallbackPort(), state.getValue());
+ callbackServer.start();
+
+ try {
+ // Step 7: Open browser for user authentication
+ LOG.info("Opening browser for user authentication...");
+ BrowserLauncher.open(authzUrl);
+
+ // Step 8: Wait for authorization code
+ LOG.info("Waiting for authorization code (timeout: 5 minutes)...");
+ String authCode = callbackServer.waitForAuthorizationCode(5, TimeUnit.MINUTES);
+ LOG.info("Authorization code received");
+
+ // Step 9: Exchange code for tokens with resource parameter
+ OAuthTokenResponse tokens = exchangeCodeForTokens(
+ authzMetadata.token_endpoint(),
+ clientRegistration,
+ authCode,
+ redirectUri,
+ codeVerifier,
+ resourceUri // RFC 8707 - CRITICAL!
+ );
+
+ // Step 10: Store tokens
+ long expiresIn = tokens.access_token_expires_in() != null ?
+ tokens.access_token_expires_in() : 300L; // Default 5 minutes
+
+ tokenManager.storeTokens(
+ tokens.access_token(),
+ tokens.refresh_token(),
+ expiresIn
+ );
+
+ LOG.info("OAuth flow completed successfully!");
+ LOG.info("Access token expires in {} seconds", expiresIn);
+
+ } finally {
+ // Always stop the callback server
+ callbackServer.stop();
+ }
+ }
+
+ /**
+ * Fetches Protected Resource Metadata (RFC 9728) from MCP server.
+ */
+ private ProtectedResourceMetadata fetchProtectedResourceMetadata(String resourceUri) throws Exception {
+ String metadataUrl = resourceUri + "/.well-known/oauth-protected-resource";
+ LOG.debug("Fetching Protected Resource Metadata: {}", metadataUrl);
+
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create(metadataUrl))
+ .GET()
+ .build();
+
+ HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
+
+ if (response.statusCode() != 200) {
+ throw new IOException("Failed to fetch Protected Resource Metadata: HTTP " + response.statusCode());
+ }
+
+ return objectMapper.readValue(response.body(), ProtectedResourceMetadata.class);
+ }
+
+ /**
+ * Fetches Authorization Server Metadata (RFC 8414) from Keycloak.
+ */
+ private AuthorizationServerMetadata fetchAuthorizationServerMetadata(String authzServerUrl) throws Exception {
+ String metadataUrl = authzServerUrl + "/.well-known/openid-configuration";
+ LOG.debug("Fetching Authorization Server Metadata: {}", metadataUrl);
+
+ HttpRequest request = HttpRequest.newBuilder()
+ .uri(URI.create(metadataUrl))
+ .GET()
+ .build();
+
+ HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
+
+ if (response.statusCode() != 200) {
+ throw new IOException("Failed to fetch Authorization Server Metadata: HTTP " + response.statusCode());
+ }
+
+ return objectMapper.readValue(response.body(), AuthorizationServerMetadata.class);
+ }
+
+ /**
+ * Registers a new OAuth client dynamically (RFC 7591).
+ */
+ private ClientRegistration resolveClientRegistration(
+ AuthorizationServerMetadata authzMetadata,
+ String redirectUri
+ ) throws Exception {
+ String configuredClientId = config.getClientId();
+ if (configuredClientId != null && !configuredClientId.isEmpty()) {
+ LOG.info("Using preconfigured client ID; skipping dynamic registration.");
+ Secret secret = null;
+ ClientAuthenticationMethod authMethod = ClientAuthenticationMethod.NONE;
+ if (config.getClientSecret() != null && !config.getClientSecret().isEmpty()) {
+ secret = new Secret(config.getClientSecret());
+ authMethod = ClientAuthenticationMethod.CLIENT_SECRET_BASIC;
+ }
+ return new ClientRegistration(new ClientID(configuredClientId), secret, authMethod);
+ }
+
+ if (authzMetadata.registration_endpoint() == null) {
+ throw new IOException("Authorization server does not advertise a registration endpoint.");
+ }
+
+ return registerClient(
+ authzMetadata.registration_endpoint(),
+ config.getClientName(),
+ redirectUri
+ );
+ }
+
+ private ClientRegistration registerClient(String registrationEndpoint, String clientName, String redirectUri)
+ throws Exception {
+ LOG.info("Registering client dynamically: {}", clientName);
+
+ OIDCClientMetadata metadata = new OIDCClientMetadata();
+ metadata.setName(clientName);
+ metadata.setRedirectionURIs(Set.of(URI.create(redirectUri)));
+ metadata.setGrantTypes(Set.of(GrantType.AUTHORIZATION_CODE, GrantType.REFRESH_TOKEN));
+ metadata.setResponseTypes(Set.of(new ResponseType(ResponseType.Value.CODE)));
+ metadata.setTokenEndpointAuthMethod(ClientAuthenticationMethod.NONE);
+
+ OIDCClientRegistrationRequest regRequest = new OIDCClientRegistrationRequest(
+ URI.create(registrationEndpoint),
+ metadata,
+ null
+ );
+
+ HTTPRequest httpRequest = regRequest.toHTTPRequest();
+ HTTPResponse httpResponse = httpRequest.send();
+
+ OIDCClientInformationResponse regResponse = OIDCClientInformationResponse.parse(httpResponse);
+
+ if (!regResponse.indicatesSuccess()) {
+ throw new IOException("Client registration failed: " + regResponse.toErrorResponse().getErrorObject());
+ }
+
+ OIDCClientInformation clientInfo =
+ (OIDCClientInformation) regResponse.toSuccessResponse().getClientInformation();
+ ClientAuthenticationMethod authMethod = clientInfo.getOIDCMetadata().getTokenEndpointAuthMethod();
+ return new ClientRegistration(clientInfo.getID(), clientInfo.getSecret(), authMethod);
+ }
+
+ /**
+ * Exchanges authorization code for access token (with PKCE + resource parameter).
+ */
+ private OAuthTokenResponse exchangeCodeForTokens(
+ String tokenEndpoint,
+ ClientRegistration clientRegistration,
+ String authCode,
+ String redirectUri,
+ CodeVerifier codeVerifier,
+ String resourceUri
+ ) throws Exception {
+ LOG.info("Exchanging authorization code for tokens...");
+
+ // Build token request with PKCE
+ AuthorizationGrant grant = new AuthorizationCodeGrant(
+ new AuthorizationCode(authCode),
+ URI.create(redirectUri),
+ codeVerifier
+ );
+
+ ClientAuthentication clientAuthentication = buildClientAuthentication(clientRegistration);
+
+ com.nimbusds.oauth2.sdk.TokenRequest tokenRequest;
+ List resources = List.of(URI.create(resourceUri)); // RFC 8707 resource parameter - CRITICAL!
+
+ if (clientAuthentication == null) {
+ tokenRequest = new com.nimbusds.oauth2.sdk.TokenRequest(
+ URI.create(tokenEndpoint),
+ clientRegistration.clientId(),
+ grant,
+ null,
+ resources,
+ null,
+ null
+ );
+ } else {
+ tokenRequest = new com.nimbusds.oauth2.sdk.TokenRequest(
+ URI.create(tokenEndpoint),
+ clientAuthentication,
+ grant,
+ null,
+ resources,
+ null
+ );
+ }
+
+ HTTPRequest httpRequest = tokenRequest.toHTTPRequest();
+ HTTPResponse httpResponse = httpRequest.send();
+
+ // Parse response
+ com.nimbusds.oauth2.sdk.TokenResponse tokenResponse = OIDCTokenResponseParser.parse(httpResponse);
+
+ if (!tokenResponse.indicatesSuccess()) {
+ throw new IOException("Token exchange failed: " + tokenResponse.toErrorResponse().getErrorObject());
+ }
+
+ OIDCTokenResponse successResponse = (OIDCTokenResponse) tokenResponse.toSuccessResponse();
+ AccessToken accessToken = successResponse.getOIDCTokens().getAccessToken();
+ RefreshToken refreshToken = successResponse.getOIDCTokens().getRefreshToken();
+
+ return new OAuthTokenResponse(
+ accessToken.getValue(),
+ refreshToken != null ? refreshToken.getValue() : null,
+ accessToken.getLifetime()
+ );
+ }
+
+ /**
+ * Extracts resource URI from server URL.
+ * Removes path component to get base URL.
+ */
+ private String extractResourceUri(String serverUrl) {
+ try {
+ URI uri = URI.create(serverUrl);
+ return uri.getScheme() + "://" + uri.getAuthority();
+ } catch (Exception e) {
+ // Fallback: return as-is
+ return serverUrl;
+ }
+ }
+
+ private ClientAuthentication buildClientAuthentication(ClientRegistration clientRegistration) throws Exception {
+ if (clientRegistration == null) {
+ return null;
+ }
+
+ ClientAuthenticationMethod authMethod = clientRegistration.authMethod();
+ Secret clientSecret = clientRegistration.clientSecret();
+
+ if (authMethod == null || ClientAuthenticationMethod.NONE.equals(authMethod)) {
+ return null;
+ }
+
+ if (clientSecret == null) {
+ throw new IOException("Token endpoint requires client authentication, but no client secret was provided.");
+ }
+
+ if (ClientAuthenticationMethod.CLIENT_SECRET_BASIC.equals(authMethod)) {
+ return new ClientSecretBasic(clientRegistration.clientId(), clientSecret);
+ }
+
+ if (ClientAuthenticationMethod.CLIENT_SECRET_POST.equals(authMethod)) {
+ return new ClientSecretPost(clientRegistration.clientId(), clientSecret);
+ }
+
+ throw new IOException("Unsupported token endpoint auth method: " + authMethod.getValue());
+ }
+
+ // DTOs for metadata and tokens
+
+ @com.fasterxml.jackson.annotation.JsonIgnoreProperties(ignoreUnknown = true)
+ public record ProtectedResourceMetadata(
+ String resource,
+ List authorization_servers,
+ List bearer_methods_supported
+ ) {}
+
+ @com.fasterxml.jackson.annotation.JsonIgnoreProperties(ignoreUnknown = true)
+ public record AuthorizationServerMetadata(
+ String issuer,
+ String authorization_endpoint,
+ String token_endpoint,
+ String registration_endpoint,
+ String jwks_uri
+ ) {}
+
+ public record OAuthTokenResponse(
+ String access_token,
+ String refresh_token,
+ Long access_token_expires_in
+ ) {}
+
+ private record ClientRegistration(
+ ClientID clientId,
+ Secret clientSecret,
+ ClientAuthenticationMethod authMethod
+ ) {}
+}
diff --git a/jvm/src/main/java/com/muchq/mcpclient/oauth/OAuthRequestCustomizer.java b/jvm/src/main/java/com/muchq/mcpclient/oauth/OAuthRequestCustomizer.java
new file mode 100644
index 00000000..9bb8bb17
--- /dev/null
+++ b/jvm/src/main/java/com/muchq/mcpclient/oauth/OAuthRequestCustomizer.java
@@ -0,0 +1,82 @@
+package com.muchq.mcpclient.oauth;
+
+import io.modelcontextprotocol.client.transport.customizer.McpSyncHttpClientRequestCustomizer;
+import io.modelcontextprotocol.common.McpTransportContext;
+import java.net.URI;
+import java.net.http.HttpRequest;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * OAuth 2.1 request customizer for MCP SDK transports.
+ *
+ * This customizer integrates with the MCP SDK's transport layer to inject
+ * OAuth Bearer tokens into outgoing HTTP requests. It works in conjunction
+ * with the TokenManager and OAuthFlowHandler to provide automatic authentication.
+ *
+ *
Features:
+ *
+ * - Injects Authorization header with Bearer token
+ * - Triggers OAuth flow when no valid token is available
+ * - Supports token refresh when tokens expire
+ * - Thread-safe for concurrent requests
+ *
+ */
+public class OAuthRequestCustomizer implements McpSyncHttpClientRequestCustomizer {
+
+ private static final Logger LOG = LoggerFactory.getLogger(OAuthRequestCustomizer.class);
+
+ private final TokenManager tokenManager;
+ private final OAuthFlowHandler oauthFlowHandler;
+
+ /**
+ * Creates a new OAuth request customizer.
+ *
+ * @param tokenManager Manages OAuth tokens
+ * @param oauthFlowHandler Handles OAuth flow execution
+ */
+ public OAuthRequestCustomizer(TokenManager tokenManager, OAuthFlowHandler oauthFlowHandler) {
+ this.tokenManager = tokenManager;
+ this.oauthFlowHandler = oauthFlowHandler;
+ }
+
+ /**
+ * Customizes the HTTP request by adding the OAuth Bearer token.
+ *
+ * If no valid token is available and this is not already an authentication
+ * request, this method will trigger the OAuth flow to obtain a new token.
+ *
+ * @param builder The HTTP request builder to customize
+ * @param method The MCP method being called (e.g., "tools/list")
+ * @param endpoint The target URI
+ * @param body The request body
+ * @param context The transport context
+ */
+ @Override
+ public void customize(HttpRequest.Builder builder, String method, URI endpoint, String body,
+ McpTransportContext context) {
+ String token = tokenManager.getAccessToken();
+
+ if (token == null) {
+ LOG.debug("No valid access token available for request to {}", endpoint);
+ // Check if we have a refresh token we can use
+ String refreshToken = tokenManager.getRefreshToken();
+ if (refreshToken != null) {
+ LOG.info("Attempting to refresh access token...");
+ try {
+ oauthFlowHandler.refreshAccessToken();
+ token = tokenManager.getAccessToken();
+ } catch (Exception e) {
+ LOG.warn("Token refresh failed, will need full OAuth flow: {}", e.getMessage());
+ }
+ }
+ }
+
+ if (token != null) {
+ LOG.debug("Adding Authorization header to request");
+ builder.header("Authorization", "Bearer " + token);
+ } else {
+ LOG.warn("No access token available - request may fail with 401");
+ }
+ }
+}
diff --git a/jvm/src/main/java/com/muchq/mcpclient/oauth/TokenManager.java b/jvm/src/main/java/com/muchq/mcpclient/oauth/TokenManager.java
new file mode 100644
index 00000000..d03d7d40
--- /dev/null
+++ b/jvm/src/main/java/com/muchq/mcpclient/oauth/TokenManager.java
@@ -0,0 +1,104 @@
+package com.muchq.mcpclient.oauth;
+
+import java.time.Instant;
+
+/**
+ * Thread-safe manager for OAuth access and refresh tokens.
+ *
+ * Responsibilities:
+ * - Store access token, refresh token, and expiration time
+ * - Check token expiration before returning
+ * - Thread-safe access for concurrent requests
+ *
+ * Note: This is an in-memory implementation suitable for demo purposes.
+ * Production implementations should use secure storage (OS keychain, encrypted file, etc.)
+ */
+public class TokenManager {
+
+ /**
+ * Safety buffer in seconds before token expiration.
+ * This prevents race conditions where a token expires between validation and use.
+ */
+ private static final long EXPIRATION_BUFFER_SECONDS = 30;
+
+ private volatile String accessToken;
+ private volatile String refreshToken;
+ private volatile Instant expiresAt;
+
+ /**
+ * Stores OAuth tokens with expiration tracking.
+ *
+ * @param accessToken The JWT access token
+ * @param refreshToken The refresh token (optional, may be null)
+ * @param expiresInSeconds Number of seconds until access token expires
+ */
+ public synchronized void storeTokens(String accessToken, String refreshToken, long expiresInSeconds) {
+ if (accessToken == null || accessToken.isEmpty()) {
+ throw new IllegalArgumentException("accessToken is required");
+ }
+ if (expiresInSeconds <= 0) {
+ throw new IllegalArgumentException("expiresInSeconds must be positive, got: " + expiresInSeconds);
+ }
+
+ this.accessToken = accessToken;
+ this.refreshToken = refreshToken;
+ this.expiresAt = Instant.now().plusSeconds(expiresInSeconds);
+ }
+
+ /**
+ * Retrieves the access token if it's still valid.
+ * Includes a safety buffer before actual expiration to prevent race conditions.
+ *
+ * @return Access token, or null if expired/missing
+ */
+ public synchronized String getAccessToken() {
+ if (accessToken == null) {
+ return null;
+ }
+
+ // Check if token is expired (with safety buffer)
+ Instant bufferExpiry = expiresAt.minusSeconds(EXPIRATION_BUFFER_SECONDS);
+ if (Instant.now().isAfter(bufferExpiry)) {
+ return null;
+ }
+
+ return accessToken;
+ }
+
+ /**
+ * Retrieves the refresh token.
+ *
+ * @return Refresh token, or null if not available
+ */
+ public synchronized String getRefreshToken() {
+ return refreshToken;
+ }
+
+ /**
+ * Checks if a valid access token is available.
+ *
+ * @return true if access token exists and is not expired
+ */
+ public synchronized boolean hasValidAccessToken() {
+ return getAccessToken() != null;
+ }
+
+ /**
+ * Clears all stored tokens.
+ * Useful when logging out or when authentication fails.
+ */
+ public synchronized void clearTokens() {
+ this.accessToken = null;
+ this.refreshToken = null;
+ this.expiresAt = null;
+ }
+
+ /**
+ * Gets the expiration time of the current access token.
+ *
+ * @return Expiration instant, or null if no token stored
+ */
+ public synchronized Instant getExpiresAt() {
+ return expiresAt;
+ }
+}
diff --git a/jvm/src/main/java/com/muchq/mcpserver/BUILD.bazel b/jvm/src/main/java/com/muchq/mcpserver/BUILD.bazel
index a83d4a67..a07871a5 100644
--- a/jvm/src/main/java/com/muchq/mcpserver/BUILD.bazel
+++ b/jvm/src/main/java/com/muchq/mcpserver/BUILD.bazel
@@ -28,6 +28,7 @@ java_binary(
"//jvm/src/main/java/com/muchq/http_client/jdk11",
"//jvm/src/main/java/com/muchq/json",
"//jvm/src/main/java/com/muchq/mcpserver/dtos",
+ "//jvm/src/main/java/com/muchq/mcpserver/oauth",
"//jvm/src/main/java/com/muchq/mcpserver/tools",
"@maven//:com_fasterxml_jackson_core_jackson_annotations",
"@maven//:com_fasterxml_jackson_core_jackson_databind",
diff --git a/jvm/src/main/java/com/muchq/mcpserver/McpAuthenticationFilter.java b/jvm/src/main/java/com/muchq/mcpserver/McpAuthenticationFilter.java
index 6dcc282e..0fb4e46d 100644
--- a/jvm/src/main/java/com/muchq/mcpserver/McpAuthenticationFilter.java
+++ b/jvm/src/main/java/com/muchq/mcpserver/McpAuthenticationFilter.java
@@ -22,6 +22,7 @@
@Filter("/mcp/**")
@Requires(property = "mcp.auth.token")
+@Requires(property = "mcp.oauth.enabled", value = "false", defaultValue = "false")
public class McpAuthenticationFilter implements HttpServerFilter {
private static final Logger LOG = LoggerFactory.getLogger(McpAuthenticationFilter.class);
diff --git a/jvm/src/main/java/com/muchq/mcpserver/McpController.java b/jvm/src/main/java/com/muchq/mcpserver/McpController.java
index c57d52ed..98c94f1f 100644
--- a/jvm/src/main/java/com/muchq/mcpserver/McpController.java
+++ b/jvm/src/main/java/com/muchq/mcpserver/McpController.java
@@ -4,9 +4,11 @@
import com.muchq.mcpserver.dtos.JsonRpcRequest;
import com.muchq.mcpserver.dtos.JsonRpcResponse;
import io.micronaut.http.HttpRequest;
+import io.micronaut.http.HttpResponse;
import io.micronaut.http.annotation.Body;
import io.micronaut.http.annotation.Controller;
import io.micronaut.http.annotation.Error;
+import io.micronaut.http.annotation.Get;
import io.micronaut.http.annotation.Post;
import jakarta.inject.Inject;
import org.slf4j.Logger;
@@ -29,6 +31,11 @@ public JsonRpcResponse handleRequest(@Body JsonRpcRequest request) {
return requestHandler.handleRequest(request);
}
+ @Get
+ public HttpResponse noopGet() {
+ return HttpResponse.noContent();
+ }
+
@Error(global = true)
public JsonRpcResponse handleError(HttpRequest> request, Throwable error) {
LOG.error("Error processing MCP request", error);
diff --git a/jvm/src/main/java/com/muchq/mcpserver/oauth/BUILD.bazel b/jvm/src/main/java/com/muchq/mcpserver/oauth/BUILD.bazel
new file mode 100644
index 00000000..7fb12fdb
--- /dev/null
+++ b/jvm/src/main/java/com/muchq/mcpserver/oauth/BUILD.bazel
@@ -0,0 +1,26 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
+java_library(
+ name = "oauth",
+ srcs = glob(["*.java"]),
+ plugins = [
+ "//bazel/rules:micronaut_type_element_visitor_processor",
+ "//bazel/rules:micronaut_aggregating_type_element_visitor_processor",
+ "//bazel/rules:micronaut_bean_definition_inject_processor",
+ "//bazel/rules:micronaut_package_element_visitor_processor",
+ ],
+ visibility = ["//jvm/src/main/java/com/muchq/mcpserver:__pkg__"],
+ deps = [
+ "//jvm/src/main/java/com/muchq/mcpserver/dtos",
+ "@maven//:com_fasterxml_jackson_core_jackson_annotations",
+ "@maven//:com_nimbusds_nimbus_jose_jwt",
+ "@maven//:io_micronaut_micronaut_context",
+ "@maven//:io_micronaut_micronaut_core",
+ "@maven//:io_micronaut_micronaut_http",
+ "@maven//:io_micronaut_micronaut_inject",
+ "@maven//:io_projectreactor_reactor_core",
+ "@maven//:jakarta_inject_jakarta_inject_api",
+ "@maven//:org_reactivestreams_reactive_streams",
+ "@maven//:org_slf4j_slf4j_api",
+ ],
+)
diff --git a/jvm/src/main/java/com/muchq/mcpserver/oauth/OAuthAuthenticationFilter.java b/jvm/src/main/java/com/muchq/mcpserver/oauth/OAuthAuthenticationFilter.java
new file mode 100644
index 00000000..8149fbbf
--- /dev/null
+++ b/jvm/src/main/java/com/muchq/mcpserver/oauth/OAuthAuthenticationFilter.java
@@ -0,0 +1,121 @@
+package com.muchq.mcpserver.oauth;
+
+import com.muchq.mcpserver.dtos.JsonRpcError;
+import com.muchq.mcpserver.dtos.JsonRpcResponse;
+import io.micronaut.context.annotation.Requires;
+import io.micronaut.http.HttpRequest;
+import io.micronaut.http.HttpResponse;
+import io.micronaut.http.HttpStatus;
+import io.micronaut.http.MutableHttpResponse;
+import io.micronaut.http.annotation.Filter;
+import io.micronaut.http.filter.HttpServerFilter;
+import io.micronaut.http.filter.ServerFilterChain;
+import jakarta.inject.Inject;
+import org.reactivestreams.Publisher;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import reactor.core.publisher.Mono;
+
+/**
+ * OAuth 2.1 authentication filter for MCP endpoints.
+ *
+ * This filter intercepts all requests to /mcp/** and validates JWT access tokens.
+ * When OAuth is disabled, the legacy McpAuthenticationFilter is used instead.
+ *
+ * Flow:
+ * 1. Extract Authorization header
+ * 2. Validate Bearer token format
+ * 3. Validate JWT signature, expiration, and audience
+ * 4. Return 401 with WWW-Authenticate header if validation fails
+ * 5. Allow request to proceed if valid
+ *
+ * WWW-Authenticate header format (RFC 6750):
+ * Bearer realm="mcp", error="invalid_token", resource=""
+ */
+@Filter("/mcp/**")
+@Requires(property = "mcp.oauth.enabled", value = "true")
+public class OAuthAuthenticationFilter implements HttpServerFilter {
+
+ private static final Logger LOG = LoggerFactory.getLogger(OAuthAuthenticationFilter.class);
+
+ private final TokenValidator tokenValidator;
+ private final OAuthConfig config;
+
+ @Inject
+ public OAuthAuthenticationFilter(TokenValidator tokenValidator, OAuthConfig config) {
+ this.tokenValidator = tokenValidator;
+ this.config = config;
+ }
+
+ @Override
+ public Publisher> doFilter(
+ HttpRequest> request,
+ ServerFilterChain chain
+ ) {
+ LOG.debug("OAuth filter processing request: {} {}", request.getMethod(), request.getPath());
+
+ // Extract Authorization header
+ String authHeader = request.getHeaders().get("Authorization");
+
+ if (authHeader == null || authHeader.isEmpty()) {
+ LOG.warn("Missing Authorization header");
+ return Mono.just(createUnauthorizedResponse("invalid_token", "Missing Authorization header"));
+ }
+
+ // Validate Bearer token format
+ if (!authHeader.startsWith("Bearer ")) {
+ LOG.warn("Invalid Authorization header format: {}", authHeader.substring(0, Math.min(20, authHeader.length())));
+ return Mono.just(createUnauthorizedResponse("invalid_token", "Authorization header must use Bearer scheme"));
+ }
+
+ // Extract token (remove "Bearer " prefix)
+ String token = authHeader.substring(7);
+
+ // Validate token
+ TokenValidator.ValidationResult result = tokenValidator.validate(token);
+
+ if (!result.isValid()) {
+ LOG.warn("Token validation failed: {}", result.getErrorMessage());
+ return Mono.just(createUnauthorizedResponse("invalid_token", result.getErrorMessage()));
+ }
+
+ // Token is valid, allow request to proceed
+ LOG.debug("Token validated successfully for subject: {}", result.getSubject());
+ return Mono.from(chain.proceed(request));
+ }
+
+ /**
+ * Creates a 401 Unauthorized response with WWW-Authenticate header.
+ *
+ * The WWW-Authenticate header follows RFC 6750 and RFC 9728:
+ * - realm: Identifies the protection space
+ * - error: OAuth error code
+ * - resource: URL to Protected Resource Metadata (RFC 9728)
+ *
+ * @param error OAuth error code (e.g., "invalid_token", "invalid_request")
+ * @param errorDescription Human-readable error description
+ * @return HTTP 401 response with JSON-RPC error body
+ */
+ private MutableHttpResponse> createUnauthorizedResponse(String error, String errorDescription) {
+ // Build WWW-Authenticate header per RFC 6750
+ String resourceMetadataUrl = config.getResourceUri() + "/.well-known/oauth-protected-resource";
+ String wwwAuthenticate = String.format(
+ "Bearer realm=\"mcp\", error=\"%s\", error_description=\"%s\", resource=\"%s\"",
+ error,
+ errorDescription,
+ resourceMetadataUrl
+ );
+
+ // Create JSON-RPC error response
+ JsonRpcResponse jsonRpcError = new JsonRpcResponse(
+ "2.0",
+ null,
+ null,
+ new JsonRpcError(-32000, "Unauthorized: " + errorDescription)
+ );
+
+ return HttpResponse.status(HttpStatus.UNAUTHORIZED)
+ .header("WWW-Authenticate", wwwAuthenticate)
+ .body(jsonRpcError);
+ }
+}
diff --git a/jvm/src/main/java/com/muchq/mcpserver/oauth/OAuthConfig.java b/jvm/src/main/java/com/muchq/mcpserver/oauth/OAuthConfig.java
new file mode 100644
index 00000000..c3d8c3f3
--- /dev/null
+++ b/jvm/src/main/java/com/muchq/mcpserver/oauth/OAuthConfig.java
@@ -0,0 +1,88 @@
+package com.muchq.mcpserver.oauth;
+
+import io.micronaut.context.annotation.ConfigurationProperties;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Configuration for OAuth 2.1 / OpenID Connect integration with Keycloak.
+ *
+ * These settings are used for:
+ * - Publishing Protected Resource Metadata (RFC 9728)
+ * - Validating JWT access tokens
+ * - Advertising the authorization server
+ */
+@ConfigurationProperties("mcp.oauth")
+public class OAuthConfig {
+
+ private static final Logger LOG = LoggerFactory.getLogger(OAuthConfig.class);
+
+ /**
+ * Whether OAuth authentication is enabled.
+ * When false, the legacy Bearer token authentication (MCP_AUTH_TOKEN) is used.
+ * When true, JWT token validation with Keycloak is used.
+ */
+ private boolean enabled = true;
+
+ /**
+ * The authorization server URL (Keycloak realm).
+ * Example: http://localhost:8180/realms/mcp-demo
+ *
+ * This is advertised in the Protected Resource Metadata (RFC 9728)
+ * so clients can discover where to obtain tokens.
+ */
+ private String authorizationServer = "http://localhost:8180/realms/mcp-demo";
+
+ /**
+ * The resource URI for this MCP server.
+ * This is the canonical identifier for this server in OAuth terms.
+ * Example: http://localhost:8080
+ *
+ * CRITICAL: This MUST match the audience (aud) claim in JWT tokens.
+ * Tokens without this audience will be rejected (RFC 8707).
+ */
+ private String resourceUri = "http://localhost:8080";
+
+ /**
+ * The JWKS (JSON Web Key Set) URI from Keycloak.
+ * Used to fetch public keys for validating JWT signatures.
+ * Example: http://localhost:8180/realms/mcp-demo/protocol/openid-connect/certs
+ */
+ private String jwksUri = "http://localhost:8180/realms/mcp-demo/protocol/openid-connect/certs";
+
+ public boolean isEnabled() {
+ return enabled;
+ }
+
+ public void setEnabled(boolean enabled) {
+ this.enabled = enabled;
+ }
+
+ public String getAuthorizationServer() {
+ LOG.debug("getAuthorizationServer() called, returning: {}", authorizationServer);
+ return authorizationServer;
+ }
+
+ public void setAuthorizationServer(String authorizationServer) {
+ LOG.info("setAuthorizationServer() called with: {}", authorizationServer);
+ this.authorizationServer = authorizationServer;
+ }
+
+ public String getResourceUri() {
+ LOG.debug("getResourceUri() called, returning: {}", resourceUri);
+ return resourceUri;
+ }
+
+ public void setResourceUri(String resourceUri) {
+ LOG.info("setResourceUri() called with: {}", resourceUri);
+ this.resourceUri = resourceUri;
+ }
+
+ public String getJwksUri() {
+ return jwksUri;
+ }
+
+ public void setJwksUri(String jwksUri) {
+ this.jwksUri = jwksUri;
+ }
+}
diff --git a/jvm/src/main/java/com/muchq/mcpserver/oauth/ProtectedResourceController.java b/jvm/src/main/java/com/muchq/mcpserver/oauth/ProtectedResourceController.java
new file mode 100644
index 00000000..8342b930
--- /dev/null
+++ b/jvm/src/main/java/com/muchq/mcpserver/oauth/ProtectedResourceController.java
@@ -0,0 +1,62 @@
+package com.muchq.mcpserver.oauth;
+
+import io.micronaut.http.annotation.Controller;
+import io.micronaut.http.annotation.Get;
+import java.util.List;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Controller for serving RFC 9728 Protected Resource Metadata.
+ *
+ * This endpoint allows MCP clients to discover:
+ * 1. The canonical resource URI for this server
+ * 2. Which authorization server(s) can issue tokens for this resource
+ * 3. How to send bearer tokens (Authorization header)
+ *
+ * Flow:
+ * 1. Client makes unauthenticated MCP request
+ * 2. Server returns 401 with WWW-Authenticate header pointing to this endpoint
+ * 3. Client fetches this metadata to discover the authorization server
+ * 4. Client proceeds with OAuth flow using the discovered authorization server
+ *
+ * @see RFC 9728
+ */
+@Controller("/.well-known")
+public class ProtectedResourceController {
+
+ private static final Logger LOG = LoggerFactory.getLogger(ProtectedResourceController.class);
+
+ private final OAuthConfig config;
+
+ public ProtectedResourceController(OAuthConfig config) {
+ this.config = config;
+ }
+
+ /**
+ * Serves Protected Resource Metadata per RFC 9728.
+ *
+ * This endpoint is accessible without authentication and provides
+ * OAuth discovery information to clients.
+ *
+ * Example response:
+ * {
+ * "resource": "http://localhost:8080",
+ * "authorization_servers": ["http://localhost:8180/realms/mcp-demo"],
+ * "bearer_methods_supported": ["header"]
+ * }
+ *
+ * @return Protected Resource Metadata
+ */
+ @Get("/oauth-protected-resource")
+ public ProtectedResourceMetadata getMetadata() {
+ LOG.debug("Serving Protected Resource Metadata: resource={}, authz_server={}",
+ config.getResourceUri(), config.getAuthorizationServer());
+
+ return new ProtectedResourceMetadata(
+ config.getResourceUri(),
+ List.of(config.getAuthorizationServer()),
+ List.of("header")
+ );
+ }
+}
diff --git a/jvm/src/main/java/com/muchq/mcpserver/oauth/ProtectedResourceMetadata.java b/jvm/src/main/java/com/muchq/mcpserver/oauth/ProtectedResourceMetadata.java
new file mode 100644
index 00000000..27a8ad85
--- /dev/null
+++ b/jvm/src/main/java/com/muchq/mcpserver/oauth/ProtectedResourceMetadata.java
@@ -0,0 +1,34 @@
+package com.muchq.mcpserver.oauth;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import io.micronaut.core.annotation.Introspected;
+import java.util.List;
+
+/**
+ * RFC 9728 - OAuth 2.0 Protected Resource Metadata
+ *
+ * This metadata document allows clients to discover:
+ * 1. The resource identifier (canonical URI for this server)
+ * 2. The authorization server(s) that can issue tokens for this resource
+ * 3. Supported bearer token methods (header, query, body)
+ *
+ * Published at: GET /.well-known/oauth-protected-resource
+ *
+ * @param resource The resource identifier (RFC 8707) - the canonical URI for this MCP server
+ * @param authorizationServers List of authorization server URLs that can issue tokens for this resource
+ * @param bearerMethodsSupported List of methods for sending bearer tokens (typically ["header"])
+ *
+ * @see RFC 9728
+ */
+@Introspected
+public record ProtectedResourceMetadata(
+ @JsonProperty("resource")
+ String resource,
+
+ @JsonProperty("authorization_servers")
+ List authorizationServers,
+
+ @JsonProperty("bearer_methods_supported")
+ List bearerMethodsSupported
+) {
+}
diff --git a/jvm/src/main/java/com/muchq/mcpserver/oauth/TokenValidator.java b/jvm/src/main/java/com/muchq/mcpserver/oauth/TokenValidator.java
new file mode 100644
index 00000000..d9d1c37d
--- /dev/null
+++ b/jvm/src/main/java/com/muchq/mcpserver/oauth/TokenValidator.java
@@ -0,0 +1,191 @@
+package com.muchq.mcpserver.oauth;
+
+import com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSVerifier;
+import com.nimbusds.jose.crypto.RSASSAVerifier;
+import com.nimbusds.jose.jwk.JWK;
+import com.nimbusds.jose.jwk.JWKSet;
+import com.nimbusds.jose.jwk.RSAKey;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.SignedJWT;
+import io.micronaut.context.annotation.Requires;
+import jakarta.inject.Singleton;
+import java.io.IOException;
+import java.net.URL;
+import java.text.ParseException;
+import java.util.Date;
+import java.util.List;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Validates JWT access tokens from Keycloak.
+ *
+ * This validator performs three critical checks:
+ * 1. Signature verification against Keycloak's JWKS
+ * 2. Expiration check
+ * 3. Audience validation (RFC 8707) - CRITICAL for security
+ *
+ * The audience check ensures tokens issued for other services
+ * cannot be used to access this MCP server.
+ */
+@Singleton
+@Requires(property = "mcp.oauth.enabled", value = "true")
+public class TokenValidator {
+
+ private static final Logger LOG = LoggerFactory.getLogger(TokenValidator.class);
+
+ private final OAuthConfig config;
+ private final JWKSet jwkSet;
+
+ public TokenValidator(OAuthConfig config) throws IOException, ParseException {
+ this.config = config;
+
+ // Fetch JWKS from Keycloak
+ LOG.info("Fetching JWKS from: {}", config.getJwksUri());
+ this.jwkSet = JWKSet.load(new URL(config.getJwksUri()));
+ LOG.info("Loaded {} keys from JWKS", jwkSet.getKeys().size());
+ }
+
+ /**
+ * Validates a JWT access token.
+ *
+ * @param bearerToken The JWT token (without "Bearer " prefix)
+ * @return ValidationResult indicating success or failure with error message
+ */
+ public ValidationResult validate(String bearerToken) {
+ try {
+ // Parse JWT
+ SignedJWT jwt = SignedJWT.parse(bearerToken);
+ JWTClaimsSet claims = jwt.getJWTClaimsSet();
+
+ LOG.debug("Validating token: kid={}, sub={}, aud={}",
+ jwt.getHeader().getKeyID(),
+ claims.getSubject(),
+ claims.getAudience());
+
+ // 1. Verify signature
+ if (!verifySignature(jwt)) {
+ LOG.warn("Token signature verification failed");
+ return ValidationResult.invalid("Invalid signature");
+ }
+
+ // 2. Check expiration
+ Date expiration = claims.getExpirationTime();
+ if (expiration == null) {
+ LOG.warn("Token missing expiration claim");
+ return ValidationResult.invalid("Missing expiration");
+ }
+
+ if (expiration.before(new Date())) {
+ LOG.warn("Token expired at: {}", expiration);
+ return ValidationResult.invalid("Token expired");
+ }
+
+ // 3. CRITICAL: Validate audience (RFC 8707)
+ // This prevents tokens issued for other services from being accepted
+ List audiences = claims.getAudience();
+ if (audiences == null || audiences.isEmpty()) {
+ LOG.warn("Token missing audience claim");
+ return ValidationResult.invalid("Missing audience");
+ }
+
+ String expectedAudience = config.getResourceUri();
+ if (!audiences.contains(expectedAudience)) {
+ LOG.warn("Token audience mismatch. Expected: {}, Got: {}",
+ expectedAudience, audiences);
+ return ValidationResult.invalid("Invalid audience");
+ }
+
+ LOG.info("Token validated successfully for subject: {}", claims.getSubject());
+ return ValidationResult.valid(claims.getSubject());
+
+ } catch (ParseException e) {
+ LOG.error("Failed to parse JWT token", e);
+ return ValidationResult.invalid("Malformed token");
+ } catch (Exception e) {
+ LOG.error("Token validation failed", e);
+ return ValidationResult.invalid("Validation error: " + e.getMessage());
+ }
+ }
+
+ /**
+ * Verifies the JWT signature against Keycloak's public keys.
+ *
+ * @param jwt The signed JWT
+ * @return true if signature is valid, false otherwise
+ */
+ private boolean verifySignature(SignedJWT jwt) {
+ try {
+ String keyId = jwt.getHeader().getKeyID();
+ JWSAlgorithm algorithm = jwt.getHeader().getAlgorithm();
+
+ if (keyId == null) {
+ LOG.warn("Token missing 'kid' header");
+ return false;
+ }
+
+ // Find the matching key in JWKS
+ JWK jwk = jwkSet.getKeyByKeyId(keyId);
+ if (jwk == null) {
+ LOG.warn("No key found for kid: {}", keyId);
+ return false;
+ }
+
+ // Only support RSA for now (Keycloak default)
+ if (!(jwk instanceof RSAKey)) {
+ LOG.warn("Unsupported key type: {}", jwk.getKeyType());
+ return false;
+ }
+
+ RSAKey rsaKey = (RSAKey) jwk;
+ JWSVerifier verifier = new RSASSAVerifier(rsaKey.toRSAPublicKey());
+
+ return jwt.verify(verifier);
+
+ } catch (JOSEException e) {
+ LOG.error("Signature verification failed", e);
+ return false;
+ }
+ }
+
+ /**
+ * Result of token validation.
+ *
+ * @param valid Whether the token is valid
+ * @param errorMessage Error message if invalid, null otherwise
+ * @param subject The subject (username) from the token if valid, null otherwise
+ */
+ public static class ValidationResult {
+ private final boolean valid;
+ private final String errorMessage;
+ private final String subject;
+
+ private ValidationResult(boolean valid, String errorMessage, String subject) {
+ this.valid = valid;
+ this.errorMessage = errorMessage;
+ this.subject = subject;
+ }
+
+ public static ValidationResult valid(String subject) {
+ return new ValidationResult(true, null, subject);
+ }
+
+ public static ValidationResult invalid(String errorMessage) {
+ return new ValidationResult(false, errorMessage, null);
+ }
+
+ public boolean isValid() {
+ return valid;
+ }
+
+ public String getErrorMessage() {
+ return errorMessage;
+ }
+
+ public String getSubject() {
+ return subject;
+ }
+ }
+}
diff --git a/jvm/src/main/resources/BUILD.bazel b/jvm/src/main/resources/BUILD.bazel
index 7f274f5a..f37ef26e 100644
--- a/jvm/src/main/resources/BUILD.bazel
+++ b/jvm/src/main/resources/BUILD.bazel
@@ -12,6 +12,9 @@ filegroup(
filegroup(
name = "micronaut_config",
- srcs = ["application.yml"],
+ srcs = [
+ "application.yml",
+ "application-dev.yml",
+ ],
visibility = ["//visibility:public"],
)
diff --git a/jvm/src/main/resources/application-dev.yml b/jvm/src/main/resources/application-dev.yml
new file mode 100644
index 00000000..22d5fae1
--- /dev/null
+++ b/jvm/src/main/resources/application-dev.yml
@@ -0,0 +1,6 @@
+mcp:
+ oauth:
+ enabled: true
+ authorization-server: http://localhost:8180/realms/mcp-demo
+ resource-uri: http://localhost:8080
+ jwks-uri: http://localhost:8180/realms/mcp-demo/protocol/openid-connect/certs
diff --git a/jvm/src/main/resources/application.yml b/jvm/src/main/resources/application.yml
index d4068550..6331cd2c 100644
--- a/jvm/src/main/resources/application.yml
+++ b/jvm/src/main/resources/application.yml
@@ -7,3 +7,8 @@ micronaut:
mcp:
auth:
token: ${MCP_AUTH_TOKEN:}
+ oauth:
+ enabled: ${MCP_OAUTH_ENABLED:true}
+ authorization-server: ${MCP_OAUTH_AUTHZ_SERVER:http://localhost:8180/realms/mcp-demo}
+ resource-uri: ${MCP_RESOURCE_URI:http://localhost:8080}
+ jwks-uri: ${MCP_OAUTH_JWKS_URI:http://localhost:8180/realms/mcp-demo/protocol/openid-connect/certs}
diff --git a/jvm/src/test/java/com/muchq/mcpclient/BUILD.bazel b/jvm/src/test/java/com/muchq/mcpclient/BUILD.bazel
new file mode 100644
index 00000000..237e2fca
--- /dev/null
+++ b/jvm/src/test/java/com/muchq/mcpclient/BUILD.bazel
@@ -0,0 +1,12 @@
+load("@contrib_rules_jvm//java:defs.bzl", "java_test_suite")
+
+java_test_suite(
+ name = "mcpclient",
+ size = "small",
+ srcs = ["McpClientConfigTest.java"],
+ deps = [
+ "//jvm/src/main/java/com/muchq/mcpclient:config",
+ "@maven//:junit_junit",
+ "@maven//:org_assertj_assertj_core",
+ ],
+)
diff --git a/jvm/src/test/java/com/muchq/mcpclient/McpClientConfigTest.java b/jvm/src/test/java/com/muchq/mcpclient/McpClientConfigTest.java
new file mode 100644
index 00000000..b8716ef4
--- /dev/null
+++ b/jvm/src/test/java/com/muchq/mcpclient/McpClientConfigTest.java
@@ -0,0 +1,171 @@
+package com.muchq.mcpclient;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import com.muchq.mcpclient.McpClientConfig.TransportType;
+import org.junit.Test;
+
+public class McpClientConfigTest {
+
+ @Test
+ public void testBuilderWithRequiredFieldsOnly() {
+ McpClientConfig config = McpClientConfig.builder()
+ .serverUrl("http://localhost:8080/mcp")
+ .build();
+
+ assertThat(config.getServerUrl()).isEqualTo("http://localhost:8080/mcp");
+ assertThat(config.getClientName()).isEqualTo("MCP Java Client"); // default
+ assertThat(config.getClientVersion()).isEqualTo("1.0.0"); // default
+ assertThat(config.getCallbackPort()).isEqualTo(8888); // default
+ assertThat(config.getClientId()).isNull();
+ assertThat(config.getClientSecret()).isNull();
+ assertThat(config.getTransportType()).isEqualTo(TransportType.HTTP); // default
+ }
+
+ @Test
+ public void testBuilderWithAllFields() {
+ McpClientConfig config = McpClientConfig.builder()
+ .serverUrl("http://localhost:8080/mcp")
+ .clientName("My Client")
+ .clientVersion("2.0.0")
+ .callbackPort(9999)
+ .clientId("client-123")
+ .clientSecret("secret-456")
+ .transportType(TransportType.SSE)
+ .build();
+
+ assertThat(config.getServerUrl()).isEqualTo("http://localhost:8080/mcp");
+ assertThat(config.getClientName()).isEqualTo("My Client");
+ assertThat(config.getClientVersion()).isEqualTo("2.0.0");
+ assertThat(config.getCallbackPort()).isEqualTo(9999);
+ assertThat(config.getClientId()).isEqualTo("client-123");
+ assertThat(config.getClientSecret()).isEqualTo("secret-456");
+ assertThat(config.getTransportType()).isEqualTo(TransportType.SSE);
+ }
+
+ @Test
+ public void testBuilderRejectsNullServerUrl() {
+ assertThatThrownBy(() -> McpClientConfig.builder().build())
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("serverUrl is required");
+ }
+
+ @Test
+ public void testBuilderRejectsEmptyServerUrl() {
+ assertThatThrownBy(() -> McpClientConfig.builder().serverUrl("").build())
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("serverUrl is required");
+ }
+
+ @Test
+ public void testBuilderWithHttpsUrl() {
+ McpClientConfig config = McpClientConfig.builder()
+ .serverUrl("https://api.example.com/mcp")
+ .build();
+
+ assertThat(config.getServerUrl()).isEqualTo("https://api.example.com/mcp");
+ }
+
+ @Test
+ public void testBuilderMethodsReturnBuilder() {
+ McpClientConfig.Builder builder = McpClientConfig.builder();
+
+ // Verify fluent API - all methods return the builder
+ assertThat(builder.serverUrl("http://localhost"))
+ .isSameAs(builder);
+ assertThat(builder.clientName("name"))
+ .isSameAs(builder);
+ assertThat(builder.clientVersion("1.0"))
+ .isSameAs(builder);
+ assertThat(builder.callbackPort(8888))
+ .isSameAs(builder);
+ assertThat(builder.clientId("id"))
+ .isSameAs(builder);
+ assertThat(builder.clientSecret("secret"))
+ .isSameAs(builder);
+ }
+
+ @Test
+ public void testDefaultClientName() {
+ McpClientConfig config = McpClientConfig.builder()
+ .serverUrl("http://localhost")
+ .build();
+
+ assertThat(config.getClientName()).isEqualTo("MCP Java Client");
+ }
+
+ @Test
+ public void testDefaultClientVersion() {
+ McpClientConfig config = McpClientConfig.builder()
+ .serverUrl("http://localhost")
+ .build();
+
+ assertThat(config.getClientVersion()).isEqualTo("1.0.0");
+ }
+
+ @Test
+ public void testDefaultCallbackPort() {
+ McpClientConfig config = McpClientConfig.builder()
+ .serverUrl("http://localhost")
+ .build();
+
+ assertThat(config.getCallbackPort()).isEqualTo(8888);
+ }
+
+ @Test
+ public void testClientIdAndSecretCanBeSetIndependently() {
+ // Test with only clientId (no secret)
+ McpClientConfig configWithIdOnly = McpClientConfig.builder()
+ .serverUrl("http://localhost")
+ .clientId("my-client")
+ .build();
+
+ assertThat(configWithIdOnly.getClientId()).isEqualTo("my-client");
+ assertThat(configWithIdOnly.getClientSecret()).isNull();
+
+ // Test with only clientSecret (no id)
+ McpClientConfig configWithSecretOnly = McpClientConfig.builder()
+ .serverUrl("http://localhost")
+ .clientSecret("my-secret")
+ .build();
+
+ assertThat(configWithSecretOnly.getClientId()).isNull();
+ assertThat(configWithSecretOnly.getClientSecret()).isEqualTo("my-secret");
+ }
+
+ @Test
+ public void testDefaultTransportTypeIsHttp() {
+ McpClientConfig config = McpClientConfig.builder()
+ .serverUrl("http://localhost")
+ .build();
+
+ assertThat(config.getTransportType()).isEqualTo(TransportType.HTTP);
+ }
+
+ @Test
+ public void testTransportTypeCanBeSetToSse() {
+ McpClientConfig config = McpClientConfig.builder()
+ .serverUrl("http://localhost")
+ .transportType(TransportType.SSE)
+ .build();
+
+ assertThat(config.getTransportType()).isEqualTo(TransportType.SSE);
+ }
+
+ @Test
+ public void testTransportTypeCanBeSetToHttp() {
+ McpClientConfig config = McpClientConfig.builder()
+ .serverUrl("http://localhost")
+ .transportType(TransportType.HTTP)
+ .build();
+
+ assertThat(config.getTransportType()).isEqualTo(TransportType.HTTP);
+ }
+
+ @Test
+ public void testTransportTypeBuilderReturnsSameBuilder() {
+ McpClientConfig.Builder builder = McpClientConfig.builder();
+ assertThat(builder.transportType(TransportType.SSE)).isSameAs(builder);
+ }
+}
diff --git a/jvm/src/test/java/com/muchq/mcpclient/oauth/BUILD.bazel b/jvm/src/test/java/com/muchq/mcpclient/oauth/BUILD.bazel
new file mode 100644
index 00000000..aca29912
--- /dev/null
+++ b/jvm/src/test/java/com/muchq/mcpclient/oauth/BUILD.bazel
@@ -0,0 +1,14 @@
+load("@contrib_rules_jvm//java:defs.bzl", "java_test_suite")
+
+java_test_suite(
+ name = "oauth",
+ size = "small",
+ srcs = [
+ "TokenManagerTest.java",
+ ],
+ deps = [
+ "//jvm/src/main/java/com/muchq/mcpclient/oauth",
+ "@maven//:junit_junit",
+ "@maven//:org_assertj_assertj_core",
+ ],
+)
diff --git a/jvm/src/test/java/com/muchq/mcpclient/oauth/TokenManagerTest.java b/jvm/src/test/java/com/muchq/mcpclient/oauth/TokenManagerTest.java
new file mode 100644
index 00000000..bbb7e858
--- /dev/null
+++ b/jvm/src/test/java/com/muchq/mcpclient/oauth/TokenManagerTest.java
@@ -0,0 +1,139 @@
+package com.muchq.mcpclient.oauth;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import org.junit.Test;
+
+public class TokenManagerTest {
+
+ @Test
+ public void testStoreAndRetrieveTokens() {
+ TokenManager manager = new TokenManager();
+
+ manager.storeTokens("access123", "refresh456", 300);
+
+ assertThat(manager.getAccessToken()).isEqualTo("access123");
+ assertThat(manager.getRefreshToken()).isEqualTo("refresh456");
+ assertThat(manager.hasValidAccessToken()).isTrue();
+ }
+
+ @Test
+ public void testStoreTokensWithNullRefreshToken() {
+ TokenManager manager = new TokenManager();
+
+ manager.storeTokens("access123", null, 300);
+
+ assertThat(manager.getAccessToken()).isEqualTo("access123");
+ assertThat(manager.getRefreshToken()).isNull();
+ assertThat(manager.hasValidAccessToken()).isTrue();
+ }
+
+ @Test
+ public void testGetAccessTokenReturnsNullWhenNotSet() {
+ TokenManager manager = new TokenManager();
+
+ assertThat(manager.getAccessToken()).isNull();
+ assertThat(manager.hasValidAccessToken()).isFalse();
+ }
+
+ @Test
+ public void testGetAccessTokenReturnsNullWhenExpired() {
+ TokenManager manager = new TokenManager();
+
+ // Store token that expires in 1 second (but buffer is 30 seconds, so it's already "expired")
+ manager.storeTokens("access123", "refresh456", 1);
+
+ // Token should be considered expired due to 30-second buffer
+ assertThat(manager.getAccessToken()).isNull();
+ assertThat(manager.hasValidAccessToken()).isFalse();
+ }
+
+ @Test
+ public void testGetAccessTokenWithSufficientTime() {
+ TokenManager manager = new TokenManager();
+
+ // Store token that expires in 60 seconds (more than 30-second buffer)
+ manager.storeTokens("access123", "refresh456", 60);
+
+ assertThat(manager.getAccessToken()).isEqualTo("access123");
+ assertThat(manager.hasValidAccessToken()).isTrue();
+ }
+
+ @Test
+ public void testClearTokens() {
+ TokenManager manager = new TokenManager();
+ manager.storeTokens("access123", "refresh456", 300);
+
+ manager.clearTokens();
+
+ assertThat(manager.getAccessToken()).isNull();
+ assertThat(manager.getRefreshToken()).isNull();
+ assertThat(manager.hasValidAccessToken()).isFalse();
+ assertThat(manager.getExpiresAt()).isNull();
+ }
+
+ @Test
+ public void testStoreTokensRejectsNullAccessToken() {
+ TokenManager manager = new TokenManager();
+
+ assertThatThrownBy(() -> manager.storeTokens(null, "refresh", 300))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("accessToken is required");
+ }
+
+ @Test
+ public void testStoreTokensRejectsEmptyAccessToken() {
+ TokenManager manager = new TokenManager();
+
+ assertThatThrownBy(() -> manager.storeTokens("", "refresh", 300))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("accessToken is required");
+ }
+
+ @Test
+ public void testStoreTokensRejectsZeroExpiration() {
+ TokenManager manager = new TokenManager();
+
+ assertThatThrownBy(() -> manager.storeTokens("access", "refresh", 0))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("expiresInSeconds must be positive");
+ }
+
+ @Test
+ public void testStoreTokensRejectsNegativeExpiration() {
+ TokenManager manager = new TokenManager();
+
+ assertThatThrownBy(() -> manager.storeTokens("access", "refresh", -1))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("expiresInSeconds must be positive");
+ }
+
+ @Test
+ public void testGetExpiresAtReturnsNullInitially() {
+ TokenManager manager = new TokenManager();
+
+ assertThat(manager.getExpiresAt()).isNull();
+ }
+
+ @Test
+ public void testGetExpiresAtReturnsValueAfterStore() {
+ TokenManager manager = new TokenManager();
+
+ manager.storeTokens("access123", "refresh456", 300);
+
+ assertThat(manager.getExpiresAt()).isNotNull();
+ }
+
+ @Test
+ public void testRefreshTokenPersistsAfterAccessTokenExpires() {
+ TokenManager manager = new TokenManager();
+
+ // Store token with very short expiration
+ manager.storeTokens("access123", "refresh456", 1);
+
+ // Access token should be null (expired due to buffer), but refresh token should persist
+ assertThat(manager.getAccessToken()).isNull();
+ assertThat(manager.getRefreshToken()).isEqualTo("refresh456");
+ }
+}
diff --git a/local_docker/keycloak/DOCKER_TROUBLESHOOTING.md b/local_docker/keycloak/DOCKER_TROUBLESHOOTING.md
new file mode 100644
index 00000000..13031379
--- /dev/null
+++ b/local_docker/keycloak/DOCKER_TROUBLESHOOTING.md
@@ -0,0 +1,96 @@
+# Docker Credentials Troubleshooting
+
+## Issue
+
+When trying to pull the Keycloak image, you may encounter:
+
+```
+error getting credentials - err: exec: "docker-credential-desktop": executable file not found in $PATH
+```
+
+## Cause
+
+This occurs when Docker Desktop's credential helper is configured but not available in PATH, or Docker Desktop isn't properly installed/running.
+
+## Solutions
+
+### Option 1: Fix Docker Credential Helper (Recommended)
+
+1. **Check if Docker Desktop is running:**
+ ```bash
+ open -a Docker # macOS
+ ```
+
+2. **Verify Docker credential helper path:**
+ ```bash
+ which docker-credential-desktop
+ # Should output: /Applications/Docker.app/Contents/Resources/bin/docker-credential-desktop
+ ```
+
+3. **Add to PATH if missing:**
+ ```bash
+ export PATH="/Applications/Docker.app/Contents/Resources/bin:$PATH"
+ # Add to ~/.zshrc or ~/.bashrc for persistence
+ ```
+
+### Option 2: Disable Credential Helper Temporarily
+
+1. **Edit Docker config:**
+ ```bash
+ vim ~/.docker/config.json
+ ```
+
+2. **Remove or comment out `credsStore`:**
+ ```json
+ {
+ "auths": {},
+ // "credsStore": "desktop" <- Comment or remove this line
+ }
+ ```
+
+3. **Try pulling again:**
+ ```bash
+ docker pull quay.io/keycloak/keycloak:26.0
+ ```
+
+### Option 3: Use Alternative Keycloak Image
+
+If `quay.io` is the issue, try Docker Hub:
+
+1. **Update `docker-compose.keycloak.yml`:**
+ ```yaml
+ services:
+ keycloak:
+ image: keycloak/keycloak:26.0 # Docker Hub instead of quay.io
+ ```
+
+### Option 4: Build from Dockerfile
+
+If pulling images continues to fail, create a minimal Dockerfile:
+
+```dockerfile
+FROM eclipse-temurin:21-jre-jammy
+WORKDIR /opt/keycloak
+# Download Keycloak release manually
+```
+
+## Verification
+
+After applying a fix, verify Docker works:
+
+```bash
+# Test pulling a small image
+docker pull hello-world
+
+# Test Keycloak pull
+docker pull quay.io/keycloak/keycloak:26.0
+
+# Start Keycloak
+cd local_docker/keycloak
+docker compose -f docker-compose.keycloak.yml up -d
+```
+
+## References
+
+- [Docker Credential Helpers](https://docs.docker.com/engine/reference/commandline/login/#credential-helpers)
+- [Docker Desktop Installation](https://docs.docker.com/desktop/install/mac-install/)
diff --git a/local_docker/keycloak/README.md b/local_docker/keycloak/README.md
new file mode 100644
index 00000000..03546c73
--- /dev/null
+++ b/local_docker/keycloak/README.md
@@ -0,0 +1,215 @@
+# Keycloak Setup for MCP OAuth Demo
+
+This directory contains Keycloak configuration for demonstrating the MCP Authorization Spec with OAuth 2.1 + PKCE + Dynamic Client Registration.
+
+## Quick Start
+
+```bash
+# Start Keycloak
+docker compose -f docker-compose.keycloak.yml up -d
+
+# Wait for Keycloak to be ready (about 30 seconds)
+until curl -sf http://localhost:8180/health/ready > /dev/null; do sleep 2; done
+
+# Verify it's running
+curl http://localhost:8180/realms/mcp-demo/.well-known/openid-configuration | jq
+
+# Stop Keycloak
+docker compose -f docker-compose.keycloak.yml down
+```
+
+## Configuration
+
+### Realm: `mcp-demo`
+
+- **Port:** `8180` (avoids conflict with MCP server on `8080`)
+- **Admin Console:** http://localhost:8180/admin
+- **Admin Credentials:** `admin` / `admin`
+
+### Test User
+
+- **Username:** `testuser`
+- **Password:** `testpass`
+- **Email:** testuser@example.com
+
+### OAuth 2.1 Features Enabled
+
+1. **Dynamic Client Registration (RFC 7591)**
+ - Anonymous registration enabled via Client Registration Policies
+ - Clients can self-register at `/realms/mcp-demo/clients-registrations/openid-connect`
+
+2. **PKCE Enforcement (RFC 7636)**
+ - Client Policy: `mcp-oauth-policy`
+ - Client Profile: `mcp-oauth-profile`
+ - PKCE is automatically enforced for all dynamically registered clients
+ - Only S256 code challenge method is supported
+
+3. **Resource Indicators (RFC 8707)**
+ - Supported via custom `resource` parameter in authorization/token requests
+ - Tokens will include `aud` claim based on resource parameter
+
+4. **Short-Lived Tokens**
+ - Access Token Lifespan: 5 minutes (300 seconds)
+ - Refresh Token: Enabled for token renewal
+
+## Key Endpoints
+
+### Well-Known Endpoints
+
+```bash
+# OpenID Configuration
+http://localhost:8180/realms/mcp-demo/.well-known/openid-configuration
+
+# JWKS (for token validation)
+http://localhost:8180/realms/mcp-demo/protocol/openid-connect/certs
+```
+
+### OAuth Endpoints
+
+```bash
+# Authorization Endpoint
+http://localhost:8180/realms/mcp-demo/protocol/openid-connect/auth
+
+# Token Endpoint
+http://localhost:8180/realms/mcp-demo/protocol/openid-connect/token
+
+# Dynamic Client Registration
+http://localhost:8180/realms/mcp-demo/clients-registrations/openid-connect
+```
+
+## Testing Dynamic Client Registration
+
+```bash
+# Register a new client
+curl -X POST http://localhost:8180/realms/mcp-demo/clients-registrations/openid-connect \
+ -H "Content-Type: application/json" \
+ -d '{
+ "client_name": "Test MCP Client",
+ "redirect_uris": ["http://localhost:8888/callback"],
+ "grant_types": ["authorization_code", "refresh_token"],
+ "response_types": ["code"],
+ "token_endpoint_auth_method": "none"
+ }' | jq
+
+# Response will include client_id and client_secret (if applicable)
+```
+
+## Verifying PKCE Enforcement
+
+The realm is configured to automatically enforce PKCE for all dynamically registered clients:
+
+```bash
+# Authorization request (MUST include code_challenge)
+http://localhost:8180/realms/mcp-demo/protocol/openid-connect/auth?\
+ response_type=code&\
+ client_id=&\
+ redirect_uri=http://localhost:8888/callback&\
+ code_challenge=&\
+ code_challenge_method=S256
+
+# Token exchange (MUST include code_verifier)
+POST http://localhost:8180/realms/mcp-demo/protocol/openid-connect/token
+Content-Type: application/x-www-form-urlencoded
+
+grant_type=authorization_code&
+code=&
+redirect_uri=http://localhost:8888/callback&
+client_id=&
+code_verifier=
+```
+
+## Client Policies Configuration
+
+The realm includes pre-configured client policies for OAuth 2.1 compliance:
+
+### Policy: `mcp-oauth-policy`
+
+- **Applies To:** Clients registered via anonymous registration
+- **Enforces:** `mcp-oauth-profile`
+
+### Profile: `mcp-oauth-profile`
+
+- **Executors:**
+ - `pkce-enforcer`: Automatically requires PKCE for authorization code flow
+ - `secure-client-authn-executor`: Restricts client authentication methods
+
+## Troubleshooting
+
+### Port Already in Use
+
+If port 8180 is already in use, modify `docker-compose.keycloak.yml`:
+
+```yaml
+ports:
+ - "8181:8180" # Change host port to 8181
+```
+
+Then update MCP server configuration:
+
+```bash
+export MCP_OAUTH_AUTHZ_SERVER=http://localhost:8181/realms/mcp-demo
+```
+
+### Keycloak Not Starting
+
+Check logs:
+
+```bash
+docker compose -f docker-compose.keycloak.yml logs -f
+```
+
+### Realm Not Imported
+
+If the realm doesn't exist after startup, manually import it:
+
+1. Open Admin Console: http://localhost:8180/admin
+2. Login with `admin` / `admin`
+3. Click "Create Realm"
+4. Click "Browse" and select `realm-export.json`
+5. Click "Create"
+
+### Dynamic Registration Failing
+
+Verify client registration policies are active:
+
+1. Admin Console → Realm Settings → Client Registration
+2. Check "Anonymous" access type is enabled
+3. Admin Console → Realm Settings → Client Policies
+4. Verify `mcp-oauth-policy` is enabled and active
+
+## Integration with MCP Server
+
+The MCP server should be configured to use this Keycloak instance:
+
+```bash
+export MCP_OAUTH_ENABLED=true
+export MCP_OAUTH_AUTHZ_SERVER=http://localhost:8180/realms/mcp-demo
+export MCP_RESOURCE_URI=http://localhost:8080
+export MCP_OAUTH_JWKS_URI=http://localhost:8180/realms/mcp-demo/protocol/openid-connect/certs
+```
+
+The MCP server will:
+- Advertise Keycloak as its authorization server in RFC 9728 metadata
+- Validate JWT tokens against Keycloak's JWKS
+- Verify token audience matches `http://localhost:8080`
+
+## Security Notes
+
+- This is a **development-only** configuration
+- SSL is disabled (`sslRequired: none`) for localhost testing
+- In production:
+ - Enable HTTPS
+ - Use proper SSL certificates
+ - Configure secure redirect URIs (no localhost)
+ - Enable brute force protection
+ - Configure proper session timeouts
+ - Use external database (not in-memory)
+
+## References
+
+- [Keycloak Documentation](https://www.keycloak.org/documentation)
+- [OAuth 2.1 Draft](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-13)
+- [RFC 7591 - Dynamic Client Registration](https://datatracker.ietf.org/doc/html/rfc7591)
+- [RFC 7636 - PKCE](https://datatracker.ietf.org/doc/html/rfc7636)
+- [RFC 8707 - Resource Indicators](https://www.rfc-editor.org/rfc/rfc8707.html)
+- [MCP Authorization Spec](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization)
diff --git a/local_docker/keycloak/docker-compose.keycloak.yml b/local_docker/keycloak/docker-compose.keycloak.yml
new file mode 100644
index 00000000..8e2583a7
--- /dev/null
+++ b/local_docker/keycloak/docker-compose.keycloak.yml
@@ -0,0 +1,29 @@
+services:
+ keycloak:
+ image: quay.io/keycloak/keycloak:26.0
+ command:
+ - start-dev
+ - --import-realm
+ environment:
+ KC_DB: dev-mem
+ KC_HTTP_PORT: 8180
+ KEYCLOAK_ADMIN: admin
+ KEYCLOAK_ADMIN_PASSWORD: admin
+ KC_HEALTH_ENABLED: true
+ KC_FEATURES: dynamic-scopes,client-secret-rotation
+ ports:
+ - "8180:8180"
+ volumes:
+ - ./realm-export.json:/opt/keycloak/data/import/realm.json:ro
+ healthcheck:
+ test: ["CMD", "curl", "-f", "http://localhost:8180/health/ready"]
+ interval: 10s
+ timeout: 5s
+ retries: 10
+ start_period: 30s
+ networks:
+ - mcp-oauth
+
+networks:
+ mcp-oauth:
+ driver: bridge
diff --git a/local_docker/keycloak/realm-export.json b/local_docker/keycloak/realm-export.json
new file mode 100644
index 00000000..622c9bb6
--- /dev/null
+++ b/local_docker/keycloak/realm-export.json
@@ -0,0 +1,752 @@
+{
+ "realm": "mcp-demo",
+ "id": "e4d28f06-91c5-4f42-b493-7ed851d56171",
+ "enabled": true,
+ "sslRequired": "none",
+ "registrationAllowed": false,
+ "loginWithEmailAllowed": true,
+ "duplicateEmailsAllowed": false,
+ "resetPasswordAllowed": false,
+ "editUsernameAllowed": false,
+ "bruteForceProtected": false,
+ "permanentLockout": false,
+ "maxFailureWaitSeconds": 900,
+ "minimumQuickLoginWaitSeconds": 60,
+ "waitIncrementSeconds": 60,
+ "quickLoginCheckMilliSeconds": 1000,
+ "maxDeltaTimeSeconds": 43200,
+ "failureFactor": 30,
+ "accessTokenLifespan": 300,
+ "accessTokenLifespanForImplicitFlow": 900,
+ "ssoSessionIdleTimeout": 1800,
+ "ssoSessionMaxLifespan": 36000,
+ "offlineSessionIdleTimeout": 2592000,
+ "accessCodeLifespan": 60,
+ "accessCodeLifespanUserAction": 300,
+ "accessCodeLifespanLogin": 1800,
+ "actionTokenGeneratedByAdminLifespan": 43200,
+ "actionTokenGeneratedByUserLifespan": 300,
+ "defaultSignatureAlgorithm": "RS256",
+ "offlineSessionMaxLifespanEnabled": false,
+ "offlineSessionMaxLifespan": 5184000,
+ "clientSessionIdleTimeout": 0,
+ "clientSessionMaxLifespan": 0,
+ "clientOfflineSessionIdleTimeout": 0,
+ "clientOfflineSessionMaxLifespan": 0,
+ "requiredCredentials": [
+ "password"
+ ],
+ "otpPolicyType": "totp",
+ "otpPolicyAlgorithm": "HmacSHA1",
+ "otpPolicyInitialCounter": 0,
+ "otpPolicyDigits": 6,
+ "otpPolicyLookAheadWindow": 1,
+ "otpPolicyPeriod": 30,
+ "otpSupportedApplications": [
+ "FreeOTP",
+ "Google Authenticator"
+ ],
+ "webAuthnPolicyRpEntityName": "keycloak",
+ "webAuthnPolicySignatureAlgorithms": [
+ "ES256"
+ ],
+ "webAuthnPolicyRpId": "",
+ "webAuthnPolicyAttestationConveyancePreference": "not specified",
+ "webAuthnPolicyAuthenticatorAttachment": "not specified",
+ "webAuthnPolicyRequireResidentKey": "not specified",
+ "webAuthnPolicyUserVerificationRequirement": "not specified",
+ "webAuthnPolicyCreateTimeout": 0,
+ "webAuthnPolicyAvoidSameAuthenticatorRegister": false,
+ "webAuthnPolicyAcceptableAaguids": [],
+ "browserSecurityHeaders": {
+ "contentSecurityPolicyReportOnly": "",
+ "xContentTypeOptions": "nosniff",
+ "referrerPolicy": "no-referrer",
+ "xRobotsTag": "none",
+ "xFrameOptions": "SAMEORIGIN",
+ "contentSecurityPolicy": "frame-src 'self'; frame-ancestors 'self'; object-src 'none';",
+ "xXSSProtection": "1; mode=block",
+ "strictTransportSecurity": "max-age=31536000; includeSubDomains"
+ },
+ "smtpServer": {},
+ "eventsEnabled": false,
+ "eventsListeners": [
+ "jboss-logging"
+ ],
+ "enabledEventTypes": [],
+ "adminEventsEnabled": false,
+ "adminEventsDetailsEnabled": false,
+ "attributes": {
+ "clientPolicies": "{\"policies\":[{\"name\":\"mcp-oauth-policy\",\"description\":\"Enforce PKCE for dynamically registered clients\",\"enabled\":true,\"conditions\":[{\"condition\":\"client-updater-source\",\"configuration\":{\"client-source\":\"anonymous\"}}],\"profiles\":[\"mcp-oauth-profile\"]}]}",
+ "clientProfiles": "{\"profiles\":[{\"name\":\"mcp-oauth-profile\",\"description\":\"OAuth 2.1 profile for MCP clients with PKCE\",\"executors\":[{\"executor\":\"pkce-enforcer\",\"configuration\":{\"auto-configure\":true}},{\"executor\":\"secure-client-authn-executor\",\"configuration\":{\"client-authns\":[\"none\",\"client-secret-basic\",\"client-secret-post\",\"client-secret-jwt\",\"private-key-jwt\"]}}]}]}",
+ "cibaBackchannelTokenDeliveryMode": "poll",
+ "cibaExpiresIn": "120",
+ "cibaInterval": "5",
+ "cibaAuthRequestedUserHint": "login_hint",
+ "oauth2DeviceCodeLifespan": "600",
+ "oauth2DevicePollingInterval": "5",
+ "parRequestUriLifespan": "60",
+ "frontendUrl": "",
+ "acr.loa.map": "{}"
+ },
+ "users": [
+ {
+ "username": "testuser",
+ "enabled": true,
+ "totp": false,
+ "emailVerified": true,
+ "email": "testuser@example.com",
+ "firstName": "Test",
+ "lastName": "User",
+ "credentials": [
+ {
+ "type": "password",
+ "value": "testpass",
+ "temporary": false
+ }
+ ],
+ "requiredActions": [],
+ "realmRoles": [
+ "default-roles-mcp-demo"
+ ],
+ "clientRoles": {},
+ "groups": []
+ }
+ ],
+ "roles": {
+ "realm": [
+ {
+ "name": "default-roles-mcp-demo",
+ "description": "Default roles for realm",
+ "composite": true,
+ "composites": {
+ "realm": [
+ "offline_access",
+ "uma_authorization"
+ ]
+ },
+ "clientRole": false,
+ "containerId": "mcp-demo"
+ },
+ {
+ "name": "uma_authorization",
+ "description": "${role_uma_authorization}",
+ "composite": false,
+ "clientRole": false,
+ "containerId": "mcp-demo"
+ },
+ {
+ "name": "offline_access",
+ "description": "${role_offline-access}",
+ "composite": false,
+ "clientRole": false,
+ "containerId": "mcp-demo"
+ }
+ ]
+ },
+ "groups": [],
+ "defaultRole": {
+ "name": "default-roles-mcp-demo",
+ "description": "Default roles for realm",
+ "composite": true,
+ "clientRole": false,
+ "containerId": "mcp-demo"
+ },
+ "requiredCredentials": [
+ "password"
+ ],
+ "scopeMappings": [],
+ "clientScopeMappings": {},
+ "clients": [],
+ "clientScopes": [
+ {
+ "name": "mcp-access",
+ "description": "Scope for MCP resource access",
+ "protocol": "openid-connect",
+ "attributes": {
+ "include.in.token.scope": "true",
+ "display.on.consent.screen": "true",
+ "consent.screen.text": "Access to MCP resources"
+ },
+ "protocolMappers": []
+ },
+ {
+ "name": "address",
+ "description": "OpenID Connect built-in scope: address",
+ "protocol": "openid-connect",
+ "attributes": {
+ "include.in.token.scope": "true",
+ "display.on.consent.screen": "true",
+ "consent.screen.text": "${addressScopeConsentText}"
+ },
+ "protocolMappers": [
+ {
+ "name": "address",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-address-mapper",
+ "consentRequired": false,
+ "config": {
+ "user.attribute.formatted": "formatted",
+ "user.attribute.country": "country",
+ "user.attribute.postal_code": "postal_code",
+ "userinfo.token.claim": "true",
+ "user.attribute.street": "street",
+ "id.token.claim": "true",
+ "user.attribute.region": "region",
+ "access.token.claim": "true",
+ "user.attribute.locality": "locality"
+ }
+ }
+ ]
+ },
+ {
+ "name": "email",
+ "description": "OpenID Connect built-in scope: email",
+ "protocol": "openid-connect",
+ "attributes": {
+ "include.in.token.scope": "true",
+ "display.on.consent.screen": "true",
+ "consent.screen.text": "${emailScopeConsentText}"
+ },
+ "protocolMappers": [
+ {
+ "name": "email",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-property-mapper",
+ "consentRequired": false,
+ "config": {
+ "userinfo.token.claim": "true",
+ "user.attribute": "email",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "email",
+ "jsonType.label": "String"
+ }
+ },
+ {
+ "name": "email verified",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-property-mapper",
+ "consentRequired": false,
+ "config": {
+ "userinfo.token.claim": "true",
+ "user.attribute": "emailVerified",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "email_verified",
+ "jsonType.label": "boolean"
+ }
+ }
+ ]
+ },
+ {
+ "name": "microprofile-jwt",
+ "description": "Microprofile - JWT built-in scope",
+ "protocol": "openid-connect",
+ "attributes": {
+ "include.in.token.scope": "true",
+ "display.on.consent.screen": "false"
+ },
+ "protocolMappers": [
+ {
+ "name": "upn",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-property-mapper",
+ "consentRequired": false,
+ "config": {
+ "userinfo.token.claim": "true",
+ "user.attribute": "username",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "upn",
+ "jsonType.label": "String"
+ }
+ },
+ {
+ "name": "groups",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-realm-role-mapper",
+ "consentRequired": false,
+ "config": {
+ "multivalued": "true",
+ "userinfo.token.claim": "true",
+ "user.attribute": "foo",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "groups",
+ "jsonType.label": "String"
+ }
+ }
+ ]
+ },
+ {
+ "name": "offline_access",
+ "description": "OpenID Connect built-in scope: offline_access",
+ "protocol": "openid-connect",
+ "attributes": {
+ "consent.screen.text": "${offlineAccessScopeConsentText}",
+ "display.on.consent.screen": "true"
+ }
+ },
+ {
+ "name": "phone",
+ "description": "OpenID Connect built-in scope: phone",
+ "protocol": "openid-connect",
+ "attributes": {
+ "include.in.token.scope": "true",
+ "display.on.consent.screen": "true",
+ "consent.screen.text": "${phoneScopeConsentText}"
+ },
+ "protocolMappers": [
+ {
+ "name": "phone number",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-attribute-mapper",
+ "consentRequired": false,
+ "config": {
+ "userinfo.token.claim": "true",
+ "user.attribute": "phoneNumber",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "phone_number",
+ "jsonType.label": "String"
+ }
+ },
+ {
+ "name": "phone number verified",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-attribute-mapper",
+ "consentRequired": false,
+ "config": {
+ "userinfo.token.claim": "true",
+ "user.attribute": "phoneNumberVerified",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "phone_number_verified",
+ "jsonType.label": "boolean"
+ }
+ }
+ ]
+ },
+ {
+ "name": "profile",
+ "description": "OpenID Connect built-in scope: profile",
+ "protocol": "openid-connect",
+ "attributes": {
+ "include.in.token.scope": "true",
+ "display.on.consent.screen": "true",
+ "consent.screen.text": "${profileScopeConsentText}"
+ },
+ "protocolMappers": [
+ {
+ "name": "full name",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-full-name-mapper",
+ "consentRequired": false,
+ "config": {
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "userinfo.token.claim": "true"
+ }
+ },
+ {
+ "name": "family name",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-property-mapper",
+ "consentRequired": false,
+ "config": {
+ "userinfo.token.claim": "true",
+ "user.attribute": "lastName",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "family_name",
+ "jsonType.label": "String"
+ }
+ },
+ {
+ "name": "given name",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-property-mapper",
+ "consentRequired": false,
+ "config": {
+ "userinfo.token.claim": "true",
+ "user.attribute": "firstName",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "given_name",
+ "jsonType.label": "String"
+ }
+ },
+ {
+ "name": "middle name",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-attribute-mapper",
+ "consentRequired": false,
+ "config": {
+ "userinfo.token.claim": "true",
+ "user.attribute": "middleName",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "middle_name",
+ "jsonType.label": "String"
+ }
+ },
+ {
+ "name": "nickname",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-attribute-mapper",
+ "consentRequired": false,
+ "config": {
+ "userinfo.token.claim": "true",
+ "user.attribute": "nickname",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "nickname",
+ "jsonType.label": "String"
+ }
+ },
+ {
+ "name": "username",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-property-mapper",
+ "consentRequired": false,
+ "config": {
+ "userinfo.token.claim": "true",
+ "user.attribute": "username",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "preferred_username",
+ "jsonType.label": "String"
+ }
+ },
+ {
+ "name": "profile",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-attribute-mapper",
+ "consentRequired": false,
+ "config": {
+ "userinfo.token.claim": "true",
+ "user.attribute": "profile",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "profile",
+ "jsonType.label": "String"
+ }
+ },
+ {
+ "name": "picture",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-attribute-mapper",
+ "consentRequired": false,
+ "config": {
+ "userinfo.token.claim": "true",
+ "user.attribute": "picture",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "picture",
+ "jsonType.label": "String"
+ }
+ },
+ {
+ "name": "website",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-attribute-mapper",
+ "consentRequired": false,
+ "config": {
+ "userinfo.token.claim": "true",
+ "user.attribute": "website",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "website",
+ "jsonType.label": "String"
+ }
+ },
+ {
+ "name": "gender",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-attribute-mapper",
+ "consentRequired": false,
+ "config": {
+ "userinfo.token.claim": "true",
+ "user.attribute": "gender",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "gender",
+ "jsonType.label": "String"
+ }
+ },
+ {
+ "name": "birthdate",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-attribute-mapper",
+ "consentRequired": false,
+ "config": {
+ "userinfo.token.claim": "true",
+ "user.attribute": "birthdate",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "birthdate",
+ "jsonType.label": "String"
+ }
+ },
+ {
+ "name": "zoneinfo",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-attribute-mapper",
+ "consentRequired": false,
+ "config": {
+ "userinfo.token.claim": "true",
+ "user.attribute": "zoneinfo",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "zoneinfo",
+ "jsonType.label": "String"
+ }
+ },
+ {
+ "name": "locale",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-attribute-mapper",
+ "consentRequired": false,
+ "config": {
+ "userinfo.token.claim": "true",
+ "user.attribute": "locale",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "locale",
+ "jsonType.label": "String"
+ }
+ },
+ {
+ "name": "updated at",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-attribute-mapper",
+ "consentRequired": false,
+ "config": {
+ "userinfo.token.claim": "true",
+ "user.attribute": "updatedAt",
+ "id.token.claim": "true",
+ "access.token.claim": "true",
+ "claim.name": "updated_at",
+ "jsonType.label": "long"
+ }
+ }
+ ]
+ },
+ {
+ "name": "role_list",
+ "description": "SAML role list",
+ "protocol": "saml",
+ "attributes": {
+ "consent.screen.text": "${samlRoleListScopeConsentText}",
+ "display.on.consent.screen": "true"
+ },
+ "protocolMappers": [
+ {
+ "name": "role list",
+ "protocol": "saml",
+ "protocolMapper": "saml-role-list-mapper",
+ "consentRequired": false,
+ "config": {
+ "single": "false",
+ "attribute.nameformat": "Basic",
+ "attribute.name": "Role"
+ }
+ }
+ ]
+ },
+ {
+ "name": "roles",
+ "description": "OpenID Connect scope for add user roles to the access token",
+ "protocol": "openid-connect",
+ "attributes": {
+ "include.in.token.scope": "false",
+ "display.on.consent.screen": "true",
+ "consent.screen.text": "${rolesScopeConsentText}"
+ },
+ "protocolMappers": [
+ {
+ "name": "realm roles",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-realm-role-mapper",
+ "consentRequired": false,
+ "config": {
+ "user.attribute": "foo",
+ "access.token.claim": "true",
+ "claim.name": "realm_access.roles",
+ "jsonType.label": "String",
+ "multivalued": "true"
+ }
+ },
+ {
+ "name": "client roles",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-usermodel-client-role-mapper",
+ "consentRequired": false,
+ "config": {
+ "user.attribute": "foo",
+ "access.token.claim": "true",
+ "claim.name": "resource_access.${client_id}.roles",
+ "jsonType.label": "String",
+ "multivalued": "true"
+ }
+ },
+ {
+ "name": "audience resolve",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-audience-resolve-mapper",
+ "consentRequired": false,
+ "config": {}
+ }
+ ]
+ },
+ {
+ "name": "web-origins",
+ "description": "OpenID Connect scope for add allowed web origins to the access token",
+ "protocol": "openid-connect",
+ "attributes": {
+ "include.in.token.scope": "false",
+ "display.on.consent.screen": "false",
+ "consent.screen.text": ""
+ },
+ "protocolMappers": [
+ {
+ "name": "allowed web origins",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-allowed-origins-mapper",
+ "consentRequired": false,
+ "config": {}
+ }
+ ]
+ },
+ {
+ "name": "acr",
+ "description": "OpenID Connect scope for add acr (authentication context class reference) to the token",
+ "protocol": "openid-connect",
+ "attributes": {
+ "include.in.token.scope": "false",
+ "display.on.consent.screen": "false"
+ },
+ "protocolMappers": [
+ {
+ "name": "acr loa level",
+ "protocol": "openid-connect",
+ "protocolMapper": "oidc-acr-mapper",
+ "consentRequired": false,
+ "config": {
+ "id.token.claim": "true",
+ "access.token.claim": "true"
+ }
+ }
+ ]
+ }
+ ],
+ "defaultDefaultClientScopes": [
+ "role_list",
+ "profile",
+ "email",
+ "roles",
+ "web-origins",
+ "acr"
+ ],
+ "defaultOptionalClientScopes": [
+ "offline_access",
+ "address",
+ "phone",
+ "microprofile-jwt",
+ "mcp-access"
+ ],
+ "browserFlow": "browser",
+ "registrationFlow": "registration",
+ "directGrantFlow": "direct grant",
+ "resetCredentialsFlow": "reset credentials",
+ "clientAuthenticationFlow": "clients",
+ "dockerAuthenticationFlow": "docker auth",
+ "components": {
+ "org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy": [
+ {
+ "id": "71d75ed5-4691-4675-9cec-2a3719692d34",
+ "name": "Allowed Protocol Mapper Types",
+ "providerId": "allowed-protocol-mappers",
+ "subType": "anonymous",
+ "config": {
+ "allowed-protocol-mapper-types": [
+ "oidc-sha256-pairwise-sub-mapper",
+ "oidc-address-mapper",
+ "oidc-full-name-mapper",
+ "saml-user-attribute-mapper",
+ "oidc-usermodel-property-mapper",
+ "saml-role-list-mapper",
+ "saml-user-property-mapper",
+ "oidc-usermodel-attribute-mapper"
+ ]
+ }
+ },
+ {
+ "id": "95ce271e-fc63-4807-b5e9-8d9924708bf0",
+ "name": "Consent Required",
+ "providerId": "consent-required",
+ "subType": "anonymous",
+ "config": {}
+ },
+ {
+ "id": "89e896bd-9bc7-41f4-9397-2e1ef0477e23",
+ "name": "Full Scope Disabled",
+ "providerId": "scope",
+ "subType": "anonymous",
+ "config": {}
+ },
+ {
+ "id": "c4980afb-9684-4030-9dc7-b7ef59be6e29",
+ "name": "Max Clients Limit",
+ "providerId": "max-clients",
+ "subType": "anonymous",
+ "config": {
+ "max-clients": [
+ "200"
+ ]
+ }
+ },
+ {
+ "id": "9fff2c2c-8df2-4325-9134-e1ae41e84885",
+ "name": "Allowed Client Scopes",
+ "providerId": "allowed-client-templates",
+ "subType": "anonymous",
+ "config": {
+ "allow-default-scopes": [
+ "true"
+ ]
+ }
+ },
+ {
+ "id": "e16de4fd-3e5b-499d-828e-5a27c33c4579",
+ "name": "Allowed Client Scopes",
+ "providerId": "allowed-client-templates",
+ "subType": "authenticated",
+ "config": {
+ "allow-default-scopes": [
+ "true"
+ ]
+ }
+ },
+ {
+ "id": "8df6190a-b46e-43a0-8124-73e8107dac67",
+ "name": "Allowed Protocol Mapper Types",
+ "providerId": "allowed-protocol-mappers",
+ "subType": "authenticated",
+ "config": {
+ "allowed-protocol-mapper-types": [
+ "oidc-sha256-pairwise-sub-mapper",
+ "oidc-full-name-mapper",
+ "saml-user-attribute-mapper",
+ "saml-user-property-mapper",
+ "oidc-usermodel-attribute-mapper",
+ "oidc-usermodel-property-mapper",
+ "oidc-address-mapper",
+ "saml-role-list-mapper"
+ ]
+ }
+ }
+ ]
+ }
+}
diff --git a/maven_install.json b/maven_install.json
index ea9c54e1..cc657117 100755
--- a/maven_install.json
+++ b/maven_install.json
@@ -1,7 +1,7 @@
{
"__AUTOGENERATED_FILE_DO_NOT_MODIFY_THIS_FILE_MANUALLY": "THERE_IS_NO_DATA_ONLY_ZUUL",
- "__INPUT_ARTIFACTS_HASH": 749049213,
- "__RESOLVED_ARTIFACTS_HASH": -1516269127,
+ "__INPUT_ARTIFACTS_HASH": -707983187,
+ "__RESOLVED_ARTIFACTS_HASH": 758269122,
"conflict_resolution": {
"com.fasterxml.jackson.core:jackson-annotations": "com.fasterxml.jackson.core:jackson-annotations:2.19.2",
"com.fasterxml.jackson.core:jackson-core": "com.fasterxml.jackson.core:jackson-core:2.19.2",
@@ -13,6 +13,7 @@
"com.google.errorprone:error_prone_annotations:2.5.1": "com.google.errorprone:error_prone_annotations:2.41.0",
"com.google.guava:guava:32.0.1-jre": "com.google.guava:guava:33.5.0-jre",
"com.google.j2objc:j2objc-annotations:2.8": "com.google.j2objc:j2objc-annotations:3.1",
+ "com.nimbusds:nimbus-jose-jwt:9.47": "com.nimbusds:nimbus-jose-jwt:10.0.1",
"io.micronaut.jaxrs:micronaut-jaxrs-processor": "io.micronaut.jaxrs:micronaut-jaxrs-processor:4.10.0",
"io.micronaut.jaxrs:micronaut-jaxrs-server": "io.micronaut.jaxrs:micronaut-jaxrs-server:4.10.0",
"io.micronaut.validation:micronaut-validation": "io.micronaut.validation:micronaut-validation:4.12.0",
@@ -41,6 +42,12 @@
},
"version": "1.5.6"
},
+ "com.ethlo.time:itu": {
+ "shasums": {
+ "jar": "23d3ba84095d489a595240f89045085ea5066fb6fc1dc091258d577df9d74abc"
+ },
+ "version": "1.10.3"
+ },
"com.fasterxml.jackson.core:jackson-annotations": {
"shasums": {
"jar": "e516743a316dcf83c572ffc9cb6e8c5e8c134880c8c5155b02f7b34e9c5dc3cf"
@@ -59,6 +66,12 @@
},
"version": "2.19.2"
},
+ "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml": {
+ "shasums": {
+ "jar": "80a213e7d998244922ab7bcf0938ea03eb7868e88e2d05a8407c6228c885a37e"
+ },
+ "version": "2.19.2"
+ },
"com.fasterxml.jackson.datatype:jackson-datatype-guava": {
"shasums": {
"jar": "0f400e0c47bc1806051c87c2629f644c90479a44a052cb004ff842eae7d10bab"
@@ -95,6 +108,12 @@
},
"version": "3.27.0"
},
+ "com.github.stephenc.jcip:jcip-annotations": {
+ "shasums": {
+ "jar": "4fccff8382aafc589962c4edb262f6aa595e34f1e11e61057d1c6a96e8fc7323"
+ },
+ "version": "1.0-1"
+ },
"com.google.code.findbugs:jsr305": {
"shasums": {
"jar": "766ad2a0783f2687962c8ad74ceecc38a28b9f72a2d085ee438b7813e928d0c7"
@@ -137,6 +156,36 @@
},
"version": "3.1"
},
+ "com.networknt:json-schema-validator": {
+ "shasums": {
+ "jar": "2c71e970dc1b67499b99efaf65982696e98ced0028548d43bf4eba06edd8834f"
+ },
+ "version": "1.5.7"
+ },
+ "com.nimbusds:content-type": {
+ "shasums": {
+ "jar": "60349793e006fba96b532cb0c21e10e969fe0db8d87f91c3b9eaf82ba2998895"
+ },
+ "version": "2.3"
+ },
+ "com.nimbusds:lang-tag": {
+ "shasums": {
+ "jar": "e8c1c594e2425bdbea2d860de55c69b69fc5d59454452449a0f0913c2a5b8a31"
+ },
+ "version": "1.7"
+ },
+ "com.nimbusds:nimbus-jose-jwt": {
+ "shasums": {
+ "jar": "f28dbd9ab128324f05050d76b78469d3a9cd83e0319aabc68d1c276e3923e13a"
+ },
+ "version": "10.0.1"
+ },
+ "com.nimbusds:oauth2-oidc-sdk": {
+ "shasums": {
+ "jar": "4a42c7e51e1788a3951ff925692f05a6b0088d50f6fc7ad4c4a2338dd0491fb4"
+ },
+ "version": "11.21"
+ },
"com.thoughtworks.paranamer:paranamer": {
"shasums": {
"jar": "a9df136f2e926b37a838a5b4e2227343c3a755d15724b3a8350b4aea4b158945"
@@ -329,6 +378,12 @@
},
"version": "4.10.12"
},
+ "io.modelcontextprotocol.sdk:mcp": {
+ "shasums": {
+ "jar": "13a5f7879ad792bb4e77a2687c61eb350373f746ce7324a19b8f7778d2739aab"
+ },
+ "version": "0.12.1"
+ },
"io.netty:netty-buffer": {
"shasums": {
"jar": "d013a96acea4889bc01ee03738d53c2e4a05e3faec14c25a92b4224c77d7a408"
@@ -427,15 +482,15 @@
},
"io.sentry:sentry": {
"shasums": {
- "jar": "955e066ac6b70aa6419442490e04725e6730261f44875c4f1fb62439fd1baace"
+ "jar": "f841a0d5e1500b130f38cbab803e897cebe3f639e4ed2f0cd1ae98872b36e0ae"
},
- "version": "8.26.0"
+ "version": "8.30.0"
},
"io.sentry:sentry-logback": {
"shasums": {
- "jar": "3f97447c1d3cf64f38e2ea1ab685f1bc731643f8cdda4e4bcbfe7c38fbbbd888"
+ "jar": "e7f056a54b3c90724d91b3558c539894e81dd4eaf9ca04a4b4b295168e4a437c"
},
- "version": "8.26.0"
+ "version": "8.30.0"
},
"jakarta.annotation:jakarta.annotation-api": {
"shasums": {
@@ -473,12 +528,30 @@
},
"version": "1.17.7"
},
+ "net.minidev:accessors-smart": {
+ "shasums": {
+ "jar": "2796ae857d0c7be4bc3580daa4d3828d555212355f4c83d38dd0af0742b3c812"
+ },
+ "version": "2.5.1"
+ },
+ "net.minidev:json-smart": {
+ "shasums": {
+ "jar": "86c0c189581b79b57b0719f443a724e9f628ffbb9eef645cf79194f5973a1001"
+ },
+ "version": "2.5.1"
+ },
"org.assertj:assertj-core": {
"shasums": {
"jar": "b27872b049abc88e23c853ce5917945dd57d43bd31175d852e3941d2a6fd0231"
},
"version": "3.27.6"
},
+ "org.bouncycastle:bcprov-jdk18on": {
+ "shasums": {
+ "jar": "82cf3a2af766c3bc874f6d36b9f20a8b99a8f09762dc776e8a227a45d8daaafb"
+ },
+ "version": "1.83"
+ },
"org.checkerframework:checker-qual": {
"shasums": {
"jar": "13b3b6c9261d35886a297adae438d6606581acc1bf7c901402dd9963f2b6a690"
@@ -634,6 +707,12 @@
"jar": "7b751d952061954d5abfed7181c1f645d336091b679891591d63329c622eb832"
},
"version": "2.0.17"
+ },
+ "org.yaml:snakeyaml": {
+ "shasums": {
+ "jar": "ef779af5d29a9dde8cc70ce0341f5c6f7735e23edff9685ceaa9d35359b7bb7f"
+ },
+ "version": "2.4"
}
},
"dependencies": {
@@ -645,6 +724,11 @@
"com.fasterxml.jackson.core:jackson-annotations",
"com.fasterxml.jackson.core:jackson-core"
],
+ "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml": [
+ "com.fasterxml.jackson.core:jackson-core",
+ "com.fasterxml.jackson.core:jackson-databind",
+ "org.yaml:snakeyaml"
+ ],
"com.fasterxml.jackson.datatype:jackson-datatype-guava": [
"com.fasterxml.jackson.core:jackson-annotations",
"com.fasterxml.jackson.core:jackson-core",
@@ -678,6 +762,19 @@
"com.google.j2objc:j2objc-annotations",
"org.jspecify:jspecify"
],
+ "com.networknt:json-schema-validator": [
+ "com.ethlo.time:itu",
+ "com.fasterxml.jackson.core:jackson-databind",
+ "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml",
+ "org.slf4j:slf4j-api"
+ ],
+ "com.nimbusds:oauth2-oidc-sdk": [
+ "com.github.stephenc.jcip:jcip-annotations",
+ "com.nimbusds:content-type",
+ "com.nimbusds:lang-tag",
+ "com.nimbusds:nimbus-jose-jwt",
+ "net.minidev:json-smart"
+ ],
"io.micronaut.jaxrs:micronaut-jaxrs-common": [
"io.micronaut:micronaut-http",
"io.micronaut:micronaut-inject",
@@ -870,6 +967,12 @@
"io.projectreactor:reactor-core",
"org.slf4j:slf4j-api"
],
+ "io.modelcontextprotocol.sdk:mcp": [
+ "com.fasterxml.jackson.core:jackson-databind",
+ "com.networknt:json-schema-validator",
+ "io.projectreactor:reactor-core",
+ "org.slf4j:slf4j-api"
+ ],
"io.netty:netty-buffer": [
"io.netty:netty-common"
],
@@ -966,6 +1069,12 @@
"junit:junit": [
"org.hamcrest:hamcrest-core"
],
+ "net.minidev:accessors-smart": [
+ "org.ow2.asm:asm"
+ ],
+ "net.minidev:json-smart": [
+ "net.minidev:accessors-smart"
+ ],
"org.assertj:assertj-core": [
"net.bytebuddy:byte-buddy"
],
@@ -1121,6 +1230,14 @@
"ch.qos.logback.core.testUtil",
"ch.qos.logback.core.util"
],
+ "com.ethlo.time:itu": [
+ "com.ethlo.time",
+ "com.ethlo.time.internal",
+ "com.ethlo.time.internal.fixed",
+ "com.ethlo.time.internal.token",
+ "com.ethlo.time.internal.util",
+ "com.ethlo.time.token"
+ ],
"com.fasterxml.jackson.core:jackson-annotations": [
"com.fasterxml.jackson.annotation"
],
@@ -1165,6 +1282,11 @@
"com.fasterxml.jackson.databind.util",
"com.fasterxml.jackson.databind.util.internal"
],
+ "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml": [
+ "com.fasterxml.jackson.dataformat.yaml",
+ "com.fasterxml.jackson.dataformat.yaml.snakeyaml.error",
+ "com.fasterxml.jackson.dataformat.yaml.util"
+ ],
"com.fasterxml.jackson.datatype:jackson-datatype-guava": [
"com.fasterxml.jackson.datatype.guava",
"com.fasterxml.jackson.datatype.guava.deser",
@@ -1258,6 +1380,9 @@
"com.github.javaparser.symbolsolver.resolution.typesolvers",
"com.github.javaparser.symbolsolver.utils"
],
+ "com.github.stephenc.jcip:jcip-annotations": [
+ "net.jcip.annotations"
+ ],
"com.google.code.findbugs:jsr305": [
"javax.annotation",
"javax.annotation.concurrent",
@@ -1304,6 +1429,115 @@
"com.google.j2objc:j2objc-annotations": [
"com.google.j2objc.annotations"
],
+ "com.networknt:json-schema-validator": [
+ "com.networknt.org.apache.commons.validator.routines",
+ "com.networknt.schema",
+ "com.networknt.schema.annotation",
+ "com.networknt.schema.format",
+ "com.networknt.schema.i18n",
+ "com.networknt.schema.oas",
+ "com.networknt.schema.output",
+ "com.networknt.schema.regex",
+ "com.networknt.schema.resource",
+ "com.networknt.schema.result",
+ "com.networknt.schema.serialization",
+ "com.networknt.schema.serialization.node",
+ "com.networknt.schema.utils",
+ "com.networknt.schema.walk"
+ ],
+ "com.nimbusds:content-type": [
+ "com.nimbusds.common.contenttype"
+ ],
+ "com.nimbusds:lang-tag": [
+ "com.nimbusds.langtag"
+ ],
+ "com.nimbusds:nimbus-jose-jwt": [
+ "com.nimbusds.jose",
+ "com.nimbusds.jose.crypto",
+ "com.nimbusds.jose.crypto.bc",
+ "com.nimbusds.jose.crypto.factories",
+ "com.nimbusds.jose.crypto.impl",
+ "com.nimbusds.jose.crypto.opts",
+ "com.nimbusds.jose.crypto.utils",
+ "com.nimbusds.jose.jca",
+ "com.nimbusds.jose.jwk",
+ "com.nimbusds.jose.jwk.gen",
+ "com.nimbusds.jose.jwk.source",
+ "com.nimbusds.jose.mint",
+ "com.nimbusds.jose.proc",
+ "com.nimbusds.jose.produce",
+ "com.nimbusds.jose.shaded.gson",
+ "com.nimbusds.jose.shaded.gson.annotations",
+ "com.nimbusds.jose.shaded.gson.internal",
+ "com.nimbusds.jose.shaded.gson.internal.bind",
+ "com.nimbusds.jose.shaded.gson.internal.bind.util",
+ "com.nimbusds.jose.shaded.gson.internal.reflect",
+ "com.nimbusds.jose.shaded.gson.internal.sql",
+ "com.nimbusds.jose.shaded.gson.reflect",
+ "com.nimbusds.jose.shaded.gson.stream",
+ "com.nimbusds.jose.shaded.jcip",
+ "com.nimbusds.jose.util",
+ "com.nimbusds.jose.util.cache",
+ "com.nimbusds.jose.util.events",
+ "com.nimbusds.jose.util.health",
+ "com.nimbusds.jwt",
+ "com.nimbusds.jwt.proc",
+ "com.nimbusds.jwt.util"
+ ],
+ "com.nimbusds:oauth2-oidc-sdk": [
+ "com.nimbusds.oauth2.sdk",
+ "com.nimbusds.oauth2.sdk.as",
+ "com.nimbusds.oauth2.sdk.assertions",
+ "com.nimbusds.oauth2.sdk.assertions.jwt",
+ "com.nimbusds.oauth2.sdk.assertions.saml2",
+ "com.nimbusds.oauth2.sdk.auth",
+ "com.nimbusds.oauth2.sdk.auth.verifier",
+ "com.nimbusds.oauth2.sdk.ciba",
+ "com.nimbusds.oauth2.sdk.client",
+ "com.nimbusds.oauth2.sdk.cnf",
+ "com.nimbusds.oauth2.sdk.device",
+ "com.nimbusds.oauth2.sdk.dpop",
+ "com.nimbusds.oauth2.sdk.dpop.verifiers",
+ "com.nimbusds.oauth2.sdk.http",
+ "com.nimbusds.oauth2.sdk.id",
+ "com.nimbusds.oauth2.sdk.jarm",
+ "com.nimbusds.oauth2.sdk.jose",
+ "com.nimbusds.oauth2.sdk.pkce",
+ "com.nimbusds.oauth2.sdk.rar",
+ "com.nimbusds.oauth2.sdk.token",
+ "com.nimbusds.oauth2.sdk.tokenexchange",
+ "com.nimbusds.oauth2.sdk.util",
+ "com.nimbusds.oauth2.sdk.util.date",
+ "com.nimbusds.oauth2.sdk.util.singleuse",
+ "com.nimbusds.oauth2.sdk.util.tls",
+ "com.nimbusds.openid.connect.sdk",
+ "com.nimbusds.openid.connect.sdk.assurance",
+ "com.nimbusds.openid.connect.sdk.assurance.claims",
+ "com.nimbusds.openid.connect.sdk.assurance.evidences",
+ "com.nimbusds.openid.connect.sdk.assurance.evidences.attachment",
+ "com.nimbusds.openid.connect.sdk.assurance.request",
+ "com.nimbusds.openid.connect.sdk.claims",
+ "com.nimbusds.openid.connect.sdk.federation",
+ "com.nimbusds.openid.connect.sdk.federation.api",
+ "com.nimbusds.openid.connect.sdk.federation.config",
+ "com.nimbusds.openid.connect.sdk.federation.entities",
+ "com.nimbusds.openid.connect.sdk.federation.policy",
+ "com.nimbusds.openid.connect.sdk.federation.policy.language",
+ "com.nimbusds.openid.connect.sdk.federation.policy.operations",
+ "com.nimbusds.openid.connect.sdk.federation.registration",
+ "com.nimbusds.openid.connect.sdk.federation.trust",
+ "com.nimbusds.openid.connect.sdk.federation.trust.constraints",
+ "com.nimbusds.openid.connect.sdk.federation.trust.marks",
+ "com.nimbusds.openid.connect.sdk.federation.utils",
+ "com.nimbusds.openid.connect.sdk.id",
+ "com.nimbusds.openid.connect.sdk.nativesso",
+ "com.nimbusds.openid.connect.sdk.op",
+ "com.nimbusds.openid.connect.sdk.rp",
+ "com.nimbusds.openid.connect.sdk.rp.statement",
+ "com.nimbusds.openid.connect.sdk.token",
+ "com.nimbusds.openid.connect.sdk.validators",
+ "com.nimbusds.secevent.sdk.claims"
+ ],
"com.thoughtworks.paranamer:paranamer": [
"com.thoughtworks.paranamer"
],
@@ -1664,6 +1898,16 @@
"io.micronaut.websocket.exceptions",
"io.micronaut.websocket.interceptor"
],
+ "io.modelcontextprotocol.sdk:mcp": [
+ "io.modelcontextprotocol.client",
+ "io.modelcontextprotocol.client.transport",
+ "io.modelcontextprotocol.client.transport.customizer",
+ "io.modelcontextprotocol.common",
+ "io.modelcontextprotocol.server",
+ "io.modelcontextprotocol.server.transport",
+ "io.modelcontextprotocol.spec",
+ "io.modelcontextprotocol.util"
+ ],
"io.netty:netty-buffer": [
"io.netty.buffer",
"io.netty.buffer.search"
@@ -1787,6 +2031,7 @@
"io.sentry.internal.modules",
"io.sentry.internal.viewhierarchy",
"io.sentry.logger",
+ "io.sentry.metrics",
"io.sentry.opentelemetry",
"io.sentry.profilemeasurements",
"io.sentry.profiling",
@@ -1907,6 +2152,17 @@
"net.bytebuddy.utility.privilege",
"net.bytebuddy.utility.visitor"
],
+ "net.minidev:accessors-smart": [
+ "net.minidev.asm",
+ "net.minidev.asm.ex"
+ ],
+ "net.minidev:json-smart": [
+ "net.minidev.json",
+ "net.minidev.json.annotate",
+ "net.minidev.json.parser",
+ "net.minidev.json.reader",
+ "net.minidev.json.writer"
+ ],
"org.assertj:assertj-core": [
"org.assertj.core.annotation",
"org.assertj.core.annotations",
@@ -1938,6 +2194,190 @@
"org.assertj.core.util.introspection",
"org.assertj.core.util.xml"
],
+ "org.bouncycastle:bcprov-jdk18on": [
+ "org.bouncycastle",
+ "org.bouncycastle.asn1",
+ "org.bouncycastle.asn1.anssi",
+ "org.bouncycastle.asn1.bc",
+ "org.bouncycastle.asn1.cryptopro",
+ "org.bouncycastle.asn1.gm",
+ "org.bouncycastle.asn1.nist",
+ "org.bouncycastle.asn1.ocsp",
+ "org.bouncycastle.asn1.pkcs",
+ "org.bouncycastle.asn1.sec",
+ "org.bouncycastle.asn1.teletrust",
+ "org.bouncycastle.asn1.ua",
+ "org.bouncycastle.asn1.util",
+ "org.bouncycastle.asn1.x500",
+ "org.bouncycastle.asn1.x500.style",
+ "org.bouncycastle.asn1.x509",
+ "org.bouncycastle.asn1.x509.qualified",
+ "org.bouncycastle.asn1.x509.sigi",
+ "org.bouncycastle.asn1.x9",
+ "org.bouncycastle.crypto",
+ "org.bouncycastle.crypto.agreement",
+ "org.bouncycastle.crypto.agreement.ecjpake",
+ "org.bouncycastle.crypto.agreement.jpake",
+ "org.bouncycastle.crypto.agreement.kdf",
+ "org.bouncycastle.crypto.agreement.srp",
+ "org.bouncycastle.crypto.commitments",
+ "org.bouncycastle.crypto.constraints",
+ "org.bouncycastle.crypto.digests",
+ "org.bouncycastle.crypto.ec",
+ "org.bouncycastle.crypto.encodings",
+ "org.bouncycastle.crypto.engines",
+ "org.bouncycastle.crypto.examples",
+ "org.bouncycastle.crypto.fpe",
+ "org.bouncycastle.crypto.generators",
+ "org.bouncycastle.crypto.hpke",
+ "org.bouncycastle.crypto.io",
+ "org.bouncycastle.crypto.kems",
+ "org.bouncycastle.crypto.macs",
+ "org.bouncycastle.crypto.modes",
+ "org.bouncycastle.crypto.modes.gcm",
+ "org.bouncycastle.crypto.modes.kgcm",
+ "org.bouncycastle.crypto.paddings",
+ "org.bouncycastle.crypto.params",
+ "org.bouncycastle.crypto.parsers",
+ "org.bouncycastle.crypto.prng",
+ "org.bouncycastle.crypto.prng.drbg",
+ "org.bouncycastle.crypto.signers",
+ "org.bouncycastle.crypto.threshold",
+ "org.bouncycastle.crypto.tls",
+ "org.bouncycastle.crypto.util",
+ "org.bouncycastle.i18n",
+ "org.bouncycastle.i18n.filter",
+ "org.bouncycastle.iana",
+ "org.bouncycastle.internal.asn1.bsi",
+ "org.bouncycastle.internal.asn1.cms",
+ "org.bouncycastle.internal.asn1.cryptlib",
+ "org.bouncycastle.internal.asn1.eac",
+ "org.bouncycastle.internal.asn1.edec",
+ "org.bouncycastle.internal.asn1.gnu",
+ "org.bouncycastle.internal.asn1.iana",
+ "org.bouncycastle.internal.asn1.isara",
+ "org.bouncycastle.internal.asn1.isismtt",
+ "org.bouncycastle.internal.asn1.iso",
+ "org.bouncycastle.internal.asn1.kisa",
+ "org.bouncycastle.internal.asn1.microsoft",
+ "org.bouncycastle.internal.asn1.misc",
+ "org.bouncycastle.internal.asn1.nsri",
+ "org.bouncycastle.internal.asn1.ntt",
+ "org.bouncycastle.internal.asn1.oiw",
+ "org.bouncycastle.internal.asn1.rosstandart",
+ "org.bouncycastle.jcajce",
+ "org.bouncycastle.jcajce.interfaces",
+ "org.bouncycastle.jcajce.io",
+ "org.bouncycastle.jcajce.provider.asymmetric",
+ "org.bouncycastle.jcajce.provider.asymmetric.compositesignatures",
+ "org.bouncycastle.jcajce.provider.asymmetric.dh",
+ "org.bouncycastle.jcajce.provider.asymmetric.dsa",
+ "org.bouncycastle.jcajce.provider.asymmetric.dstu",
+ "org.bouncycastle.jcajce.provider.asymmetric.ec",
+ "org.bouncycastle.jcajce.provider.asymmetric.ecgost",
+ "org.bouncycastle.jcajce.provider.asymmetric.ecgost12",
+ "org.bouncycastle.jcajce.provider.asymmetric.edec",
+ "org.bouncycastle.jcajce.provider.asymmetric.elgamal",
+ "org.bouncycastle.jcajce.provider.asymmetric.gost",
+ "org.bouncycastle.jcajce.provider.asymmetric.ies",
+ "org.bouncycastle.jcajce.provider.asymmetric.mldsa",
+ "org.bouncycastle.jcajce.provider.asymmetric.mlkem",
+ "org.bouncycastle.jcajce.provider.asymmetric.rsa",
+ "org.bouncycastle.jcajce.provider.asymmetric.slhdsa",
+ "org.bouncycastle.jcajce.provider.asymmetric.util",
+ "org.bouncycastle.jcajce.provider.asymmetric.x509",
+ "org.bouncycastle.jcajce.provider.config",
+ "org.bouncycastle.jcajce.provider.digest",
+ "org.bouncycastle.jcajce.provider.drbg",
+ "org.bouncycastle.jcajce.provider.kdf",
+ "org.bouncycastle.jcajce.provider.kdf.hkdf",
+ "org.bouncycastle.jcajce.provider.kdf.pbepbkdf2",
+ "org.bouncycastle.jcajce.provider.kdf.scrypt",
+ "org.bouncycastle.jcajce.provider.keystore",
+ "org.bouncycastle.jcajce.provider.keystore.bc",
+ "org.bouncycastle.jcajce.provider.keystore.bcfks",
+ "org.bouncycastle.jcajce.provider.keystore.pkcs12",
+ "org.bouncycastle.jcajce.provider.keystore.util",
+ "org.bouncycastle.jcajce.provider.symmetric",
+ "org.bouncycastle.jcajce.provider.symmetric.util",
+ "org.bouncycastle.jcajce.provider.util",
+ "org.bouncycastle.jcajce.spec",
+ "org.bouncycastle.jcajce.util",
+ "org.bouncycastle.jce",
+ "org.bouncycastle.jce.exception",
+ "org.bouncycastle.jce.interfaces",
+ "org.bouncycastle.jce.netscape",
+ "org.bouncycastle.jce.provider",
+ "org.bouncycastle.jce.spec",
+ "org.bouncycastle.math",
+ "org.bouncycastle.math.ec",
+ "org.bouncycastle.math.ec.custom.djb",
+ "org.bouncycastle.math.ec.custom.gm",
+ "org.bouncycastle.math.ec.custom.sec",
+ "org.bouncycastle.math.ec.endo",
+ "org.bouncycastle.math.ec.rfc7748",
+ "org.bouncycastle.math.ec.rfc8032",
+ "org.bouncycastle.math.ec.tools",
+ "org.bouncycastle.math.field",
+ "org.bouncycastle.math.raw",
+ "org.bouncycastle.pqc.asn1",
+ "org.bouncycastle.pqc.crypto",
+ "org.bouncycastle.pqc.crypto.bike",
+ "org.bouncycastle.pqc.crypto.cmce",
+ "org.bouncycastle.pqc.crypto.crystals.dilithium",
+ "org.bouncycastle.pqc.crypto.falcon",
+ "org.bouncycastle.pqc.crypto.frodo",
+ "org.bouncycastle.pqc.crypto.hqc",
+ "org.bouncycastle.pqc.crypto.lms",
+ "org.bouncycastle.pqc.crypto.mayo",
+ "org.bouncycastle.pqc.crypto.mldsa",
+ "org.bouncycastle.pqc.crypto.mlkem",
+ "org.bouncycastle.pqc.crypto.newhope",
+ "org.bouncycastle.pqc.crypto.ntru",
+ "org.bouncycastle.pqc.crypto.ntruprime",
+ "org.bouncycastle.pqc.crypto.picnic",
+ "org.bouncycastle.pqc.crypto.rainbow",
+ "org.bouncycastle.pqc.crypto.saber",
+ "org.bouncycastle.pqc.crypto.slhdsa",
+ "org.bouncycastle.pqc.crypto.snova",
+ "org.bouncycastle.pqc.crypto.sphincs",
+ "org.bouncycastle.pqc.crypto.sphincsplus",
+ "org.bouncycastle.pqc.crypto.util",
+ "org.bouncycastle.pqc.crypto.xmss",
+ "org.bouncycastle.pqc.crypto.xwing",
+ "org.bouncycastle.pqc.jcajce.interfaces",
+ "org.bouncycastle.pqc.jcajce.provider",
+ "org.bouncycastle.pqc.jcajce.provider.bike",
+ "org.bouncycastle.pqc.jcajce.provider.cmce",
+ "org.bouncycastle.pqc.jcajce.provider.dilithium",
+ "org.bouncycastle.pqc.jcajce.provider.falcon",
+ "org.bouncycastle.pqc.jcajce.provider.frodo",
+ "org.bouncycastle.pqc.jcajce.provider.hqc",
+ "org.bouncycastle.pqc.jcajce.provider.kyber",
+ "org.bouncycastle.pqc.jcajce.provider.lms",
+ "org.bouncycastle.pqc.jcajce.provider.mayo",
+ "org.bouncycastle.pqc.jcajce.provider.newhope",
+ "org.bouncycastle.pqc.jcajce.provider.ntru",
+ "org.bouncycastle.pqc.jcajce.provider.ntruprime",
+ "org.bouncycastle.pqc.jcajce.provider.picnic",
+ "org.bouncycastle.pqc.jcajce.provider.saber",
+ "org.bouncycastle.pqc.jcajce.provider.snova",
+ "org.bouncycastle.pqc.jcajce.provider.sphincs",
+ "org.bouncycastle.pqc.jcajce.provider.sphincsplus",
+ "org.bouncycastle.pqc.jcajce.provider.util",
+ "org.bouncycastle.pqc.jcajce.provider.xmss",
+ "org.bouncycastle.pqc.jcajce.spec",
+ "org.bouncycastle.pqc.math.ntru",
+ "org.bouncycastle.pqc.math.ntru.parameters",
+ "org.bouncycastle.util",
+ "org.bouncycastle.util.encoders",
+ "org.bouncycastle.util.io",
+ "org.bouncycastle.util.io.pem",
+ "org.bouncycastle.util.test",
+ "org.bouncycastle.x509",
+ "org.bouncycastle.x509.extension",
+ "org.bouncycastle.x509.util"
+ ],
"org.checkerframework:checker-qual": [
"org.checkerframework.checker.builder.qual",
"org.checkerframework.checker.calledmethods.qual",
@@ -2119,21 +2559,48 @@
"org.slf4j.event",
"org.slf4j.helpers",
"org.slf4j.spi"
+ ],
+ "org.yaml:snakeyaml": [
+ "org.yaml.snakeyaml",
+ "org.yaml.snakeyaml.comments",
+ "org.yaml.snakeyaml.composer",
+ "org.yaml.snakeyaml.constructor",
+ "org.yaml.snakeyaml.emitter",
+ "org.yaml.snakeyaml.env",
+ "org.yaml.snakeyaml.error",
+ "org.yaml.snakeyaml.events",
+ "org.yaml.snakeyaml.extensions.compactnotation",
+ "org.yaml.snakeyaml.external.com.google.gdata.util.common.base",
+ "org.yaml.snakeyaml.inspector",
+ "org.yaml.snakeyaml.internal",
+ "org.yaml.snakeyaml.introspector",
+ "org.yaml.snakeyaml.nodes",
+ "org.yaml.snakeyaml.parser",
+ "org.yaml.snakeyaml.reader",
+ "org.yaml.snakeyaml.representer",
+ "org.yaml.snakeyaml.resolver",
+ "org.yaml.snakeyaml.scanner",
+ "org.yaml.snakeyaml.serializer",
+ "org.yaml.snakeyaml.tokens",
+ "org.yaml.snakeyaml.util"
]
},
"repositories": {
"https://repo1.maven.org/maven2/": [
"ch.qos.logback:logback-classic",
"ch.qos.logback:logback-core",
+ "com.ethlo.time:itu",
"com.fasterxml.jackson.core:jackson-annotations",
"com.fasterxml.jackson.core:jackson-core",
"com.fasterxml.jackson.core:jackson-databind",
+ "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml",
"com.fasterxml.jackson.datatype:jackson-datatype-guava",
"com.fasterxml.jackson.datatype:jackson-datatype-jdk8",
"com.fasterxml.jackson.datatype:jackson-datatype-jsr310",
"com.fasterxml.jackson.module:jackson-module-scala_2.13",
"com.github.javaparser:javaparser-core",
"com.github.javaparser:javaparser-symbol-solver-core",
+ "com.github.stephenc.jcip:jcip-annotations",
"com.google.code.findbugs:jsr305",
"com.google.code.gson:gson",
"com.google.errorprone:error_prone_annotations",
@@ -2141,6 +2608,11 @@
"com.google.guava:guava",
"com.google.guava:listenablefuture",
"com.google.j2objc:j2objc-annotations",
+ "com.networknt:json-schema-validator",
+ "com.nimbusds:content-type",
+ "com.nimbusds:lang-tag",
+ "com.nimbusds:nimbus-jose-jwt",
+ "com.nimbusds:oauth2-oidc-sdk",
"com.thoughtworks.paranamer:paranamer",
"io.micronaut.jaxrs:micronaut-jaxrs-common",
"io.micronaut.jaxrs:micronaut-jaxrs-processor",
@@ -2173,6 +2645,7 @@
"io.micronaut:micronaut-router",
"io.micronaut:micronaut-runtime",
"io.micronaut:micronaut-websocket",
+ "io.modelcontextprotocol.sdk:mcp",
"io.netty:netty-buffer",
"io.netty:netty-codec",
"io.netty:netty-codec-base",
@@ -2197,7 +2670,10 @@
"jakarta.ws.rs:jakarta.ws.rs-api",
"junit:junit",
"net.bytebuddy:byte-buddy",
+ "net.minidev:accessors-smart",
+ "net.minidev:json-smart",
"org.assertj:assertj-core",
+ "org.bouncycastle:bcprov-jdk18on",
"org.checkerframework:checker-qual",
"org.eclipse.jetty.toolchain:jetty-jakarta-servlet-api",
"org.eclipse.jetty.websocket:websocket-core-common",
@@ -2223,7 +2699,8 @@
"org.ow2.asm:asm-util",
"org.reactivestreams:reactive-streams",
"org.scala-lang:scala-library",
- "org.slf4j:slf4j-api"
+ "org.slf4j:slf4j-api",
+ "org.yaml:snakeyaml"
]
},
"services": {
@@ -2245,6 +2722,14 @@
"com.fasterxml.jackson.databind.ObjectMapper"
]
},
+ "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml": {
+ "com.fasterxml.jackson.core.JsonFactory": [
+ "com.fasterxml.jackson.dataformat.yaml.YAMLFactory"
+ ],
+ "com.fasterxml.jackson.core.ObjectCodec": [
+ "com.fasterxml.jackson.dataformat.yaml.YAMLMapper"
+ ]
+ },
"com.fasterxml.jackson.datatype:jackson-datatype-guava": {
"com.fasterxml.jackson.databind.Module": [
"com.fasterxml.jackson.datatype.guava.GuavaModule"
@@ -2530,6 +3015,12 @@
"reactor.core.scheduler.ReactorBlockHoundIntegration"
]
},
+ "org.bouncycastle:bcprov-jdk18on": {
+ "java.security.Provider": [
+ "org.bouncycastle.jce.provider.BouncyCastleProvider",
+ "org.bouncycastle.pqc.jcajce.provider.BouncyCastlePQCProvider"
+ ]
+ },
"org.eclipse.jetty.websocket:websocket-core-common": {
"org.eclipse.jetty.websocket.core.Extension": [
"org.eclipse.jetty.websocket.core.internal.FragmentExtension",
diff --git a/scripts/run-mcp-oauth-demo.sh b/scripts/run-mcp-oauth-demo.sh
new file mode 100755
index 00000000..9258d9fc
--- /dev/null
+++ b/scripts/run-mcp-oauth-demo.sh
@@ -0,0 +1,230 @@
+#!/bin/bash
+set -e
+
+echo "=== MCP OAuth 2.1 Demo Setup ==="
+echo ""
+
+# Colors for output
+GREEN='\033[0;32m'
+RED='\033[0;31m'
+YELLOW='\033[1;33m'
+NC='\033[0m' # No Color
+
+# Configuration
+KEYCLOAK_PORT=8180
+MCP_SERVER_PORT=8080
+CALLBACK_PORT=8888
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+KEYCLOAK_DIR="$ROOT_DIR/local_docker/keycloak"
+STARTED_KEYCLOAK=false
+KEYCLOAK_BASE_URL="http://localhost:$KEYCLOAK_PORT"
+MCP_PID=""
+KEEP_KEYCLOAK="${1:-}"
+
+# Cleanup function for signal handling
+cleanup() {
+ echo ""
+ echo "=== Cleanup ==="
+ echo -e "${YELLOW}→${NC} Stopping MCP Server..."
+ # Kill bazel wrapper if we have the PID
+ if [ -n "$MCP_PID" ]; then
+ kill $MCP_PID 2>/dev/null || true
+ fi
+ # Also kill any process on the MCP port (bazel run spawns child processes)
+ local port_pid
+ port_pid=$(lsof -i :$MCP_SERVER_PORT -t 2>/dev/null || true)
+ if [ -n "$port_pid" ]; then
+ kill $port_pid 2>/dev/null || true
+ fi
+
+ if [ "$KEEP_KEYCLOAK" != "--keep-keycloak" ]; then
+ if [ "$STARTED_KEYCLOAK" = true ]; then
+ echo -e "${YELLOW}→${NC} Stopping Keycloak..."
+ cd "$KEYCLOAK_DIR"
+ docker compose -f docker-compose.keycloak.yml down
+ cd "$ROOT_DIR"
+ else
+ echo -e "${YELLOW}→${NC} Keycloak was already running; leaving it up."
+ fi
+ fi
+}
+
+# Trap signals to ensure cleanup runs
+trap cleanup EXIT INT TERM
+
+echo "Configuration:"
+echo " Keycloak: http://localhost:$KEYCLOAK_PORT"
+echo " MCP Server: http://localhost:$MCP_SERVER_PORT"
+echo " OAuth Callback: http://localhost:$CALLBACK_PORT/callback"
+echo ""
+
+# Check if Keycloak is already running
+if curl -sf http://localhost:$KEYCLOAK_PORT/health/ready > /dev/null 2>&1; then
+ echo -e "${GREEN}✓${NC} Keycloak is already running"
+else
+ echo -e "${YELLOW}→${NC} Starting Keycloak..."
+ cd "$KEYCLOAK_DIR"
+ docker compose -f docker-compose.keycloak.yml up -d
+ STARTED_KEYCLOAK=true
+
+ echo -e "${YELLOW}→${NC} Waiting for Keycloak to be ready (this may take 30-60 seconds)..."
+ RETRY_COUNT=0
+ MAX_RETRIES=60
+ until curl -sf http://localhost:$KEYCLOAK_PORT/realms/mcp-demo/.well-known/openid-configuration > /dev/null 2>&1; do
+ sleep 2
+ RETRY_COUNT=$((RETRY_COUNT + 1))
+ if [ $RETRY_COUNT -ge $MAX_RETRIES ]; then
+ echo -e "${RED}✗${NC} Keycloak failed to start within timeout"
+ echo "Checking logs..."
+ docker compose -f docker-compose.keycloak.yml logs --tail 20
+ exit 1
+ fi
+ echo -n "."
+ done
+ echo ""
+ echo -e "${GREEN}✓${NC} Keycloak is ready"
+ cd "$ROOT_DIR"
+fi
+
+# Verify Keycloak realm
+echo -e "${YELLOW}→${NC} Verifying Keycloak realm configuration..."
+if curl -sf http://localhost:$KEYCLOAK_PORT/realms/mcp-demo/.well-known/openid-configuration > /dev/null; then
+ echo -e "${GREEN}✓${NC} Keycloak realm 'mcp-demo' is configured"
+else
+ echo -e "${RED}✗${NC} Keycloak realm 'mcp-demo' not found"
+ echo "Please check local_docker/keycloak/realm-export.json"
+ exit 1
+fi
+
+# Ensure demo client exists only when explicitly requested
+if [ -z "${MCP_CLIENT_ID:-}" ] && [ "${MCP_FORCE_ADMIN_CLIENT:-}" = "1" ]; then
+ echo -e "${YELLOW}→${NC} Ensuring demo client exists in Keycloak..."
+ TOKEN_RESPONSE=$(curl -sS \
+ -d "client_id=admin-cli" \
+ -d "grant_type=password" \
+ -d "username=admin" \
+ -d "password=admin" \
+ "$KEYCLOAK_BASE_URL/realms/master/protocol/openid-connect/token")
+
+ if [ -z "$TOKEN_RESPONSE" ]; then
+ echo -e "${RED}✗${NC} Failed to obtain Keycloak admin token (empty response)"
+ exit 1
+ fi
+
+ ADMIN_TOKEN=$(python3 -c 'import json,sys;
+data = sys.stdin.read().strip();
+print(json.loads(data).get("access_token","") if data else "")' <<< "$TOKEN_RESPONSE")
+
+ if [ -z "$ADMIN_TOKEN" ]; then
+ echo -e "${RED}✗${NC} Failed to obtain Keycloak admin token"
+ echo "$TOKEN_RESPONSE"
+ exit 1
+ fi
+
+ CLIENT_ID="mcp-demo-client"
+ CLIENT_INTERNAL_ID=$(curl -sf \
+ -H "Authorization: Bearer $ADMIN_TOKEN" \
+ "$KEYCLOAK_BASE_URL/admin/realms/mcp-demo/clients?clientId=$CLIENT_ID" | \
+ python3 -c 'import json,sys;
+data = json.load(sys.stdin);
+print(data[0].get("id","") if isinstance(data,list) and data else "")'
+ )
+
+ if [ -z "$CLIENT_INTERNAL_ID" ]; then
+ REDIRECT_URI="http://localhost:$CALLBACK_PORT/callback"
+ CREATE_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
+ -H "Authorization: Bearer $ADMIN_TOKEN" \
+ -H "Content-Type: application/json" \
+ -d "{
+ \"clientId\": \"$CLIENT_ID\",
+ \"name\": \"MCP Demo Client\",
+ \"publicClient\": true,
+ \"standardFlowEnabled\": true,
+ \"directAccessGrantsEnabled\": false,
+ \"serviceAccountsEnabled\": false,
+ \"redirectUris\": [\"$REDIRECT_URI\"],
+ \"webOrigins\": [\"+\"]
+}" \
+ "$KEYCLOAK_BASE_URL/admin/realms/mcp-demo/clients")
+
+ if [ "$CREATE_STATUS" != "201" ]; then
+ echo -e "${RED}✗${NC} Failed to create demo client (HTTP $CREATE_STATUS)"
+ exit 1
+ fi
+ echo -e "${GREEN}✓${NC} Created demo client '$CLIENT_ID'"
+ else
+ echo -e "${GREEN}✓${NC} Demo client '$CLIENT_ID' already exists"
+ fi
+
+ export MCP_CLIENT_ID="$CLIENT_ID"
+elif [ -n "${MCP_CLIENT_ID:-}" ]; then
+ echo -e "${YELLOW}→${NC} Using preconfigured MCP client ID: ${MCP_CLIENT_ID}"
+fi
+
+# Start MCP Server with OAuth
+echo -e "${YELLOW}→${NC} Starting MCP Server with OAuth enabled..."
+export MICRONAUT_ENVIRONMENTS=dev
+export PORT=$MCP_SERVER_PORT
+
+bazel run //jvm/src/main/java/com/muchq/mcpserver:mcpserver > /tmp/mcp-server.log 2>&1 &
+MCP_PID=$!
+
+echo -e "${YELLOW}→${NC} Waiting for MCP Server to start..."
+sleep 5
+
+# Check if MCP server is running
+if ! ps -p $MCP_PID > /dev/null; then
+ echo -e "${RED}✗${NC} MCP Server failed to start"
+ cat /tmp/mcp-server.log
+ exit 1
+fi
+
+# Verify Protected Resource Metadata endpoint
+if curl -sf http://localhost:$MCP_SERVER_PORT/.well-known/oauth-protected-resource > /dev/null; then
+ echo -e "${GREEN}✓${NC} MCP Server is ready and serving OAuth metadata"
+else
+ echo -e "${RED}✗${NC} MCP Server not responding to OAuth metadata endpoint"
+ kill $MCP_PID 2>/dev/null || true
+ exit 1
+fi
+
+echo ""
+echo "=== Running MCP Client Demo ==="
+echo ""
+echo "The demo will:"
+echo " 1. Discover the authorization server from MCP server"
+echo " 2. Fetch authorization server metadata from Keycloak"
+echo " 3. Dynamically register as an OAuth client"
+echo " 4. Open your browser for authentication"
+echo " 5. Wait for you to log in (use testuser/testpass)"
+echo " 6. Exchange authorization code for access token"
+echo " 7. Display success message"
+echo ""
+echo -e "${YELLOW}Press Enter to start the demo${NC}"
+read
+
+# Run the demo
+bazel run //jvm/src/main/java/com/muchq/mcpclient/demo:demo
+
+DEMO_EXIT_CODE=$?
+
+# Cleanup is handled by the EXIT trap
+
+if [ $DEMO_EXIT_CODE -eq 0 ]; then
+ echo ""
+ echo -e "${GREEN}=== Demo Completed Successfully! ===${NC}"
+ echo ""
+ echo "What just happened:"
+ echo " ✓ Client discovered OAuth endpoints (RFC 9728, RFC 8414)"
+ echo " ✓ Client dynamically registered (RFC 7591)"
+ echo " ✓ PKCE protected the authorization code exchange"
+ echo " ✓ Resource parameter ensured token audience validation (RFC 8707)"
+ echo " ✓ MCP server validated the JWT token"
+ echo ""
+ echo "This demonstrates a complete implementation of the MCP Authorization Spec!"
+else
+ echo ""
+ echo -e "${RED}=== Demo Failed ===${NC}"
+ echo "Check the logs above for error details"
+ exit 1
+fi