You are viewing a plain text version of this content. The canonical link for it is here.
Posted to common-commits@hadoop.apache.org by bt...@apache.org on 2026/09/15 13:23:37 UTC
(hadoop) branch trunk updated: YARN-11977. Add a Hadoop-native MCP HTTP server framework in hadoop-common (#8665)
This is an automated email from the ASF dual-hosted git repository.
brumi1024 pushed a commit to branch trunk
in repository https://gitbox.apache.org/repos/asf/hadoop.git
The following commit(s) were added to refs/heads/trunk by this push:
new 90b043bd820 YARN-11977. Add a Hadoop-native MCP HTTP server framework in hadoop-common (#8665)
90b043bd820 is described below
commit 90b043bd820a840e1393f73c0dfdc2b521f7680c
Author: K0K0V0K <10...@users.noreply.github.com>
AuthorDate: Tue Sep 15 15:23:29 2026 +0200
YARN-11977. Add a Hadoop-native MCP HTTP server framework in hadoop-common (#8665)
Introduce org.apache.hadoop.mcp with JSON-RPC streamable HTTP transport and McpHttpServer/McpServer.
---
.../apache/hadoop/mcp/JacksonMcpJsonMapper.java | 71 +++++
.../java/org/apache/hadoop/mcp/McpCallContext.java | 42 +++
.../org/apache/hadoop/mcp/McpHttpResponse.java | 68 +++++
.../java/org/apache/hadoop/mcp/McpHttpServer.java | 277 +++++++++++++++++++
.../java/org/apache/hadoop/mcp/McpHttpServlet.java | 111 ++++++++
.../java/org/apache/hadoop/mcp/McpJsonMapper.java | 41 +++
.../java/org/apache/hadoop/mcp/McpJsonRpc.java | 54 ++++
.../org/apache/hadoop/mcp/McpJsonRpcResponses.java | 95 +++++++
.../org/apache/hadoop/mcp/McpJsonRpcValidator.java | 89 ++++++
.../org/apache/hadoop/mcp/McpRequestHandler.java | 190 +++++++++++++
.../main/java/org/apache/hadoop/mcp/McpSchema.java | 147 ++++++++++
.../main/java/org/apache/hadoop/mcp/McpServer.java | 139 ++++++++++
.../org/apache/hadoop/mcp/McpToolCallHandler.java | 36 +++
.../java/org/apache/hadoop/mcp/McpToolSchema.java | 86 ++++++
.../java/org/apache/hadoop/mcp/package-info.java | 27 ++
.../org/apache/hadoop/mcp/TestMcpHttpServer.java | 217 +++++++++++++++
.../org/apache/hadoop/mcp/TestMcpHttpServlet.java | 303 +++++++++++++++++++++
.../apache/hadoop/mcp/TestMcpJsonRpcValidator.java | 176 ++++++++++++
.../org/apache/hadoop/mcp/TestMcpToolSchema.java | 62 +++++
19 files changed, 2231 insertions(+)
diff --git a/hadoop-common-project/hadoop-common/src/main/java/org/apache/hadoop/mcp/JacksonMcpJsonMapper.java b/hadoop-common-project/hadoop-common/src/main/java/org/apache/hadoop/mcp/JacksonMcpJsonMapper.java
new file mode 100644
index 00000000000..7e260242bce
--- /dev/null
+++ b/hadoop-common-project/hadoop-common/src/main/java/org/apache/hadoop/mcp/JacksonMcpJsonMapper.java
@@ -0,0 +1,71 @@
+/**
+ * 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.hadoop.mcp;
+
+import java.io.IOException;
+import java.util.Map;
+
+import org.apache.hadoop.classification.InterfaceAudience;
+import org.apache.hadoop.classification.InterfaceStability;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+/**
+ * Jackson-backed {@link McpJsonMapper}.
+ */
+@InterfaceAudience.Private
+@InterfaceStability.Evolving
+public final class JacksonMcpJsonMapper implements McpJsonMapper {
+
+ private static final TypeReference<Map<String, Object>> MAP_TYPE = new TypeReference<>() {};
+
+ private final ObjectMapper mapper;
+
+ public JacksonMcpJsonMapper() {
+ ObjectMapper objectMapper = new ObjectMapper();
+ objectMapper.configure(DeserializationFeature.FAIL_ON_TRAILING_TOKENS, true);
+ this.mapper = objectMapper;
+ }
+
+ public JacksonMcpJsonMapper(ObjectMapper mapper) {
+ this.mapper = mapper;
+ }
+
+ public ObjectMapper getObjectMapper() {
+ return mapper;
+ }
+
+ @Override
+ public String writeValueAsString(Object value) throws IOException {
+ return mapper.writeValueAsString(value);
+ }
+
+ @Override
+ public JsonNode readTree(String json) throws IOException {
+ return mapper.readTree(json);
+ }
+
+ @Override
+ public Map<String, Object> readMap(String json) throws IOException {
+ return mapper.readValue(json, MAP_TYPE);
+ }
+}
diff --git a/hadoop-common-project/hadoop-common/src/main/java/org/apache/hadoop/mcp/McpCallContext.java b/hadoop-common-project/hadoop-common/src/main/java/org/apache/hadoop/mcp/McpCallContext.java
new file mode 100644
index 00000000000..03ff6d4c30c
--- /dev/null
+++ b/hadoop-common-project/hadoop-common/src/main/java/org/apache/hadoop/mcp/McpCallContext.java
@@ -0,0 +1,42 @@
+/**
+ * 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.hadoop.mcp;
+
+import javax.servlet.http.HttpServletRequest;
+
+import org.apache.hadoop.classification.InterfaceAudience;
+import org.apache.hadoop.classification.InterfaceStability;
+
+/**
+ * Request-scoped context passed to MCP tool handlers.
+ */
+@InterfaceAudience.Private
+@InterfaceStability.Evolving
+public final class McpCallContext {
+
+ private final HttpServletRequest request;
+
+ public McpCallContext(HttpServletRequest request) {
+ this.request = request;
+ }
+
+ public HttpServletRequest getRequest() {
+ return request;
+ }
+}
diff --git a/hadoop-common-project/hadoop-common/src/main/java/org/apache/hadoop/mcp/McpHttpResponse.java b/hadoop-common-project/hadoop-common/src/main/java/org/apache/hadoop/mcp/McpHttpResponse.java
new file mode 100644
index 00000000000..a2d41afc09f
--- /dev/null
+++ b/hadoop-common-project/hadoop-common/src/main/java/org/apache/hadoop/mcp/McpHttpResponse.java
@@ -0,0 +1,68 @@
+/**
+ * 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.hadoop.mcp;
+
+import java.util.Collections;
+import java.util.Map;
+
+import org.apache.hadoop.classification.InterfaceAudience;
+import org.apache.hadoop.classification.InterfaceStability;
+
+import com.fasterxml.jackson.databind.JsonNode;
+
+/**
+ * HTTP response produced by {@link McpRequestHandler}.
+ */
+@InterfaceAudience.Private
+@InterfaceStability.Evolving
+public final class McpHttpResponse {
+
+ private static final int STATUS_OK = 200;
+ private static final int STATUS_ACCEPTED = 202;
+
+ private final int status;
+ private final Map<String, String> headers;
+ private final JsonNode body;
+
+ private McpHttpResponse(int status, Map<String, String> headers, JsonNode body) {
+ this.status = status;
+ this.headers = headers;
+ this.body = body;
+ }
+
+ public static McpHttpResponse notification() {
+ return new McpHttpResponse(STATUS_ACCEPTED, Collections.emptyMap(), null);
+ }
+
+ public static McpHttpResponse ok(JsonNode body, Map<String, String> headers) {
+ return new McpHttpResponse(STATUS_OK, headers, body);
+ }
+
+ public int status() {
+ return status;
+ }
+
+ public Map<String, String> headers() {
+ return headers;
+ }
+
+ public JsonNode body() {
+ return body;
+ }
+}
diff --git a/hadoop-common-project/hadoop-common/src/main/java/org/apache/hadoop/mcp/McpHttpServer.java b/hadoop-common-project/hadoop-common/src/main/java/org/apache/hadoop/mcp/McpHttpServer.java
new file mode 100644
index 00000000000..102c74c4304
--- /dev/null
+++ b/hadoop-common-project/hadoop-common/src/main/java/org/apache/hadoop/mcp/McpHttpServer.java
@@ -0,0 +1,277 @@
+/**
+ * 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.hadoop.mcp;
+
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.security.GeneralSecurityException;
+
+import org.apache.hadoop.classification.InterfaceAudience;
+import org.apache.hadoop.classification.InterfaceStability;
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.http.HttpServer2;
+import org.apache.hadoop.security.ssl.SSLFactory;
+import org.apache.hadoop.util.StringUtils;
+import org.eclipse.jetty.http.HttpVersion;
+import org.eclipse.jetty.server.HttpConfiguration;
+import org.eclipse.jetty.server.HttpConnectionFactory;
+import org.eclipse.jetty.server.SecureRequestCustomizer;
+import org.eclipse.jetty.server.Server;
+import org.eclipse.jetty.server.ServerConnector;
+import org.eclipse.jetty.server.SslConnectionFactory;
+import org.eclipse.jetty.servlet.ServletContextHandler;
+import org.eclipse.jetty.servlet.ServletHolder;
+import org.eclipse.jetty.util.ssl.SslContextFactory;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Minimal Jetty HTTP/HTTPS server that hosts an {@link McpServer} servlet on a dedicated port.
+ *
+ * <p>No Kerberos/SPNEGO filters are installed. Callers authenticate MCP tool calls
+ * at the application layer (for example API keys).
+ */
+@InterfaceAudience.Private
+@InterfaceStability.Evolving
+public final class McpHttpServer implements AutoCloseable {
+
+ private static final Logger LOG = LoggerFactory.getLogger(McpHttpServer.class);
+
+ private final McpServer mcpServer;
+ private final Server server;
+ private final ServerConnector connector;
+ private final SSLFactory sslFactory;
+
+ private McpHttpServer(McpServer mcpServer, Server server, ServerConnector connector,
+ SSLFactory sslFactory) {
+ this.mcpServer = mcpServer;
+ this.server = server;
+ this.connector = connector;
+ this.sslFactory = sslFactory;
+ }
+
+ /**
+ * Starts a dedicated MCP HTTPS server.
+ *
+ * @param mcpServer MCP server whose servlet handles JSON-RPC requests
+ * @param conf daemon configuration containing SSL keystore settings
+ * @param bindAddress address to bind (port {@code 0} picks an ephemeral port)
+ * @param pathSpec servlet path, for example {@code /ws/v1/mcp}
+ */
+ public static McpHttpServer start(McpServer mcpServer, Configuration conf,
+ InetSocketAddress bindAddress, String pathSpec) throws IOException {
+ return start(mcpServer, conf, bindAddress, pathSpec, true);
+ }
+
+ /**
+ * Starts a dedicated MCP HTTP or HTTPS server.
+ *
+ * @param mcpServer MCP server whose servlet handles JSON-RPC requests
+ * @param conf daemon configuration; SSL keystore settings are required when {@code useHttps}
+ * is {@code true}
+ * @param bindAddress address to bind (port {@code 0} picks an ephemeral port)
+ * @param pathSpec servlet path, for example {@code /ws/v1/mcp}
+ * @param useHttps when {@code true}, serve HTTPS using the daemon SSL keystore; otherwise
+ * serve plain HTTP
+ */
+ public static McpHttpServer start(McpServer mcpServer, Configuration conf,
+ InetSocketAddress bindAddress, String pathSpec, boolean useHttps) throws IOException {
+ Configuration serverConf = conf == null ? new Configuration(false) : conf;
+ SSLFactory sslFactory = null;
+ Server server = new Server();
+ ServerConnector connector;
+ if (useHttps) {
+ sslFactory = new SSLFactory(SSLFactory.Mode.SERVER, serverConf);
+ try {
+ sslFactory.init();
+ } catch (GeneralSecurityException e) {
+ throw new IOException("Failed to initialize SSLFactory", e);
+ }
+ connector = createHttpsConnector(server, serverConf, bindAddress, sslFactory);
+ } else {
+ connector = createHttpConnector(server, bindAddress);
+ }
+ server.addConnector(connector);
+
+ ServletContextHandler context =
+ new ServletContextHandler(ServletContextHandler.NO_SESSIONS);
+ context.setContextPath("/");
+ context.addServlet(new ServletHolder(mcpServer.getServlet()), pathSpec);
+ server.setHandler(context);
+
+ try {
+ server.start();
+ } catch (Exception e) {
+ if (sslFactory != null) {
+ sslFactory.destroy();
+ }
+ try {
+ server.stop();
+ } catch (Exception ex) {
+ LOG.debug("Failed to stop server after start failed", ex);
+ }
+ throw new IOException("Failed to start MCP HTTP server", e);
+ }
+
+ String host = bindAddress.getHostString();
+ if (host == null || host.isEmpty()) {
+ host = "0.0.0.0";
+ }
+ String scheme = useHttps ? "https" : "http";
+ LOG.info("MCP {} server listening on {}://{}:{}{}",
+ useHttps ? "HTTPS" : "HTTP", scheme, host, connector.getLocalPort(), pathSpec);
+ return new McpHttpServer(mcpServer, server, connector, sslFactory);
+ }
+
+ private static ServerConnector createHttpConnector(Server server,
+ InetSocketAddress bindAddress) {
+ HttpConfiguration httpConfig = new HttpConfiguration();
+ httpConfig.setSendServerVersion(false);
+
+ ServerConnector connector = new ServerConnector(server,
+ new HttpConnectionFactory(httpConfig));
+ applyBindAddress(connector, bindAddress);
+ return connector;
+ }
+
+ private static ServerConnector createHttpsConnector(Server server, Configuration sslConf,
+ InetSocketAddress bindAddress, SSLFactory sslFactory) throws IOException {
+ HttpConfiguration httpConfig = new HttpConfiguration();
+ httpConfig.setSendServerVersion(false);
+ httpConfig.setSecureScheme("https");
+ boolean sniHostCheckEnabled = sslConf.getBoolean(
+ HttpServer2.HTTP_SNI_HOST_CHECK_ENABLED_KEY,
+ HttpServer2.HTTP_SNI_HOST_CHECK_ENABLED_DEFAULT);
+ httpConfig.addCustomizer(new SecureRequestCustomizer(sniHostCheckEnabled));
+
+ ServerConnector connector = new ServerConnector(server);
+ SslContextFactory.Server sslContextFactory =
+ createJettySslContextFactory(sslFactory, sslConf);
+ connector.addConnectionFactory(new HttpConnectionFactory(httpConfig));
+ connector.addFirstConnectionFactory(new SslConnectionFactory(sslContextFactory,
+ HttpVersion.HTTP_1_1.asString()));
+
+ applyBindAddress(connector, bindAddress);
+ return connector;
+ }
+
+ private static void applyBindAddress(ServerConnector connector,
+ InetSocketAddress bindAddress) {
+ String host = bindAddress.getHostString();
+ if (host != null && !host.isEmpty() && !"0.0.0.0".equals(host)) {
+ connector.setHost(host);
+ }
+ connector.setPort(bindAddress.getPort());
+ }
+
+ private static SslContextFactory.Server createJettySslContextFactory(
+ SSLFactory sslFactory, Configuration conf) throws IOException {
+ Configuration sslServerConf =
+ SSLFactory.readSSLConfiguration(conf, SSLFactory.Mode.SERVER);
+ String keyStore = sslServerConf.getTrimmed(SSLFactory.SSL_SERVER_KEYSTORE_LOCATION);
+ if (keyStore == null || keyStore.isEmpty()) {
+ throw new IOException("Property " + SSLFactory.SSL_SERVER_KEYSTORE_LOCATION
+ + " not specified");
+ }
+
+ SslContextFactory.Server sslContextFactory = new SslContextFactory.Server();
+ sslContextFactory.setKeyStorePath(keyStore);
+ sslContextFactory.setKeyStoreType(sslServerConf.get(SSLFactory.SSL_SERVER_KEYSTORE_TYPE,
+ SSLFactory.SSL_SERVER_KEYSTORE_TYPE_DEFAULT));
+ String keyStorePassword = getPasswordString(sslServerConf,
+ SSLFactory.SSL_SERVER_KEYSTORE_PASSWORD);
+ if (keyStorePassword == null) {
+ throw new IOException("Property " + SSLFactory.SSL_SERVER_KEYSTORE_PASSWORD
+ + " not specified");
+ }
+ sslContextFactory.setKeyStorePassword(keyStorePassword);
+
+ String keyPassword = getPasswordString(sslServerConf,
+ SSLFactory.SSL_SERVER_KEYSTORE_KEYPASSWORD);
+ if (keyPassword != null) {
+ sslContextFactory.setKeyManagerPassword(keyPassword);
+ }
+
+ String trustStore = sslServerConf.get(SSLFactory.SSL_SERVER_TRUSTSTORE_LOCATION);
+ if (trustStore != null) {
+ sslContextFactory.setTrustStorePath(trustStore);
+ sslContextFactory.setTrustStoreType(sslServerConf.get(
+ SSLFactory.SSL_SERVER_TRUSTSTORE_TYPE,
+ SSLFactory.SSL_SERVER_TRUSTSTORE_TYPE_DEFAULT));
+ String trustStorePassword = getPasswordString(sslServerConf,
+ SSLFactory.SSL_SERVER_TRUSTSTORE_PASSWORD);
+ if (trustStorePassword != null) {
+ sslContextFactory.setTrustStorePassword(trustStorePassword);
+ }
+ }
+
+ sslContextFactory.setNeedClientAuth(sslFactory.isClientCertRequired());
+
+ String[] enabledProtocols = conf.getStrings(SSLFactory.SSL_ENABLED_PROTOCOLS_KEY,
+ SSLFactory.SSL_ENABLED_PROTOCOLS_DEFAULT);
+ if (enabledProtocols != null) {
+ sslContextFactory.setIncludeProtocols(enabledProtocols);
+ }
+
+ String excludeCiphers = sslServerConf.get(SSLFactory.SSL_SERVER_EXCLUDE_CIPHER_LIST);
+ if (StringUtils.hasLength(excludeCiphers)) {
+ sslContextFactory.setExcludeCipherSuites(StringUtils.getTrimmedStrings(excludeCiphers));
+ }
+ String includeCiphers = sslServerConf.get(SSLFactory.SSL_SERVER_INCLUDE_CIPHER_LIST);
+ if (StringUtils.hasLength(includeCiphers)) {
+ sslContextFactory.setIncludeCipherSuites(StringUtils.getTrimmedStrings(includeCiphers));
+ }
+ return sslContextFactory;
+ }
+
+ private static String getPasswordString(Configuration conf, String name)
+ throws IOException {
+ char[] password = conf.getPassword(name);
+ if (password == null) {
+ return null;
+ }
+ return new String(password);
+ }
+
+ /** Local port of the connector (useful when bind port was 0). */
+ public int getPort() {
+ return connector.getLocalPort();
+ }
+
+ public InetSocketAddress getConnectorAddress() {
+ String host = connector.getHost();
+ if (host == null || host.isEmpty()) {
+ host = "0.0.0.0";
+ }
+ return new InetSocketAddress(host, connector.getLocalPort());
+ }
+
+ @Override
+ public void close() throws IOException {
+ try {
+ server.stop();
+ } catch (Exception e) {
+ throw new IOException("Failed to stop MCP HTTP server", e);
+ }
+ if (sslFactory != null) {
+ sslFactory.destroy();
+ }
+ mcpServer.close();
+ }
+}
diff --git a/hadoop-common-project/hadoop-common/src/main/java/org/apache/hadoop/mcp/McpHttpServlet.java b/hadoop-common-project/hadoop-common/src/main/java/org/apache/hadoop/mcp/McpHttpServlet.java
new file mode 100644
index 00000000000..f6cfd083a9c
--- /dev/null
+++ b/hadoop-common-project/hadoop-common/src/main/java/org/apache/hadoop/mcp/McpHttpServlet.java
@@ -0,0 +1,111 @@
+/**
+ * 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.hadoop.mcp;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Map;
+
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+import org.apache.commons.io.IOUtils;
+import org.apache.commons.io.input.BoundedInputStream;
+import org.apache.hadoop.classification.InterfaceAudience;
+import org.apache.hadoop.classification.InterfaceStability;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+/**
+ * javax.servlet endpoint implementing MCP streamable HTTP transport.
+ */
+@InterfaceAudience.Private
+@InterfaceStability.Evolving
+public final class McpHttpServlet extends HttpServlet {
+
+ private static final long serialVersionUID = 1L;
+
+ private final ObjectMapper objectMapper;
+ private final McpRequestHandler requestHandler;
+
+ McpHttpServlet(ObjectMapper objectMapper, McpRequestHandler requestHandler) {
+ this.objectMapper = objectMapper;
+ this.requestHandler = requestHandler;
+ }
+
+ void close() {
+ // Stateless server; nothing to release.
+ }
+
+ @Override
+ protected void doGet(HttpServletRequest req, HttpServletResponse resp)
+ throws ServletException, IOException {
+ resp.setStatus(HttpServletResponse.SC_METHOD_NOT_ALLOWED);
+ }
+
+ @Override
+ protected void doPost(HttpServletRequest req, HttpServletResponse resp)
+ throws ServletException, IOException {
+ req.setCharacterEncoding(StandardCharsets.UTF_8.name());
+ resp.setCharacterEncoding(StandardCharsets.UTF_8.name());
+
+ byte[] body = readRequestBody(req);
+ if (body.length == 0 || body.length > McpJsonRpc.MAX_REQUEST_BODY_BYTES) {
+ writeResponse(objectMapper, resp, body.length == 0
+ ? requestHandler.parseErrorResponse()
+ : requestHandler.requestBodyTooLargeResponse());
+ return;
+ }
+
+ JsonNode requestNode;
+ try {
+ requestNode = objectMapper.readTree(body);
+ } catch (JsonProcessingException e) {
+ writeResponse(objectMapper, resp, requestHandler.parseErrorResponse());
+ return;
+ }
+
+ writeResponse(objectMapper, resp,
+ requestHandler.handle(requestNode, new McpCallContext(req)));
+ }
+
+ private byte[] readRequestBody(HttpServletRequest req) throws IOException {
+ try (InputStream in = new BoundedInputStream(req.getInputStream(),
+ McpJsonRpc.MAX_REQUEST_BODY_BYTES + 1L)) {
+ return IOUtils.toByteArray(in);
+ }
+ }
+
+ static void writeResponse(ObjectMapper objectMapper, HttpServletResponse resp,
+ McpHttpResponse mcpResponse) throws IOException {
+ for (Map.Entry<String, String> header : mcpResponse.headers().entrySet()) {
+ resp.setHeader(header.getKey(), header.getValue());
+ }
+ resp.setStatus(mcpResponse.status());
+ if (mcpResponse.body() != null) {
+ resp.setContentType("application/json");
+ objectMapper.writeValue(resp.getWriter(), mcpResponse.body());
+ }
+ }
+}
diff --git a/hadoop-common-project/hadoop-common/src/main/java/org/apache/hadoop/mcp/McpJsonMapper.java b/hadoop-common-project/hadoop-common/src/main/java/org/apache/hadoop/mcp/McpJsonMapper.java
new file mode 100644
index 00000000000..91cd096bb7c
--- /dev/null
+++ b/hadoop-common-project/hadoop-common/src/main/java/org/apache/hadoop/mcp/McpJsonMapper.java
@@ -0,0 +1,41 @@
+/**
+ * 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.hadoop.mcp;
+
+import java.io.IOException;
+import java.util.Map;
+
+import org.apache.hadoop.classification.InterfaceAudience;
+import org.apache.hadoop.classification.InterfaceStability;
+
+import com.fasterxml.jackson.databind.JsonNode;
+
+/**
+ * JSON serialization support for MCP messages.
+ */
+@InterfaceAudience.Private
+@InterfaceStability.Evolving
+public interface McpJsonMapper {
+
+ String writeValueAsString(Object value) throws IOException;
+
+ JsonNode readTree(String json) throws IOException;
+
+ Map<String, Object> readMap(String json) throws IOException;
+}
diff --git a/hadoop-common-project/hadoop-common/src/main/java/org/apache/hadoop/mcp/McpJsonRpc.java b/hadoop-common-project/hadoop-common/src/main/java/org/apache/hadoop/mcp/McpJsonRpc.java
new file mode 100644
index 00000000000..d13882e636d
--- /dev/null
+++ b/hadoop-common-project/hadoop-common/src/main/java/org/apache/hadoop/mcp/McpJsonRpc.java
@@ -0,0 +1,54 @@
+/**
+ * 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.hadoop.mcp;
+
+import org.apache.hadoop.classification.InterfaceAudience;
+import org.apache.hadoop.classification.InterfaceStability;
+
+/**
+ * JSON-RPC 2.0 constants used by the MCP HTTP transport.
+ */
+@InterfaceAudience.Private
+@InterfaceStability.Evolving
+public final class McpJsonRpc {
+
+ public static final String VERSION = "2.0";
+
+ /** MCP protocol version implemented by this server. */
+ public static final String PROTOCOL_VERSION = "2025-06-18";
+
+ /** JSON-RPC 2.0 parse error. */
+ public static final int PARSE_ERROR = -32700;
+ /** JSON-RPC 2.0 invalid request. */
+ public static final int INVALID_REQUEST = -32600;
+ /** JSON-RPC 2.0 method not found. */
+ public static final int METHOD_NOT_FOUND = -32601;
+ /** JSON-RPC 2.0 invalid params. */
+ public static final int INVALID_PARAMS = -32602;
+
+ public static final String PARSE_ERROR_MESSAGE = "Parse error";
+ public static final String INVALID_REQUEST_MESSAGE = "Invalid Request";
+ public static final String REQUEST_BODY_TOO_LARGE_MESSAGE = "Request body too large";
+
+ /** Maximum MCP JSON-RPC request body size accepted by {@link McpHttpServlet}. */
+ public static final int MAX_REQUEST_BODY_BYTES = 1024 * 1024;
+
+ private McpJsonRpc() {
+ }
+}
diff --git a/hadoop-common-project/hadoop-common/src/main/java/org/apache/hadoop/mcp/McpJsonRpcResponses.java b/hadoop-common-project/hadoop-common/src/main/java/org/apache/hadoop/mcp/McpJsonRpcResponses.java
new file mode 100644
index 00000000000..007556f446f
--- /dev/null
+++ b/hadoop-common-project/hadoop-common/src/main/java/org/apache/hadoop/mcp/McpJsonRpcResponses.java
@@ -0,0 +1,95 @@
+/**
+ * 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.hadoop.mcp;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.hadoop.classification.InterfaceAudience;
+import org.apache.hadoop.classification.InterfaceStability;
+import org.apache.hadoop.mcp.McpSchema.CallToolResult;
+import org.apache.hadoop.mcp.McpSchema.TextContent;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+
+/**
+ * Builds JSON-RPC 2.0 HTTP responses for MCP.
+ */
+@InterfaceAudience.Private
+@InterfaceStability.Evolving
+final class McpJsonRpcResponses {
+
+ private final ObjectMapper objectMapper;
+
+ McpJsonRpcResponses(ObjectMapper objectMapper) {
+ this.objectMapper = objectMapper;
+ }
+
+ McpHttpResponse success(JsonNode idNode, Object result) {
+ return success(idNode, result, Collections.emptyMap());
+ }
+
+ McpHttpResponse success(JsonNode idNode, Object result, Map<String, String> headers) {
+ ObjectNode response = objectMapper.createObjectNode();
+ response.put("jsonrpc", McpJsonRpc.VERSION);
+ if (idNode != null && !idNode.isNull()) {
+ response.set("id", idNode);
+ }
+ response.set("result", objectMapper.valueToTree(result));
+ return McpHttpResponse.ok(response, headers);
+ }
+
+ McpHttpResponse error(JsonNode idNode, int code, String message) {
+ ObjectNode response = objectMapper.createObjectNode();
+ response.put("jsonrpc", McpJsonRpc.VERSION);
+ setErrorResponseId(response, idNode);
+ ObjectNode error = objectMapper.createObjectNode();
+ error.put("code", code);
+ error.put("message", message);
+ response.set("error", error);
+ return McpHttpResponse.ok(response, Collections.emptyMap());
+ }
+
+ private void setErrorResponseId(ObjectNode response, JsonNode idNode) {
+ if (idNode != null && !idNode.isNull()) {
+ response.set("id", idNode);
+ } else {
+ response.putNull("id");
+ }
+ }
+
+ static Map<String, Object> toResultMap(CallToolResult callResult) {
+ List<Map<String, Object>> content = new ArrayList<>();
+ for (TextContent textContent : callResult.content()) {
+ Map<String, Object> item = new LinkedHashMap<>();
+ item.put("type", textContent.type());
+ item.put("text", textContent.text());
+ content.add(item);
+ }
+ Map<String, Object> result = new LinkedHashMap<>();
+ result.put("content", content);
+ result.put("isError", callResult.isError());
+ return result;
+ }
+}
diff --git a/hadoop-common-project/hadoop-common/src/main/java/org/apache/hadoop/mcp/McpJsonRpcValidator.java b/hadoop-common-project/hadoop-common/src/main/java/org/apache/hadoop/mcp/McpJsonRpcValidator.java
new file mode 100644
index 00000000000..9baae75dcec
--- /dev/null
+++ b/hadoop-common-project/hadoop-common/src/main/java/org/apache/hadoop/mcp/McpJsonRpcValidator.java
@@ -0,0 +1,89 @@
+/**
+ * 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.hadoop.mcp;
+
+import org.apache.hadoop.classification.InterfaceAudience;
+import org.apache.hadoop.classification.InterfaceStability;
+
+import com.fasterxml.jackson.databind.JsonNode;
+
+/**
+ * Validates JSON-RPC envelope fields required by MCP 2025-06-18.
+ */
+@InterfaceAudience.Private
+@InterfaceStability.Evolving
+final class McpJsonRpcValidator {
+
+ private McpJsonRpcValidator() {
+ }
+
+ /**
+ * @return an error response when the envelope is invalid, otherwise {@code null}
+ */
+ static McpHttpResponse validate(JsonNode requestNode, McpJsonRpcResponses responses) {
+ if (requestNode == null || requestNode.isNull() || !requestNode.isObject()) {
+ return responses.error(null, McpJsonRpc.INVALID_REQUEST, McpJsonRpc.INVALID_REQUEST_MESSAGE);
+ }
+
+ JsonNode jsonrpcNode = requestNode.get("jsonrpc");
+ if (jsonrpcNode == null || jsonrpcNode.isNull()
+ || !jsonrpcNode.isTextual()
+ || !McpJsonRpc.VERSION.equals(jsonrpcNode.asText())) {
+ return responses.error(responseId(requestNode), McpJsonRpc.INVALID_REQUEST,
+ McpJsonRpc.INVALID_REQUEST_MESSAGE);
+ }
+
+ JsonNode methodNode = requestNode.get("method");
+ if (methodNode == null || methodNode.isNull() || !methodNode.isTextual()
+ || methodNode.asText().isEmpty()) {
+ return responses.error(responseId(requestNode), McpJsonRpc.INVALID_REQUEST,
+ McpJsonRpc.INVALID_REQUEST_MESSAGE);
+ }
+
+ JsonNode paramsNode = requestNode.get("params");
+ if (paramsNode != null && paramsNode.isNull()) {
+ return responses.error(responseId(requestNode), McpJsonRpc.INVALID_REQUEST,
+ McpJsonRpc.INVALID_REQUEST_MESSAGE);
+ }
+
+ if (isJsonRpcNotification(requestNode)) {
+ return null;
+ }
+
+ if (!isValidRequestId(requestNode.get("id"))) {
+ return responses.error(null, McpJsonRpc.INVALID_REQUEST, McpJsonRpc.INVALID_REQUEST_MESSAGE);
+ }
+ return null;
+ }
+
+ static boolean isJsonRpcNotification(JsonNode requestNode) {
+ return !requestNode.has("id");
+ }
+
+ private static boolean isValidRequestId(JsonNode idNode) {
+ return idNode != null && !idNode.isNull()
+ && (idNode.isIntegralNumber() || idNode.isTextual());
+ }
+
+ /** Returns {@code id} when it is a valid request id, otherwise {@code null}. */
+ private static JsonNode responseId(JsonNode requestNode) {
+ JsonNode idNode = requestNode.get("id");
+ return isValidRequestId(idNode) ? idNode : null;
+ }
+}
diff --git a/hadoop-common-project/hadoop-common/src/main/java/org/apache/hadoop/mcp/McpRequestHandler.java b/hadoop-common-project/hadoop-common/src/main/java/org/apache/hadoop/mcp/McpRequestHandler.java
new file mode 100644
index 00000000000..83d03578606
--- /dev/null
+++ b/hadoop-common-project/hadoop-common/src/main/java/org/apache/hadoop/mcp/McpRequestHandler.java
@@ -0,0 +1,190 @@
+/**
+ * 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.hadoop.mcp;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+
+import org.apache.hadoop.classification.InterfaceAudience;
+import org.apache.hadoop.classification.InterfaceStability;
+import org.apache.hadoop.mcp.McpSchema.ServerCapabilities;
+import org.apache.hadoop.mcp.McpSchema.Tool;
+import org.apache.hadoop.mcp.McpServer.RegisteredTool;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+/**
+ * Transport-neutral MCP JSON-RPC request handler.
+ */
+@InterfaceAudience.Private
+@InterfaceStability.Evolving
+public final class McpRequestHandler {
+
+ public static final String SESSION_HEADER = "Mcp-Session-Id";
+
+ public static final String METHOD_INITIALIZE = "initialize";
+ public static final String METHOD_TOOLS_LIST = "tools/list";
+ public static final String METHOD_TOOLS_CALL = "tools/call";
+
+ private static final String TOOL_EXECUTION_FAILED_MESSAGE = "Tool execution failed";
+
+ private static final TypeReference<Map<String, Object>> PARAMS_TYPE = new TypeReference<>() {};
+
+ private final ObjectMapper objectMapper;
+ private final String serverName;
+ private final String serverVersion;
+ private final ServerCapabilities capabilities;
+ private final Map<String, RegisteredTool> tools;
+ private final McpJsonRpcResponses jsonRpcResponses;
+
+ McpRequestHandler(ObjectMapper objectMapper, String serverName, String serverVersion,
+ ServerCapabilities capabilities, Map<String, RegisteredTool> tools) {
+ this.objectMapper = objectMapper;
+ this.serverName = serverName;
+ this.serverVersion = serverVersion;
+ this.capabilities = capabilities;
+ this.tools = Collections.unmodifiableMap(new LinkedHashMap<>(tools));
+ this.jsonRpcResponses = new McpJsonRpcResponses(objectMapper);
+ }
+
+ public McpHttpResponse handle(JsonNode requestNode) {
+ return handle(requestNode, new McpCallContext(null));
+ }
+
+ public McpHttpResponse parseErrorResponse() {
+ return jsonRpcResponses.error(null, McpJsonRpc.PARSE_ERROR, McpJsonRpc.PARSE_ERROR_MESSAGE);
+ }
+
+ public McpHttpResponse requestBodyTooLargeResponse() {
+ return jsonRpcResponses.error(null, McpJsonRpc.INVALID_REQUEST,
+ McpJsonRpc.REQUEST_BODY_TOO_LARGE_MESSAGE);
+ }
+
+ public McpHttpResponse handle(JsonNode requestNode, McpCallContext context) {
+ McpHttpResponse error = McpJsonRpcValidator.validate(requestNode, jsonRpcResponses);
+ if (error != null) {
+ return error;
+ }
+
+ if (McpJsonRpcValidator.isJsonRpcNotification(requestNode)) {
+ return McpHttpResponse.notification();
+ }
+
+ String method = requestNode.get("method").asText();
+ JsonNode idNode = requestNode.get("id");
+ JsonNode paramsNode = requestNode.get("params");
+ if (paramsNode != null && !paramsNode.isNull() && !paramsNode.isObject()) {
+ return jsonRpcResponses.error(idNode, McpJsonRpc.INVALID_PARAMS,
+ "params must be a JSON object");
+ }
+ Map<String, Object> params = paramsNode == null
+ ? Collections.emptyMap()
+ : objectMapper.convertValue(paramsNode, PARAMS_TYPE);
+
+ switch (method) {
+ case METHOD_INITIALIZE:
+ return initializeResponse(idNode);
+ case METHOD_TOOLS_LIST:
+ return jsonRpcResponses.success(idNode, buildToolsListResult());
+ case METHOD_TOOLS_CALL:
+ return toolsCallResponse(idNode, params, context);
+ default:
+ return jsonRpcResponses.error(idNode, McpJsonRpc.METHOD_NOT_FOUND,
+ "Method not found: " + method);
+ }
+ }
+
+ private McpHttpResponse initializeResponse(JsonNode idNode) {
+ Map<String, Object> result = new HashMap<>();
+ result.put("protocolVersion", McpJsonRpc.PROTOCOL_VERSION);
+ result.put("capabilities", capabilities.toMap());
+ Map<String, Object> serverInfo = new HashMap<>();
+ serverInfo.put("name", serverName);
+ serverInfo.put("version", serverVersion);
+ result.put("serverInfo", serverInfo);
+
+ Map<String, String> headers = new HashMap<>();
+ headers.put(SESSION_HEADER, UUID.randomUUID().toString());
+ return jsonRpcResponses.success(idNode, result, headers);
+ }
+
+ private Map<String, Object> buildToolsListResult() {
+ List<Map<String, Object>> toolList = new ArrayList<>();
+ for (RegisteredTool registeredTool : tools.values()) {
+ Tool tool = registeredTool.tool();
+ Map<String, Object> toolMap = new LinkedHashMap<>();
+ toolMap.put("name", tool.name());
+ toolMap.put("description", tool.description());
+ toolMap.put("inputSchema", tool.inputSchema());
+ toolList.add(toolMap);
+ }
+ Map<String, Object> result = new HashMap<>();
+ result.put("tools", toolList);
+ return result;
+ }
+
+ private McpHttpResponse toolsCallResponse(JsonNode idNode, Map<String, Object> params,
+ McpCallContext context) {
+ Object nameObject = params.get("name");
+ if (!(nameObject instanceof String)) {
+ return jsonRpcResponses.error(idNode, McpJsonRpc.INVALID_PARAMS, "Missing tool name");
+ }
+
+ RegisteredTool registeredTool = tools.get(nameObject);
+ if (registeredTool == null) {
+ return jsonRpcResponses.error(idNode, McpJsonRpc.INVALID_PARAMS,
+ "Unknown tool: " + nameObject);
+ }
+
+ Object argumentsObject = params.get("arguments");
+ Map<String, Object> arguments;
+ if (!params.containsKey("arguments")) {
+ arguments = Collections.emptyMap();
+ } else if (argumentsObject == null) {
+ return jsonRpcResponses.error(idNode, McpJsonRpc.INVALID_PARAMS,
+ "arguments must be a JSON object");
+ } else if (!(argumentsObject instanceof Map)) {
+ return jsonRpcResponses.error(idNode, McpJsonRpc.INVALID_PARAMS,
+ "arguments must be a JSON object");
+ } else {
+ arguments = objectMapper.convertValue(
+ objectMapper.valueToTree(argumentsObject), PARAMS_TYPE);
+ }
+
+ final McpSchema.CallToolResult callResult;
+ try {
+ callResult = registeredTool.call(context, arguments);
+ } catch (Exception e) {
+ String message = e.getMessage();
+ if (message == null || message.isEmpty()) {
+ message = TOOL_EXECUTION_FAILED_MESSAGE;
+ }
+ return jsonRpcResponses.success(idNode, McpJsonRpcResponses.toResultMap(
+ McpSchema.CallToolResult.error(message)));
+ }
+ return jsonRpcResponses.success(idNode, McpJsonRpcResponses.toResultMap(callResult));
+ }
+}
diff --git a/hadoop-common-project/hadoop-common/src/main/java/org/apache/hadoop/mcp/McpSchema.java b/hadoop-common-project/hadoop-common/src/main/java/org/apache/hadoop/mcp/McpSchema.java
new file mode 100644
index 00000000000..26ffe707387
--- /dev/null
+++ b/hadoop-common-project/hadoop-common/src/main/java/org/apache/hadoop/mcp/McpSchema.java
@@ -0,0 +1,147 @@
+/**
+ * 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.hadoop.mcp;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.hadoop.classification.InterfaceAudience;
+import org.apache.hadoop.classification.InterfaceStability;
+
+/**
+ * MCP protocol data types.
+ */
+@InterfaceAudience.Private
+@InterfaceStability.Evolving
+public final class McpSchema {
+
+ private McpSchema() {
+ }
+
+ public static final class Tool {
+ private final String name;
+ private final String description;
+ private final Map<String, Object> inputSchema;
+
+ private Tool(String name, String description, Map<String, Object> inputSchema) {
+ this.name = name;
+ this.description = description;
+ this.inputSchema = inputSchema;
+ }
+
+ public String name() {
+ return name;
+ }
+
+ public String description() {
+ return description;
+ }
+
+ public Map<String, Object> inputSchema() {
+ return inputSchema;
+ }
+
+ public static Tool of(String name, String description, Map<String, Object> inputSchema) {
+ return new Tool(name, description, inputSchema);
+ }
+
+ public static Tool of(String name, String description, McpJsonMapper jsonMapper,
+ String schemaJson) {
+ try {
+ return of(name, description, jsonMapper.readMap(schemaJson));
+ } catch (IOException e) {
+ throw new UncheckedIOException("Failed to parse tool input schema", e);
+ }
+ }
+ }
+
+ public static final class TextContent {
+ private final String text;
+
+ public TextContent(String text) {
+ this.text = text;
+ }
+
+ public String type() {
+ return "text";
+ }
+
+ public String text() {
+ return text;
+ }
+ }
+
+ public static final class CallToolResult {
+ private final List<TextContent> content;
+ private final boolean isError;
+
+ private CallToolResult(List<TextContent> content, boolean isError) {
+ this.content = content;
+ this.isError = isError;
+ }
+
+ public List<TextContent> content() {
+ return content;
+ }
+
+ public boolean isError() {
+ return isError;
+ }
+
+ public static CallToolResult text(String text) {
+ return new CallToolResult(Collections.singletonList(new TextContent(text)), false);
+ }
+
+ public static CallToolResult error(String message) {
+ return new CallToolResult(Collections.singletonList(new TextContent(message)), true);
+ }
+ }
+
+ public static final class ServerCapabilities {
+ private final boolean tools;
+
+ private ServerCapabilities(boolean tools) {
+ this.tools = tools;
+ }
+
+ public boolean tools() {
+ return tools;
+ }
+
+ public Map<String, Object> toMap() {
+ Map<String, Object> capabilities = new HashMap<>();
+ if (tools) {
+ capabilities.put("tools", Collections.emptyMap());
+ }
+ return capabilities;
+ }
+
+ public static ServerCapabilities withTools() {
+ return new ServerCapabilities(true);
+ }
+
+ public static ServerCapabilities withoutTools() {
+ return new ServerCapabilities(false);
+ }
+ }
+}
diff --git a/hadoop-common-project/hadoop-common/src/main/java/org/apache/hadoop/mcp/McpServer.java b/hadoop-common-project/hadoop-common/src/main/java/org/apache/hadoop/mcp/McpServer.java
new file mode 100644
index 00000000000..95b5cef56d7
--- /dev/null
+++ b/hadoop-common-project/hadoop-common/src/main/java/org/apache/hadoop/mcp/McpServer.java
@@ -0,0 +1,139 @@
+/**
+ * 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.hadoop.mcp;
+
+import java.io.Closeable;
+import java.io.IOException;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import javax.servlet.http.HttpServlet;
+
+import org.apache.hadoop.classification.InterfaceAudience;
+import org.apache.hadoop.classification.InterfaceStability;
+import org.apache.hadoop.mcp.McpSchema.CallToolResult;
+import org.apache.hadoop.mcp.McpSchema.ServerCapabilities;
+import org.apache.hadoop.mcp.McpSchema.Tool;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+/**
+ * Minimal MCP server for streamable HTTP transport.
+ *
+ * <p>Host {@link #getServlet()} with {@link McpHttpServer} on a dedicated port:
+ * <pre>{@code
+ * McpServer mcpServer = McpServer.sync(jsonMapper)
+ * .serverInfo("yarn-resourcemanager", version)
+ * .capabilities(ServerCapabilities.withTools())
+ * .toolCall(tool, handler)
+ * .build();
+ * try (McpHttpServer httpServer = McpHttpServer.start(mcpServer, conf, bindAddress,
+ * "/ws/v1/mcp")) {
+ * // ...
+ * }
+ * }</pre>
+ */
+@InterfaceAudience.Private
+@InterfaceStability.Evolving
+public final class McpServer implements Closeable {
+
+ private final McpRequestHandler requestHandler;
+ private final McpHttpServlet servlet;
+
+ private McpServer(McpRequestHandler requestHandler, McpHttpServlet servlet) {
+ this.requestHandler = requestHandler;
+ this.servlet = servlet;
+ }
+
+ public static Builder sync(McpJsonMapper jsonMapper) {
+ return new Builder(jsonMapper);
+ }
+
+ /**
+ * Returns a servlet suitable for {@code WebAppContext.addServlet(...)}.
+ */
+ public HttpServlet getServlet() {
+ return servlet;
+ }
+
+ McpRequestHandler getRequestHandler() {
+ return requestHandler;
+ }
+
+ @Override
+ public void close() throws IOException {
+ servlet.close();
+ }
+
+ public static final class Builder {
+ private final ObjectMapper objectMapper;
+ private String serverName = "hadoop-mcp";
+ private String serverVersion = "1.0.0";
+ private ServerCapabilities capabilities = ServerCapabilities.withoutTools();
+ private final Map<String, RegisteredTool> tools = new LinkedHashMap<>();
+
+ private Builder(McpJsonMapper jsonMapper) {
+ if (!(jsonMapper instanceof JacksonMcpJsonMapper)) {
+ throw new IllegalArgumentException("McpServer requires JacksonMcpJsonMapper");
+ }
+ this.objectMapper = ((JacksonMcpJsonMapper) jsonMapper).getObjectMapper();
+ }
+
+ public Builder serverInfo(String name, String version) {
+ this.serverName = name;
+ this.serverVersion = version;
+ return this;
+ }
+
+ public Builder capabilities(ServerCapabilities serverCapabilities) {
+ this.capabilities = serverCapabilities;
+ return this;
+ }
+
+ public Builder toolCall(Tool tool, McpToolCallHandler handler) {
+ tools.put(tool.name(), new RegisteredTool(tool, handler));
+ return this;
+ }
+
+ public McpServer build() {
+ McpRequestHandler handler = new McpRequestHandler(objectMapper, serverName,
+ serverVersion, capabilities, tools);
+ McpHttpServlet httpServlet = new McpHttpServlet(objectMapper, handler);
+ return new McpServer(handler, httpServlet);
+ }
+ }
+
+ static final class RegisteredTool {
+ private final Tool tool;
+ private final McpToolCallHandler handler;
+
+ private RegisteredTool(Tool tool, McpToolCallHandler handler) {
+ this.tool = tool;
+ this.handler = handler;
+ }
+
+ Tool tool() {
+ return tool;
+ }
+
+ CallToolResult call(McpCallContext context, Map<String, Object> arguments) {
+ return handler.call(context, arguments);
+ }
+ }
+}
diff --git a/hadoop-common-project/hadoop-common/src/main/java/org/apache/hadoop/mcp/McpToolCallHandler.java b/hadoop-common-project/hadoop-common/src/main/java/org/apache/hadoop/mcp/McpToolCallHandler.java
new file mode 100644
index 00000000000..47fed732c7e
--- /dev/null
+++ b/hadoop-common-project/hadoop-common/src/main/java/org/apache/hadoop/mcp/McpToolCallHandler.java
@@ -0,0 +1,36 @@
+/**
+ * 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.hadoop.mcp;
+
+import java.util.Map;
+
+import org.apache.hadoop.classification.InterfaceAudience;
+import org.apache.hadoop.classification.InterfaceStability;
+import org.apache.hadoop.mcp.McpSchema.CallToolResult;
+
+/**
+ * Handler invoked when an MCP client calls a registered tool.
+ */
+@InterfaceAudience.Private
+@InterfaceStability.Evolving
+@FunctionalInterface
+public interface McpToolCallHandler {
+
+ CallToolResult call(McpCallContext context, Map<String, Object> arguments);
+}
diff --git a/hadoop-common-project/hadoop-common/src/main/java/org/apache/hadoop/mcp/McpToolSchema.java b/hadoop-common-project/hadoop-common/src/main/java/org/apache/hadoop/mcp/McpToolSchema.java
new file mode 100644
index 00000000000..5065589f17e
--- /dev/null
+++ b/hadoop-common-project/hadoop-common/src/main/java/org/apache/hadoop/mcp/McpToolSchema.java
@@ -0,0 +1,86 @@
+/**
+ * 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.hadoop.mcp;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import org.apache.hadoop.classification.InterfaceAudience;
+import org.apache.hadoop.classification.InterfaceStability;
+
+/**
+ * Builds JSON Schema maps for MCP tool input definitions.
+ */
+@InterfaceAudience.Private
+@InterfaceStability.Evolving
+public final class McpToolSchema {
+
+ private final Map<String, Object> schema;
+
+ private McpToolSchema() {
+ Map<String, Object> properties = new LinkedHashMap<>();
+ schema = new LinkedHashMap<>();
+ schema.put("type", "object");
+ schema.put("properties", properties);
+ }
+
+ public static McpToolSchema object() {
+ return new McpToolSchema();
+ }
+
+ public static Map<String, Object> emptyObject() {
+ return object().build();
+ }
+
+ public McpToolSchema string(String name, String description) {
+ properties().put(name, typedProperty("string", description));
+ return this;
+ }
+
+ public McpToolSchema stringArray(String name, String description) {
+ Map<String, Object> items = new LinkedHashMap<>();
+ items.put("type", "string");
+ Map<String, Object> property = new LinkedHashMap<>();
+ property.put("type", "array");
+ property.put("items", items);
+ if (description != null) {
+ property.put("description", description);
+ }
+ properties().put(name, property);
+ return this;
+ }
+
+ public Map<String, Object> build() {
+ return schema;
+ }
+
+ @SuppressWarnings("unchecked")
+ private Map<String, Object> properties() {
+ return (Map<String, Object>) schema.get("properties");
+ }
+
+ private static Map<String, Object> typedProperty(String type, String description) {
+ Map<String, Object> property = new LinkedHashMap<>();
+ property.put("type", type);
+ if (description != null) {
+ property.put("description", description);
+ }
+ return property;
+ }
+}
diff --git a/hadoop-common-project/hadoop-common/src/main/java/org/apache/hadoop/mcp/package-info.java b/hadoop-common-project/hadoop-common/src/main/java/org/apache/hadoop/mcp/package-info.java
new file mode 100644
index 00000000000..eca22d61138
--- /dev/null
+++ b/hadoop-common-project/hadoop-common/src/main/java/org/apache/hadoop/mcp/package-info.java
@@ -0,0 +1,27 @@
+/**
+ * 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.
+ */
+
+/**
+ * Hadoop-native Model Context Protocol (MCP) HTTP server framework.
+ */
+@InterfaceAudience.Private
+@InterfaceStability.Evolving
+package org.apache.hadoop.mcp;
+
+import org.apache.hadoop.classification.InterfaceAudience;
+import org.apache.hadoop.classification.InterfaceStability;
diff --git a/hadoop-common-project/hadoop-common/src/test/java/org/apache/hadoop/mcp/TestMcpHttpServer.java b/hadoop-common-project/hadoop-common/src/test/java/org/apache/hadoop/mcp/TestMcpHttpServer.java
new file mode 100644
index 00000000000..1412ef19c12
--- /dev/null
+++ b/hadoop-common-project/hadoop-common/src/test/java/org/apache/hadoop/mcp/TestMcpHttpServer.java
@@ -0,0 +1,217 @@
+/**
+ * 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.hadoop.mcp;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.File;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.HttpURLConnection;
+import java.net.InetSocketAddress;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+
+import javax.net.ssl.HttpsURLConnection;
+
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.fs.FileUtil;
+import org.apache.hadoop.security.ssl.KeyStoreTestUtil;
+import org.apache.hadoop.security.ssl.SSLFactory;
+import org.apache.hadoop.test.GenericTestUtils;
+import org.apache.hadoop.util.Shell;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+public class TestMcpHttpServer {
+
+ private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+ private static final JacksonMcpJsonMapper JSON_MAPPER = new JacksonMcpJsonMapper();
+ private static final String BASEDIR =
+ GenericTestUtils.getTempPath(TestMcpHttpServer.class.getSimpleName());
+
+ private static Configuration conf;
+ private static String keystoreDir;
+ private static String sslConfDir;
+ private static SSLFactory clientSslFactory;
+
+ @BeforeAll
+ public static void setupSsl() throws Exception {
+ conf = new Configuration();
+ File base = new File(BASEDIR);
+ FileUtil.fullyDelete(base);
+ base.mkdirs();
+ keystoreDir = base.getAbsolutePath();
+ sslConfDir = KeyStoreTestUtil.getClasspathDir(TestMcpHttpServer.class);
+ KeyStoreTestUtil.setupSSLConfig(keystoreDir, sslConfDir, conf, false);
+ Configuration sslConf = KeyStoreTestUtil.getSslConfig();
+ clientSslFactory = new SSLFactory(SSLFactory.Mode.CLIENT, sslConf);
+ clientSslFactory.init();
+ String protocols = Shell.isJavaVersionAtLeast(11)
+ ? "TLSv1.3,TLSv1.2" : "TLSv1.2";
+ conf.set(SSLFactory.SSL_ENABLED_PROTOCOLS_KEY, protocols);
+ sslConf.set(SSLFactory.SSL_ENABLED_PROTOCOLS_KEY, protocols);
+ }
+
+ @AfterAll
+ public static void cleanupSsl() throws Exception {
+ if (clientSslFactory != null) {
+ clientSslFactory.destroy();
+ clientSslFactory = null;
+ }
+ FileUtil.fullyDelete(new File(BASEDIR));
+ KeyStoreTestUtil.cleanupSSLConfig(keystoreDir, sslConfDir);
+ }
+
+ @Test
+ public void testPlainHttpServerRespondsToInitialize() throws Exception {
+ McpServer server = McpServer.sync(JSON_MAPPER)
+ .serverInfo("test-server", "1.0")
+ .capabilities(McpSchema.ServerCapabilities.withTools())
+ .build();
+
+ try (McpHttpServer httpServer = McpHttpServer.start(server, new Configuration(),
+ new InetSocketAddress("localhost", 0), "/mcp", false)) {
+ URL url = new URL("http://localhost:" + httpServer.getPort() + "/mcp");
+ HttpURLConnection conn = (HttpURLConnection) url.openConnection();
+ conn.setRequestMethod("POST");
+ conn.setDoOutput(true);
+ conn.setRequestProperty("Content-Type", "application/json");
+ byte[] body = ("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\","
+ + "\"params\":{\"protocolVersion\":\"2025-06-18\",\"capabilities\":{},"
+ + "\"clientInfo\":{\"name\":\"t\",\"version\":\"1\"}}}").getBytes(
+ StandardCharsets.UTF_8);
+ try (OutputStream out = conn.getOutputStream()) {
+ out.write(body);
+ }
+
+ assertEquals(200, conn.getResponseCode());
+ JsonNode response;
+ try (InputStream in = conn.getInputStream()) {
+ response = OBJECT_MAPPER.readTree(in);
+ }
+ assertEquals("2.0", response.get("jsonrpc").asText());
+ assertEquals(McpJsonRpc.PROTOCOL_VERSION,
+ response.get("result").get("protocolVersion").asText());
+ assertEquals("test-server",
+ response.get("result").get("serverInfo").get("name").asText());
+ assertNotNull(conn.getHeaderField(McpRequestHandler.SESSION_HEADER));
+ }
+ }
+
+ @Test
+ public void testStandaloneServerRespondsToInitialize() throws Exception {
+ McpServer server = McpServer.sync(JSON_MAPPER)
+ .serverInfo("test-server", "1.0")
+ .capabilities(McpSchema.ServerCapabilities.withTools())
+ .build();
+
+ try (McpHttpServer httpServer = McpHttpServer.start(server, conf,
+ new InetSocketAddress("localhost", 0), "/mcp")) {
+ assertNotNull(httpServer.getConnectorAddress());
+
+ URL url = new URL("https://localhost:" + httpServer.getPort() + "/mcp");
+ HttpsURLConnection conn = (HttpsURLConnection) url.openConnection();
+ conn.setSSLSocketFactory(clientSslFactory.createSSLSocketFactory());
+ conn.setRequestMethod("POST");
+ conn.setDoOutput(true);
+ conn.setRequestProperty("Content-Type", "application/json");
+ byte[] body = ("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\","
+ + "\"params\":{\"protocolVersion\":\"2025-06-18\",\"capabilities\":{},"
+ + "\"clientInfo\":{\"name\":\"t\",\"version\":\"1\"}}}").getBytes(
+ StandardCharsets.UTF_8);
+ try (OutputStream out = conn.getOutputStream()) {
+ out.write(body);
+ }
+
+ assertEquals(200, conn.getResponseCode());
+ JsonNode response;
+ try (InputStream in = conn.getInputStream()) {
+ response = OBJECT_MAPPER.readTree(in);
+ }
+ assertEquals("2.0", response.get("jsonrpc").asText());
+ assertEquals(McpJsonRpc.PROTOCOL_VERSION,
+ response.get("result").get("protocolVersion").asText());
+ assertEquals("test-server",
+ response.get("result").get("serverInfo").get("name").asText());
+ assertNotNull(conn.getHeaderField(McpRequestHandler.SESSION_HEADER));
+ }
+ }
+
+ @Test
+ public void testEmptyBodyReturnsParseError() throws Exception {
+ McpServer server = McpServer.sync(JSON_MAPPER)
+ .serverInfo("test-server", "1.0")
+ .build();
+
+ try (McpHttpServer httpServer = McpHttpServer.start(server, new Configuration(),
+ new InetSocketAddress("localhost", 0), "/mcp", false)) {
+ URL url = new URL("http://localhost:" + httpServer.getPort() + "/mcp");
+ HttpURLConnection conn = (HttpURLConnection) url.openConnection();
+ conn.setRequestMethod("POST");
+ conn.setDoOutput(true);
+ conn.setRequestProperty("Content-Type", "application/json");
+ conn.getOutputStream().close();
+
+ assertEquals(200, conn.getResponseCode());
+ JsonNode response;
+ try (InputStream in = conn.getInputStream()) {
+ response = OBJECT_MAPPER.readTree(in);
+ }
+ assertEquals(McpJsonRpc.PARSE_ERROR, response.get("error").get("code").asInt());
+ assertTrue(response.has("id"));
+ assertTrue(response.get("id").isNull());
+ }
+ }
+
+ @Test
+ public void testMalformedJsonReturnsParseError() throws Exception {
+ McpServer server = McpServer.sync(JSON_MAPPER)
+ .serverInfo("test-server", "1.0")
+ .build();
+
+ try (McpHttpServer httpServer = McpHttpServer.start(server, new Configuration(),
+ new InetSocketAddress("localhost", 0), "/mcp", false)) {
+ URL url = new URL("http://localhost:" + httpServer.getPort() + "/mcp");
+ HttpURLConnection conn = (HttpURLConnection) url.openConnection();
+ conn.setRequestMethod("POST");
+ conn.setDoOutput(true);
+ conn.setRequestProperty("Content-Type", "application/json");
+ byte[] body = "{not-json".getBytes(StandardCharsets.UTF_8);
+ try (OutputStream out = conn.getOutputStream()) {
+ out.write(body);
+ }
+
+ assertEquals(200, conn.getResponseCode());
+ JsonNode response;
+ try (InputStream in = conn.getInputStream()) {
+ response = OBJECT_MAPPER.readTree(in);
+ }
+ assertEquals(McpJsonRpc.PARSE_ERROR, response.get("error").get("code").asInt());
+ assertTrue(response.has("id"));
+ assertTrue(response.get("id").isNull());
+ }
+ }
+}
diff --git a/hadoop-common-project/hadoop-common/src/test/java/org/apache/hadoop/mcp/TestMcpHttpServlet.java b/hadoop-common-project/hadoop-common/src/test/java/org/apache/hadoop/mcp/TestMcpHttpServlet.java
new file mode 100644
index 00000000000..a6617b8dbe4
--- /dev/null
+++ b/hadoop-common-project/hadoop-common/src/test/java/org/apache/hadoop/mcp/TestMcpHttpServlet.java
@@ -0,0 +1,303 @@
+/**
+ * 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.hadoop.mcp;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+public class TestMcpHttpServlet {
+
+ private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+ private static final JacksonMcpJsonMapper JSON_MAPPER =
+ new JacksonMcpJsonMapper(OBJECT_MAPPER);
+
+ @Test
+ public void testToolsList() throws Exception {
+ McpServer server = McpServer.sync(JSON_MAPPER)
+ .serverInfo("test-server", "1.0")
+ .capabilities(McpSchema.ServerCapabilities.withTools())
+ .toolCall(McpSchema.Tool.of("echo", "Echo input", JSON_MAPPER,
+ "{\"type\":\"object\",\"properties\":{}}"),
+ (context, args) -> McpSchema.CallToolResult.text("ok"))
+ .build();
+
+ JsonNode request = OBJECT_MAPPER.readTree(
+ "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\",\"params\":{}}");
+ McpHttpResponse response = server.getRequestHandler().handle(request);
+
+ assertEquals(200, response.status());
+ JsonNode body = response.body();
+ assertEquals("2.0", body.get("jsonrpc").asText());
+ assertEquals(1, body.get("id").asInt());
+ assertTrue(body.get("result").get("tools").isArray());
+ assertEquals("echo", body.get("result").get("tools").get(0).get("name").asText());
+ }
+
+ @Test
+ public void testToolsCall() throws Exception {
+ McpServer server = McpServer.sync(JSON_MAPPER)
+ .serverInfo("test-server", "1.0")
+ .capabilities(McpSchema.ServerCapabilities.withTools())
+ .toolCall(McpSchema.Tool.of("echo", "Echo input", JSON_MAPPER,
+ "{\"type\":\"object\",\"properties\":{}}"),
+ (context, args) -> McpSchema.CallToolResult.text("{\"value\":\"test\"}"))
+ .build();
+
+ JsonNode request = OBJECT_MAPPER.readTree(
+ "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\","
+ + "\"params\":{\"name\":\"echo\",\"arguments\":{}}}");
+ McpHttpResponse response = server.getRequestHandler().handle(request);
+
+ JsonNode body = response.body();
+ assertEquals("test", OBJECT_MAPPER.readTree(
+ body.get("result").get("content").get(0).get("text").asText()).get("value").asText());
+ }
+
+ @Test
+ public void testUnknownToolReturnsError() throws Exception {
+ McpServer server = McpServer.sync(JSON_MAPPER)
+ .serverInfo("test-server", "1.0")
+ .build();
+
+ JsonNode request = OBJECT_MAPPER.readTree(
+ "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\","
+ + "\"params\":{\"name\":\"missing\",\"arguments\":{}}}");
+ McpHttpResponse response = server.getRequestHandler().handle(request);
+
+ JsonNode body = response.body();
+ assertNotNull(body.get("error"));
+ assertEquals(McpJsonRpc.INVALID_PARAMS, body.get("error").get("code").asInt());
+ }
+
+ @Test
+ public void testToolHandlerExceptionReturnsErrorResult() throws Exception {
+ McpServer server = McpServer.sync(JSON_MAPPER)
+ .serverInfo("test-server", "1.0")
+ .capabilities(McpSchema.ServerCapabilities.withTools())
+ .toolCall(McpSchema.Tool.of("boom", "Throws", JSON_MAPPER,
+ "{\"type\":\"object\",\"properties\":{}}"),
+ (context, args) -> {
+ throw new RuntimeException("boom");
+ })
+ .build();
+
+ JsonNode request = OBJECT_MAPPER.readTree(
+ "{\"jsonrpc\":\"2.0\",\"id\":8,\"method\":\"tools/call\","
+ + "\"params\":{\"name\":\"boom\",\"arguments\":{}}}");
+ McpHttpResponse response = server.getRequestHandler().handle(request);
+
+ assertEquals(200, response.status());
+ JsonNode body = response.body();
+ assertEquals(8, body.get("id").asInt());
+ assertTrue(body.has("result"));
+ assertTrue(!body.has("error"));
+ assertTrue(body.get("result").get("isError").asBoolean());
+ assertTrue(body.get("result").get("content").get(0).get("text").asText().contains("boom"));
+ }
+
+ @Test
+ public void testToolHandlerExceptionWithoutMessageUsesFallback() throws Exception {
+ McpServer server = McpServer.sync(JSON_MAPPER)
+ .serverInfo("test-server", "1.0")
+ .capabilities(McpSchema.ServerCapabilities.withTools())
+ .toolCall(McpSchema.Tool.of("fail", "Throws without message", JSON_MAPPER,
+ "{\"type\":\"object\",\"properties\":{}}"),
+ (context, args) -> {
+ throw new RuntimeException();
+ })
+ .build();
+
+ JsonNode request = OBJECT_MAPPER.readTree(
+ "{\"jsonrpc\":\"2.0\",\"id\":9,\"method\":\"tools/call\","
+ + "\"params\":{\"name\":\"fail\",\"arguments\":{}}}");
+ McpHttpResponse response = server.getRequestHandler().handle(request);
+
+ JsonNode body = response.body();
+ assertTrue(body.get("result").get("isError").asBoolean());
+ assertEquals("Tool execution failed",
+ body.get("result").get("content").get(0).get("text").asText());
+ }
+
+ @Test
+ public void testNonObjectArgumentsReturnsInvalidParams() throws Exception {
+ McpServer server = buildEchoServer();
+
+ JsonNode stringArgsRequest = OBJECT_MAPPER.readTree(
+ "{\"jsonrpc\":\"2.0\",\"id\":5,\"method\":\"tools/call\","
+ + "\"params\":{\"name\":\"echo\",\"arguments\":\"oops\"}}");
+ McpHttpResponse stringArgsResponse = server.getRequestHandler().handle(stringArgsRequest);
+ JsonNode stringArgsBody = stringArgsResponse.body();
+ assertEquals(McpJsonRpc.INVALID_PARAMS, stringArgsBody.get("error").get("code").asInt());
+ assertEquals("arguments must be a JSON object",
+ stringArgsBody.get("error").get("message").asText());
+ assertEquals(5, stringArgsBody.get("id").asInt());
+
+ JsonNode arrayArgsRequest = OBJECT_MAPPER.readTree(
+ "{\"jsonrpc\":\"2.0\",\"id\":6,\"method\":\"tools/call\","
+ + "\"params\":{\"name\":\"echo\",\"arguments\":[]}}");
+ McpHttpResponse arrayArgsResponse = server.getRequestHandler().handle(arrayArgsRequest);
+ JsonNode arrayArgsBody = arrayArgsResponse.body();
+ assertEquals(McpJsonRpc.INVALID_PARAMS, arrayArgsBody.get("error").get("code").asInt());
+ assertEquals("arguments must be a JSON object",
+ arrayArgsBody.get("error").get("message").asText());
+ assertEquals(6, arrayArgsBody.get("id").asInt());
+ }
+
+ @Test
+ public void testNullArgumentsReturnsInvalidParams() throws Exception {
+ McpServer server = buildEchoServer();
+
+ JsonNode request = OBJECT_MAPPER.readTree(
+ "{\"jsonrpc\":\"2.0\",\"id\":8,\"method\":\"tools/call\","
+ + "\"params\":{\"name\":\"echo\",\"arguments\":null}}");
+ McpHttpResponse response = server.getRequestHandler().handle(request);
+ JsonNode body = response.body();
+ assertEquals(McpJsonRpc.INVALID_PARAMS, body.get("error").get("code").asInt());
+ assertEquals("arguments must be a JSON object",
+ body.get("error").get("message").asText());
+ assertEquals(8, body.get("id").asInt());
+ }
+
+ @Test
+ public void testOmittedArgumentsUsesEmptyMap() throws Exception {
+ McpServer server = buildEchoServer();
+
+ JsonNode request = OBJECT_MAPPER.readTree(
+ "{\"jsonrpc\":\"2.0\",\"id\":7,\"method\":\"tools/call\","
+ + "\"params\":{\"name\":\"echo\"}}");
+ McpHttpResponse response = server.getRequestHandler().handle(request);
+
+ assertEquals(200, response.status());
+ JsonNode body = response.body();
+ assertEquals(7, body.get("id").asInt());
+ assertTrue(body.has("result"));
+ }
+
+ private static McpServer buildEchoServer() {
+ return McpServer.sync(JSON_MAPPER)
+ .serverInfo("test-server", "1.0")
+ .capabilities(McpSchema.ServerCapabilities.withTools())
+ .toolCall(McpSchema.Tool.of("echo", "Echo input", JSON_MAPPER,
+ "{\"type\":\"object\",\"properties\":{}}"),
+ (context, args) -> McpSchema.CallToolResult.text("ok"))
+ .build();
+ }
+
+ @Test
+ public void testNonObjectParamsReturnsInvalidParams() throws Exception {
+ McpServer server = McpServer.sync(JSON_MAPPER)
+ .serverInfo("test-server", "1.0")
+ .capabilities(McpSchema.ServerCapabilities.withTools())
+ .build();
+
+ JsonNode request = OBJECT_MAPPER.readTree(
+ "{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"tools/list\",\"params\":[]}");
+ McpHttpResponse response = server.getRequestHandler().handle(request);
+
+ assertEquals(200, response.status());
+ JsonNode body = response.body();
+ assertEquals(McpJsonRpc.INVALID_PARAMS, body.get("error").get("code").asInt());
+ assertEquals("params must be a JSON object", body.get("error").get("message").asText());
+ assertEquals(4, body.get("id").asInt());
+ }
+
+ @Test
+ public void testInvalidEnvelopeRejectedByHandler() throws Exception {
+ McpServer server = McpServer.sync(JSON_MAPPER)
+ .serverInfo("test-server", "1.0")
+ .build();
+
+ JsonNode request = OBJECT_MAPPER.readTree("{\"id\":1,\"method\":\"tools/list\"}");
+ McpHttpResponse response = server.getRequestHandler().handle(request);
+
+ JsonNode body = response.body();
+ assertEquals(McpJsonRpc.INVALID_REQUEST, body.get("error").get("code").asInt());
+ assertEquals(McpJsonRpc.INVALID_REQUEST_MESSAGE,
+ body.get("error").get("message").asText());
+ assertEquals(1, body.get("id").asInt());
+ }
+
+ @Test
+ public void testInitializeReturns20250618ProtocolVersion() throws Exception {
+ McpServer server = McpServer.sync(JSON_MAPPER)
+ .serverInfo("test-server", "1.0")
+ .build();
+
+ JsonNode request = OBJECT_MAPPER.readTree(
+ "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\","
+ + "\"params\":{\"protocolVersion\":\"2024-11-05\",\"capabilities\":{},"
+ + "\"clientInfo\":{\"name\":\"test\",\"version\":\"1.0\"}}}");
+ McpHttpResponse response = server.getRequestHandler().handle(request);
+
+ JsonNode body = response.body();
+ assertEquals(McpJsonRpc.PROTOCOL_VERSION,
+ body.get("result").get("protocolVersion").asText());
+ }
+
+ @Test
+ public void testStringIdIsAccepted() throws Exception {
+ McpServer server = McpServer.sync(JSON_MAPPER)
+ .serverInfo("test-server", "1.0")
+ .capabilities(McpSchema.ServerCapabilities.withTools())
+ .build();
+
+ JsonNode request = OBJECT_MAPPER.readTree(
+ "{\"jsonrpc\":\"2.0\",\"id\":\"req-1\",\"method\":\"tools/list\",\"params\":{}}");
+ McpHttpResponse response = server.getRequestHandler().handle(request);
+
+ JsonNode body = response.body();
+ assertEquals("req-1", body.get("id").asText());
+ assertTrue(body.has("result"));
+ }
+
+ @Test
+ public void testRequestMethodWithoutIdReturnsAccepted() throws Exception {
+ McpServer server = McpServer.sync(JSON_MAPPER)
+ .serverInfo("test-server", "1.0")
+ .capabilities(McpSchema.ServerCapabilities.withTools())
+ .build();
+
+ JsonNode request = OBJECT_MAPPER.readTree(
+ "{\"jsonrpc\":\"2.0\",\"method\":\"tools/list\",\"params\":{}}");
+ McpHttpResponse response = server.getRequestHandler().handle(request);
+
+ assertEquals(202, response.status());
+ assertTrue(response.body() == null);
+ }
+
+ @Test
+ public void testNotificationWithoutIdReturnsAccepted() throws Exception {
+ McpServer server = McpServer.sync(JSON_MAPPER)
+ .serverInfo("test-server", "1.0")
+ .build();
+
+ JsonNode request = OBJECT_MAPPER.readTree(
+ "{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}");
+ McpHttpResponse response = server.getRequestHandler().handle(request);
+
+ assertEquals(202, response.status());
+ assertTrue(response.body() == null);
+ }
+}
diff --git a/hadoop-common-project/hadoop-common/src/test/java/org/apache/hadoop/mcp/TestMcpJsonRpcValidator.java b/hadoop-common-project/hadoop-common/src/test/java/org/apache/hadoop/mcp/TestMcpJsonRpcValidator.java
new file mode 100644
index 00000000000..4dd495ee5be
--- /dev/null
+++ b/hadoop-common-project/hadoop-common/src/test/java/org/apache/hadoop/mcp/TestMcpJsonRpcValidator.java
@@ -0,0 +1,176 @@
+/**
+ * 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.hadoop.mcp;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+public class TestMcpJsonRpcValidator {
+
+ private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+
+ private McpJsonRpcResponses responses;
+
+ @BeforeEach
+ public void setUp() {
+ responses = new McpJsonRpcResponses(OBJECT_MAPPER);
+ }
+
+ @Test
+ public void testNullRequestIsInvalid() {
+ assertInvalid(null, null);
+ }
+
+ @Test
+ public void testNonObjectRequestIsInvalid() throws Exception {
+ assertInvalid(OBJECT_MAPPER.readTree("[]"), null);
+ assertInvalid(OBJECT_MAPPER.readTree("null"), null);
+ }
+
+ @Test
+ public void testMissingJsonRpcIsInvalid() throws Exception {
+ JsonNode request = OBJECT_MAPPER.readTree("{\"id\":1,\"method\":\"tools/list\"}");
+ assertInvalid(request, 1);
+ }
+
+ @Test
+ public void testWrongJsonRpcVersionIsInvalid() throws Exception {
+ JsonNode request = OBJECT_MAPPER.readTree(
+ "{\"jsonrpc\":\"1.0\",\"id\":2,\"method\":\"tools/list\"}");
+ assertInvalid(request, 2);
+ }
+
+ @Test
+ public void testMissingMethodIsInvalid() throws Exception {
+ JsonNode request = OBJECT_MAPPER.readTree("{\"jsonrpc\":\"2.0\",\"id\":3}");
+ assertInvalid(request, 3);
+ }
+
+ @Test
+ public void testNullParamsIsInvalid() throws Exception {
+ JsonNode request = OBJECT_MAPPER.readTree(
+ "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/list\",\"params\":null}");
+ assertInvalid(request, 3);
+ }
+
+ @Test
+ public void testNullParamsOnNotificationIsInvalid() throws Exception {
+ JsonNode request = OBJECT_MAPPER.readTree(
+ "{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\",\"params\":null}");
+ assertInvalid(request, null);
+ }
+
+ @Test
+ public void testValidRequestAccepted() throws Exception {
+ JsonNode request = OBJECT_MAPPER.readTree(
+ "{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"tools/list\"}");
+ assertNull(McpJsonRpcValidator.validate(request, responses));
+ }
+
+ @Test
+ public void testStringRequestIdAccepted() throws Exception {
+ JsonNode request = OBJECT_MAPPER.readTree(
+ "{\"jsonrpc\":\"2.0\",\"id\":\"req-1\",\"method\":\"initialize\"}");
+ assertNull(McpJsonRpcValidator.validate(request, responses));
+ }
+
+ @Test
+ public void testMissingRequestIdAcceptedAsNotification() throws Exception {
+ JsonNode request = OBJECT_MAPPER.readTree(
+ "{\"jsonrpc\":\"2.0\",\"method\":\"tools/list\"}");
+ assertNull(McpJsonRpcValidator.validate(request, responses));
+ }
+
+ @Test
+ public void testNullRequestIdIsInvalid() throws Exception {
+ JsonNode request = OBJECT_MAPPER.readTree(
+ "{\"jsonrpc\":\"2.0\",\"id\":null,\"method\":\"tools/list\"}");
+ assertInvalid(request, null);
+ }
+
+ @Test
+ public void testObjectRequestIdIsInvalid() throws Exception {
+ JsonNode request = OBJECT_MAPPER.readTree(
+ "{\"jsonrpc\":\"2.0\",\"id\":{\"bad\":true},\"method\":\"tools/list\"}");
+ assertInvalid(request, null);
+ }
+
+ @Test
+ public void testNotificationWithoutIdAccepted() throws Exception {
+ JsonNode request = OBJECT_MAPPER.readTree(
+ "{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}");
+ assertNull(McpJsonRpcValidator.validate(request, responses));
+ }
+
+ @Test
+ public void testNotificationWithIdAcceptedAsRequest() throws Exception {
+ JsonNode request = OBJECT_MAPPER.readTree(
+ "{\"jsonrpc\":\"2.0\",\"id\":5,\"method\":\"notifications/initialized\"}");
+ assertNull(McpJsonRpcValidator.validate(request, responses));
+ }
+
+ @Test
+ public void testNotificationWithNullIdIsInvalid() throws Exception {
+ JsonNode request = OBJECT_MAPPER.readTree(
+ "{\"jsonrpc\":\"2.0\",\"id\":null,\"method\":\"notifications/initialized\"}");
+ assertInvalid(request, null);
+ }
+
+ @Test
+ public void testErrorResponseIncludesNullIdWhenRequestIdUnknown() {
+ McpHttpResponse response = responses.error(null, McpJsonRpc.PARSE_ERROR,
+ McpJsonRpc.PARSE_ERROR_MESSAGE);
+ JsonNode body = response.body();
+ assertTrue(body.has("id"));
+ assertTrue(body.get("id").isNull());
+ assertEquals(McpJsonRpc.PARSE_ERROR, body.get("error").get("code").asInt());
+ }
+
+ @Test
+ public void testIsJsonRpcNotification() throws Exception {
+ JsonNode notification = OBJECT_MAPPER.readTree(
+ "{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}");
+ JsonNode request = OBJECT_MAPPER.readTree(
+ "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\"}");
+ assertTrue(McpJsonRpcValidator.isJsonRpcNotification(notification));
+ assertFalse(McpJsonRpcValidator.isJsonRpcNotification(request));
+ }
+
+ private void assertInvalid(JsonNode request, Integer expectedId) {
+ McpHttpResponse response = McpJsonRpcValidator.validate(request, responses);
+ assertNotNull(response, "response should not be null");
+ JsonNode body = response.body();
+ assertEquals(McpJsonRpc.INVALID_REQUEST, body.get("error").get("code").asInt());
+ if (expectedId == null) {
+ assertTrue(body.has("id"));
+ assertTrue(body.get("id").isNull());
+ } else {
+ assertEquals(expectedId.intValue(), body.get("id").asInt());
+ }
+ }
+}
diff --git a/hadoop-common-project/hadoop-common/src/test/java/org/apache/hadoop/mcp/TestMcpToolSchema.java b/hadoop-common-project/hadoop-common/src/test/java/org/apache/hadoop/mcp/TestMcpToolSchema.java
new file mode 100644
index 00000000000..bcb994a1af1
--- /dev/null
+++ b/hadoop-common-project/hadoop-common/src/test/java/org/apache/hadoop/mcp/TestMcpToolSchema.java
@@ -0,0 +1,62 @@
+/**
+ * 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.hadoop.mcp;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+
+public class TestMcpToolSchema {
+
+ @Test
+ public void testEmptyObject() {
+ Map<String, Object> schema = McpToolSchema.emptyObject();
+ assertEquals("object", schema.get("type"));
+ @SuppressWarnings("unchecked")
+ Map<String, Object> properties = (Map<String, Object>) schema.get("properties");
+ assertTrue(properties.isEmpty());
+ }
+
+ @Test
+ public void testStringAndStringArrayProperties() {
+ Map<String, Object> schema = McpToolSchema.object()
+ .string("user", "Submitting user filter")
+ .stringArray("states", "Application states to include")
+ .build();
+
+ @SuppressWarnings("unchecked")
+ Map<String, Object> properties = (Map<String, Object>) schema.get("properties");
+
+ @SuppressWarnings("unchecked")
+ Map<String, Object> user = (Map<String, Object>) properties.get("user");
+ assertEquals("string", user.get("type"));
+ assertEquals("Submitting user filter", user.get("description"));
+
+ @SuppressWarnings("unchecked")
+ Map<String, Object> states = (Map<String, Object>) properties.get("states");
+ assertEquals("array", states.get("type"));
+ assertEquals("Application states to include", states.get("description"));
+ @SuppressWarnings("unchecked")
+ Map<String, Object> items = (Map<String, Object>) states.get("items");
+ assertEquals("string", items.get("type"));
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: common-commits-unsubscribe@hadoop.apache.org
For additional commands, e-mail: common-commits-help@hadoop.apache.org