[mcp] Support additional content types for api calls (#20949)

* [mcp] Support additional content types for api calls
We had hardcoded JSON content types for the API tools calls which does not work on all openHAB api endpoints, we now use the requestBody.content from the OpenAPI spec to determine the correct type to use.

Signed-off-by: Dan Cunningham <dan@digitaldan.com>
This commit is contained in:
Dan Cunningham
2026-06-19 17:17:15 +02:00
committed by GitHub
parent 7261d9d8f1
commit f363606478
2 changed files with 322 additions and 4 deletions
@@ -234,8 +234,14 @@ public class ApiTools {
"Map of path parameter name to value (URL-encoded automatically)."));
props.put("queryParams", Map.of("type", "object", "description",
"Map of query parameter name to value. Values are URL-encoded."));
props.put("body",
Map.of("description", "Optional JSON body. Pass as an object/array/primitive; it will be serialized."));
props.put("body", Map.of("description",
"Optional request body. Pass a JSON object/array for JSON endpoints, or a string/number/boolean "
+ "for text endpoints (e.g. \"ON\" for an item command, true for a rule enable). Serialized "
+ "automatically."));
props.put("contentType", Map.of("type", "string", "description",
"Optional override for the request Content-Type. When omitted it is inferred from the endpoint's "
+ "OpenAPI spec — application/json for most endpoints, text/plain for commands, states, and "
+ "enable/runnow/regenerate flags."));
return McpSchema.Tool.builder().name("call_api").description("""
Invoke an arbitrary openHAB REST endpoint. The call uses your own bearer token, \
so permissions match a direct REST call. Supports all HTTP methods. Returns \
@@ -290,8 +296,11 @@ public class ApiTools {
.header("Authorization", "Bearer " + token).header("Accept", "application/json");
Object body = args.get("body");
if (body != null && httpMethod != HttpMethod.GET && httpMethod != HttpMethod.DELETE) {
String bodyJson = body instanceof String s ? s : jackson.writeValueAsString(body);
req.content(new StringContentProvider(bodyJson, StandardCharsets.UTF_8), "application/json");
String serialized = body instanceof String s ? s : jackson.writeValueAsString(body);
String override = getStringArg(args, "contentType");
String contentType = override != null ? override
: resolveRequestContentType(exchange, path, resolvedPath, method, body);
req.content(new StringContentProvider(serialized, StandardCharsets.UTF_8), contentType);
}
ContentResponse resp = req.send();
return textResult(jsonMapper, buildResponse(resp));
@@ -334,6 +343,104 @@ public class ApiTools {
}
}
private static final String JSON_CONTENT_TYPE = "application/json";
private static final String TEXT_CONTENT_TYPE = "text/plain";
/**
* Picks the request Content-Type from the endpoint's spec, since openHAB mixes JSON and text/plain consumers.
*/
private String resolveRequestContentType(McpSyncServerExchange exchange, String templatePath, String resolvedPath,
String method, Object body) {
JsonNode op = findOperation(exchange, templatePath, resolvedPath, method);
JsonNode content = op == null ? null : op.path("requestBody").path("content");
if (content != null && content.isObject() && !content.isEmpty()) {
String json = null;
String text = null;
String first = null;
for (Map.Entry<String, JsonNode> entry : content.properties()) {
String mediaType = entry.getKey();
if (first == null) {
first = mediaType;
}
String lower = mediaType.toLowerCase(Locale.ROOT);
if (json == null && lower.contains("json")) {
json = mediaType;
} else if (text == null && lower.startsWith("text/")) {
text = mediaType;
}
}
// Scalar bodies (string/number/boolean) prefer text, structured bodies JSON, else take what's declared.
if (isScalarBody(body)) {
return text != null ? text : (json != null ? json : first);
}
return json != null ? json : (text != null ? text : first);
}
return isScalarBody(body) ? TEXT_CONTENT_TYPE : JSON_CONTENT_TYPE;
}
private static boolean isScalarBody(Object body) {
return body instanceof String || body instanceof Number || body instanceof Boolean;
}
/**
* Finds the spec operation for a path, matching templates against inlined param values segment by segment.
*/
private @Nullable JsonNode findOperation(McpSyncServerExchange exchange, String templatePath, String resolvedPath,
String method) {
JsonNode spec = loadSpec(exchange).spec;
if (spec == null) {
return null;
}
String verb = method.toLowerCase(Locale.ROOT);
JsonNode paths = spec.path("paths");
JsonNode exact = paths.path(normalizePath(templatePath)).path(verb);
if (exact.isObject()) {
return exact;
}
String[] want = splitPath(resolvedPath);
for (Map.Entry<String, JsonNode> entry : paths.properties()) {
String[] candidate = splitPath(entry.getKey());
if (candidate.length != want.length) {
continue;
}
boolean matches = true;
for (int i = 0; i < candidate.length; i++) {
String seg = candidate[i];
boolean isVar = seg.startsWith("{") && seg.endsWith("}");
if (!isVar && !seg.equals(want[i])) {
matches = false;
break;
}
}
if (matches) {
JsonNode op = entry.getValue().path(verb);
if (op.isObject()) {
return op;
}
}
}
return null;
}
private static String normalizePath(String path) {
return path.startsWith("/") ? path : "/" + path;
}
private static String[] splitPath(String path) {
String p = path;
int query = p.indexOf('?');
if (query >= 0) {
p = p.substring(0, query);
}
if (p.startsWith("/")) {
p = p.substring(1);
}
if (p.endsWith("/")) {
p = p.substring(0, p.length() - 1);
}
return p.isEmpty() ? new String[0] : p.split("/");
}
private static String substitutePathParams(String path, @Nullable Map<String, Object> pathParams) {
if (pathParams == null || pathParams.isEmpty()) {
return path;
@@ -0,0 +1,211 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.io.mcp.internal.tools;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import static org.openhab.io.mcp.internal.McpTestHelper.*;
import java.net.URI;
import java.util.Map;
import java.util.function.Function;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jetty.client.HttpClient;
import org.eclipse.jetty.client.api.ContentResponse;
import org.eclipse.jetty.client.api.Request;
import org.eclipse.jetty.http.HttpMethod;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.openhab.io.mcp.internal.McpTestHelper;
import io.modelcontextprotocol.json.McpJsonMapper;
import io.modelcontextprotocol.server.McpSyncServerExchange;
import io.modelcontextprotocol.spec.McpSchema.CallToolResult;
/**
* Tests for {@link ApiTools} request {@code Content-Type} selection.
*
* @author Dan Cunningham - Initial contribution
*/
@NonNullByDefault
@ExtendWith(MockitoExtension.class)
class ApiToolsTest {
private static final String BASE_URL = "http://localhost:8080";
private static final String SPEC = """
{
"paths": {
"/rules/{ruleUID}/enable": { "post": { "requestBody": { "content": { "text/plain": {} } } } },
"/items/{itemname}": { "put": { "requestBody": { "content": { "application/json": {} } } } },
"/items/{itemname}/x": { "post": { "requestBody": { "content": {
"application/json": {}, "text/plain": {} } } } }
}
}
""";
@Mock
@Nullable
HttpClient httpClient;
@Mock
@Nullable
Request request;
@Mock
@Nullable
ContentResponse response;
@Mock
@Nullable
McpSyncServerExchange exchange;
private final McpJsonMapper jsonMapper = McpTestHelper.newJsonMapper();
private final Function<String, @Nullable String> tokenSupplier = sid -> "test-token";
@BeforeEach
void setUp() throws Exception {
Request r = requireNonNull(request);
lenient().when(r.method(any(HttpMethod.class))).thenReturn(r);
lenient().when(r.header(anyString(), anyString())).thenReturn(r);
lenient().when(r.content(any(), anyString())).thenReturn(r);
lenient().when(r.send()).thenReturn(requireNonNull(response));
lenient().when(httpClient.newRequest(any(URI.class))).thenReturn(r);
lenient().when(exchange.sessionId()).thenReturn("session-1");
}
private ApiTools tools() {
return new ApiTools(requireNonNull(httpClient), BASE_URL, tokenSupplier, jsonMapper);
}
private void stubSpecAvailable() {
ContentResponse resp = requireNonNull(response);
lenient().when(resp.getStatus()).thenReturn(200);
lenient().when(resp.getContentAsString()).thenReturn(SPEC);
}
private void stubSpecUnavailable() {
ContentResponse resp = requireNonNull(response);
lenient().when(resp.getStatus()).thenReturn(500);
lenient().when(resp.getContentAsString()).thenReturn("");
}
private String capturedContentType() {
ArgumentCaptor<String> captor = ArgumentCaptor.forClass(String.class);
verify(requireNonNull(request)).content(any(), captor.capture());
return captor.getValue();
}
private CallToolResult callApi(Map<String, Object> args) {
return tools().handleCallApi(requireNonNull(exchange), createRequest(args));
}
private static <T> T requireNonNull(@Nullable T value) {
assertNotNull(value);
return value;
}
@Test
void textPlainEndpointSendsTextPlain() {
stubSpecAvailable();
CallToolResult result = callApi(Map.of("method", "POST", "path", "/rules/{ruleUID}/enable", "pathParams",
Map.of("ruleUID", "myRule"), "body", "true"));
assertSuccess(result);
assertEquals("text/plain", capturedContentType());
}
@Test
void textPlainEndpointMatchedWhenPathParamsAreInlined() {
stubSpecAvailable();
// No pathParams: value inlined in the path, so the spec template is matched structurally.
CallToolResult result = callApi(Map.of("method", "POST", "path", "/rules/myRule/enable", "body", "false"));
assertSuccess(result);
assertEquals("text/plain", capturedContentType());
}
@Test
void jsonEndpointSendsJson() {
stubSpecAvailable();
CallToolResult result = callApi(Map.of("method", "PUT", "path", "/items/{itemname}", "pathParams",
Map.of("itemname", "Light"), "body", Map.of("type", "Switch")));
assertSuccess(result);
assertEquals("application/json", capturedContentType());
}
@Test
void endpointAcceptingBothPrefersTextForStringBody() {
stubSpecAvailable();
CallToolResult result = callApi(Map.of("method", "POST", "path", "/items/{itemname}/x", "pathParams",
Map.of("itemname", "Light"), "body", "ON"));
assertSuccess(result);
assertEquals("text/plain", capturedContentType());
}
@Test
void endpointAcceptingBothPrefersJsonForStructuredBody() {
stubSpecAvailable();
CallToolResult result = callApi(Map.of("method", "POST", "path", "/items/{itemname}/x", "pathParams",
Map.of("itemname", "Light"), "body", Map.of("a", 1)));
assertSuccess(result);
assertEquals("application/json", capturedContentType());
}
@Test
void explicitContentTypeOverrideWins() {
stubSpecAvailable();
CallToolResult result = callApi(Map.of("method", "POST", "path", "/rules/{ruleUID}/enable", "pathParams",
Map.of("ruleUID", "myRule"), "body", "true", "contentType", "application/xml"));
assertSuccess(result);
assertEquals("application/xml", capturedContentType());
}
@Test
void fallbackUsesTextPlainForStringBodyWhenSpecUnavailable() {
stubSpecUnavailable();
CallToolResult result = callApi(Map.of("method", "POST", "path", "/some/unknown/path", "body", "raw"));
assertSuccess(result);
assertEquals("text/plain", capturedContentType());
}
@Test
void fallbackUsesJsonForStructuredBodyWhenSpecUnavailable() {
stubSpecUnavailable();
CallToolResult result = callApi(Map.of("method", "POST", "path", "/some/unknown/path", "body", Map.of("a", 1)));
assertSuccess(result);
assertEquals("application/json", capturedContentType());
}
@Test
void textPlainEndpointSendsTextPlainForBooleanBody() {
stubSpecAvailable();
CallToolResult result = callApi(Map.of("method", "POST", "path", "/rules/{ruleUID}/enable", "pathParams",
Map.of("ruleUID", "myRule"), "body", true));
assertSuccess(result);
assertEquals("text/plain", capturedContentType());
}
@Test
void fallbackUsesTextPlainForScalarBodyWhenSpecUnavailable() {
stubSpecUnavailable();
CallToolResult result = callApi(Map.of("method", "POST", "path", "/some/unknown/path", "body", true));
assertSuccess(result);
assertEquals("text/plain", capturedContentType());
}
}