parameters) throws Exception {
McpConfiguration config = new McpConfiguration();
+ config.setUri(remaining); // Set the URI from the remaining part
setProperties(config, parameters);
return new McpEndpoint(uri, this, config);
}
diff --git a/src/main/java/io/dscope/camel/mcp/McpConfiguration.java b/src/main/java/io/dscope/camel/mcp/McpConfiguration.java
index 086d6c3..b2df8a1 100644
--- a/src/main/java/io/dscope/camel/mcp/McpConfiguration.java
+++ b/src/main/java/io/dscope/camel/mcp/McpConfiguration.java
@@ -1,15 +1,50 @@
package io.dscope.camel.mcp;
+import org.apache.camel.spi.Metadata;
import org.apache.camel.spi.UriParam;
import org.apache.camel.spi.UriPath;
public class McpConfiguration {
- @UriPath
+
+ @UriPath(description = "The target MCP server URI (e.g. http://localhost:8080/mcp). "
+ + "For consumers this is the listen address; for producers the remote server address.")
+ @Metadata(required = true)
private String uri;
- @UriParam(label = "operation")
+
+ @UriParam(label = "producer", defaultValue = "tools/list",
+ description = "The MCP JSON-RPC method to invoke. "
+ + "Supported: initialize, ping, tools/list, tools/call, resources/list, resources/read, "
+ + "resources/get, ui/initialize, ui/message, ui/update-model-context, ui/tools/call.",
+ enums = "initialize,ping,tools/list,tools/call,resources/list,resources/read,"
+ + "resources/get,health,stream,ui/initialize,ui/message,ui/update-model-context,ui/tools/call")
private String method = "tools/list";
+
+ @UriParam(label = "consumer", defaultValue = "false",
+ description = "When true the consumer creates a WebSocket endpoint instead of HTTP.")
+ private boolean websocket = false;
+
+ @UriParam(label = "consumer", defaultValue = "false",
+ description = "For WebSocket consumers, whether to broadcast messages to all connected clients.")
+ private boolean sendToAll = false;
+
+ @UriParam(label = "consumer", defaultValue = "*",
+ description = "Comma-separated list of allowed CORS origins. Use * for any origin.")
+ private String allowedOrigins = "*";
+
+ @UriParam(label = "consumer", defaultValue = "POST",
+ description = "HTTP methods allowed by the consumer endpoint.")
+ private String httpMethodRestrict = "POST";
+
public String getUri() { return uri; }
public void setUri(String uri) { this.uri = uri; }
public String getMethod() { return method; }
public void setMethod(String method) { this.method = method; }
+ public boolean isWebsocket() { return websocket; }
+ public void setWebsocket(boolean websocket) { this.websocket = websocket; }
+ public boolean isSendToAll() { return sendToAll; }
+ public void setSendToAll(boolean sendToAll) { this.sendToAll = sendToAll; }
+ public String getAllowedOrigins() { return allowedOrigins; }
+ public void setAllowedOrigins(String allowedOrigins) { this.allowedOrigins = allowedOrigins; }
+ public String getHttpMethodRestrict() { return httpMethodRestrict; }
+ public void setHttpMethodRestrict(String httpMethodRestrict) { this.httpMethodRestrict = httpMethodRestrict; }
}
diff --git a/src/main/java/io/dscope/camel/mcp/McpConsumer.java b/src/main/java/io/dscope/camel/mcp/McpConsumer.java
index f6cefde..e728374 100644
--- a/src/main/java/io/dscope/camel/mcp/McpConsumer.java
+++ b/src/main/java/io/dscope/camel/mcp/McpConsumer.java
@@ -1,19 +1,174 @@
package io.dscope.camel.mcp;
-import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.camel.Exchange;
import org.apache.camel.Processor;
+import org.apache.camel.component.undertow.UndertowConsumer;
+import org.apache.camel.component.undertow.UndertowEndpoint;
import org.apache.camel.support.DefaultConsumer;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import io.dscope.camel.mcp.processor.McpJsonRpcEnvelopeProcessor;
+import io.dscope.camel.mcp.processor.McpRequestSizeGuardProcessor;
+import io.dscope.camel.mcp.processor.McpRateLimitProcessor;
+import io.dscope.camel.mcp.processor.McpHttpValidatorProcessor;
+/**
+ * MCP Consumer that sets up HTTP or WebSocket server endpoints to receive MCP JSON-RPC requests.
+ *
+ * The consumer wraps an Undertow consumer to listen for incoming MCP requests,
+ * processes them through the JSON-RPC envelope parser, and delegates to the configured processor.
+ */
public class McpConsumer extends DefaultConsumer {
+ private static final Logger LOG = LoggerFactory.getLogger(McpConsumer.class);
+
private final McpEndpoint endpoint;
+ private final McpRequestSizeGuardProcessor requestSizeGuard;
+ private final McpRateLimitProcessor rateLimit;
+ private final McpJsonRpcEnvelopeProcessor jsonRpcEnvelope;
+ private final McpHttpValidatorProcessor httpValidator;
+ private final ObjectMapper objectMapper;
+ private UndertowConsumer undertowConsumer;
+
public McpConsumer(McpEndpoint endpoint, Processor processor) {
super(endpoint, processor);
this.endpoint = endpoint;
+ this.requestSizeGuard = new McpRequestSizeGuardProcessor();
+ this.rateLimit = new McpRateLimitProcessor();
+ this.jsonRpcEnvelope = new McpJsonRpcEnvelopeProcessor();
+ this.httpValidator = new McpHttpValidatorProcessor();
+ this.objectMapper = new ObjectMapper();
}
+
@Override
protected void doStart() throws Exception {
super.doStart();
- // Placeholder for Undertow listener integration
+ LOG.info("Starting MCP consumer for endpoint: {}", endpoint.getEndpointUri());
+
+ McpConfiguration config = endpoint.getConfiguration();
+ LOG.info("Configuration URI before processing: {}", config.getUri());
+
+ String undertowUri = buildUndertowUri(config);
+
+ LOG.info("Creating MCP server with Undertow URI: {}", undertowUri);
+
+ // Create an Undertow endpoint using the CamelContext
+ // The URI must include the "undertow:" prefix for the component
+ String fullUndertowUri = "undertow:" + undertowUri;
+ LOG.info("Full Undertow URI with component prefix: {}", fullUndertowUri);
+
+ UndertowEndpoint undertowEndpoint = (UndertowEndpoint) endpoint.getCamelContext().getEndpoint(fullUndertowUri);
+
+ // Create a processor chain that includes MCP processing before calling user processor
+ Processor mcpProcessor = exchange -> {
+ try {
+ // Apply MCP processors in sequence
+ requestSizeGuard.process(exchange);
+
+ if (!config.isWebsocket()) {
+ httpValidator.process(exchange);
+ }
+
+ rateLimit.process(exchange);
+ jsonRpcEnvelope.process(exchange);
+
+ // Call the user's processor
+ getProcessor().process(exchange);
+
+ // Serialize response to JSON if it's a Map or POJO
+ Object body = exchange.getMessage().getBody();
+ if (body != null && !(body instanceof String) && !(body instanceof byte[])) {
+ try {
+ String json = objectMapper.writeValueAsString(body);
+ exchange.getMessage().setBody(json);
+ } catch (Exception e) {
+ String bodyType = body.getClass().getName();
+ String bodyPreview = body.toString();
+ if (bodyPreview.length() > 100) {
+ bodyPreview = bodyPreview.substring(0, 100) + "...";
+ }
+ throw new IllegalStateException(
+ String.format("Failed to serialize response body to JSON. Type: %s, Preview: %s",
+ bodyType, bodyPreview), e);
+ }
+ }
+
+ // Ensure response has JSON content type
+ if (exchange.getMessage().getHeader("Content-Type") == null) {
+ exchange.getMessage().setHeader("Content-Type", "application/json");
+ }
+ } catch (Exception e) {
+ LOG.error("Error processing MCP request", e);
+ throw e;
+ }
+ };
+
+ // Create and start the Undertow consumer with our processor chain
+ undertowConsumer = (UndertowConsumer) undertowEndpoint.createConsumer(mcpProcessor);
+ undertowConsumer.start();
+
+ LOG.info("MCP consumer started successfully on {}", undertowUri);
+ }
+
+ @Override
+ protected void doStop() throws Exception {
+ LOG.info("Stopping MCP consumer for endpoint: {}", endpoint.getEndpointUri());
+
+ if (undertowConsumer != null) {
+ try {
+ undertowConsumer.stop();
+ LOG.info("MCP consumer stopped successfully");
+ } catch (Exception e) {
+ LOG.warn("Error stopping MCP Undertow consumer", e);
+ }
+ }
+
+ super.doStop();
+ }
+
+ /**
+ * Builds the Undertow component URI based on configuration.
+ */
+ private String buildUndertowUri(McpConfiguration config) {
+ String baseUri = config.getUri();
+
+ if (baseUri == null || baseUri.isBlank()) {
+ throw new IllegalArgumentException("URI must be specified for MCP consumer");
+ }
+
+ // The URI from config might already have http://, or it might just be host:port/path
+ // Normalize it to ensure it has a scheme
+ if (!baseUri.startsWith("http://") && !baseUri.startsWith("https://") &&
+ !baseUri.startsWith("ws://") && !baseUri.startsWith("wss://")) {
+ baseUri = "http://" + baseUri;
+ }
+
+ StringBuilder uri = new StringBuilder();
+
+ if (config.isWebsocket()) {
+ // Convert http:// to ws:// for WebSocket
+ String wsUri = baseUri.replace("http://", "ws://").replace("https://", "wss://");
+ uri.append(wsUri);
+ appendQueryParam(uri, "sendToAll", String.valueOf(config.isSendToAll()));
+ appendQueryParam(uri, "allowedOrigins", config.getAllowedOrigins());
+ appendQueryParam(uri, "exchangePattern", "InOut");
+ } else {
+ // HTTP endpoint
+ uri.append(baseUri);
+ appendQueryParam(uri, "httpMethodRestrict", config.getHttpMethodRestrict());
+ }
+
+ return uri.toString();
+ }
+
+ /**
+ * Helper method to append query parameters to URI.
+ */
+ private void appendQueryParam(StringBuilder uri, String param, String value) {
+ String currentUri = uri.toString();
+ uri.append(currentUri.contains("?") ? "&" : "?");
+ uri.append(param).append("=").append(value);
}
}
diff --git a/src/main/java/io/dscope/camel/mcp/McpEndpoint.java b/src/main/java/io/dscope/camel/mcp/McpEndpoint.java
index 9dd4ba6..9d71963 100644
--- a/src/main/java/io/dscope/camel/mcp/McpEndpoint.java
+++ b/src/main/java/io/dscope/camel/mcp/McpEndpoint.java
@@ -1,18 +1,67 @@
package io.dscope.camel.mcp;
+import org.apache.camel.Category;
import org.apache.camel.Consumer;
import org.apache.camel.Processor;
import org.apache.camel.Producer;
+import org.apache.camel.spi.Metadata;
+import org.apache.camel.spi.UriEndpoint;
+import org.apache.camel.spi.UriParam;
+import org.apache.camel.spi.UriPath;
import org.apache.camel.support.DefaultEndpoint;
+/**
+ * Camel component for the Model Context Protocol (MCP).
+ *
+ * Enables Camel routes to act as MCP clients (producer) sending JSON-RPC 2.0 requests,
+ * or MCP servers (consumer) exposing tools, resources, and prompts to AI agents.
+ */
+@UriEndpoint(
+ firstVersion = "1.0.0",
+ scheme = "mcp",
+ title = "MCP",
+ syntax = "mcp:uri",
+ category = { Category.AI },
+ producerOnly = false,
+ lenientProperties = true
+)
+@Metadata(annotations = {
+ "protocol=http"
+})
public class McpEndpoint extends DefaultEndpoint {
+
+ @UriPath(description = "The target MCP server URI (e.g. http://localhost:8080/mcp). "
+ + "For consumers this is the listen address; for producers the remote server address.")
+ @Metadata(required = true)
+ private String uri;
+
+ @UriParam(label = "producer", defaultValue = "tools/list",
+ description = "The MCP JSON-RPC method to invoke.")
+ private String method;
+
+ @UriParam(label = "consumer", defaultValue = "false",
+ description = "When true the consumer creates a WebSocket endpoint instead of HTTP.")
+ private boolean websocket;
+
private final McpConfiguration configuration;
- public McpEndpoint(String uri, McpComponent component, McpConfiguration configuration) {
- super(uri, component);
+
+ public McpEndpoint(String endpointUri, McpComponent component, McpConfiguration configuration) {
+ super(endpointUri, component);
this.configuration = configuration;
}
+
@Override public Producer createProducer() { return new McpProducer(this); }
@Override public Consumer createConsumer(Processor processor) { return new McpConsumer(this, processor); }
@Override public boolean isSingleton() { return true; }
+
public McpConfiguration getConfiguration() { return configuration; }
+
+ public String getUri() { return configuration.getUri(); }
+ public void setUri(String uri) { configuration.setUri(uri); }
+
+ public String getMethod() { return configuration.getMethod(); }
+ public void setMethod(String method) { configuration.setMethod(method); }
+
+ public boolean isWebsocket() { return configuration.isWebsocket(); }
+ public void setWebsocket(boolean websocket) { configuration.setWebsocket(websocket); }
}
diff --git a/src/main/java/io/dscope/camel/mcp/config/McpAppsConfiguration.java b/src/main/java/io/dscope/camel/mcp/config/McpAppsConfiguration.java
index c7c1bd3..8e2c58f 100644
--- a/src/main/java/io/dscope/camel/mcp/config/McpAppsConfiguration.java
+++ b/src/main/java/io/dscope/camel/mcp/config/McpAppsConfiguration.java
@@ -14,7 +14,7 @@ public class McpAppsConfiguration {
private boolean enabled = DEFAULT_ENABLED;
private long sessionTimeoutMs = DEFAULT_SESSION_TIMEOUT_MS;
private String hostName = "camel-mcp";
- private String hostVersion = "1.2.0";
+ private String hostVersion = "1.3.0";
public McpAppsConfiguration() {
}
@@ -26,7 +26,7 @@ public McpAppsConfiguration() {
* - mcp.apps.enabled (boolean, default: true)
* - mcp.apps.session.timeout (long milliseconds, default: 3600000)
* - mcp.apps.host.name (string, default: camel-mcp)
- * - mcp.apps.host.version (string, default: 1.2.0)
+ * - mcp.apps.host.version (string, default: 1.3.0)
*
* @return configuration instance
*/
diff --git a/src/main/java/io/dscope/camel/mcp/model/McpUiHostInfo.java b/src/main/java/io/dscope/camel/mcp/model/McpUiHostInfo.java
index 7a47c48..401fb48 100644
--- a/src/main/java/io/dscope/camel/mcp/model/McpUiHostInfo.java
+++ b/src/main/java/io/dscope/camel/mcp/model/McpUiHostInfo.java
@@ -11,7 +11,7 @@
public class McpUiHostInfo {
private static final String DEFAULT_NAME = "camel-mcp";
- private static final String DEFAULT_VERSION = "1.2.0";
+ private static final String DEFAULT_VERSION = "1.3.0";
private String name;
private String version;
diff --git a/src/main/java/io/dscope/camel/mcp/processor/McpUiInitializeProcessor.java b/src/main/java/io/dscope/camel/mcp/processor/McpUiInitializeProcessor.java
index 1b3d733..0161569 100644
--- a/src/main/java/io/dscope/camel/mcp/processor/McpUiInitializeProcessor.java
+++ b/src/main/java/io/dscope/camel/mcp/processor/McpUiInitializeProcessor.java
@@ -25,7 +25,7 @@ public class McpUiInitializeProcessor extends AbstractMcpResponseProcessor {
public static final String EXCHANGE_PROPERTY_UI_SESSION_ID = "mcp.ui.sessionId";
private static final String DEFAULT_HOST_NAME = "camel-mcp";
- private static final String DEFAULT_HOST_VERSION = "1.2.0";
+ private static final String DEFAULT_HOST_VERSION = "1.3.0";
private static final List CAPABILITIES = List.of(
"tools/call",
diff --git a/src/main/java/io/dscope/camel/mcp/processor/McpUiMessageProcessor.java b/src/main/java/io/dscope/camel/mcp/processor/McpUiMessageProcessor.java
index c388e8e..fd20d21 100644
--- a/src/main/java/io/dscope/camel/mcp/processor/McpUiMessageProcessor.java
+++ b/src/main/java/io/dscope/camel/mcp/processor/McpUiMessageProcessor.java
@@ -48,22 +48,22 @@ protected void handleResponse(Exchange exchange) {
return; // Error already written
}
- // Extract message content
- String message = params != null ? (String) params.get("message") : null;
+ // Extract message content – may be a plain String or a structured Map
+ Object rawMessage = params != null ? params.get("message") : null;
String type = params != null ? (String) params.get("type") : null;
- if (message == null || message.isBlank()) {
+ if (rawMessage == null || (rawMessage instanceof String s && s.isBlank())) {
writeError(exchange, createError(-32602, "Missing required parameter: message"), 400);
return;
}
- // Store message on exchange for downstream processing
- exchange.setProperty(EXCHANGE_PROPERTY_UI_MESSAGE, message);
+ // Store message on exchange for downstream processing (preserves original type)
+ exchange.setProperty(EXCHANGE_PROPERTY_UI_MESSAGE, rawMessage);
if (type != null) {
exchange.setProperty(EXCHANGE_PROPERTY_UI_MESSAGE_TYPE, type);
}
- LOG.info("UI Message from session {}: type={} message={}", sessionId, type, message);
+ LOG.info("UI Message from session {}: type={} message={}", sessionId, type, rawMessage);
// Acknowledge receipt
Map result = newResultMap();
diff --git a/src/main/java/io/dscope/tools/karavan/McpKaravanMetadataGenerator.java b/src/main/java/io/dscope/tools/karavan/McpKaravanMetadataGenerator.java
new file mode 100644
index 0000000..e105fa3
--- /dev/null
+++ b/src/main/java/io/dscope/tools/karavan/McpKaravanMetadataGenerator.java
@@ -0,0 +1,271 @@
+package io.dscope.tools.karavan;
+
+import java.io.File;
+import java.util.Map;
+import java.util.TreeMap;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.SerializationFeature;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+
+/**
+ * Generates Apache Karavan metadata for the Camel MCP component.
+ *
+ * Produces:
+ *
+ * - {@code karavan/metadata/component/mcp.json} — component descriptor
+ * with endpoint properties and method enums
+ * - {@code karavan/metadata/kamelet/mcp-rest-service.json} — REST kamelet descriptor
+ * - {@code karavan/metadata/kamelet/mcp-ws-service.json} — WebSocket kamelet descriptor
+ * - {@code karavan/metadata/model-labels.json} — human-friendly labels
+ *
+ *
+ * Usage
+ *
+ * mvn -Pkaravan-metadata compile exec:java
+ *
+ */
+public class McpKaravanMetadataGenerator {
+
+ private static final String BASE_DIR = "src/main/resources/karavan/metadata";
+ private static final String COMPONENT_DIR = BASE_DIR + "/component";
+ private static final String KAMELET_DIR = BASE_DIR + "/kamelet";
+ private static final String LABELS_FILE = BASE_DIR + "/model-labels.json";
+
+ // MCP JSON-RPC methods supported by the component
+ private static final String[][] MCP_METHODS = {
+ {"initialize", "Core", "Initialize the MCP session and negotiate capabilities."},
+ {"ping", "Core", "Health-check ping; the server replies immediately."},
+ {"tools/list", "Tools", "List all tools the server exposes."},
+ {"tools/call", "Tools", "Invoke a named tool with arguments."},
+ {"resources/list", "Resources","List available resources."},
+ {"resources/read", "Resources","Read the content of a specific resource."},
+ {"resources/get", "Resources","Stream or fetch a resource."},
+ {"health", "Core", "Return overall health/status of the server."},
+ {"stream", "Core", "Open a bidirectional streaming channel."},
+ {"ui/initialize", "UI Bridge","Initialize an MCP Apps Bridge UI session."},
+ {"ui/message", "UI Bridge","Send a message through the UI bridge."},
+ {"ui/update-model-context", "UI Bridge","Push updated model context to the UI."},
+ {"ui/tools/call", "UI Bridge","Call a tool within a UI session."},
+ };
+
+ // Notification methods (no JSON-RPC id)
+ private static final String[][] NOTIFICATION_METHODS = {
+ {"notifications/initialized", "Notifications", "Sent by client after initialize handshake."},
+ {"notifications/cancelled", "Notifications", "Cancel a running operation."},
+ {"notifications/progress", "Notifications", "Report progress for a long-running operation."},
+ };
+
+ public static void main(String[] args) throws Exception {
+ ObjectMapper om = new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT);
+
+ // Ensure output directories exist
+ new File(COMPONENT_DIR).mkdirs();
+ new File(KAMELET_DIR).mkdirs();
+
+ Map allLabels = new TreeMap<>();
+
+ // 1. Generate component descriptor
+ generateComponentDescriptor(om);
+
+ // 2. Generate method catalog metadata
+ generateMethodCatalog(om, allLabels);
+
+ // 3. Generate kamelet descriptors
+ generateKameletDescriptor(om, "mcp-rest-service",
+ "MCP REST Service",
+ "Exposes an MCP-compliant JSON-RPC server over HTTP using Undertow.",
+ false, allLabels);
+ generateKameletDescriptor(om, "mcp-ws-service",
+ "MCP WebSocket Service",
+ "Exposes an MCP-compliant JSON-RPC server over WebSocket using Undertow.",
+ true, allLabels);
+
+ // 4. Write labels file
+ om.writeValue(new File(LABELS_FILE), allLabels);
+ System.out.println("Wrote " + LABELS_FILE + " (" + allLabels.size() + " labels)");
+
+ System.out.println("\nKaravan metadata generation complete.");
+ }
+
+ // ---- component descriptor ------------------------------------------------
+
+ private static void generateComponentDescriptor(ObjectMapper om) throws Exception {
+ ObjectNode root = om.createObjectNode();
+
+ // Component section
+ ObjectNode comp = root.putObject("component");
+ comp.put("kind", "component");
+ comp.put("name", "mcp");
+ comp.put("title", "MCP");
+ comp.put("description",
+ "Model Context Protocol (MCP) component for AI agent integration. "
+ + "Supports JSON-RPC 2.0 over HTTP and WebSocket. "
+ + "Producer sends requests to an MCP server; Consumer exposes an MCP server endpoint.");
+ comp.put("scheme", "mcp");
+ comp.put("syntax", "mcp:uri");
+ comp.put("alternativeSyntax", "mcp:uri?method=initialize");
+ comp.put("firstVersion", "1.0.0");
+ comp.put("groupId", "io.dscope.camel");
+ comp.put("artifactId", "camel-mcp");
+ comp.put("version", "1.3.0");
+ comp.put("producerOnly", false);
+ comp.put("consumerOnly", false);
+ comp.put("lenientProperties", true);
+ ArrayNode labels = comp.putArray("label");
+ labels.add("ai");
+ labels.add("mcp");
+ labels.add("rpc");
+
+ // Properties section
+ ObjectNode props = root.putObject("properties");
+
+ addProperty(props, "uri", "path", true,
+ "string", null,
+ "The target MCP server URI (e.g. http://localhost:8080/mcp).",
+ "common");
+ addProperty(props, "method", "parameter", false,
+ "string", "tools/list",
+ "The MCP JSON-RPC method to invoke.",
+ "producer");
+ addProperty(props, "websocket", "parameter", false,
+ "boolean", "false",
+ "When true the consumer creates a WebSocket endpoint instead of HTTP.",
+ "consumer");
+ addProperty(props, "sendToAll", "parameter", false,
+ "boolean", "false",
+ "For WebSocket consumers, broadcast messages to all connected clients.",
+ "consumer");
+ addProperty(props, "allowedOrigins", "parameter", false,
+ "string", "*",
+ "Comma-separated list of allowed CORS origins.",
+ "consumer");
+ addProperty(props, "httpMethodRestrict", "parameter", false,
+ "string", "POST",
+ "HTTP methods allowed by the consumer endpoint.",
+ "consumer");
+
+ // Add method enum values
+ ArrayNode methodEnums = ((ObjectNode) props.get("method")).putArray("enum");
+ for (String[] m : MCP_METHODS) {
+ methodEnums.add(m[0]);
+ }
+
+ String file = COMPONENT_DIR + "/mcp.json";
+ om.writeValue(new File(file), root);
+ System.out.println("Wrote " + file);
+ }
+
+ private static void addProperty(ObjectNode props, String name, String kind, boolean required,
+ String type, String defaultValue, String description, String label) {
+ ObjectNode p = props.putObject(name);
+ p.put("kind", kind);
+ p.put("displayName", generateLabel(name));
+ p.put("group", label);
+ p.put("label", label);
+ p.put("required", required);
+ p.put("type", type);
+ if (defaultValue != null) {
+ p.put("defaultValue", defaultValue);
+ }
+ p.put("description", description);
+ }
+
+ // ---- method catalog metadata ------------------------------------------
+
+ private static void generateMethodCatalog(ObjectMapper om, Map allLabels) throws Exception {
+ ObjectNode root = om.createObjectNode();
+ root.put("kind", "mcp-methods");
+ root.put("title", "MCP Methods");
+
+ ArrayNode methods = root.putArray("methods");
+ for (String[] m : MCP_METHODS) {
+ ObjectNode method = methods.addObject();
+ method.put("name", m[0]);
+ method.put("group", m[1]);
+ method.put("description", m[2]);
+ method.put("type", "request");
+ allLabels.put("method." + m[0], m[2]);
+ }
+ for (String[] n : NOTIFICATION_METHODS) {
+ ObjectNode method = methods.addObject();
+ method.put("name", n[0]);
+ method.put("group", n[1]);
+ method.put("description", n[2]);
+ method.put("type", "notification");
+ allLabels.put("method." + n[0], n[2]);
+ }
+
+ String file = BASE_DIR + "/mcp-methods.json";
+ om.writeValue(new File(file), root);
+ System.out.println("Wrote " + file + " (" + (MCP_METHODS.length + NOTIFICATION_METHODS.length) + " methods)");
+ }
+
+ // ---- kamelet descriptors -----------------------------------------------
+
+ private static void generateKameletDescriptor(ObjectMapper om, String kameletId, String title,
+ String description, boolean ws,
+ Map allLabels) throws Exception {
+ ObjectNode root = om.createObjectNode();
+ root.put("kind", "kamelet");
+ root.put("name", kameletId);
+ root.put("title", title);
+ root.put("description", description);
+
+ // Kamelet properties extracted from YAML
+ ObjectNode props = root.putObject("properties");
+ addKameletProp(props, "port",
+ ws ? "8090" : "8080",
+ "integer",
+ ws ? "WebSocket listen port" : "HTTP listen port");
+ addKameletProp(props, "host", "0.0.0.0", "string", "Listen address");
+ addKameletProp(props, "path", "/mcp", "string", "Context path for the MCP endpoint");
+
+ ArrayNode labels = root.putArray("labels");
+ labels.add("ai");
+ labels.add("mcp");
+ if (ws) labels.add("websocket");
+
+ // Supported methods
+ ArrayNode supportedMethods = root.putArray("supportedMethods");
+ for (String[] m : MCP_METHODS) {
+ supportedMethods.add(m[0]);
+ }
+
+ allLabels.put("kamelet." + kameletId + ".title", title);
+ allLabels.put("kamelet." + kameletId + ".description", description);
+
+ String file = KAMELET_DIR + "/" + kameletId + ".json";
+ om.writeValue(new File(file), root);
+ System.out.println("Wrote " + file);
+ }
+
+ private static void addKameletProp(ObjectNode props, String name, String defaultValue,
+ String type, String description) {
+ ObjectNode p = props.putObject(name);
+ p.put("title", generateLabel(name));
+ p.put("type", type);
+ p.put("default", defaultValue);
+ p.put("description", description);
+ }
+
+ // ---- helpers ------------------------------------------------------------
+
+ /**
+ * Generate a human-friendly label from a camelCase field name.
+ * e.g. "httpMethodRestrict" → "Http Method Restrict"
+ */
+ private static String generateLabel(String fieldName) {
+ String[] words = fieldName.split("(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])");
+ StringBuilder label = new StringBuilder();
+ for (String word : words) {
+ if (label.length() > 0) label.append(" ");
+ if (!word.isEmpty()) {
+ label.append(Character.toUpperCase(word.charAt(0)));
+ if (word.length() > 1) label.append(word.substring(1));
+ }
+ }
+ return label.toString();
+ }
+}
diff --git a/src/main/resources/kamelets/mcp-rest-service.kamelet.yaml b/src/main/resources/kamelets/mcp-rest-service.kamelet.yaml
index b993b11..fac645a 100644
--- a/src/main/resources/kamelets/mcp-rest-service.kamelet.yaml
+++ b/src/main/resources/kamelets/mcp-rest-service.kamelet.yaml
@@ -57,6 +57,10 @@ spec:
type: io.dscope.camel.mcp.processor.McpToolsListProcessor
- name: mcpResourcesGet
type: io.dscope.camel.mcp.processor.McpResourcesGetProcessor
+ - name: mcpResourcesList
+ type: io.dscope.camel.mcp.processor.McpResourcesListProcessor
+ - name: mcpResourcesRead
+ type: io.dscope.camel.mcp.processor.McpResourcesReadProcessor
- name: mcpStream
type: io.dscope.camel.mcp.processor.McpStreamProcessor
- name: mcpHealthStatus
@@ -67,6 +71,24 @@ spec:
type: io.dscope.camel.mcp.processor.McpNotificationsInitializedProcessor
- name: mcpNotificationAck
type: io.dscope.camel.mcp.processor.McpNotificationAckProcessor
+ - name: mcpError
+ type: io.dscope.camel.mcp.processor.McpErrorProcessor
+ - name: mcpRateLimit
+ type: io.dscope.camel.mcp.processor.McpRateLimitProcessor
+ - name: mcpRequestSizeGuard
+ type: io.dscope.camel.mcp.processor.McpRequestSizeGuardProcessor
+ - name: mcpUiSessionRegistry
+ type: io.dscope.camel.mcp.service.McpUiSessionRegistry
+ - name: mcpUiInitialize
+ type: io.dscope.camel.mcp.processor.McpUiInitializeProcessor
+ - name: mcpUiMessage
+ type: io.dscope.camel.mcp.processor.McpUiMessageProcessor
+ - name: mcpUiUpdateModelContext
+ type: io.dscope.camel.mcp.processor.McpUiUpdateModelContextProcessor
+ - name: mcpUiToolsCall
+ type: io.dscope.camel.mcp.processor.McpUiToolsCallProcessor
+ - name: mcpUiToolsCallPost
+ type: io.dscope.camel.mcp.processor.McpUiToolsCallPostProcessor
from:
uri: "{{restComponent}}:http://{{restHost}}:{{restPort}}{{restContextPath}}?matchOnUriPrefix=true"
steps:
@@ -100,66 +122,145 @@ spec:
# JSON-RPC POST dispatcher: POST /mcp (accept both /mcp and /mcp/)
- simple: "${header.CamelHttpMethod} == 'POST'"
steps:
- - to: "bean:mcpJsonRpcEnvelope"
- - log:
- loggingLevel: DEBUG
- message: "MCP JSON-RPC payload: ${body}"
- - log:
- loggingLevel: INFO
- message: "MCP JSON-RPC request received: ${exchangeProperty[mcp.jsonrpc.method]}"
- - choice:
- when:
- # Notifications branch
- - simple: "${exchangeProperty[mcp.jsonrpc.type]} == 'NOTIFICATION'"
- steps:
- - log:
- loggingLevel: INFO
- message: "MCP Notification received"
- - to: "bean:mcpNotification"
- - choice:
- when:
- - simple: "${exchangeProperty[mcp.notification.type]} == 'initialized'"
- steps:
- - to: "bean:mcpNotificationsInitialized"
- otherwise:
- steps:
- - to: "bean:mcpNotificationAck"
- # Request methods branch
- - simple: "${exchangeProperty[mcp.jsonrpc.method]} == 'initialize'"
+ - doTry:
+ steps:
+ - to: "bean:mcpJsonRpcEnvelope"
+ - log:
+ loggingLevel: DEBUG
+ message: "MCP JSON-RPC payload: ${body}"
+ - log:
+ loggingLevel: INFO
+ message: "MCP JSON-RPC request received: ${exchangeProperty[mcp.jsonrpc.method]}"
+ - choice:
+ when:
+ # Notifications branch
+ - simple: "${exchangeProperty[mcp.jsonrpc.type]} == 'NOTIFICATION'"
+ steps:
+ - log:
+ loggingLevel: INFO
+ message: "MCP Notification received"
+ - to: "bean:mcpNotification"
+ - choice:
+ when:
+ - simple: "${exchangeProperty[mcp.notification.type]} == 'initialized'"
+ steps:
+ - to: "bean:mcpNotificationsInitialized"
+ otherwise:
+ steps:
+ - to: "bean:mcpNotificationAck"
+ # Request methods branch
+ - simple: "${exchangeProperty[mcp.jsonrpc.method]} == 'initialize'"
+ steps:
+ - log:
+ loggingLevel: INFO
+ message: "MCP Initialize request received"
+ - to: mcpInitialize
+ - simple: "${exchangeProperty[mcp.jsonrpc.method]} == 'ping'"
+ steps:
+ - log:
+ loggingLevel: INFO
+ message: "MCP Ping request received"
+ - to: mcpPing
+ - simple: "${exchangeProperty[mcp.jsonrpc.method]} == 'tools/list'"
+ steps:
+ - log:
+ loggingLevel: INFO
+ message: "MCP Tools List request received"
+ - to: mcpToolsList
+ - simple: "${exchangeProperty[mcp.jsonrpc.method]} == 'tools/call'"
+ steps:
+ - log:
+ loggingLevel: INFO
+ message: "MCP Tools Call request for bean {{toolsCallBean}} received"
+ - to: "bean:{{toolsCallBean}}"
+ - simple: "${exchangeProperty[mcp.jsonrpc.method]} == 'resources/list'"
+ steps:
+ - log:
+ loggingLevel: INFO
+ message: "MCP Resources List request received"
+ - to: mcpResourcesList
+ - simple: "${exchangeProperty[mcp.jsonrpc.method]} == 'resources/read'"
+ steps:
+ - log:
+ loggingLevel: INFO
+ message: "MCP Resources Read request received"
+ - to: mcpResourcesRead
+ - simple: "${exchangeProperty[mcp.jsonrpc.method]} == 'resources/get'"
+ steps:
+ - log:
+ loggingLevel: INFO
+ message: "MCP Resources Get request received"
+ - to: "bean:{{resourcesGetBean}}"
+ # MCP Apps Bridge (ui/*) methods
+ - simple: "${exchangeProperty[mcp.jsonrpc.method]} == 'ui/initialize'"
+ steps:
+ - log:
+ loggingLevel: INFO
+ message: "MCP UI Initialize request received"
+ - to: "bean:mcpUiInitialize"
+ - simple: "${exchangeProperty[mcp.jsonrpc.method]} == 'ui/message'"
+ steps:
+ - log:
+ loggingLevel: INFO
+ message: "MCP UI Message request received"
+ - to: "bean:mcpUiMessage"
+ - simple: "${exchangeProperty[mcp.jsonrpc.method]} == 'ui/update-model-context'"
+ steps:
+ - log:
+ loggingLevel: INFO
+ message: "MCP UI Update Model Context request received"
+ - to: "bean:mcpUiUpdateModelContext"
+ - simple: "${exchangeProperty[mcp.jsonrpc.method]} == 'ui/tools/call'"
+ steps:
+ - log:
+ loggingLevel: INFO
+ message: "MCP UI Tools Call request received"
+ - doTry:
+ steps:
+ - to: "bean:mcpUiToolsCall"
+ - to: "bean:{{toolsCallBean}}"
+ - to: "bean:mcpUiToolsCallPost"
+ doCatch:
+ - exception:
+ - java.lang.Exception
+ steps:
+ - to: "bean:mcpUiToolsCallPost"
+ - setProperty:
+ name: mcp.error.code
+ constant: "-32603"
+ - setProperty:
+ name: mcp.error.message
+ simple: "UI tool execution failed: ${exception.message}"
+ - process:
+ ref: mcpError
+ otherwise:
+ steps:
+ - log:
+ loggingLevel: WARN
+ message: "Unsupported MCP method: ${exchangeProperty[mcp.jsonrpc.method]}"
+ - setProperty:
+ name: mcp.error.code
+ constant: "-32601"
+ - setProperty:
+ name: mcp.error.message
+ simple: "Unsupported MCP method: ${exchangeProperty[mcp.jsonrpc.method]}"
+ - process:
+ ref: mcpError
+ doCatch:
+ - exception:
+ - java.lang.IllegalArgumentException
steps:
- - log:
- loggingLevel: INFO
- message: "MCP Initialize request received"
- - to: mcpInitialize
- - simple: "${exchangeProperty[mcp.jsonrpc.method]} == 'ping'"
- steps:
- - log:
- loggingLevel: INFO
- message: "MCP Ping request received"
- - to: mcpPing
- - simple: "${exchangeProperty[mcp.jsonrpc.method]} == 'tools/list'"
- steps:
- - log:
- loggingLevel: INFO
- message: "MCP Tools List request received"
- - to: mcpToolsList
- - simple: "${exchangeProperty[mcp.jsonrpc.method]} == 'tools/call'"
- steps:
- - log:
- loggingLevel: INFO
- message: "MCP Tools Call request for bean {{toolsCallBean}} received"
- - to: "bean:{{toolsCallBean}}"
- - simple: "${exchangeProperty[mcp.jsonrpc.method]} == 'resources/get'"
- steps:
- - log:
- loggingLevel: INFO
- message: "MCP Resources Get request received"
- - to: "bean:{{resourcesGetBean}}"
- otherwise:
- steps:
- - log:
- loggingLevel: WARN
- message: "Unsupported MCP method: ${exchangeProperty[mcp.jsonrpc.method]}"
+ - setHeader:
+ name: CamelHttpResponseCode
+ constant: "400"
+ - setProperty:
+ name: mcp.error.code
+ constant: "-32600"
+ - setProperty:
+ name: mcp.error.message
+ simple: "${exception.message}"
+ - process:
+ ref: mcpError
- choice:
when:
- simple: "${body} != null"
diff --git a/src/main/resources/kamelets/mcp-ws-service.kamelet.yaml b/src/main/resources/kamelets/mcp-ws-service.kamelet.yaml
index d578994..2e1ac71 100644
--- a/src/main/resources/kamelets/mcp-ws-service.kamelet.yaml
+++ b/src/main/resources/kamelets/mcp-ws-service.kamelet.yaml
@@ -58,6 +58,10 @@ spec:
type: io.dscope.camel.mcp.processor.McpToolsListProcessor
- name: mcpResourcesGet
type: io.dscope.camel.mcp.processor.McpResourcesGetProcessor
+ - name: mcpResourcesList
+ type: io.dscope.camel.mcp.processor.McpResourcesListProcessor
+ - name: mcpResourcesRead
+ type: io.dscope.camel.mcp.processor.McpResourcesReadProcessor
- name: mcpStream
type: io.dscope.camel.mcp.processor.McpStreamProcessor
- name: mcpHealthStatus
@@ -74,49 +78,129 @@ spec:
type: io.dscope.camel.mcp.processor.McpRateLimitProcessor
- name: mcpRequestSizeGuard
type: io.dscope.camel.mcp.processor.McpRequestSizeGuardProcessor
+ - name: mcpUiSessionRegistry
+ type: io.dscope.camel.mcp.service.McpUiSessionRegistry
+ - name: mcpUiInitialize
+ type: io.dscope.camel.mcp.processor.McpUiInitializeProcessor
+ - name: mcpUiMessage
+ type: io.dscope.camel.mcp.processor.McpUiMessageProcessor
+ - name: mcpUiUpdateModelContext
+ type: io.dscope.camel.mcp.processor.McpUiUpdateModelContextProcessor
+ - name: mcpUiToolsCall
+ type: io.dscope.camel.mcp.processor.McpUiToolsCallProcessor
+ - name: mcpUiToolsCallPost
+ type: io.dscope.camel.mcp.processor.McpUiToolsCallPostProcessor
from:
uri: "{{wsComponent}}:ws://{{wsHost}}:{{wsPort}}{{wsPath}}?sendToAll=false&allowedOrigins=*&exchangePattern=InOut"
steps:
- process: { ref: mcpRequestSizeGuard }
- process: { ref: mcpRateLimit }
- - to: "bean:mcpJsonRpcEnvelope"
- - log:
- loggingLevel: DEBUG
- message: "WebSocket received: ${body}"
- - choice:
- when:
- - simple: "${exchangeProperty.mcp.jsonrpc.type} == 'NOTIFICATION'"
- steps:
- - to: "bean:mcpNotification"
- - log:
- loggingLevel: INFO
- message: "Notification: ${exchangeProperty[mcp.notification.type]}"
- - stop: {}
- - simple: "${exchangeProperty[mcp.jsonrpc.method]} == 'initialize'"
- steps:
- - to: mcpInitialize
- - simple: "${exchangeProperty[mcp.jsonrpc.method]} == 'ping'"
- steps:
- - to: mcpPing
- - simple: "${exchangeProperty[mcp.jsonrpc.method]} == 'tools/list'"
- steps:
- - to: mcpToolsList
- - simple: "${exchangeProperty[mcp.jsonrpc.method]} == 'tools/call'"
- steps:
- - to: "bean:{{toolsCallBean}}"
- - simple: "${exchangeProperty[mcp.jsonrpc.method]} == 'resources/get'"
+ - doTry:
+ steps:
+ - to: "bean:mcpJsonRpcEnvelope"
+ - log:
+ loggingLevel: DEBUG
+ message: "WebSocket received: ${body}"
+ - choice:
+ when:
+ - simple: "${exchangeProperty.mcp.jsonrpc.type} == 'NOTIFICATION'"
+ steps:
+ - to: "bean:mcpNotification"
+ - log:
+ loggingLevel: INFO
+ message: "Notification: ${exchangeProperty[mcp.notification.type]}"
+ - stop: {}
+ - simple: "${exchangeProperty[mcp.jsonrpc.method]} == 'initialize'"
+ steps:
+ - to: mcpInitialize
+ - simple: "${exchangeProperty[mcp.jsonrpc.method]} == 'ping'"
+ steps:
+ - to: mcpPing
+ - simple: "${exchangeProperty[mcp.jsonrpc.method]} == 'tools/list'"
+ steps:
+ - to: mcpToolsList
+ - simple: "${exchangeProperty[mcp.jsonrpc.method]} == 'tools/call'"
+ steps:
+ - to: "bean:{{toolsCallBean}}"
+ - simple: "${exchangeProperty[mcp.jsonrpc.method]} == 'resources/list'"
+ steps:
+ - log:
+ loggingLevel: INFO
+ message: "MCP Resources List request received"
+ - to: mcpResourcesList
+ - simple: "${exchangeProperty[mcp.jsonrpc.method]} == 'resources/read'"
+ steps:
+ - log:
+ loggingLevel: INFO
+ message: "MCP Resources Read request received"
+ - to: mcpResourcesRead
+ - simple: "${exchangeProperty[mcp.jsonrpc.method]} == 'resources/get'"
+ steps:
+ - log:
+ loggingLevel: INFO
+ message: "MCP Resources Get request received"
+ - to: "bean:{{resourcesGetBean}}"
+ # MCP Apps Bridge (ui/*) methods
+ - simple: "${exchangeProperty[mcp.jsonrpc.method]} == 'ui/initialize'"
+ steps:
+ - log:
+ loggingLevel: INFO
+ message: "MCP UI Initialize request received"
+ - to: "bean:mcpUiInitialize"
+ - simple: "${exchangeProperty[mcp.jsonrpc.method]} == 'ui/message'"
+ steps:
+ - log:
+ loggingLevel: INFO
+ message: "MCP UI Message request received"
+ - to: "bean:mcpUiMessage"
+ - simple: "${exchangeProperty[mcp.jsonrpc.method]} == 'ui/update-model-context'"
+ steps:
+ - log:
+ loggingLevel: INFO
+ message: "MCP UI Update Model Context request received"
+ - to: "bean:mcpUiUpdateModelContext"
+ - simple: "${exchangeProperty[mcp.jsonrpc.method]} == 'ui/tools/call'"
+ steps:
+ - log:
+ loggingLevel: INFO
+ message: "MCP UI Tools Call request received"
+ - doTry:
+ steps:
+ - to: "bean:mcpUiToolsCall"
+ - to: "bean:{{toolsCallBean}}"
+ - to: "bean:mcpUiToolsCallPost"
+ doCatch:
+ - exception:
+ - java.lang.Exception
+ steps:
+ - to: "bean:mcpUiToolsCallPost"
+ - setProperty:
+ name: mcp.error.code
+ constant: "-32603"
+ - setProperty:
+ name: mcp.error.message
+ simple: "UI tool execution failed: ${exception.message}"
+ - process:
+ ref: mcpError
+ otherwise:
+ steps:
+ - setProperty: { name: mcp.error.code, constant: "-32601" }
+ - setProperty:
+ name: mcp.error.message
+ simple: "Unsupported MCP method: ${exchangeProperty[mcp.jsonrpc.method]}"
+ - process: { ref: mcpError }
+ doCatch:
+ - exception:
+ - java.lang.IllegalArgumentException
steps:
- - log:
- loggingLevel: INFO
- message: "MCP Resources Get request received"
- - to: "bean:{{resourcesGetBean}}"
- otherwise:
- steps:
- - setProperty: { name: mcp.error.code, constant: "-32601" }
- - setProperty:
- name: mcp.error.message
- simple: "Unsupported MCP method: ${exchangeProperty[mcp.jsonrpc.method]}"
- - process: { ref: mcpError }
+ - setProperty:
+ name: mcp.error.code
+ constant: "-32600"
+ - setProperty:
+ name: mcp.error.message
+ simple: "${exception.message}"
+ - process:
+ ref: mcpError
- choice:
when:
- simple: "${header.Content-Type} == null"
diff --git a/src/main/resources/karavan/metadata/component/mcp.json b/src/main/resources/karavan/metadata/component/mcp.json
new file mode 100644
index 0000000..00ea1c5
--- /dev/null
+++ b/src/main/resources/karavan/metadata/component/mcp.json
@@ -0,0 +1,81 @@
+{
+ "component" : {
+ "kind" : "component",
+ "name" : "mcp",
+ "title" : "MCP",
+ "description" : "Model Context Protocol (MCP) component for AI agent integration. Supports JSON-RPC 2.0 over HTTP and WebSocket. Producer sends requests to an MCP server; Consumer exposes an MCP server endpoint.",
+ "scheme" : "mcp",
+ "syntax" : "mcp:uri",
+ "alternativeSyntax" : "mcp:uri?method=initialize",
+ "firstVersion" : "1.0.0",
+ "groupId" : "io.dscope.camel",
+ "artifactId" : "camel-mcp",
+ "version" : "1.3.0",
+ "producerOnly" : false,
+ "consumerOnly" : false,
+ "lenientProperties" : true,
+ "label" : [ "ai", "mcp", "rpc" ]
+ },
+ "properties" : {
+ "uri" : {
+ "kind" : "path",
+ "displayName" : "Uri",
+ "group" : "common",
+ "label" : "common",
+ "required" : true,
+ "type" : "string",
+ "description" : "The target MCP server URI (e.g. http://localhost:8080/mcp)."
+ },
+ "method" : {
+ "kind" : "parameter",
+ "displayName" : "Method",
+ "group" : "producer",
+ "label" : "producer",
+ "required" : false,
+ "type" : "string",
+ "defaultValue" : "tools/list",
+ "description" : "The MCP JSON-RPC method to invoke.",
+ "enum" : [ "initialize", "ping", "tools/list", "tools/call", "resources/list", "resources/read", "resources/get", "health", "stream", "ui/initialize", "ui/message", "ui/update-model-context", "ui/tools/call" ]
+ },
+ "websocket" : {
+ "kind" : "parameter",
+ "displayName" : "Websocket",
+ "group" : "consumer",
+ "label" : "consumer",
+ "required" : false,
+ "type" : "boolean",
+ "defaultValue" : "false",
+ "description" : "When true the consumer creates a WebSocket endpoint instead of HTTP."
+ },
+ "sendToAll" : {
+ "kind" : "parameter",
+ "displayName" : "Send To All",
+ "group" : "consumer",
+ "label" : "consumer",
+ "required" : false,
+ "type" : "boolean",
+ "defaultValue" : "false",
+ "description" : "For WebSocket consumers, broadcast messages to all connected clients."
+ },
+ "allowedOrigins" : {
+ "kind" : "parameter",
+ "displayName" : "Allowed Origins",
+ "group" : "consumer",
+ "label" : "consumer",
+ "required" : false,
+ "type" : "string",
+ "defaultValue" : "*",
+ "description" : "Comma-separated list of allowed CORS origins."
+ },
+ "httpMethodRestrict" : {
+ "kind" : "parameter",
+ "displayName" : "Http Method Restrict",
+ "group" : "consumer",
+ "label" : "consumer",
+ "required" : false,
+ "type" : "string",
+ "defaultValue" : "POST",
+ "description" : "HTTP methods allowed by the consumer endpoint."
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/resources/karavan/metadata/kamelet/mcp-rest-service.json b/src/main/resources/karavan/metadata/kamelet/mcp-rest-service.json
new file mode 100644
index 0000000..45f1e28
--- /dev/null
+++ b/src/main/resources/karavan/metadata/kamelet/mcp-rest-service.json
@@ -0,0 +1,28 @@
+{
+ "kind" : "kamelet",
+ "name" : "mcp-rest-service",
+ "title" : "MCP REST Service",
+ "description" : "Exposes an MCP-compliant JSON-RPC server over HTTP using Undertow.",
+ "properties" : {
+ "port" : {
+ "title" : "Port",
+ "type" : "integer",
+ "default" : "8080",
+ "description" : "HTTP listen port"
+ },
+ "host" : {
+ "title" : "Host",
+ "type" : "string",
+ "default" : "0.0.0.0",
+ "description" : "Listen address"
+ },
+ "path" : {
+ "title" : "Path",
+ "type" : "string",
+ "default" : "/mcp",
+ "description" : "Context path for the MCP endpoint"
+ }
+ },
+ "labels" : [ "ai", "mcp" ],
+ "supportedMethods" : [ "initialize", "ping", "tools/list", "tools/call", "resources/list", "resources/read", "resources/get", "health", "stream", "ui/initialize", "ui/message", "ui/update-model-context", "ui/tools/call" ]
+}
\ No newline at end of file
diff --git a/src/main/resources/karavan/metadata/kamelet/mcp-ws-service.json b/src/main/resources/karavan/metadata/kamelet/mcp-ws-service.json
new file mode 100644
index 0000000..6cb499d
--- /dev/null
+++ b/src/main/resources/karavan/metadata/kamelet/mcp-ws-service.json
@@ -0,0 +1,28 @@
+{
+ "kind" : "kamelet",
+ "name" : "mcp-ws-service",
+ "title" : "MCP WebSocket Service",
+ "description" : "Exposes an MCP-compliant JSON-RPC server over WebSocket using Undertow.",
+ "properties" : {
+ "port" : {
+ "title" : "Port",
+ "type" : "integer",
+ "default" : "8090",
+ "description" : "WebSocket listen port"
+ },
+ "host" : {
+ "title" : "Host",
+ "type" : "string",
+ "default" : "0.0.0.0",
+ "description" : "Listen address"
+ },
+ "path" : {
+ "title" : "Path",
+ "type" : "string",
+ "default" : "/mcp",
+ "description" : "Context path for the MCP endpoint"
+ }
+ },
+ "labels" : [ "ai", "mcp", "websocket" ],
+ "supportedMethods" : [ "initialize", "ping", "tools/list", "tools/call", "resources/list", "resources/read", "resources/get", "health", "stream", "ui/initialize", "ui/message", "ui/update-model-context", "ui/tools/call" ]
+}
\ No newline at end of file
diff --git a/src/main/resources/karavan/metadata/mcp-methods.json b/src/main/resources/karavan/metadata/mcp-methods.json
new file mode 100644
index 0000000..e7db167
--- /dev/null
+++ b/src/main/resources/karavan/metadata/mcp-methods.json
@@ -0,0 +1,85 @@
+{
+ "kind" : "mcp-methods",
+ "title" : "MCP Methods",
+ "methods" : [ {
+ "name" : "initialize",
+ "group" : "Core",
+ "description" : "Initialize the MCP session and negotiate capabilities.",
+ "type" : "request"
+ }, {
+ "name" : "ping",
+ "group" : "Core",
+ "description" : "Health-check ping; the server replies immediately.",
+ "type" : "request"
+ }, {
+ "name" : "tools/list",
+ "group" : "Tools",
+ "description" : "List all tools the server exposes.",
+ "type" : "request"
+ }, {
+ "name" : "tools/call",
+ "group" : "Tools",
+ "description" : "Invoke a named tool with arguments.",
+ "type" : "request"
+ }, {
+ "name" : "resources/list",
+ "group" : "Resources",
+ "description" : "List available resources.",
+ "type" : "request"
+ }, {
+ "name" : "resources/read",
+ "group" : "Resources",
+ "description" : "Read the content of a specific resource.",
+ "type" : "request"
+ }, {
+ "name" : "resources/get",
+ "group" : "Resources",
+ "description" : "Stream or fetch a resource.",
+ "type" : "request"
+ }, {
+ "name" : "health",
+ "group" : "Core",
+ "description" : "Return overall health/status of the server.",
+ "type" : "request"
+ }, {
+ "name" : "stream",
+ "group" : "Core",
+ "description" : "Open a bidirectional streaming channel.",
+ "type" : "request"
+ }, {
+ "name" : "ui/initialize",
+ "group" : "UI Bridge",
+ "description" : "Initialize an MCP Apps Bridge UI session.",
+ "type" : "request"
+ }, {
+ "name" : "ui/message",
+ "group" : "UI Bridge",
+ "description" : "Send a message through the UI bridge.",
+ "type" : "request"
+ }, {
+ "name" : "ui/update-model-context",
+ "group" : "UI Bridge",
+ "description" : "Push updated model context to the UI.",
+ "type" : "request"
+ }, {
+ "name" : "ui/tools/call",
+ "group" : "UI Bridge",
+ "description" : "Call a tool within a UI session.",
+ "type" : "request"
+ }, {
+ "name" : "notifications/initialized",
+ "group" : "Notifications",
+ "description" : "Sent by client after initialize handshake.",
+ "type" : "notification"
+ }, {
+ "name" : "notifications/cancelled",
+ "group" : "Notifications",
+ "description" : "Cancel a running operation.",
+ "type" : "notification"
+ }, {
+ "name" : "notifications/progress",
+ "group" : "Notifications",
+ "description" : "Report progress for a long-running operation.",
+ "type" : "notification"
+ } ]
+}
\ No newline at end of file
diff --git a/src/main/resources/karavan/metadata/model-labels.json b/src/main/resources/karavan/metadata/model-labels.json
new file mode 100644
index 0000000..39065e5
--- /dev/null
+++ b/src/main/resources/karavan/metadata/model-labels.json
@@ -0,0 +1,22 @@
+{
+ "kamelet.mcp-rest-service.description" : "Exposes an MCP-compliant JSON-RPC server over HTTP using Undertow.",
+ "kamelet.mcp-rest-service.title" : "MCP REST Service",
+ "kamelet.mcp-ws-service.description" : "Exposes an MCP-compliant JSON-RPC server over WebSocket using Undertow.",
+ "kamelet.mcp-ws-service.title" : "MCP WebSocket Service",
+ "method.health" : "Return overall health/status of the server.",
+ "method.initialize" : "Initialize the MCP session and negotiate capabilities.",
+ "method.notifications/cancelled" : "Cancel a running operation.",
+ "method.notifications/initialized" : "Sent by client after initialize handshake.",
+ "method.notifications/progress" : "Report progress for a long-running operation.",
+ "method.ping" : "Health-check ping; the server replies immediately.",
+ "method.resources/get" : "Stream or fetch a resource.",
+ "method.resources/list" : "List available resources.",
+ "method.resources/read" : "Read the content of a specific resource.",
+ "method.stream" : "Open a bidirectional streaming channel.",
+ "method.tools/call" : "Invoke a named tool with arguments.",
+ "method.tools/list" : "List all tools the server exposes.",
+ "method.ui/initialize" : "Initialize an MCP Apps Bridge UI session.",
+ "method.ui/message" : "Send a message through the UI bridge.",
+ "method.ui/tools/call" : "Call a tool within a UI session.",
+ "method.ui/update-model-context" : "Push updated model context to the UI."
+}
\ No newline at end of file
diff --git a/src/test/java/io/dscope/camel/mcp/McpComponentTest.java b/src/test/java/io/dscope/camel/mcp/McpComponentTest.java
index 3abcf30..46acd14 100644
--- a/src/test/java/io/dscope/camel/mcp/McpComponentTest.java
+++ b/src/test/java/io/dscope/camel/mcp/McpComponentTest.java
@@ -3,10 +3,9 @@
import org.apache.camel.CamelContext;
import org.apache.camel.ProducerTemplate;
import org.apache.camel.main.Main;
+import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
-import static org.junit.jupiter.api.Assertions.*;
-
public class McpComponentTest {
@Test
@@ -17,7 +16,7 @@ public void testInitializeResponse() throws Exception {
CamelContext context = main.getCamelContext();
ProducerTemplate template = context.createProducerTemplate();
- String response = template.requestBody("http://localhost:8080/mcp",
+ String response = template.requestBody("http://localhost:18080/mcp",
"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}",
String.class);
diff --git a/src/test/java/io/dscope/camel/mcp/McpConsumerTest.java b/src/test/java/io/dscope/camel/mcp/McpConsumerTest.java
new file mode 100644
index 0000000..5b2ef84
--- /dev/null
+++ b/src/test/java/io/dscope/camel/mcp/McpConsumerTest.java
@@ -0,0 +1,179 @@
+package io.dscope.camel.mcp;
+
+import org.apache.camel.CamelContext;
+import org.apache.camel.Exchange;
+import org.apache.camel.Processor;
+import org.apache.camel.ProducerTemplate;
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.impl.DefaultCamelContext;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Tests for McpConsumer functionality including HTTP and WebSocket endpoints.
+ */
+class McpConsumerTest {
+
+ private CamelContext context;
+ private ProducerTemplate template;
+
+ @BeforeEach
+ void setUp() throws Exception {
+ context = new DefaultCamelContext();
+ template = context.createProducerTemplate();
+ }
+
+ @AfterEach
+ void tearDown() throws Exception {
+ if (context != null) {
+ context.stop();
+ }
+ }
+
+ @Test
+ void testHttpConsumerStartsAndResponds() throws Exception {
+ // Setup a simple echo processor
+ context.addRoutes(new RouteBuilder() {
+ @Override
+ public void configure() {
+ from("mcp:http://localhost:9876/test")
+ .process(exchange -> {
+ // Simple echo - just set a response
+ Map response = Map.of(
+ "jsonrpc", "2.0",
+ "id", exchange.getProperty("mcp.jsonrpc.id", String.class),
+ "result", Map.of("echo", "pong")
+ );
+ exchange.getMessage().setBody(response);
+ });
+ }
+ });
+
+ context.start();
+
+ // Give the consumer time to start
+ TimeUnit.MILLISECONDS.sleep(500);
+
+ // Send a ping request with proper MCP headers
+ String request = """
+ {
+ "jsonrpc": "2.0",
+ "id": "test-1",
+ "method": "ping"
+ }
+ """;
+
+ String response = template.requestBodyAndHeaders(
+ "http://localhost:9876/test",
+ request,
+ Map.of(
+ "Content-Type", "application/json",
+ "Accept", "application/json, text/event-stream"
+ ),
+ String.class
+ );
+
+ assertNotNull(response);
+ assertTrue(response.contains("\"jsonrpc\":\"2.0\"") || response.contains("\"jsonrpc\": \"2.0\""),
+ "Response should contain JSON-RPC 2.0 but was: " + response);
+ }
+
+ @Test
+ void testWebSocketConsumerConfiguration() throws Exception {
+ // Test that WebSocket consumer can be configured
+ context.addRoutes(new RouteBuilder() {
+ @Override
+ public void configure() {
+ from("mcp:http://localhost:9877/ws?websocket=true")
+ .process(exchange -> {
+ Map response = Map.of(
+ "jsonrpc", "2.0",
+ "result", Map.of("status", "ok")
+ );
+ exchange.getMessage().setBody(response);
+ });
+ }
+ });
+
+ context.start();
+
+ // Just verify the route starts without errors
+ TimeUnit.MILLISECONDS.sleep(500);
+
+ // Verify the context started successfully
+ assertTrue(context.getStatus().isStarted());
+ assertEquals(1, context.getRoutes().size());
+ }
+
+ @Test
+ void testConsumerWithJsonRpcParsing() throws Exception {
+ // Test that JSON-RPC envelope is properly parsed
+ final String[] capturedMethod = new String[1];
+
+ context.addRoutes(new RouteBuilder() {
+ @Override
+ public void configure() {
+ from("mcp:http://localhost:9878/mcp")
+ .process(exchange -> {
+ // Capture the parsed method
+ capturedMethod[0] = exchange.getProperty("mcp.jsonrpc.method", String.class);
+
+ Map response = Map.of(
+ "jsonrpc", "2.0",
+ "id", exchange.getProperty("mcp.jsonrpc.id", String.class),
+ "result", Map.of()
+ );
+ exchange.getMessage().setBody(response);
+ });
+ }
+ });
+
+ context.start();
+ TimeUnit.MILLISECONDS.sleep(500);
+
+ String request = """
+ {
+ "jsonrpc": "2.0",
+ "id": "test-2",
+ "method": "tools/list"
+ }
+ """;
+
+ template.requestBodyAndHeaders(
+ "http://localhost:9878/mcp",
+ request,
+ Map.of(
+ "Content-Type", "application/json",
+ "Accept", "application/json, text/event-stream"
+ ),
+ String.class
+ );
+
+ assertEquals("tools/list", capturedMethod[0]);
+ }
+
+ @Test
+ void testConsumerStopsCleanly() throws Exception {
+ context.addRoutes(new RouteBuilder() {
+ @Override
+ public void configure() {
+ from("mcp:http://localhost:9879/mcp")
+ .process(exchange -> {
+ // No-op processor
+ });
+ }
+ });
+
+ context.start();
+ TimeUnit.MILLISECONDS.sleep(300);
+
+ // Stop should not throw
+ assertDoesNotThrow(() -> context.stop());
+ }
+}
diff --git a/src/test/java/io/dscope/camel/mcp/processor/McpUiInitializeProcessorTest.java b/src/test/java/io/dscope/camel/mcp/processor/McpUiInitializeProcessorTest.java
index 8d15378..b34dae3 100644
--- a/src/test/java/io/dscope/camel/mcp/processor/McpUiInitializeProcessorTest.java
+++ b/src/test/java/io/dscope/camel/mcp/processor/McpUiInitializeProcessorTest.java
@@ -62,7 +62,7 @@ void shouldCreateSessionOnInitialize() throws Exception {
Map hostInfo = (Map) result.get("hostInfo");
assertNotNull(hostInfo);
assertEquals("camel-mcp", hostInfo.get("name"));
- assertEquals("1.2.0", hostInfo.get("version"));
+ assertEquals("1.3.0", hostInfo.get("version"));
// Verify capabilities are returned
assertNotNull(result.get("capabilities"));
diff --git a/src/test/java/io/dscope/camel/mcp/processor/McpUiMessageProcessorTest.java b/src/test/java/io/dscope/camel/mcp/processor/McpUiMessageProcessorTest.java
index 79cae8d..5b0baf2 100644
--- a/src/test/java/io/dscope/camel/mcp/processor/McpUiMessageProcessorTest.java
+++ b/src/test/java/io/dscope/camel/mcp/processor/McpUiMessageProcessorTest.java
@@ -105,6 +105,47 @@ void shouldRejectInvalidSession() throws Exception {
}
}
+ @Test
+ void shouldAcknowledgeStructuredMessage() throws Exception {
+ McpUiSessionRegistry registry = new McpUiSessionRegistry();
+ McpUiSession session = registry.register("ui://test.com/app");
+ McpUiMessageProcessor processor = new McpUiMessageProcessor(registry);
+
+ try (DefaultCamelContext ctx = new DefaultCamelContext()) {
+ Exchange exchange = new DefaultExchange(ctx);
+ exchange.setProperty(McpJsonRpcEnvelopeProcessor.EXCHANGE_PROPERTY_ID, "msg-map");
+ exchange.setProperty(McpHttpValidatorProcessor.EXCHANGE_PROTOCOL_VERSION, "2025-06-18");
+ exchange.setProperty(McpUiInitializeProcessor.EXCHANGE_PROPERTY_UI_SESSION_ID, session.getSessionId());
+
+ Map messageObj = new LinkedHashMap<>();
+ messageObj.put("role", "user");
+ messageObj.put("content", "Hello from app");
+
+ Map params = new LinkedHashMap<>();
+ params.put("message", messageObj);
+ params.put("type", "chat");
+ exchange.getIn().setBody(params);
+
+ processor.process(exchange);
+
+ String body = exchange.getIn().getBody(String.class);
+ assertNotNull(body);
+
+ @SuppressWarnings("unchecked")
+ Map envelope = MAPPER.readValue(body, Map.class);
+
+ @SuppressWarnings("unchecked")
+ Map result = (Map) envelope.get("result");
+ assertNotNull(result);
+ assertEquals(true, result.get("acknowledged"));
+
+ // Verify structured message was stored on exchange
+ Object storedMessage = exchange.getProperty(McpUiMessageProcessor.EXCHANGE_PROPERTY_UI_MESSAGE);
+ assertNotNull(storedMessage);
+ assertEquals(messageObj, storedMessage);
+ }
+ }
+
@Test
void shouldAcceptSessionIdFromHeader() throws Exception {
McpUiSessionRegistry registry = new McpUiSessionRegistry();
@@ -117,7 +158,7 @@ void shouldAcceptSessionIdFromHeader() throws Exception {
exchange.setProperty(McpHttpValidatorProcessor.EXCHANGE_PROTOCOL_VERSION, "2025-06-18");
// Set session ID via header instead of property
exchange.getIn().setHeader("X-MCP-Session-Id", session.getSessionId());
-
+
Map params = new LinkedHashMap<>();
params.put("message", "Hello via header");
exchange.getIn().setBody(params);
diff --git a/src/test/resources/routes/example-mcp.yaml b/src/test/resources/routes/example-mcp.yaml
index 2f30b75..881c2ac 100644
--- a/src/test/resources/routes/example-mcp.yaml
+++ b/src/test/resources/routes/example-mcp.yaml
@@ -23,6 +23,6 @@
json:
library: Jackson
- to:
- uri: "mcp:http://localhost:8080/mcp?method=initialize"
+ uri: "mcp:http://localhost:18080/mcp?method=initialize"
- log:
message: "MCP Response: ${body}"
diff --git a/src/test/resources/routes/mock-mcp-server.yaml b/src/test/resources/routes/mock-mcp-server.yaml
index dca459f..8f441ff 100644
--- a/src/test/resources/routes/mock-mcp-server.yaml
+++ b/src/test/resources/routes/mock-mcp-server.yaml
@@ -2,7 +2,7 @@
- route:
id: mock-mcp-server
from:
- uri: "undertow:http://0.0.0.0:8080/mcp?httpMethodRestrict=POST"
+ uri: "undertow:http://0.0.0.0:18080/mcp?httpMethodRestrict=POST"
steps:
- unmarshal:
json: