Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
71 commits
Select commit Hold shift + click to select a range
899be42
init
christiangoerdes May 28, 2026
9a15d18
Implement JSON-RPC request validation with rules and batch support
christiangoerdes May 28, 2026
69d37c7
Add unit tests for JsonRPCProtectionInterceptor
christiangoerdes May 28, 2026
9b7da29
Refactor JSON-RPC validation logic into JsonRPCValidator for improved…
christiangoerdes May 28, 2026
9222fc2
Add JSON-RPC parameter schema validation with unit tests
christiangoerdes May 28, 2026
89e6590
Support JSON-RPC parameter schema validation with regex-based method …
christiangoerdes May 28, 2026
8a57b48
Merge branch 'master' into json-rpc-protection
christiangoerdes May 28, 2026
5a225af
Make `fromNode` method public in JSONRPCRequest, add logging to JsonR…
christiangoerdes May 28, 2026
329ee81
Merge remote-tracking branch 'origin/json-rpc-protection' into json-r…
christiangoerdes May 28, 2026
5bd32da
Add Javadoc comments for JSON-RPC protection classes with detailed de…
christiangoerdes May 28, 2026
4fd835a
Support XML-style parameter mappings for JSON-RPC schema validation a…
christiangoerdes May 28, 2026
de22caa
Refactor JSON-RPC validation: rename `rules` to `methods` and improve…
predic8 May 28, 2026
c4b7d4d
Merge remote-tracking branch 'origin/json-rpc-protection' into json-r…
predic8 May 28, 2026
6825c32
Fix test failures: set batch rules before interceptor init
christiangoerdes Jun 1, 2026
7d431c3
Filter child elements excluded from JSON schema in `JsonSchemaGenerat…
christiangoerdes Jun 1, 2026
c25259c
Refactor Allow/Deny rule hierarchy: move to util.allowdeny, rename me…
christiangoerdes Jun 1, 2026
fd8d1f7
Reject non-JSON content types in `JsonRPCProtectionInterceptor` with …
christiangoerdes Jun 1, 2026
7ce7007
Merge branch 'master' into json-rpc-protection
christiangoerdes Jun 1, 2026
c1bab2b
Move Allow/Deny rules to `util.config.allowdeny` package and update a…
christiangoerdes Jun 1, 2026
f52a9a6
Add JSON-RPC protection tutorial with method filtering, parameter val…
christiangoerdes Jun 1, 2026
cd0bd99
Add tests for JSON-RPC protection tutorial covering method filtering,…
christiangoerdes Jun 1, 2026
fe0833b
Switch JSON-RPC `params` method matching from regex to exact names an…
christiangoerdes Jun 1, 2026
f7d963f
Add result schema validation to JSON-RPC protection and refactor sche…
christiangoerdes Jun 1, 2026
f223210
Add JSON-RPC result schema validation for `rpc.echo` with test update…
christiangoerdes Jun 1, 2026
92bb547
disable response handling when result mappings are empty
christiangoerdes Jun 1, 2026
0b4ed0c
Update JSON-RPC protection tutorial and tests to use consistent `id` …
christiangoerdes Jun 1, 2026
2519939
Add schema validation support for JSON-RPC `params`, `response`, and …
christiangoerdes Jun 5, 2026
79f985a
Remove deprecated JSON-RPC `params` and `result` schema handling, rep…
christiangoerdes Jun 5, 2026
f957fdf
Remove redundant body empty check in `JsonRPCProtectionInterceptor` s…
christiangoerdes Jun 5, 2026
24db336
Add copyright headers to JSON-RPC interceptor classes
christiangoerdes Jun 5, 2026
666c123
Merge branch 'master' into json-rpc-protection
christiangoerdes Jun 5, 2026
7ba583f
Add inline schema support for JSON-RPC `error` validation, enhance do…
christiangoerdes Jun 5, 2026
63ec1ea
Merge remote-tracking branch 'origin/json-rpc-protection' into json-r…
christiangoerdes Jun 5, 2026
f7d5cbd
Use inline schema in tutorial
christiangoerdes Jun 5, 2026
7a1e798
Add response schema validation for JSON-RPC methods, update tests wit…
christiangoerdes Jun 5, 2026
edc038e
Rename JSON-RPC schema-related classes for clarity, refactor usage in…
christiangoerdes Jun 8, 2026
941bb11
Refactor JSONPath segment processing logic, enhance error rendering w…
christiangoerdes Jun 8, 2026
742b367
Improve documentation for JSONPath property encoding and `@MCOtherAtt…
christiangoerdes Jun 8, 2026
0d3a074
Fix formatting in JSON-RPC-Protection tutorial configuration
christiangoerdes Jun 9, 2026
8c8169e
Merge branch 'master' into json-rpc-protection
christiangoerdes Jun 9, 2026
c917dd1
Restructure JSON-RPC tutorial: adjust file hierarchy, refine document…
christiangoerdes Jun 9, 2026
98d7223
Update JSON-RPC tutorials: restructure file hierarchy, rename configu…
christiangoerdes Jun 9, 2026
37713aa
Add new tests for JSON-RPC allow/deny methods, batch validation, and …
christiangoerdes Jun 9, 2026
6017a0e
Refactor JSON-RPC Allow/Deny and Batch Validation tutorial: simplify …
christiangoerdes Jun 9, 2026
d6373ab
Update JSON-RPC Allow/Deny and Batch Validation tutorial: add demo ba…
christiangoerdes Jun 12, 2026
60f3c4d
Add MCPProtectionInterceptor and related classes: implement JSON-RPC …
christiangoerdes Jun 12, 2026
c61bbd2
Add comprehensive tests for MCPProtectionInterceptor: validate JSON-R…
christiangoerdes Jun 12, 2026
bc77e5f
Add error suppression for rejected JSON-RPC notifications and enhance…
christiangoerdes Jun 12, 2026
89f71ee
Merge branch 'master' into mcp-protection
christiangoerdes Jun 12, 2026
c1c0405
Filter denied tools from `tools/list` responses and add unaltered han…
christiangoerdes Jun 29, 2026
926bed4
Refactor JSON-RPC validation logic: replace inline `payloadType` meth…
christiangoerdes Jun 29, 2026
22fcf31
Update JSON-RPC Protection tutorial: rename methods to reflect read-o…
christiangoerdes Jun 29, 2026
b0a054b
Enhance JSON-RPC validation: allow ignoring notification responses wh…
christiangoerdes Jun 29, 2026
c2f14f4
Refactor LineYamlErrorRenderer: introduce public methods for JSONPath…
christiangoerdes Jun 29, 2026
7167f14
Add comprehensive tests for JSONPath segment parsing logic in LineYam…
christiangoerdes Jun 29, 2026
20fa54d
Merge branch 'master' into json-rpc-protection
christiangoerdes Jun 29, 2026
271fa07
Merge branch 'json-rpc-protection' into mcp-protection
christiangoerdes Jun 29, 2026
2a9e558
Add type-safe support for `MCOtherAttributes`, enhance scalar value c…
christiangoerdes Jun 29, 2026
e9ce647
Add request marker for JSON-RPC eligibility, skip response validation…
christiangoerdes Jun 29, 2026
940be96
Improve JSON-RPC schema location creation: replace sanitize logic wit…
christiangoerdes Jun 29, 2026
4faa710
Reject unmatched JSON-RPC error response IDs and add corresponding te…
christiangoerdes Jun 29, 2026
09af67d
Refactor JSON-RPC validation: replace `getValidator` with direct `val…
christiangoerdes Jun 29, 2026
108ad86
Add comprehensive tests for map value type inference in `MethodSetter…
christiangoerdes Jun 29, 2026
e42eecc
Add tutorial for `mcpProtection`, restrict MCP tools, and update docu…
christiangoerdes Jun 30, 2026
fa6a1b2
Add `McpProtectionTutorialTest` and `AbstractMcpTutorialTest` to vali…
christiangoerdes Jun 30, 2026
5f4b3d9
Merge branch 'master' into mcp-protection
christiangoerdes Jun 30, 2026
0214642
Add tests to validate MCP tool restriction logic, hide denied tools i…
christiangoerdes Jun 30, 2026
5dfb8bf
Merge branch 'master' into mcp-protection
christiangoerdes Jun 30, 2026
f4e3d0c
Merge branch 'master' into mcp-protection
christiangoerdes Jun 30, 2026
f0ee1cb
Extract `JsonPathUtil` from `LineYamlErrorRenderer`, relocate related…
christiangoerdes Jun 30, 2026
8c43051
Add tests for `ScalarValueConverter`, document methods, and improve c…
christiangoerdes Jun 30, 2026
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
5 changes: 5 additions & 0 deletions annot/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,11 @@
<artifactId>junit-jupiter-engine</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-params</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.hamcrest</groupId>
<artifactId>hamcrest</artifactId>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/* Copyright 2026 predic8 GmbH, www.predic8.com

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License. */

