diff --git a/.agents/skills/naftiko-capability/references/design-guidelines.md b/.agents/skills/naftiko-capability/references/design-guidelines.md index 7167d0ae..ee084a55 100644 --- a/.agents/skills/naftiko-capability/references/design-guidelines.md +++ b/.agents/skills/naftiko-capability/references/design-guidelines.md @@ -103,6 +103,7 @@ Avoid: - Set `destructive: true` for tools that delete or overwrite (DELETE, PUT). - Set `idempotent: true` for tools safe to retry. - Set `openWorld: true` for tools calling external APIs; `false` for closed-domain tools (local data, caches). +- Use mock mode (`outputParameters` with `const`, no `call`/`steps`) for prototyping, demos, or contract-first development when no consumed API is available yet. ## Orchestration guidelines (steps + mappings) diff --git a/.agents/skills/naftiko-capability/references/wrap-api-as-mcp.md b/.agents/skills/naftiko-capability/references/wrap-api-as-mcp.md index 63bcd1db..4345bf1c 100644 --- a/.agents/skills/naftiko-capability/references/wrap-api-as-mcp.md +++ b/.agents/skills/naftiko-capability/references/wrap-api-as-mcp.md @@ -51,8 +51,9 @@ An MCP tool/resource/prompt can be: - simple mode: `call` + optional `with` - orchestrated mode: `steps` + optional `mappings` + `outputParameters` +- mock mode: `outputParameters` with `const` values only (no `call`, no `steps`, no `consumes` block required) -Do not mix simple and orchestrated fields in the same tool/resource (choose one). +Do not mix fields from different modes in the same tool/resource (choose one). ## Mapping strategy (recommended) @@ -109,7 +110,11 @@ For each MCP tool: - must define `steps` (min 1), each step has `name` - may define `mappings` - `outputParameters` must use orchestrated output parameter objects (named + typed) -6. Tool `inputParameters`: +6. If tool is mock (no consumed API): + - must define `outputParameters` (at least 1) with `const` values + - must NOT define `call` or `steps` + - no `consumes` block is required +7. Tool `inputParameters`: - each parameter must have `name`, `type`, `description` - set `required: false` explicitly for optional params (default is true) diff --git a/src/main/java/io/naftiko/engine/exposes/MockResponseBuilder.java b/src/main/java/io/naftiko/engine/exposes/MockResponseBuilder.java new file mode 100644 index 00000000..a1949e89 --- /dev/null +++ b/src/main/java/io/naftiko/engine/exposes/MockResponseBuilder.java @@ -0,0 +1,164 @@ +/** + * Copyright 2025-2026 Naftiko + * + * Licensed 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 io.naftiko.engine.exposes; + +import java.util.List; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.NullNode; +import io.naftiko.spec.OutputParameterSpec; + +/** + * Builds mock JSON responses from outputParameters with const values. + * Shared by REST and MCP adapters for mock mode (no consumed HTTP adapter). + */ +public class MockResponseBuilder { + + private MockResponseBuilder() {} + + /** + * Check if a list of output parameters can produce a mock response. + * Returns true if at least one parameter in the tree has a const value. + */ + public static boolean canBuildMockResponse(List outputParameters) { + if (outputParameters == null || outputParameters.isEmpty()) { + return false; + } + + for (OutputParameterSpec param : outputParameters) { + if (hasConstValue(param)) { + return true; + } + } + + return false; + } + + /** + * Build a JSON object with mock data from outputParameters const values. + * + * @return the mock JSON node, or null if no const values found + */ + public static JsonNode buildMockData(List outputParameters, + ObjectMapper mapper) { + if (outputParameters == null || outputParameters.isEmpty()) { + return null; + } + + com.fasterxml.jackson.databind.node.ObjectNode result = mapper.createObjectNode(); + + for (OutputParameterSpec param : outputParameters) { + JsonNode paramValue = buildParameterValue(param, mapper); + if (paramValue != null && !(paramValue instanceof NullNode)) { + String fieldName = param.getName() != null ? param.getName() : "value"; + result.set(fieldName, paramValue); + } + } + + return result.size() > 0 ? result : null; + } + + /** + * Build a JSON node for a single parameter, using const values or structures. + */ + public static JsonNode buildParameterValue(OutputParameterSpec param, ObjectMapper mapper) { + if (param == null) { + return NullNode.instance; + } + + String type = param.getType() != null ? param.getType().toLowerCase() : "string"; + + if (param.getConstant() != null) { + return typedStringToNode(param.getConstant(), type, mapper); + } + + switch (type) { + case "array": + com.fasterxml.jackson.databind.node.ArrayNode arrayNode = mapper.createArrayNode(); + OutputParameterSpec items = param.getItems(); + + if (items != null) { + JsonNode itemValue = buildParameterValue(items, mapper); + if (itemValue != null && !(itemValue instanceof NullNode)) { + arrayNode.add(itemValue); + } + } + + return arrayNode; + + case "object": + com.fasterxml.jackson.databind.node.ObjectNode objectNode = + mapper.createObjectNode(); + + if (param.getProperties() != null) { + for (OutputParameterSpec prop : param.getProperties()) { + JsonNode propValue = buildParameterValue(prop, mapper); + if (propValue != null && !(propValue instanceof NullNode)) { + String propName = + prop.getName() != null ? prop.getName() : "property"; + objectNode.set(propName, propValue); + } + } + } + + return objectNode.size() > 0 ? objectNode : NullNode.instance; + + default: + return NullNode.instance; + } + } + + /** + * Convert a string value to the appropriate JSON node based on the declared type. + */ + static JsonNode typedStringToNode(String value, String type, ObjectMapper mapper) { + switch (type) { + case "boolean": + return mapper.getNodeFactory().booleanNode(Boolean.parseBoolean(value)); + case "number": + return mapper.getNodeFactory().numberNode(Double.parseDouble(value)); + case "integer": + return mapper.getNodeFactory().numberNode(Long.parseLong(value)); + default: + return mapper.getNodeFactory().textNode(value); + } + } + + /** + * Recursively check if a parameter or its nested structure has any const values. + */ + static boolean hasConstValue(OutputParameterSpec param) { + if (param == null) { + return false; + } + + if (param.getConstant() != null) { + return true; + } + + if (param.getProperties() != null) { + for (OutputParameterSpec prop : param.getProperties()) { + if (hasConstValue(prop)) { + return true; + } + } + } + + if (param.getItems() != null) { + return hasConstValue(param.getItems()); + } + + return false; + } +} diff --git a/src/main/java/io/naftiko/engine/exposes/mcp/ToolHandler.java b/src/main/java/io/naftiko/engine/exposes/mcp/ToolHandler.java index 9f33d402..9a2a2c25 100644 --- a/src/main/java/io/naftiko/engine/exposes/mcp/ToolHandler.java +++ b/src/main/java/io/naftiko/engine/exposes/mcp/ToolHandler.java @@ -19,9 +19,12 @@ import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.logging.Logger; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import io.modelcontextprotocol.spec.McpSchema; import io.naftiko.Capability; import io.naftiko.engine.Resolver; +import io.naftiko.engine.exposes.MockResponseBuilder; import io.naftiko.engine.exposes.OperationStepExecutor; import io.naftiko.spec.exposes.McpServerToolSpec; @@ -94,6 +97,12 @@ public McpSchema.CallToolResult handleToolCall(String toolName, Map diff --git a/src/main/java/io/naftiko/engine/exposes/rest/ResourceRestlet.java b/src/main/java/io/naftiko/engine/exposes/rest/ResourceRestlet.java index 100bf12f..017428ea 100644 --- a/src/main/java/io/naftiko/engine/exposes/rest/ResourceRestlet.java +++ b/src/main/java/io/naftiko/engine/exposes/rest/ResourceRestlet.java @@ -24,6 +24,7 @@ import io.naftiko.engine.Resolver; import io.naftiko.engine.consumes.ClientAdapter; import io.naftiko.engine.consumes.http.HttpClientAdapter; +import io.naftiko.engine.exposes.MockResponseBuilder; import io.naftiko.engine.exposes.OperationStepExecutor; import io.naftiko.spec.OutputParameterSpec; import io.naftiko.spec.exposes.RestServerForwardSpec; @@ -195,57 +196,7 @@ private boolean handleFromOperationSpec(Request request, Response response) { * Returns true if the operation has at least one outputParameter with a const value. */ boolean canBuildMockResponse(RestServerOperationSpec serverOp) { - if (serverOp.getOutputParameters() == null || serverOp.getOutputParameters().isEmpty()) { - return false; - } - - // Check if at least one output parameter has a const value - for (OutputParameterSpec param : serverOp.getOutputParameters()) { - if (param.getConstant() != null) { - return true; - } - // Check nested properties for const values - if (param.getProperties() != null && !param.getProperties().isEmpty()) { - for (OutputParameterSpec prop : param.getProperties()) { - if (hasConstValue(prop)) { - return true; - } - } - } - // Check items for const values - if (param.getItems() != null && hasConstValue(param.getItems())) { - return true; - } - } - - return false; - } - - /** - * Recursively check if a parameter or its nested structure has any const values. - */ - private boolean hasConstValue(OutputParameterSpec param) { - if (param == null) { - return false; - } - - if (param.getConstant() != null) { - return true; - } - - if (param.getProperties() != null) { - for (OutputParameterSpec prop : param.getProperties()) { - if (hasConstValue(prop)) { - return true; - } - } - } - - if (param.getItems() != null) { - return hasConstValue(param.getItems()); - } - - return false; + return MockResponseBuilder.canBuildMockResponse(serverOp.getOutputParameters()); } /** @@ -255,8 +206,7 @@ void sendMockResponse(RestServerOperationSpec serverOp, Response response) { try { ObjectMapper mapper = new ObjectMapper(); - // Build a JSON response using const values from outputParameters - JsonNode mockRoot = buildMockData(serverOp, mapper); + JsonNode mockRoot = MockResponseBuilder.buildMockData(serverOp.getOutputParameters(), mapper); if (mockRoot != null) { response.setStatus(Status.SUCCESS_OK); @@ -276,80 +226,6 @@ void sendMockResponse(RestServerOperationSpec serverOp, Response response) { response.commit(); } - /** - * Build a JSON object with mock data from outputParameters const values. - */ - private JsonNode buildMockData(RestServerOperationSpec serverOp, ObjectMapper mapper) { - if (serverOp.getOutputParameters() == null || serverOp.getOutputParameters().isEmpty()) { - return null; - } - - com.fasterxml.jackson.databind.node.ObjectNode result = mapper.createObjectNode(); - - for (OutputParameterSpec param : serverOp.getOutputParameters()) { - JsonNode paramValue = buildParameterValue(param, mapper); - if (paramValue != null && !(paramValue instanceof NullNode)) { - // Use the parameter name if available, otherwise use "value" - String fieldName = param.getName() != null ? param.getName() : "value"; - result.set(fieldName, paramValue); - } - } - - return result.size() > 0 ? result : null; - } - - /** - * Build a JSON node for a single parameter, using const values or structures. - */ - JsonNode buildParameterValue(OutputParameterSpec param, ObjectMapper mapper) { - if (param == null) { - return NullNode.instance; - } - - // Handle const values directly - if (param.getConstant() != null) { - return mapper.getNodeFactory().textNode(param.getConstant()); - } - - String type = param.getType(); - - // Handle array types - if ("array".equalsIgnoreCase(type)) { - com.fasterxml.jackson.databind.node.ArrayNode arrayNode = mapper.createArrayNode(); - OutputParameterSpec items = param.getItems(); - - if (items != null) { - // Create one mock item to demonstrate the structure - JsonNode itemValue = buildParameterValue(items, mapper); - if (itemValue != null && !(itemValue instanceof NullNode)) { - arrayNode.add(itemValue); - } - } - - return arrayNode; - } - - // Handle object types - if ("object".equalsIgnoreCase(type)) { - com.fasterxml.jackson.databind.node.ObjectNode objectNode = mapper.createObjectNode(); - - if (param.getProperties() != null) { - for (OutputParameterSpec prop : param.getProperties()) { - JsonNode propValue = buildParameterValue(prop, mapper); - if (propValue != null && !(propValue instanceof NullNode)) { - String propName = prop.getName() != null ? prop.getName() : "property"; - objectNode.set(propName, propValue); - } - } - } - - return objectNode.size() > 0 ? objectNode : NullNode.instance; - } - - // For other types without const values, return null - return NullNode.instance; - } - void sendResponse(RestServerOperationSpec serverOp, Response response, OperationStepExecutor.HandlingContext found) { // Apply output mappings if present or forward the raw entity diff --git a/src/main/resources/schemas/examples/mock-mcp.yml b/src/main/resources/schemas/examples/mock-mcp.yml new file mode 100644 index 00000000..ef0c9097 --- /dev/null +++ b/src/main/resources/schemas/examples/mock-mcp.yml @@ -0,0 +1,52 @@ +# yaml-language-server: $schema=../naftiko-schema.json +# +# Mock MCP Server — no consumes block required. +# Tools return static values from const in outputParameters. +# +--- +naftiko: "1.0.0-alpha1" +info: + label: "Mock MCP Server" + description: "Demonstrates MCP tools that return static mock data without consuming any HTTP API" + tags: + - MCP + - Mock + - Example + created: "2026-04-03" + modified: "2026-04-03" + stakeholders: + - role: "editor" + fullName: "John Doe" + email: "john.doe@example.com" + +capability: + exposes: + - type: mcp + port: 3001 + namespace: "mock-tools" + description: "Mock MCP server that returns constant values" + tools: + - name: say-hello + description: "Returns a greeting message" + inputParameters: + - name: name + type: string + required: true + description: "Name to greet" + outputParameters: + - name: message + type: string + const: "Hello, John Doe!" + + - name: get-company-info + description: "Returns static company information" + outputParameters: + - name: company + type: string + const: "Example Corp" + - name: industry + type: string + const: "Technology" + - name: founded + type: string + const: "2020" diff --git a/src/main/resources/schemas/naftiko-schema.json b/src/main/resources/schemas/naftiko-schema.json index ab859a10..55c323b7 100644 --- a/src/main/resources/schemas/naftiko-schema.json +++ b/src/main/resources/schemas/naftiko-schema.json @@ -448,6 +448,44 @@ } ] }, + "MockOutputParameter": { + "type": "object", + "description": "A named output parameter with a static const value for mock mode (no consumed operations).", + "properties": { + "name": { + "$ref": "#/$defs/IdentifierKebab", + "description": "Name of the output field in the mock response JSON." + }, + "type": { + "type": "string", + "enum": [ + "string", + "number", + "boolean" + ] + }, + "const": { + "description": "Static value returned by the mock tool.", + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + } + ] + } + }, + "required": [ + "name", + "type", + "const" + ], + "additionalProperties": false + }, "OrchestratedOutputParameterBase": { "type": "object", "description": "Base for a named output parameter whose value is populated by step mappings during orchestration.", @@ -852,7 +890,7 @@ }, "McpTool": { "type": "object", - "description": "An MCP tool definition. Each tool maps to one or more consumed HTTP operations.", + "description": "An MCP tool definition. Each tool maps to one or more consumed HTTP operations, or serves static mock responses using const values.", "properties": { "name": { "$ref": "#/$defs/IdentifierKebab", @@ -935,6 +973,28 @@ } } } + }, + { + "required": [ + "outputParameters" + ], + "type": "object", + "description": "Mock mode: serves static responses from const values in outputParameters. No consumes block required.", + "properties": { + "outputParameters": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/MockOutputParameter" + } + } + }, + "not": { + "anyOf": [ + { "required": ["call"] }, + { "required": ["steps"] } + ] + } } ], "additionalProperties": false diff --git a/src/main/resources/wiki/Installation.md b/src/main/resources/wiki/Installation.md index 1d1c870b..425260a7 100644 --- a/src/main/resources/wiki/Installation.md +++ b/src/main/resources/wiki/Installation.md @@ -31,7 +31,7 @@ To use Naftiko Framework, you must install and then run its engine. * If your capability refers to some local hosts, be careful to not use 'localhost', but 'host.docker.internal' instead. This is because your capability will run into an isolated docker container, so 'localhost' will refer to the container and not your local machine.\ For example: ```bash - baseUri: "http://host.docker.internal:8080/api/" + baseUri: "http://host.docker.internal:8080/api" ``` * In the same way, if your capability expose a local host, be careful to not use 'localhost', but '0.0.0.0' instead. Else requests to localhost coming from outside of the container won't succeed.\ For example: diff --git "a/src/main/resources/wiki/Specification-\342\200\220-Schema.md" "b/src/main/resources/wiki/Specification-\342\200\220-Schema.md" index 41a380dd..ea4d3c0a 100644 --- "a/src/main/resources/wiki/Specification-\342\200\220-Schema.md" +++ "b/src/main/resources/wiki/Specification-\342\200\220-Schema.md" @@ -333,7 +333,7 @@ Capability groups not declared in the configuration are omitted from the `initia An MCP tool definition. Each tool maps to one or more consumed HTTP operations, similar to ExposedOperation but adapted for the MCP protocol (no HTTP method, tool-oriented input schema). -> The McpTool supports the same two modes as ExposedOperation: **simple** (direct `call` + `with`) and **orchestrated** (multi-step with `steps` + `mappings`). +> The McpTool supports three modes: **simple** (direct `call` + `with`), **orchestrated** (multi-step with `steps` + `mappings`), and **mock** (static responses from `const` values in `outputParameters`, no `call` or `steps` needed). > **Fixed Fields:** @@ -368,10 +368,18 @@ An MCP tool definition. Each tool maps to one or more consumed HTTP operations, - `outputParameters` are `OrchestratedOutputParameter[]` - `call` and `with` MUST NOT be present +**Mock mode** — static responses from `const` values (no consumed operations required): + +- `outputParameters` is **REQUIRED** (at least 1 entry), using `MockOutputParameter[]` +- Each `MockOutputParameter` has `name`, `type`, and `const` (all required) +- `call` and `steps` MUST NOT be present +- No `consumes` block is needed +- Returns a fixed JSON response built from `const` values in `outputParameters` + **Rules:** - Both `name` and `description` are mandatory. -- Exactly one of the two modes MUST be used (simple or orchestrated). +- Exactly one of the three modes MUST be used (simple, orchestrated, or mock). - In simple mode, `call` MUST follow the format `{namespace}.{operationId}` and reference a valid consumed operation. - In orchestrated mode, the `steps` array MUST contain at least one entry. - Input parameters are accessed via namespace-qualified references of the form `{mcpNamespace}.{paramName}`. @@ -443,6 +451,35 @@ Declares an input parameter for an MCP tool. These become properties in the tool required: false ``` +#### 3.5.6b MockOutputParameter Object + +A named output parameter with a static `const` value, used in **mock mode** MCP tools. Mock tools return fixed JSON responses without consuming any HTTP API. + +**Fixed Fields:** + +| Field Name | Type | Description | +| --- | --- | --- | +| **name** | `string` | **REQUIRED**. Name of the output field in the mock response JSON. MUST match pattern `^[a-zA-Z0-9-]+$`. | +| **type** | `string` | **REQUIRED**. Data type. One of: `string`, `number`, `boolean`. | +| **const** | `string \| number \| boolean` | **REQUIRED**. Static value returned by the mock tool. | + +**Rules:** + +- All three fields (`name`, `type`, `const`) are mandatory. +- No additional properties are allowed. + +**MockOutputParameter Example:** + +```yaml +outputParameters: + - name: message + type: string + const: "Hello, World!" + - name: status-code + type: number + const: 200 +``` + #### 3.5.7 McpResource Object An MCP resource definition. Resources expose data that agents can **read** (but not invoke like tools). Two source types are supported: **dynamic** (backed by consumed HTTP operations) and **static** (served from local files). diff --git a/src/test/java/io/naftiko/engine/exposes/MockResponseBuilderTest.java b/src/test/java/io/naftiko/engine/exposes/MockResponseBuilderTest.java new file mode 100644 index 00000000..838de6ba --- /dev/null +++ b/src/test/java/io/naftiko/engine/exposes/MockResponseBuilderTest.java @@ -0,0 +1,218 @@ +/** + * Copyright 2025-2026 Naftiko + * + * Licensed 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 io.naftiko.engine.exposes; + +import static org.junit.jupiter.api.Assertions.*; +import java.util.List; +import org.junit.jupiter.api.Test; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.naftiko.spec.OutputParameterSpec; + +public class MockResponseBuilderTest { + + private final ObjectMapper mapper = new ObjectMapper(); + + @Test + void canBuildMockResponseShouldReturnFalseWhenNull() { + assertFalse(MockResponseBuilder.canBuildMockResponse(null)); + } + + @Test + void canBuildMockResponseShouldReturnFalseWhenEmpty() { + assertFalse(MockResponseBuilder.canBuildMockResponse(List.of())); + } + + @Test + void canBuildMockResponseShouldReturnFalseWhenNoConstValues() { + OutputParameterSpec param = new OutputParameterSpec("field", "string", null, null); + assertFalse(MockResponseBuilder.canBuildMockResponse(List.of(param))); + } + + @Test + void canBuildMockResponseShouldReturnTrueWhenConstPresent() { + OutputParameterSpec param = new OutputParameterSpec(); + param.setName("greeting"); + param.setType("string"); + param.setConstant("hello"); + assertTrue(MockResponseBuilder.canBuildMockResponse(List.of(param))); + } + + @Test + void canBuildMockResponseShouldDetectNestedConst() { + OutputParameterSpec nested = new OutputParameterSpec(); + nested.setName("city"); + nested.setType("string"); + nested.setConstant("Paris"); + + OutputParameterSpec parent = new OutputParameterSpec(); + parent.setName("address"); + parent.setType("object"); + parent.getProperties().add(nested); + + assertTrue(MockResponseBuilder.canBuildMockResponse(List.of(parent))); + } + + @Test + void canBuildMockResponseShouldDetectConstInArrayItems() { + OutputParameterSpec item = new OutputParameterSpec(); + item.setName("tag"); + item.setType("string"); + item.setConstant("sample"); + + OutputParameterSpec array = new OutputParameterSpec(); + array.setName("tags"); + array.setType("array"); + array.setItems(item); + + assertTrue(MockResponseBuilder.canBuildMockResponse(List.of(array))); + } + + @Test + void buildMockDataShouldReturnNullWhenNull() { + assertNull(MockResponseBuilder.buildMockData(null, mapper)); + } + + @Test + void buildMockDataShouldReturnNullWhenEmpty() { + assertNull(MockResponseBuilder.buildMockData(List.of(), mapper)); + } + + @Test + void buildMockDataShouldBuildStringConst() { + OutputParameterSpec param = new OutputParameterSpec(); + param.setName("message"); + param.setType("string"); + param.setConstant("Hello, World!"); + + JsonNode result = MockResponseBuilder.buildMockData(List.of(param), mapper); + assertNotNull(result); + assertEquals("Hello, World!", result.get("message").asText()); + } + + @Test + void buildMockDataShouldBuildObjectWithProperties() { + OutputParameterSpec company = new OutputParameterSpec(); + company.setName("company"); + company.setType("string"); + company.setConstant("Naftiko"); + + OutputParameterSpec role = new OutputParameterSpec(); + role.setName("role"); + role.setType("string"); + role.setConstant("Engineer"); + + OutputParameterSpec obj = new OutputParameterSpec(); + obj.setName("profile"); + obj.setType("object"); + obj.getProperties().add(company); + obj.getProperties().add(role); + + JsonNode result = MockResponseBuilder.buildMockData(List.of(obj), mapper); + assertNotNull(result); + JsonNode profile = result.get("profile"); + assertNotNull(profile); + assertEquals("Naftiko", profile.get("company").asText()); + assertEquals("Engineer", profile.get("role").asText()); + } + + @Test + void buildMockDataShouldBuildArrayWithItems() { + OutputParameterSpec item = new OutputParameterSpec(); + item.setType("string"); + item.setConstant("tag-value"); + + OutputParameterSpec array = new OutputParameterSpec(); + array.setName("tags"); + array.setType("array"); + array.setItems(item); + + JsonNode result = MockResponseBuilder.buildMockData(List.of(array), mapper); + assertNotNull(result); + JsonNode tags = result.get("tags"); + assertTrue(tags.isArray()); + assertEquals(1, tags.size()); + assertEquals("tag-value", tags.get(0).asText()); + } + + @Test + void buildParameterValueShouldReturnNullNodeForNullParam() { + JsonNode result = MockResponseBuilder.buildParameterValue(null, mapper); + assertTrue(result.isNull()); + } + + @Test + void buildParameterValueShouldReturnNullNodeForTypeWithoutConst() { + OutputParameterSpec param = new OutputParameterSpec(); + param.setName("field"); + param.setType("string"); + + JsonNode result = MockResponseBuilder.buildParameterValue(param, mapper); + assertTrue(result.isNull()); + } + + @Test + void buildMockDataShouldUseValueAsDefaultFieldName() { + OutputParameterSpec param = new OutputParameterSpec(); + param.setType("string"); + param.setConstant("no-name-field"); + + JsonNode result = MockResponseBuilder.buildMockData(List.of(param), mapper); + assertNotNull(result); + assertEquals("no-name-field", result.get("value").asText()); + } + + @Test + void buildParameterValueShouldReturnBooleanNodeForBooleanConst() { + OutputParameterSpec param = new OutputParameterSpec(); + param.setName("active"); + param.setType("boolean"); + param.setConstant("true"); + + JsonNode result = MockResponseBuilder.buildParameterValue(param, mapper); + assertTrue(result.isBoolean()); + assertTrue(result.booleanValue()); + } + + @Test + void buildParameterValueShouldReturnNumberNodeForNumberConst() { + OutputParameterSpec param = new OutputParameterSpec(); + param.setName("price"); + param.setType("number"); + param.setConstant("19.99"); + + JsonNode result = MockResponseBuilder.buildParameterValue(param, mapper); + assertTrue(result.isNumber()); + assertEquals(19.99, result.doubleValue()); + } + + @Test + void buildParameterValueShouldReturnIntegerNodeForIntegerConst() { + OutputParameterSpec param = new OutputParameterSpec(); + param.setName("count"); + param.setType("integer"); + param.setConstant("42"); + + JsonNode result = MockResponseBuilder.buildParameterValue(param, mapper); + assertTrue(result.isNumber()); + assertEquals(42L, result.longValue()); + } + + @Test + void constToNodeShouldDefaultToTextForUnknownType() { + JsonNode result = MockResponseBuilder.typedStringToNode("hello", "string", mapper); + assertTrue(result.isTextual()); + assertEquals("hello", result.asText()); + } +} diff --git a/src/test/java/io/naftiko/engine/exposes/mcp/MockMcpIntegrationTest.java b/src/test/java/io/naftiko/engine/exposes/mcp/MockMcpIntegrationTest.java new file mode 100644 index 00000000..7c3fb794 --- /dev/null +++ b/src/test/java/io/naftiko/engine/exposes/mcp/MockMcpIntegrationTest.java @@ -0,0 +1,108 @@ +/** + * Copyright 2025-2026 Naftiko + * + * Licensed 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 io.naftiko.engine.exposes.mcp; + +import static org.junit.jupiter.api.Assertions.*; +import java.io.File; +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import io.modelcontextprotocol.spec.McpSchema; +import io.naftiko.Capability; +import io.naftiko.spec.NaftikoSpec; +import io.naftiko.spec.exposes.McpServerSpec; + +/** + * Integration tests for MCP mock mode. + * Validates the full chain: YAML fixture → deserialization → ToolHandler → mock response. + */ +public class MockMcpIntegrationTest { + + private Capability capability; + private McpServerSpec mcpSpec; + + @BeforeEach + public void setUp() throws Exception { + String resourcePath = "src/test/resources/mock-mcp-capability.yaml"; + File file = new File(resourcePath); + assertTrue(file.exists(), "Mock MCP capability file should exist at " + resourcePath); + + ObjectMapper mapper = new ObjectMapper(new YAMLFactory()); + mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + NaftikoSpec spec = mapper.readValue(file, NaftikoSpec.class); + + capability = new Capability(spec); + mcpSpec = (McpServerSpec) spec.getCapability().getExposes().get(0); + } + + @Test + void capabilityShouldLoadWithNoConsumesBlock() { + assertTrue(capability.getClientAdapters().isEmpty(), + "Mock capability should have no client adapters"); + } + + @Test + void mcpSpecShouldHaveTwoTools() { + assertEquals(2, mcpSpec.getTools().size()); + assertEquals("say-hello", mcpSpec.getTools().get(0).getName()); + assertEquals("get-profile", mcpSpec.getTools().get(1).getName()); + } + + @Test + void sayHelloShouldReturnMockGreeting() throws Exception { + ToolHandler handler = new ToolHandler(capability, mcpSpec.getTools(), + mcpSpec.getNamespace()); + + McpSchema.CallToolResult result = + handler.handleToolCall("say-hello", Map.of("name", "Alice")); + + assertFalse(result.isError(), "Mock tool should not return an error"); + assertNotNull(result.content()); + assertEquals(1, result.content().size()); + + String text = ((McpSchema.TextContent) result.content().get(0)).text(); + assertTrue(text.contains("Hello, World!"), + "Response should contain the const value"); + } + + @Test + void getProfileShouldReturnMockProfile() throws Exception { + ToolHandler handler = new ToolHandler(capability, mcpSpec.getTools(), + mcpSpec.getNamespace()); + + McpSchema.CallToolResult result = + handler.handleToolCall("get-profile", Map.of()); + + assertFalse(result.isError(), "Mock tool should not return an error"); + String text = ((McpSchema.TextContent) result.content().get(0)).text(); + assertTrue(text.contains("Naftiko")); + assertTrue(text.contains("Engineer")); + assertTrue(text.contains("Earth")); + } + + @Test + void mockToolShouldBuildValidMcpToolDefinition() { + McpServerAdapter adapter = (McpServerAdapter) capability.getServerAdapters().get(0); + + assertNotNull(adapter.getTools()); + assertEquals(2, adapter.getTools().size()); + + McpSchema.Tool sayHello = adapter.getTools().get(0); + assertEquals("say-hello", sayHello.name()); + assertNotNull(sayHello.inputSchema()); + } +} diff --git a/src/test/java/io/naftiko/engine/exposes/mcp/ToolHandlerTest.java b/src/test/java/io/naftiko/engine/exposes/mcp/ToolHandlerTest.java index 7915b474..60bb075d 100644 --- a/src/test/java/io/naftiko/engine/exposes/mcp/ToolHandlerTest.java +++ b/src/test/java/io/naftiko/engine/exposes/mcp/ToolHandlerTest.java @@ -21,10 +21,13 @@ import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import io.modelcontextprotocol.spec.McpSchema; import io.naftiko.Capability; import io.naftiko.spec.NaftikoSpec; +import io.naftiko.spec.OutputParameterSpec; import io.naftiko.spec.exposes.McpServerSpec; import io.naftiko.spec.exposes.McpServerToolSpec; +import io.naftiko.spec.exposes.ServerCallSpec; public class ToolHandlerTest { @@ -42,29 +45,30 @@ public void handleToolCallShouldThrowForUnknownTool() { } @Test - public void handleToolCallShouldHandleNullArguments() { + public void handleToolCallShouldHandleNullArguments() throws Exception { McpServerToolSpec tool = new McpServerToolSpec(); tool.setName("test-tool"); + tool.setCall(new ServerCallSpec("ns.op")); tool.setWith(Map.of("default_param", "default_value")); - // This will fail at execution because we have no real capability/steps setup, - // but it tests that null arguments are handled gracefully before that point - + ToolHandler handler = new ToolHandler(null, List.of(tool)); - assertThrows(Exception.class, () -> handler.handleToolCall("test-tool", null)); + McpSchema.CallToolResult result = handler.handleToolCall("test-tool", null); + assertTrue(result.isError(), "Should return an error result when capability is null"); } @Test - public void handleToolCallShouldMergeToolWithParameters() { + public void handleToolCallShouldMergeToolWithParameters() throws Exception { McpServerToolSpec tool = new McpServerToolSpec(); tool.setName("test-tool"); + tool.setCall(new ServerCallSpec("ns.op")); tool.setWith(Map.of("fromTool", "fromToolValue")); ToolHandler handler = new ToolHandler(null, List.of(tool)); - // Execution will fail beyond argument merging, but the tool is properly set up - assertThrows(Exception.class, () -> handler.handleToolCall("test-tool", - Map.of("fromArgs", "fromArgsValue"))); + McpSchema.CallToolResult result = handler.handleToolCall("test-tool", + Map.of("fromArgs", "fromArgsValue")); + assertTrue(result.isError(), "Should return an error result when capability is null"); } /** @@ -98,4 +102,67 @@ public void handleToolCallShouldResolveMustacheTemplatesInWithValues() throws Ex // (connection failure at HTTP level is acceptable — the template must be resolved first) assertDoesNotThrow(() -> handler.handleToolCall("get-ship", Map.of("imo", "IMO-9321483"))); } + + @Test + public void handleToolCallShouldReturnMockResponseWhenNoCallOrSteps() throws Exception { + McpServerToolSpec tool = new McpServerToolSpec(); + tool.setName("mock-tool"); + tool.setDescription("A mock tool"); + + OutputParameterSpec param = new OutputParameterSpec(); + param.setName("message"); + param.setType("string"); + param.setConstant("Hello, World!"); + tool.getOutputParameters().add(param); + + ToolHandler handler = new ToolHandler(null, List.of(tool)); + McpSchema.CallToolResult result = handler.handleToolCall("mock-tool", Map.of()); + + assertFalse(result.isError(), "Mock response should not be an error"); + assertNotNull(result.content()); + assertFalse(result.content().isEmpty()); + + String text = ((McpSchema.TextContent) result.content().get(0)).text(); + assertTrue(text.contains("Hello, World!")); + } + + @Test + public void handleToolCallShouldReturnMockObjectWithMultipleFields() throws Exception { + McpServerToolSpec tool = new McpServerToolSpec(); + tool.setName("mock-profile"); + tool.setDescription("Returns a mock profile"); + + OutputParameterSpec name = new OutputParameterSpec(); + name.setName("name"); + name.setType("string"); + name.setConstant("John"); + + OutputParameterSpec role = new OutputParameterSpec(); + role.setName("role"); + role.setType("string"); + role.setConstant("Engineer"); + + tool.getOutputParameters().add(name); + tool.getOutputParameters().add(role); + + ToolHandler handler = new ToolHandler(null, List.of(tool)); + McpSchema.CallToolResult result = handler.handleToolCall("mock-profile", Map.of()); + + assertFalse(result.isError()); + String text = ((McpSchema.TextContent) result.content().get(0)).text(); + assertTrue(text.contains("John")); + assertTrue(text.contains("Engineer")); + } + + @Test + public void buildMockToolResultShouldReturnErrorWhenNoConstValues() { + McpServerToolSpec tool = new McpServerToolSpec(); + tool.setName("empty-mock"); + tool.setDescription("No const values"); + + ToolHandler handler = new ToolHandler(null, List.of(tool)); + McpSchema.CallToolResult result = handler.buildMockToolResult(tool); + + assertTrue(result.isError(), "Should be an error when no const values found"); + } } diff --git a/src/test/java/io/naftiko/engine/exposes/rest/ResourceRestletTest.java b/src/test/java/io/naftiko/engine/exposes/rest/ResourceRestletTest.java index 50b4fd3a..b0f71890 100644 --- a/src/test/java/io/naftiko/engine/exposes/rest/ResourceRestletTest.java +++ b/src/test/java/io/naftiko/engine/exposes/rest/ResourceRestletTest.java @@ -35,6 +35,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; import io.naftiko.Capability; +import io.naftiko.engine.exposes.MockResponseBuilder; import io.naftiko.engine.exposes.OperationStepExecutor; import io.naftiko.spec.NaftikoSpec; import io.naftiko.spec.OutputParameterSpec; @@ -217,12 +218,6 @@ public void canBuildMockResponseShouldCheckNestedConstValues() throws Exception @Test public void buildParameterValueShouldHandleObjectArrayAndPrimitiveFallback() throws Exception { - Capability capability = capabilityFromYaml(minimalCapabilityYaml()); - RestServerSpec serverSpec = (RestServerSpec) capability.getServerAdapters().get(0) - .getSpec(); - ResourceRestlet restlet = new ResourceRestlet(capability, serverSpec, - serverSpec.getResources().get(0)); - ObjectMapper mapper = new ObjectMapper(); OutputParameterSpec objectParam = new OutputParameterSpec(); @@ -233,7 +228,7 @@ public void buildParameterValueShouldHandleObjectArrayAndPrimitiveFallback() thr field.setConstant("ok"); objectParam.getProperties().add(field); - JsonNode objectNode = restlet.buildParameterValue(objectParam, mapper); + JsonNode objectNode = MockResponseBuilder.buildParameterValue(objectParam, mapper); assertEquals("ok", objectNode.path("status").asText()); OutputParameterSpec arrayParam = new OutputParameterSpec(); @@ -243,13 +238,13 @@ public void buildParameterValueShouldHandleObjectArrayAndPrimitiveFallback() thr item.setConstant("v"); arrayParam.setItems(item); - JsonNode arrayNode = restlet.buildParameterValue(arrayParam, mapper); + JsonNode arrayNode = MockResponseBuilder.buildParameterValue(arrayParam, mapper); assertTrue(arrayNode.isArray()); assertEquals("v", arrayNode.get(0).asText()); OutputParameterSpec primitiveNoConst = new OutputParameterSpec(); primitiveNoConst.setType("string"); - JsonNode primitive = restlet.buildParameterValue(primitiveNoConst, mapper); + JsonNode primitive = MockResponseBuilder.buildParameterValue(primitiveNoConst, mapper); assertTrue(primitive.isNull()); } diff --git a/src/test/resources/mock-mcp-capability.yaml b/src/test/resources/mock-mcp-capability.yaml new file mode 100644 index 00000000..25c6517f --- /dev/null +++ b/src/test/resources/mock-mcp-capability.yaml @@ -0,0 +1,48 @@ +# yaml-language-server: $schema=../../main/resources/schemas/naftiko-schema.json +--- +naftiko: "1.0.0-alpha1" +info: + label: "Mock MCP Capability" + description: "Test fixture for MCP mock mode — tools with const values, no consumes block" + tags: + - test + - mock + created: "2026-04-03" + modified: "2026-04-03" + stakeholders: + - role: "editor" + fullName: "Test Author" + email: "test@example.com" + +capability: + exposes: + - type: mcp + address: "localhost" + port: 9098 + namespace: "mock-mcp" + description: "Mock MCP server for testing" + tools: + - name: say-hello + description: "Returns a static greeting" + inputParameters: + - name: name + type: string + required: true + description: "Name to greet" + outputParameters: + - name: message + type: string + const: "Hello, World!" + + - name: get-profile + description: "Returns a static user profile" + outputParameters: + - name: company + type: string + const: "Naftiko" + - name: role + type: string + const: "Engineer" + - name: location + type: string + const: "Earth"