Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
package com.predic8.membrane.core.interceptor.mcp;

import com.predic8.membrane.core.exchange.AbstractExchange;
import com.predic8.membrane.core.exchange.Exchange;
import com.predic8.membrane.core.proxies.Proxy;
import org.jetbrains.annotations.Nullable;

import java.net.URI;
import java.net.URISyntaxException;
import java.util.regex.PatternSyntaxException;

import static com.predic8.membrane.core.util.HttpUtil.isAbsoluteURI;

final class ExchangeUtils {

private ExchangeUtils() {
}

static boolean matchesExchangeFilter(AbstractExchange exchange, @Nullable String host, @Nullable Integer port, @Nullable String pathPattern) {
return matchesHost(exchange, host)
&& matchesPort(exchange, port)
&& matchesPathPattern(exchange, pathPattern);
}

private static boolean matchesHost(AbstractExchange exchange, @Nullable String host) {
if (host == null) {
return true;
}

String requestHost = getRequestHost(exchange);
return requestHost != null && requestHost.equalsIgnoreCase(host);
}

private static boolean matchesPort(AbstractExchange exchange, @Nullable Integer port) {
if (port == null) {
return true;
}

Integer requestPort = getRequestPort(exchange);
return requestPort != null && requestPort.equals(port);
}

private static boolean matchesPathPattern(AbstractExchange exchange, @Nullable String pathPattern) {
if (pathPattern == null) {
return true;
}

String requestPath = getRequestPath(exchange);
return requestPath != null && (requestPath.startsWith(pathPattern) || matchesRegex(requestPath, pathPattern)); //TODO keep 'startsWith'?
}

private static boolean matchesRegex(String requestPath, String pathPattern) {
try {
return requestPath.matches(pathPattern);
} catch (PatternSyntaxException ignored) {
return false;
}
}

private static @Nullable String getRequestHost(AbstractExchange exchange) {
URI authority = getRequestAuthority(exchange);
return authority != null ? authority.getHost() : null;
}

private static @Nullable Integer getRequestPort(AbstractExchange exchange) {
URI authority = getRequestAuthority(exchange);
if (authority != null) {
int authorityPort = authority.getPort();
if (authorityPort != -1) {
return authorityPort;
}
}

Proxy proxy = exchange.getProxy();
if (proxy != null && proxy.getKey() != null && proxy.getKey().getPort() > 0) {
return proxy.getKey().getPort();
}
return null;
}

private static @Nullable URI getRequestAuthority(AbstractExchange exchange) {
if (exchange instanceof Exchange exc) {
String host = exc.getOriginalHostHeaderHost();
if (host != null && !host.isBlank()) {
try {
String port = exc.getOriginalHostHeaderPort();
return new URI("http://" + host + (port.isBlank() ? "" : ":" + port));
} catch (URISyntaxException ignored) {}
}
}

String uri = exchange.getRequest().getUri();
if (uri != null && isAbsoluteURI(uri)) {
try {
return new URI(uri);
} catch (URISyntaxException ignored) {
return null;
}
}

return null;
}

private static @Nullable String getRequestPath(AbstractExchange exchange) {
if (exchange instanceof Exchange exc) {
try {
return exc.getOriginalRelativeURI();
} catch (RuntimeException ignored) {}
}

String uri = exchange.getRequest().getUri();
if (uri == null) {
return null;
}
if (!isAbsoluteURI(uri)) {
return uri;
}

try {
URI parsed = new URI(uri);
String path = parsed.getRawPath();
if (path == null || path.isEmpty()) {
path = "/";
}
return parsed.getRawQuery() == null ? path : path + "?" + parsed.getRawQuery();
} catch (URISyntaxException ignored) {
return uri;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,22 @@ public static boolean getOptionalBooleanArgument(MCPToolsCall call, String name,
throw new InvalidToolArgumentsException("Tool argument '" + name + "' must be a boolean");
}

public static @Nullable String getOptionalStringArgument(MCPToolsCall call, String name) {
Object value = call.getArgument(name);
if (value == null) {
return null;
}
if (!(value instanceof String text)) {
throw new InvalidToolArgumentsException("Tool argument '" + name + "' must be a string");
}

String trimmed = text.trim();
if (trimmed.isEmpty()) {
throw new InvalidToolArgumentsException("Tool argument '" + name + "' must not be blank");
}
return trimmed;
}

public static void rejectUnexpectedArguments(MCPToolsCall call, Set<String> allowed) {
for (String argumentName : call.getArguments().keySet()) {
if (!allowed.contains(argumentName)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
import static com.predic8.membrane.core.http.Response.accepted;
import static com.predic8.membrane.core.http.Response.statusCode;
import static com.predic8.membrane.core.interceptor.Outcome.RETURN;
import static com.predic8.membrane.core.interceptor.mcp.ExchangeUtils.matchesExchangeFilter;
import static com.predic8.membrane.core.interceptor.mcp.MCPUtil.*;
import static com.predic8.membrane.core.interceptor.mcp.McpSessionContext.McpSessionState.INITIALIZED;
import static com.predic8.membrane.core.interceptor.mcp.McpSessionContext.McpSessionState.READY;
import static com.predic8.membrane.core.jsonrpc.JSONRPCRequest.parse;
Expand Down Expand Up @@ -203,7 +205,7 @@ private McpToolRegistry buildToolRegistry() {
))
.register(new McpToolDefinition(
"getExchanges",
"Gets recent HTTP exchanges with sanitized headers and optional bodies",
"Gets recent HTTP exchanges with sanitized headers, optional bodies, and request filters",
getExchangesSchema(),
this::getExchanges
))
Expand All @@ -216,7 +218,7 @@ private McpToolRegistry buildToolRegistry() {
}

private MCPToolsCallResponse listProxies(MCPToolsCall call, Exchange exc) {
MCPUtil.rejectUnexpectedArguments(call, Set.of());
rejectUnexpectedArguments(call, Set.of());
return MCPToolsCallResponse.from(call)
.withJson(Map.of(
"proxies",
Expand All @@ -230,37 +232,51 @@ private MCPToolsCallResponse listProxies(MCPToolsCall call, Exchange exc) {
}

private MCPToolsCallResponse getStatistics(MCPToolsCall call, Exchange exc) {
MCPUtil.rejectUnexpectedArguments(call, Set.of());
rejectUnexpectedArguments(call, Set.of());
return MCPToolsCallResponse.from(call)
.withJson(getRouter().getStatistics());
}

private MCPToolsCallResponse getExchanges(MCPToolsCall call, Exchange exc) {
MCPUtil.rejectUnexpectedArguments(call, Set.of("limit", "includeBodies"));

int limit = MCPUtil.getOptionalIntArgument(call, "limit", maxExchanges, 1, maxExchanges);
boolean includeBodies = MCPUtil.getOptionalBooleanArgument(call, "includeBodies", false);

List<AbstractExchange> exchanges = Optional.ofNullable(getRouter().getExchangeStore().getAllExchangesAsList())
.orElse(List.of());
int start = Math.max(0, exchanges.size() - limit);
rejectUnexpectedArguments(call, Set.of("limit", "includeBodies", "host", "port", "pathPattern"));

// do not inline: args must be validated fist
String host = getOptionalStringArgument(call, "host");
Integer port = getOptionalPort(call);
String pathPattern = getOptionalStringArgument(call, "pathPattern");
int limit = getOptionalIntArgument(call, "limit", maxExchanges, 1, maxExchanges);
boolean includeBodies = getOptionalBooleanArgument(call, "includeBodies", false);

List<AbstractExchange> filteredExchanges = Optional.ofNullable(getRouter().getExchangeStore().getAllExchangesAsList())
.orElse(List.of())
.stream()
.filter(exchange -> matchesExchangeFilter(exchange, host, port, pathPattern))
.toList();
List<AbstractExchange> exchanges = filteredExchanges.subList(Math.max(0, filteredExchanges.size() - limit), filteredExchanges.size());

return MCPToolsCallResponse.from(call)
.withJson(Map.of(
"exchanges",
exchanges.subList(start, exchanges.size()).stream()
exchanges.stream()
.map(exchange -> MCPUtil.describeExchange(exchange, includeBodies, payloadSanitizer))
.filter(Objects::nonNull)
.toList()
));
}

private static @Nullable Integer getOptionalPort(MCPToolsCall call) {
return call.getArgument("port") == null ? null : getOptionalIntArgument(call, "port", -1, 1, 65535);
}

private Map<String, Object> getExchangesSchema() {
return Map.of(
"type", "object",
"properties", Map.of(
"limit", Map.of("type", "integer", "minimum", 1, "maximum", maxExchanges),
"includeBodies", Map.of("type", "boolean")
"includeBodies", Map.of("type", "boolean"),
"host", Map.of("type", "string"),
"port", Map.of("type", "integer", "minimum", 1, "maximum", 65535),
"pathPattern", Map.of("type", "string", "description", "Matches by prefix or regex")
),
"additionalProperties", false
);
Expand Down
Loading