This is an automated email from the ASF dual-hosted git repository. jamesbognar pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/juneau.git
commit 4d3b7ad6104456569df185ba568600e7536c6870 Author: James Bognar <[email protected]> AuthorDate: Tue Jul 28 16:49:58 2026 -0400 feat: add the McpRevision SPI surface (exchange, error kinds, param utils) McpRevision is deliberately exactly three methods. The seven neutral helpers that used to be McpDispatcher privates are redistributed to the types that own the concept rather than becoming default methods on the SPI: three to JsonRpcResponse, one to McpCursor, three to the new McpParamUtils. Co-authored-by: Cursor <[email protected]> --- .../apache/juneau/rest/server/mcp/McpCursor.java | 12 +++ .../juneau/rest/server/mcp/McpErrorKind.java | 59 ++++++++++++ .../apache/juneau/rest/server/mcp/McpExchange.java | 70 ++++++++++++++ .../juneau/rest/server/mcp/McpParamUtils.java | 93 ++++++++++++++++++ .../apache/juneau/rest/server/mcp/McpRevision.java | 66 +++++++++++++ .../apache/juneau/rest/server/mcp/McpSpi_Test.java | 106 +++++++++++++++++++++ 6 files changed, 406 insertions(+) diff --git a/juneau-rest/juneau-rest-server-mcp/src/main/java/org/apache/juneau/rest/server/mcp/McpCursor.java b/juneau-rest/juneau-rest-server-mcp/src/main/java/org/apache/juneau/rest/server/mcp/McpCursor.java index 85f5f1ae79..57ed6fc487 100644 --- a/juneau-rest/juneau-rest-server-mcp/src/main/java/org/apache/juneau/rest/server/mcp/McpCursor.java +++ b/juneau-rest/juneau-rest-server-mcp/src/main/java/org/apache/juneau/rest/server/mcp/McpCursor.java @@ -100,4 +100,16 @@ public interface McpCursor { return 0; } } + + /** + * Extracts the opaque pagination cursor from a JSON-RPC {@code params} value. + * + * @param params The raw params value from the request. Can be <jk>null</jk>. + * @return The {@code cursor} parameter, or <jk>null</jk> if absent. + * @throws org.apache.juneau.bean.jsonrpc.McpException If {@code params} is present but is not a + * JSON object. + */ + static String cursorOf(Object params) { + return McpParamUtils.strParam(McpParamUtils.asMap(params), "cursor"); + } } diff --git a/juneau-rest/juneau-rest-server-mcp/src/main/java/org/apache/juneau/rest/server/mcp/McpErrorKind.java b/juneau-rest/juneau-rest-server-mcp/src/main/java/org/apache/juneau/rest/server/mcp/McpErrorKind.java new file mode 100644 index 0000000000..0b4c604171 --- /dev/null +++ b/juneau-rest/juneau-rest-server-mcp/src/main/java/org/apache/juneau/rest/server/mcp/McpErrorKind.java @@ -0,0 +1,59 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.juneau.rest.server.mcp; + +/** + * Revision-neutral classification of a dispatch failure. + * + * <p> + * The core raises a <em>kind</em>; each revision maps kinds to its own JSON-RPC error codes via + * {@link McpRevision#errorCode(McpErrorKind)}. Several kinds legitimately map to the same code on a + * given revision — the point of the enum is that the core never has to know which. + */ +public enum McpErrorKind { + + /** The JSON-RPC envelope itself was unusable (absent or empty {@code method}, null envelope). */ + INVALID_REQUEST, + + /** The requested top-level JSON-RPC method is not implemented by this revision. */ + UNKNOWN_METHOD, + + /** A {@code tools/call} named a tool that is not registered. */ + TOOL_NOT_FOUND, + + /** A {@code prompts/get} named a prompt that is not registered. */ + PROMPT_NOT_FOUND, + + /** A {@code resources/read} named a resource that is not registered. */ + RESOURCE_NOT_FOUND, + + /** A required parameter was missing, or a parameter had the wrong JSON shape. */ + INVALID_PARAMS, + + /** A handler threw an unexpected exception. */ + INTERNAL_ERROR, + + /** + * The request body could not be parsed as JSON. + * + * <p> + * No dispatch path raises this kind today — JSON parsing happens in the REST layer before + * dispatch is reached. The constant exists so a revision's code table is complete; that is not + * the same claim as the path being reachable. + */ + PARSE_ERROR +} diff --git a/juneau-rest/juneau-rest-server-mcp/src/main/java/org/apache/juneau/rest/server/mcp/McpExchange.java b/juneau-rest/juneau-rest-server-mcp/src/main/java/org/apache/juneau/rest/server/mcp/McpExchange.java new file mode 100644 index 0000000000..2d75e8bb79 --- /dev/null +++ b/juneau-rest/juneau-rest-server-mcp/src/main/java/org/apache/juneau/rest/server/mcp/McpExchange.java @@ -0,0 +1,70 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.juneau.rest.server.mcp; + +import static org.apache.juneau.commons.utils.AssertionUtils.*; + +import java.util.function.*; + +import org.apache.juneau.bean.jsonrpc.*; + +/** + * The inbound JSON-RPC envelope plus request-header access, with no servlet or HTTP types attached. + * + * <p> + * This is the sole argument a revision receives about the transport. It exists so a revision can + * read protocol headers (later MCP revisions route on them) without the core or the revision + * depending on a servlet API. + */ +public final class McpExchange { + + private final JsonRpcRequest request; + private final Function<String,String> headers; + + /** + * Constructor. + * + * @param request The bound JSON-RPC request envelope. Can be <jk>null</jk> (an unparseable or + * absent body), which a revision must report as an invalid request. + * @param headers Header lookup by name, returning <jk>null</jk> for an absent header. Must not + * be <jk>null</jk>. + */ + public McpExchange(JsonRpcRequest request, Function<String,String> headers) { + assertArgNotNull("headers", headers); + this.request = request; + this.headers = headers; + } + + /** + * The inbound JSON-RPC request envelope. + * + * @return The envelope, or <jk>null</jk> if the body was absent or unparseable. + */ + public JsonRpcRequest request() { + return request; + } + + /** + * Looks up a request header. + * + * @param name The header name. Can be <jk>null</jk>. + * @return The header value, or <jk>null</jk> if absent. + */ + public String header(String name) { + return headers.apply(name); + } +} diff --git a/juneau-rest/juneau-rest-server-mcp/src/main/java/org/apache/juneau/rest/server/mcp/McpParamUtils.java b/juneau-rest/juneau-rest-server-mcp/src/main/java/org/apache/juneau/rest/server/mcp/McpParamUtils.java new file mode 100644 index 0000000000..0b4f292ee6 --- /dev/null +++ b/juneau-rest/juneau-rest-server-mcp/src/main/java/org/apache/juneau/rest/server/mcp/McpParamUtils.java @@ -0,0 +1,93 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.juneau.rest.server.mcp; + +import java.util.*; + +import org.apache.juneau.bean.jsonrpc.*; + +/** + * Shared internal plumbing for revision {@link McpRevision#dispatch} implementations. + * + * <p> + * Untyped-parameter coercion and typed-argument extraction. Not a general-purpose public API: the + * methods are {@code public} only because a revision adapter lives in a different package from this + * one ({@code org.apache.juneau.rest.server.mcp.v20250618} vs. {@code org.apache.juneau.rest.server.mcp}), + * and Java package-private visibility does not span that boundary. + * + * <p> + * The {@code -32602} code these methods raise is the <b>JSON-RPC 2.0 standard</b> "Invalid params" + * code, not a per-revision choice, which is why it is fixed here rather than routed through + * {@link McpRevision#errorCode(McpErrorKind)}. A revision that needs a different code for malformed + * parameters must coerce parameters itself instead of using these helpers. + */ +public final class McpParamUtils { + + private static final int CODE_INVALID_PARAMS = -32602; + + private McpParamUtils() {} + + /** + * Coerces a JSON-RPC {@code params} value to a string-keyed map. + * + * @param params The raw params value. Can be <jk>null</jk>. + * @return The params as a map; an empty map when {@code params} is <jk>null</jk>. Never <jk>null</jk>. + * @throws McpException If {@code params} is present but is not a JSON object. + */ + @SuppressWarnings({ + "unchecked" // Cast is safe: type parameter verified by MCP protocol contract. + }) + public static Map<String,Object> asMap(Object params) { + if (params == null) + return Map.of(); + if (params instanceof Map) + return (Map<String,Object>) params; + throw new McpException(CODE_INVALID_PARAMS, "Params must be an object"); + } + + /** + * Reads a parameter as a string. + * + * @param args The params map. Never <jk>null</jk>. + * @param key The parameter name. + * @return The value's {@code toString()}, or <jk>null</jk> if absent. + */ + public static String strParam(Map<String,Object> args, String key) { + var v = args.get(key); + return v == null ? null : v.toString(); + } + + /** + * Reads a parameter as a nested string-keyed map. + * + * @param args The params map. Never <jk>null</jk>. + * @param key The parameter name. + * @return The nested map; an empty map when the parameter is absent. Never <jk>null</jk>. + * @throws McpException If the parameter is present but is not a JSON object. + */ + @SuppressWarnings({ + "unchecked" // Cast is safe: type parameter verified by MCP protocol contract. + }) + public static Map<String,Object> mapParam(Map<String,Object> args, String key) { + var v = args.get(key); + if (v == null) + return Map.of(); + if (v instanceof Map) + return (Map<String,Object>) v; + throw new McpException(CODE_INVALID_PARAMS, "Param '" + key + "' must be an object"); + } +} diff --git a/juneau-rest/juneau-rest-server-mcp/src/main/java/org/apache/juneau/rest/server/mcp/McpRevision.java b/juneau-rest/juneau-rest-server-mcp/src/main/java/org/apache/juneau/rest/server/mcp/McpRevision.java new file mode 100644 index 0000000000..3a0310996b --- /dev/null +++ b/juneau-rest/juneau-rest-server-mcp/src/main/java/org/apache/juneau/rest/server/mcp/McpRevision.java @@ -0,0 +1,66 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.juneau.rest.server.mcp; + +import org.apache.juneau.bean.jsonrpc.*; +import org.apache.juneau.commons.inject.*; + +/** + * Service-provider interface implemented once per MCP protocol revision. + * + * <p> + * A consumer binds exactly one revision at compile time, by extending that revision's abstract + * servlet or composing its endpoint mixin. There is deliberately no {@code ServiceLoader} wiring + * and no {@code priority()}/{@code isAvailable()} pair: unlike the transport-provider precedent in + * {@code juneau-rest-client-apache-httpclient-45}, there is never a set of runtime candidates to + * choose between. + * + * <p> + * <b>This interface has exactly three methods and must stay that way.</b> {@code default} methods + * here are reserved for genuinely additive future protocol growth — a hook a later revision needs + * that earlier revisions can no-op. They are <em>not</em> for utility plumbing: a revision-neutral + * helper belongs on whichever neutral type already owns the concept (an envelope bean, + * {@link McpCursor}, {@link McpParamUtils}), never here. + */ +public interface McpRevision { + + /** + * The MCP protocol revision string this implementation speaks. + * + * @return The revision token (e.g. {@code "2025-06-18"}). Never <jk>null</jk>. + */ + String protocolVersion(); + + /** + * Dispatches one JSON-RPC request. + * + * @param exchange The inbound envelope plus header access. Never <jk>null</jk>. + * @param config The neutral handler registry and pagination strategy. Never <jk>null</jk>. + * @param ctx The per-request bean store, passed through to handlers. Never <jk>null</jk>. + * @return The response, or <jk>null</jk> for a notification request (which the HTTP layer renders + * as an empty body). + */ + JsonRpcResponse dispatch(McpExchange exchange, McpServerConfig config, BeanStore ctx); + + /** + * Maps a neutral failure classification to this revision's JSON-RPC error code. + * + * @param kind The failure classification. Never <jk>null</jk>. + * @return The JSON-RPC error code this revision reports for that kind. + */ + int errorCode(McpErrorKind kind); +} diff --git a/juneau-rest/juneau-rest-server-mcp/src/test/java/org/apache/juneau/rest/server/mcp/McpSpi_Test.java b/juneau-rest/juneau-rest-server-mcp/src/test/java/org/apache/juneau/rest/server/mcp/McpSpi_Test.java new file mode 100644 index 0000000000..f1a4f4bf77 --- /dev/null +++ b/juneau-rest/juneau-rest-server-mcp/src/test/java/org/apache/juneau/rest/server/mcp/McpSpi_Test.java @@ -0,0 +1,106 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.juneau.rest.server.mcp; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.*; + +import org.apache.juneau.bean.jsonrpc.*; +import org.apache.juneau.marshall.collections.*; +import org.junit.jupiter.api.*; + +/** + * Coverage for the revision SPI surface: {@link McpErrorKind}, {@link McpExchange}, + * {@link McpRevision}, {@link McpParamUtils}, and {@link McpCursor#cursorOf(Object)}. + */ +class McpSpi_Test { + + @Test + void a01_errorKind_hasEightConstants() { + assertEquals(8, McpErrorKind.values().length); + assertNotNull(McpErrorKind.valueOf("INVALID_REQUEST")); + assertNotNull(McpErrorKind.valueOf("UNKNOWN_METHOD")); + assertNotNull(McpErrorKind.valueOf("TOOL_NOT_FOUND")); + assertNotNull(McpErrorKind.valueOf("PROMPT_NOT_FOUND")); + assertNotNull(McpErrorKind.valueOf("RESOURCE_NOT_FOUND")); + assertNotNull(McpErrorKind.valueOf("INVALID_PARAMS")); + assertNotNull(McpErrorKind.valueOf("INTERNAL_ERROR")); + assertNotNull(McpErrorKind.valueOf("PARSE_ERROR")); + } + + @Test + void b01_exchange_exposesRequestAndHeaders() { + var a = new JsonRpcRequest().setMethod("ping"); + var b = new McpExchange(a, n -> "Mcp-Method".equals(n) ? "tools/call" : null); + assertSame(a, b.request()); + assertEquals("tools/call", b.header("Mcp-Method")); + assertNull(b.header("Missing")); + } + + @Test + void b02_exchange_allowsNullRequest() { + assertNull(new McpExchange(null, n -> null).request()); + } + + @Test + void b03_exchange_rejectsNullHeaderLookup() { + assertThrows(IllegalArgumentException.class, () -> new McpExchange(null, null)); + } + + @Test + void c01_paramUtils_asMap() { + assertTrue(McpParamUtils.asMap(null).isEmpty()); + assertEquals("v", McpParamUtils.asMap(JsonMap.of("k", "v")).get("k")); + var e = assertThrows(McpException.class, () -> McpParamUtils.asMap("not-a-map")); + assertEquals(-32602, e.getCode()); + assertEquals("Params must be an object", e.getMessage()); + } + + @Test + void c02_paramUtils_strParam() { + assertNull(McpParamUtils.strParam(Map.of(), "name")); + assertEquals("x", McpParamUtils.strParam(Map.of("name", "x"), "name")); + assertEquals("7", McpParamUtils.strParam(Map.of("name", 7), "name")); + } + + @Test + void c03_paramUtils_mapParam() { + assertTrue(McpParamUtils.mapParam(Map.of(), "arguments").isEmpty()); + assertEquals("v", McpParamUtils.mapParam(Map.of("arguments", JsonMap.of("k", "v")), "arguments").get("k")); + var e = assertThrows(McpException.class, () -> McpParamUtils.mapParam(Map.of("arguments", "nope"), "arguments")); + assertEquals(-32602, e.getCode()); + assertEquals("Param 'arguments' must be an object", e.getMessage()); + } + + @Test + void c04_paramUtils_constructorIsPrivate() { + assertDoesNotThrow(() -> { + var ctor = McpParamUtils.class.getDeclaredConstructor(); + ctor.setAccessible(true); + assertNotNull(ctor.newInstance()); + }); + } + + @Test + void d01_cursorOf_extractsCursorParam() { + assertNull(McpCursor.cursorOf(null)); + assertNull(McpCursor.cursorOf(JsonMap.of("other", "x"))); + assertEquals("3", McpCursor.cursorOf(JsonMap.of("cursor", "3"))); + assertThrows(McpException.class, () -> McpCursor.cursorOf("not-a-map")); + } +}