package com.predic8.membrane.annot.util;

import com.predic8.membrane.annot.yaml.ParsingContext;

public final class JsonPathUtil {


private JsonPathUtil() {}

/**
* Returns the parent path before the last JSONPath segment.
*
* @param jsonPath JSONPath created by {@link ParsingContext}
* @return parent path without the last segment
*/
public static String getParentPath(String jsonPath) {
return jsonPath.substring(0, findLastSegmentStart(jsonPath));
}

/**
* Returns the last part of a JSONPath created by {@link ParsingContext}.
* <p>
* Examples:
* {@code $.api.methods} -> {@code methods}
* {@code $.api.methods['rpc.echo']} -> {@code rpc.echo}
* {@code $.api.methods[0]} -> {@code 0}
*
* @param jsonPath JSONPath created by {@link ParsingContext}
* @return unescaped field name or array index of the last segment
*/
public static String getLastSegment(String jsonPath) {
String segment = jsonPath.substring(findLastSegmentStart(jsonPath));

if (segment.startsWith(".")) {
return segment.substring(1);
}

if (isQuotedPropertySegment(segment)) {
return decodeQuotedPropertySegment(segment);
}

if (isArrayIndexSegment(segment)) {
return segment.substring(1, segment.length() - 1);
}

throw new IllegalArgumentException("Unsupported JSONPath segment: " + segment);
}

private static boolean isQuotedPropertySegment(String segment) {
return segment.startsWith("['") && segment.endsWith("']");
}

private static boolean isArrayIndexSegment(String segment) {
return segment.startsWith("[") && segment.endsWith("]");
}

/**
* Removes JSONPath bracket quoting and unescapes supported characters.
*/
private static String decodeQuotedPropertySegment(String segment) {
return segment.substring(2, segment.length() - 2)
.replace("\\'", "'")

.replace("\\\\", "\\");
}

/**
* Returns the start index of the last JSONPath segment.
*/
private static int findLastSegmentStart(String jsonPath) {
int depth = 0;
for (int i = jsonPath.length() - 1; i >= 0; i--) {
char c = jsonPath.charAt(i);
if (c == ']') {
depth++;
} else if (c == '[') {
depth--;
}
if (depth == 0 && (c == '.' || c == '[')) {
return i;
}
}
throw new IllegalArgumentException("Cannot determine parent path of: " + jsonPath);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@
import org.jetbrains.annotations.*;
import org.slf4j.*;

import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
* Immutable parsing state passed down while traversing YAML.
* - context: current element scope used for local type resolution in {@link Grammar}.
Expand All @@ -29,6 +32,7 @@
public record ParsingContext<T extends BeanRegistry & BeanLifecycleManager>(String context, T registry, Grammar grammar, JsonNode topLevel, String path, String key) {

private static final Logger log = LoggerFactory.getLogger(ParsingContext.class);
public static final Pattern VALID_PROPERTY_NAME = Pattern.compile("[A-Za-z_][A-Za-z0-9_]*");

public ParsingContext<T> updateContext(String context) {
return new ParsingContext<>(context, registry, grammar, topLevel, path,key);
Expand Down Expand Up @@ -96,7 +100,7 @@ public String getPath() {
* such as `.` or `'` that JSONPath would otherwise interpret as syntax.
*/
private static String toJsonPathProperty(String property) {
if (property.matches("[A-Za-z_][A-Za-z0-9_]*")) {
if (VALID_PROPERTY_NAME.matcher(property).matches()) {
return "." + property;
}
return "['" + property
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@

import java.nio.file.Path;

import static com.predic8.membrane.annot.util.JsonPathUtil.getLastSegment;
import static com.predic8.membrane.annot.util.JsonPathUtil.getParentPath;
import static com.predic8.membrane.common.TerminalColorsMini.*;

/**
Expand Down Expand Up @@ -58,6 +60,12 @@ public static String renderErrorReport(ParsingContext pc) throws JsonProcessingE
return renderErrorReport(pc, null);
}

/**
* Renders a YAML representation of the JSON node with line-based error markers
* and, if available, the source file path.
*
* @return YAML string with ">" prefixes and red color for error lines
*/
public static String renderErrorReport(ParsingContext pc, Path sourceFile) throws JsonProcessingException {

JsonNode node = pc.getNode();
Expand Down Expand Up @@ -270,6 +278,9 @@ private static String markLinesWithPrefix(String yaml, Path sourceFile) {
return result.toString().trim();
}

/**
* Counts the leading spaces in a YAML line.
*/
private static int getIndentation(String line) {
int indent = 0;
for (char c : line.toCharArray()) {
Expand All @@ -281,67 +292,4 @@ private static int getIndentation(String line) {
}
return indent;
}

static String getParentPath(String jsonPath) {
return jsonPath.substring(0, findLastSegmentStart(jsonPath));
}

/**
* Returns the last part of a JSONPath created by {@link ParsingContext}.
* Examples:
* `$.api.methods` -> `methods`
* `$.api.methods['rpc.echo']` -> `rpc.echo`
* `$.api.methods[0]` -> `0`
*/
static String getLastSegment(String jsonPath) {
String segment = jsonPath.substring(findLastSegmentStart(jsonPath));

if (isPropertySegment(segment)) {
return segment.substring(1);
}

if (isQuotedPropertySegment(segment)) {
return decodeQuotedPropertySegment(segment);
}

if (isArrayIndexSegment(segment)) {
return segment.substring(1, segment.length() - 1);
}

throw new IllegalArgumentException("Unsupported JSONPath segment: " + segment);
}

private static boolean isPropertySegment(String segment) {
return segment.startsWith(".");
}

private static boolean isQuotedPropertySegment(String segment) {
return segment.startsWith("['") && segment.endsWith("']");
}

private static String decodeQuotedPropertySegment(String segment) {
return segment.substring(2, segment.length() - 2)
.replace("\\'", "'")
.replace("\\\\", "\\");
}

private static boolean isArrayIndexSegment(String segment) {
return segment.startsWith("[") && segment.endsWith("]");
}

private static int findLastSegmentStart(String jsonPath) {
int depth = 0;
for (int i = jsonPath.length() - 1; i >= 0; i--) {
char c = jsonPath.charAt(i);
if (c == ']') {
depth++;
} else if (c == '[') {
depth--;
}
if (depth == 0 && (c == '.' || c == '[')) {
return i;
}
}
throw new IllegalArgumentException("Cannot determine parent path of: " + jsonPath);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ public final class ScalarValueConverter {
private final SpelEvaluator spelEvaluator = new SpelEvaluator();
private final ReferenceResolver referenceResolver = new ReferenceResolver();

/**
* Converts a YAML scalar node to the setter target type or resolves a reference.
*/
public Object coerceScalarOrReference(ParsingContext<?> ctx, Method setter, JsonNode node, String key, Class<?> wanted) throws WrongEnumConstantException {
if (wanted.equals(String.class))
return node.isTextual() ? spelEvaluator.resolve(node.asText(), String.class) : node.asText();
Expand All @@ -55,12 +58,18 @@ public Object coerceScalarOrReference(ParsingContext<?> ctx, Method setter, Json
return coerceNonTextual(ctx, setter, node, key, wanted);
}

/**
* Converts a scalar node directly, evaluating SpEL first for textual values.
*/
public static Object convertScalarOrSpel(JsonNode node, Class<?> targetType) {
if (node == null || !node.isTextual())
return SCALAR_MAPPER.convertValue(node, targetType);
return STATIC_SPEL_EVALUATOR.resolve(node.asText(), targetType);
}

/**
* Handles textual YAML values, including SpEL, numbers, references, and maps.
*/
private Object coerceTextual(ParsingContext<?> ctx, Method setter, JsonNode node, String key, Class<?> wanted) {
String evaluated = evaluateSpelForString(key, node.asText());
if (evaluated == null) {
Expand All @@ -84,6 +93,9 @@ private Object coerceTextual(ParsingContext<?> ctx, Method setter, JsonNode node
throw unsupported(wanted, key, node);
}

/**
* Handles already typed JSON values such as booleans and numbers.
*/
private Object coerceNonTextual(ParsingContext<?> ctx, Method setter, JsonNode node, String key, Class<?> wanted) {
if (isInteger(wanted))
return node.isInt() ? node.intValue() : parseInt(node.asText());
Expand All @@ -102,17 +114,24 @@ private Object coerceNonTextual(ParsingContext<?> ctx, Method setter, JsonNode n

/**
* Converts the value of one entry from an {@code @MCOtherAttributes} map.
* Example: for `methods: { 'rpc.echo': { params: ... } }` the key is `rpc.echo`.
* Example: for {@code methods: { 'rpc.echo': { params: ... } }} the key is {@code rpc.echo}.
* The nested object is then bound as the configured Java type for the map value,
* not left as a raw map. Plain scalar values such as `timeout: 5` stay plain values.
* not left as a raw map. Plain scalar values such as {@code timeout: 5} stay plain values.
*/
private Object convertAnySetterValue(ParsingContext<?> ctx, Method setter, JsonNode node, String key) {
Class<?> valueType = getMapValueType(setter);
if (valueType == null || valueType == Object.class) {
return SCALAR_MAPPER.convertValue(node, Object.class);
}
if (valueType == String.class) {
return node.isTextual() ? evaluateSpelForString(key, node.asText()) : node.asText();
if (node.isTextual())
return evaluateSpelForString(key, node.asText());
if (node.isObject() || node.isArray())
throw unsupported(valueType, key, node);
return SCALAR_MAPPER.convertValue(node, valueType);
}
if (isScalarCompatible(valueType)) {
return SCALAR_MAPPER.convertValue(node, valueType);
}
return bind(
ctx.updateContext(getElementName(valueType)).addProperty(key),
Expand All @@ -121,6 +140,9 @@ private Object convertAnySetterValue(ParsingContext<?> ctx, Method setter, JsonN
);
}

/**
* Returns the runtime class used as the value type of a map setter parameter.
*/
private static Class<?> getMapValueType(Method setter) {
Comment thread
christiangoerdes marked this conversation as resolved.
Type genericType = setter.getGenericParameterTypes()[0];
if (!(genericType instanceof ParameterizedType parameterizedType)) {
Expand Down Expand Up @@ -150,6 +172,9 @@ private String evaluateSpelForString(String key, String value) {
}
}

/**
* Parses numeric text and reports invalid values as configuration errors.
*/
private Object parseNumericOrThrow(ParsingContext<?> ctx, String key, Class<?> wanted, String value, JsonNode node) {
try {
if (isInteger(wanted))
Expand All @@ -167,12 +192,18 @@ private Object parseNumericOrThrow(ParsingContext<?> ctx, String key, Class<?> w
throw unsupported(wanted, key, node);
}

/**
* Checks whether the target type should be treated as a bean reference.
*/
private static boolean isBeanReference(Class<?> wanted) {
if (wanted == Integer.TYPE || wanted == Long.TYPE || wanted == Float.TYPE || wanted == Double.TYPE || wanted == Boolean.TYPE || wanted == String.class)
return false;
return !wanted.isEnum();
}

/**
* Parses enum values case-insensitively.
*/
@SuppressWarnings("unchecked")
private static <E extends Enum<E>> E parseEnum(Class<?> enumClass, JsonNode node) throws WrongEnumConstantException {
String value = node.asText().toUpperCase(ROOT);
Expand Down Expand Up @@ -203,6 +234,31 @@ private static boolean isNumber(Class<?> wanted) {
return isInteger(wanted) || isLong(wanted) || isDouble(wanted);
}

/**
* Checks whether Jackson can convert the node as a scalar value.
*/
private static boolean isScalarCompatible(Class<?> wanted) {
return wanted.isEnum()
|| isPrimitiveScalar(wanted)
|| isPrimitiveWrapper(wanted)
|| Number.class.isAssignableFrom(wanted);
}

private static boolean isPrimitiveScalar(Class<?> wanted) {
return wanted.isPrimitive() && wanted != void.class;
}

private static boolean isPrimitiveWrapper(Class<?> wanted) {
return wanted == Boolean.class
|| wanted == Character.class
|| wanted == Byte.class
|| wanted == Short.class
|| wanted == Integer.class
|| wanted == Long.class
|| wanted == Float.class
|| wanted == Double.class;
}

private static ConfigurationParsingException unsupported(Class<?> wanted, String key, JsonNode node) {
return new ConfigurationParsingException("Unsupported setter type: %s for key '%s' with node type %s".formatted(wanted.getName(), key, node.getNodeType()));
}
Expand Down
Loading
Loading