mirror of
https://github.com/danieldemus/openhab-addons
synced 2026-07-29 12:34:21 +02:00
[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:
+111
-4
@@ -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;
|
||||
|
||||
+211
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user