From 87be5549224ce75709cb5b198d45e8f920823869 Mon Sep 17 00:00:00 2001 From: Jerome Louvel <374450+jlouvel@users.noreply.github.com> Date: Fri, 3 Apr 2026 15:33:14 -0400 Subject: [PATCH 1/3] feat: support MCP tool hints mapped to ToolAnnotations --- .agents/skills/naftiko-capability/SKILL.md | 4 +- .../references/design-guidelines.md | 5 + .../references/wrap-api-as-mcp.md | 11 +- .../engine/exposes/mcp/McpServerAdapter.java | 26 ++- .../exposes/mcp/ProtocolDispatcher.java | 23 +++ .../spec/exposes/McpServerToolSpec.java | 11 ++ .../spec/exposes/McpToolHintsSpec.java | 71 ++++++++ .../schemas/examples/skill-adapter.yml | 3 + .../resources/schemas/naftiko-schema.json | 26 +++ src/main/resources/wiki/FAQ.md | 7 +- .../Specification-\342\200\220-Schema.md" | 32 ++++ .../wiki/Tutorial-\342\200\220-Part-1.md" | 16 ++ .../mcp/McpToolHintsIntegrationTest.java | 170 ++++++++++++++++++ src/test/resources/mcp-hints-capability.yaml | 64 +++++++ 14 files changed, 461 insertions(+), 8 deletions(-) create mode 100644 src/main/java/io/naftiko/spec/exposes/McpToolHintsSpec.java create mode 100644 src/test/java/io/naftiko/engine/exposes/mcp/McpToolHintsIntegrationTest.java create mode 100644 src/test/resources/mcp-hints-capability.yaml diff --git a/.agents/skills/naftiko-capability/SKILL.md b/.agents/skills/naftiko-capability/SKILL.md index 1b1c32b6..a9ef3014 100644 --- a/.agents/skills/naftiko-capability/SKILL.md +++ b/.agents/skills/naftiko-capability/SKILL.md @@ -111,7 +111,9 @@ Specification directly. 9. `ForwardConfig` requires `targetNamespace` (single string, not array) and `trustedHeaders` (at least one entry). 10. MCP tools must have `name` and `description`. MCP tool input parameters - must have `name`, `type`, and `description`. + must have `name`, `type`, and `description`. Tools may declare optional + `hints` (readOnly, destructive, idempotent, openWorld) — these map to + MCP `ToolAnnotations` on the wire. 11. ExposedOperation supports exactly two modes (oneOf): simple (`call` + optional `with`) or orchestrated (`steps` + optional `mappings`). Never mix fields from both modes. diff --git a/.agents/skills/naftiko-capability/references/design-guidelines.md b/.agents/skills/naftiko-capability/references/design-guidelines.md index c5877f1b..7167d0ae 100644 --- a/.agents/skills/naftiko-capability/references/design-guidelines.md +++ b/.agents/skills/naftiko-capability/references/design-guidelines.md @@ -98,6 +98,11 @@ Avoid: - Use tools for actions and resources for read-only data access. - Prefer small tools with crisp, typed `inputParameters`. - If an MCP tool becomes complex, switch to orchestration and document it clearly. +- Use `hints` to signal tool behavior to clients: + - Set `readOnly: true` for tools that only read data (GET-like). + - 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). ## 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 2cce5e14..63bcd1db 100644 --- a/.agents/skills/naftiko-capability/references/wrap-api-as-mcp.md +++ b/.agents/skills/naftiko-capability/references/wrap-api-as-mcp.md @@ -96,15 +96,20 @@ For each MCP tool: 1. `name` (kebab-case / IdentifierKebab) is required and must be stable (used as the MCP tool name). 2. `description` is required (agent discovery depends on it). -3. If tool is simple: +3. `hints` is optional — declares behavioral hints mapped to MCP `ToolAnnotations`: + - `readOnly` (bool) — tool does not modify its environment (default: false) + - `destructive` (bool) — tool may perform destructive updates (default: true, meaningful only when readOnly is false) + - `idempotent` (bool) — repeating the call has no additional effect (default: false, meaningful only when readOnly is false) + - `openWorld` (bool) — tool interacts with external entities (default: true) +4. If tool is simple: - must define `call: {namespace}.{operationName}` - may define `with` - should define `outputParameters` (typed) when you want structured results. -4. If tool is orchestrated: +5. If tool is orchestrated: - must define `steps` (min 1), each step has `name` - may define `mappings` - `outputParameters` must use orchestrated output parameter objects (named + typed) -5. Tool `inputParameters`: +6. 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/mcp/McpServerAdapter.java b/src/main/java/io/naftiko/engine/exposes/mcp/McpServerAdapter.java index 0061d5d3..f19ce23d 100644 --- a/src/main/java/io/naftiko/engine/exposes/mcp/McpServerAdapter.java +++ b/src/main/java/io/naftiko/engine/exposes/mcp/McpServerAdapter.java @@ -28,6 +28,7 @@ import io.naftiko.spec.InputParameterSpec; import io.naftiko.spec.exposes.McpServerSpec; import io.naftiko.spec.exposes.McpServerToolSpec; +import io.naftiko.spec.exposes.McpToolHintsSpec; /** * MCP Server Adapter implementation. @@ -149,8 +150,31 @@ private McpSchema.Tool buildMcpTool(McpServerToolSpec toolSpec) { schemaProperties.isEmpty() ? null : schemaProperties, required.isEmpty() ? null : required, null, null, null); + // Build ToolAnnotations from spec hints and label + McpSchema.ToolAnnotations annotations = buildToolAnnotations(toolSpec); + return McpSchema.Tool.builder().name(toolSpec.getName()) - .description(toolSpec.getDescription()).inputSchema(inputSchema).build(); + .description(toolSpec.getDescription()).inputSchema(inputSchema) + .annotations(annotations).build(); + } + + /** + * Build MCP ToolAnnotations from the tool spec's hints and label. Returns null if neither hints + * nor label are present. + */ + McpSchema.ToolAnnotations buildToolAnnotations(McpServerToolSpec toolSpec) { + McpToolHintsSpec hints = toolSpec.getHints(); + String label = toolSpec.getLabel(); + + if (hints == null && label == null) { + return null; + } + + return new McpSchema.ToolAnnotations(label, + hints != null ? hints.getReadOnly() : null, + hints != null ? hints.getDestructive() : null, + hints != null ? hints.getIdempotent() : null, + hints != null ? hints.getOpenWorld() : null, null); } public McpServerSpec getMcpServerSpec() { diff --git a/src/main/java/io/naftiko/engine/exposes/mcp/ProtocolDispatcher.java b/src/main/java/io/naftiko/engine/exposes/mcp/ProtocolDispatcher.java index 8d3599c6..0b11e0b7 100644 --- a/src/main/java/io/naftiko/engine/exposes/mcp/ProtocolDispatcher.java +++ b/src/main/java/io/naftiko/engine/exposes/mcp/ProtocolDispatcher.java @@ -165,6 +165,29 @@ private ObjectNode handleToolsList(JsonNode id) { toolNode.set("inputSchema", mapper.valueToTree(tool.inputSchema())); } + if (tool.annotations() != null) { + ObjectNode annotationsNode = mapper.createObjectNode(); + McpSchema.ToolAnnotations ann = tool.annotations(); + if (ann.title() != null) { + annotationsNode.put("title", ann.title()); + } + if (ann.readOnlyHint() != null) { + annotationsNode.put("readOnlyHint", ann.readOnlyHint()); + } + if (ann.destructiveHint() != null) { + annotationsNode.put("destructiveHint", ann.destructiveHint()); + } + if (ann.idempotentHint() != null) { + annotationsNode.put("idempotentHint", ann.idempotentHint()); + } + if (ann.openWorldHint() != null) { + annotationsNode.put("openWorldHint", ann.openWorldHint()); + } + if (!annotationsNode.isEmpty()) { + toolNode.set("annotations", annotationsNode); + } + } + toolsArray.add(toolNode); } diff --git a/src/main/java/io/naftiko/spec/exposes/McpServerToolSpec.java b/src/main/java/io/naftiko/spec/exposes/McpServerToolSpec.java index f5ab85ca..27664357 100644 --- a/src/main/java/io/naftiko/spec/exposes/McpServerToolSpec.java +++ b/src/main/java/io/naftiko/spec/exposes/McpServerToolSpec.java @@ -48,6 +48,9 @@ public class McpServerToolSpec { @JsonInclude(JsonInclude.Include.NON_EMPTY) private final List steps; + @JsonInclude(JsonInclude.Include.NON_NULL) + private volatile McpToolHintsSpec hints; + @JsonInclude(JsonInclude.Include.NON_EMPTY) private final List mappings; @@ -124,4 +127,12 @@ public List getOutputParameters() { return outputParameters; } + public McpToolHintsSpec getHints() { + return hints; + } + + public void setHints(McpToolHintsSpec hints) { + this.hints = hints; + } + } diff --git a/src/main/java/io/naftiko/spec/exposes/McpToolHintsSpec.java b/src/main/java/io/naftiko/spec/exposes/McpToolHintsSpec.java new file mode 100644 index 00000000..38e7721d --- /dev/null +++ b/src/main/java/io/naftiko/spec/exposes/McpToolHintsSpec.java @@ -0,0 +1,71 @@ +/** + * 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.spec.exposes; + +/** + * MCP Tool Hints Specification Element. + * + * Optional hints describing tool behavior to MCP clients. All properties are advisory. Mapped to + * ToolAnnotations in the MCP protocol. + */ +public class McpToolHintsSpec { + + private Boolean readOnly; + private Boolean destructive; + private Boolean idempotent; + private Boolean openWorld; + + public McpToolHintsSpec() {} + + public McpToolHintsSpec(Boolean readOnly, Boolean destructive, Boolean idempotent, + Boolean openWorld) { + this.readOnly = readOnly; + this.destructive = destructive; + this.idempotent = idempotent; + this.openWorld = openWorld; + } + + public Boolean getReadOnly() { + return readOnly; + } + + public void setReadOnly(Boolean readOnly) { + this.readOnly = readOnly; + } + + public Boolean getDestructive() { + return destructive; + } + + public void setDestructive(Boolean destructive) { + this.destructive = destructive; + } + + public Boolean getIdempotent() { + return idempotent; + } + + public void setIdempotent(Boolean idempotent) { + this.idempotent = idempotent; + } + + public Boolean getOpenWorld() { + return openWorld; + } + + public void setOpenWorld(Boolean openWorld) { + this.openWorld = openWorld; + } + +} diff --git a/src/main/resources/schemas/examples/skill-adapter.yml b/src/main/resources/schemas/examples/skill-adapter.yml index ec832b4d..6f756556 100644 --- a/src/main/resources/schemas/examples/skill-adapter.yml +++ b/src/main/resources/schemas/examples/skill-adapter.yml @@ -47,6 +47,9 @@ capability: tools: - name: get-current-weather description: "Retrieve current weather conditions for a location" + hints: + readOnly: true + openWorld: true inputParameters: - name: location type: string diff --git a/src/main/resources/schemas/naftiko-schema.json b/src/main/resources/schemas/naftiko-schema.json index 6d9542ce..ab859a10 100644 --- a/src/main/resources/schemas/naftiko-schema.json +++ b/src/main/resources/schemas/naftiko-schema.json @@ -895,6 +895,9 @@ "$ref": "#/$defs/StepOutputMapping" } }, + "hints": { + "$ref": "#/$defs/McpToolHints" + }, "outputParameters": { "type": "array" } @@ -936,6 +939,29 @@ ], "additionalProperties": false }, + "McpToolHints": { + "type": "object", + "description": "Optional hints describing tool behavior to MCP clients. All properties are advisory. Mapped to ToolAnnotations in the MCP protocol.", + "properties": { + "readOnly": { + "type": "boolean", + "description": "If true, the tool does not modify its environment. Default: false." + }, + "destructive": { + "type": "boolean", + "description": "If true, the tool may perform destructive updates. Meaningful only when readOnly is false. Default: true." + }, + "idempotent": { + "type": "boolean", + "description": "If true, calling the tool repeatedly with the same arguments has no additional effect. Meaningful only when readOnly is false. Default: false." + }, + "openWorld": { + "type": "boolean", + "description": "If true, the tool may interact with external entities. Default: true." + } + }, + "additionalProperties": false + }, "McpToolInputParameter": { "type": "object", "description": "Declares an input parameter for an MCP tool. These become properties in the tool's JSON Schema input definition.", diff --git a/src/main/resources/wiki/FAQ.md b/src/main/resources/wiki/FAQ.md index 63ca4ab5..d726e8f5 100644 --- a/src/main/resources/wiki/FAQ.md +++ b/src/main/resources/wiki/FAQ.md @@ -808,9 +808,10 @@ Check the naftiko field in your YAML to specify the version. 1. **Use `type: mcp`** in `exposes` 2. **Define `tools`** - each tool is an MCP tool your capability provides -3. **Use stdio transport** - for native Claude Desktop integration -4. **Test with Claude** - configure Claude Desktop with your MCP server -5. **Publish** - share your capability spec with the community +3. **Add `hints`** (optional) - declare behavioral hints like `readOnly`, `destructive`, `idempotent`, `openWorld` to help clients understand tool safety +4. **Use stdio transport** - for native Claude Desktop integration +5. **Test with Claude** - configure Claude Desktop with your MCP server +6. **Publish** - share your capability spec with the community See [Tutorial - Part 1](https://github.com/naftiko/framework/wiki/Tutorial-MCP-Part-1) for a full MCP example, then continue with [Tutorial - Part 2](https://github.com/naftiko/framework/wiki/Tutorial-MCP-Part-2) for Skill and REST exposure. 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 67b01774..e5a37aba 100644 --- "a/src/main/resources/wiki/Specification-\342\200\220-Schema.md" +++ "b/src/main/resources/wiki/Specification-\342\200\220-Schema.md" @@ -343,6 +343,7 @@ An MCP tool definition. Each tool maps to one or more consumed HTTP operations, | **name** | `string` | **REQUIRED**. Technical name for the tool. Used as the MCP tool name. MUST match pattern `^[a-zA-Z0-9-]+$`. | | **label** | `string` | Human-readable display name for the tool. Mapped to MCP `title` in protocol responses. | | **description** | `string` | **REQUIRED**. A meaningful description of the tool. Essential for agent discovery. | +| **hints** | `McpToolHints` | Optional behavioral hints for MCP clients. Mapped to `ToolAnnotations` in the MCP protocol. See [3.5.5.1 McpToolHints Object](#3551-mctoolhints-object). | | **inputParameters** | `McpToolInputParameter[]` | Tool input parameters. These become the MCP tool's input schema (JSON Schema). | | **call** | `string` | **Simple mode only**. Reference to a consumed operation. Format: `{namespace}.{operationId}`. MUST match pattern `^[a-zA-Z0-9-]+\.[a-zA-Z0-9-]+$`. | | **with** | `WithInjector` | **Simple mode only**. Parameter injection for the called operation. | @@ -376,6 +377,37 @@ An MCP tool definition. Each tool maps to one or more consumed HTTP operations, - Input parameters are accessed via namespace-qualified references of the form `{mcpNamespace}.{paramName}`. - No additional properties are allowed. +#### 3.5.5.1 McpToolHints Object + +Optional behavioral hints describing a tool to MCP clients. All properties are advisory — clients SHOULD NOT make trust decisions based on these values from untrusted servers. Mapped to `ToolAnnotations` in the MCP protocol wire format. + +**Fixed Fields:** + +| Field Name | Type | Description | +| --- | --- | --- | +| **readOnly** | `boolean` | If `true`, the tool does not modify its environment. Default: `false`. | +| **destructive** | `boolean` | If `true`, the tool may perform destructive updates. Meaningful only when `readOnly` is `false`. Default: `true`. | +| **idempotent** | `boolean` | If `true`, calling the tool repeatedly with the same arguments has no additional effect. Meaningful only when `readOnly` is `false`. Default: `false`. | +| **openWorld** | `boolean` | If `true`, the tool may interact with external entities (e.g. web APIs). If `false`, the tool's domain is closed (e.g. local data). Default: `true`. | + +**Rules:** + +- All fields are optional. Omitted fields fall back to their defaults. +- `destructive` and `idempotent` are only meaningful when `readOnly` is `false`. +- No additional properties are allowed. + +**McpToolHints Example:** + +```yaml +tools: + - name: get-current-weather + description: "Retrieve current weather conditions" + hints: + readOnly: true + openWorld: true + call: weather-api.get-current +``` + #### 3.5.6 McpToolInputParameter Object Declares an input parameter for an MCP tool. These become properties in the tool's JSON Schema input definition. diff --git "a/src/main/resources/wiki/Tutorial-\342\200\220-Part-1.md" "b/src/main/resources/wiki/Tutorial-\342\200\220-Part-1.md" index e04eefec..9f4f829f 100644 --- "a/src/main/resources/wiki/Tutorial-\342\200\220-Part-1.md" +++ "b/src/main/resources/wiki/Tutorial-\342\200\220-Part-1.md" @@ -317,6 +317,22 @@ The response gets shaped too — flat fields like `departurePort`/`arrivalPort` The agent went from observer to operator. +> **💡 Tip: Tool hints.** Now that you have both read-only tools (`list-ships`, `get-ship`) and write tools (`create-voyage`), you can declare behavioral hints that help clients distinguish them: +> ```yaml +> - name: list-ships +> hints: +> readOnly: true +> # ... +> - name: create-voyage +> hints: +> readOnly: false +> destructive: false +> idempotent: false +> openWorld: true +> # ... +> ``` +> Hints map to MCP `ToolAnnotations` and are advisory — clients use them to decide which tools need confirmation, can be retried safely, etc. See [McpToolHints](https://github.com/naftiko/framework/wiki/Specification-Schema#3551-mctoolhints-object) in the spec. + **What you learned:** `POST` operations, `body` template, array-type inputs, write tools. --- diff --git a/src/test/java/io/naftiko/engine/exposes/mcp/McpToolHintsIntegrationTest.java b/src/test/java/io/naftiko/engine/exposes/mcp/McpToolHintsIntegrationTest.java new file mode 100644 index 00000000..ab60ab02 --- /dev/null +++ b/src/test/java/io/naftiko/engine/exposes/mcp/McpToolHintsIntegrationTest.java @@ -0,0 +1,170 @@ +/** + * 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 org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonNode; +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; +import io.naftiko.spec.exposes.McpServerToolSpec; +import io.naftiko.spec.exposes.McpToolHintsSpec; +import java.io.File; + +/** + * Integration tests for MCP tool hints (ToolAnnotations) support. Validates YAML deserialization, + * spec-to-SDK mapping, and wire format generation. + */ +public class McpToolHintsIntegrationTest { + + private static final ObjectMapper JSON = new ObjectMapper(); + + private Capability capability; + private McpServerAdapter adapter; + + @BeforeEach + public void setUp() throws Exception { + String resourcePath = "src/test/resources/mcp-hints-capability.yaml"; + File file = new File(resourcePath); + assertTrue(file.exists(), "MCP hints capability test file should exist"); + + 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); + adapter = (McpServerAdapter) capability.getServerAdapters().get(0); + } + + @Test + void hintsSpecShouldDeserializeFromYaml() { + McpServerSpec serverSpec = adapter.getMcpServerSpec(); + McpServerToolSpec readTool = serverSpec.getTools().get(0); + + McpToolHintsSpec hints = readTool.getHints(); + assertNotNull(hints, "Hints should be deserialized for read-data tool"); + assertEquals(true, hints.getReadOnly()); + assertEquals(true, hints.getOpenWorld()); + assertNull(hints.getDestructive(), "Unset hints should be null"); + assertNull(hints.getIdempotent(), "Unset hints should be null"); + } + + @Test + void allHintsShouldDeserializeWhenFullySpecified() { + McpServerSpec serverSpec = adapter.getMcpServerSpec(); + McpServerToolSpec deleteTool = serverSpec.getTools().get(1); + + McpToolHintsSpec hints = deleteTool.getHints(); + assertNotNull(hints); + assertEquals(false, hints.getReadOnly()); + assertEquals(true, hints.getDestructive()); + assertEquals(true, hints.getIdempotent()); + assertEquals(false, hints.getOpenWorld()); + } + + @Test + void toolWithoutHintsShouldHaveNullHintsSpec() { + McpServerSpec serverSpec = adapter.getMcpServerSpec(); + McpServerToolSpec noHintsTool = serverSpec.getTools().get(2); + + assertNull(noHintsTool.getHints(), "Tool without hints should have null hints"); + } + + @Test + void buildToolAnnotationsShouldMapHintsAndLabel() { + McpServerToolSpec toolSpec = new McpServerToolSpec("test", "Test Title", "desc"); + toolSpec.setHints(new McpToolHintsSpec(true, false, true, false)); + + McpSchema.ToolAnnotations annotations = adapter.buildToolAnnotations(toolSpec); + + assertNotNull(annotations); + assertEquals("Test Title", annotations.title()); + assertEquals(true, annotations.readOnlyHint()); + assertEquals(false, annotations.destructiveHint()); + assertEquals(true, annotations.idempotentHint()); + assertEquals(false, annotations.openWorldHint()); + } + + @Test + void buildToolAnnotationsShouldReturnNullWhenNoHintsAndNoLabel() { + McpServerToolSpec toolSpec = new McpServerToolSpec("test", null, "desc"); + + McpSchema.ToolAnnotations annotations = adapter.buildToolAnnotations(toolSpec); + + assertNull(annotations, "Should return null when no hints and no label"); + } + + @Test + void buildToolAnnotationsShouldMapLabelOnlyWhenNoHints() { + McpServerToolSpec toolSpec = new McpServerToolSpec("test", "Label Only", "desc"); + + McpSchema.ToolAnnotations annotations = adapter.buildToolAnnotations(toolSpec); + + assertNotNull(annotations); + assertEquals("Label Only", annotations.title()); + assertNull(annotations.readOnlyHint()); + assertNull(annotations.destructiveHint()); + assertNull(annotations.idempotentHint()); + assertNull(annotations.openWorldHint()); + } + + @Test + void toolsListShouldIncludeAnnotationsInWireFormat() throws Exception { + ProtocolDispatcher dispatcher = new ProtocolDispatcher(adapter); + + JsonNode response = dispatcher.dispatch(JSON.readTree( + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\"}")); + + JsonNode tools = response.path("result").path("tools"); + assertEquals(3, tools.size(), "Should have three tools"); + + // read-data tool: has label + readOnly + openWorld hints + JsonNode readTool = tools.get(0); + assertEquals("read-data", readTool.path("name").asText()); + assertEquals("Read Data", readTool.path("title").asText()); + + JsonNode readAnnotations = readTool.path("annotations"); + assertFalse(readAnnotations.isMissingNode(), "read-data should have annotations"); + assertEquals("Read Data", readAnnotations.path("title").asText()); + assertEquals(true, readAnnotations.path("readOnlyHint").asBoolean()); + assertEquals(true, readAnnotations.path("openWorldHint").asBoolean()); + assertTrue(readAnnotations.path("destructiveHint").isMissingNode(), + "Unset hints should be absent from wire format"); + + // delete-record tool: no label, full hints + JsonNode deleteTool = tools.get(1); + assertEquals("delete-record", deleteTool.path("name").asText()); + + JsonNode deleteAnnotations = deleteTool.path("annotations"); + assertFalse(deleteAnnotations.isMissingNode()); + assertTrue(deleteAnnotations.path("title").isMissingNode(), + "Tool without label should not have annotations.title"); + assertEquals(false, deleteAnnotations.path("readOnlyHint").asBoolean()); + assertEquals(true, deleteAnnotations.path("destructiveHint").asBoolean()); + assertEquals(true, deleteAnnotations.path("idempotentHint").asBoolean()); + assertEquals(false, deleteAnnotations.path("openWorldHint").asBoolean()); + + // no-hints-tool: no annotations at all + JsonNode noHintsTool = tools.get(2); + assertEquals("no-hints-tool", noHintsTool.path("name").asText()); + assertTrue(noHintsTool.path("annotations").isMissingNode(), + "Tool without hints or label should have no annotations"); + } +} diff --git a/src/test/resources/mcp-hints-capability.yaml b/src/test/resources/mcp-hints-capability.yaml new file mode 100644 index 00000000..dc0d4522 --- /dev/null +++ b/src/test/resources/mcp-hints-capability.yaml @@ -0,0 +1,64 @@ +# yaml-language-server: $schema=../../main/resources/schemas/naftiko-schema.json +--- +naftiko: "1.0.0-alpha1" +info: + label: "MCP Hints Test Capability" + description: "Test capability for MCP tool hints (ToolAnnotations) support" + tags: + - Test + - MCP + created: "2026-04-03" + modified: "2026-04-03" + +capability: + exposes: + - type: "mcp" + address: "localhost" + port: 9098 + namespace: "hints-mcp" + description: "Test MCP server for validating tool hints mapped to ToolAnnotations." + + tools: + - name: "read-data" + label: "Read Data" + description: "Read-only data retrieval tool." + hints: + readOnly: true + openWorld: true + call: "mock-api.get-data" + outputParameters: + - type: "string" + mapping: "$.result" + + - name: "delete-record" + description: "Destructive record deletion tool." + hints: + readOnly: false + destructive: true + idempotent: true + openWorld: false + inputParameters: + - name: "record-id" + type: "string" + description: "ID of the record to delete" + required: true + call: "mock-api.delete-record" + with: + id: "$this.hints-mcp.record-id" + + - name: "no-hints-tool" + description: "Tool without any hints for backward compatibility testing." + call: "mock-api.get-data" + + consumes: + - type: "http" + namespace: "mock-api" + baseUri: "http://localhost:8080/v1" + resources: + - path: "data" + name: "data" + operations: + - method: "GET" + name: "get-data" + - method: "DELETE" + name: "delete-record" From 83e4be63cba0ff6a2b2f673e62c0fc887c6aafa8 Mon Sep 17 00:00:00 2001 From: Jerome Louvel <374450+jlouvel@users.noreply.github.com> Date: Fri, 3 Apr 2026 21:01:59 -0400 Subject: [PATCH 2/3] feat: add aggregates with DDD-inspired domain functions and ref resolution Introduce aggregates[] on Capability for defining reusable domain functions - Schema: Aggregate, AggregateFunction, Semantics definitions; ref on McpTool and ExposedOperation - Engine: AggregateRefResolver merges ref fields and derives MCP hints from semantics - Spectral: aggregate-semantics-consistency rule detects contradictions between semantics, MCP hints, and REST methods - Tests: 25 unit + 11 integration + 3 Spectral tests - Docs: Specification-Schema, FAQ, design-guidelines, AGENTS.md, SKILL.md, wrap-api-as-mcp Closes #191 --- .agents/skills/naftiko-capability/SKILL.md | 28 +- .../references/design-guidelines.md | 27 ++ .../references/wrap-api-as-mcp.md | 16 +- AGENTS.md | 5 + src/main/java/io/naftiko/Capability.java | 5 + .../naftiko/engine/AggregateRefResolver.java | 286 +++++++++++ .../naftiko/spec/AggregateFunctionSpec.java | 119 +++++ .../java/io/naftiko/spec/AggregateSpec.java | 54 +++ .../java/io/naftiko/spec/CapabilitySpec.java | 8 + .../java/io/naftiko/spec/SemanticsSpec.java | 58 +++ .../spec/exposes/McpServerToolSpec.java | 11 + .../spec/exposes/RestServerOperationSpec.java | 11 + .../aggregate-semantics-consistency.js | 183 +++++++ src/main/resources/rules/naftiko-rules.yml | 26 + .../schemas/examples/forecast-aggregate.yml | 87 ++++ .../resources/schemas/naftiko-schema.json | 168 ++++++- src/main/resources/wiki/FAQ.md | 70 +++ .../Specification-\342\200\220-Schema.md" | 198 +++++++- .../engine/AggregateRefResolverTest.java | 446 ++++++++++++++++++ .../exposes/mcp/AggregateIntegrationTest.java | 211 +++++++++ .../spec/NaftikoSpectralRulesetTest.java | 64 +++ src/test/resources/aggregate-basic.yaml | 79 ++++ .../resources/aggregate-hints-override.yaml | 86 ++++ src/test/resources/aggregate-invalid-ref.yaml | 43 ++ .../spectral-semantics-consistent.yaml | 60 +++ .../spectral-semantics-inconsistent.yaml | 60 +++ 26 files changed, 2388 insertions(+), 21 deletions(-) create mode 100644 src/main/java/io/naftiko/engine/AggregateRefResolver.java create mode 100644 src/main/java/io/naftiko/spec/AggregateFunctionSpec.java create mode 100644 src/main/java/io/naftiko/spec/AggregateSpec.java create mode 100644 src/main/java/io/naftiko/spec/SemanticsSpec.java create mode 100644 src/main/resources/rules/functions/aggregate-semantics-consistency.js create mode 100644 src/main/resources/schemas/examples/forecast-aggregate.yml create mode 100644 src/test/java/io/naftiko/engine/AggregateRefResolverTest.java create mode 100644 src/test/java/io/naftiko/engine/exposes/mcp/AggregateIntegrationTest.java create mode 100644 src/test/resources/aggregate-basic.yaml create mode 100644 src/test/resources/aggregate-hints-override.yaml create mode 100644 src/test/resources/aggregate-invalid-ref.yaml create mode 100644 src/test/resources/spectral-semantics-consistent.yaml create mode 100644 src/test/resources/spectral-semantics-inconsistent.yaml diff --git a/.agents/skills/naftiko-capability/SKILL.md b/.agents/skills/naftiko-capability/SKILL.md index a9ef3014..bf29cdd2 100644 --- a/.agents/skills/naftiko-capability/SKILL.md +++ b/.agents/skills/naftiko-capability/SKILL.md @@ -25,9 +25,10 @@ is a single YAML file validated against the Naftiko JSON Schema (v1.0.0-alpha1). Key spec objects you will work with: - **Info** — metadata: label, description, tags, stakeholders -- **Capability** — root technical config; contains `exposes` and `consumes` +- **Capability** — root technical config; contains `exposes`, `consumes`, and `aggregates` - **Consumes** — HTTP client adapter: baseUri, namespace, resources, operations - **Exposes** — server adapter: REST (`type: rest`), MCP (`type: mcp`), or Skill (`type: skill`) +- **Aggregates** — DDD-inspired domain building blocks; each aggregate groups reusable functions under a namespace. Tools and operations reference functions via `ref` - **Binds** — variable injection from file (dev) or runtime (prod) - **Namespace** — unique identifier linking exposes to consumes via routing @@ -50,6 +51,7 @@ for *how*. | "I want to proxy an API today and encapsulate it incrementally" | Read `references/proxy-then-customize.md` | | "I want to chain multiple HTTP calls to consumed APIs and expose the result into a single REST operation" | Read `references/chain-api-calls.md` | | "I need to go from local test credentials to production secrets" | Read `references/dev-to-production.md` | +| "I want to define a domain function once and expose it via both REST and MCP" | Use `aggregates` with `ref` — read `references/design-guidelines.md` (Aggregate Design Guidelines) | | "I want to build a full-featured capability that does all of the above" | Read all stories in order, then use `assets/capability-example.yml` as structural reference | | "I have a YAML validation error" | Run `scripts/lint-capability.sh` — see **Lint workflow** below | | "I'm done writing — what should I check before shipping?" | Read `references/design-guidelines.md`, then run lint | @@ -110,13 +112,15 @@ Specification directly. 8. `variable` expressions resolve from `binds` keys. 9. `ForwardConfig` requires `targetNamespace` (single string, not array) and `trustedHeaders` (at least one entry). -10. MCP tools must have `name` and `description`. MCP tool input parameters - must have `name`, `type`, and `description`. Tools may declare optional +10. MCP tools must have `name` and `description` (unless using `ref`, in which + case they are inherited from the referenced aggregate function). MCP tool input + parameters must have `name`, `type`, and `description`. Tools may declare optional `hints` (readOnly, destructive, idempotent, openWorld) — these map to MCP `ToolAnnotations` on the wire. -11. ExposedOperation supports exactly two modes (oneOf): simple (`call` + - optional `with`) or orchestrated (`steps` + optional `mappings`). Never - mix fields from both modes. +11. ExposedOperation supports three modes (oneOf): simple (`call` + + optional `with`), orchestrated (`steps` + optional `mappings`), or + ref (`ref` to an aggregate function). Never mix fields from + incompatible modes. 12. Do not modify `scripts/lint-capability.sh` unless explicitly asked — it wraps Spectral with the correct ruleset and flags. 13. Do not add properties that are not in the JSON Schema — the schema @@ -129,4 +133,14 @@ Specification directly. outputs are dead declarations that add noise without value. 16. Do not prefix variable names with the capability, namespace, or resource name — variables are already scoped to their context. - Redundant prefixes reduce readability without adding disambiguation. \ No newline at end of file + Redundant prefixes reduce readability without adding disambiguation. +17. When using `ref` on MCP tools or REST operations, the `ref` value must + follow the format `{aggregate-namespace}.{function-name}` and resolve + to an existing function in the capability's `aggregates` array. +18. Do not chain `ref` through multiple levels of aggregates — `ref` + resolves to a function in a single aggregate, not transitively. +19. Aggregate functions can declare `semantics` (safe, idempotent, cacheable). + When exposed via MCP, the engine auto-derives `hints` from semantics. + Explicit `hints` on the MCP tool override derived values. +20. Do not duplicate a full function definition inline on both MCP tools + and REST operations — use `aggregates` + `ref` instead. \ No newline at end of file diff --git a/.agents/skills/naftiko-capability/references/design-guidelines.md b/.agents/skills/naftiko-capability/references/design-guidelines.md index 7167d0ae..7a5eae62 100644 --- a/.agents/skills/naftiko-capability/references/design-guidelines.md +++ b/.agents/skills/naftiko-capability/references/design-guidelines.md @@ -133,6 +133,33 @@ Do not mix fields from both modes in one operation/tool. - Do not expose internal IDs unless they are necessary and meaningful for consumers. - Avoid returning massive nested objects if only a few fields are needed. +## Aggregate design guidelines (DDD-inspired) + +Aggregates borrow from [Domain-Driven Design](https://en.wikipedia.org/wiki/Domain-driven_design#Building_blocks): each aggregate groups related functions under a namespace that represents a single domain concept (the **Aggregate Root**). Functions within the aggregate are the operations that agents and clients can invoke. + +### Define aggregate boundaries around domain concepts + +- One aggregate = one domain concept (e.g., `forecast`, `ticket`, `user-profile`). +- Functions within an aggregate should operate on the same domain data — if a function feels unrelated, it likely belongs in a different aggregate. +- Keep function names intention-revealing and adapter-neutral: `get-forecast`, not `mcp-get-forecast` or `rest-forecast-query`. + +### Use `ref` to share functions across adapters + +- When the same domain operation is exposed via REST *and* MCP, define it once in `aggregates` and reference it with `ref` from both adapters. +- Override only the adapter-specific fields at the tool/operation level (e.g., `method` for REST, `hints` for MCP). +- Do not duplicate the full function definition inline when `ref` can carry it. + +### Use `semantics` as the single source of behavioral truth + +- Declare `safe`, `idempotent`, and `cacheable` on the aggregate function — they describe the domain behavior, not a transport detail. +- Let the engine derive MCP `hints` from semantics automatically. Override hints only when the derived values are insufficient (e.g., setting `openWorld`). +- Do not set `semantics` on functions that are only exposed via REST — REST has its own semantic model via HTTP methods. + +### Keep aggregates lean + +- Start with functions only (the "functions-first" approach). Entities, events, and other DDD stereotypes may be added in future schema versions. +- Avoid creating an aggregate for a single function that is only used in one place — aggregates pay off when sharing across adapters or when grouping related operations. + ## Secret management (dev → prod) - Use `binds` for any sensitive values or environment-dependent configuration. 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..c502d5b3 100644 --- a/.agents/skills/naftiko-capability/references/wrap-api-as-mcp.md +++ b/.agents/skills/naftiko-capability/references/wrap-api-as-mcp.md @@ -76,6 +76,14 @@ When wrapping an API as MCP, design in this order: - simple mode: inline typed output parameters (`MappedOutputParameter`) - orchestrated mode: named typed outputs (`OrchestratedOutputParameter`) plus `mappings` +4) If the same operation is also exposed via REST, consider using **aggregates**: + +- Define the function once in `capability.aggregates[]` (DDD Aggregate pattern) +- Reference it from MCP tools with `ref: {namespace}.{function-name}` +- Declare `semantics` (safe, idempotent, cacheable) on the function — the engine auto-derives MCP `hints` +- Override only MCP-specific fields on the tool (e.g., explicit `hints` for `openWorld`) +- `name` and `description` are inherited from the function unless overridden + ## Constraints (aligned with schema + rules) ### Global constraints @@ -95,12 +103,15 @@ When wrapping an API as MCP, design in this order: For each MCP tool: 1. `name` (kebab-case / IdentifierKebab) is required and must be stable (used as the MCP tool name). + When using `ref`, `name` is optional — inherited from the aggregate function. 2. `description` is required (agent discovery depends on it). + When using `ref`, `description` is optional — inherited from the aggregate function. 3. `hints` is optional — declares behavioral hints mapped to MCP `ToolAnnotations`: - `readOnly` (bool) — tool does not modify its environment (default: false) - `destructive` (bool) — tool may perform destructive updates (default: true, meaningful only when readOnly is false) - `idempotent` (bool) — repeating the call has no additional effect (default: false, meaningful only when readOnly is false) - `openWorld` (bool) — tool interacts with external entities (default: true) + When using `ref` with `semantics` on the function, hints are auto-derived (safe→readOnly/destructive, idempotent→idempotent). Explicit hints override derived values. 4. If tool is simple: - must define `call: {namespace}.{operationName}` - may define `with` @@ -109,7 +120,10 @@ 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 uses `ref`: + - must define `ref: {namespace}.{function-name}` pointing to an aggregate function + - all other fields are optional — inherited from the function, explicit values override +7. Tool `inputParameters`: - each parameter must have `name`, `type`, `description` - set `required: false` explicitly for optional params (default is true) diff --git a/AGENTS.md b/AGENTS.md index 6a29471f..400f5d75 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -88,6 +88,9 @@ When designing or modifying a Capability: - Keep the [Naftiko Specification](src/main/resources/schemas/naftiko-schema.json) and the [Naftiko Rules](src/main/resources/rules/naftiko-rules.yml) as first-class citizens — the schema enforces structure, the rules enforce cross-object consistency, quality, and security - Look at `src/main/resources/schemas/examples/` for patterns before writing new capabilities - When renaming a consumed field for a lookup `match`, also add a `ConsumedOutputParameter` on the consumed operation to map the raw field name to a kebab-case name — otherwise the lookup has nothing to match against +- Use `aggregates` to define reusable domain functions when the same operation is exposed through multiple adapters (REST and MCP) — this follows the DDD Aggregate pattern: one definition, multiple projections +- Declare `semantics` (safe, idempotent, cacheable) on aggregate functions to describe domain behavior — the engine derives MCP `hints` automatically +- Override only adapter-specific fields when using `ref` (e.g., `method` for REST, `hints` for MCP) — let the rest be inherited from the function **Don't:** - Expose an `inputParameter` that is not used in any step @@ -103,6 +106,8 @@ When designing or modifying a Capability: - Use `MappedOutputParameter` (with `mapping`, no `name`) when the tool/operation uses `steps` — use `OrchestratedOutputParameter` (with `name`, no `mapping`) instead - Use typed objects for lookup step `outputParameters` — they are plain string arrays of field names to extract (e.g. `- "fullName"`) - Put a `path` property on an `ExposedOperation` — extract multi-step operations with a different path into their own `ExposedResource` +- Duplicate a full function definition inline on both MCP tools and REST operations — use `aggregates` + `ref` instead +- Chain `ref` through multiple levels of aggregates — `ref` resolves to a function in a single aggregate, not transitively ## Contribution Workflow diff --git a/src/main/java/io/naftiko/Capability.java b/src/main/java/io/naftiko/Capability.java index f93d3704..3b9fd81b 100644 --- a/src/main/java/io/naftiko/Capability.java +++ b/src/main/java/io/naftiko/Capability.java @@ -21,6 +21,7 @@ import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import io.naftiko.engine.AggregateRefResolver; import io.naftiko.engine.BindingResolver; import io.naftiko.engine.ConsumesImportResolver; import io.naftiko.spec.ExecutionContext; @@ -68,6 +69,10 @@ public Capability(NaftikoSpec spec, String capabilityDir) throws Exception { importResolver.resolveImports(spec.getCapability().getConsumes(), capabilityDir); } + // Resolve aggregate function refs before adapter initialization + AggregateRefResolver aggregateRefResolver = new AggregateRefResolver(); + aggregateRefResolver.resolve(spec); + // Resolve bindings early for injection into adapters BindingResolver bindingResolver = new BindingResolver(); ExecutionContext context = new ExecutionContext() { diff --git a/src/main/java/io/naftiko/engine/AggregateRefResolver.java b/src/main/java/io/naftiko/engine/AggregateRefResolver.java new file mode 100644 index 00000000..85b02df8 --- /dev/null +++ b/src/main/java/io/naftiko/engine/AggregateRefResolver.java @@ -0,0 +1,286 @@ +/** + * 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; + +import java.util.HashMap; +import java.util.Map; +import java.util.logging.Level; +import org.restlet.Context; +import io.naftiko.spec.AggregateFunctionSpec; +import io.naftiko.spec.AggregateSpec; +import io.naftiko.spec.CapabilitySpec; +import io.naftiko.spec.InputParameterSpec; +import io.naftiko.spec.NaftikoSpec; +import io.naftiko.spec.OutputParameterSpec; +import io.naftiko.spec.SemanticsSpec; +import io.naftiko.spec.exposes.McpServerSpec; +import io.naftiko.spec.exposes.McpServerToolSpec; +import io.naftiko.spec.exposes.McpToolHintsSpec; +import io.naftiko.spec.exposes.OperationStepSpec; +import io.naftiko.spec.exposes.RestServerOperationSpec; +import io.naftiko.spec.exposes.RestServerResourceSpec; +import io.naftiko.spec.exposes.RestServerSpec; +import io.naftiko.spec.exposes.ServerSpec; + +/** + * Resolves aggregate function references ({@code ref}) in adapter units (MCP tools, REST + * operations). Runs at capability load time, before server startup. + * + *

