diff --git a/bundles/org.openhab.io.mcp/src/main/java/org/openhab/io/mcp/internal/tools/ApiTools.java b/bundles/org.openhab.io.mcp/src/main/java/org/openhab/io/mcp/internal/tools/ApiTools.java index 997cde105a..d89de6af42 100644 --- a/bundles/org.openhab.io.mcp/src/main/java/org/openhab/io/mcp/internal/tools/ApiTools.java +++ b/bundles/org.openhab.io.mcp/src/main/java/org/openhab/io/mcp/internal/tools/ApiTools.java @@ -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 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 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 pathParams) { if (pathParams == null || pathParams.isEmpty()) { return path; diff --git a/bundles/org.openhab.io.mcp/src/test/java/org/openhab/io/mcp/internal/tools/ApiToolsTest.java b/bundles/org.openhab.io.mcp/src/test/java/org/openhab/io/mcp/internal/tools/ApiToolsTest.java new file mode 100644 index 0000000000..8c0fcff12a --- /dev/null +++ b/bundles/org.openhab.io.mcp/src/test/java/org/openhab/io/mcp/internal/tools/ApiToolsTest.java @@ -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 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 captor = ArgumentCaptor.forClass(String.class); + verify(requireNonNull(request)).content(any(), captor.capture()); + return captor.getValue(); + } + + private CallToolResult callApi(Map args) { + return tools().handleCallApi(requireNonNull(exchange), createRequest(args)); + } + + private static 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()); + } +}