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/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/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..147e1f62d5 --- /dev/null +++ b/runtime/binding-mcp/src/main/java/io/aklivity/zilla/runtime/binding/mcp/internal/config/McpConditionMatcher.java @@ -0,0 +1,194 @@ +/* + * 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; + } + + 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) + { + 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..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 @@ -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)) { @@ -195,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) { @@ -224,80 +270,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/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..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; @@ -34,6 +35,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 +105,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 +176,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 +244,7 @@ private final class McpListClient implements McpRouteRequest private final long originId; private final long routedId; private final String8FW prefix; + private final Predicate admits; private final McpLifecycleClient lifecycle; private long initialId; private long replyId; @@ -267,16 +273,20 @@ 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, + Predicate admits) { this.server = server; this.originId = server.lifecycle.originId; this.routedId = routedId; this.prefix = prefix; + this.admits = admits; this.lifecycle = server.lifecycle.supplyClient(routedId); } @@ -665,7 +675,11 @@ private void cleanupClientSlot() private void onDecodedItemBegin( long traceId) { - server.doEncodeBeginItem(traceId); + if (!itemBegun) + { + itemBegun = true; + server.doEncodeBeginItem(traceId); + } } private int onDecodedItemChunk( @@ -905,9 +919,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.admits != null) + { + 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 +945,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.admits == null || client.admits.test(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 +1564,11 @@ private void onNextClient( doEncodeEndItems(traceId); return; } - client = new McpListClient(this, route.resolvedId(), route.prefix()); + 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); } 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..a83e0dc794 --- /dev/null +++ b/runtime/binding-mcp/src/test/java/io/aklivity/zilla/runtime/binding/mcp/internal/config/McpConditionMatcherTest.java @@ -0,0 +1,145 @@ +/* + * 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 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() + { + 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/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/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..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 @@ -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({ @@ -148,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/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/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/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/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/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 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 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 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() {