+ * Resolution merges inherited fields from the referenced function into the adapter unit. Explicit + * adapter-local fields override inherited ones. For MCP tools, semantics are automatically derived + * into hints (with field-level override). + */ +public class AggregateRefResolver { + + /** + * Resolve all {@code ref} fields across adapter units in the given spec. Modifies specs + * in-place. + * + * @param spec The root Naftiko spec to resolve + * @throws IllegalArgumentException if a ref target is unknown or a chained ref is detected + */ + public void resolve(NaftikoSpec spec) { + CapabilitySpec capability = spec.getCapability(); + if (capability == null || capability.getAggregates().isEmpty()) { + return; + } + + // Build lookup map: "namespace.functionName" → AggregateFunctionSpec + Map functionMap = buildFunctionMap(capability); + + // Resolve refs in all adapter units + for (ServerSpec serverSpec : capability.getExposes()) { + if (serverSpec instanceof McpServerSpec mcpSpec) { + for (McpServerToolSpec tool : mcpSpec.getTools()) { + if (tool.getRef() != null) { + resolveMcpToolRef(tool, functionMap); + } + } + } else if (serverSpec instanceof RestServerSpec restSpec) { + for (RestServerResourceSpec resource : restSpec.getResources()) { + for (RestServerOperationSpec op : resource.getOperations()) { + if (op.getRef() != null) { + resolveRestOperationRef(op, functionMap); + } + } + } + } + } + } + + /** + * Build the lookup map of aggregate functions keyed by "namespace.functionName". + */ + Map buildFunctionMap(CapabilitySpec capability) { + Map map = new HashMap<>(); + + for (AggregateSpec aggregate : capability.getAggregates()) { + for (AggregateFunctionSpec function : aggregate.getFunctions()) { + String key = aggregate.getNamespace() + "." + function.getName(); + if (map.containsKey(key)) { + throw new IllegalArgumentException( + "Duplicate aggregate function ref: '" + key + "'"); + } + map.put(key, function); + } + } + + Context.getCurrentLogger().log(Level.INFO, + "Built aggregate function map with {0} entries", map.size()); + return map; + } + + /** + * Resolve a ref on an MCP tool. Merges inherited fields and derives hints from semantics. + */ + void resolveMcpToolRef(McpServerToolSpec tool, + Map functionMap) { + AggregateFunctionSpec function = lookupFunction(tool.getRef(), functionMap); + + // Merge name (function provides default, tool overrides) + if (tool.getName() == null || tool.getName().isEmpty()) { + tool.setName(function.getName()); + } + + // Merge description (function provides default, tool overrides) + if (tool.getDescription() == null || tool.getDescription().isEmpty()) { + tool.setDescription(function.getDescription()); + } + + // Merge call (function provides default, tool overrides) + if (tool.getCall() == null && function.getCall() != null) { + tool.setCall(function.getCall()); + } + + // Merge with (function provides default, tool overrides) + if (tool.getWith() == null && function.getWith() != null) { + tool.setWith(function.getWith()); + } + + // Merge steps (function provides default, tool overrides) + if (tool.getSteps().isEmpty() && !function.getSteps().isEmpty()) { + for (OperationStepSpec step : function.getSteps()) { + tool.getSteps().add(step); + } + } + + // Merge inputParameters (function provides default, tool overrides) + if (tool.getInputParameters().isEmpty() && !function.getInputParameters().isEmpty()) { + for (InputParameterSpec param : function.getInputParameters()) { + tool.getInputParameters().add(param); + } + } + + // Merge outputParameters (function provides default, tool overrides) + if (tool.getOutputParameters().isEmpty() && !function.getOutputParameters().isEmpty()) { + for (OutputParameterSpec param : function.getOutputParameters()) { + tool.getOutputParameters().add(param); + } + } + + // Derive MCP hints from function semantics, with tool-level override + if (function.getSemantics() != null) { + McpToolHintsSpec derived = deriveHints(function.getSemantics()); + tool.setHints(mergeHints(derived, tool.getHints())); + } + } + + /** + * Resolve a ref on a REST operation. Merges inherited fields. + */ + void resolveRestOperationRef(RestServerOperationSpec op, + Map functionMap) { + AggregateFunctionSpec function = lookupFunction(op.getRef(), functionMap); + + // Merge name + if (op.getName() == null || op.getName().isEmpty()) { + op.setName(function.getName()); + } + + // Merge description + if (op.getDescription() == null || op.getDescription().isEmpty()) { + op.setDescription(function.getDescription()); + } + + // Merge call + if (op.getCall() == null && function.getCall() != null) { + op.setCall(function.getCall()); + } + + // Merge with + if (op.getWith() == null && function.getWith() != null) { + op.setWith(function.getWith()); + } + + // Merge steps + if (op.getSteps().isEmpty() && !function.getSteps().isEmpty()) { + for (OperationStepSpec step : function.getSteps()) { + op.getSteps().add(step); + } + } + + // Merge inputParameters + if (op.getInputParameters().isEmpty() && !function.getInputParameters().isEmpty()) { + for (InputParameterSpec param : function.getInputParameters()) { + op.getInputParameters().add(param); + } + } + + // Merge outputParameters + if (op.getOutputParameters().isEmpty() && !function.getOutputParameters().isEmpty()) { + for (OutputParameterSpec param : function.getOutputParameters()) { + op.getOutputParameters().add(param); + } + } + } + + /** + * Look up a function by ref key. Fails fast on unknown or chained refs. + */ + AggregateFunctionSpec lookupFunction(String ref, + Map functionMap) { + AggregateFunctionSpec function = functionMap.get(ref); + if (function == null) { + throw new IllegalArgumentException( + "Unknown aggregate function ref: '" + ref + + "'. Available refs: " + functionMap.keySet()); + } + return function; + } + + /** + * Derive MCP tool hints from transport-neutral semantics. + * + *

+ * Mapping: + *

    + *
  • {@code safe: true} → {@code readOnly: true, destructive: false}
  • + *
  • {@code safe: false/null} → {@code readOnly: false}
  • + *
  • {@code idempotent} → copied directly
  • + *
  • {@code cacheable} → not mapped (no MCP equivalent)
  • + *
  • {@code openWorld} → not derived (MCP-specific)
  • + *
+ */ + McpToolHintsSpec deriveHints(SemanticsSpec semantics) { + McpToolHintsSpec hints = new McpToolHintsSpec(); + + if (Boolean.TRUE.equals(semantics.getSafe())) { + hints.setReadOnly(true); + hints.setDestructive(false); + } else if (Boolean.FALSE.equals(semantics.getSafe())) { + hints.setReadOnly(false); + } + + if (semantics.getIdempotent() != null) { + hints.setIdempotent(semantics.getIdempotent()); + } + + // cacheable has no MCP equivalent — not mapped + // openWorld is MCP-specific — not derived from semantics + + return hints; + } + + /** + * Merge derived hints with explicit tool-level overrides. Each non-null explicit field wins over + * the derived value. + * + * @param derived Hints derived from semantics (never null) + * @param explicit Explicit tool-level hints (may be null) + * @return Merged hints + */ + McpToolHintsSpec mergeHints(McpToolHintsSpec derived, McpToolHintsSpec explicit) { + if (explicit == null) { + return derived; + } + + // Each explicit non-null field overrides the derived value + if (explicit.getReadOnly() != null) { + derived.setReadOnly(explicit.getReadOnly()); + } + if (explicit.getDestructive() != null) { + derived.setDestructive(explicit.getDestructive()); + } + if (explicit.getIdempotent() != null) { + derived.setIdempotent(explicit.getIdempotent()); + } + if (explicit.getOpenWorld() != null) { + derived.setOpenWorld(explicit.getOpenWorld()); + } + + return derived; + } + +} diff --git a/src/main/java/io/naftiko/spec/AggregateFunctionSpec.java b/src/main/java/io/naftiko/spec/AggregateFunctionSpec.java new file mode 100644 index 00000000..a5750103 --- /dev/null +++ b/src/main/java/io/naftiko/spec/AggregateFunctionSpec.java @@ -0,0 +1,119 @@ +/** + * 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.spec; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import com.fasterxml.jackson.annotation.JsonInclude; +import io.naftiko.spec.exposes.OperationStepSpec; +import io.naftiko.spec.exposes.ServerCallSpec; + +/** + * Aggregate Function Specification Element. + * + * A reusable invocable unit within an aggregate. Adapter units reference it via + * ref: aggregate-namespace.function-name. + */ +public class AggregateFunctionSpec { + + private volatile String name; + private volatile String description; + + @JsonInclude(JsonInclude.Include.NON_NULL) + private volatile SemanticsSpec semantics; + + @JsonInclude(JsonInclude.Include.NON_EMPTY) + private final List inputParameters; + + @JsonInclude(JsonInclude.Include.NON_NULL) + private volatile ServerCallSpec call; + + @JsonInclude(JsonInclude.Include.NON_NULL) + private volatile Map with; + + @JsonInclude(JsonInclude.Include.NON_EMPTY) + private final List steps; + + @JsonInclude(JsonInclude.Include.NON_EMPTY) + private final List> mappings; + + @JsonInclude(JsonInclude.Include.NON_EMPTY) + private final List outputParameters; + + public AggregateFunctionSpec() { + this.inputParameters = new CopyOnWriteArrayList<>(); + this.steps = new CopyOnWriteArrayList<>(); + this.mappings = new CopyOnWriteArrayList<>(); + this.outputParameters = new CopyOnWriteArrayList<>(); + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public SemanticsSpec getSemantics() { + return semantics; + } + + public void setSemantics(SemanticsSpec semantics) { + this.semantics = semantics; + } + + public List getInputParameters() { + return inputParameters; + } + + public ServerCallSpec getCall() { + return call; + } + + public void setCall(ServerCallSpec call) { + this.call = call; + } + + public Map getWith() { + return with; + } + + public void setWith(Map with) { + this.with = with != null ? new ConcurrentHashMap<>(with) : null; + } + + public List getSteps() { + return steps; + } + + public List> getMappings() { + return mappings; + } + + public List getOutputParameters() { + return outputParameters; + } + +} diff --git a/src/main/java/io/naftiko/spec/AggregateSpec.java b/src/main/java/io/naftiko/spec/AggregateSpec.java new file mode 100644 index 00000000..eb137715 --- /dev/null +++ b/src/main/java/io/naftiko/spec/AggregateSpec.java @@ -0,0 +1,54 @@ +/** + * 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.spec; + +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * Aggregate Specification Element. + * + * A domain aggregate grouping reusable functions. Adapters reference these functions via ref. + */ +public class AggregateSpec { + + private volatile String label; + private volatile String namespace; + private final List functions; + + public AggregateSpec() { + this.functions = new CopyOnWriteArrayList<>(); + } + + public String getLabel() { + return label; + } + + public void setLabel(String label) { + this.label = label; + } + + public String getNamespace() { + return namespace; + } + + public void setNamespace(String namespace) { + this.namespace = namespace; + } + + public List getFunctions() { + return functions; + } + +} diff --git a/src/main/java/io/naftiko/spec/CapabilitySpec.java b/src/main/java/io/naftiko/spec/CapabilitySpec.java index bedba781..40a6fbb7 100644 --- a/src/main/java/io/naftiko/spec/CapabilitySpec.java +++ b/src/main/java/io/naftiko/spec/CapabilitySpec.java @@ -29,10 +29,14 @@ public class CapabilitySpec { private final List exposes; private final List consumes; + @JsonInclude(JsonInclude.Include.NON_EMPTY) + private final List aggregates; + public CapabilitySpec() { this.binds = new CopyOnWriteArrayList<>(); this.exposes = new CopyOnWriteArrayList<>(); this.consumes = new CopyOnWriteArrayList<>(); + this.aggregates = new CopyOnWriteArrayList<>(); } public List getBinds() { @@ -47,4 +51,8 @@ public List getConsumes() { return consumes; } + public List getAggregates() { + return aggregates; + } + } diff --git a/src/main/java/io/naftiko/spec/SemanticsSpec.java b/src/main/java/io/naftiko/spec/SemanticsSpec.java new file mode 100644 index 00000000..2cfa40f3 --- /dev/null +++ b/src/main/java/io/naftiko/spec/SemanticsSpec.java @@ -0,0 +1,58 @@ +/** + * 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.spec; + +/** + * Transport-neutral behavioral metadata for an invocable unit. Used for design-time tooling and + * adapter derivations (e.g. MCP hints). + */ +public class SemanticsSpec { + + private Boolean safe; + private Boolean idempotent; + private Boolean cacheable; + + public SemanticsSpec() {} + + public SemanticsSpec(Boolean safe, Boolean idempotent, Boolean cacheable) { + this.safe = safe; + this.idempotent = idempotent; + this.cacheable = cacheable; + } + + public Boolean getSafe() { + return safe; + } + + public void setSafe(Boolean safe) { + this.safe = safe; + } + + public Boolean getIdempotent() { + return idempotent; + } + + public void setIdempotent(Boolean idempotent) { + this.idempotent = idempotent; + } + + public Boolean getCacheable() { + return cacheable; + } + + public void setCacheable(Boolean cacheable) { + this.cacheable = cacheable; + } + +} diff --git a/src/main/java/io/naftiko/spec/exposes/McpServerToolSpec.java b/src/main/java/io/naftiko/spec/exposes/McpServerToolSpec.java index 27664357..ff996c3f 100644 --- a/src/main/java/io/naftiko/spec/exposes/McpServerToolSpec.java +++ b/src/main/java/io/naftiko/spec/exposes/McpServerToolSpec.java @@ -36,6 +36,9 @@ public class McpServerToolSpec { private volatile String description; + @JsonInclude(JsonInclude.Include.NON_NULL) + private volatile String ref; + @JsonInclude(JsonInclude.Include.NON_EMPTY) private final List inputParameters; @@ -135,4 +138,12 @@ public void setHints(McpToolHintsSpec hints) { this.hints = hints; } + public String getRef() { + return ref; + } + + public void setRef(String ref) { + this.ref = ref; + } + } diff --git a/src/main/java/io/naftiko/spec/exposes/RestServerOperationSpec.java b/src/main/java/io/naftiko/spec/exposes/RestServerOperationSpec.java index 0f86ae19..164cbca1 100644 --- a/src/main/java/io/naftiko/spec/exposes/RestServerOperationSpec.java +++ b/src/main/java/io/naftiko/spec/exposes/RestServerOperationSpec.java @@ -37,6 +37,9 @@ public class RestServerOperationSpec extends OperationSpec { @JsonInclude(JsonInclude.Include.NON_NULL) private volatile Map with; + @JsonInclude(JsonInclude.Include.NON_NULL) + private volatile String ref; + public RestServerOperationSpec() { this(null, null, null, null, null, null, null, null, null); } @@ -85,5 +88,13 @@ public void setWith(Map with) { this.with = with != null ? new ConcurrentHashMap<>(with) : null; } + public String getRef() { + return ref; + } + + public void setRef(String ref) { + this.ref = ref; + } + } diff --git a/src/main/resources/rules/functions/aggregate-semantics-consistency.js b/src/main/resources/rules/functions/aggregate-semantics-consistency.js new file mode 100644 index 00000000..339cc024 --- /dev/null +++ b/src/main/resources/rules/functions/aggregate-semantics-consistency.js @@ -0,0 +1,183 @@ +export default function aggregateSemanticsConsistency(targetVal) { + if (!targetVal || typeof targetVal !== "object") { + return; + } + + const capability = + targetVal.capability && typeof targetVal.capability === "object" + ? targetVal.capability + : {}; + + const aggregates = Array.isArray(capability.aggregates) + ? capability.aggregates + : []; + + if (aggregates.length === 0) { + return; + } + + // Build function index: "namespace.function-name" → semantics + const functionIndex = new Map(); + for (let i = 0; i < aggregates.length; i += 1) { + const agg = aggregates[i]; + if (!agg || typeof agg.namespace !== "string") { + continue; + } + const functions = Array.isArray(agg.functions) ? agg.functions : []; + for (let j = 0; j < functions.length; j += 1) { + const fn = functions[j]; + if (!fn || typeof fn.name !== "string") { + continue; + } + const key = agg.namespace + "." + fn.name; + functionIndex.set(key, { + semantics: fn.semantics && typeof fn.semantics === "object" ? fn.semantics : null, + aggIndex: i, + fnIndex: j, + }); + } + } + + const results = []; + const exposes = Array.isArray(capability.exposes) ? capability.exposes : []; + + for (let e = 0; e < exposes.length; e += 1) { + const adapter = exposes[e]; + if (!adapter || typeof adapter !== "object") { + continue; + } + + if (adapter.type === "mcp") { + checkMcpTools(adapter, e, functionIndex, results); + } else if (adapter.type === "rest") { + checkRestOperations(adapter, e, functionIndex, results); + } + } + + return results; +} + +function checkMcpTools(adapter, adapterIndex, functionIndex, results) { + const tools = Array.isArray(adapter.tools) ? adapter.tools : []; + + for (let t = 0; t < tools.length; t += 1) { + const tool = tools[t]; + if (!tool || typeof tool.ref !== "string") { + continue; + } + + const entry = functionIndex.get(tool.ref); + if (!entry || !entry.semantics) { + continue; + } + + const semantics = entry.semantics; + const hints = tool.hints && typeof tool.hints === "object" ? tool.hints : null; + if (!hints) { + continue; + } + + const basePath = [ + "capability", "exposes", adapterIndex, "tools", t, "hints", + ]; + + // safe vs readOnly + if (semantics.safe === true && hints.readOnly === false) { + results.push({ + message: + "Function '" + tool.ref + "' has semantics.safe=true but tool hints set readOnly=false. Safe functions should be read-only.", + path: basePath.concat("readOnly"), + }); + } + if (semantics.safe === false && hints.readOnly === true) { + results.push({ + message: + "Function '" + tool.ref + "' has semantics.safe=false but tool hints set readOnly=true. Unsafe functions should not be read-only.", + path: basePath.concat("readOnly"), + }); + } + + // safe vs destructive + if (semantics.safe === true && hints.destructive === true) { + results.push({ + message: + "Function '" + tool.ref + "' has semantics.safe=true but tool hints set destructive=true. Safe functions should not be destructive.", + path: basePath.concat("destructive"), + }); + } + + // idempotent consistency + if (semantics.idempotent === true && hints.idempotent === false) { + results.push({ + message: + "Function '" + tool.ref + "' has semantics.idempotent=true but tool hints set idempotent=false.", + path: basePath.concat("idempotent"), + }); + } + if (semantics.idempotent === false && hints.idempotent === true) { + results.push({ + message: + "Function '" + tool.ref + "' has semantics.idempotent=false but tool hints set idempotent=true.", + path: basePath.concat("idempotent"), + }); + } + } +} + +function checkRestOperations(adapter, adapterIndex, functionIndex, results) { + const resources = Array.isArray(adapter.resources) ? adapter.resources : []; + + for (let r = 0; r < resources.length; r += 1) { + const resource = resources[r]; + if (!resource || typeof resource !== "object") { + continue; + } + + const operations = Array.isArray(resource.operations) + ? resource.operations + : []; + + for (let o = 0; o < operations.length; o += 1) { + const op = operations[o]; + if (!op || typeof op.ref !== "string" || typeof op.method !== "string") { + continue; + } + + const entry = functionIndex.get(op.ref); + if (!entry || !entry.semantics) { + continue; + } + + const semantics = entry.semantics; + const method = op.method.toUpperCase(); + const basePath = [ + "capability", "exposes", adapterIndex, "resources", r, "operations", o, "method", + ]; + + // safe vs mutating methods + if (semantics.safe === true && method !== "GET") { + results.push({ + message: + "Function '" + op.ref + "' has semantics.safe=true but REST operation uses " + method + ". Safe functions should use GET.", + path: basePath, + }); + } + if (semantics.safe === false && method === "GET") { + results.push({ + message: + "Function '" + op.ref + "' has semantics.safe=false but REST operation uses GET. Unsafe functions should not use GET.", + path: basePath, + }); + } + + // idempotent vs POST + if (semantics.idempotent === true && method === "POST") { + results.push({ + message: + "Function '" + op.ref + "' has semantics.idempotent=true but REST operation uses POST. POST is not idempotent by convention.", + path: basePath, + }); + } + } + } +} diff --git a/src/main/resources/rules/naftiko-rules.yml b/src/main/resources/rules/naftiko-rules.yml index 445e6944..de87e723 100644 --- a/src/main/resources/rules/naftiko-rules.yml +++ b/src/main/resources/rules/naftiko-rules.yml @@ -24,6 +24,7 @@ functionsDir: ./functions functions: - unique-namespaces + - aggregate-semantics-consistency rules: @@ -113,6 +114,31 @@ rules: functionOptions: notMatch: "example\\.com$" + naftiko-aggregate-function-description: + message: "Each aggregate function should have a `description` field." + description: > + Function descriptions are inherited by adapter units and improve agent + discoverability. + severity: warn + recommended: true + given: "$.capability.aggregates[*].functions[*]" + then: + field: "description" + function: truthy + + naftiko-aggregate-semantics-consistency: + message: "Aggregate function semantics must be consistent with MCP tool hints and REST operation methods." + description: > + When an MCP tool or REST operation references an aggregate function via `ref`, + any explicit hints or HTTP methods should not contradict the function's declared + semantics. For example, a safe function should not have destructive=true hints + or use a POST/DELETE method. + severity: warn + recommended: true + given: "$" + then: + function: aggregate-semantics-consistency + # ──────────────────────────────────────────────────────────────── # 2. QUALITY & DISCOVERABILITY # ──────────────────────────────────────────────────────────────── diff --git a/src/main/resources/schemas/examples/forecast-aggregate.yml b/src/main/resources/schemas/examples/forecast-aggregate.yml new file mode 100644 index 00000000..a5724058 --- /dev/null +++ b/src/main/resources/schemas/examples/forecast-aggregate.yml @@ -0,0 +1,87 @@ +# yaml-language-server: $schema=../naftiko-schema.json +--- +naftiko: "1.0.0-alpha1" +info: + label: "Weather Forecast (Aggregate)" + description: > + Demonstrates domain-driven factorization with aggregates. + A single Forecast aggregate function is defined once and referenced + by both MCP and REST adapters via ref. + tags: + - Weather + - Aggregate + - Example + created: "2026-04-03" + modified: "2026-04-03" + +capability: + aggregates: + - label: "Forecast" + namespace: "forecast" + functions: + - name: "get-forecast" + description: "Fetch current weather forecast for a location." + semantics: + safe: true + idempotent: true + inputParameters: + - name: "location" + type: "string" + description: "City name or coordinates (e.g. 'Paris' or '48.8566,2.3522')." + call: "weather-api.get-forecast" + with: + location: "location" + outputParameters: + - type: "object" + mapping: "$.forecast" + properties: + temperature: + type: "number" + mapping: "$.temperature" + condition: + type: "string" + mapping: "$.condition" + + exposes: + # MCP adapter — inherits everything from the aggregate function. + # Hints are auto-derived from semantics: readOnly=true, destructive=false, idempotent=true. + - type: "mcp" + address: "localhost" + port: 3000 + namespace: "forecast-mcp" + description: "MCP server exposing weather forecast tools." + tools: + - name: "get-forecast" + description: "Get the weather forecast for a location." + ref: "forecast.get-forecast" + + # REST adapter — inherits call, with, and outputParameters. + # Adds REST-specific fields: method, path parameters. + - type: "rest" + address: "localhost" + port: 3001 + namespace: "forecast-rest" + resources: + - path: "/forecast/{location}" + name: "forecast" + description: "Weather forecast resource." + operations: + - ref: "forecast.get-forecast" + method: "GET" + inputParameters: + - name: "location" + in: "path" + type: "string" + description: "City name or coordinates." + + consumes: + - type: "http" + namespace: "weather-api" + description: "External weather API" + baseUri: "https://api.weather.example/v1" + resources: + - path: "forecast" + name: "forecast" + operations: + - method: "GET" + name: "get-forecast" diff --git a/src/main/resources/schemas/naftiko-schema.json b/src/main/resources/schemas/naftiko-schema.json index ab859a10..02502e35 100644 --- a/src/main/resources/schemas/naftiko-schema.json +++ b/src/main/resources/schemas/naftiko-schema.json @@ -697,6 +697,14 @@ ] }, "minItems": 1 + }, + "aggregates": { + "type": "array", + "description": "Domain aggregates defining reusable functions that adapters can reference via ref.", + "items": { + "$ref": "#/$defs/Aggregate" + }, + "minItems": 1 } }, "anyOf": [ @@ -719,6 +727,137 @@ ], "additionalProperties": false }, + "Aggregate": { + "type": "object", + "description": "A domain aggregate grouping reusable functions. Adapters reference these functions via ref.", + "properties": { + "label": { + "type": "string", + "description": "Human-readable name for this aggregate (e.g. 'Forecast')." + }, + "namespace": { + "$ref": "#/$defs/IdentifierKebab", + "description": "Machine-readable qualifier for this aggregate. Used as the first segment in ref values." + }, + "functions": { + "type": "array", + "description": "Reusable invocable units within this aggregate.", + "items": { + "$ref": "#/$defs/AggregateFunction" + }, + "minItems": 1 + } + }, + "required": [ + "label", + "namespace", + "functions" + ], + "additionalProperties": false + }, + "AggregateFunction": { + "type": "object", + "description": "A reusable invocable unit within an aggregate. Adapter units reference it via ref: aggregate-namespace.function-name.", + "properties": { + "name": { + "$ref": "#/$defs/IdentifierKebab", + "description": "Function name. Combined with aggregate namespace to form the ref target." + }, + "description": { + "type": "string", + "description": "A meaningful description of the function." + }, + "semantics": { + "$ref": "#/$defs/Semantics" + }, + "inputParameters": { + "type": "array", + "items": { + "$ref": "#/$defs/McpToolInputParameter" + } + }, + "call": { + "type": "string", + "description": "Reference to the consumed operation. Format: {namespace}.{operationId}.", + "pattern": "^[a-zA-Z0-9-]+\\.[a-zA-Z0-9-]+$" + }, + "with": { + "$ref": "#/$defs/WithInjector" + }, + "steps": { + "type": "array", + "items": { + "$ref": "#/$defs/OperationStep" + }, + "minItems": 1 + }, + "mappings": { + "type": "array", + "description": "Maps step outputs to the function's output parameters.", + "items": { + "$ref": "#/$defs/StepOutputMapping" + } + }, + "outputParameters": { + "type": "array" + } + }, + "required": [ + "name", + "description" + ], + "anyOf": [ + { + "required": [ + "call" + ], + "type": "object", + "properties": { + "outputParameters": { + "type": "array", + "items": { + "$ref": "#/$defs/MappedOutputParameter" + } + } + } + }, + { + "required": [ + "steps" + ], + "type": "object", + "properties": { + "mappings": true, + "outputParameters": { + "type": "array", + "items": { + "$ref": "#/$defs/OrchestratedOutputParameter" + } + } + } + } + ], + "additionalProperties": false + }, + "Semantics": { + "type": "object", + "description": "Transport-neutral behavioral metadata for an invocable unit. Used for design-time tooling and adapter derivations (e.g. MCP hints).", + "properties": { + "safe": { + "type": "boolean", + "description": "If true, the function does not modify state. Default: false." + }, + "idempotent": { + "type": "boolean", + "description": "If true, repeating the call has no additional effect. Default: false." + }, + "cacheable": { + "type": "boolean", + "description": "If true, the result can be cached. Default: false." + } + }, + "additionalProperties": false + }, "ExposesRest": { "type": "object", "description": "REST exposition configuration", @@ -873,6 +1012,11 @@ "$ref": "#/$defs/McpToolInputParameter" } }, + "ref": { + "type": "string", + "description": "Reference to an aggregate function. Format: {aggregate-namespace}.{function-name}. Inherited fields are merged; explicit fields override.", + "pattern": "^[a-zA-Z0-9-]+\\.[a-zA-Z0-9-]+$" + }, "call": { "type": "string", "description": "For simple cases, the reference to the consumed operation. Format: {namespace}.{operationId}.", @@ -902,13 +1046,12 @@ "type": "array" } }, - "required": [ - "name", - "description" - ], + "required": [], "anyOf": [ { "required": [ + "name", + "description", "call" ], "type": "object", @@ -923,6 +1066,8 @@ }, { "required": [ + "name", + "description", "steps" ], "type": "object", @@ -935,6 +1080,11 @@ } } } + }, + { + "required": [ + "ref" + ] } ], "additionalProperties": false @@ -1330,6 +1480,11 @@ "$ref": "#/$defs/ExposedInputParameter" } }, + "ref": { + "type": "string", + "description": "Reference to an aggregate function. Format: {aggregate-namespace}.{function-name}. Inherited fields are merged; explicit fields override.", + "pattern": "^[a-zA-Z0-9-]+\\.[a-zA-Z0-9-]+$" + }, "call": { "type": "string", "description": "For simple cases, the reference to the consumed operation. Format: {namespace}.{operationId}. E.g.: notion.get-database or github.get-user", @@ -1382,6 +1537,11 @@ } } } + }, + { + "required": [ + "ref" + ] } ], "additionalProperties": false diff --git a/src/main/resources/wiki/FAQ.md b/src/main/resources/wiki/FAQ.md index d726e8f5..3437bb74 100644 --- a/src/main/resources/wiki/FAQ.md +++ b/src/main/resources/wiki/FAQ.md @@ -151,6 +151,76 @@ steps: --- +## 🧱 Aggregates & Reuse (DDD-inspired) + +### Q: What are aggregates and why should I use them? +**A:** Aggregates are **domain-centric building blocks** inspired by [Domain-Driven Design (DDD)](https://en.wikipedia.org/wiki/Domain-driven_design#Building_blocks). An aggregate groups reusable **functions** under a namespace that represents a coherent domain concept — similar to how a DDD Aggregate Root encapsulates a cluster of related entities. + +Use aggregates when: +- The **same domain operation** (e.g., "get forecast") is exposed through multiple adapters (REST *and* MCP). +- You want to maintain a **single source of truth** for function definitions (name, description, call chain, parameters). +- You want **transport-neutral behavioral metadata** (semantics) that auto-maps to adapter-specific features. + +```yaml +capability: + aggregates: + - label: Weather Forecast + namespace: forecast + functions: + - name: get-forecast + description: Retrieve weather forecast for a city + semantics: + safe: true + idempotent: true + call: weather-api.get-forecast + inputParameters: + - name: city + type: string + description: City name + outputParameters: + - type: object + mapping: $.forecast +``` + +### Q: How does `ref` work to reference an aggregate function? +**A:** MCP tools and REST operations can reference an aggregate function using `ref: {namespace}.{function-name}`. The engine merges inherited fields from the function — you only specify what's different at the adapter level. + +```yaml +exposes: + - type: mcp + tools: + - ref: forecast.get-forecast # Inherits name, description, call, params + - ref: forecast.get-forecast # Override description for MCP context + name: weather-lookup + description: Look up weather for a city (MCP-optimized) + - type: rest + resources: + - path: /forecast + operations: + - method: GET + ref: forecast.get-forecast # Same function, REST adapter +``` + +**Merge rules:** +- Explicit fields on the tool/operation **override** inherited fields from the function. +- Fields not set on the tool/operation are **inherited** from the function. +- `name` and `description` are optional when using `ref` — they default to the function's values. + +### Q: How do semantics map to MCP tool hints? +**A:** Aggregate functions can declare transport-neutral **semantics** (`safe`, `idempotent`, `cacheable`). When exposed as MCP tools, the engine automatically derives [MCP tool hints](https://modelcontextprotocol.io/specification/2025-06-18/server/tools#tool-annotations): + +| Semantics | MCP Hint | Rule | +|-----------|----------|------| +| `safe: true` | `readOnly: true`, `destructive: false` | Safe operations don't modify state | +| `safe: false` | `readOnly: false`, `destructive: true` | Unsafe operations may modify state | +| `idempotent` | `idempotent` | Passed through directly | +| `cacheable` | *(not mapped)* | No MCP equivalent | +| *(not derived)* | `openWorld` | Must be set explicitly on the MCP tool | + +Explicit hints on the MCP tool **override** derived values, so you can fine-tune behavior per-tool. + +--- + ## 🔩 Configuration & Parameters ### Q: How do I inject input parameters into a consumed operation? 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 e5a37aba..0a837d95 100644 --- "a/src/main/resources/wiki/Specification-\342\200\220-Schema.md" +++ "b/src/main/resources/wiki/Specification-\342\200\220-Schema.md" @@ -179,6 +179,7 @@ Defines the technical configuration of the capability. | **exposes** | `Exposes[]` | List of exposed server adapters. Each entry is a REST Expose (`type: "rest"`), an MCP Expose (`type: "mcp"`), or a Skill Expose (`type: "skill"`). | | **consumes** | `Consumes[]` | List of consumed client adapters. | | **binds** | `Bind[]` | List of external bindings for variable injection. Each entry declares injected variables via a `keys` map. | +| **aggregates** | `Aggregate[]` | Domain aggregates defining reusable functions. Adapter units (tools, operations) reference them via `ref`. See [3.4.5 Aggregate Object](#345-aggregate-object). | #### 3.4.2 Rules @@ -188,6 +189,7 @@ Defines the technical configuration of the capability. - Each `consumes` entry MUST include both `baseUri` and `namespace` fields. - There are several types of exposed adapters and consumed sources objects, all will be described in following objects. - The `binds` field is OPTIONAL. When present, it MUST contain at least one entry. +- The `aggregates` field is OPTIONAL. When present, it MUST contain at least one entry. Aggregate namespaces MUST be unique. - No additional properties are allowed. #### 3.4.3 Namespace Uniqueness Rule @@ -239,6 +241,163 @@ capability: --- +### 3.4.5 Aggregate Object + +A domain aggregate in the sense of [Domain-Driven Design (DDD)](https://en.wikipedia.org/wiki/Domain-driven_design#Building_blocks). Each aggregate groups reusable **functions** — transport-neutral invocable units that adapters reference via `ref`. This factorizes domain behavior so it is defined once and reused across REST, MCP, Skill, and future adapters without duplication. + +> New in schema v1.0.0-alpha1. + +**Fixed Fields:** + +| Field Name | Type | Description | +| --- | --- | --- | +| **label** | `string` | **REQUIRED**. Human-readable name for this aggregate (e.g. `"Forecast"`). | +| **namespace** | `string` | **REQUIRED**. Machine-readable qualifier (`IdentifierKebab`). Used as the first segment in `ref` values (`{aggregate-namespace}.{function-name}`). | +| **functions** | `AggregateFunction[]` | **REQUIRED**. Reusable invocable units within this aggregate (minimum 1). | + +**Rules:** + +- All three fields are mandatory. +- The `namespace` MUST be unique across all aggregates. +- No additional properties are allowed. + +#### 3.4.5.1 AggregateFunction Object + +A reusable invocable unit within an aggregate. Adapter units (MCP tools, REST operations) reference it via `ref: {aggregate-namespace}.{function-name}`. Referenced fields are merged into the adapter unit; adapter-local explicit fields override inherited ones. + +**Fixed Fields:** + +| Field Name | Type | Description | +| --- | --- | --- | +| **name** | `string` | **REQUIRED**. Function name (`IdentifierKebab`). Combined with aggregate namespace to form the ref target. | +| **description** | `string` | **REQUIRED**. A meaningful description of the function. Inherited by adapter units that omit their own. | +| **semantics** | `Semantics` | Transport-neutral behavioral metadata. Automatically derived into adapter-specific metadata (e.g. MCP hints). See [3.4.5.2 Semantics Object](#3452-semantics-object). | +| **inputParameters** | `McpToolInputParameter[]` | Input parameters for this function. | +| **call** | `string` | **Simple mode**. Reference to consumed operation (`{namespace}.{operationId}`). | +| **with** | `WithInjector` | **Simple mode**. Parameter injection for the called operation. | +| **steps** | `OperationStep[]` | **Orchestrated mode**. Sequence of calls to consumed operations (minimum 1). | +| **mappings** | `StepOutputMapping[]` | **Orchestrated mode**. Maps step outputs to function output parameters. | +| **outputParameters** (simple) | `MappedOutputParameter[]` | **Simple mode**. Output params mapped from consumed operation response. | +| **outputParameters** (orchestrated) | `OrchestratedOutputParameter[]` | **Orchestrated mode**. Named, typed output parameters. | + +**Modes:** + +Same two modes as McpTool and ExposedOperation: + +- **Simple mode** — `call` is REQUIRED, `with` optional, `steps` MUST NOT be present. +- **Orchestrated mode** — `steps` is REQUIRED, `mappings` optional, `call` and `with` MUST NOT be present. + +**Rules:** + +- `name` and `description` are mandatory. +- Exactly one mode MUST be used. +- Function names MUST be unique within an aggregate. +- No chained refs — a function cannot itself use `ref`. +- No additional properties are allowed. + +#### 3.4.5.2 Semantics Object + +Transport-neutral behavioral metadata for an invocable unit. These properties describe the function's intent independent of any transport protocol. The framework automatically derives adapter-specific metadata from semantics — for example, MCP tool `hints` are derived from `semantics` at capability load time (see [Semantics-to-Hints derivation](#3453-semantics-to-hints-derivation)). + +**Fixed Fields:** + +| Field Name | Type | Description | +| --- | --- | --- | +| **safe** | `boolean` | If `true`, the function does not modify state. Default: `false`. | +| **idempotent** | `boolean` | If `true`, repeating the call has no additional effect. Default: `false`. | +| **cacheable** | `boolean` | If `true`, the result can be cached. Default: `false`. | + +**Rules:** + +- All fields are optional. Omitted fields fall back to their defaults. +- No additional properties are allowed. + +#### 3.4.5.3 Semantics-to-Hints Derivation + +When an MCP tool references an aggregate function via `ref`, the function's `semantics` are automatically derived into MCP `hints` (`McpToolHints`). Explicit `hints` on the MCP tool override derived values field by field. + +**Mapping table:** + +| Aggregate `semantics` | Derived MCP `hints` | Rationale | +| --- | --- | --- | +| `safe: true` | `readOnly: true`, `destructive: false` | A safe function doesn't change state | +| `safe: false` (or absent) | `readOnly: false` | Default — may have side effects | +| `idempotent: true` | `idempotent: true` | Direct 1:1 mapping | +| `cacheable` | *(not mapped)* | No MCP equivalent; informational for future adapters | +| *(no semantic)* | `openWorld` not derived | `openWorld` is MCP-specific context; set explicitly at tool level | + +**Override rule:** Each non-null field in the tool-level `hints` wins over the derived value. Absent fields in the tool-level `hints` still inherit from semantics. + +#### 3.4.5.4 Aggregate Object Example + +```yaml +capability: + aggregates: + - label: "Forecast" + namespace: "forecast" + functions: + - name: "get-forecast" + description: "Fetch current weather forecast for a location." + semantics: + safe: true + idempotent: true + inputParameters: + - name: "location" + type: "string" + description: "City name or coordinates" + call: "weather-api.get-forecast" + with: + location: "location" + outputParameters: + - type: "string" + mapping: "$.forecast" + + exposes: + - type: "mcp" + namespace: "forecast-mcp" + tools: + # Minimal ref — name, description, call, params, outputs all inherited + - ref: "forecast.get-forecast" + + # Override only the name; everything else inherited + - ref: "forecast.get-forecast" + name: "weather" + + # Add MCP-specific openWorld hint; readOnly/destructive/idempotent derived + - ref: "forecast.get-forecast" + name: "weather-open" + hints: + openWorld: true + + - type: "rest" + namespace: "forecast-rest" + resources: + - path: "/forecast/{location}" + name: "forecast" + description: "Forecast resource" + operations: + - ref: "forecast.get-forecast" + method: "GET" + inputParameters: + - name: "location" + in: "path" + type: "string" + description: "City name or coordinates" + + consumes: + - type: "http" + namespace: "weather-api" + baseUri: "https://api.weather.example.com/v1" + resources: + - path: "forecast" + name: "forecast" + operations: + - method: "GET" + name: "get-forecast" +``` + +--- + ### 3.5 Exposes Object Describes a server adapter that exposes functionality. @@ -333,17 +492,18 @@ 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 the same two modes as ExposedOperation: **simple** (direct `call` + `with`) and **orchestrated** (multi-step with `steps` + `mappings`). Additionally, a tool can use **`ref`** to reference an aggregate function, inheriting its fields. > **Fixed Fields:** | Field Name | Type | Description | | --- | --- | --- | -| **name** | `string` | **REQUIRED**. Technical name for the tool. Used as the MCP tool name. MUST match pattern `^[a-zA-Z0-9-]+$`. | +| **name** | `string` | Technical name for the tool. Used as the MCP tool name. MUST match pattern `^[a-zA-Z0-9-]+$`. **REQUIRED** unless `ref` is used (inherited from function). | | **label** | `string` | Human-readable display name for the tool. Mapped to MCP `title` in protocol responses. | -| **description** | `string` | **REQUIRED**. A meaningful description of the tool. Essential for agent discovery. | -| **hints** | `McpToolHints` | Optional behavioral hints for MCP clients. Mapped to `ToolAnnotations` in the MCP protocol. See [3.5.5.1 McpToolHints Object](#3551-mctoolhints-object). | +| **description** | `string` | A meaningful description of the tool. Essential for agent discovery. **REQUIRED** unless `ref` is used (inherited from function). | +| **ref** | `string` | Reference to an aggregate function. Format: `{aggregate-namespace}.{function-name}`. Inherited fields are merged; explicit fields override. See [3.4.5 Aggregate Object](#345-aggregate-object). | +| **hints** | `McpToolHints` | Optional behavioral hints for MCP clients. Mapped to `ToolAnnotations` in the MCP protocol. When `ref` is used, hints are automatically derived from the function's `semantics`; explicit values override derived ones. See [3.5.5.1 McpToolHints Object](#3551-mctoolhints-object). | | **inputParameters** | `McpToolInputParameter[]` | Tool input parameters. These become the MCP tool's input schema (JSON Schema). | | **call** | `string` | **Simple mode only**. Reference to a consumed operation. Format: `{namespace}.{operationId}`. MUST match pattern `^[a-zA-Z0-9-]+\.[a-zA-Z0-9-]+$`. | | **with** | `WithInjector` | **Simple mode only**. Parameter injection for the called operation. | @@ -368,10 +528,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 +**Ref mode** — reference to an aggregate function: + +- `ref` is **REQUIRED** +- All other fields are optional — inherited from the referenced function +- Explicit fields override inherited ones (field-level merge) +- `hints` are automatically derived from the function's `semantics` (see [3.4.5.3](#3453-semantics-to-hints-derivation)) + **Rules:** -- Both `name` and `description` are mandatory. -- Exactly one of the two modes MUST be used (simple or orchestrated). +- Exactly one mode MUST be used: simple (`call`), orchestrated (`steps`), or ref (`ref`). +- In simple and orchestrated modes, `name` and `description` are mandatory. +- In ref mode, `name` and `description` are optional (inherited from the function). - 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}`. @@ -981,6 +1149,8 @@ outputParameters: Describes an operation exposed on an exposed resource. > Update (schema v0.5): ExposedOperation now supports two modes via `oneOf` — **simple** (direct call with mapped output) and **orchestrated** (multi-step with named operation). The `call` and `with` fields are new. The `name` and `steps` fields are only required in orchestrated mode. +> +> Update (schema v1.0.0-alpha1): A third **ref mode** allows referencing an aggregate function, inheriting its fields. See [3.4.5 Aggregate Object](#345-aggregate-object). > #### 3.9.1 Fixed Fields @@ -990,9 +1160,10 @@ All fields available on ExposedOperation: | Field Name | Type | Description | | --- | --- | --- | | **method** | `string` | **REQUIRED**. HTTP method. One of: `GET`, `POST`, `PUT`, `PATCH`, `DELETE`. | -| **name** | `string` | Technical name for the operation (pattern `^[a-zA-Z0-9-]+$`). **REQUIRED in orchestrated mode only.** | +| **name** | `string` | Technical name for the operation (pattern `^[a-zA-Z0-9-]+$`). **REQUIRED in orchestrated mode.** Optional when `ref` is used (inherited from function). | | **label** | `string` | Display name for the operation (likely used in UIs). | -| **description** | `string` | A longer description of the operation. Useful for agent discovery and documentation. | +| **description** | `string` | A longer description of the operation. Useful for agent discovery and documentation. Optional when `ref` is used (inherited from function). | +| **ref** | `string` | Reference to an aggregate function. Format: `{aggregate-namespace}.{function-name}`. Inherited fields are merged; explicit fields override. See [3.4.5 Aggregate Object](#345-aggregate-object). | | **inputParameters** | `ExposedInputParameter[]` | Input parameters attached to the operation. | | **call** | `string` | **Simple mode only**. Direct reference to a consumed operation. Format: `{namespace}.{operationId}`. MUST match pattern `^[a-zA-Z0-9-]+\.[a-zA-Z0-9-]+$`. | | **with** | `WithInjector` | **Simple mode only**. Parameter injection for the called operation. | @@ -1019,11 +1190,20 @@ All fields available on ExposedOperation: - `outputParameters` are `OrchestratedOutputParameter[]` (name + type) - `call` and `with` MUST NOT be present +**Ref mode** — reference to an aggregate function: + +- `ref` is **REQUIRED** +- `method` is still **REQUIRED** (transport-specific) +- All other fields are optional — inherited from the referenced function +- Explicit fields override inherited ones (field-level merge) +- REST-specific fields like `inputParameters` with `in` location can be added to specialize the function for HTTP + #### 3.9.3 Rules -- Exactly one of the two modes MUST be used (simple or orchestrated). +- Exactly one of the three modes MUST be used (simple, orchestrated, or ref). - 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. Each step references a consumed operation using `{namespace}.{operationName}`. +- In ref mode, `ref` MUST resolve to an existing aggregate function at capability load time. - The `method` field is always required regardless of mode. #### 3.9.4 ExposedOperation Object Examples diff --git a/src/test/java/io/naftiko/engine/AggregateRefResolverTest.java b/src/test/java/io/naftiko/engine/AggregateRefResolverTest.java new file mode 100644 index 00000000..2eeaa9ec --- /dev/null +++ b/src/test/java/io/naftiko/engine/AggregateRefResolverTest.java @@ -0,0 +1,446 @@ +/** + * 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; + +import static org.junit.jupiter.api.Assertions.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import java.util.HashMap; +import java.util.Map; +import io.naftiko.spec.AggregateFunctionSpec; +import io.naftiko.spec.AggregateSpec; +import io.naftiko.spec.CapabilitySpec; +import io.naftiko.spec.InputParameterSpec; +import io.naftiko.spec.NaftikoSpec; +import io.naftiko.spec.SemanticsSpec; +import io.naftiko.spec.exposes.McpServerSpec; +import io.naftiko.spec.exposes.McpServerToolSpec; +import io.naftiko.spec.exposes.McpToolHintsSpec; +import io.naftiko.spec.exposes.RestServerOperationSpec; +import io.naftiko.spec.exposes.ServerCallSpec; + +/** + * Unit tests for AggregateRefResolver — ref resolution, merge semantics, hints derivation. + */ +public class AggregateRefResolverTest { + + private AggregateRefResolver resolver; + + @BeforeEach + void setUp() { + resolver = new AggregateRefResolver(); + } + + // ── buildFunctionMap ── + + @Test + void buildFunctionMapShouldIndexByNamespaceAndName() { + CapabilitySpec cap = new CapabilitySpec(); + AggregateSpec agg = new AggregateSpec(); + agg.setNamespace("forecast"); + agg.setLabel("Forecast"); + + AggregateFunctionSpec fn = new AggregateFunctionSpec(); + fn.setName("get-forecast"); + fn.setDescription("Get forecast"); + agg.getFunctions().add(fn); + cap.getAggregates().add(agg); + + Map map = resolver.buildFunctionMap(cap); + + assertEquals(1, map.size()); + assertTrue(map.containsKey("forecast.get-forecast")); + assertSame(fn, map.get("forecast.get-forecast")); + } + + @Test + void buildFunctionMapShouldFailOnDuplicateRef() { + CapabilitySpec cap = new CapabilitySpec(); + + AggregateSpec agg1 = new AggregateSpec(); + agg1.setNamespace("data"); + agg1.setLabel("Data1"); + AggregateFunctionSpec fn1 = new AggregateFunctionSpec(); + fn1.setName("read"); + fn1.setDescription("Read1"); + agg1.getFunctions().add(fn1); + + AggregateSpec agg2 = new AggregateSpec(); + agg2.setNamespace("data"); + agg2.setLabel("Data2"); + AggregateFunctionSpec fn2 = new AggregateFunctionSpec(); + fn2.setName("read"); + fn2.setDescription("Read2"); + agg2.getFunctions().add(fn2); + + cap.getAggregates().add(agg1); + cap.getAggregates().add(agg2); + + assertThrows(IllegalArgumentException.class, () -> resolver.buildFunctionMap(cap)); + } + + // ── lookupFunction ── + + @Test + void lookupFunctionShouldFailOnUnknownRef() { + Map map = new HashMap<>(); + AggregateFunctionSpec fn = new AggregateFunctionSpec(); + fn.setName("read"); + map.put("data.read", fn); + + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> resolver.lookupFunction("unknown.nonexistent", map)); + assertTrue(ex.getMessage().contains("unknown.nonexistent")); + } + + // ── MCP tool ref merge ── + + @Test + void resolveMcpToolRefShouldInheritNameFromFunction() { + AggregateFunctionSpec fn = new AggregateFunctionSpec(); + fn.setName("get-data"); + fn.setDescription("Get data"); + + Map map = Map.of("data.get-data", fn); + + McpServerToolSpec tool = new McpServerToolSpec(null, null, null); + tool.setRef("data.get-data"); + + resolver.resolveMcpToolRef(tool, map); + + assertEquals("get-data", tool.getName()); + } + + @Test + void resolveMcpToolRefShouldNotOverrideExplicitName() { + AggregateFunctionSpec fn = new AggregateFunctionSpec(); + fn.setName("get-data"); + fn.setDescription("Get data"); + + Map map = Map.of("data.get-data", fn); + + McpServerToolSpec tool = new McpServerToolSpec("custom-name", null, null); + tool.setRef("data.get-data"); + + resolver.resolveMcpToolRef(tool, map); + + assertEquals("custom-name", tool.getName()); + } + + @Test + void resolveMcpToolRefShouldInheritCallFromFunction() { + AggregateFunctionSpec fn = new AggregateFunctionSpec(); + fn.setName("get-data"); + fn.setDescription("Get data"); + fn.setCall(new ServerCallSpec("mock-api.get-data")); + + Map map = Map.of("data.get-data", fn); + + McpServerToolSpec tool = new McpServerToolSpec("get-data", null, "Get data"); + tool.setRef("data.get-data"); + + resolver.resolveMcpToolRef(tool, map); + + assertNotNull(tool.getCall()); + assertEquals("mock-api.get-data", tool.getCall().getOperation()); + } + + @Test + void resolveMcpToolRefShouldNotOverrideExplicitCall() { + AggregateFunctionSpec fn = new AggregateFunctionSpec(); + fn.setName("get-data"); + fn.setDescription("Get data"); + fn.setCall(new ServerCallSpec("mock-api.get-data")); + + Map map = Map.of("data.get-data", fn); + + McpServerToolSpec tool = new McpServerToolSpec("get-data", null, "Get data"); + tool.setRef("data.get-data"); + tool.setCall(new ServerCallSpec("other-api.get-data")); + + resolver.resolveMcpToolRef(tool, map); + + assertEquals("other-api.get-data", tool.getCall().getOperation()); + } + + @Test + void resolveMcpToolRefShouldInheritInputParameters() { + AggregateFunctionSpec fn = new AggregateFunctionSpec(); + fn.setName("get-data"); + fn.setDescription("Get data"); + InputParameterSpec param = new InputParameterSpec(); + param.setName("location"); + param.setType("string"); + fn.getInputParameters().add(param); + + Map map = Map.of("data.get-data", fn); + + McpServerToolSpec tool = new McpServerToolSpec("get-data", null, "Get data"); + tool.setRef("data.get-data"); + + resolver.resolveMcpToolRef(tool, map); + + assertEquals(1, tool.getInputParameters().size()); + assertEquals("location", tool.getInputParameters().get(0).getName()); + } + + @Test + void resolveMcpToolRefShouldInheritDescription() { + AggregateFunctionSpec fn = new AggregateFunctionSpec(); + fn.setName("get-data"); + fn.setDescription("Function description"); + + Map map = Map.of("data.get-data", fn); + + McpServerToolSpec tool = new McpServerToolSpec("get-data", null, null); + tool.setRef("data.get-data"); + + resolver.resolveMcpToolRef(tool, map); + + assertEquals("Function description", tool.getDescription()); + } + + @Test + void resolveMcpToolRefShouldNotOverrideExplicitDescription() { + AggregateFunctionSpec fn = new AggregateFunctionSpec(); + fn.setName("get-data"); + fn.setDescription("Function description"); + + Map map = Map.of("data.get-data", fn); + + McpServerToolSpec tool = new McpServerToolSpec("get-data", null, "Tool description"); + tool.setRef("data.get-data"); + + resolver.resolveMcpToolRef(tool, map); + + assertEquals("Tool description", tool.getDescription()); + } + + // ── REST operation ref merge ── + + @Test + void resolveRestOperationRefShouldInheritNameFromFunction() { + AggregateFunctionSpec fn = new AggregateFunctionSpec(); + fn.setName("get-data"); + fn.setDescription("Get data"); + + Map map = Map.of("data.get-data", fn); + + RestServerOperationSpec op = new RestServerOperationSpec(); + op.setRef("data.get-data"); + + resolver.resolveRestOperationRef(op, map); + + assertEquals("get-data", op.getName()); + } + + @Test + void resolveRestOperationRefShouldInheritCallFromFunction() { + AggregateFunctionSpec fn = new AggregateFunctionSpec(); + fn.setName("get-data"); + fn.setDescription("Get data"); + fn.setCall(new ServerCallSpec("mock-api.get-data")); + + Map map = Map.of("data.get-data", fn); + + RestServerOperationSpec op = new RestServerOperationSpec(); + op.setRef("data.get-data"); + + resolver.resolveRestOperationRef(op, map); + + assertNotNull(op.getCall()); + assertEquals("mock-api.get-data", op.getCall().getOperation()); + } + + // ── deriveHints ── + + @Test + void deriveHintsShouldMapSafeToReadOnlyAndNotDestructive() { + SemanticsSpec semantics = new SemanticsSpec(true, null, null); + + McpToolHintsSpec hints = resolver.deriveHints(semantics); + + assertEquals(true, hints.getReadOnly()); + assertEquals(false, hints.getDestructive()); + assertNull(hints.getIdempotent()); + assertNull(hints.getOpenWorld()); + } + + @Test + void deriveHintsShouldDefaultReadOnlyFalseWhenSafeFalse() { + SemanticsSpec semantics = new SemanticsSpec(false, null, null); + + McpToolHintsSpec hints = resolver.deriveHints(semantics); + + assertEquals(false, hints.getReadOnly()); + assertNull(hints.getDestructive()); + } + + @Test + void deriveHintsShouldNotSetReadOnlyWhenSafeAbsent() { + SemanticsSpec semantics = new SemanticsSpec(null, true, null); + + McpToolHintsSpec hints = resolver.deriveHints(semantics); + + assertNull(hints.getReadOnly()); + assertEquals(true, hints.getIdempotent()); + } + + @Test + void deriveHintsShouldMapIdempotentDirectly() { + SemanticsSpec semantics = new SemanticsSpec(null, true, null); + + McpToolHintsSpec hints = resolver.deriveHints(semantics); + + assertEquals(true, hints.getIdempotent()); + } + + @Test + void deriveHintsShouldNotMapCacheable() { + SemanticsSpec semantics = new SemanticsSpec(null, null, true); + + McpToolHintsSpec hints = resolver.deriveHints(semantics); + + assertNull(hints.getReadOnly()); + assertNull(hints.getIdempotent()); + assertNull(hints.getOpenWorld()); + assertNull(hints.getDestructive()); + } + + @Test + void deriveHintsShouldNotSetOpenWorld() { + SemanticsSpec semantics = new SemanticsSpec(true, true, true); + + McpToolHintsSpec hints = resolver.deriveHints(semantics); + + assertNull(hints.getOpenWorld()); + } + + // ── mergeHints ── + + @Test + void mergeHintsShouldReturnDerivedWhenExplicitIsNull() { + McpToolHintsSpec derived = new McpToolHintsSpec(true, false, null, null); + + McpToolHintsSpec result = resolver.mergeHints(derived, null); + + assertSame(derived, result); + } + + @Test + void explicitHintsShouldOverrideDerived() { + McpToolHintsSpec derived = new McpToolHintsSpec(true, false, true, null); + McpToolHintsSpec explicit = new McpToolHintsSpec(false, null, null, null); + + McpToolHintsSpec result = resolver.mergeHints(derived, explicit); + + assertEquals(false, result.getReadOnly()); + assertEquals(false, result.getDestructive()); // not overridden + assertEquals(true, result.getIdempotent()); // not overridden + } + + @Test + void explicitHintsShouldMergeOpenWorldWithDerived() { + McpToolHintsSpec derived = new McpToolHintsSpec(true, false, null, null); + McpToolHintsSpec explicit = new McpToolHintsSpec(null, null, null, true); + + McpToolHintsSpec result = resolver.mergeHints(derived, explicit); + + assertEquals(true, result.getReadOnly()); // from derived + assertEquals(false, result.getDestructive()); // from derived + assertEquals(true, result.getOpenWorld()); // from explicit + } + + // ── resolve (full pipeline) ── + + @Test + void resolveShouldSkipWhenNoAggregates() { + NaftikoSpec spec = new NaftikoSpec("1.0.0-alpha1", null, new CapabilitySpec()); + + // Should not throw + resolver.resolve(spec); + } + + @Test + void resolveShouldSkipWhenCapabilityIsNull() { + NaftikoSpec spec = new NaftikoSpec(); + + // Should not throw + resolver.resolve(spec); + } + + @Test + void resolveShouldDeriveHintsOnMcpToolFromSemantics() { + NaftikoSpec spec = buildSpecWithMcpRef( + new SemanticsSpec(true, true, null), null); + + resolver.resolve(spec); + + McpServerSpec mcpSpec = (McpServerSpec) spec.getCapability().getExposes().get(0); + McpServerToolSpec tool = mcpSpec.getTools().get(0); + + assertEquals(true, tool.getHints().getReadOnly()); + assertEquals(false, tool.getHints().getDestructive()); + assertEquals(true, tool.getHints().getIdempotent()); + assertNull(tool.getHints().getOpenWorld()); + } + + @Test + void resolveShouldMergeExplicitHintsOverDerived() { + McpToolHintsSpec explicitHints = new McpToolHintsSpec(null, null, null, true); + NaftikoSpec spec = buildSpecWithMcpRef( + new SemanticsSpec(true, true, null), explicitHints); + + resolver.resolve(spec); + + McpServerSpec mcpSpec = (McpServerSpec) spec.getCapability().getExposes().get(0); + McpServerToolSpec tool = mcpSpec.getTools().get(0); + + assertEquals(true, tool.getHints().getReadOnly()); + assertEquals(false, tool.getHints().getDestructive()); + assertEquals(true, tool.getHints().getIdempotent()); + assertEquals(true, tool.getHints().getOpenWorld()); + } + + // ── helpers ── + + private NaftikoSpec buildSpecWithMcpRef(SemanticsSpec semantics, + McpToolHintsSpec explicitHints) { + CapabilitySpec cap = new CapabilitySpec(); + + AggregateSpec agg = new AggregateSpec(); + agg.setNamespace("data"); + agg.setLabel("Data"); + AggregateFunctionSpec fn = new AggregateFunctionSpec(); + fn.setName("read"); + fn.setDescription("Read data"); + fn.setSemantics(semantics); + fn.setCall(new ServerCallSpec("mock.read")); + agg.getFunctions().add(fn); + cap.getAggregates().add(agg); + + McpServerSpec mcpSpec = new McpServerSpec(); + mcpSpec.setNamespace("test-mcp"); + McpServerToolSpec tool = new McpServerToolSpec("read", null, "Read data"); + tool.setRef("data.read"); + if (explicitHints != null) { + tool.setHints(explicitHints); + } + mcpSpec.getTools().add(tool); + cap.getExposes().add(mcpSpec); + + return new NaftikoSpec("1.0.0-alpha1", null, cap); + } + +} diff --git a/src/test/java/io/naftiko/engine/exposes/mcp/AggregateIntegrationTest.java b/src/test/java/io/naftiko/engine/exposes/mcp/AggregateIntegrationTest.java new file mode 100644 index 00000000..f33dbc52 --- /dev/null +++ b/src/test/java/io/naftiko/engine/exposes/mcp/AggregateIntegrationTest.java @@ -0,0 +1,211 @@ +/** + * 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 org.junit.jupiter.api.Test; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import io.naftiko.Capability; +import io.naftiko.spec.NaftikoSpec; +import io.naftiko.spec.exposes.McpServerSpec; +import io.naftiko.spec.exposes.McpServerToolSpec; +import java.io.File; + +/** + * Integration tests for aggregate function ref resolution and semantics-to-hints derivation + * through the full capability loading pipeline. + */ +public class AggregateIntegrationTest { + + private static final ObjectMapper JSON = new ObjectMapper(); + + private Capability loadCapability(String path) throws Exception { + File file = new File(path); + assertTrue(file.exists(), "Test file should exist: " + path); + ObjectMapper mapper = new ObjectMapper(new YAMLFactory()); + mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + NaftikoSpec spec = mapper.readValue(file, NaftikoSpec.class); + return new Capability(spec); + } + + // ── Basic ref resolution ── + + @Test + void refShouldResolveCallFromAggregateFunction() throws Exception { + Capability capability = loadCapability("src/test/resources/aggregate-basic.yaml"); + McpServerAdapter adapter = (McpServerAdapter) capability.getServerAdapters().get(0); + McpServerSpec serverSpec = adapter.getMcpServerSpec(); + + McpServerToolSpec tool = serverSpec.getTools().get(0); + assertEquals("get-forecast", tool.getName()); + assertNotNull(tool.getCall(), "call should be inherited from function"); + assertEquals("weather-api.get-forecast", tool.getCall().getOperation()); + } + + @Test + void refShouldResolveInputParametersFromFunction() throws Exception { + Capability capability = loadCapability("src/test/resources/aggregate-basic.yaml"); + McpServerAdapter adapter = (McpServerAdapter) capability.getServerAdapters().get(0); + McpServerSpec serverSpec = adapter.getMcpServerSpec(); + + McpServerToolSpec tool = serverSpec.getTools().get(0); + assertEquals(1, tool.getInputParameters().size()); + assertEquals("location", tool.getInputParameters().get(0).getName()); + } + + @Test + void refShouldInheritDescriptionWhenOmitted() throws Exception { + Capability capability = loadCapability("src/test/resources/aggregate-basic.yaml"); + McpServerAdapter adapter = (McpServerAdapter) capability.getServerAdapters().get(0); + McpServerSpec serverSpec = adapter.getMcpServerSpec(); + + // Second tool omits description — should inherit from function + McpServerToolSpec tool = serverSpec.getTools().get(1); + assertEquals("get-forecast-inherited", tool.getName()); + assertEquals("Fetch current weather forecast for a location.", tool.getDescription()); + } + + @Test + void restOperationRefShouldInheritNameAndDescription() throws Exception { + Capability capability = loadCapability("src/test/resources/aggregate-basic.yaml"); + // REST adapter is the second server adapter + io.naftiko.engine.exposes.rest.RestServerAdapter restAdapter = + (io.naftiko.engine.exposes.rest.RestServerAdapter) capability.getServerAdapters() + .get(1); + io.naftiko.spec.exposes.RestServerSpec restSpec = + (io.naftiko.spec.exposes.RestServerSpec) restAdapter.getSpec(); + + // Second operation (POST) omits name/description — inherited from function + io.naftiko.spec.exposes.RestServerOperationSpec op = + restSpec.getResources().get(0).getOperations().get(1); + assertEquals("POST", op.getMethod()); + assertEquals("get-forecast", op.getName()); + assertEquals("Fetch current weather forecast for a location.", op.getDescription()); + } + + // ── Semantics → hints derivation ── + + @Test + void safeFunctionShouldDeriveReadOnlyHint() throws Exception { + Capability capability = loadCapability("src/test/resources/aggregate-basic.yaml"); + McpServerAdapter adapter = (McpServerAdapter) capability.getServerAdapters().get(0); + McpServerSpec serverSpec = adapter.getMcpServerSpec(); + + McpServerToolSpec tool = serverSpec.getTools().get(0); + assertNotNull(tool.getHints(), "Hints should be derived from semantics"); + assertEquals(true, tool.getHints().getReadOnly()); + assertEquals(false, tool.getHints().getDestructive()); + assertEquals(true, tool.getHints().getIdempotent()); + } + + // ── Hints override ── + + @Test + void toolWithNoExplicitHintsShouldGetFullDerivation() throws Exception { + Capability capability = + loadCapability("src/test/resources/aggregate-hints-override.yaml"); + McpServerAdapter adapter = (McpServerAdapter) capability.getServerAdapters().get(0); + McpServerSpec serverSpec = adapter.getMcpServerSpec(); + + // read-items: safe=true, idempotent=true → readOnly=true, destructive=false, idempotent=true + McpServerToolSpec readTool = serverSpec.getTools().get(0); + assertEquals("read-items", readTool.getName()); + assertEquals(true, readTool.getHints().getReadOnly()); + assertEquals(false, readTool.getHints().getDestructive()); + assertEquals(true, readTool.getHints().getIdempotent()); + assertNull(readTool.getHints().getOpenWorld()); + } + + @Test + void toolWithPartialHintOverrideShouldMerge() throws Exception { + Capability capability = + loadCapability("src/test/resources/aggregate-hints-override.yaml"); + McpServerAdapter adapter = (McpServerAdapter) capability.getServerAdapters().get(0); + McpServerSpec serverSpec = adapter.getMcpServerSpec(); + + // read-items-open: same derivation + openWorld=true from tool + McpServerToolSpec openTool = serverSpec.getTools().get(1); + assertEquals("read-items-open", openTool.getName()); + assertEquals(true, openTool.getHints().getReadOnly()); + assertEquals(false, openTool.getHints().getDestructive()); + assertEquals(true, openTool.getHints().getIdempotent()); + assertEquals(true, openTool.getHints().getOpenWorld()); + } + + @Test + void toolWithExplicitHintsShouldOverrideDerived() throws Exception { + Capability capability = + loadCapability("src/test/resources/aggregate-hints-override.yaml"); + McpServerAdapter adapter = (McpServerAdapter) capability.getServerAdapters().get(0); + McpServerSpec serverSpec = adapter.getMcpServerSpec(); + + // delete-item: safe=false → readOnly=false; explicit override: destructive=true, openWorld=false + McpServerToolSpec deleteTool = serverSpec.getTools().get(2); + assertEquals("delete-item", deleteTool.getName()); + assertEquals(false, deleteTool.getHints().getReadOnly()); + assertEquals(true, deleteTool.getHints().getDestructive()); + assertEquals(true, deleteTool.getHints().getIdempotent()); // from semantics + assertEquals(false, deleteTool.getHints().getOpenWorld()); // from explicit + } + + @Test + void toolWithoutRefShouldNotHaveHints() throws Exception { + Capability capability = + loadCapability("src/test/resources/aggregate-hints-override.yaml"); + McpServerAdapter adapter = (McpServerAdapter) capability.getServerAdapters().get(0); + McpServerSpec serverSpec = adapter.getMcpServerSpec(); + + McpServerToolSpec plainTool = serverSpec.getTools().get(3); + assertEquals("plain-tool", plainTool.getName()); + assertNull(plainTool.getHints()); + } + + // ── Wire format ── + + @Test + void toolsListShouldIncludeDerivedAnnotationsInWireFormat() throws Exception { + Capability capability = loadCapability("src/test/resources/aggregate-basic.yaml"); + McpServerAdapter adapter = (McpServerAdapter) capability.getServerAdapters().get(0); + ProtocolDispatcher dispatcher = new ProtocolDispatcher(adapter); + + JsonNode response = dispatcher.dispatch(JSON.readTree( + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\"}")); + + JsonNode tools = response.path("result").path("tools"); + assertEquals(2, tools.size()); + + JsonNode forecastTool = tools.get(0); + assertEquals("get-forecast", forecastTool.path("name").asText()); + + JsonNode annotations = forecastTool.path("annotations"); + assertFalse(annotations.isMissingNode(), "Should have annotations from derived hints"); + assertEquals(true, annotations.path("readOnlyHint").asBoolean()); + assertEquals(false, annotations.path("destructiveHint").asBoolean()); + assertEquals(true, annotations.path("idempotentHint").asBoolean()); + assertTrue(annotations.path("openWorldHint").isMissingNode(), + "openWorld should not be set"); + } + + // ── Error cases ── + + @Test + void unknownRefShouldFailFast() { + assertThrows(IllegalArgumentException.class, + () -> loadCapability("src/test/resources/aggregate-invalid-ref.yaml")); + } + +} diff --git a/src/test/java/io/naftiko/spec/NaftikoSpectralRulesetTest.java b/src/test/java/io/naftiko/spec/NaftikoSpectralRulesetTest.java index 31846f0f..fce1f9ab 100644 --- a/src/test/java/io/naftiko/spec/NaftikoSpectralRulesetTest.java +++ b/src/test/java/io/naftiko/spec/NaftikoSpectralRulesetTest.java @@ -125,6 +125,70 @@ public void testGlobalNamespaceUniquenessAcrossConsumedAndExposedAdapters() thro } } + @Test + public void semanticsConsistencyRuleShouldWarnWhenMcpHintsContradictSemantics() + throws IOException { + ProcessResult result = runCommand( + "npx", + "@stoplight/spectral-cli", + "lint", + "src/test/resources/spectral-semantics-inconsistent.yaml", + "--ruleset", + rulesetPath.toAbsolutePath().toString()); + + assertTrue(result.exitCode() != 0, + "Expected inconsistent semantics document to produce warnings.\n" + + result.output()); + String output = result.output(); + assertTrue(output.contains("naftiko-aggregate-semantics-consistency"), + "Expected lint output to reference naftiko-aggregate-semantics-consistency.\n" + + output); + assertTrue(output.contains("readOnly=false"), + "Expected warning about readOnly=false contradicting safe=true.\n" + + output); + assertTrue(output.contains("destructive=true"), + "Expected warning about destructive=true contradicting safe=true.\n" + + output); + } + + @Test + public void semanticsConsistencyRuleShouldWarnWhenRestMethodContradictsSafeSemantics() + throws IOException { + ProcessResult result = runCommand( + "npx", + "@stoplight/spectral-cli", + "lint", + "src/test/resources/spectral-semantics-inconsistent.yaml", + "--ruleset", + rulesetPath.toAbsolutePath().toString()); + + assertTrue(result.exitCode() != 0, + "Expected inconsistent semantics document to produce warnings.\n" + + result.output()); + String output = result.output(); + assertTrue(output.contains("DELETE"), + "Expected warning about DELETE contradicting safe=true.\n" + + output); + } + + @Test + public void semanticsConsistencyRuleShouldNotWarnWhenHintsAreConsistent() + throws IOException { + ProcessResult result = runCommand( + "npx", + "@stoplight/spectral-cli", + "lint", + "src/test/resources/spectral-semantics-consistent.yaml", + "--ruleset", + rulesetPath.toAbsolutePath().toString()); + + String output = result.output(); + assertTrue( + !output.contains("naftiko-aggregate-semantics-consistency"), + "Expected no semantics consistency warnings for consistent document.\n" + + output); + } + private boolean isCommandAvailable(String command, String arg) { ProcessResult result = runCommand(command, arg); return result.exitCode() == 0; diff --git a/src/test/resources/aggregate-basic.yaml b/src/test/resources/aggregate-basic.yaml new file mode 100644 index 00000000..5d263221 --- /dev/null +++ b/src/test/resources/aggregate-basic.yaml @@ -0,0 +1,79 @@ +# yaml-language-server: $schema=../../main/resources/schemas/naftiko-schema.json +--- +naftiko: "1.0.0-alpha1" +info: + label: "Aggregate Basic Test" + description: "Test capability for aggregate function ref resolution" + tags: + - Test + - Aggregate + created: "2026-04-03" + modified: "2026-04-03" + +capability: + aggregates: + - label: "Forecast" + namespace: "forecast" + functions: + - name: "get-forecast" + description: "Fetch current weather forecast for a location." + semantics: + safe: true + idempotent: true + inputParameters: + - name: "location" + type: "string" + description: "City or coordinates" + call: "weather-api.get-forecast" + with: + location: "location" + outputParameters: + - type: "string" + mapping: "$.forecast" + + exposes: + - type: "mcp" + address: "localhost" + port: 9100 + namespace: "forecast-mcp" + description: "MCP server using aggregate ref." + + tools: + - name: "get-forecast" + description: "Get the weather forecast." + ref: "forecast.get-forecast" + + # Tool that omits name and description — inherited from function + - ref: "forecast.get-forecast" + name: "get-forecast-inherited" + + - type: "rest" + address: "localhost" + port: 9101 + namespace: "forecast-rest" + resources: + - path: "/forecast/{location}" + name: "forecast" + description: "Forecast resource" + operations: + - ref: "forecast.get-forecast" + method: "GET" + inputParameters: + - name: "location" + in: "path" + type: "string" + description: "City or coordinates" + # Operation that omits name/description — inherited + - ref: "forecast.get-forecast" + method: "POST" + + consumes: + - type: "http" + namespace: "weather-api" + baseUri: "http://localhost:8080/v1" + resources: + - path: "forecast" + name: "forecast" + operations: + - method: "GET" + name: "get-forecast" diff --git a/src/test/resources/aggregate-hints-override.yaml b/src/test/resources/aggregate-hints-override.yaml new file mode 100644 index 00000000..1b8b2d77 --- /dev/null +++ b/src/test/resources/aggregate-hints-override.yaml @@ -0,0 +1,86 @@ +# yaml-language-server: $schema=../../main/resources/schemas/naftiko-schema.json +--- +naftiko: "1.0.0-alpha1" +info: + label: "Aggregate Hints Override Test" + description: "Test capability for semantics-to-hints derivation with override" + tags: + - Test + - Aggregate + created: "2026-04-03" + modified: "2026-04-03" + +capability: + aggregates: + - label: "Data" + namespace: "data" + functions: + - name: "read-items" + description: "Read items from the data store." + semantics: + safe: true + idempotent: true + call: "mock-api.get-items" + outputParameters: + - type: "string" + mapping: "$.items" + + - name: "delete-item" + description: "Delete an item from the data store." + semantics: + safe: false + idempotent: true + inputParameters: + - name: "item-id" + type: "string" + description: "ID of the item to delete" + call: "mock-api.delete-item" + with: + id: "item-id" + + exposes: + - type: "mcp" + address: "localhost" + port: 9102 + namespace: "data-mcp" + description: "MCP server testing hints derivation with override." + + tools: + # Tool with no explicit hints — fully derived from semantics + - name: "read-items" + description: "Read all items." + ref: "data.read-items" + + # Tool with partial hint override — openWorld is MCP-specific + - name: "read-items-open" + description: "Read items with openWorld hint." + ref: "data.read-items" + hints: + openWorld: true + + # Tool that overrides a derived hint value + - name: "delete-item" + description: "Delete an item with explicit hint override." + ref: "data.delete-item" + hints: + readOnly: false + destructive: true + openWorld: false + + # Tool without ref — no derivation, backward compatible + - name: "plain-tool" + description: "A tool without ref or hints." + call: "mock-api.get-items" + + consumes: + - type: "http" + namespace: "mock-api" + baseUri: "http://localhost:8080/v1" + resources: + - path: "items" + name: "items" + operations: + - method: "GET" + name: "get-items" + - method: "DELETE" + name: "delete-item" diff --git a/src/test/resources/aggregate-invalid-ref.yaml b/src/test/resources/aggregate-invalid-ref.yaml new file mode 100644 index 00000000..2cd9d9c2 --- /dev/null +++ b/src/test/resources/aggregate-invalid-ref.yaml @@ -0,0 +1,43 @@ +# yaml-language-server: $schema=../../main/resources/schemas/naftiko-schema.json +--- +naftiko: "1.0.0-alpha1" +info: + label: "Aggregate Invalid Ref Test" + description: "Test capability with an unknown aggregate ref" + tags: + - Test + - Aggregate + created: "2026-04-03" + modified: "2026-04-03" + +capability: + aggregates: + - label: "Data" + namespace: "data" + functions: + - name: "read-items" + description: "Read items." + call: "mock-api.get-items" + + exposes: + - type: "mcp" + address: "localhost" + port: 9103 + namespace: "bad-ref-mcp" + description: "MCP server with a bad ref." + + tools: + - name: "bad-tool" + description: "Tool with unknown ref." + ref: "unknown.nonexistent" + + consumes: + - type: "http" + namespace: "mock-api" + baseUri: "http://localhost:8080/v1" + resources: + - path: "items" + name: "items" + operations: + - method: "GET" + name: "get-items" diff --git a/src/test/resources/spectral-semantics-consistent.yaml b/src/test/resources/spectral-semantics-consistent.yaml new file mode 100644 index 00000000..8aac13e3 --- /dev/null +++ b/src/test/resources/spectral-semantics-consistent.yaml @@ -0,0 +1,60 @@ +naftiko: "1.0.0-alpha1" + +capability: + aggregates: + - label: Weather Service + namespace: weather + functions: + - name: get-forecast + description: Get weather forecast (safe, read-only) + semantics: + safe: true + idempotent: true + call: weather-api.get-forecast + inputParameters: + - name: city + type: string + description: City name + outputParameters: + - type: string + mapping: $.forecast + + consumes: + - type: http + namespace: weather-api + description: Weather API + baseUri: https://api.weather.example.com + resources: + - name: forecasts + path: /forecast + operations: + - name: get-forecast + method: GET + inputParameters: + - name: city + in: query + outputParameters: + - name: forecast + type: object + value: $.forecast + + exposes: + - type: mcp + port: 9091 + namespace: weather-mcp + description: Weather MCP server + tools: + - ref: weather.get-forecast + hints: + readOnly: true + openWorld: true + + - type: rest + port: 8081 + namespace: weather-rest + resources: + - path: /forecast + description: Weather forecast endpoint + operations: + - method: GET + ref: weather.get-forecast diff --git a/src/test/resources/spectral-semantics-inconsistent.yaml b/src/test/resources/spectral-semantics-inconsistent.yaml new file mode 100644 index 00000000..521371ac --- /dev/null +++ b/src/test/resources/spectral-semantics-inconsistent.yaml @@ -0,0 +1,60 @@ +naftiko: "1.0.0-alpha1" + +capability: + aggregates: + - label: Weather Service + namespace: weather + functions: + - name: get-forecast + description: Get weather forecast (safe, read-only) + semantics: + safe: true + idempotent: true + call: weather-api.get-forecast + inputParameters: + - name: city + type: string + description: City name + outputParameters: + - type: string + mapping: $.forecast + + consumes: + - type: http + namespace: weather-api + description: Weather API + baseUri: https://api.weather.example.com + resources: + - name: forecasts + path: /forecast + operations: + - name: get-forecast + method: GET + inputParameters: + - name: city + in: query + outputParameters: + - name: forecast + type: object + value: $.forecast + + exposes: + - type: mcp + port: 9091 + namespace: weather-mcp + description: Weather MCP server + tools: + - ref: weather.get-forecast + hints: + readOnly: false + destructive: true + + - type: rest + port: 8081 + namespace: weather-rest + resources: + - path: /forecast + description: Weather forecast endpoint + operations: + - method: DELETE + ref: weather.get-forecast From 32a83babf35566e3e325089160151bc5dcadc54f Mon Sep 17 00:00:00 2001 From: Jerome Louvel <374450+jlouvel@users.noreply.github.com> Date: Fri, 3 Apr 2026 21:41:12 -0400 Subject: [PATCH 3/3] chore: organize test fixtures into subdirectories --- README.md | 1 + src/main/resources/wiki/Home.md | 1 + .../consumes/http/AvroIntegrationTest.java | 2 +- .../consumes/http/CsvIntegrationTest.java | 2 +- .../consumes/http/ForwardValueFieldTest.java | 2 +- .../consumes/http/HtmlIntegrationTest.java | 2 +- .../http/MarkdownIntegrationTest.java | 2 +- .../http/ProtobufIntegrationTest.java | 2 +- .../consumes/http/XmlIntegrationTest.java | 2 +- .../consumes/http/YamlIntegrationTest.java | 2 +- .../exposes/mcp/AggregateIntegrationTest.java | 22 +++++++++---------- .../mcp/JettyStreamableHandlerTest.java | 2 +- .../exposes/mcp/McpIntegrationTest.java | 2 +- .../mcp/McpToolHintsIntegrationTest.java | 2 +- .../mcp/ProtocolDispatcherCoverageTest.java | 12 +++++----- .../mcp/ProtocolDispatcherNegativeTest.java | 2 +- .../mcp/ResourcesPromptsIntegrationTest.java | 2 +- .../exposes/mcp/StdioIntegrationTest.java | 4 ++-- .../engine/exposes/mcp/ToolHandlerTest.java | 2 +- .../rest/HeaderQueryIntegrationTest.java | 2 +- .../exposes/rest/HttpBodyIntegrationTest.java | 2 +- .../spec/NaftikoSpectralRulesetTest.java | 6 ++--- .../{ => aggregates}/aggregate-basic.yaml | 0 .../aggregate-hints-override.yaml | 0 .../aggregate-invalid-ref.yaml | 0 .../{ => formats}/avro-capability.yaml | 0 .../{ => formats}/csv-capability.yaml | 0 .../{ => formats}/html-capability.yaml | 0 .../{ => formats}/markdown-capability.yaml | 0 .../{ => formats}/proto-capability.yaml | 0 .../{ => formats}/xml-capability.yaml | 0 .../{ => formats}/yaml-capability.yaml | 0 .../{ => http}/http-body-capability.yaml | 0 .../http-forward-value-capability.yaml | 0 .../http-header-query-capability.yaml | 0 .../resources/{ => mcp}/mcp-capability.yaml | 0 .../{ => mcp}/mcp-hints-capability.yaml | 0 .../mcp-resources-prompts-capability.yaml | 0 .../{ => mcp}/mcp-stdio-capability.yaml | 0 ...ool-handler-with-mustache-capability.yaml} | 0 .../spectral-semantics-consistent.yaml | 0 .../spectral-semantics-inconsistent.yaml | 0 42 files changed, 40 insertions(+), 38 deletions(-) rename src/test/resources/{ => aggregates}/aggregate-basic.yaml (100%) rename src/test/resources/{ => aggregates}/aggregate-hints-override.yaml (100%) rename src/test/resources/{ => aggregates}/aggregate-invalid-ref.yaml (100%) rename src/test/resources/{ => formats}/avro-capability.yaml (100%) rename src/test/resources/{ => formats}/csv-capability.yaml (100%) rename src/test/resources/{ => formats}/html-capability.yaml (100%) rename src/test/resources/{ => formats}/markdown-capability.yaml (100%) rename src/test/resources/{ => formats}/proto-capability.yaml (100%) rename src/test/resources/{ => formats}/xml-capability.yaml (100%) rename src/test/resources/{ => formats}/yaml-capability.yaml (100%) rename src/test/resources/{ => http}/http-body-capability.yaml (100%) rename src/test/resources/{ => http}/http-forward-value-capability.yaml (100%) rename src/test/resources/{ => http}/http-header-query-capability.yaml (100%) rename src/test/resources/{ => mcp}/mcp-capability.yaml (100%) rename src/test/resources/{ => mcp}/mcp-hints-capability.yaml (100%) rename src/test/resources/{ => mcp}/mcp-resources-prompts-capability.yaml (100%) rename src/test/resources/{ => mcp}/mcp-stdio-capability.yaml (100%) rename src/test/resources/{tool-handler-with-mustache-capability.yaml => mcp/mcp-tool-handler-with-mustache-capability.yaml} (100%) rename src/test/resources/{ => rules}/spectral-semantics-consistent.yaml (100%) rename src/test/resources/{ => rules}/spectral-semantics-inconsistent.yaml (100%) diff --git a/README.md b/README.md index d4dfaa29..3d232c7c 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ Each capability is a coarse piece of domain that consumes existing HTTP-based AP | Data Format Conversion | Transform **Protobuf**, **XML**, **YAML**, **CSV**, **TSV**, **PSV**, **Avro**, **HTML**, and **Markdown** payloads into JSON | | HTTP API Consumption | Connect to any HTTP-based API with built-in authentication support | | Templating & Querying | Use **Mustache** templates and **JSONPath** expressions for flexible data mapping | +| Domain-Driven Aggregates | Define reusable domain functions once, expose via multiple adapters — inspired by **DDD** Aggregate pattern | | AI Native | Designed for Context Engineering and Agent Orchestration, making capabilities directly consumable by AI agents | | Docker Native | Ships as a ready-to-run **Docker** container | | Extensible | Open-source core extensible with new protocols and adapters | diff --git a/src/main/resources/wiki/Home.md b/src/main/resources/wiki/Home.md index 633a2224..2aa3148f 100644 --- a/src/main/resources/wiki/Home.md +++ b/src/main/resources/wiki/Home.md @@ -11,6 +11,7 @@ Each capability is a coarse piece of domain that consumes existing HTTP-based AP | Data Format Conversion | Transform **Protobuf**, **XML**, **YAML**, **CSV**, **TSV**, **PSV**, **Avro**, **HTML**, and **Markdown** payloads into JSON | | HTTP API Consumption | Connect to any HTTP-based API with built-in authentication support | | Templating & Querying | Use **Mustache** templates and **JSONPath** expressions for flexible data mapping | +| Domain-Driven Aggregates | Define reusable domain functions once, expose via multiple adapters — inspired by **DDD** Aggregate pattern | | AI Native | Designed for Context Engineering and Agent Orchestration, making capabilities directly consumable by AI agents | | Docker Native | Ships as a ready-to-run **Docker** container | | Extensible | Open-source core extensible with new protocols and adapters | diff --git a/src/test/java/io/naftiko/engine/consumes/http/AvroIntegrationTest.java b/src/test/java/io/naftiko/engine/consumes/http/AvroIntegrationTest.java index de2acf39..8f503680 100644 --- a/src/test/java/io/naftiko/engine/consumes/http/AvroIntegrationTest.java +++ b/src/test/java/io/naftiko/engine/consumes/http/AvroIntegrationTest.java @@ -31,7 +31,7 @@ public class AvroIntegrationTest { @BeforeEach public void setUp() throws Exception { // Load the Avro capability from test resources - String resourcePath = "src/test/resources/avro-capability.yaml"; + String resourcePath = "src/test/resources/formats/avro-capability.yaml"; File file = new File(resourcePath); assertTrue(file.exists(), "Avro capability test file should exist at " + resourcePath); diff --git a/src/test/java/io/naftiko/engine/consumes/http/CsvIntegrationTest.java b/src/test/java/io/naftiko/engine/consumes/http/CsvIntegrationTest.java index 23dbaa22..fab91417 100644 --- a/src/test/java/io/naftiko/engine/consumes/http/CsvIntegrationTest.java +++ b/src/test/java/io/naftiko/engine/consumes/http/CsvIntegrationTest.java @@ -38,7 +38,7 @@ public class CsvIntegrationTest { @BeforeEach public void setUp() throws Exception { // Load the CSV capability from test resources - String resourcePath = "src/test/resources/csv-capability.yaml"; + String resourcePath = "src/test/resources/formats/csv-capability.yaml"; File file = new File(resourcePath); assertTrue(file.exists(), "CSV capability test file should exist at " + resourcePath); diff --git a/src/test/java/io/naftiko/engine/consumes/http/ForwardValueFieldTest.java b/src/test/java/io/naftiko/engine/consumes/http/ForwardValueFieldTest.java index e6715a8e..2d3d5bd8 100644 --- a/src/test/java/io/naftiko/engine/consumes/http/ForwardValueFieldTest.java +++ b/src/test/java/io/naftiko/engine/consumes/http/ForwardValueFieldTest.java @@ -39,7 +39,7 @@ public class ForwardValueFieldTest { @BeforeEach public void setUp() throws Exception { - String resourcePath = "src/test/resources/http-forward-value-capability.yaml"; + String resourcePath = "src/test/resources/http/http-forward-value-capability.yaml"; File file = new File(resourcePath); assertTrue(file.exists(), "Capability file should exist at " + resourcePath); diff --git a/src/test/java/io/naftiko/engine/consumes/http/HtmlIntegrationTest.java b/src/test/java/io/naftiko/engine/consumes/http/HtmlIntegrationTest.java index 5bdb6829..75697e4d 100644 --- a/src/test/java/io/naftiko/engine/consumes/http/HtmlIntegrationTest.java +++ b/src/test/java/io/naftiko/engine/consumes/http/HtmlIntegrationTest.java @@ -36,7 +36,7 @@ public class HtmlIntegrationTest { @BeforeEach public void setUp() throws Exception { - String resourcePath = "src/test/resources/html-capability.yaml"; + String resourcePath = "src/test/resources/formats/html-capability.yaml"; File file = new File(resourcePath); assertTrue(file.exists(), "HTML capability test file should exist at " + resourcePath); diff --git a/src/test/java/io/naftiko/engine/consumes/http/MarkdownIntegrationTest.java b/src/test/java/io/naftiko/engine/consumes/http/MarkdownIntegrationTest.java index ca2e1c6b..54fc0fa5 100644 --- a/src/test/java/io/naftiko/engine/consumes/http/MarkdownIntegrationTest.java +++ b/src/test/java/io/naftiko/engine/consumes/http/MarkdownIntegrationTest.java @@ -36,7 +36,7 @@ public class MarkdownIntegrationTest { @BeforeEach public void setUp() throws Exception { - String resourcePath = "src/test/resources/markdown-capability.yaml"; + String resourcePath = "src/test/resources/formats/markdown-capability.yaml"; File file = new File(resourcePath); assertTrue(file.exists(), "Markdown capability test file should exist at " + resourcePath); diff --git a/src/test/java/io/naftiko/engine/consumes/http/ProtobufIntegrationTest.java b/src/test/java/io/naftiko/engine/consumes/http/ProtobufIntegrationTest.java index 43104b49..53a3ba42 100644 --- a/src/test/java/io/naftiko/engine/consumes/http/ProtobufIntegrationTest.java +++ b/src/test/java/io/naftiko/engine/consumes/http/ProtobufIntegrationTest.java @@ -40,7 +40,7 @@ public class ProtobufIntegrationTest { @BeforeEach public void setUp() throws Exception { // Load the Protobuf capability from test resources - String resourcePath = "src/test/resources/proto-capability.yaml"; + String resourcePath = "src/test/resources/formats/proto-capability.yaml"; File file = new File(resourcePath); assertTrue(file.exists(), diff --git a/src/test/java/io/naftiko/engine/consumes/http/XmlIntegrationTest.java b/src/test/java/io/naftiko/engine/consumes/http/XmlIntegrationTest.java index 71a525d6..3a64f9bb 100644 --- a/src/test/java/io/naftiko/engine/consumes/http/XmlIntegrationTest.java +++ b/src/test/java/io/naftiko/engine/consumes/http/XmlIntegrationTest.java @@ -42,7 +42,7 @@ public class XmlIntegrationTest { @BeforeEach public void setUp() throws Exception { // Load the XML capability from test resources - String resourcePath = "src/test/resources/xml-capability.yaml"; + String resourcePath = "src/test/resources/formats/xml-capability.yaml"; File file = new File(resourcePath); assertTrue(file.exists(), "XML capability test file should exist at " + resourcePath); diff --git a/src/test/java/io/naftiko/engine/consumes/http/YamlIntegrationTest.java b/src/test/java/io/naftiko/engine/consumes/http/YamlIntegrationTest.java index d166e8c3..9d216399 100644 --- a/src/test/java/io/naftiko/engine/consumes/http/YamlIntegrationTest.java +++ b/src/test/java/io/naftiko/engine/consumes/http/YamlIntegrationTest.java @@ -41,7 +41,7 @@ public class YamlIntegrationTest { @BeforeEach public void setUp() throws Exception { // Load the YAML capability from test resources - String resourcePath = "src/test/resources/yaml-capability.yaml"; + String resourcePath = "src/test/resources/formats/yaml-capability.yaml"; File file = new File(resourcePath); assertTrue(file.exists(), "YAML capability test file should exist at " + resourcePath); diff --git a/src/test/java/io/naftiko/engine/exposes/mcp/AggregateIntegrationTest.java b/src/test/java/io/naftiko/engine/exposes/mcp/AggregateIntegrationTest.java index f33dbc52..258abb67 100644 --- a/src/test/java/io/naftiko/engine/exposes/mcp/AggregateIntegrationTest.java +++ b/src/test/java/io/naftiko/engine/exposes/mcp/AggregateIntegrationTest.java @@ -46,7 +46,7 @@ private Capability loadCapability(String path) throws Exception { @Test void refShouldResolveCallFromAggregateFunction() throws Exception { - Capability capability = loadCapability("src/test/resources/aggregate-basic.yaml"); + Capability capability = loadCapability("src/test/resources/aggregates/aggregate-basic.yaml"); McpServerAdapter adapter = (McpServerAdapter) capability.getServerAdapters().get(0); McpServerSpec serverSpec = adapter.getMcpServerSpec(); @@ -58,7 +58,7 @@ void refShouldResolveCallFromAggregateFunction() throws Exception { @Test void refShouldResolveInputParametersFromFunction() throws Exception { - Capability capability = loadCapability("src/test/resources/aggregate-basic.yaml"); + Capability capability = loadCapability("src/test/resources/aggregates/aggregate-basic.yaml"); McpServerAdapter adapter = (McpServerAdapter) capability.getServerAdapters().get(0); McpServerSpec serverSpec = adapter.getMcpServerSpec(); @@ -69,7 +69,7 @@ void refShouldResolveInputParametersFromFunction() throws Exception { @Test void refShouldInheritDescriptionWhenOmitted() throws Exception { - Capability capability = loadCapability("src/test/resources/aggregate-basic.yaml"); + Capability capability = loadCapability("src/test/resources/aggregates/aggregate-basic.yaml"); McpServerAdapter adapter = (McpServerAdapter) capability.getServerAdapters().get(0); McpServerSpec serverSpec = adapter.getMcpServerSpec(); @@ -81,7 +81,7 @@ void refShouldInheritDescriptionWhenOmitted() throws Exception { @Test void restOperationRefShouldInheritNameAndDescription() throws Exception { - Capability capability = loadCapability("src/test/resources/aggregate-basic.yaml"); + Capability capability = loadCapability("src/test/resources/aggregates/aggregate-basic.yaml"); // REST adapter is the second server adapter io.naftiko.engine.exposes.rest.RestServerAdapter restAdapter = (io.naftiko.engine.exposes.rest.RestServerAdapter) capability.getServerAdapters() @@ -101,7 +101,7 @@ void restOperationRefShouldInheritNameAndDescription() throws Exception { @Test void safeFunctionShouldDeriveReadOnlyHint() throws Exception { - Capability capability = loadCapability("src/test/resources/aggregate-basic.yaml"); + Capability capability = loadCapability("src/test/resources/aggregates/aggregate-basic.yaml"); McpServerAdapter adapter = (McpServerAdapter) capability.getServerAdapters().get(0); McpServerSpec serverSpec = adapter.getMcpServerSpec(); @@ -117,7 +117,7 @@ void safeFunctionShouldDeriveReadOnlyHint() throws Exception { @Test void toolWithNoExplicitHintsShouldGetFullDerivation() throws Exception { Capability capability = - loadCapability("src/test/resources/aggregate-hints-override.yaml"); + loadCapability("src/test/resources/aggregates/aggregate-hints-override.yaml"); McpServerAdapter adapter = (McpServerAdapter) capability.getServerAdapters().get(0); McpServerSpec serverSpec = adapter.getMcpServerSpec(); @@ -133,7 +133,7 @@ void toolWithNoExplicitHintsShouldGetFullDerivation() throws Exception { @Test void toolWithPartialHintOverrideShouldMerge() throws Exception { Capability capability = - loadCapability("src/test/resources/aggregate-hints-override.yaml"); + loadCapability("src/test/resources/aggregates/aggregate-hints-override.yaml"); McpServerAdapter adapter = (McpServerAdapter) capability.getServerAdapters().get(0); McpServerSpec serverSpec = adapter.getMcpServerSpec(); @@ -149,7 +149,7 @@ void toolWithPartialHintOverrideShouldMerge() throws Exception { @Test void toolWithExplicitHintsShouldOverrideDerived() throws Exception { Capability capability = - loadCapability("src/test/resources/aggregate-hints-override.yaml"); + loadCapability("src/test/resources/aggregates/aggregate-hints-override.yaml"); McpServerAdapter adapter = (McpServerAdapter) capability.getServerAdapters().get(0); McpServerSpec serverSpec = adapter.getMcpServerSpec(); @@ -165,7 +165,7 @@ void toolWithExplicitHintsShouldOverrideDerived() throws Exception { @Test void toolWithoutRefShouldNotHaveHints() throws Exception { Capability capability = - loadCapability("src/test/resources/aggregate-hints-override.yaml"); + loadCapability("src/test/resources/aggregates/aggregate-hints-override.yaml"); McpServerAdapter adapter = (McpServerAdapter) capability.getServerAdapters().get(0); McpServerSpec serverSpec = adapter.getMcpServerSpec(); @@ -178,7 +178,7 @@ void toolWithoutRefShouldNotHaveHints() throws Exception { @Test void toolsListShouldIncludeDerivedAnnotationsInWireFormat() throws Exception { - Capability capability = loadCapability("src/test/resources/aggregate-basic.yaml"); + Capability capability = loadCapability("src/test/resources/aggregates/aggregate-basic.yaml"); McpServerAdapter adapter = (McpServerAdapter) capability.getServerAdapters().get(0); ProtocolDispatcher dispatcher = new ProtocolDispatcher(adapter); @@ -205,7 +205,7 @@ void toolsListShouldIncludeDerivedAnnotationsInWireFormat() throws Exception { @Test void unknownRefShouldFailFast() { assertThrows(IllegalArgumentException.class, - () -> loadCapability("src/test/resources/aggregate-invalid-ref.yaml")); + () -> loadCapability("src/test/resources/aggregates/aggregate-invalid-ref.yaml")); } } diff --git a/src/test/java/io/naftiko/engine/exposes/mcp/JettyStreamableHandlerTest.java b/src/test/java/io/naftiko/engine/exposes/mcp/JettyStreamableHandlerTest.java index bcf17191..35393a7d 100644 --- a/src/test/java/io/naftiko/engine/exposes/mcp/JettyStreamableHandlerTest.java +++ b/src/test/java/io/naftiko/engine/exposes/mcp/JettyStreamableHandlerTest.java @@ -136,7 +136,7 @@ void postShouldReturnMethodNotFoundForUnknownRpcMethod() throws Exception { } private static McpServerAdapter startAdapterOnFreePort() throws Exception { - String resourcePath = "src/test/resources/mcp-capability.yaml"; + String resourcePath = "src/test/resources/mcp/mcp-capability.yaml"; NaftikoSpec spec = YAML.readValue(new File(resourcePath), NaftikoSpec.class); McpServerSpec mcpServerSpec = (McpServerSpec) spec.getCapability().getExposes().get(0); mcpServerSpec.setPort(findFreePort()); diff --git a/src/test/java/io/naftiko/engine/exposes/mcp/McpIntegrationTest.java b/src/test/java/io/naftiko/engine/exposes/mcp/McpIntegrationTest.java index 7f0f87f6..9d1d9b60 100644 --- a/src/test/java/io/naftiko/engine/exposes/mcp/McpIntegrationTest.java +++ b/src/test/java/io/naftiko/engine/exposes/mcp/McpIntegrationTest.java @@ -38,7 +38,7 @@ public class McpIntegrationTest { @BeforeEach public void setUp() throws Exception { - String resourcePath = "src/test/resources/mcp-capability.yaml"; + String resourcePath = "src/test/resources/mcp/mcp-capability.yaml"; File file = new File(resourcePath); assertTrue(file.exists(), "MCP capability test file should exist at " + resourcePath); diff --git a/src/test/java/io/naftiko/engine/exposes/mcp/McpToolHintsIntegrationTest.java b/src/test/java/io/naftiko/engine/exposes/mcp/McpToolHintsIntegrationTest.java index ab60ab02..51524864 100644 --- a/src/test/java/io/naftiko/engine/exposes/mcp/McpToolHintsIntegrationTest.java +++ b/src/test/java/io/naftiko/engine/exposes/mcp/McpToolHintsIntegrationTest.java @@ -41,7 +41,7 @@ public class McpToolHintsIntegrationTest { @BeforeEach public void setUp() throws Exception { - String resourcePath = "src/test/resources/mcp-hints-capability.yaml"; + String resourcePath = "src/test/resources/mcp/mcp-hints-capability.yaml"; File file = new File(resourcePath); assertTrue(file.exists(), "MCP hints capability test file should exist"); diff --git a/src/test/java/io/naftiko/engine/exposes/mcp/ProtocolDispatcherCoverageTest.java b/src/test/java/io/naftiko/engine/exposes/mcp/ProtocolDispatcherCoverageTest.java index e2c8c6ad..0b423da4 100644 --- a/src/test/java/io/naftiko/engine/exposes/mcp/ProtocolDispatcherCoverageTest.java +++ b/src/test/java/io/naftiko/engine/exposes/mcp/ProtocolDispatcherCoverageTest.java @@ -32,7 +32,7 @@ class ProtocolDispatcherCoverageTest { @Test void dispatchShouldReturnInternalErrorWhenRequestIsNull() throws Exception { - ProtocolDispatcher dispatcher = dispatcherFrom("src/test/resources/mcp-capability.yaml"); + ProtocolDispatcher dispatcher = dispatcherFrom("src/test/resources/mcp/mcp-capability.yaml"); ObjectNode response = dispatcher.dispatch(null); @@ -43,7 +43,7 @@ void dispatchShouldReturnInternalErrorWhenRequestIsNull() throws Exception { @Test void initializeShouldAdvertiseOnlyToolsWhenNoResourcesOrPrompts() throws Exception { - ProtocolDispatcher dispatcher = dispatcherFrom("src/test/resources/mcp-capability.yaml"); + ProtocolDispatcher dispatcher = dispatcherFrom("src/test/resources/mcp/mcp-capability.yaml"); JsonNode response = dispatcher.dispatch(JSON.readTree( "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}")); @@ -56,7 +56,7 @@ void initializeShouldAdvertiseOnlyToolsWhenNoResourcesOrPrompts() throws Excepti @Test void toolsCallUnknownToolShouldReturnInvalidParams() throws Exception { - ProtocolDispatcher dispatcher = dispatcherFrom("src/test/resources/mcp-capability.yaml"); + ProtocolDispatcher dispatcher = dispatcherFrom("src/test/resources/mcp/mcp-capability.yaml"); JsonNode response = dispatcher.dispatch(JSON.readTree(""" { @@ -74,7 +74,7 @@ void toolsCallUnknownToolShouldReturnInvalidParams() throws Exception { @Test void resourcesAndPromptsInvalidParamsShouldReturnExpectedErrors() throws Exception { ProtocolDispatcher dispatcher = - dispatcherFrom("src/test/resources/mcp-resources-prompts-capability.yaml"); + dispatcherFrom("src/test/resources/mcp/mcp-resources-prompts-capability.yaml"); JsonNode readNullParams = dispatcher.dispatch(JSON.readTree( "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"resources/read\"}")); @@ -91,7 +91,7 @@ void resourcesAndPromptsInvalidParamsShouldReturnExpectedErrors() throws Excepti @Test void jsonRpcEnvelopeBuildersShouldHandleNullAndPresentIds() throws Exception { - ProtocolDispatcher dispatcher = dispatcherFrom("src/test/resources/mcp-capability.yaml"); + ProtocolDispatcher dispatcher = dispatcherFrom("src/test/resources/mcp/mcp-capability.yaml"); ObjectNode resultNoId = dispatcher.buildJsonRpcResult(null, JSON.createObjectNode()); assertEquals("2.0", resultNoId.path("jsonrpc").asText()); @@ -107,7 +107,7 @@ void jsonRpcEnvelopeBuildersShouldHandleNullAndPresentIds() throws Exception { @Test void toolsCallShouldReturnIsErrorResultOnUnexpectedExecutionFailure() throws Exception { - ProtocolDispatcher dispatcher = dispatcherFrom("src/test/resources/mcp-capability.yaml"); + ProtocolDispatcher dispatcher = dispatcherFrom("src/test/resources/mcp/mcp-capability.yaml"); JsonNode response = dispatcher.dispatch(JSON.readTree(""" { diff --git a/src/test/java/io/naftiko/engine/exposes/mcp/ProtocolDispatcherNegativeTest.java b/src/test/java/io/naftiko/engine/exposes/mcp/ProtocolDispatcherNegativeTest.java index 70add5a4..582ae8d3 100644 --- a/src/test/java/io/naftiko/engine/exposes/mcp/ProtocolDispatcherNegativeTest.java +++ b/src/test/java/io/naftiko/engine/exposes/mcp/ProtocolDispatcherNegativeTest.java @@ -33,7 +33,7 @@ public class ProtocolDispatcherNegativeTest { @BeforeEach public void setUp() throws Exception { mapper = new ObjectMapper(); - String resourcePath = "src/test/resources/mcp-resources-prompts-capability.yaml"; + String resourcePath = "src/test/resources/mcp/mcp-resources-prompts-capability.yaml"; File file = new File(resourcePath); ObjectMapper yamlMapper = new ObjectMapper(new YAMLFactory()); yamlMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); diff --git a/src/test/java/io/naftiko/engine/exposes/mcp/ResourcesPromptsIntegrationTest.java b/src/test/java/io/naftiko/engine/exposes/mcp/ResourcesPromptsIntegrationTest.java index 8725b57c..2b4091b7 100644 --- a/src/test/java/io/naftiko/engine/exposes/mcp/ResourcesPromptsIntegrationTest.java +++ b/src/test/java/io/naftiko/engine/exposes/mcp/ResourcesPromptsIntegrationTest.java @@ -43,7 +43,7 @@ public class ResourcesPromptsIntegrationTest { @BeforeEach public void setUp() throws Exception { - String resourcePath = "src/test/resources/mcp-resources-prompts-capability.yaml"; + String resourcePath = "src/test/resources/mcp/mcp-resources-prompts-capability.yaml"; File file = new File(resourcePath); assertTrue(file.exists(), diff --git a/src/test/java/io/naftiko/engine/exposes/mcp/StdioIntegrationTest.java b/src/test/java/io/naftiko/engine/exposes/mcp/StdioIntegrationTest.java index 823f12d4..37440b0d 100644 --- a/src/test/java/io/naftiko/engine/exposes/mcp/StdioIntegrationTest.java +++ b/src/test/java/io/naftiko/engine/exposes/mcp/StdioIntegrationTest.java @@ -39,7 +39,7 @@ public class StdioIntegrationTest { @BeforeEach public void setUp() throws Exception { - String resourcePath = "src/test/resources/mcp-stdio-capability.yaml"; + String resourcePath = "src/test/resources/mcp/mcp-stdio-capability.yaml"; File file = new File(resourcePath); assertTrue(file.exists(), @@ -217,7 +217,7 @@ public void testStdioHandlerEndToEnd() throws Exception { @Test public void testHttpTransportDefaultWhenNotSet() throws Exception { // Load the original MCP capability (no transport field) - String resourcePath = "src/test/resources/mcp-capability.yaml"; + String resourcePath = "src/test/resources/mcp/mcp-capability.yaml"; File file = new File(resourcePath); assertTrue(file.exists()); 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..ac3b8938 100644 --- a/src/test/java/io/naftiko/engine/exposes/mcp/ToolHandlerTest.java +++ b/src/test/java/io/naftiko/engine/exposes/mcp/ToolHandlerTest.java @@ -82,7 +82,7 @@ public void handleToolCallShouldMergeToolWithParameters() { */ @Test public void handleToolCallShouldResolveMustacheTemplatesInWithValues() throws Exception { - String resourcePath = "src/test/resources/tool-handler-with-mustache-capability.yaml"; + String resourcePath = "src/test/resources/mcp/mcp-tool-handler-with-mustache-capability.yaml"; File file = new File(resourcePath); assertTrue(file.exists(), "Test capability file should exist at " + resourcePath); diff --git a/src/test/java/io/naftiko/engine/exposes/rest/HeaderQueryIntegrationTest.java b/src/test/java/io/naftiko/engine/exposes/rest/HeaderQueryIntegrationTest.java index 3297eb3a..bfa85f90 100644 --- a/src/test/java/io/naftiko/engine/exposes/rest/HeaderQueryIntegrationTest.java +++ b/src/test/java/io/naftiko/engine/exposes/rest/HeaderQueryIntegrationTest.java @@ -41,7 +41,7 @@ public class HeaderQueryIntegrationTest { @BeforeEach public void setUp() throws Exception { - String resourcePath = "src/test/resources/http-header-query-capability.yaml"; + String resourcePath = "src/test/resources/http/http-header-query-capability.yaml"; File file = new File(resourcePath); assertTrue(file.exists(), "Capability file should exist at " + resourcePath); diff --git a/src/test/java/io/naftiko/engine/exposes/rest/HttpBodyIntegrationTest.java b/src/test/java/io/naftiko/engine/exposes/rest/HttpBodyIntegrationTest.java index 0d515ec3..5afeb94d 100644 --- a/src/test/java/io/naftiko/engine/exposes/rest/HttpBodyIntegrationTest.java +++ b/src/test/java/io/naftiko/engine/exposes/rest/HttpBodyIntegrationTest.java @@ -39,7 +39,7 @@ public class HttpBodyIntegrationTest { @BeforeEach public void setUp() throws Exception { - String resourcePath = "src/test/resources/http-body-capability.yaml"; + String resourcePath = "src/test/resources/http/http-body-capability.yaml"; File file = new File(resourcePath); assertTrue(file.exists(), "HTTP body capability file should exist at " + resourcePath); diff --git a/src/test/java/io/naftiko/spec/NaftikoSpectralRulesetTest.java b/src/test/java/io/naftiko/spec/NaftikoSpectralRulesetTest.java index fce1f9ab..e02f3513 100644 --- a/src/test/java/io/naftiko/spec/NaftikoSpectralRulesetTest.java +++ b/src/test/java/io/naftiko/spec/NaftikoSpectralRulesetTest.java @@ -132,7 +132,7 @@ public void semanticsConsistencyRuleShouldWarnWhenMcpHintsContradictSemantics() "npx", "@stoplight/spectral-cli", "lint", - "src/test/resources/spectral-semantics-inconsistent.yaml", + "src/test/resources/rules/spectral-semantics-inconsistent.yaml", "--ruleset", rulesetPath.toAbsolutePath().toString()); @@ -158,7 +158,7 @@ public void semanticsConsistencyRuleShouldWarnWhenRestMethodContradictsSafeSeman "npx", "@stoplight/spectral-cli", "lint", - "src/test/resources/spectral-semantics-inconsistent.yaml", + "src/test/resources/rules/spectral-semantics-inconsistent.yaml", "--ruleset", rulesetPath.toAbsolutePath().toString()); @@ -178,7 +178,7 @@ public void semanticsConsistencyRuleShouldNotWarnWhenHintsAreConsistent() "npx", "@stoplight/spectral-cli", "lint", - "src/test/resources/spectral-semantics-consistent.yaml", + "src/test/resources/rules/spectral-semantics-consistent.yaml", "--ruleset", rulesetPath.toAbsolutePath().toString()); diff --git a/src/test/resources/aggregate-basic.yaml b/src/test/resources/aggregates/aggregate-basic.yaml similarity index 100% rename from src/test/resources/aggregate-basic.yaml rename to src/test/resources/aggregates/aggregate-basic.yaml diff --git a/src/test/resources/aggregate-hints-override.yaml b/src/test/resources/aggregates/aggregate-hints-override.yaml similarity index 100% rename from src/test/resources/aggregate-hints-override.yaml rename to src/test/resources/aggregates/aggregate-hints-override.yaml diff --git a/src/test/resources/aggregate-invalid-ref.yaml b/src/test/resources/aggregates/aggregate-invalid-ref.yaml similarity index 100% rename from src/test/resources/aggregate-invalid-ref.yaml rename to src/test/resources/aggregates/aggregate-invalid-ref.yaml diff --git a/src/test/resources/avro-capability.yaml b/src/test/resources/formats/avro-capability.yaml similarity index 100% rename from src/test/resources/avro-capability.yaml rename to src/test/resources/formats/avro-capability.yaml diff --git a/src/test/resources/csv-capability.yaml b/src/test/resources/formats/csv-capability.yaml similarity index 100% rename from src/test/resources/csv-capability.yaml rename to src/test/resources/formats/csv-capability.yaml diff --git a/src/test/resources/html-capability.yaml b/src/test/resources/formats/html-capability.yaml similarity index 100% rename from src/test/resources/html-capability.yaml rename to src/test/resources/formats/html-capability.yaml diff --git a/src/test/resources/markdown-capability.yaml b/src/test/resources/formats/markdown-capability.yaml similarity index 100% rename from src/test/resources/markdown-capability.yaml rename to src/test/resources/formats/markdown-capability.yaml diff --git a/src/test/resources/proto-capability.yaml b/src/test/resources/formats/proto-capability.yaml similarity index 100% rename from src/test/resources/proto-capability.yaml rename to src/test/resources/formats/proto-capability.yaml diff --git a/src/test/resources/xml-capability.yaml b/src/test/resources/formats/xml-capability.yaml similarity index 100% rename from src/test/resources/xml-capability.yaml rename to src/test/resources/formats/xml-capability.yaml diff --git a/src/test/resources/yaml-capability.yaml b/src/test/resources/formats/yaml-capability.yaml similarity index 100% rename from src/test/resources/yaml-capability.yaml rename to src/test/resources/formats/yaml-capability.yaml diff --git a/src/test/resources/http-body-capability.yaml b/src/test/resources/http/http-body-capability.yaml similarity index 100% rename from src/test/resources/http-body-capability.yaml rename to src/test/resources/http/http-body-capability.yaml diff --git a/src/test/resources/http-forward-value-capability.yaml b/src/test/resources/http/http-forward-value-capability.yaml similarity index 100% rename from src/test/resources/http-forward-value-capability.yaml rename to src/test/resources/http/http-forward-value-capability.yaml diff --git a/src/test/resources/http-header-query-capability.yaml b/src/test/resources/http/http-header-query-capability.yaml similarity index 100% rename from src/test/resources/http-header-query-capability.yaml rename to src/test/resources/http/http-header-query-capability.yaml diff --git a/src/test/resources/mcp-capability.yaml b/src/test/resources/mcp/mcp-capability.yaml similarity index 100% rename from src/test/resources/mcp-capability.yaml rename to src/test/resources/mcp/mcp-capability.yaml diff --git a/src/test/resources/mcp-hints-capability.yaml b/src/test/resources/mcp/mcp-hints-capability.yaml similarity index 100% rename from src/test/resources/mcp-hints-capability.yaml rename to src/test/resources/mcp/mcp-hints-capability.yaml diff --git a/src/test/resources/mcp-resources-prompts-capability.yaml b/src/test/resources/mcp/mcp-resources-prompts-capability.yaml similarity index 100% rename from src/test/resources/mcp-resources-prompts-capability.yaml rename to src/test/resources/mcp/mcp-resources-prompts-capability.yaml diff --git a/src/test/resources/mcp-stdio-capability.yaml b/src/test/resources/mcp/mcp-stdio-capability.yaml similarity index 100% rename from src/test/resources/mcp-stdio-capability.yaml rename to src/test/resources/mcp/mcp-stdio-capability.yaml diff --git a/src/test/resources/tool-handler-with-mustache-capability.yaml b/src/test/resources/mcp/mcp-tool-handler-with-mustache-capability.yaml similarity index 100% rename from src/test/resources/tool-handler-with-mustache-capability.yaml rename to src/test/resources/mcp/mcp-tool-handler-with-mustache-capability.yaml diff --git a/src/test/resources/spectral-semantics-consistent.yaml b/src/test/resources/rules/spectral-semantics-consistent.yaml similarity index 100% rename from src/test/resources/spectral-semantics-consistent.yaml rename to src/test/resources/rules/spectral-semantics-consistent.yaml diff --git a/src/test/resources/spectral-semantics-inconsistent.yaml b/src/test/resources/rules/spectral-semantics-inconsistent.yaml similarity index 100% rename from src/test/resources/spectral-semantics-inconsistent.yaml rename to src/test/resources/rules/spectral-semantics-inconsistent.yaml