From c9a0a2f6aab08a508d1960bb77cbae3e248ff286 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Jun 2026 04:43:13 +0000 Subject: [PATCH 1/9] feat(binding-mcp): per-route primitive-name allow-set filtering (#1833) Add optional tools/prompts/resources glob allow-sets to the mcp proxy route `when` condition. Semantics: absent = no constraint; present = allow-set (match >=1 glob); empty = matches nothing. Capability advertisement stays driven solely by `capability` (an empty allow-set does not suppress it). Invocation enforcement (authoritative) falls out of route resolution: McpConditionMatcher.match() now gates the stripped name against the per-capability allow-set, so tools/call, prompts/get and resources/read to a name outside the set fail to resolve and are rejected. Extracts the previously-nested ConditionMatcher to a top-level McpConditionMatcher (mirrors MqttKafkaConditionMatcher) for direct unit testing. Schema adds the three array fields plus cross-field validation rejecting a name field whose type is excluded from `capability`. Advisory list-federation filtering is deferred to a follow-up. https://claude.ai/code/session_0115ZRrNA9iPG83DfsM1pduB --- .../mcp/config/McpConditionConfig.java | 11 +- .../mcp/config/McpConditionConfigBuilder.java | 26 ++- .../config/McpConditionConfigAdapter.java | 51 ++++- .../internal/config/McpConditionMatcher.java | 181 ++++++++++++++++++ .../mcp/internal/config/McpRouteConfig.java | 93 +-------- .../config/McpConditionConfigAdapterTest.java | 135 +++++++++++++ .../config/McpConditionMatcherTest.java | 114 +++++++++++ .../binding/mcp/config/proxy.filter.yaml | 33 ++++ ...roxy.routes.filter.capability.invalid.yaml | 27 +++ .../binding/mcp/schema/mcp.schema.patch.json | 88 ++++++++- .../specs/binding/mcp/config/SchemaTest.java | 14 ++ 11 files changed, 674 insertions(+), 99 deletions(-) create mode 100644 runtime/binding-mcp/src/main/java/io/aklivity/zilla/runtime/binding/mcp/internal/config/McpConditionMatcher.java create mode 100644 runtime/binding-mcp/src/test/java/io/aklivity/zilla/runtime/binding/mcp/internal/config/McpConditionConfigAdapterTest.java create mode 100644 runtime/binding-mcp/src/test/java/io/aklivity/zilla/runtime/binding/mcp/internal/config/McpConditionMatcherTest.java create mode 100644 specs/binding-mcp.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/config/proxy.filter.yaml create mode 100644 specs/binding-mcp.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/config/proxy.routes.filter.capability.invalid.yaml diff --git a/runtime/binding-mcp/src/main/java/io/aklivity/zilla/runtime/binding/mcp/config/McpConditionConfig.java b/runtime/binding-mcp/src/main/java/io/aklivity/zilla/runtime/binding/mcp/config/McpConditionConfig.java index 65f9e1f17f..80cb28ad20 100644 --- a/runtime/binding-mcp/src/main/java/io/aklivity/zilla/runtime/binding/mcp/config/McpConditionConfig.java +++ b/runtime/binding-mcp/src/main/java/io/aklivity/zilla/runtime/binding/mcp/config/McpConditionConfig.java @@ -23,13 +23,22 @@ public final class McpConditionConfig extends ConditionConfig { public final String toolkit; public final List capability; + public final List tools; + public final List prompts; + public final List resources; McpConditionConfig( String toolkit, - List capability) + List capability, + List tools, + List prompts, + List resources) { this.toolkit = toolkit; this.capability = capability; + this.tools = tools; + this.prompts = prompts; + this.resources = resources; } public static McpConditionConfigBuilder builder() diff --git a/runtime/binding-mcp/src/main/java/io/aklivity/zilla/runtime/binding/mcp/config/McpConditionConfigBuilder.java b/runtime/binding-mcp/src/main/java/io/aklivity/zilla/runtime/binding/mcp/config/McpConditionConfigBuilder.java index b0677fbe12..ffbe28847f 100644 --- a/runtime/binding-mcp/src/main/java/io/aklivity/zilla/runtime/binding/mcp/config/McpConditionConfigBuilder.java +++ b/runtime/binding-mcp/src/main/java/io/aklivity/zilla/runtime/binding/mcp/config/McpConditionConfigBuilder.java @@ -26,6 +26,9 @@ public final class McpConditionConfigBuilder extends ConfigBuilder capability; + private List tools; + private List prompts; + private List resources; public McpConditionConfigBuilder( Function mapper) @@ -47,6 +50,27 @@ public McpConditionConfigBuilder capability( return this; } + public McpConditionConfigBuilder tools( + List tools) + { + this.tools = tools; + return this; + } + + public McpConditionConfigBuilder prompts( + List prompts) + { + this.prompts = prompts; + return this; + } + + public McpConditionConfigBuilder resources( + List resources) + { + this.resources = resources; + return this; + } + @Override @SuppressWarnings("unchecked") protected Class> thisType() @@ -57,6 +81,6 @@ protected Class> thisType() @Override public T build() { - return mapper.apply(new McpConditionConfig(toolkit, capability)); + return mapper.apply(new McpConditionConfig(toolkit, capability, tools, prompts, resources)); } } diff --git a/runtime/binding-mcp/src/main/java/io/aklivity/zilla/runtime/binding/mcp/internal/config/McpConditionConfigAdapter.java b/runtime/binding-mcp/src/main/java/io/aklivity/zilla/runtime/binding/mcp/internal/config/McpConditionConfigAdapter.java index 6578f9cb50..0d93e23445 100644 --- a/runtime/binding-mcp/src/main/java/io/aklivity/zilla/runtime/binding/mcp/internal/config/McpConditionConfigAdapter.java +++ b/runtime/binding-mcp/src/main/java/io/aklivity/zilla/runtime/binding/mcp/internal/config/McpConditionConfigAdapter.java @@ -35,6 +35,9 @@ public final class McpConditionConfigAdapter implements ConditionConfigAdapterSp { private static final String TOOLKIT_NAME = "toolkit"; private static final String CAPABILITY_NAME = "capability"; + private static final String TOOLS_NAME = "tools"; + private static final String PROMPTS_NAME = "prompts"; + private static final String RESOURCES_NAME = "resources"; @Override public String type() @@ -62,6 +65,27 @@ public JsonObject adaptToJson( object.add(CAPABILITY_NAME, array); } + if (mcpCondition.tools != null) + { + JsonArrayBuilder array = Json.createArrayBuilder(); + mcpCondition.tools.forEach(array::add); + object.add(TOOLS_NAME, array); + } + + if (mcpCondition.prompts != null) + { + JsonArrayBuilder array = Json.createArrayBuilder(); + mcpCondition.prompts.forEach(array::add); + object.add(PROMPTS_NAME, array); + } + + if (mcpCondition.resources != null) + { + JsonArrayBuilder array = Json.createArrayBuilder(); + mcpCondition.resources.forEach(array::add); + object.add(RESOURCES_NAME, array); + } + return object.build(); } @@ -73,19 +97,28 @@ public ConditionConfig adaptFromJson( ? object.getString(TOOLKIT_NAME) : null; - List capability = null; - if (object.containsKey(CAPABILITY_NAME)) + return McpConditionConfig.builder() + .toolkit(toolkit) + .capability(asStringList(object, CAPABILITY_NAME)) + .tools(asStringList(object, TOOLS_NAME)) + .prompts(asStringList(object, PROMPTS_NAME)) + .resources(asStringList(object, RESOURCES_NAME)) + .build(); + } + + private static List asStringList( + JsonObject object, + String name) + { + List result = null; + if (object.containsKey(name)) { - JsonArray array = object.getJsonArray(CAPABILITY_NAME); - capability = array.stream() + JsonArray array = object.getJsonArray(name); + result = array.stream() .map(JsonString.class::cast) .map(JsonString::getString) .collect(toList()); } - - return McpConditionConfig.builder() - .toolkit(toolkit) - .capability(capability) - .build(); + return result; } } diff --git a/runtime/binding-mcp/src/main/java/io/aklivity/zilla/runtime/binding/mcp/internal/config/McpConditionMatcher.java b/runtime/binding-mcp/src/main/java/io/aklivity/zilla/runtime/binding/mcp/internal/config/McpConditionMatcher.java new file mode 100644 index 0000000000..5a99485d38 --- /dev/null +++ b/runtime/binding-mcp/src/main/java/io/aklivity/zilla/runtime/binding/mcp/internal/config/McpConditionMatcher.java @@ -0,0 +1,181 @@ +/* + * Copyright 2021-2024 Aklivity Inc + * + * Licensed under the Aklivity Community License (the "License"); you may not use + * this file except in compliance with the License. You may obtain a copy of the + * License at + * + * https://www.aklivity.io/aklivity-community-license/ + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + */ +package io.aklivity.zilla.runtime.binding.mcp.internal.config; + +import static io.aklivity.zilla.runtime.binding.mcp.internal.config.McpRouteConfig.CAPABILITY_PROMPTS; +import static io.aklivity.zilla.runtime.binding.mcp.internal.config.McpRouteConfig.CAPABILITY_RESOURCES; +import static io.aklivity.zilla.runtime.binding.mcp.internal.config.McpRouteConfig.CAPABILITY_TOOLS; +import static io.aklivity.zilla.runtime.binding.mcp.internal.types.McpCapabilities.SERVER_PROMPTS; +import static io.aklivity.zilla.runtime.binding.mcp.internal.types.McpCapabilities.SERVER_RESOURCES; +import static io.aklivity.zilla.runtime.binding.mcp.internal.types.McpCapabilities.SERVER_TOOLS; + +import java.util.List; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import io.aklivity.zilla.runtime.binding.mcp.config.McpConditionConfig; + +final class McpConditionMatcher +{ + private static final String DELIMITER_NAME = "__"; + private static final String DELIMITER_URI = "+"; + + final String toolkit; + + private final String toolsPrefix; + private final String promptsPrefix; + private final String resourcesPrefix; + private final List toolsAllow; + private final List promptsAllow; + private final List resourcesAllow; + + McpConditionMatcher( + McpConditionConfig condition) + { + final List capabilities = condition.capability; + final String toolkit = condition.toolkit; + this.toolkit = toolkit; + + final boolean anyCapability = capabilities == null; + final boolean tools = anyCapability || capabilities.contains(CAPABILITY_TOOLS); + final boolean prompts = anyCapability || capabilities.contains(CAPABILITY_PROMPTS); + final boolean resources = anyCapability || capabilities.contains(CAPABILITY_RESOURCES); + + this.toolsPrefix = tools ? (toolkit != null ? toolkit + DELIMITER_NAME : "") : null; + this.promptsPrefix = prompts ? (toolkit != null ? toolkit + DELIMITER_NAME : "") : null; + this.resourcesPrefix = resources ? (toolkit != null ? toolkit + DELIMITER_URI : "") : null; + + this.toolsAllow = compile(condition.tools); + this.promptsAllow = compile(condition.prompts); + this.resourcesAllow = compile(condition.resources); + } + + int serverCapabilities() + { + int bits = 0; + if (toolsPrefix != null) + { + bits |= SERVER_TOOLS.value(); + } + if (promptsPrefix != null) + { + bits |= SERVER_PROMPTS.value(); + } + if (resourcesPrefix != null) + { + bits |= SERVER_RESOURCES.value(); + } + return bits; + } + + String match( + String capability, + String identifier) + { + final String prefix = prefix(capability); + String result = null; + + if (prefix != null && identifier != null && identifier.startsWith(prefix)) + { + final String stripped = identifier.substring(prefix.length()); + if (admits(allow(capability), stripped)) + { + result = stripped; + } + } + + return result; + } + + boolean serves( + String capability) + { + return prefix(capability) != null; + } + + String prefix( + String capability) + { + return switch (capability) + { + case CAPABILITY_TOOLS -> toolsPrefix; + case CAPABILITY_PROMPTS -> promptsPrefix; + case CAPABILITY_RESOURCES -> resourcesPrefix; + default -> null; + }; + } + + private List allow( + String capability) + { + return switch (capability) + { + case CAPABILITY_TOOLS -> toolsAllow; + case CAPABILITY_PROMPTS -> promptsAllow; + case CAPABILITY_RESOURCES -> resourcesAllow; + default -> null; + }; + } + + private static boolean admits( + List allow, + String name) + { + boolean result = allow == null; + + if (!result) + { + for (Pattern pattern : allow) + { + if (pattern.matcher(name).matches()) + { + result = true; + break; + } + } + } + + return result; + } + + private static List compile( + List globs) + { + return globs == null + ? null + : globs.stream() + .map(McpConditionMatcher::compile) + .collect(Collectors.toList()); + } + + private static Pattern compile( + String glob) + { + final StringBuilder regex = new StringBuilder(); + final String[] literals = glob.split("\\*", -1); + for (int index = 0; index < literals.length; index++) + { + if (index > 0) + { + regex.append(".*"); + } + if (!literals[index].isEmpty()) + { + regex.append(Pattern.quote(literals[index])); + } + } + return Pattern.compile(regex.toString()); + } +} diff --git a/runtime/binding-mcp/src/main/java/io/aklivity/zilla/runtime/binding/mcp/internal/config/McpRouteConfig.java b/runtime/binding-mcp/src/main/java/io/aklivity/zilla/runtime/binding/mcp/internal/config/McpRouteConfig.java index c091b5c055..91a0a367c6 100644 --- a/runtime/binding-mcp/src/main/java/io/aklivity/zilla/runtime/binding/mcp/internal/config/McpRouteConfig.java +++ b/runtime/binding-mcp/src/main/java/io/aklivity/zilla/runtime/binding/mcp/internal/config/McpRouteConfig.java @@ -41,13 +41,10 @@ public final class McpRouteConfig static final String CAPABILITY_PROMPTS = "prompts"; static final String CAPABILITY_RESOURCES = "resources"; - private static final String DELIMITER_NAME = "__"; - private static final String DELIMITER_URI = "+"; - public final long id; public final McpWithConfig with; - private final List matchers; + private final List matchers; private final LongObjectPredicate> authorized; private final String toolkit; @@ -58,7 +55,7 @@ public McpRouteConfig( this.with = McpWithConfig.class.cast(route.with); this.matchers = route.when.stream() .map(McpConditionConfig.class::cast) - .map(ConditionMatcher::new) + .map(McpConditionMatcher::new) .collect(toList()); this.authorized = route.authorized; this.toolkit = matchers.stream() @@ -89,7 +86,7 @@ public int serverCapabilities() else { bits = 0; - for (ConditionMatcher matcher : matchers) + for (McpConditionMatcher matcher : matchers) { bits |= matcher.serverCapabilities(); } @@ -107,7 +104,7 @@ public String strip( if (capability != null && identifier != null && !matchers.isEmpty()) { - for (ConditionMatcher matcher : matchers) + for (McpConditionMatcher matcher : matchers) { final String stripped = matcher.match(capability, identifier); if (stripped != null) @@ -140,7 +137,7 @@ private String prefix( if (capability != null && !matchers.isEmpty()) { - for (ConditionMatcher matcher : matchers) + for (McpConditionMatcher matcher : matchers) { final String prefix = matcher.prefix(capability); if (prefix != null) @@ -162,7 +159,7 @@ boolean matches( if (!result) { - for (ConditionMatcher matcher : matchers) + for (McpConditionMatcher matcher : matchers) { if (matcher.match(capability, identifier) != null) { @@ -182,7 +179,7 @@ boolean serves( if (!result) { - for (ConditionMatcher matcher : matchers) + for (McpConditionMatcher matcher : matchers) { if (matcher.serves(capability)) { @@ -224,80 +221,4 @@ static String identifierOf( default -> null; }; } - - private static final class ConditionMatcher - { - private final String toolkit; - private final String toolsPrefix; - private final String promptsPrefix; - private final String resourcesPrefix; - - private ConditionMatcher( - McpConditionConfig condition) - { - final List capabilities = condition.capability; - final String toolkit = condition.toolkit; - this.toolkit = toolkit; - - final boolean anyCapability = capabilities == null; - final boolean tools = anyCapability || capabilities.contains(CAPABILITY_TOOLS); - final boolean prompts = anyCapability || capabilities.contains(CAPABILITY_PROMPTS); - final boolean resources = anyCapability || capabilities.contains(CAPABILITY_RESOURCES); - - this.toolsPrefix = tools ? (toolkit != null ? toolkit + DELIMITER_NAME : "") : null; - this.promptsPrefix = prompts ? (toolkit != null ? toolkit + DELIMITER_NAME : "") : null; - this.resourcesPrefix = resources ? (toolkit != null ? toolkit + DELIMITER_URI : "") : null; - } - - private int serverCapabilities() - { - int bits = 0; - if (toolsPrefix != null) - { - bits |= SERVER_TOOLS.value(); - } - if (promptsPrefix != null) - { - bits |= SERVER_PROMPTS.value(); - } - if (resourcesPrefix != null) - { - bits |= SERVER_RESOURCES.value(); - } - return bits; - } - - private String match( - String capability, - String identifier) - { - final String prefix = prefix(capability); - String result = null; - - if (prefix != null && identifier != null && identifier.startsWith(prefix)) - { - result = identifier.substring(prefix.length()); - } - - return result; - } - - private boolean serves( - String capability) - { - return prefix(capability) != null; - } - - private String prefix( - String capability) - { - return switch (capability) - { - case CAPABILITY_TOOLS -> toolsPrefix; - case CAPABILITY_PROMPTS -> promptsPrefix; - case CAPABILITY_RESOURCES -> resourcesPrefix; - default -> null; - }; - } - } } diff --git a/runtime/binding-mcp/src/test/java/io/aklivity/zilla/runtime/binding/mcp/internal/config/McpConditionConfigAdapterTest.java b/runtime/binding-mcp/src/test/java/io/aklivity/zilla/runtime/binding/mcp/internal/config/McpConditionConfigAdapterTest.java new file mode 100644 index 0000000000..896e006f64 --- /dev/null +++ b/runtime/binding-mcp/src/test/java/io/aklivity/zilla/runtime/binding/mcp/internal/config/McpConditionConfigAdapterTest.java @@ -0,0 +1,135 @@ +/* + * Copyright 2021-2024 Aklivity Inc + * + * Licensed under the Aklivity Community License (the "License"); you may not use + * this file except in compliance with the License. You may obtain a copy of the + * License at + * + * https://www.aklivity.io/aklivity-community-license/ + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + */ +package io.aklivity.zilla.runtime.binding.mcp.internal.config; + +import static java.util.Arrays.asList; +import static java.util.Collections.emptyList; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.nullValue; + +import jakarta.json.bind.Jsonb; +import jakarta.json.bind.JsonbBuilder; +import jakarta.json.bind.JsonbConfig; + +import org.junit.Before; +import org.junit.Test; + +import io.aklivity.zilla.runtime.binding.mcp.config.McpConditionConfig; + +public class McpConditionConfigAdapterTest +{ + private Jsonb jsonb; + + @Before + public void initJson() + { + JsonbConfig config = new JsonbConfig() + .withAdapters(new McpConditionConfigAdapter()); + jsonb = JsonbBuilder.create(config); + } + + @Test + public void shouldReadToolkitCondition() + { + String text = "{\"toolkit\":\"github\",\"capability\":[\"tools\",\"resources\"]}"; + + McpConditionConfig condition = jsonb.fromJson(text, McpConditionConfig.class); + + assertThat(condition, not(nullValue())); + assertThat(condition.toolkit, equalTo("github")); + assertThat(condition.capability, contains("tools", "resources")); + assertThat(condition.tools, nullValue()); + assertThat(condition.prompts, nullValue()); + assertThat(condition.resources, nullValue()); + } + + @Test + public void shouldWriteToolkitCondition() + { + McpConditionConfig condition = McpConditionConfig.builder() + .toolkit("github") + .capability(asList("tools", "resources")) + .build(); + + String text = jsonb.toJson(condition); + + assertThat(text, not(nullValue())); + assertThat(text, equalTo("{\"toolkit\":\"github\",\"capability\":[\"tools\",\"resources\"]}")); + } + + @Test + public void shouldReadFilterCondition() + { + String text = "{\"toolkit\":\"github\",\"capability\":[\"tools\",\"resources\"]," + + "\"tools\":[\"create_*\",\"get_*\"],\"resources\":[\"repo://*\"]}"; + + McpConditionConfig condition = jsonb.fromJson(text, McpConditionConfig.class); + + assertThat(condition, not(nullValue())); + assertThat(condition.toolkit, equalTo("github")); + assertThat(condition.tools, contains("create_*", "get_*")); + assertThat(condition.resources, contains("repo://*")); + assertThat(condition.prompts, nullValue()); + } + + @Test + public void shouldWriteFilterCondition() + { + McpConditionConfig condition = McpConditionConfig.builder() + .toolkit("github") + .capability(asList("tools", "resources")) + .tools(asList("create_*", "get_*")) + .resources(asList("repo://*")) + .build(); + + String text = jsonb.toJson(condition); + + assertThat(text, not(nullValue())); + assertThat(text, equalTo("{\"toolkit\":\"github\",\"capability\":[\"tools\",\"resources\"]," + + "\"tools\":[\"create_*\",\"get_*\"],\"resources\":[\"repo://*\"]}")); + } + + @Test + public void shouldReadEmptyFilterCondition() + { + String text = "{\"toolkit\":\"slack\",\"capability\":[\"tools\"],\"tools\":[]}"; + + McpConditionConfig condition = jsonb.fromJson(text, McpConditionConfig.class); + + assertThat(condition, not(nullValue())); + assertThat(condition.tools, empty()); + assertThat(condition.prompts, nullValue()); + assertThat(condition.resources, nullValue()); + } + + @Test + public void shouldWriteEmptyFilterCondition() + { + McpConditionConfig condition = McpConditionConfig.builder() + .toolkit("slack") + .capability(asList("tools")) + .tools(emptyList()) + .build(); + + String text = jsonb.toJson(condition); + + assertThat(text, not(nullValue())); + assertThat(text, equalTo("{\"toolkit\":\"slack\",\"capability\":[\"tools\"],\"tools\":[]}")); + } +} diff --git a/runtime/binding-mcp/src/test/java/io/aklivity/zilla/runtime/binding/mcp/internal/config/McpConditionMatcherTest.java b/runtime/binding-mcp/src/test/java/io/aklivity/zilla/runtime/binding/mcp/internal/config/McpConditionMatcherTest.java new file mode 100644 index 0000000000..da562a47da --- /dev/null +++ b/runtime/binding-mcp/src/test/java/io/aklivity/zilla/runtime/binding/mcp/internal/config/McpConditionMatcherTest.java @@ -0,0 +1,114 @@ +/* + * Copyright 2021-2024 Aklivity Inc + * + * Licensed under the Aklivity Community License (the "License"); you may not use + * this file except in compliance with the License. You may obtain a copy of the + * License at + * + * https://www.aklivity.io/aklivity-community-license/ + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + */ +package io.aklivity.zilla.runtime.binding.mcp.internal.config; + +import static java.util.Arrays.asList; +import static java.util.Collections.emptyList; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import io.aklivity.zilla.runtime.binding.mcp.config.McpConditionConfig; + +public class McpConditionMatcherTest +{ + @Test + public void shouldMatchToolWithinAllowSet() + { + McpConditionConfig condition = McpConditionConfig.builder() + .toolkit("github") + .capability(asList("tools", "resources")) + .tools(asList("create_*", "get_*")) + .resources(asList("repo://*")) + .build(); + McpConditionMatcher matcher = new McpConditionMatcher(condition); + + assertEquals("create_pr", matcher.match("tools", "github__create_pr")); + assertEquals("get_user", matcher.match("tools", "github__get_user")); + assertEquals("repo://aklivity/zilla", matcher.match("resources", "github+repo://aklivity/zilla")); + } + + @Test + public void shouldNotMatchToolOutsideAllowSet() + { + McpConditionConfig condition = McpConditionConfig.builder() + .toolkit("github") + .capability(asList("tools", "resources")) + .tools(asList("create_*", "get_*")) + .resources(asList("repo://*")) + .build(); + McpConditionMatcher matcher = new McpConditionMatcher(condition); + + assertNull(matcher.match("tools", "github__delete_repo")); + assertNull(matcher.match("resources", "github+issue://1")); + } + + @Test + public void shouldMatchAnyToolWhenAllowSetAbsent() + { + McpConditionConfig condition = McpConditionConfig.builder() + .toolkit("github") + .capability(asList("tools")) + .build(); + McpConditionMatcher matcher = new McpConditionMatcher(condition); + + assertEquals("create_pr", matcher.match("tools", "github__create_pr")); + assertEquals("delete_repo", matcher.match("tools", "github__delete_repo")); + } + + @Test + public void shouldMatchNoToolWhenAllowSetEmpty() + { + McpConditionConfig condition = McpConditionConfig.builder() + .toolkit("slack") + .capability(asList("tools")) + .tools(emptyList()) + .build(); + McpConditionMatcher matcher = new McpConditionMatcher(condition); + + assertNull(matcher.match("tools", "slack__post_message")); + } + + @Test + public void shouldServeCapabilityIndependentOfAllowSet() + { + McpConditionConfig condition = McpConditionConfig.builder() + .toolkit("slack") + .capability(asList("tools")) + .tools(emptyList()) + .build(); + McpConditionMatcher matcher = new McpConditionMatcher(condition); + + assertTrue(matcher.serves("tools")); + assertFalse(matcher.serves("prompts")); + assertFalse(matcher.serves("resources")); + } + + @Test + public void shouldNotMatchUnservedCapability() + { + McpConditionConfig condition = McpConditionConfig.builder() + .toolkit("github") + .capability(asList("resources")) + .build(); + McpConditionMatcher matcher = new McpConditionMatcher(condition); + + assertNull(matcher.match("tools", "github__create_pr")); + assertFalse(matcher.serves("tools")); + } +} diff --git a/specs/binding-mcp.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/config/proxy.filter.yaml b/specs/binding-mcp.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/config/proxy.filter.yaml new file mode 100644 index 0000000000..b4689f6970 --- /dev/null +++ b/specs/binding-mcp.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/config/proxy.filter.yaml @@ -0,0 +1,33 @@ +# +# Copyright 2021-2024 Aklivity Inc +# +# Licensed under the Aklivity Community License (the "License"); you may not use +# this file except in compliance with the License. You may obtain a copy of the +# License at +# +# https://www.aklivity.io/aklivity-community-license/ +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OF ANY KIND, either express or implied. See the License for the +# specific language governing permissions and limitations under the License. +# + +--- +name: test +bindings: + app0: + type: mcp + kind: proxy + routes: + - exit: app1 + when: + - toolkit: github + capability: [tools, resources] + tools: ["create_*", "get_*"] + resources: ["repo://*"] + - exit: app2 + when: + - toolkit: slack + capability: [tools] + tools: [] diff --git a/specs/binding-mcp.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/config/proxy.routes.filter.capability.invalid.yaml b/specs/binding-mcp.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/config/proxy.routes.filter.capability.invalid.yaml new file mode 100644 index 0000000000..c317bda083 --- /dev/null +++ b/specs/binding-mcp.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/config/proxy.routes.filter.capability.invalid.yaml @@ -0,0 +1,27 @@ +# +# Copyright 2021-2024 Aklivity Inc +# +# Licensed under the Aklivity Community License (the "License"); you may not use +# this file except in compliance with the License. You may obtain a copy of the +# License at +# +# https://www.aklivity.io/aklivity-community-license/ +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OF ANY KIND, either express or implied. See the License for the +# specific language governing permissions and limitations under the License. +# + +--- +name: test +bindings: + app0: + type: mcp + kind: proxy + routes: + - exit: app1 + when: + - toolkit: github + capability: [resources] + tools: ["create_*"] diff --git a/specs/binding-mcp.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/schema/mcp.schema.patch.json b/specs/binding-mcp.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/schema/mcp.schema.patch.json index 683d6a6ad2..bb32e4c5df 100644 --- a/specs/binding-mcp.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/schema/mcp.schema.patch.json +++ b/specs/binding-mcp.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/schema/mcp.schema.patch.json @@ -189,6 +189,33 @@ }, "minItems": 1, "uniqueItems": true + }, + "tools": + { + "title": "Tools", + "type": "array", + "items": + { + "type": "string" + } + }, + "prompts": + { + "title": "Prompts", + "type": "array", + "items": + { + "type": "string" + } + }, + "resources": + { + "title": "Resources", + "type": "array", + "items": + { + "type": "string" + } } }, "additionalProperties": false @@ -269,7 +296,61 @@ { "items": { - "required": [ "toolkit" ] + "required": [ "toolkit" ], + "allOf": + [ + { + "if": + { + "required": [ "capability" ], + "properties": + { + "capability": + { + "not": { "contains": { "const": "tools" } } + } + } + }, + "then": + { + "not": { "required": [ "tools" ] } + } + }, + { + "if": + { + "required": [ "capability" ], + "properties": + { + "capability": + { + "not": { "contains": { "const": "prompts" } } + } + } + }, + "then": + { + "not": { "required": [ "prompts" ] } + } + }, + { + "if": + { + "required": [ "capability" ], + "properties": + { + "capability": + { + "not": { "contains": { "const": "resources" } } + } + } + }, + "then": + { + "not": { "required": [ "resources" ] } + } + } + ] } } } @@ -300,7 +381,10 @@ { "properties": { - "toolkit": false + "toolkit": false, + "tools": false, + "prompts": false, + "resources": false } } } diff --git a/specs/binding-mcp.spec/src/test/java/io/aklivity/zilla/specs/binding/mcp/config/SchemaTest.java b/specs/binding-mcp.spec/src/test/java/io/aklivity/zilla/specs/binding/mcp/config/SchemaTest.java index 6988e8ea03..a6b7c3390b 100644 --- a/specs/binding-mcp.spec/src/test/java/io/aklivity/zilla/specs/binding/mcp/config/SchemaTest.java +++ b/specs/binding-mcp.spec/src/test/java/io/aklivity/zilla/specs/binding/mcp/config/SchemaTest.java @@ -57,12 +57,26 @@ public void shouldValidateProxy() assertThat(config, not(nullValue())); } + @Test + public void shouldValidateProxyFilter() + { + JsonObject config = schema.validate("proxy.filter.yaml"); + + assertThat(config, not(nullValue())); + } + @Test(expected = JsonValidatingException.class) public void shouldRejectProxyRouteMissingToolkit() { schema.validate("proxy.routes.missing.toolkit.invalid.yaml"); } + @Test(expected = JsonValidatingException.class) + public void shouldRejectProxyRouteFilterCapabilityMismatch() + { + schema.validate("proxy.routes.filter.capability.invalid.yaml"); + } + @Test(expected = JsonValidatingException.class) public void shouldRejectProxyWithAuthorization() { From ce9fef9aba76921f238170e8db6fcf9332e8f36e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Jun 2026 06:06:16 +0000 Subject: [PATCH 2/9] test(binding-mcp): IT for proxy rejecting unauthorized tool invocation (#1833) Add a McpProxyIT scenario proving end-to-end invocation enforcement: a tools/call to a name outside the route allow-set (bluesky__delete_account vs tools: [get_*]) is rejected by the proxy with a stream reset (`connect aborted`), while the lifecycle handshake completes normally. The route resolves to null in McpProxyItemFactory so no upstream stream is opened. Like other route-reject scenarios (e.g. http.unknown.path), the upstream server script is lifecycle-only and the rejection is zilla-enforced, so there is no complementary peer ApplicationIT pair. https://claude.ai/code/session_0115ZRrNA9iPG83DfsM1pduB --- .../mcp/internal/stream/McpProxyIT.java | 11 ++++ .../mcp/config/proxy.toolkit.filter.yaml | 26 ++++++++++ .../client.rpt | 51 +++++++++++++++++++ .../server.rpt | 38 ++++++++++++++ 4 files changed, 126 insertions(+) create mode 100644 specs/binding-mcp.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/config/proxy.toolkit.filter.yaml create mode 100644 specs/binding-mcp.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/streams/application/tools.call.toolkit.reject.unauthorized/client.rpt create mode 100644 specs/binding-mcp.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/streams/application/tools.call.toolkit.reject.unauthorized/server.rpt diff --git a/runtime/binding-mcp/src/test/java/io/aklivity/zilla/runtime/binding/mcp/internal/stream/McpProxyIT.java b/runtime/binding-mcp/src/test/java/io/aklivity/zilla/runtime/binding/mcp/internal/stream/McpProxyIT.java index 258d301129..c404198798 100644 --- a/runtime/binding-mcp/src/test/java/io/aklivity/zilla/runtime/binding/mcp/internal/stream/McpProxyIT.java +++ b/runtime/binding-mcp/src/test/java/io/aklivity/zilla/runtime/binding/mcp/internal/stream/McpProxyIT.java @@ -126,6 +126,17 @@ public void shouldCallToolWithToolkit() throws Exception k3po.finish(); } + @Test + @Configuration("proxy.toolkit.filter.yaml") + @Specification({ + "${app}/tools.call.toolkit.reject.unauthorized/client", + "${app}/tools.call.toolkit.reject.unauthorized/server" }) + @ScriptProperty("serverAddress \"zilla://streams/app1\"") + public void shouldRejectUnauthorizedToolCallWithToolkit() throws Exception + { + k3po.finish(); + } + @Test @Configuration("proxy.yaml") @Specification({ diff --git a/specs/binding-mcp.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/config/proxy.toolkit.filter.yaml b/specs/binding-mcp.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/config/proxy.toolkit.filter.yaml new file mode 100644 index 0000000000..bc55311f14 --- /dev/null +++ b/specs/binding-mcp.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/config/proxy.toolkit.filter.yaml @@ -0,0 +1,26 @@ +# +# Copyright 2021-2024 Aklivity Inc +# +# Licensed under the Aklivity Community License (the "License"); you may not use +# this file except in compliance with the License. You may obtain a copy of the +# License at +# +# https://www.aklivity.io/aklivity-community-license/ +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OF ANY KIND, either express or implied. See the License for the +# specific language governing permissions and limitations under the License. +# + +--- +name: test +bindings: + app0: + type: mcp + kind: proxy + routes: + - exit: app1 + when: + - toolkit: bluesky + tools: ["get_*"] diff --git a/specs/binding-mcp.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/streams/application/tools.call.toolkit.reject.unauthorized/client.rpt b/specs/binding-mcp.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/streams/application/tools.call.toolkit.reject.unauthorized/client.rpt new file mode 100644 index 0000000000..9e2ca2b929 --- /dev/null +++ b/specs/binding-mcp.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/streams/application/tools.call.toolkit.reject.unauthorized/client.rpt @@ -0,0 +1,51 @@ +# +# Copyright 2021-2024 Aklivity Inc +# +# Licensed under the Aklivity Community License (the "License"); you may not use +# this file except in compliance with the License. You may obtain a copy of the +# License at +# +# https://www.aklivity.io/aklivity-community-license/ +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OF ANY KIND, either express or implied. See the License for the +# specific language governing permissions and limitations under the License. +# + +connect "zilla://streams/app0" + option zilla:window 8192 + option zilla:transmission "half-duplex" + +write zilla:begin.ext ${mcp:beginEx() + .typeId(zilla:id("mcp")) + .lifecycle() + .build() + .build()} + +connected + +read zilla:begin.ext ${mcp:matchBeginEx() + .typeId(zilla:id("mcp")) + .lifecycle() + .sessionId("session-1") + .build() + .build()} + +read notify LIFECYCLE_INITIALIZED + +connect await LIFECYCLE_INITIALIZED + "zilla://streams/app0" + option zilla:window 8192 + option zilla:transmission "half-duplex" + +write zilla:begin.ext ${mcp:beginEx() + .typeId(zilla:id("mcp")) + .toolsCall() + .sessionId("session-1") + .name("bluesky__delete_account") + .contentLength(40) + .build() + .build()} + +connect aborted diff --git a/specs/binding-mcp.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/streams/application/tools.call.toolkit.reject.unauthorized/server.rpt b/specs/binding-mcp.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/streams/application/tools.call.toolkit.reject.unauthorized/server.rpt new file mode 100644 index 0000000000..d1d7c237da --- /dev/null +++ b/specs/binding-mcp.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/streams/application/tools.call.toolkit.reject.unauthorized/server.rpt @@ -0,0 +1,38 @@ +# +# Copyright 2021-2024 Aklivity Inc +# +# Licensed under the Aklivity Community License (the "License"); you may not use +# this file except in compliance with the License. You may obtain a copy of the +# License at +# +# https://www.aklivity.io/aklivity-community-license/ +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OF ANY KIND, either express or implied. See the License for the +# specific language governing permissions and limitations under the License. +# + +property serverAddress "zilla://streams/app0" + +accept ${serverAddress} + option zilla:window 8192 + option zilla:transmission "half-duplex" + +accepted + +read zilla:begin.ext ${mcp:matchBeginEx() + .typeId(zilla:id("mcp")) + .lifecycle() + .build() + .build()} + +connected + +write zilla:begin.ext ${mcp:beginEx() + .typeId(zilla:id("mcp")) + .lifecycle() + .sessionId("session-1") + .build() + .build()} +write flush From 8af08f5032d979465d556c7fdafd1ef62d8e5f1c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Jun 2026 16:28:23 +0000 Subject: [PATCH 3/9] feat(binding-mcp): advisory list-federation filtering by allow-set (#1833) Filter tools/list, prompts/list and resources/list federation results by the per-route allow-set, so listings only advertise permitted primitives. The federated emitter streams each item chunk-by-chunk, so to drop an item by name without breaking the backpressure/retention machine, filtering defers item emission: in filtering mode an item is scanned (without emitting) until its name/uri is decoded, then either emitted from the start (begin + prefix-injected body, reusing the existing backpressure path) or skipped. Non-filtering routes keep the exact original streaming path, so unfiltered lists are byte-identical and unaffected. Per item, the name is taken from the JSON parser, so field order does not matter. Items too large to inspect before the slot boundary pass through unfiltered (advisory filter; invocation enforcement remains the security boundary). The cache path inherits filtering because hydration runs through the same McpListServer, so cache entries are populated already filtered. McpRouteConfig gains admits(kind|capability, name) and filters(kind), threaded to the list client via McpRoutePrefix. https://claude.ai/code/session_0115ZRrNA9iPG83DfsM1pduB --- .../mcp/internal/config/McpBindingConfig.java | 2 +- .../internal/config/McpConditionMatcher.java | 13 ++ .../mcp/internal/config/McpRouteConfig.java | 49 +++++ .../mcp/internal/config/McpRoutePrefix.java | 3 +- .../internal/stream/McpProxyListFactory.java | 185 +++++++++++++++++- .../config/McpConditionMatcherTest.java | 31 +++ .../mcp/internal/stream/McpProxyIT.java | 11 ++ .../tools.list.toolkit.filtered/client.rpt | 66 +++++++ .../tools.list.toolkit.filtered/server.rpt | 75 +++++++ 9 files changed, 427 insertions(+), 8 deletions(-) create mode 100644 specs/binding-mcp.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/streams/application/tools.list.toolkit.filtered/client.rpt create mode 100644 specs/binding-mcp.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/streams/application/tools.list.toolkit.filtered/server.rpt diff --git a/runtime/binding-mcp/src/main/java/io/aklivity/zilla/runtime/binding/mcp/internal/config/McpBindingConfig.java b/runtime/binding-mcp/src/main/java/io/aklivity/zilla/runtime/binding/mcp/internal/config/McpBindingConfig.java index 77c9e9db0c..2cd61b5a65 100644 --- a/runtime/binding-mcp/src/main/java/io/aklivity/zilla/runtime/binding/mcp/internal/config/McpBindingConfig.java +++ b/runtime/binding-mcp/src/main/java/io/aklivity/zilla/runtime/binding/mcp/internal/config/McpBindingConfig.java @@ -207,7 +207,7 @@ public List resolveAll( { if (route.authorized(authorization) && route.serves(capability)) { - result.add(new McpRoutePrefix(route.id, new String8FW(route.prefix(kind)))); + result.add(new McpRoutePrefix(route.id, new String8FW(route.prefix(kind)), route)); } } result.sort(Comparator.comparing(p -> p.prefix().asString())); diff --git a/runtime/binding-mcp/src/main/java/io/aklivity/zilla/runtime/binding/mcp/internal/config/McpConditionMatcher.java b/runtime/binding-mcp/src/main/java/io/aklivity/zilla/runtime/binding/mcp/internal/config/McpConditionMatcher.java index 5a99485d38..147e1f62d5 100644 --- a/runtime/binding-mcp/src/main/java/io/aklivity/zilla/runtime/binding/mcp/internal/config/McpConditionMatcher.java +++ b/runtime/binding-mcp/src/main/java/io/aklivity/zilla/runtime/binding/mcp/internal/config/McpConditionMatcher.java @@ -105,6 +105,19 @@ boolean serves( return prefix(capability) != null; } + boolean admits( + String capability, + String name) + { + return serves(capability) && admits(allow(capability), name); + } + + boolean filters( + String capability) + { + return serves(capability) && allow(capability) != null; + } + String prefix( String capability) { diff --git a/runtime/binding-mcp/src/main/java/io/aklivity/zilla/runtime/binding/mcp/internal/config/McpRouteConfig.java b/runtime/binding-mcp/src/main/java/io/aklivity/zilla/runtime/binding/mcp/internal/config/McpRouteConfig.java index 91a0a367c6..c246f1475d 100644 --- a/runtime/binding-mcp/src/main/java/io/aklivity/zilla/runtime/binding/mcp/internal/config/McpRouteConfig.java +++ b/runtime/binding-mcp/src/main/java/io/aklivity/zilla/runtime/binding/mcp/internal/config/McpRouteConfig.java @@ -192,6 +192,55 @@ boolean serves( return result; } + public boolean admits( + int kind, + String name) + { + return admits(capabilityOf(kind), name); + } + + public boolean filters( + int kind) + { + final String capability = capabilityOf(kind); + boolean result = false; + + if (capability != null) + { + for (McpConditionMatcher matcher : matchers) + { + if (matcher.filters(capability)) + { + result = true; + break; + } + } + } + + return result; + } + + public boolean admits( + String capability, + String name) + { + boolean result = matchers.isEmpty(); + + if (!result) + { + for (McpConditionMatcher matcher : matchers) + { + if (matcher.admits(capability, name)) + { + result = true; + break; + } + } + } + + return result; + } + static String capabilityOf( McpBeginExFW beginEx) { diff --git a/runtime/binding-mcp/src/main/java/io/aklivity/zilla/runtime/binding/mcp/internal/config/McpRoutePrefix.java b/runtime/binding-mcp/src/main/java/io/aklivity/zilla/runtime/binding/mcp/internal/config/McpRoutePrefix.java index f4890542df..6d1737cfb6 100644 --- a/runtime/binding-mcp/src/main/java/io/aklivity/zilla/runtime/binding/mcp/internal/config/McpRoutePrefix.java +++ b/runtime/binding-mcp/src/main/java/io/aklivity/zilla/runtime/binding/mcp/internal/config/McpRoutePrefix.java @@ -18,6 +18,7 @@ public record McpRoutePrefix( long resolvedId, - String8FW prefix) + String8FW prefix, + McpRouteConfig route) { } diff --git a/runtime/binding-mcp/src/main/java/io/aklivity/zilla/runtime/binding/mcp/internal/stream/McpProxyListFactory.java b/runtime/binding-mcp/src/main/java/io/aklivity/zilla/runtime/binding/mcp/internal/stream/McpProxyListFactory.java index 198b54a3e4..f1c091076d 100644 --- a/runtime/binding-mcp/src/main/java/io/aklivity/zilla/runtime/binding/mcp/internal/stream/McpProxyListFactory.java +++ b/runtime/binding-mcp/src/main/java/io/aklivity/zilla/runtime/binding/mcp/internal/stream/McpProxyListFactory.java @@ -34,6 +34,7 @@ import io.aklivity.zilla.runtime.binding.mcp.internal.McpConfiguration; import io.aklivity.zilla.runtime.binding.mcp.internal.config.McpBindingConfig; +import io.aklivity.zilla.runtime.binding.mcp.internal.config.McpRouteConfig; import io.aklivity.zilla.runtime.binding.mcp.internal.config.McpRoutePrefix; import io.aklivity.zilla.runtime.binding.mcp.internal.stream.McpProxyLifecycleFactory.McpLifecycleClient; import io.aklivity.zilla.runtime.binding.mcp.internal.stream.McpProxyLifecycleFactory.McpLifecycleServer; @@ -103,6 +104,9 @@ abstract class McpProxyListFactory implements BindingHandler private final McpListClientDecoder decodeSkipObject = this::decodeSkipObject; private final McpListClientDecoder decodeItems = this::decodeItems; private final McpListClientDecoder decodeItemStart = this::decodeItemStart; + private final McpListClientDecoder decodeItemScan = this::decodeItemScan; + private final McpListClientDecoder decodeItemName = this::decodeItemName; + private final McpListClientDecoder decodeItemDrop = this::decodeItemDrop; private final McpListClientDecoder decodeItemBody = this::decodeItemBody; private final McpListClientDecoder decodeItemId = this::decodeItemId; private final McpListClientDecoder decodeItemFinalize = this::decodeItemFinalize; @@ -171,7 +175,7 @@ public final MessageConsumer newStream( { final List prefixes = binding.resolveAll(beginEx, authorization) .stream() - .map(r -> new McpRoutePrefix(r.id, new String8FW(r.prefix(kind)))) + .map(r -> new McpRoutePrefix(r.id, new String8FW(r.prefix(kind)), r)) .toList(); newStream = new McpListServer( lifecycle, @@ -239,6 +243,8 @@ private final class McpListClient implements McpRouteRequest private final long originId; private final long routedId; private final String8FW prefix; + private final McpRouteConfig route; + private final boolean filtering; private final McpLifecycleClient lifecycle; private long initialId; private long replyId; @@ -267,16 +273,21 @@ private final class McpListClient implements McpRouteRequest private McpListClientDecoder decoder = decodeInit; private String arrayKey; private String idKey; + private boolean itemBegun; + private boolean itemDeferred; private McpListClient( McpListServer server, long routedId, - String8FW prefix) + String8FW prefix, + McpRouteConfig route) { this.server = server; this.originId = server.lifecycle.originId; this.routedId = routedId; this.prefix = prefix; + this.route = route; + this.filtering = route != null && route.filters(kind); this.lifecycle = server.lifecycle.supplyClient(routedId); } @@ -665,7 +676,11 @@ private void cleanupClientSlot() private void onDecodedItemBegin( long traceId) { - server.doEncodeBeginItem(traceId); + if (!itemBegun) + { + itemBegun = true; + server.doEncodeBeginItem(traceId); + } } private int onDecodedItemChunk( @@ -905,9 +920,19 @@ private int decodeItemStart( { case START_OBJECT: client.decodedItemProgress = decodedItemProgress - 1; - client.onDecodedItemBegin(traceId); client.decodeItemDepth = 1; - client.decoder = decodeItemBody; + client.itemBegun = false; + if (client.filtering) + { + client.itemDeferred = true; + client.decoder = decodeItemScan; + } + else + { + client.itemDeferred = false; + client.onDecodedItemBegin(traceId); + client.decoder = decodeItemBody; + } break decode; case END_ARRAY: client.decodeDepth--; @@ -921,6 +946,154 @@ private int decodeItemStart( return offset + (int) (parser.getLocation().getStreamOffset() - client.decodedParserProgress); } + private int decodeItemScan( + McpListClient client, + long traceId, + long authorization, + long budgetId, + int reserved, + DirectBuffer buffer, + int offset, + int progress, + int limit) + { + final JsonParser parser = client.decodableJson; + + decode: + while (parser.hasNext()) + { + final JsonParser.Event event = parser.next(); + switch (event) + { + case START_OBJECT: + case START_ARRAY: + client.decodeItemDepth++; + break; + case END_ARRAY: + client.decodeItemDepth--; + break; + case END_OBJECT: + client.decodeItemDepth--; + if (client.decodeItemDepth == 0) + { + client.onDecodedItemBegin(traceId); + client.itemDeferred = false; + client.decoder = decodeItemFinalize; + break decode; + } + break; + case KEY_NAME: + if (client.decodeItemDepth == 1 && + client.idKey.equals(parser.getString())) + { + client.decoder = decodeItemName; + break decode; + } + break; + default: + break; + } + } + + return offset + (int) (parser.getLocation().getStreamOffset() - client.decodedParserProgress); + } + + private int decodeItemName( + McpListClient client, + long traceId, + long authorization, + long budgetId, + int reserved, + DirectBuffer buffer, + int offset, + int progress, + int limit) + { + final JsonParser parser = client.decodableJson; + final long decodedKeyProgress = parser.getLocation().getStreamOffset(); + + if (parser.hasNext()) + { + final long decodedValueProgress = parser.getLocation().getStreamOffset(); + final JsonParser.Event event = parser.next(); + if (event == JsonParser.Event.VALUE_STRING) + { + final String name = parser.getString(); + if (client.route == null || client.route.admits(kind, name)) + { + client.onDecodedItemBegin(traceId); + client.itemDeferred = false; + final int decodedKeyOffset = offset + (int) (decodedKeyProgress - client.decodedParserProgress); + final int decodedValueOffset = offset + (int) (decodedValueProgress - client.decodedParserProgress); + final int decodedOpenQuote = indexOfByte(buffer, decodedKeyOffset, decodedValueOffset, (byte) '"'); + final int decodedContent = (decodedOpenQuote != -1 ? decodedOpenQuote : decodedValueOffset) + 1; + final int decodedOffset = + offset + (int) (client.decodedItemProgress - client.decodedParserProgress); + client.onDecodedItemChunk(buffer, decodedOffset, decodedContent - decodedOffset, traceId); + client.onDecodedItemChunk(client.prefix.value(), 0, client.prefix.length(), traceId); + client.decodedItemProgress = client.decodedParserProgress + (long) (decodedContent - offset); + client.decoder = decodeItemBody; + } + else + { + client.decodedItemProgress = -1; + client.decoder = decodeItemDrop; + } + } + else + { + client.onDecodedItemBegin(traceId); + client.itemDeferred = false; + client.decoder = decodeItemBody; + } + } + + return offset + (int) (parser.getLocation().getStreamOffset() - client.decodedParserProgress); + } + + private int decodeItemDrop( + McpListClient client, + long traceId, + long authorization, + long budgetId, + int reserved, + DirectBuffer buffer, + int offset, + int progress, + int limit) + { + final JsonParser parser = client.decodableJson; + + decode: + while (parser.hasNext()) + { + final JsonParser.Event event = parser.next(); + switch (event) + { + case START_OBJECT: + case START_ARRAY: + client.decodeItemDepth++; + break; + case END_ARRAY: + client.decodeItemDepth--; + break; + case END_OBJECT: + client.decodeItemDepth--; + if (client.decodeItemDepth == 0) + { + client.decodedItemProgress = -1; + client.decoder = decodeItemStart; + break decode; + } + break; + default: + break; + } + } + + return offset + (int) (parser.getLocation().getStreamOffset() - client.decodedParserProgress); + } + private int decodeItemBody( McpListClient client, long traceId, @@ -1392,7 +1565,7 @@ private void onNextClient( doEncodeEndItems(traceId); return; } - client = new McpListClient(this, route.resolvedId(), route.prefix()); + client = new McpListClient(this, route.resolvedId(), route.prefix(), route.route()); client.doClientBegin(traceId); } diff --git a/runtime/binding-mcp/src/test/java/io/aklivity/zilla/runtime/binding/mcp/internal/config/McpConditionMatcherTest.java b/runtime/binding-mcp/src/test/java/io/aklivity/zilla/runtime/binding/mcp/internal/config/McpConditionMatcherTest.java index da562a47da..a83e0dc794 100644 --- a/runtime/binding-mcp/src/test/java/io/aklivity/zilla/runtime/binding/mcp/internal/config/McpConditionMatcherTest.java +++ b/runtime/binding-mcp/src/test/java/io/aklivity/zilla/runtime/binding/mcp/internal/config/McpConditionMatcherTest.java @@ -99,6 +99,37 @@ public void shouldServeCapabilityIndependentOfAllowSet() assertFalse(matcher.serves("resources")); } + @Test + public void shouldAdmitNamesByAllowSet() + { + McpConditionConfig condition = McpConditionConfig.builder() + .toolkit("github") + .capability(asList("tools", "resources")) + .tools(asList("get_*")) + .build(); + McpConditionMatcher matcher = new McpConditionMatcher(condition); + + assertTrue(matcher.admits("tools", "get_user")); + assertFalse(matcher.admits("tools", "delete_repo")); + assertTrue(matcher.admits("resources", "anything")); + assertFalse(matcher.admits("prompts", "anything")); + } + + @Test + public void shouldReportFilteringByAllowSet() + { + McpConditionConfig condition = McpConditionConfig.builder() + .toolkit("github") + .capability(asList("tools", "resources")) + .tools(asList("get_*")) + .build(); + McpConditionMatcher matcher = new McpConditionMatcher(condition); + + assertTrue(matcher.filters("tools")); + assertFalse(matcher.filters("resources")); + assertFalse(matcher.filters("prompts")); + } + @Test public void shouldNotMatchUnservedCapability() { diff --git a/runtime/binding-mcp/src/test/java/io/aklivity/zilla/runtime/binding/mcp/internal/stream/McpProxyIT.java b/runtime/binding-mcp/src/test/java/io/aklivity/zilla/runtime/binding/mcp/internal/stream/McpProxyIT.java index c404198798..aad6f0f66f 100644 --- a/runtime/binding-mcp/src/test/java/io/aklivity/zilla/runtime/binding/mcp/internal/stream/McpProxyIT.java +++ b/runtime/binding-mcp/src/test/java/io/aklivity/zilla/runtime/binding/mcp/internal/stream/McpProxyIT.java @@ -159,6 +159,17 @@ public void shouldListToolsWithToolkit() throws Exception k3po.finish(); } + @Test + @Configuration("proxy.toolkit.filter.yaml") + @Specification({ + "${app}/tools.list.toolkit.filtered/client", + "${app}/tools.list.toolkit.filtered/server" }) + @ScriptProperty("serverAddress \"zilla://streams/app1\"") + public void shouldListToolsFilteredByAllowSet() throws Exception + { + k3po.finish(); + } + @Test @Configuration("proxy.toolkit.multi.yaml") @Specification({ diff --git a/specs/binding-mcp.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/streams/application/tools.list.toolkit.filtered/client.rpt b/specs/binding-mcp.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/streams/application/tools.list.toolkit.filtered/client.rpt new file mode 100644 index 0000000000..5d562019c9 --- /dev/null +++ b/specs/binding-mcp.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/streams/application/tools.list.toolkit.filtered/client.rpt @@ -0,0 +1,66 @@ +# +# Copyright 2021-2024 Aklivity Inc +# +# Licensed under the Aklivity Community License (the "License"); you may not use +# this file except in compliance with the License. You may obtain a copy of the +# License at +# +# https://www.aklivity.io/aklivity-community-license/ +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OF ANY KIND, either express or implied. See the License for the +# specific language governing permissions and limitations under the License. +# + +connect "zilla://streams/app0" + option zilla:window 8192 + option zilla:transmission "half-duplex" + +write zilla:begin.ext ${mcp:beginEx() + .typeId(zilla:id("mcp")) + .lifecycle() + .build() + .build()} + +connected + +read zilla:begin.ext ${mcp:matchBeginEx() + .typeId(zilla:id("mcp")) + .lifecycle() + .sessionId("session-1") + .build() + .build()} + +read notify LIFECYCLE_INITIALIZED + +connect await LIFECYCLE_INITIALIZED + "zilla://streams/app0" + option zilla:window 8192 + option zilla:transmission "half-duplex" + +write zilla:begin.ext ${mcp:beginEx() + .typeId(zilla:id("mcp")) + .toolsList() + .sessionId("session-1") + .build() + .build()} + +connected + +read '{"tools":' + '[' + '{' + '"name":"bluesky__get_weather",' + '"title":"Weather Information Provider"' + '}' + ',' + '{' + '"name":"bluesky__get_forecast",' + '"title":"Forecast Provider"' + '}' + ']' + '}' +read closed + +write close diff --git a/specs/binding-mcp.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/streams/application/tools.list.toolkit.filtered/server.rpt b/specs/binding-mcp.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/streams/application/tools.list.toolkit.filtered/server.rpt new file mode 100644 index 0000000000..70a278a9c0 --- /dev/null +++ b/specs/binding-mcp.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/streams/application/tools.list.toolkit.filtered/server.rpt @@ -0,0 +1,75 @@ +# +# Copyright 2021-2024 Aklivity Inc +# +# Licensed under the Aklivity Community License (the "License"); you may not use +# this file except in compliance with the License. You may obtain a copy of the +# License at +# +# https://www.aklivity.io/aklivity-community-license/ +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OF ANY KIND, either express or implied. See the License for the +# specific language governing permissions and limitations under the License. +# + +property serverAddress "zilla://streams/app0" + +accept ${serverAddress} + option zilla:window 8192 + option zilla:transmission "half-duplex" + +accepted + +read zilla:begin.ext ${mcp:matchBeginEx() + .typeId(zilla:id("mcp")) + .lifecycle() + .build() + .build()} + +connected + +write zilla:begin.ext ${mcp:beginEx() + .typeId(zilla:id("mcp")) + .lifecycle() + .sessionId("session-1") + .build() + .build()} +write flush + +accepted + +read zilla:begin.ext ${mcp:matchBeginEx() + .typeId(zilla:id("mcp")) + .toolsList() + .sessionId("session-1") + .build() + .build()} + +connected + +write flush + +write '{"tools":' + '[' + '{' + '"name":"get_weather",' + '"title":"Weather Information Provider"' + '}' + ',' + '{' + '"name":"delete_account",' + '"title":"Account Deletion"' + '}' + ',' + '{' + '"name":"get_forecast",' + '"title":"Forecast Provider"' + '}' + ']' + '}' +write flush + +write close + +read closed From eba0b3c2e8179289d59d46270ce109d19ed9dcde Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Jun 2026 16:53:41 +0000 Subject: [PATCH 4/9] test(binding-mcp): IT for cache-path list filtering at hydration (#1833) Add a McpProxyCacheIT scenario proving the cache path filters: a client tools/list triggers hydration, the upstream returns a full 3-tool list, zilla filters it to the 2 tools matching the route allow-set (get_*), prefixes them (bluesky__), stores the filtered result, and serves it back. Confirms cache entries are populated already-filtered via the shared hydration McpListServer path. https://claude.ai/code/session_0115ZRrNA9iPG83DfsM1pduB --- .../mcp/internal/stream/McpProxyCacheIT.java | 12 +++ .../config/proxy.cache.toolkit.filter.yaml | 32 ++++++++ .../client.rpt | 66 ++++++++++++++++ .../server.rpt | 75 +++++++++++++++++++ 4 files changed, 185 insertions(+) create mode 100644 specs/binding-mcp.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/config/proxy.cache.toolkit.filter.yaml create mode 100644 specs/binding-mcp.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/streams/application/cache.serve.tools.list.toolkit.filtered/client.rpt create mode 100644 specs/binding-mcp.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/streams/application/cache.serve.tools.list.toolkit.filtered/server.rpt diff --git a/runtime/binding-mcp/src/test/java/io/aklivity/zilla/runtime/binding/mcp/internal/stream/McpProxyCacheIT.java b/runtime/binding-mcp/src/test/java/io/aklivity/zilla/runtime/binding/mcp/internal/stream/McpProxyCacheIT.java index fc14476e31..dc8dadef5f 100644 --- a/runtime/binding-mcp/src/test/java/io/aklivity/zilla/runtime/binding/mcp/internal/stream/McpProxyCacheIT.java +++ b/runtime/binding-mcp/src/test/java/io/aklivity/zilla/runtime/binding/mcp/internal/stream/McpProxyCacheIT.java @@ -173,6 +173,18 @@ public void shouldServeToolsList() throws Exception k3po.finish(); } + @Test + @Configuration("proxy.cache.toolkit.filter.yaml") + @Specification({ + "${app}/cache.serve.tools.list.toolkit.filtered/server", + "${app}/cache.serve.tools.list.toolkit.filtered/client" }) + @ScriptProperty("serverAddress \"zilla://streams/app1\"") + @Configure(name = MCP_HYDRATE_FILTER_NAME, value = "tools") + public void shouldServeToolsListFilteredByAllowSet() throws Exception + { + k3po.finish(); + } + @Test @Configuration("proxy.cache.yaml") @Specification({ diff --git a/specs/binding-mcp.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/config/proxy.cache.toolkit.filter.yaml b/specs/binding-mcp.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/config/proxy.cache.toolkit.filter.yaml new file mode 100644 index 0000000000..6c6ccec616 --- /dev/null +++ b/specs/binding-mcp.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/config/proxy.cache.toolkit.filter.yaml @@ -0,0 +1,32 @@ +# +# Copyright 2021-2024 Aklivity Inc +# +# Licensed under the Aklivity Community License (the "License"); you may not use +# this file except in compliance with the License. You may obtain a copy of the +# License at +# +# https://www.aklivity.io/aklivity-community-license/ +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OF ANY KIND, either express or implied. See the License for the +# specific language governing permissions and limitations under the License. +# + +--- +name: test +stores: + memory0: + type: test +bindings: + app0: + type: mcp + kind: proxy + options: + cache: + store: memory0 + routes: + - exit: app1 + when: + - toolkit: bluesky + tools: ["get_*"] diff --git a/specs/binding-mcp.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/streams/application/cache.serve.tools.list.toolkit.filtered/client.rpt b/specs/binding-mcp.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/streams/application/cache.serve.tools.list.toolkit.filtered/client.rpt new file mode 100644 index 0000000000..6c98c5251e --- /dev/null +++ b/specs/binding-mcp.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/streams/application/cache.serve.tools.list.toolkit.filtered/client.rpt @@ -0,0 +1,66 @@ +# +# Copyright 2021-2024 Aklivity Inc +# +# Licensed under the Aklivity Community License (the "License"); you may not use +# this file except in compliance with the License. You may obtain a copy of the +# License at +# +# https://www.aklivity.io/aklivity-community-license/ +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OF ANY KIND, either express or implied. See the License for the +# specific language governing permissions and limitations under the License. +# + +connect "zilla://streams/app0" + option zilla:window 8192 + option zilla:transmission "half-duplex" + +write zilla:begin.ext ${mcp:beginEx() + .typeId(zilla:id("mcp")) + .lifecycle() + .build() + .build()} + +connected + +read zilla:begin.ext ${mcp:matchBeginEx() + .typeId(zilla:id("mcp")) + .lifecycle() + .sessionId("agent-1") + .build() + .build()} + +read notify LIFECYCLE_INITIALIZED + +connect await LIFECYCLE_INITIALIZED + "zilla://streams/app0" + option zilla:window 8192 + option zilla:transmission "half-duplex" + +write zilla:begin.ext ${mcp:beginEx() + .typeId(zilla:id("mcp")) + .toolsList() + .sessionId("agent-1") + .build() + .build()} + +connected + +read '{"tools":' + '[' + '{' + '"name":"bluesky__get_weather",' + '"title":"Weather Information Provider"' + '}' + ',' + '{' + '"name":"bluesky__get_forecast",' + '"title":"Forecast Provider"' + '}' + ']' + '}' +read closed + +write close diff --git a/specs/binding-mcp.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/streams/application/cache.serve.tools.list.toolkit.filtered/server.rpt b/specs/binding-mcp.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/streams/application/cache.serve.tools.list.toolkit.filtered/server.rpt new file mode 100644 index 0000000000..a3bbcd8c6c --- /dev/null +++ b/specs/binding-mcp.spec/src/main/scripts/io/aklivity/zilla/specs/binding/mcp/streams/application/cache.serve.tools.list.toolkit.filtered/server.rpt @@ -0,0 +1,75 @@ +# +# Copyright 2021-2024 Aklivity Inc +# +# Licensed under the Aklivity Community License (the "License"); you may not use +# this file except in compliance with the License. You may obtain a copy of the +# License at +# +# https://www.aklivity.io/aklivity-community-license/ +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OF ANY KIND, either express or implied. See the License for the +# specific language governing permissions and limitations under the License. +# + +property serverAddress "zilla://streams/app0" + +accept ${serverAddress} + option zilla:window 8192 + option zilla:transmission "half-duplex" + +accepted + +read zilla:begin.ext ${mcp:matchBeginEx() + .typeId(zilla:id("mcp")) + .lifecycle() + .build() + .build()} + +connected + +write zilla:begin.ext ${mcp:beginEx() + .typeId(zilla:id("mcp")) + .lifecycle() + .sessionId("hydrate-1") + .build() + .build()} +write flush + +accepted + +read zilla:begin.ext ${mcp:matchBeginEx() + .typeId(zilla:id("mcp")) + .toolsList() + .sessionId("hydrate-1") + .build() + .build()} + +connected + +write flush + +write '{"tools":' + '[' + '{' + '"name":"get_weather",' + '"title":"Weather Information Provider"' + '}' + ',' + '{' + '"name":"delete_account",' + '"title":"Account Deletion"' + '}' + ',' + '{' + '"name":"get_forecast",' + '"title":"Forecast Provider"' + '}' + ']' + '}' +write flush + +write close + +read closed From 45d30f9892837f5de8422c96d32cfb087e8c5d45 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Jun 2026 17:27:17 +0000 Subject: [PATCH 5/9] chore: re-trigger CI Re-kick after an unrelated flaky failure in specs/filesystem-http.spec (ApplicationIT.shouldWatch timed out after 5s); binding-mcp modules were skipped, not failed. https://claude.ai/code/session_0115ZRrNA9iPG83DfsM1pduB From d6442e7a1694fd1cba45e74afd2e66c4611b2080 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Jun 2026 17:46:20 +0000 Subject: [PATCH 6/9] chore: re-trigger CI Re-kick after unrelated flaky failures: Build (25) hit a timing flake in McpServerIT.shouldCallToolElicitCompleted (SSE/elicitation, passes locally) and Analyze (java) failed in the CodeQL build/extraction step (regular build compiled all modules). Neither relates to this PR's changes. https://claude.ai/code/session_0115ZRrNA9iPG83DfsM1pduB From 2560d38b38ff78e1b8308a1ce80533148c7f00f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Jun 2026 18:21:00 +0000 Subject: [PATCH 7/9] test(binding-mcp): temporarily ignore flaky shouldCallToolElicitCompleted Pre-existing CI timing flake in the elicitation/SSE flow, unrelated to the allow-set changes in this PR (passes locally; fails intermittently in CI with ComparisonFailure/TestTimedOut). Ignored to unblock the PR build while the real fix is prepared separately. REVERT before merge once the fix lands on develop. Marker: TODO(#1841). https://claude.ai/code/session_0115ZRrNA9iPG83DfsM1pduB --- .../zilla/runtime/binding/mcp/internal/stream/McpServerIT.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/runtime/binding-mcp/src/test/java/io/aklivity/zilla/runtime/binding/mcp/internal/stream/McpServerIT.java b/runtime/binding-mcp/src/test/java/io/aklivity/zilla/runtime/binding/mcp/internal/stream/McpServerIT.java index 10e979a495..600a5a89bb 100644 --- a/runtime/binding-mcp/src/test/java/io/aklivity/zilla/runtime/binding/mcp/internal/stream/McpServerIT.java +++ b/runtime/binding-mcp/src/test/java/io/aklivity/zilla/runtime/binding/mcp/internal/stream/McpServerIT.java @@ -26,6 +26,7 @@ import static java.util.concurrent.TimeUnit.SECONDS; import static org.junit.rules.RuleChain.outerRule; +import org.junit.Ignore; import org.junit.Rule; import org.junit.Test; import org.junit.rules.DisableOnDebug; @@ -304,6 +305,8 @@ public void shouldCallToolWithTimeout() throws Exception k3po.finish(); } + @Ignore("TODO(#1841): temporarily ignored due to a pre-existing CI timing flake in the " + + "elicitation/SSE flow, unrelated to this PR; remove once the fix is merged to develop") @Test @Configuration("server.timeout.yaml") @Specification({ From 891c76280e75be71521614eb3db1eba39e492ee3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Jun 2026 22:34:57 +0000 Subject: [PATCH 8/9] test(binding-mcp): re-enable shouldCallToolElicitCompleted after #1842 fix Reverts the temporary @Ignore now that the elicitation/SSE timing fix is merged to develop (#1842) and merged into this branch. The merge had slid the @Ignore onto the newly-added shouldCallToolWithUpstreamResumableFlush; removing it restores both tests as active. https://claude.ai/code/session_0115ZRrNA9iPG83DfsM1pduB --- .../zilla/runtime/binding/mcp/internal/stream/McpServerIT.java | 3 --- 1 file changed, 3 deletions(-) diff --git a/runtime/binding-mcp/src/test/java/io/aklivity/zilla/runtime/binding/mcp/internal/stream/McpServerIT.java b/runtime/binding-mcp/src/test/java/io/aklivity/zilla/runtime/binding/mcp/internal/stream/McpServerIT.java index 8e4243884d..8415b429e7 100644 --- a/runtime/binding-mcp/src/test/java/io/aklivity/zilla/runtime/binding/mcp/internal/stream/McpServerIT.java +++ b/runtime/binding-mcp/src/test/java/io/aklivity/zilla/runtime/binding/mcp/internal/stream/McpServerIT.java @@ -26,7 +26,6 @@ import static java.util.concurrent.TimeUnit.SECONDS; import static org.junit.rules.RuleChain.outerRule; -import org.junit.Ignore; import org.junit.Rule; import org.junit.Test; import org.junit.rules.DisableOnDebug; @@ -305,8 +304,6 @@ public void shouldCallToolWithTimeout() throws Exception k3po.finish(); } - @Ignore("TODO(#1841): temporarily ignored due to a pre-existing CI timing flake in the " + - "elicitation/SSE flow, unrelated to this PR; remove once the fix is merged to develop") @Test @Configuration("server.timeout.yaml") @Specification({ From a9ad55628805a906f30fe79e75a8c084ec7ba1cc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 5 Jun 2026 23:21:27 +0000 Subject: [PATCH 9/9] refactor(binding-mcp): pass list-item admit check as a functional interface Address review feedback: McpListClient now takes a Predicate admit function instead of holding the whole McpRouteConfig. The predicate is built once per client in onNextClient (null when the route has no allow-set for the capability), and the deferred-scan path engages when it is non-null. https://claude.ai/code/session_0115ZRrNA9iPG83DfsM1pduB --- .../internal/stream/McpProxyListFactory.java | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/runtime/binding-mcp/src/main/java/io/aklivity/zilla/runtime/binding/mcp/internal/stream/McpProxyListFactory.java b/runtime/binding-mcp/src/main/java/io/aklivity/zilla/runtime/binding/mcp/internal/stream/McpProxyListFactory.java index f1c091076d..6d42e25d30 100644 --- a/runtime/binding-mcp/src/main/java/io/aklivity/zilla/runtime/binding/mcp/internal/stream/McpProxyListFactory.java +++ b/runtime/binding-mcp/src/main/java/io/aklivity/zilla/runtime/binding/mcp/internal/stream/McpProxyListFactory.java @@ -24,6 +24,7 @@ import java.util.function.LongFunction; import java.util.function.LongSupplier; import java.util.function.LongUnaryOperator; +import java.util.function.Predicate; import jakarta.json.stream.JsonParser; import jakarta.json.stream.JsonParserFactory; @@ -243,8 +244,7 @@ private final class McpListClient implements McpRouteRequest private final long originId; private final long routedId; private final String8FW prefix; - private final McpRouteConfig route; - private final boolean filtering; + private final Predicate admits; private final McpLifecycleClient lifecycle; private long initialId; private long replyId; @@ -280,14 +280,13 @@ private McpListClient( McpListServer server, long routedId, String8FW prefix, - McpRouteConfig route) + Predicate admits) { this.server = server; this.originId = server.lifecycle.originId; this.routedId = routedId; this.prefix = prefix; - this.route = route; - this.filtering = route != null && route.filters(kind); + this.admits = admits; this.lifecycle = server.lifecycle.supplyClient(routedId); } @@ -922,7 +921,7 @@ private int decodeItemStart( client.decodedItemProgress = decodedItemProgress - 1; client.decodeItemDepth = 1; client.itemBegun = false; - if (client.filtering) + if (client.admits != null) { client.itemDeferred = true; client.decoder = decodeItemScan; @@ -1019,7 +1018,7 @@ private int decodeItemName( if (event == JsonParser.Event.VALUE_STRING) { final String name = parser.getString(); - if (client.route == null || client.route.admits(kind, name)) + if (client.admits == null || client.admits.test(name)) { client.onDecodedItemBegin(traceId); client.itemDeferred = false; @@ -1565,7 +1564,11 @@ private void onNextClient( doEncodeEndItems(traceId); return; } - client = new McpListClient(this, route.resolvedId(), route.prefix(), route.route()); + final McpRouteConfig routeConfig = route.route(); + final Predicate admits = routeConfig != null && routeConfig.filters(kind) + ? name -> routeConfig.admits(kind, name) + : null; + client = new McpListClient(this, route.resolvedId(), route.prefix(), admits); client.doClientBegin(traceId); }